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 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 MdpGW?0UO)@11b3SXaE2J literal 0 HcmV?d00001 diff --git a/src/core/dvui.zig b/src/core/dvui.zig index 37242c67..97397697 100644 --- a/src/core/dvui.zig +++ b/src/core/dvui.zig @@ -10,6 +10,16 @@ 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"); +pub const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); + +/// Code-editor `textEntry` with Fizzy-specific chromeless + tree-sitter highlighting. +pub fn textEntry(src: std.builtin.SourceLocation, init_opts: TextEntryWidget.InitOptions, opts: dvui.Options) *TextEntryWidget { + var ret = dvui.widgetAlloc(TextEntryWidget); + ret.init(src, init_opts, opts); + ret.processEvents(); + ret.draw(); + return ret; +} /// 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 @@ -983,7 +993,7 @@ pub fn keybindLabels(self: *const dvui.enums.Keybind, enabled: bool, opts: dvui. var second_opts = opts.strip(); second_opts.color_text = color; - second_opts.font = dvui.Font.theme(.mono).larger(-2.0); + second_opts.font = dvui.Font.theme(.mono); second_opts.gravity_y = 0.5; var needs_space = false; diff --git a/src/core/widgets/TextEntryWidget.zig b/src/core/widgets/TextEntryWidget.zig new file mode 100644 index 00000000..2083dd04 --- /dev/null +++ b/src/core/widgets/TextEntryWidget.zig @@ -0,0 +1,1846 @@ +const builtin = @import("builtin"); +const std = @import("std"); +const dvui = @import("dvui"); + +const Event = dvui.Event; +const Options = dvui.Options; +const Rect = dvui.Rect; +const RectScale = dvui.RectScale; +const ScrollInfo = dvui.ScrollInfo; +const Size = dvui.Size; +const Widget = dvui.Widget; +const WidgetData = dvui.WidgetData; +const ScrollAreaWidget = dvui.ScrollAreaWidget; +const TextLayoutWidget = dvui.TextLayoutWidget; +const AccessKit = dvui.AccessKit; + +const TextEntryWidget = @This(); + +/// If min_size_content is not given, use Font.sizeM(defaultMWidth, 1). +/// If multiline is false and max_size_content is not given, use min_size_content. +pub var defaultMWidth: f32 = 14; + +pub var defaults: Options = .{ + .name = "TextEntry", + .role = .text_input, // can change to multiline in init + .margin = Rect.all(4), + .corner_radius = Rect.all(5), + .border = Rect.all(1), + .padding = Rect.all(6), + .background = true, + .style = .content, + // min_size_content/max_size_content is calculated in init() +}; + +const realloc_bin_size = 100; + +pub const SyntaxHighlight = struct { + name: []const u8, + opts: dvui.Options, + + pub const Match = struct { + opts: dvui.Options = .{}, + specificity: u16 = 0, + }; + + /// Longest `highlights` entry whose name is a prefix of `capture_name`. + pub fn optsForCapture(capture_name: []const u8, highlights: []const SyntaxHighlight) Match { + var best: Match = .{}; + for (0..highlights.len) |i| { + const sh = highlights[highlights.len - i - 1]; + if (std.mem.startsWith(u8, capture_name, sh.name) and sh.name.len > best.specificity) { + best = .{ .opts = sh.opts, .specificity = @intCast(sh.name.len) }; + } + } + return best; + } +}; + +/// Tree-sitter 0.27+ leaves `#match?` / `#eq?` / … to the host. Without this, +/// every `(identifier)` pattern matches every identifier regardless of predicates. +const QueryPredicates = if (dvui.useTreeSitter) struct { + const Arg = union(enum) { + capture: u32, + string: []const u8, + }; + + fn captureTextInMatch(match: dvui.c.TSQueryMatch, capture_id: u32, text: []const u8) ?[]const u8 { + var i: u32 = 0; + while (i < match.capture_count) : (i += 1) { + const cap = match.captures[i]; + if (cap.index == capture_id) { + const start = dvui.c.ts_node_start_byte(cap.node); + const end = dvui.c.ts_node_end_byte(cap.node); + return text[start..end]; + } + } + return null; + } + + fn queryStringValue(query: *const dvui.c.TSQuery, id: u32) []const u8 { + var len: u32 = undefined; + const ptr = dvui.c.ts_query_string_value_for_id(query, id, &len); + return ptr[0..len]; + } + + fn isIdentChar(ch: u8) bool { + return (ch >= 'a' and ch <= 'z') or (ch >= 'A' and ch <= 'Z') or (ch >= '0' and ch <= '9') or ch == '_'; + } + + fn isMatchRegex(text: []const u8, pattern: []const u8) bool { + if (std.mem.eql(u8, pattern, "^[A-Z_][a-zA-Z0-9_]*")) { + if (text.len == 0) return false; + const c0 = text[0]; + if (c0 != '_' and (c0 < 'A' or c0 > 'Z')) return false; + for (text[1..]) |ch| if (!isIdentChar(ch)) return false; + return true; + } + if (std.mem.eql(u8, pattern, "^[a-z_][a-zA-Z0-9_]*")) { + if (text.len == 0) return false; + const c0 = text[0]; + if (c0 != '_' and (c0 < 'a' or c0 > 'z')) return false; + for (text[1..]) |ch| if (!isIdentChar(ch)) return false; + return true; + } + if (std.mem.eql(u8, pattern, "^[A-Z][A-Z_0-9]+$")) { + if (text.len == 0) return false; + if (text[0] < 'A' or text[0] > 'Z') return false; + for (text[1..]) |ch| { + if ((ch >= 'A' and ch <= 'Z') or (ch >= '0' and ch <= '9') or ch == '_') continue; + return false; + } + return true; + } + if (std.mem.startsWith(u8, pattern, "^") and std.mem.endsWith(u8, pattern, "$") and pattern.len > 2) { + return std.mem.eql(u8, text, pattern[1 .. pattern.len - 1]); + } + if (std.mem.startsWith(u8, pattern, "^")) { + return std.mem.startsWith(u8, text, pattern[1..]); + } + return std.mem.eql(u8, text, pattern); + } + + fn evalPredicate( + name: []const u8, + args: []const Arg, + match: dvui.c.TSQueryMatch, + text: []const u8, + ) bool { + if (std.mem.eql(u8, name, "#set!")) return true; + + if (std.mem.eql(u8, name, "#match?") or std.mem.eql(u8, name, "#lua-match?")) { + if (args.len < 2) return true; + const cap_text = switch (args[0]) { + .capture => |id| captureTextInMatch(match, id, text) orelse return false, + else => return false, + }; + const pattern = switch (args[1]) { + .string => |s| s, + else => return false, + }; + return isMatchRegex(cap_text, pattern); + } + + if (std.mem.eql(u8, name, "#eq?")) { + if (args.len < 2) return true; + const a = switch (args[0]) { + .capture => |id| captureTextInMatch(match, id, text) orelse return false, + .string => |s| s, + }; + const b = switch (args[1]) { + .capture => |id| captureTextInMatch(match, id, text) orelse return false, + .string => |s| s, + }; + return std.mem.eql(u8, a, b); + } + + if (std.mem.eql(u8, name, "#any-of?")) { + if (args.len < 2) return true; + const cap_text = switch (args[0]) { + .capture => |id| captureTextInMatch(match, id, text) orelse return false, + else => return false, + }; + for (args[1..]) |arg| { + switch (arg) { + .string => |s| if (std.mem.eql(u8, cap_text, s)) return true, + else => {}, + } + } + return false; + } + + return true; + } + + pub fn patternMatches( + query: *const dvui.c.TSQuery, + pattern_index: u16, + match: dvui.c.TSQueryMatch, + text: []const u8, + ) bool { + var step_count: u32 = 0; + const steps = dvui.c.ts_query_predicates_for_pattern(query, pattern_index, &step_count); + if (step_count == 0) return true; + + var i: u32 = 0; + while (i < step_count) { + const first = steps[i]; + if (first.type != dvui.c.TSQueryPredicateStepTypeString) { + i += 1; + continue; + } + const pred_name = queryStringValue(query, first.value_id); + i += 1; + + var args: [16]Arg = undefined; + var arg_count: usize = 0; + while (i < step_count and steps[i].type != dvui.c.TSQueryPredicateStepTypeDone) { + const step = steps[i]; + i += 1; + if (arg_count >= args.len) break; + switch (step.type) { + dvui.c.TSQueryPredicateStepTypeCapture => { + args[arg_count] = .{ .capture = step.value_id }; + arg_count += 1; + }, + dvui.c.TSQueryPredicateStepTypeString => { + args[arg_count] = .{ .string = queryStringValue(query, step.value_id) }; + arg_count += 1; + }, + else => {}, + } + } + if (i < step_count and steps[i].type == dvui.c.TSQueryPredicateStepTypeDone) i += 1; + + if (!evalPredicate(pred_name, args[0..arg_count], match, text)) return false; + } + return true; + } +} else struct { + pub fn patternMatches(_: *const dvui.c.TSQuery, _: u16, _: dvui.c.TSQueryMatch, _: []const u8) bool { + return true; + } +}; + +pub const TreeSitterParser = if (dvui.useTreeSitter) struct { + parser: *dvui.c.TSParser, + tree: *dvui.c.TSTree, + query: *dvui.c.TSQuery, + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + + dvui.c.ts_query_delete(self.query); + dvui.c.ts_tree_delete(self.tree); + dvui.c.ts_parser_delete(self.parser); + } + + pub fn queryCursorCaptureIterator(self: *const TreeSitterParser, qc: *dvui.c.TSQueryCursor, text: []const u8) QueryCursorCaptureIterator { + return .{ + .query_cursor = qc, + .prev_match = null, + .query = self.query, + .text = text, + }; + } + + pub const QueryCursorCaptureIterator = struct { + pub const Match = struct { + iter: *const QueryCursorCaptureIterator, + node: dvui.c.TSNode, + capture_index: u32, + + pub fn captureName(self: *const Match) []const u8 { + var len: u32 = undefined; + const name = dvui.c.ts_query_capture_name_for_id(self.iter.query, self.capture_index, &len); + return name[0..len]; + } + + pub fn debugLog(self: *const Match, comptime kind: []const u8) void { + const start = dvui.c.ts_node_start_byte(self.node); + const end = dvui.c.ts_node_end_byte(self.node); + dvui.log.debug(kind ++ " capture @{s} : {s}", .{ self.captureName(), self.iter.text[start..end] }); + } + }; + + query_cursor: *dvui.c.TSQueryCursor, + prev_match: ?Match, + + // used for debugging + debug: bool = false, + query: *dvui.c.TSQuery, + text: []const u8, + + pub fn next(self: *QueryCursorCaptureIterator) ?Match { + var match: dvui.c.TSQueryMatch = undefined; + var captureIdx: u32 = undefined; + loop: while (dvui.c.ts_query_cursor_next_capture(self.query_cursor, &match, &captureIdx)) { + const capture = match.captures[captureIdx]; + if (self.prev_match) |pm| { + if (dvui.c.ts_node_eq(pm.node, capture.node)) { + // same node as previous + self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; + if (self.debug) self.prev_match.?.debugLog("ts same "); + continue :loop; + } + + // not the same + const ret = self.prev_match; + self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; + if (self.debug) self.prev_match.?.debugLog("ts new "); + return ret; + } else { + // first time + self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; + if (self.debug) self.prev_match.?.debugLog("ts first"); + continue :loop; + } + } + + const ret = self.prev_match; + if (ret) |r| { + if (self.debug) r.debugLog("ts last "); + } + self.prev_match = null; + return ret; + } + }; +} else void; + +pub const InitOptions = struct { + pub const TextOption = union(enum) { + /// Use this slice of bytes, cannot add more. + buffer: []u8, + + /// Use and grow with realloc and shrink with resize as needed. + buffer_dynamic: struct { + backing: *[]u8, + allocator: std.mem.Allocator, + limit: usize = 10_000, + }, + + /// Use std.ArrayList(u8). The limit is total characters, the + /// arraylist might allocate more capacity. ArrayList.items is updated + /// in deinit() (file an issue if this is a problem). + array_list: struct { + backing: *std.ArrayList(u8), + allocator: std.mem.Allocator, + limit: usize = 10_000, + }, + + /// Use internal buffer up to limit. + /// - use getText() to get contents. + internal: struct { + limit: usize = 10_000, + }, + }; + + pub const TreeSitterOption = if (dvui.useTreeSitter) struct { + language: *dvui.c.TSLanguage, + queries: []const u8, + highlights: []const SyntaxHighlight, + /// If true dump all captures to dvui.log.debug + log_captures: bool = false, + } else void; + + text: TextOption = .{ .internal = .{} }, + tree_sitter: ?TreeSitterOption = null, + /// Faded text shown when the textEntry is empty + placeholder: ?[]const u8 = null, + + /// If true, assume text (and text height) is the same (excepting edits we + /// do internally) as we saw last frame and only process what is needed for + /// visibility (and copy). + cache_layout: bool = false, + + /// When false, skip the themed focus ring around the widget border. + show_focus_border: bool = true, + + break_lines: bool = false, + kerning: ?bool = null, + scroll_vertical: ?bool = null, // default is value of multiline + scroll_vertical_bar: ?ScrollInfo.ScrollBarMode = null, // default .auto + scroll_horizontal: ?bool = null, // default true + scroll_horizontal_bar: ?ScrollInfo.ScrollBarMode = null, // default .auto if multiline, .hide if not + + // must be a single utf8 character + password_char: ?[]const u8 = null, + multiline: bool = false, +}; + +wd: WidgetData, +prevClip: Rect.Physical = undefined, +scroll: ScrollAreaWidget = undefined, +scrollClip: Rect.Physical = undefined, +textLayout: TextLayoutWidget = undefined, +textClip: Rect.Physical = undefined, +padding: Rect, + +init_opts: InitOptions, +text: []u8, +len: usize, +enter_pressed: bool = false, // not valid if multiline +text_changed: bool = false, + +// see textChanged() +text_changed_start: usize = std.math.maxInt(usize), +text_changed_end: usize = 0, // index of bytes before edits (so matches previous frame) +text_changed_added: i64 = 0, // bytes added +edited_outside_last_frame: *bool = undefined, + +/// It's expected to call this when `self` is `undefined` +pub fn init(self: *TextEntryWidget, src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) void { + var scroll_init_opts = ScrollAreaWidget.InitOpts{ + .vertical = if (init_opts.scroll_vertical orelse init_opts.multiline) .auto else .none, + .vertical_bar = init_opts.scroll_vertical_bar orelse .auto, + .horizontal = if (init_opts.scroll_horizontal orelse true) .auto else .none, + .horizontal_bar = init_opts.scroll_horizontal_bar orelse (if (init_opts.multiline) .auto else .hide), + }; + + var options = defaults.themeOverride(opts.theme).min_sizeM(defaultMWidth, 1); + + if (init_opts.password_char != null) { + options.role = .password_input; + } else if (init_opts.multiline) { + options.role = .multiline_text_input; + } + + options = options.override(opts); + if (!init_opts.multiline and options.max_size_content == null) { + options = options.override(.{ .max_size_content = .size(options.min_size_contentGet()) }); + } + + // padding is interpreted as the padding for the TextLayoutWidget, but + // we also need to add it to content size because TextLayoutWidget is + // inside the scroll area + const padding = options.paddingGet(); + options.padding = null; + options.min_size_content.?.w += padding.x + padding.w; + options.min_size_content.?.h += padding.y + padding.h; + if (options.max_size_content != null) { + options.max_size_content.?.w += padding.x + padding.w; + options.max_size_content.?.h += padding.y + padding.h; + } + + const wd = WidgetData.init(src, .{}, options); + scroll_init_opts.focus_id = wd.id; + + var text: []u8 = undefined; + var find_zero = true; + var len_utf8_boundary: usize = undefined; + switch (init_opts.text) { + .buffer => |b| text = b, + .buffer_dynamic => |b| text = b.backing.*, + .internal => text = dvui.dataGetSliceDefault(null, wd.id, "_buffer", []u8, &.{}), + .array_list => |al| { + find_zero = false; + text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; + len_utf8_boundary = dvui.findUtf8Start(text, al.backing.items.len); + }, + } + + if (find_zero) { + const len_byte = std.mem.findScalar(u8, text, 0) orelse text.len; + len_utf8_boundary = dvui.findUtf8Start(text[0..len_byte], len_byte); + } + + self.* = .{ + .wd = wd, + .padding = padding, + .init_opts = init_opts, + .text = text, + .len = len_utf8_boundary, + + // SAFETY: The following fields are set bellow + .prevClip = undefined, + .scroll = undefined, + .scrollClip = undefined, + .textLayout = undefined, + .textClip = undefined, + }; + + self.data().register(); + + dvui.tabIndexSet(self.data().id, self.data().options.tab_index, self.data().rectScale().r); + + dvui.parentSet(self.widget()); + + if (self.data().options.backgroundGet() or self.data().options.borderGet().nonZero()) { + self.data().borderAndBackground(.{}); + } + + self.prevClip = dvui.clip(self.data().borderRectScale().r); + const borderClip = dvui.clipGet(); + + // We do this dance with last_focused_id_this_frame so scroll will process + // key events we skip (like page up/down). Normally it would not (text + // entry is not a child of scroll). So with this we make scroll think that + // text entry ran as a child. + const focused = (self.data().id == dvui.lastFocusedIdInFrame()); + if (focused) dvui.currentWindow().last_focused_id_this_frame = .zero; + + // scrollbars process mouse events here + self.scroll.init(@src(), scroll_init_opts, self.data().options.strip().override(.{ + .role = .none, + .expand = .both, + .background = false, + .border = Rect{}, + .corner_radius = Rect{}, + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, + })); + + if (focused) dvui.currentWindow().last_focused_id_this_frame = self.data().id; + + self.scrollClip = dvui.clipGet(); + + self.edited_outside_last_frame = dvui.dataGetPtrDefault(null, self.data().id, "_edited_outside", bool, false); + if (self.init_opts.cache_layout and self.edited_outside_last_frame.*) { + dvui.log.debug("TextEntryWidget forcing cache_layout false due to text being edited after drawing last frame", .{}); + self.init_opts.cache_layout = false; + self.edited_outside_last_frame.* = false; + self.text_changed = true; // trigger tree_sitter full reparse + } + + self.textLayout.init(@src(), .{ + .break_lines = self.init_opts.break_lines, + .kerning = self.init_opts.kerning, + .touch_edit_just_focused = false, + .cache_layout = self.init_opts.cache_layout, + .focused = self.data().id == dvui.focusedWidgetId(), + .show_touch_draggables = (self.len > 0), + }, self.data().options.strip().override(.{ + .role = .none, + .expand = .both, + .padding = self.padding, + .background = false, + .border = Rect{}, + .corner_radius = Rect{}, + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, + })); + + // if textLayout forced cache_layout to false, we need to honor that + self.init_opts.cache_layout = self.textLayout.cache_layout; + + self.textClip = dvui.clipGet(); + + if (self.textLayout.touchEditing()) |floating_widget| { + defer floating_widget.deinit(); + + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .corner_radius = dvui.ButtonWidget.defaults.themeOverride(opts.theme).corner_radiusGet(), + .background = true, + .border = dvui.Rect.all(1), + }); + defer hbox.deinit(); + + if (dvui.buttonIcon(@src(), "paste", dvui.entypo.clipboard, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.paste(); + } + + if (dvui.buttonIcon(@src(), "select all", dvui.entypo.swap, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.textLayout.selection.selectAll(); + } + + if (dvui.buttonIcon(@src(), "cut", dvui.entypo.scissors, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.cut(); + } + + if (dvui.buttonIcon(@src(), "copy", dvui.entypo.copy, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.copy(); + } + } + + // don't call textLayout.processEvents here, we forward events inside our own processEvents + + // textLayout is maintaining the selection for us, but if the text + // changed, we need to update the selection to be valid before we + // process any events + var sel = self.textLayout.selection; + sel.start = dvui.findUtf8Start(self.text[0..self.len], sel.start); + sel.cursor = dvui.findUtf8Start(self.text[0..self.len], sel.cursor); + sel.end = dvui.findUtf8Start(self.text[0..self.len], sel.end); + + // textLayout clips to its content, but we need to get events out to our border + dvui.clipSet(borderClip); + if (self.data().accesskit_node()) |ak_node| { + AccessKit.nodeAddAction(ak_node, AccessKit.Action.focus); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.set_value); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.set_text_selection); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.replace_selected_text); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.scroll_into_view); // AK TODO - not yet implemented + AccessKit.nodeSetClipsChildren(ak_node); // AK TODO: Check this is correct? + + if (self.data().options.role != .password_input) { + const str = self.text[0..self.len]; + AccessKit.nodeSetValueWithLength(ak_node, str.ptr, str.len); + } + } +} + +pub fn matchEvent(self: *TextEntryWidget, e: *Event) bool { + // textLayout could be passively listening to events in matchEvent, so + // don't short circuit + const match1 = dvui.eventMatchSimple(e, self.data()); + const match2 = self.scroll.scroll.?.matchEvent(e); + const match3 = self.textLayout.matchEvent(e); + return match1 or match2 or match3; +} + +pub fn processEvents(self: *TextEntryWidget) void { + const evts = dvui.events(); + for (evts) |*e| { + if (!self.matchEvent(e)) + continue; + + self.processEvent(e); + } +} + +pub fn draw(self: *TextEntryWidget) void { + self.drawBeforeText(); + + if (self.len == 0) { + if (self.init_opts.placeholder) |placeholder| { + if (self.data().accesskit_node()) |ak_node| { + AccessKit.nodeSetPlaceholderWithLength(ak_node, placeholder.ptr, placeholder.len); + + // Create an empty text run for the empty text entry. + dvui.currentWindow().accesskit.text_run_parent = self.data().id; + self.textLayout.textRunCreateEmpty(self.data().id, true); + // prevent textLayout from making a text run for the placeholder text + dvui.currentWindow().accesskit.text_run_parent = null; + } + self.textLayout.addText(placeholder, .{ .color_text = self.textLayout.data().options.color(.text).opacity(0.65) }); + } + } + + if (dvui.accesskit_enabled) { + // parent text runs to us + dvui.currentWindow().accesskit.text_run_parent = self.data().id; + } + + if (self.init_opts.password_char) |pc| { + { + // adjust selection for obfuscation + var count: usize = 0; + var bytes: usize = 0; + var sel = self.textLayout.selection; + var sstart: ?usize = null; + var scursor: ?usize = null; + var send: ?usize = null; + var utf8it = (std.unicode.Utf8View.initUnchecked(self.text[0..self.len])).iterator(); + while (utf8it.nextCodepoint()) |codepoint| { + if (sstart == null and sel.start == bytes) sstart = count * pc.len; + if (scursor == null and sel.cursor == bytes) scursor = count * pc.len; + if (send == null and sel.end == bytes) send = count * pc.len; + count += 1; + bytes += std.unicode.utf8CodepointSequenceLength(codepoint) catch unreachable; + } else { + if (sstart == null and sel.start >= bytes) sstart = count * pc.len; + if (scursor == null and sel.cursor >= bytes) scursor = count * pc.len; + if (send == null and sel.end >= bytes) send = count * pc.len; + } + sel.start = sstart.?; + sel.cursor = scursor.?; + sel.end = send.?; + const password_str: ?[]u8 = dvui.currentWindow().lifo().alloc(u8, count * pc.len) catch null; + if (password_str) |pstr| { + defer dvui.currentWindow().lifo().free(pstr); + for (0..count) |i| { + for (0..pc.len) |pci| { + pstr[i * pc.len + pci] = pc[pci]; + } + } + self.textLayout.addText(pstr, self.data().options.strip()); + } else { + dvui.log.warn("Could not allocate password_str, falling back to one single password_str", .{}); + self.textLayout.addText(pc, self.data().options.strip()); + } + } + + self.textLayout.addTextDone(self.data().options.strip()); + + { + // reset selection + var count: usize = 0; + var bytes: usize = 0; + var sel = self.textLayout.selection; + var sstart: ?usize = null; + var scursor: ?usize = null; + var send: ?usize = null; + // NOTE: We assume that all text in the area it valid utf8, loop with exit early on invalid utf8 + var utf8it = (std.unicode.Utf8View.initUnchecked(self.text[0..self.len])).iterator(); + while (utf8it.nextCodepoint()) |codepoint| { + if (sstart == null and sel.start == count * pc.len) sstart = bytes; + if (scursor == null and sel.cursor == count * pc.len) scursor = bytes; + if (send == null and sel.end == count * pc.len) send = bytes; + count += 1; + bytes += std.unicode.utf8CodepointSequenceLength(codepoint) catch unreachable; + } else { + if (sstart == null and sel.start >= count * pc.len) sstart = bytes; + if (scursor == null and sel.cursor >= count * pc.len) scursor = bytes; + if (send == null and sel.end >= count * pc.len) send = bytes; + } + sel.start = sstart.?; + sel.cursor = scursor.?; + sel.end = send.?; + } + + self.drawAfterText(); + return; + } + + if (dvui.useTreeSitter) { + if (self.init_opts.tree_sitter) |ts| { + // syntax highlighting + const parser = dvui.dataGetPtr(null, self.data().id, "parser", TreeSitterParser) orelse blk: { + const p = dvui.c.ts_parser_new(); + _ = dvui.c.ts_parser_set_language(p, ts.language); + const tree = dvui.c.ts_parser_parse_string(p, null, self.text.ptr, @intCast(self.len)); + + var errorOffset: u32 = undefined; + var errorType: dvui.c.TSQueryError = undefined; + const query = dvui.c.ts_query_new(ts.language, ts.queries.ptr, @intCast(ts.queries.len), &errorOffset, &errorType); + if (query == null) { + dvui.log.err("TextEntryWidget tree-sitter query failed at offset {d}: {any}", .{ errorOffset, errorType }); + dvui.c.ts_tree_delete(tree); + dvui.c.ts_parser_delete(p); + break :blk null; + } + + const parser_state: TreeSitterParser = .{ .parser = p.?, .tree = tree.?, .query = query.? }; + dvui.dataSet(null, self.data().id, "parser", parser_state); + dvui.dataSetDeinitFunction(null, self.data().id, "parser", &TreeSitterParser.deinit); + break :blk dvui.dataGetPtr(null, self.data().id, "parser", TreeSitterParser).?; + }; + + if (parser == null) { + self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); + self.textLayout.addTextDone(self.data().options.strip()); + self.drawAfterText(); + return; + } + const ts_parser = parser.?; + + if (self.text_changed and !dvui.firstFrame(self.data().id)) { + if (self.init_opts.cache_layout) { + var edit: dvui.c.TSInputEdit = undefined; + edit.start_byte = @intCast(self.text_changed_start); + edit.old_end_byte = @intCast(self.text_changed_end); + edit.new_end_byte = @intCast(@as(i64, @intCast(self.text_changed_end)) + self.text_changed_added); + + edit.start_point = .{ .row = 0, .column = 0 }; + edit.old_end_point = .{ .row = 0, .column = 0 }; + edit.new_end_point = .{ .row = 0, .column = 0 }; + + dvui.c.ts_tree_edit(ts_parser.tree, &edit); + + const tree = dvui.c.ts_parser_parse_string(ts_parser.parser, ts_parser.tree, self.text.ptr, @intCast(self.len)); + dvui.c.ts_tree_delete(ts_parser.tree); + ts_parser.tree = tree.?; + } else { + const tree = dvui.c.ts_parser_parse_string(ts_parser.parser, null, self.text.ptr, @intCast(self.len)); + dvui.c.ts_tree_delete(ts_parser.tree); + ts_parser.tree = tree.?; + } + } + + // parsing + const root = dvui.c.ts_tree_root_node(ts_parser.tree); + + // queries + const qc = dvui.c.ts_query_cursor_new(); + defer dvui.c.ts_query_cursor_delete(qc); + + if (self.textLayout.cache_layout_bytes) |clb| { + _ = dvui.c.ts_query_cursor_set_byte_range(qc, @intCast(clb.start), @intCast(clb.end)); + } + + dvui.c.ts_query_cursor_exec(qc, ts_parser.query, root); + + const range_start: usize = if (self.textLayout.cache_layout_bytes) |clb| @min(self.len, clb.start) else 0; + const range_end: usize = if (self.textLayout.cache_layout_bytes) |clb| @min(self.len, clb.end) else self.len; + const range_len = range_end - range_start; + + const default_opts = self.data().options.strip(); + + if (range_start > 0) { + self.textLayout.addText(self.text[0..range_start], default_opts); + } + + if (range_len > 0) { + const lifo = dvui.currentWindow().lifo(); + + const CaptureSpan = struct { + start: usize, + end: usize, + capture_index: u32, + specificity: u16, + }; + + var spans: std.ArrayListUnmanaged(CaptureSpan) = .empty; + defer spans.deinit(lifo); + + var match: dvui.c.TSQueryMatch = undefined; + var capture_idx: u32 = undefined; + while (dvui.c.ts_query_cursor_next_capture(qc, &match, &capture_idx)) { + if (!QueryPredicates.patternMatches(ts_parser.query, match.pattern_index, match, self.text)) continue; + + const cap = match.captures[capture_idx]; + const span_start = dvui.c.ts_node_start_byte(cap.node); + const span_end = dvui.c.ts_node_end_byte(cap.node); + if (span_end <= range_start or span_start >= range_end) continue; + + var cap_len: u32 = undefined; + const cap_name = dvui.c.ts_query_capture_name_for_id(ts_parser.query, cap.index, &cap_len); + const highlight = SyntaxHighlight.optsForCapture(cap_name[0..cap_len], ts.highlights); + if (highlight.specificity == 0) continue; + + if (ts.log_captures) { + dvui.log.debug("ts capture @{s} : {s}", .{ cap_name[0..cap_len], self.text[span_start..span_end] }); + } + + spans.append(lifo, .{ + .start = span_start, + .end = span_end, + .capture_index = cap.index, + .specificity = highlight.specificity, + }) catch { + dvui.log.err("tree-sitter highlight span alloc failed", .{}); + break; + }; + } + + const best_spec = lifo.alloc(u16, range_len) catch { + dvui.log.err("tree-sitter highlight buffer alloc failed", .{}); + self.textLayout.addText(self.text[range_start..range_end], default_opts); + if (range_end < self.len) self.textLayout.addText(self.text[range_end..self.len], default_opts); + self.textLayout.addTextDone(default_opts); + self.drawAfterText(); + return; + }; + const best_span_len = lifo.alloc(u16, range_len) catch { + dvui.log.err("tree-sitter highlight buffer alloc failed", .{}); + self.textLayout.addText(self.text[range_start..range_end], default_opts); + if (range_end < self.len) self.textLayout.addText(self.text[range_end..self.len], default_opts); + self.textLayout.addTextDone(default_opts); + self.drawAfterText(); + return; + }; + const best_cap = lifo.alloc(u32, range_len) catch { + dvui.log.err("tree-sitter highlight buffer alloc failed", .{}); + self.textLayout.addText(self.text[range_start..range_end], default_opts); + if (range_end < self.len) self.textLayout.addText(self.text[range_end..self.len], default_opts); + self.textLayout.addTextDone(default_opts); + self.drawAfterText(); + return; + }; + @memset(best_spec, 0); + @memset(best_span_len, std.math.maxInt(u16)); + @memset(best_cap, std.math.maxInt(u32)); + + for (spans.items) |span| { + const apply_start = @max(span.start, range_start); + const apply_end = @min(span.end, range_end); + const span_len: u16 = @intCast(@min(apply_end - apply_start, std.math.maxInt(u16))); + var b = apply_start; + while (b < apply_end) : (b += 1) { + const i = b - range_start; + if (span.specificity > best_spec[i] or + (span.specificity == best_spec[i] and span_len < best_span_len[i])) + { + best_spec[i] = span.specificity; + best_span_len[i] = span_len; + best_cap[i] = span.capture_index; + } + } + } + + var run: usize = 0; + while (run < range_len) { + const spec = best_spec[run]; + const cap_idx = best_cap[run]; + var run_end = run + 1; + while (run_end < range_len and best_spec[run_end] == spec and best_cap[run_end] == cap_idx) : (run_end += 1) {} + + const abs_start = range_start + run; + const abs_end = range_start + run_end; + var opts = default_opts; + if (spec > 0 and cap_idx != std.math.maxInt(u32)) { + var cap_len: u32 = undefined; + const cap_name = dvui.c.ts_query_capture_name_for_id(ts_parser.query, cap_idx, &cap_len); + opts = SyntaxHighlight.optsForCapture(cap_name[0..cap_len], ts.highlights).opts; + } + self.textLayout.addText(self.text[abs_start..abs_end], opts); + run = run_end; + } + } + + if (range_end < self.len) { + self.textLayout.addText(self.text[range_end..self.len], default_opts); + } + + self.textLayout.addTextDone(default_opts); + self.drawAfterText(); + return; + } + } + + // simple text + self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); + self.textLayout.addTextDone(self.data().options.strip()); + + self.drawAfterText(); +} + +pub fn drawBeforeText(self: *TextEntryWidget) void { + const focused = (self.data().id == dvui.focusedWidgetId()); + + if (focused) { + dvui.wantTextInput(self.data().borderRectScale().r.toNatural()); + } + + // set clip back to what textLayout had, so we don't draw over the scrollbars + dvui.clipSet(self.textClip); + + if (self.init_opts.cache_layout) { + self.textLayout.cache_layout_bytes = self.textLayout.bytesNeeded( + self.text_changed_start, + self.text_changed_end, + self.text_changed_added, + ); + } +} + +pub fn drawAfterText(self: *TextEntryWidget) void { + const focused = (self.data().id == dvui.focusedWidgetId()); + if (focused) { + self.drawCursor(); + } + + dvui.clipSet(self.prevClip); + + if (focused and self.init_opts.show_focus_border) { + self.data().focusBorder(); + } +} + +pub fn drawCursor(self: *TextEntryWidget) void { + var sel = self.textLayout.selectionGet(self.len); + if (sel.empty()) { + // the cursor can be slightly outside the textLayout clip + dvui.clipSet(self.scrollClip); + + var crect = self.textLayout.cursor_rect.plus(.{ .x = -1 }); + crect.w = 2; + self.textLayout.screenRectScale(crect).r.fill(.{}, .{ .color = dvui.themeGet().focus, .fade = 1.0 }); + } +} + +pub fn widget(self: *TextEntryWidget) Widget { + return Widget.init(self, data, rectFor, screenRectScale, minSizeForChild); +} + +pub fn data(self: *TextEntryWidget) *WidgetData { + return self.wd.validate(); +} + +pub fn rectFor(self: *TextEntryWidget, id: dvui.Id, min_size: Size, e: Options.Expand, g: Options.Gravity) Rect { + _ = id; + return dvui.placeIn(self.data().contentRect().justSize(), min_size, e, g); +} + +pub fn screenRectScale(self: *TextEntryWidget, rect: Rect) RectScale { + return self.data().contentRectScale().rectToRectScale(rect); +} + +pub fn minSizeForChild(self: *TextEntryWidget, s: Size) void { + self.data().minSizeMax(self.data().options.padSize(s)); +} + +pub fn textChangedRemoved(self: *TextEntryWidget, start: usize, end: usize) void { + self.textChanged(start, end, @as(i64, @intCast(start)) - @as(i64, @intCast(end))); +} + +// Inserting text is at a single point in the previous frame's indexing. +pub fn textChangedAdded(self: *TextEntryWidget, pos: usize, added: usize) void { + self.textChanged(pos, pos, @intCast(added)); +} + +// Only needed when cache_layout is true. We are maintaining an interval of +// bytes from last frame plus a total number added (might be negative) in that +// interval. This is sent to textLayout so it will process at least this +// interval (plus whatever is visible). +pub fn textChanged(self: *TextEntryWidget, start: usize, end: usize, added: i64) void { + self.text_changed = true; + if (end > self.text_changed_start) { + // end is in current bytes, so we update it to previous frame's indexing + var end_old: usize = undefined; + if (self.text_changed_added >= 0) { + end_old = end - @as(usize, @intCast(self.text_changed_added)); + } else { + end_old = end + @as(usize, @intCast(-self.text_changed_added)); + } + // This assumes that the current update happens after (in bytes) all + // previous updates. This is not exact, but will always give an + // interval that includes all the updates. + self.text_changed_end = @max(self.text_changed_end, end_old); + } else { + // before previous updates then indexing is the same + self.text_changed_end = @max(self.text_changed_end, end); + } + + // if we are before the previous updates then the indexing is the same + self.text_changed_start = @min(self.text_changed_start, start); + self.text_changed_added += added; + + if (self.textLayout.add_text_done) { + self.edited_outside_last_frame.* = true; + } + + //std.debug.print("textChanged {d} {d} {d}\n", .{ self.text_changed_start, self.text_changed_end, self.text_changed_added }); +} + +/// Return text as a slice to the backing storage. The returned slice is +/// valid after `deinit`, and is only invalidated by events or functions that +/// change the text (like `textSet` or `paste`). +pub fn textGet(self: *const TextEntryWidget) []u8 { + return self.text[0..self.len]; +} + +/// Deprecated in favor of `textGet`. +pub fn getText(self: *const TextEntryWidget) []u8 { + return self.textGet(); +} + +pub fn textSet(self: *TextEntryWidget, text: []const u8, selected: bool) void { + self.textLayout.selection.selectAll(); + self.textTyped(text, selected); +} + +pub fn textTyped(self: *TextEntryWidget, new: []const u8, selected: bool) void { + // strip out carriage returns, which we get from copy/paste on windows + if (std.mem.findScalar(u8, new, '\r')) |idx| { + self.textTyped(new[0..idx], selected); + self.textTyped(new[idx + 1 ..], selected); + return; + } + + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.len -= (sel.end - sel.start); + sel.end = sel.start; + sel.cursor = sel.start; + } + + const space_left = self.text.len - self.len; + if (space_left < new.len) { + var new_size = realloc_bin_size * (@divTrunc(self.len + new.len, realloc_bin_size) + 1); + switch (self.init_opts.text) { + .buffer => {}, + .buffer_dynamic => |b| { + new_size = @min(new_size, b.limit); + b.backing.* = b.allocator.realloc(self.text, new_size) catch |err| blk: { + dvui.logError(@src(), err, "{x} TextEntryWidget.textTyped failed to realloc backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_size }); + break :blk b.backing.*; + }; + self.text = b.backing.*; + }, + .array_list => |al| { + new_size = @min(new_size, al.limit); + al.backing.ensureTotalCapacity(al.allocator, new_size) catch |err| { + dvui.logError(@src(), err, "{x} TextEntryWidget.textTyped failed to realloc ArrayList backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_size }); + }; + self.text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; + }, + .internal => |i| { + new_size = @min(new_size, i.limit); + // If we are the same size then there is no work to do + // This is important because same sized data allocations will be reused + if (new_size != self.text.len) { + // NOTE: Using prev_text is safe because data is trashed and stays valid until the end of the frame + const prev_text = self.text; + dvui.dataSetSliceCopies(null, self.data().id, "_buffer", &[_]u8{0}, new_size); + self.text = dvui.dataGetSlice(null, self.data().id, "_buffer", []u8).?; + const min_len = @min(prev_text.len, self.text.len); + if (self.text.ptr != prev_text.ptr) { + @memcpy(self.text[0..min_len], prev_text[0..min_len]); + } + } + }, + } + } + var new_len = @min(new.len, self.text.len - self.len); + + // find start of last utf8 char + var last: usize = new_len -| 1; + while (last < new_len and new[last] & 0xc0 == 0x80) { + last -|= 1; + } + + // if the last utf8 char can't fit, don't include it + if (last < new_len) { + const utf8_size = std.unicode.utf8ByteSequenceLength(new[last]) catch 0; + if (utf8_size != (new_len - last)) { + new_len = last; + } + } + + // make room if we can + if (new_len > 0 and sel.cursor + new_len < self.text.len) { + @memmove(self.text[sel.cursor + new_len ..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); + } + + if (new_len > 0) { + self.textChangedAdded(sel.cursor, new_len); + } + + // update our len and maintain 0 termination if possible + self.setLen(self.len + new_len); + + // insert + @memmove(self.text[sel.cursor..][0..new_len], new[0..new_len]); + if (selected) { + sel.start = sel.cursor; + sel.cursor += new_len; + sel.end = sel.cursor; + } else { + sel.cursor += new_len; + sel.end = sel.cursor; + sel.start = sel.cursor; + } + if (std.mem.findScalar(u8, new[0..new_len], '\n') != null) { + sel.affinity = .after; + } + + // we might have dropped to a new line, so make sure the cursor is visible + self.textLayout.scroll_to_cursor_next_frame = true; + dvui.refresh(null, @src(), self.data().id); +} + +/// Remove all characters that not present in filter_chars. +/// Designed to run after event processing and before drawing. +pub fn filterIn(self: *TextEntryWidget, filter_chars: []const u8) void { + if (filter_chars.len == 0) { + return; + } + + var i: usize = 0; + var j: usize = 0; + const n = self.len; + while (i < n) { + if (std.mem.findScalar(u8, filter_chars, self.text[i]) == null) { + self.len -= 1; + var sel = self.textLayout.selection; + if (sel.start > i) sel.start -= 1; + if (sel.cursor > i) sel.cursor -= 1; + if (sel.end > i) sel.end -= 1; + self.text_changed = true; + + i += 1; + } else { + self.text[j] = self.text[i]; + i += 1; + j += 1; + } + } + + if (j < self.text.len) + self.text[j] = 0; +} + +/// Remove all instances of the string needle. +/// Designed to run after event processing and before drawing. +pub fn filterOut(self: *TextEntryWidget, needle: []const u8) void { + if (needle.len == 0) { + return; + } + + var i: usize = 0; + var j: usize = 0; + const n = self.len; + while (i < n) { + if (std.mem.startsWith(u8, self.text[i..], needle)) { + self.len -= needle.len; + var sel = self.textLayout.selection; + if (sel.start > i) sel.start -= needle.len; + if (sel.cursor > i) sel.cursor -= needle.len; + if (sel.end > i) sel.end -= needle.len; + self.text_changed = true; + + i += needle.len; + } else { + self.text[j] = self.text[i]; + i += 1; + j += 1; + } + } + + if (j < self.text.len) + self.text[j] = 0; +} + +/// Sets the new length and does fixups: +/// - add null terminator if there is space +/// - shrink allocation if needed +/// - fixup array_list backing +pub fn setLen(self: *TextEntryWidget, newlen: usize) void { + self.len = newlen; + + // add null terminator if there is space + if (self.len < self.text.len) { + self.text[self.len] = 0; + } + + // shrink allocation if needed + const needed_binds = @divTrunc(self.len, realloc_bin_size) + 1; + const current_bins = @divTrunc(self.text.len, realloc_bin_size); + // dvui.log.debug("TextEntry {x} needs {d} bins, has {d}", .{ self.data().id, needed_binds, current_bins }); + if (self.len == 0 or needed_binds < current_bins) { + // we want to shrink the allocation + const new_len = if (self.len == 0) 0 else realloc_bin_size * needed_binds; + switch (self.init_opts.text) { + .buffer => {}, + .buffer_dynamic => |b| { + if (b.allocator.resize(self.text, new_len)) { + b.backing.*.len = new_len; + self.text.len = new_len; + } else { + dvui.logError(@src(), std.mem.Allocator.Error.OutOfMemory, "{x} TextEntryWidget.textTyped failed to realloc backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_len }); + } + }, + .array_list => |al| { + if (new_len < al.backing.capacity / 2) { + al.backing.items.len = al.backing.capacity; + al.backing.shrinkAndFree(al.allocator, new_len); + self.text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; + } + }, + .internal => { + // NOTE: Using prev_text is safe because data is trashed and stays valid until the end of the frame + const prev_text = self.text; + dvui.dataSetSliceCopies(null, self.data().id, "_buffer", &[_]u8{0}, new_len); + self.text = dvui.dataGetSlice(null, self.data().id, "_buffer", []u8).?; + const min_len = @min(prev_text.len, self.text.len); + @memcpy(self.text[0..min_len], prev_text[0..min_len]); + }, + } + } + + // fixup array_list backing + switch (self.init_opts.text) { + .array_list => |al| { + al.backing.items.len = self.len; + }, + else => {}, + } +} + +pub fn processEvent(self: *TextEntryWidget, e: *Event) void { + // scroll gets first crack, because it is logically outside the text area + self.scroll.scroll.?.processEvent(e); + if (e.handled) return; + + switch (e.evt) { + .key => |ke| blk: { + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("next_widget")) { + e.handle(@src(), self.data()); + dvui.tabIndexNext(e.num); + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("prev_widget")) { + e.handle(@src(), self.data()); + dvui.tabIndexPrev(e.num); + break :blk; + } + + if (ke.action == .down and ke.matchBind("paste")) { + e.handle(@src(), self.data()); + self.paste(); + break :blk; + } + + if (ke.action == .down and ke.matchBind("cut")) { + e.handle(@src(), self.data()); + self.cut(); + break :blk; + } + + if (ke.action == .down and ke.matchBind("copy")) { + e.handle(@src(), self.data()); + self.copy(); + break :blk; + } + + if (ke.action == .down and ke.matchBind("text_start")) { + e.handle(@src(), self.data()); + self.textLayout.selection.moveCursor(0, false); + self.textLayout.scroll_to_cursor = true; + break :blk; + } + + if (ke.action == .down and ke.matchBind("text_end")) { + e.handle(@src(), self.data()); + self.textLayout.selection.moveCursor(std.math.maxInt(usize), false); + self.textLayout.scroll_to_cursor = true; + break :blk; + } + + if (ke.action == .down and ke.matchBind("line_start")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .home } }; + } + break :blk; + } + + if (ke.action == .down and ke.matchBind("line_end")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .end } }; + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_left")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .word_left_right) { + self.textLayout.sel_move.word_left_right.count -= 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_right")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); + self.textLayout.selection.affinity = .before; + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .word_left_right) { + self.textLayout.sel_move.word_left_right.count += 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_left")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .char_left_right) { + self.textLayout.sel_move.char_left_right.count -= 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_right")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); + self.textLayout.selection.affinity = .before; + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .char_left_right) { + self.textLayout.sel_move.char_left_right.count += 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_up")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; + } + if (self.textLayout.sel_move == .cursor_updown) { + self.textLayout.sel_move.cursor_updown.count -= 1; + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_down")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; + } + if (self.textLayout.sel_move == .cursor_updown) { + self.textLayout.sel_move.cursor_updown.count += 1; + } + break :blk; + } + + switch (ke.code) { + .backspace => { + if (ke.action == .down or ke.action == .repeat) { + e.handle(@src(), self.data()); + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // just delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.setLen(self.len - (sel.end - sel.start)); + sel.end = sel.start; + sel.cursor = sel.start; + self.textLayout.scroll_to_cursor = true; + } else if (ke.matchBind("delete_prev_word")) { + // delete word before cursor + + const oldcur = sel.cursor; + // find end of last word + if (sel.cursor > 0 and std.mem.findAny(u8, self.text[sel.cursor - 1 ..][0..1], " \n") != null) { + sel.cursor = std.mem.findLastNone(u8, self.text[0..sel.cursor], " \n") orelse 0; + } + + // find start of word + if (std.mem.findLastAny(u8, self.text[0..sel.cursor], " \n")) |last_space| { + sel.cursor = last_space + 1; + } else { + sel.cursor = 0; + } + + // delete from sel.cursor to oldcur + if (sel.cursor != oldcur) self.textChangedRemoved(sel.cursor, oldcur); + @memmove(self.text[sel.cursor..][0 .. self.len - oldcur], self.text[oldcur..self.len]); + self.setLen(self.len - (oldcur - sel.cursor)); + sel.end = sel.cursor; + sel.start = sel.cursor; + self.textLayout.scroll_to_cursor = true; + } else if (sel.cursor > 0) { + // delete character just before cursor + // + // A utf8 char might consist of more than one byte. + // Find the beginning of the last byte by iterating over + // the string backwards. The first byte of a utf8 char + // does not have the pattern 10xxxxxx. + var i: usize = 1; + while (sel.cursor - i > 0 and self.text[sel.cursor - i] & 0xc0 == 0x80) : (i += 1) {} + self.textChangedRemoved(sel.cursor - i, sel.cursor); + @memmove(self.text[sel.cursor - i ..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); + self.setLen(self.len - i); + sel.cursor -= i; + sel.start = sel.cursor; + sel.end = sel.cursor; + self.textLayout.scroll_to_cursor = true; + } + } + }, + .delete => { + if (ke.action == .down or ke.action == .repeat) { + e.handle(@src(), self.data()); + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // just delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.setLen(self.len - (sel.end - sel.start)); + sel.end = sel.start; + sel.cursor = sel.start; + self.textLayout.scroll_to_cursor = true; + } else if (ke.matchBind("delete_next_word")) { + // delete word after cursor + + const oldcur = sel.cursor; + // find start of next word + if (sel.cursor < self.len and std.mem.findAny(u8, self.text[sel.cursor..][0..1], " \n") != null) { + sel.cursor = std.mem.findNonePos(u8, self.text, sel.cursor, " \n") orelse self.len; + } + + // find end of word + if (std.mem.findAny(u8, self.text[sel.cursor..self.len], " \n")) |last_space| { + sel.cursor = sel.cursor + last_space; + } else { + sel.cursor = self.len; + } + + // delete from oldcur to sel.cursor + if (sel.cursor != oldcur) self.textChangedRemoved(oldcur, sel.cursor); + @memmove(self.text[oldcur..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); + self.setLen(self.len - (sel.cursor - oldcur)); + sel.cursor = oldcur; + sel.end = sel.cursor; + sel.start = sel.cursor; + self.textLayout.scroll_to_cursor = true; + } else if (sel.cursor < self.len) { + // delete the character just after the cursor + // + // A utf8 char might consist of more than one byte. + const ii = std.unicode.utf8ByteSequenceLength(self.text[sel.cursor]) catch 1; + const i = @min(ii, self.len - sel.cursor); + + self.textChangedRemoved(sel.cursor, sel.cursor + i); + const remaining = self.len - (sel.cursor + i); + @memmove(self.text[sel.cursor..][0..remaining], self.text[sel.cursor + i ..][0..remaining]); + self.setLen(self.len - i); + self.textLayout.scroll_to_cursor = true; + } + } + }, + .enter => { + if (ke.action == .down or ke.action == .repeat) { + e.handle(@src(), self.data()); + if (self.init_opts.multiline) { + self.textTyped("\n", false); + } else if (ke.action == .down) { + self.enter_pressed = true; + dvui.refresh(null, @src(), self.data().id); + } + } + }, + else => {}, + } + }, + .text => |te| { + switch (te.action) { + .value => |set| { + e.handle(@src(), self.data()); + var new = std.mem.sliceTo(set.txt, 0); + if (self.init_opts.multiline) { + self.textTyped(new, set.selected); + } else { + var i: usize = 0; + while (i < new.len) { + if (std.mem.findScalar(u8, new[i..], '\n')) |idx| { + self.textTyped(new[i..][0..idx], set.selected); + i += idx + 1; + } else { + self.textTyped(new[i..], set.selected); + break; + } + } + } + }, + else => {}, + } + }, + .mouse => |me| { + if (me.action == .focus) { + e.handle(@src(), self.data()); + dvui.focusWidget(self.data().id, null, e.num); + } + }, + else => {}, + } + + if (!e.handled) { + self.textLayout.processEvent(e); + + if (!e.handled and e.evt == .key) { + switch (e.evt.key.code) { + .page_up, .page_down => {}, // handled by scroll container + else => { + // Mark all remaining key events as handled. This allows + // checking a keybind (like "d") after the textEntry, but + // where textEntry will get it first. + e.handle(@src(), self.data()); + }, + } + } + } +} + +pub fn paste(self: *TextEntryWidget) void { + const clip_text = dvui.clipboardText(); + + if (self.init_opts.multiline) { + self.textTyped(clip_text, false); + } else { + var i: usize = 0; + while (i < clip_text.len) { + if (std.mem.findScalar(u8, clip_text[i..], '\n')) |idx| { + self.textTyped(clip_text[i..][0..idx], false); + i += idx + 1; + } else { + self.textTyped(clip_text[i..], false); + break; + } + } + } +} + +pub fn cut(self: *TextEntryWidget) void { + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // copy selection to clipboard + dvui.clipboardTextSet(self.text[sel.start..sel.end]); + + // delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.setLen(self.len - (sel.end - sel.start)); + sel.end = sel.start; + sel.cursor = sel.start; + self.textLayout.scroll_to_cursor = true; + } +} + +/// This could use textLayout.copy(), but that doesn't work if we have a masked +/// password field (textLayout only sees the password char). +pub fn copy(self: *TextEntryWidget) void { + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // copy selection to clipboard + dvui.clipboardTextSet(self.text[sel.start..sel.end]); + } +} + +pub fn deinit(self: *TextEntryWidget) void { + defer if (dvui.widgetIsAllocated(self)) dvui.widgetFree(self); + defer self.* = undefined; + + // set clip back to what textLayout had, because it might need it to set + // the mouse cursor + dvui.clipSet(self.textClip); + self.textLayout.deinit(); + self.scroll.deinit(); + + dvui.clipSet(self.prevClip); + self.data().minSizeSetAndRefresh(); + self.data().minSizeReportToParent(); + dvui.parentReset(self.data().id, self.data().parent); +} + +test { + @import("std").testing.refAllDecls(@This()); +} + +test "text internal" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + + // Set a limit that is not a multiple of the bin size + const limit = realloc_bin_size * 5 / 2; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ + .text = .{ .internal = .{ .limit = limit } }, + }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "This is some short sample text!"; + // text length should not be a multiple of the limit or bin size + try std.testing.expect(Local.limit % text.len != 0); + try std.testing.expect(realloc_bin_size % text.len != 0); + + try dvui.testing.writeText(text); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); + + for (0..@divFloor(Local.limit, text.len)) |_| { + // Fill the internal buffer + try dvui.testing.writeText(text); + } + try dvui.testing.settle(Local.frame); + + const full_text_buffer = comptime blk: { + var text_buf: []const u8 = text; + while (text_buf.len < Local.limit) text_buf = text_buf ++ text; + break :blk text_buf; + }[0..Local.limit]; + try std.testing.expectEqualStrings(full_text_buffer, Local.text); +} + +test "text dynamic buffer" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + + // Set a limit that is not a multiple of the bin size + const limit = realloc_bin_size * 5 / 2; + + var buffer: [limit]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buffer); + var backing: []u8 = &.{}; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ + .text = .{ .buffer_dynamic = .{ + .backing = &backing, + .allocator = fba.allocator(), + .limit = limit, + } }, + }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "This is some short sample text!"; + // limit should not be a multiple of the text length + try std.testing.expect(Local.limit % text.len != 0); + try std.testing.expect(realloc_bin_size % text.len != 0); + + try dvui.testing.writeText(text); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); + + for (0..@divFloor(Local.limit, text.len)) |_| { + // Fill the internal buffer + // This verifies that any OOM error is handled by writing past the buffer size + try dvui.testing.writeText(text); + } + try dvui.testing.settle(Local.frame); + + const full_text_buffer = comptime blk: { + var text_buf: []const u8 = text; + while (text_buf.len < Local.limit) text_buf = text_buf ++ text; + break :blk text_buf; + }[0..Local.limit]; + try std.testing.expectEqualStrings(full_text_buffer, Local.text); +} + +test "text buffer" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + + // Set a limit that is not a multiple of the bin size + const limit = realloc_bin_size * 5 / 2; + + var buffer: [limit]u8 = undefined; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ + .text = .{ .buffer = &buffer }, + }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "This is some short sample text!"; + // limit should not be a multiple of the text length + try std.testing.expect(Local.limit % text.len != 0); + try std.testing.expect(realloc_bin_size % text.len != 0); + + try dvui.testing.writeText(text); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); + + for (0..@divFloor(Local.limit, text.len)) |_| { + // Fill the internal buffer + // This verifies that any OOM error is handled by writing past the buffer size + try dvui.testing.writeText(text); + } + try dvui.testing.settle(Local.frame); + + const full_text_buffer = comptime blk: { + var text_buf: []const u8 = text; + while (text_buf.len < Local.limit) text_buf = text_buf ++ text; + break :blk text_buf; + }[0..Local.limit]; + try std.testing.expectEqualStrings(full_text_buffer, Local.text); +} + +test "text array_list" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + var al: std.ArrayList(u8) = .empty; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ .text = .{ .array_list = .{ + .backing = &al, + .allocator = std.testing.allocator, + } } }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + + return .ok; + } + }; + + defer Local.al.deinit(std.testing.allocator); + + _ = try dvui.testing.step(Local.frame); + try dvui.testing.pressKey(.tab, .none); + _ = try dvui.testing.step(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "Testing text"; + try dvui.testing.writeText(text); + _ = try dvui.testing.step(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); +} diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 722816f3..981473bb 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -121,7 +121,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { const folder = fizzy.editor.recents.folders.items[i - 1]; if (menuItem(@src(), folder, .{}, .{ .expand = .horizontal, - .font = dvui.Font.theme(.mono).larger(-2.0), + .font = dvui.Font.theme(.mono), .id_extra = i, .margin = dvui.Rect.all(1), .padding = dvui.Rect.all(2), diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig index 518de372..c6ebd68e 100644 --- a/src/editor/Settings.zig +++ b/src/editor/Settings.zig @@ -44,7 +44,7 @@ theme: []const u8 = default_theme, font_body_size: f32 = 9, font_title_size: f32 = 9, font_heading_size: f32 = 8, -font_mono_size: f32 = 10, +font_mono_size: f32 = 9, /// Opacity of the background window /// CURRENTLY ONLY SUPPORTED ON MACOS and Windows diff --git a/src/plugins/code/code.zig b/src/plugins/code/code.zig index a753e49a..d394de71 100644 --- a/src/plugins/code/code.zig +++ b/src/plugins/code/code.zig @@ -11,3 +11,5 @@ 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"); +pub const CodeEditor = @import("src/CodeEditor.zig"); +pub const SyntaxHighlight = @import("src/SyntaxHighlight.zig"); diff --git a/src/plugins/code/queries/json.scm b/src/plugins/code/queries/json.scm new file mode 100644 index 00000000..0fe34774 --- /dev/null +++ b/src/plugins/code/queries/json.scm @@ -0,0 +1,16 @@ +(string) @feppz.string + +(pair + key: (_) @feppz.string.special.key) + +(number) @feppz.number + +[ + (null) + (true) + (false) +] @feppz.keyword.constant.default + +(escape_sequence) @feppz.string.escape + +(comment) @feppz.comment diff --git a/src/plugins/code/queries/zig.scm b/src/plugins/code/queries/zig.scm new file mode 100644 index 00000000..08435bd3 --- /dev/null +++ b/src/plugins/code/queries/zig.scm @@ -0,0 +1,315 @@ +; Feppz! / vscode-zig aligned captures for tree-sitter highlighting. +; Capture names mirror TextMate scopes from ziglang.vscode-zig where possible. + +; --- Functions & calls (before generic identifiers) --- +(function_declaration + name: (identifier) @feppz.entity.name.function) + +(call_expression + function: (identifier) @feppz.entity.name.function) + +(call_expression + function: (field_expression + member: (identifier) @feppz.entity.name.function)) + +; const/var name — the identifier immediately after the keyword. +(variable_declaration + [ + "const" + "var" + ] + (identifier) @feppz.variable.definition) + +; PascalCase types only when not a dotted path segment (see field_expression below). +((identifier) @feppz.entity.name.type + (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*")) + +(variable_declaration + (identifier) @feppz.entity.name.type + (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*") + "=" + [ + (struct_declaration) + (enum_declaration) + (union_declaration) + (opaque_declaration) + ]) + +; --- Types --- +(parameter + type: (identifier) @feppz.entity.name.type + (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*")) + +[ + (builtin_type) + "anyframe" + "anyopaque" +] @feppz.keyword.type + +; --- Parameters & fields --- +(parameter + name: (identifier) @feppz.variable) + +(payload + (identifier) @feppz.variable) + +; Dotted paths: dvui in dvui.TextureTarget, std/mem in std.mem.Allocator +(field_expression + object: (identifier) @feppz.variable.namespace + (#match? @feppz.variable.namespace "^[a-z_][a-zA-Z0-9_]*")) + +(field_expression + (_) + member: (identifier) @feppz.entity.name.type + (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*")) + +(field_expression + (_) + member: (identifier) @feppz.variable.namespace + (#match? @feppz.variable.namespace "^[a-z_][a-zA-Z0-9_]*")) + +(field_initializer + . + (identifier) @feppz.variable.member) + +(container_field + name: (identifier) @feppz.variable.member) + +(enum_declaration + (container_field + type: (identifier) @feppz.variable.enum_member)) + +(initializer_list + (assignment_expression + left: (field_expression + . + member: (identifier) @feppz.variable.namespace + (#match? @feppz.variable.namespace "^[a-z_][a-zA-Z0-9_]*")))) + +(initializer_list + (assignment_expression + left: (field_expression + . + member: (identifier) @feppz.entity.name.type + (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*")))) + +; --- Constants --- +((identifier) @feppz.constant + (#match? @feppz.constant "^[A-Z][A-Z_0-9]+$")) + +[ + "null" + "undefined" +] @feppz.keyword.constant.default + +(boolean) @feppz.keyword.constant.bool + +; --- Labels --- +(block_label + (identifier) @feppz.label) + +(break_label + (identifier) @feppz.label) + +; --- Builtins & modules --- +(builtin_function + (builtin_identifier) @feppz.support.function.builtin) + +(builtin_identifier) @feppz.support.function.builtin + +(call_expression + function: (builtin_function + (builtin_identifier) @feppz.support.function.builtin)) + +(variable_declaration + (identifier) @feppz.variable.module + (builtin_function + (builtin_identifier) @feppz.support.function.builtin + (#any-of? @feppz.support.function.builtin "@import" "@cImport"))) + +[ + "c" + "..." +] @feppz.variable.builtin + +((identifier) @feppz.variable.builtin + (#eq? @feppz.variable.builtin "_")) + +(calling_convention + (identifier) @feppz.variable.builtin) + +; --- Keywords (vscode-zig scopes) --- +[ + "const" + "var" + "test" + "and" + "or" +] @feppz.keyword.default + +"fn" @feppz.storage.type.function + +[ + "struct" + "union" + "enum" + "opaque" +] @feppz.keyword.structure + +[ + "extern" + "packed" + "export" + "pub" + "noalias" + "inline" + "comptime" + "volatile" + "align" + "linksection" + "threadlocal" + "allowzero" + "noinline" + "callconv" + "usingnamespace" + "addrspace" +] @feppz.keyword.storage + +"asm" @feppz.keyword.control.flow + +"error" @feppz.keyword.control.flow + +[ + "break" + "return" + "continue" + "defer" + "errdefer" + "unreachable" +] @feppz.keyword.control.flow + +[ + "while" + "for" +] @feppz.keyword.control.flow + +[ + "resume" + "suspend" + "nosuspend" + "async" + "await" +] @feppz.keyword.control.flow + +[ + "if" + "else" + "switch" + "orelse" +] @feppz.keyword.control.flow + +[ + "try" + "catch" +] @feppz.keyword.control.flow + +; --- Operators --- +[ + "=" + "*=" + "*%=" + "*|=" + "/=" + "%=" + "+=" + "+%=" + "+|=" + "-=" + "-%=" + "-|=" + "<<=" + "<<|=" + ">>=" + "&=" + "^=" + "|=" + "!" + "~" + "-" + "-%" + "&" + "==" + "!=" + ">" + ">=" + "<=" + "<" + "^" + "|" + "<<" + ">>" + "<<|" + "+" + "++" + "+%" + "-%" + "+|" + "-|" + "*" + "/" + "%" + "**" + "*%" + "*|" + "||" + ".*" + ".?" + "?" + ".." +] @feppz.operator + +; --- Literals --- +(character) @feppz.string.character + +([ + (string) + (multiline_string) +] @feppz.string + (#set! "priority" 1)) + +(integer) @feppz.number + +(float) @feppz.number.float + +(escape_sequence) @feppz.string.escape + (#set! "priority" 95) + +; --- Punctuation --- +["(" ")"] @feppz.punctuation.round + +["[" "]"] @feppz.punctuation.square + +["{" "}"] @feppz.punctuation.curly + +[ + ";" + "," + ":" + "=>" + "->" +] @feppz.punctuation + +"." @feppz.punctuation.accessor + +(payload + "|" @feppz.punctuation.square) + +; --- Comments --- +(comment) @feppz.comment @spell + +((comment) @feppz.comment.documentation + (#match? @feppz.comment.documentation "^//!")) + +; --- Fallback identifiers (lowest priority) --- +(identifier) @feppz.variable + (#set! "priority" 0) diff --git a/src/plugins/code/src/CodeEditor.zig b/src/plugins/code/src/CodeEditor.zig new file mode 100644 index 00000000..5ae99fe3 --- /dev/null +++ b/src/plugins/code/src/CodeEditor.zig @@ -0,0 +1,126 @@ +//! Monospace code editor: gutter line numbers + tree-sitter `textEntry`. +const std = @import("std"); +const code = @import("../code.zig"); +const dvui = code.dvui; +const wdvui = code.core.dvui; +const Document = code.Document; +const SyntaxHighlight = @import("SyntaxHighlight.zig"); + +const editor_padding = dvui.Rect.all(8); +const gutter_pad_x: f32 = 12; + +/// Tree-sitter + per-token layout is O(file size) each frame without layout caching. +/// Above this size we still edit, but skip syntax highlighting. +const syntax_highlight_max_bytes: usize = 512 * 1024; + +const chromeless = dvui.Options{ + .background = false, + .margin = dvui.Rect{}, + .padding = null, + // override() treats null as "unset", so use empty rects to clear TextEntry defaults. + .border = dvui.Rect{}, + .corner_radius = dvui.Rect{}, + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, +}; + +pub fn draw(doc: *Document, id_extra: u64, gpa: std.mem.Allocator) !bool { + const font = dvui.Font.theme(.mono); + const theme = SyntaxHighlight.default_theme; + const gutter_w = gutterWidth(doc.line_count, font); + const line_height = font.lineHeight(); + + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, chromeless.override(.{ + .expand = .both, + })); + defer hbox.deinit(); + + _ = dvui.spacer(@src(), .{ + .min_size_content = .{ .w = gutter_w }, + .expand = .vertical, + }); + + const use_syntax = doc.text.items.len <= syntax_highlight_max_bytes; + + var te = wdvui.textEntry(@src(), .{ + .multiline = true, + .break_lines = false, + // Limit layout + tree-sitter query work to the visible scroll range (see dvui Examples/text_entry.zig). + .cache_layout = true, + .scroll_horizontal = true, + .show_focus_border = false, + .text = .{ .array_list = .{ .backing = &doc.text, .allocator = gpa, .limit = max_text_bytes } }, + .tree_sitter = if (use_syntax) SyntaxHighlight.treeSitterOption(doc.path, theme) else null, + }, chromeless.override(.{ + .expand = .both, + .font = font, + .padding = editor_padding, + .color_text = theme.text, + .id_extra = @intCast(id_extra), + })); + defer te.deinit(); + + const te_rs = te.data().borderRectScale(); + const gutter_rs: dvui.RectScale = .{ + .r = .{ + .x = te_rs.r.x - gutter_w * te_rs.s, + .y = te_rs.r.y, + .w = gutter_w * te_rs.s, + .h = te_rs.r.h, + }, + .s = te_rs.s, + }; + drawLineNumbers(gutter_rs, doc.line_count, te.scroll.si.viewport.y, font, line_height, theme.line_number); + + if (te.text_changed) doc.refreshLineCount(); + return te.text_changed; +} + +const max_text_bytes: usize = 64 * 1024 * 1024; + +fn gutterWidth(line_count: usize, font: dvui.Font) f32 { + var buf: [16]u8 = undefined; + const sample = std.fmt.bufPrint(&buf, "{d}", .{line_count}) catch "9999"; + return font.textSize(sample).w + gutter_pad_x * 2; +} + +fn drawLineNumbers( + rs: dvui.RectScale, + line_count: usize, + scroll_y: f32, + font: dvui.Font, + line_height: f32, + number_color: dvui.Color, +) void { + if (rs.r.empty()) return; + + const prev_clip = dvui.clip(rs.r); + defer dvui.clipSet(prev_clip); + + const first_line: usize = @intCast(@max(0, @as(i64, @intFromFloat((scroll_y - editor_padding.y) / line_height)))); + + var line: usize = first_line; + var y: f32 = editor_padding.y + @as(f32, @floatFromInt(line)) * line_height - scroll_y; + + var num_buf: [32]u8 = undefined; + + while (line < line_count and y < rs.r.h + line_height) : ({ + line += 1; + y += line_height; + }) { + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{line + 1}) catch continue; + const text_size = font.textSize(num_str).scale(rs.s, dvui.Size.Physical); + const x = rs.r.x + rs.r.w - editor_padding.w - text_size.w; + const y_phys = rs.r.y + y * rs.s; + + dvui.renderText(.{ + .font = font, + .text = num_str, + .rs = .{ .r = .{ .x = x, .y = y_phys, .w = text_size.w, .h = text_size.h }, .s = rs.s }, + .color = number_color, + }) catch |err| { + dvui.log.err("line number text: {any}", .{err}); + }; + } +} diff --git a/src/plugins/code/src/Document.zig b/src/plugins/code/src/Document.zig index 19d70a88..8831b3bc 100644 --- a/src/plugins/code/src/Document.zig +++ b/src/plugins/code/src/Document.zig @@ -19,6 +19,8 @@ path: []u8, grouping: u64 = 0, /// File contents. The text-editing widget reads from and writes back to `items`. text: std.ArrayList(u8) = .empty, +/// Cached `\n` count + 1; refreshed on load and when the editor reports edits. +line_count: usize = 1, /// Unsaved-edits flag, set when the editing widget reports a change. dirty: bool = false, @@ -33,11 +35,17 @@ pub fn fromBytes(path: []const u8, bytes: []const u8) !Document { try text.appendSlice(gpa, bytes); const path_copy = try gpa.dupe(u8, path); errdefer gpa.free(path_copy); - return .{ + var doc = Document{ .id = Globals.host.allocDocId(), .path = path_copy, .text = text, }; + doc.refreshLineCount(); + return doc; +} + +pub fn refreshLineCount(self: *Document) void { + self.line_count = if (self.text.items.len == 0) 1 else std.mem.count(u8, self.text.items, "\n") + 1; } /// Build a document by reading `path` from disk. Runs on the shell's load worker thread. diff --git a/src/plugins/code/src/SyntaxHighlight.zig b/src/plugins/code/src/SyntaxHighlight.zig new file mode 100644 index 00000000..1f7c6f74 --- /dev/null +++ b/src/plugins/code/src/SyntaxHighlight.zig @@ -0,0 +1,159 @@ +//! Tree-sitter syntax highlighting for the code editor. +//! +//! Capture names in `queries/zig.scm` mirror vscode-zig / Feppz! TextMate scopes. +//! Colors match the Feppz! theme as shown in VS Code/Cursor. +const std = @import("std"); +const code = @import("../code.zig"); +const dvui = code.dvui; +const wdvui = code.core.dvui; + +const SyntaxHighlight = @This(); + +pub const Language = enum { + plain, + zig, + zon, + json, + atlas, + + pub fn fromPath(path: []const u8) Language { + const ext = std.fs.path.extension(path); + if (std.ascii.eqlIgnoreCase(ext, ".zig")) return .zig; + if (std.ascii.eqlIgnoreCase(ext, ".zon")) return .zon; + if (std.ascii.eqlIgnoreCase(ext, ".json")) return .json; + if (std.ascii.eqlIgnoreCase(ext, ".atlas")) return .atlas; + return .plain; + } +}; + +/// Editor token colors. More specific capture names must appear later in each slice. +pub const Theme = struct { + text: dvui.Color, + line_number: dvui.Color, + zig_highlights: []const wdvui.TextEntryWidget.SyntaxHighlight, + json_highlights: []const wdvui.TextEntryWidget.SyntaxHighlight, +}; + +fn rgb(r: u8, g: u8, b: u8) dvui.Color { + return .{ .r = r, .g = g, .b = b, .a = 255 }; +} + +fn hi(name: []const u8, color: dvui.Color) wdvui.TextEntryWidget.SyntaxHighlight { + return .{ .name = name, .opts = .{ .color_text = color } }; +} + +// Feppz palette (from Feppz!-color-theme.json + vscode-zig scopes) +const fn_green = rgb(0x4d, 0xa5, 0x86); +const type_orange = rgb(0xd8, 0x8e, 0x79); +const var_yellow = rgb(0xd9, 0xc6, 0x79); +const kw_brown = rgb(0x61, 0x53, 0x53); // keyword.default.zig — const, var +const kw_decl = rgb(0x87, 0x65, 0x60); // pub, fn, struct, storage +const kw_pink = rgb(0xce, 0xa4, 0x7f); // if, for, return, orelse, error, … + +pub const feppz: Theme = .{ + .text = rgb(0xdd, 0xdc, 0xd3), + .line_number = rgb(0x58, 0x58, 0x5f), + .zig_highlights = &feppz_zig_highlights, + .json_highlights = &feppz_json_highlights, +}; + +pub const default_theme = feppz; + +const feppz_zig_highlights = [_]wdvui.TextEntryWidget.SyntaxHighlight{ + hi("feppz.comment", rgb(0x57, 0x5b, 0x65)), + hi("feppz.comment.documentation", rgb(0x7a, 0x7a, 0x78)), + + hi("feppz.punctuation", rgb(0x9c, 0x9d, 0x9d)), + hi("feppz.punctuation.round", rgb(0x85, 0x87, 0x8a)), + hi("feppz.punctuation.square", rgb(0x72, 0x75, 0x7b)), + hi("feppz.punctuation.curly", rgb(0x63, 0x67, 0x6f)), + hi("feppz.punctuation.accessor", rgb(0x9c, 0x9d, 0x9d)), + + hi("feppz.operator", rgb(0xb9, 0xb9, 0xb5)), + + hi("feppz.string", rgb(0x60, 0xc0, 0xd2)), + hi("feppz.string.character", rgb(0x60, 0xd2, 0xbe)), + hi("feppz.string.escape", rgb(0x58, 0x8e, 0x9a)), + hi("feppz.number", rgb(0x60, 0x9a, 0xd2)), + hi("feppz.number.float", rgb(0x60, 0x9a, 0xd2)), + + // Variables, namespace path segments (std.mem), struct fields + hi("feppz.variable", var_yellow), + hi("feppz.variable.definition", var_yellow), + hi("feppz.variable.namespace", var_yellow), + hi("feppz.variable.module", var_yellow), + hi("feppz.variable.member", var_yellow), + hi("feppz.variable.enum_member", rgb(0x53, 0x5c, 0x90)), + hi("feppz.variable.builtin", rgb(0x6a, 0x66, 0x56)), + hi("feppz.constant", rgb(0x60, 0x74, 0xd2)), + hi("feppz.label", rgb(0xc8, 0xc8, 0xc8)), + + hi("feppz.entity.name.function", fn_green), + hi("feppz.support.function.builtin", fn_green), + + // Types: PascalCase names, primitives (u32), anyopaque, … + hi("feppz.entity.name.type", type_orange), + hi("feppz.keyword.type", type_orange), + + // Declaration keywords — brown/tan + hi("feppz.keyword.default", kw_brown), + hi("feppz.storage.type.function", kw_decl), + hi("feppz.keyword.structure", kw_decl), + hi("feppz.keyword.storage", kw_decl), + + // Control flow — pink (return, if, for, orelse, error, …) + hi("feppz.keyword.control.flow", kw_pink), + + hi("feppz.keyword.constant.default", rgb(0x53, 0x5c, 0x90)), + hi("feppz.keyword.constant.bool", rgb(0x53, 0x5c, 0x90)), +}; + +const feppz_json_highlights = [_]wdvui.TextEntryWidget.SyntaxHighlight{ + hi("feppz.comment", rgb(0x57, 0x5b, 0x65)), + hi("feppz.number", rgb(0x60, 0x9a, 0xd2)), + hi("feppz.constant", rgb(0x60, 0x74, 0xd2)), + hi("feppz.string", rgb(0x60, 0xc0, 0xd2)), + hi("feppz.string.escape", rgb(0x58, 0x8e, 0x9a)), + hi("feppz.keyword.constant.default", rgb(0x53, 0x5c, 0x90)), + hi("feppz.string.special.key", rgb(0xb6, 0x77, 0x6b)), +}; + +const zig_queries = @embedFile("../queries/zig.scm"); +const json_queries = @embedFile("../queries/json.scm"); + +const TreeSitter = if (dvui.useTreeSitter) struct { + extern fn tree_sitter_zig() callconv(.c) *dvui.c.TSLanguage; + extern fn tree_sitter_json() callconv(.c) *dvui.c.TSLanguage; + + fn option( + language: *dvui.c.TSLanguage, + queries: []const u8, + highlights: []const wdvui.TextEntryWidget.SyntaxHighlight, + ) wdvui.TextEntryWidget.InitOptions.TreeSitterOption { + return .{ + .language = language, + .queries = queries, + .highlights = highlights, + }; + } +} else struct {}; + +pub fn treeSitterOption( + path: []const u8, + theme: Theme, +) ?wdvui.TextEntryWidget.InitOptions.TreeSitterOption { + if (!dvui.useTreeSitter) return null; + return switch (Language.fromPath(path)) { + .zig, .zon => TreeSitter.option( + TreeSitter.tree_sitter_zig(), + zig_queries, + theme.zig_highlights, + ), + .json, .atlas => TreeSitter.option( + TreeSitter.tree_sitter_json(), + json_queries, + theme.json_highlights, + ), + .plain => null, + }; +} diff --git a/src/plugins/code/src/plugin.zig b/src/plugins/code/src/plugin.zig index 62a9d7ee..8557b028 100644 --- a/src/plugins/code/src/plugin.zig +++ b/src/plugins/code/src/plugin.zig @@ -1,4 +1,4 @@ -//! The code editor plugin: owns text documents (`.zig`/`.json`/…) and renders them as +//! The code editor plugin: fallback owner for plain-text documents 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"); @@ -8,6 +8,7 @@ const dvui = code.dvui; const Globals = code.Globals; const State = code.State; const Document = code.Document; +const CodeEditor = code.CodeEditor; const DocHandle = sdk.DocHandle; var plugin: sdk.Plugin = .{ @@ -69,19 +70,12 @@ fn deinit(state: *anyopaque) void { // ---- 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", ".atlas", ".txt", ".md", ".toml", ".yaml", ".yml", - ".glsl", ".c", ".h", ".cpp", ".hpp", ".js", ".ts", ".css", - ".html", ".xml", ".sh", ".py", ".lua", -}; - +/// Fallback text editor: opens any file when no other plugin claims the extension. +/// Pixel-art wins for `.fiz`/`.pixi` (0) and flat images (10); everything else +/// opens here — including extensionless paths and renamed `.txt` → `.foo`. fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 { - for (text_extensions) |e| { - if (std.ascii.eqlIgnoreCase(ext, e)) return 50; - } - return null; + _ = ext; + return sdk.Plugin.file_type_fallback_priority; } // ---- document staging buffer ------------------------------------------------- @@ -161,23 +155,9 @@ fn documentHasRecognizedSaveExtension(_: *anyopaque, _: DocHandle) bool { 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; + if (try CodeEditor.draw(doc, handle.id, Globals.allocator())) { + doc.dirty = true; + } } fn closeDocument(_: *anyopaque, handle: DocHandle) void { @@ -196,8 +176,6 @@ fn documentDefaultSaveAsFilename(_: *anyopaque, handle: DocHandle, allocator: st // ---- helpers ----------------------------------------------------------------- -const max_text_bytes: usize = 64 * 1024 * 1024; - fn docBuf(buf: *anyopaque) *Document { return @ptrCast(@alignCast(buf)); } diff --git a/src/plugins/pixelart/src/Tools.zig b/src/plugins/pixelart/src/Tools.zig index 9f8eb276..1ba3ac69 100644 --- a/src/plugins/pixelart/src/Tools.zig +++ b/src/plugins/pixelart/src/Tools.zig @@ -313,7 +313,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 .font = dvui.Font.theme(.heading), }, .{ - .font = dvui.Font.theme(.mono).larger(-2.0), + .font = dvui.Font.theme(.mono), .margin = dvui.Rect.all(4), }, ); diff --git a/src/plugins/pixelart/src/dialogs/Export.zig b/src/plugins/pixelart/src/dialogs/Export.zig index e28e94f2..a732c52c 100644 --- a/src/plugins/pixelart/src/dialogs/Export.zig +++ b/src/plugins/pixelart/src/dialogs/Export.zig @@ -440,7 +440,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); + const entry_font = dvui.Font.theme(.mono); DimensionsLabel.drawDimensionsLabel(@src(), column_w, row_h, entry_font, "px", .{ .gravity_x = 0.5 }); } diff --git a/src/plugins/pixelart/src/dialogs/NewFile.zig b/src/plugins/pixelart/src/dialogs/NewFile.zig index 6a221ab8..b2c1aad5 100644 --- a/src/plugins/pixelart/src/dialogs/NewFile.zig +++ b/src/plugins/pixelart/src/dialogs/NewFile.zig @@ -40,7 +40,7 @@ pub fn request(parent_path: ?[]const u8, id_extra: usize) void { } pub fn dialog(id: dvui.Id) anyerror!bool { - const entry_font = dvui.Font.theme(.mono).larger(-2); + const entry_font = dvui.Font.theme(.mono); // Touch explorer target path every frame so dvui does not drop it at Window.end before OK. _ = dvui.dataGetSlice(null, id, "_parent_path", []u8); diff --git a/src/plugins/pixelart/src/explorer/sprites.zig b/src/plugins/pixelart/src/explorer/sprites.zig index 888304e5..c431669f 100644 --- a/src/plugins/pixelart/src/explorer/sprites.zig +++ b/src/plugins/pixelart/src/explorer/sprites.zig @@ -1704,6 +1704,7 @@ pub fn drawFrames(self: *Sprites) !void { return; }; + const frame_font = dvui.Font.theme(.mono); const result = dvui.textEntryNumber(@src(), u32, .{ .value = &frame.ms, .min = 0, .max = 9999999 }, .{ .expand = .horizontal, .background = false, @@ -1711,10 +1712,10 @@ pub fn drawFrames(self: *Sprites) !void { .margin = dvui.Rect.all(0), .border = dvui.Rect.all(0), .min_size_content = .{ - .w = dvui.Font.theme(.mono).larger(-2.0).textSize(frame_ms_text).w + 2.0, - .h = dvui.Font.theme(.mono).larger(-2.0).textSize(frame_ms_text).h + 2.0, + .w = frame_font.textSize(frame_ms_text).w + 2.0, + .h = frame_font.textSize(frame_ms_text).h + 2.0, }, - .font = dvui.Font.theme(.mono).larger(-2.0), + .font = frame_font, .gravity_y = 0.5, }); @@ -1731,7 +1732,7 @@ pub fn drawFrames(self: *Sprites) !void { dvui.labelNoFmt(@src(), "ms", .{}, .{ .gravity_y = 0.5, .margin = dvui.Rect.all(0), - .font = dvui.Font.theme(.mono).larger(-4.0), + .font = frame_font, .padding = .{ .x = 2, .w = 6 }, }); diff --git a/src/plugins/pixelart/src/infobar_status.zig b/src/plugins/pixelart/src/infobar_status.zig index 2476d10d..068e6c1f 100644 --- a/src/plugins/pixelart/src/infobar_status.zig +++ b/src/plugins/pixelart/src/infobar_status.zig @@ -16,7 +16,7 @@ fn docFile(st: *State, doc: DocHandle) ?*Internal.File { 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); + const font_mono = dvui.Font.theme(.mono); dvui.icon( @src(), diff --git a/src/plugins/pixelart/src/widgets/FileWidget.zig b/src/plugins/pixelart/src/widgets/FileWidget.zig index af56d986..0936378e 100644 --- a/src/plugins/pixelart/src/widgets/FileWidget.zig +++ b/src/plugins/pixelart/src/widgets/FileWidget.zig @@ -3157,7 +3157,7 @@ pub fn drawTransform(self: *FileWidget) void { // Dimensions and angle labels { - const dim_font = dvui.Font.theme(.mono).larger(-2); + const dim_font = dvui.Font.theme(.mono); if (show_ortho_dims) { const ns = dvui.currentWindow().natural_scale; diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index 0b5081c7..a317a5e6 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -153,6 +153,7 @@ pub fn drawBranchDecorations(self: *Workbench, path: []const u8, id_extra: usize /// tab dirty indicator (`Workspace.zig` ~:528) so the two stay visually consistent. fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void { const doc = Globals.host.docFromPath(path) orelse return; + if (doc.owner.showsSaveStatusIndicator(doc)) 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 69ced3ed..a68c3e0b 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -987,7 +987,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { .draw_focus = false, }, .{ .expand = .horizontal, - .font = dvui.Font.theme(.mono).larger(-2.0), + .font = dvui.Font.theme(.mono), .id_extra = i, .margin = dvui.Rect.all(1), .padding = dvui.Rect.all(2), diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index 26c45b7e..12496b5d 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -772,7 +772,6 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u }; if (Globals.host.docFromPath(abs_path)) |doc| { - const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc); if (doc.owner.showsSaveStatusIndicator(doc)) { wdvui.bubbleSpinner(@src(), .{ .id_extra = inner_id_extra.* +% 4001, @@ -782,20 +781,8 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u .gravity_y = 0.5, .color_text = dvui.themeGet().color(.window, .text), }, .{ - .complete_elapsed_ns = save_flash_elapsed, + .complete_elapsed_ns = doc.owner.timeSinceSaveCompleteNs(doc), }); - } else if (doc.owner.isDirty(doc)) { - _ = dvui.icon( - @src(), - "DirtyIcon", - icons.tvg.lucide.@"circle-small", - .{ .stroke_color = dvui.themeGet().color(.window, .text) }, - .{ - .expand = .none, - .gravity_x = 1.0, - .gravity_y = 0.5, - }, - ); } } diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 7f89686e..39f3b7ff 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -489,8 +489,9 @@ pub fn activeCenter(self: *Host) ?*CenterProvider { return null; } -/// The registered plugin with the highest priority (lowest value) for `ext`, or -/// null if none claims it. Routes file opens to the right plugin. +/// The registered plugin with the highest priority (lowest numeric value) for `ext`, +/// or null if none claims it. Specialized plugins claim known types at low values; +/// the code plugin claims every extension at `Plugin.file_type_fallback_priority`. pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin { var best: ?*Plugin = null; var best_priority: u8 = 255; diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index f5c8b9b5..45dac786 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -14,6 +14,11 @@ const EditorAPI = @import("EditorAPI.zig"); pub const Plugin = @This(); +/// Priority for a plugin that opens any file as plain text when no specialized plugin +/// claims the extension. Must be higher (numerically larger) than every specialized +/// claim so `Host.pluginForExtension` only picks it as a fallback. +pub const file_type_fallback_priority: u8 = 100; + /// Opaque, plugin-owned state passed back to every vtable call. state: *anyopaque, vtable: *const VTable, @@ -35,8 +40,10 @@ pub const VTable = struct { 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`. - /// A plugin may claim many extensions. + /// ".fiz", or `""` when the basename has no extension); lower value wins. + /// `null` = this plugin does not handle `ext`. A plugin may claim many extensions. + /// A text editor may return `file_type_fallback_priority` for every `ext` so it + /// opens anything no other plugin claims. fileTypePriority: ?*const fn (state: *anyopaque, ext: []const u8) ?u8 = null, // ---- document lifecycle (operates on the plugin's own type via DocHandle) ---- From 76fda97f0892cea0d615bf92b4b4971322583779 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 12:30:21 -0500 Subject: [PATCH 45/49] Refine highlighting theme --- src/core/dvui.zig | 10 - src/core/widgets/TextEntryWidget.zig | 1846 ---------------------- src/plugins/code/queries/json.scm | 16 - src/plugins/code/queries/zig.scm | 306 ++-- src/plugins/code/src/CodeEditor.zig | 78 +- src/plugins/code/src/SyntaxHighlight.zig | 195 +-- 6 files changed, 252 insertions(+), 2199 deletions(-) delete mode 100644 src/core/widgets/TextEntryWidget.zig delete mode 100644 src/plugins/code/queries/json.scm diff --git a/src/core/dvui.zig b/src/core/dvui.zig index 97397697..f3b415f8 100644 --- a/src/core/dvui.zig +++ b/src/core/dvui.zig @@ -10,16 +10,6 @@ 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"); -pub const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); - -/// Code-editor `textEntry` with Fizzy-specific chromeless + tree-sitter highlighting. -pub fn textEntry(src: std.builtin.SourceLocation, init_opts: TextEntryWidget.InitOptions, opts: dvui.Options) *TextEntryWidget { - var ret = dvui.widgetAlloc(TextEntryWidget); - ret.init(src, init_opts, opts); - ret.processEvents(); - ret.draw(); - return ret; -} /// 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 diff --git a/src/core/widgets/TextEntryWidget.zig b/src/core/widgets/TextEntryWidget.zig deleted file mode 100644 index 2083dd04..00000000 --- a/src/core/widgets/TextEntryWidget.zig +++ /dev/null @@ -1,1846 +0,0 @@ -const builtin = @import("builtin"); -const std = @import("std"); -const dvui = @import("dvui"); - -const Event = dvui.Event; -const Options = dvui.Options; -const Rect = dvui.Rect; -const RectScale = dvui.RectScale; -const ScrollInfo = dvui.ScrollInfo; -const Size = dvui.Size; -const Widget = dvui.Widget; -const WidgetData = dvui.WidgetData; -const ScrollAreaWidget = dvui.ScrollAreaWidget; -const TextLayoutWidget = dvui.TextLayoutWidget; -const AccessKit = dvui.AccessKit; - -const TextEntryWidget = @This(); - -/// If min_size_content is not given, use Font.sizeM(defaultMWidth, 1). -/// If multiline is false and max_size_content is not given, use min_size_content. -pub var defaultMWidth: f32 = 14; - -pub var defaults: Options = .{ - .name = "TextEntry", - .role = .text_input, // can change to multiline in init - .margin = Rect.all(4), - .corner_radius = Rect.all(5), - .border = Rect.all(1), - .padding = Rect.all(6), - .background = true, - .style = .content, - // min_size_content/max_size_content is calculated in init() -}; - -const realloc_bin_size = 100; - -pub const SyntaxHighlight = struct { - name: []const u8, - opts: dvui.Options, - - pub const Match = struct { - opts: dvui.Options = .{}, - specificity: u16 = 0, - }; - - /// Longest `highlights` entry whose name is a prefix of `capture_name`. - pub fn optsForCapture(capture_name: []const u8, highlights: []const SyntaxHighlight) Match { - var best: Match = .{}; - for (0..highlights.len) |i| { - const sh = highlights[highlights.len - i - 1]; - if (std.mem.startsWith(u8, capture_name, sh.name) and sh.name.len > best.specificity) { - best = .{ .opts = sh.opts, .specificity = @intCast(sh.name.len) }; - } - } - return best; - } -}; - -/// Tree-sitter 0.27+ leaves `#match?` / `#eq?` / … to the host. Without this, -/// every `(identifier)` pattern matches every identifier regardless of predicates. -const QueryPredicates = if (dvui.useTreeSitter) struct { - const Arg = union(enum) { - capture: u32, - string: []const u8, - }; - - fn captureTextInMatch(match: dvui.c.TSQueryMatch, capture_id: u32, text: []const u8) ?[]const u8 { - var i: u32 = 0; - while (i < match.capture_count) : (i += 1) { - const cap = match.captures[i]; - if (cap.index == capture_id) { - const start = dvui.c.ts_node_start_byte(cap.node); - const end = dvui.c.ts_node_end_byte(cap.node); - return text[start..end]; - } - } - return null; - } - - fn queryStringValue(query: *const dvui.c.TSQuery, id: u32) []const u8 { - var len: u32 = undefined; - const ptr = dvui.c.ts_query_string_value_for_id(query, id, &len); - return ptr[0..len]; - } - - fn isIdentChar(ch: u8) bool { - return (ch >= 'a' and ch <= 'z') or (ch >= 'A' and ch <= 'Z') or (ch >= '0' and ch <= '9') or ch == '_'; - } - - fn isMatchRegex(text: []const u8, pattern: []const u8) bool { - if (std.mem.eql(u8, pattern, "^[A-Z_][a-zA-Z0-9_]*")) { - if (text.len == 0) return false; - const c0 = text[0]; - if (c0 != '_' and (c0 < 'A' or c0 > 'Z')) return false; - for (text[1..]) |ch| if (!isIdentChar(ch)) return false; - return true; - } - if (std.mem.eql(u8, pattern, "^[a-z_][a-zA-Z0-9_]*")) { - if (text.len == 0) return false; - const c0 = text[0]; - if (c0 != '_' and (c0 < 'a' or c0 > 'z')) return false; - for (text[1..]) |ch| if (!isIdentChar(ch)) return false; - return true; - } - if (std.mem.eql(u8, pattern, "^[A-Z][A-Z_0-9]+$")) { - if (text.len == 0) return false; - if (text[0] < 'A' or text[0] > 'Z') return false; - for (text[1..]) |ch| { - if ((ch >= 'A' and ch <= 'Z') or (ch >= '0' and ch <= '9') or ch == '_') continue; - return false; - } - return true; - } - if (std.mem.startsWith(u8, pattern, "^") and std.mem.endsWith(u8, pattern, "$") and pattern.len > 2) { - return std.mem.eql(u8, text, pattern[1 .. pattern.len - 1]); - } - if (std.mem.startsWith(u8, pattern, "^")) { - return std.mem.startsWith(u8, text, pattern[1..]); - } - return std.mem.eql(u8, text, pattern); - } - - fn evalPredicate( - name: []const u8, - args: []const Arg, - match: dvui.c.TSQueryMatch, - text: []const u8, - ) bool { - if (std.mem.eql(u8, name, "#set!")) return true; - - if (std.mem.eql(u8, name, "#match?") or std.mem.eql(u8, name, "#lua-match?")) { - if (args.len < 2) return true; - const cap_text = switch (args[0]) { - .capture => |id| captureTextInMatch(match, id, text) orelse return false, - else => return false, - }; - const pattern = switch (args[1]) { - .string => |s| s, - else => return false, - }; - return isMatchRegex(cap_text, pattern); - } - - if (std.mem.eql(u8, name, "#eq?")) { - if (args.len < 2) return true; - const a = switch (args[0]) { - .capture => |id| captureTextInMatch(match, id, text) orelse return false, - .string => |s| s, - }; - const b = switch (args[1]) { - .capture => |id| captureTextInMatch(match, id, text) orelse return false, - .string => |s| s, - }; - return std.mem.eql(u8, a, b); - } - - if (std.mem.eql(u8, name, "#any-of?")) { - if (args.len < 2) return true; - const cap_text = switch (args[0]) { - .capture => |id| captureTextInMatch(match, id, text) orelse return false, - else => return false, - }; - for (args[1..]) |arg| { - switch (arg) { - .string => |s| if (std.mem.eql(u8, cap_text, s)) return true, - else => {}, - } - } - return false; - } - - return true; - } - - pub fn patternMatches( - query: *const dvui.c.TSQuery, - pattern_index: u16, - match: dvui.c.TSQueryMatch, - text: []const u8, - ) bool { - var step_count: u32 = 0; - const steps = dvui.c.ts_query_predicates_for_pattern(query, pattern_index, &step_count); - if (step_count == 0) return true; - - var i: u32 = 0; - while (i < step_count) { - const first = steps[i]; - if (first.type != dvui.c.TSQueryPredicateStepTypeString) { - i += 1; - continue; - } - const pred_name = queryStringValue(query, first.value_id); - i += 1; - - var args: [16]Arg = undefined; - var arg_count: usize = 0; - while (i < step_count and steps[i].type != dvui.c.TSQueryPredicateStepTypeDone) { - const step = steps[i]; - i += 1; - if (arg_count >= args.len) break; - switch (step.type) { - dvui.c.TSQueryPredicateStepTypeCapture => { - args[arg_count] = .{ .capture = step.value_id }; - arg_count += 1; - }, - dvui.c.TSQueryPredicateStepTypeString => { - args[arg_count] = .{ .string = queryStringValue(query, step.value_id) }; - arg_count += 1; - }, - else => {}, - } - } - if (i < step_count and steps[i].type == dvui.c.TSQueryPredicateStepTypeDone) i += 1; - - if (!evalPredicate(pred_name, args[0..arg_count], match, text)) return false; - } - return true; - } -} else struct { - pub fn patternMatches(_: *const dvui.c.TSQuery, _: u16, _: dvui.c.TSQueryMatch, _: []const u8) bool { - return true; - } -}; - -pub const TreeSitterParser = if (dvui.useTreeSitter) struct { - parser: *dvui.c.TSParser, - tree: *dvui.c.TSTree, - query: *dvui.c.TSQuery, - - pub fn deinit(ptr: *anyopaque) void { - const self: *@This() = @ptrCast(@alignCast(ptr)); - - dvui.c.ts_query_delete(self.query); - dvui.c.ts_tree_delete(self.tree); - dvui.c.ts_parser_delete(self.parser); - } - - pub fn queryCursorCaptureIterator(self: *const TreeSitterParser, qc: *dvui.c.TSQueryCursor, text: []const u8) QueryCursorCaptureIterator { - return .{ - .query_cursor = qc, - .prev_match = null, - .query = self.query, - .text = text, - }; - } - - pub const QueryCursorCaptureIterator = struct { - pub const Match = struct { - iter: *const QueryCursorCaptureIterator, - node: dvui.c.TSNode, - capture_index: u32, - - pub fn captureName(self: *const Match) []const u8 { - var len: u32 = undefined; - const name = dvui.c.ts_query_capture_name_for_id(self.iter.query, self.capture_index, &len); - return name[0..len]; - } - - pub fn debugLog(self: *const Match, comptime kind: []const u8) void { - const start = dvui.c.ts_node_start_byte(self.node); - const end = dvui.c.ts_node_end_byte(self.node); - dvui.log.debug(kind ++ " capture @{s} : {s}", .{ self.captureName(), self.iter.text[start..end] }); - } - }; - - query_cursor: *dvui.c.TSQueryCursor, - prev_match: ?Match, - - // used for debugging - debug: bool = false, - query: *dvui.c.TSQuery, - text: []const u8, - - pub fn next(self: *QueryCursorCaptureIterator) ?Match { - var match: dvui.c.TSQueryMatch = undefined; - var captureIdx: u32 = undefined; - loop: while (dvui.c.ts_query_cursor_next_capture(self.query_cursor, &match, &captureIdx)) { - const capture = match.captures[captureIdx]; - if (self.prev_match) |pm| { - if (dvui.c.ts_node_eq(pm.node, capture.node)) { - // same node as previous - self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; - if (self.debug) self.prev_match.?.debugLog("ts same "); - continue :loop; - } - - // not the same - const ret = self.prev_match; - self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; - if (self.debug) self.prev_match.?.debugLog("ts new "); - return ret; - } else { - // first time - self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; - if (self.debug) self.prev_match.?.debugLog("ts first"); - continue :loop; - } - } - - const ret = self.prev_match; - if (ret) |r| { - if (self.debug) r.debugLog("ts last "); - } - self.prev_match = null; - return ret; - } - }; -} else void; - -pub const InitOptions = struct { - pub const TextOption = union(enum) { - /// Use this slice of bytes, cannot add more. - buffer: []u8, - - /// Use and grow with realloc and shrink with resize as needed. - buffer_dynamic: struct { - backing: *[]u8, - allocator: std.mem.Allocator, - limit: usize = 10_000, - }, - - /// Use std.ArrayList(u8). The limit is total characters, the - /// arraylist might allocate more capacity. ArrayList.items is updated - /// in deinit() (file an issue if this is a problem). - array_list: struct { - backing: *std.ArrayList(u8), - allocator: std.mem.Allocator, - limit: usize = 10_000, - }, - - /// Use internal buffer up to limit. - /// - use getText() to get contents. - internal: struct { - limit: usize = 10_000, - }, - }; - - pub const TreeSitterOption = if (dvui.useTreeSitter) struct { - language: *dvui.c.TSLanguage, - queries: []const u8, - highlights: []const SyntaxHighlight, - /// If true dump all captures to dvui.log.debug - log_captures: bool = false, - } else void; - - text: TextOption = .{ .internal = .{} }, - tree_sitter: ?TreeSitterOption = null, - /// Faded text shown when the textEntry is empty - placeholder: ?[]const u8 = null, - - /// If true, assume text (and text height) is the same (excepting edits we - /// do internally) as we saw last frame and only process what is needed for - /// visibility (and copy). - cache_layout: bool = false, - - /// When false, skip the themed focus ring around the widget border. - show_focus_border: bool = true, - - break_lines: bool = false, - kerning: ?bool = null, - scroll_vertical: ?bool = null, // default is value of multiline - scroll_vertical_bar: ?ScrollInfo.ScrollBarMode = null, // default .auto - scroll_horizontal: ?bool = null, // default true - scroll_horizontal_bar: ?ScrollInfo.ScrollBarMode = null, // default .auto if multiline, .hide if not - - // must be a single utf8 character - password_char: ?[]const u8 = null, - multiline: bool = false, -}; - -wd: WidgetData, -prevClip: Rect.Physical = undefined, -scroll: ScrollAreaWidget = undefined, -scrollClip: Rect.Physical = undefined, -textLayout: TextLayoutWidget = undefined, -textClip: Rect.Physical = undefined, -padding: Rect, - -init_opts: InitOptions, -text: []u8, -len: usize, -enter_pressed: bool = false, // not valid if multiline -text_changed: bool = false, - -// see textChanged() -text_changed_start: usize = std.math.maxInt(usize), -text_changed_end: usize = 0, // index of bytes before edits (so matches previous frame) -text_changed_added: i64 = 0, // bytes added -edited_outside_last_frame: *bool = undefined, - -/// It's expected to call this when `self` is `undefined` -pub fn init(self: *TextEntryWidget, src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) void { - var scroll_init_opts = ScrollAreaWidget.InitOpts{ - .vertical = if (init_opts.scroll_vertical orelse init_opts.multiline) .auto else .none, - .vertical_bar = init_opts.scroll_vertical_bar orelse .auto, - .horizontal = if (init_opts.scroll_horizontal orelse true) .auto else .none, - .horizontal_bar = init_opts.scroll_horizontal_bar orelse (if (init_opts.multiline) .auto else .hide), - }; - - var options = defaults.themeOverride(opts.theme).min_sizeM(defaultMWidth, 1); - - if (init_opts.password_char != null) { - options.role = .password_input; - } else if (init_opts.multiline) { - options.role = .multiline_text_input; - } - - options = options.override(opts); - if (!init_opts.multiline and options.max_size_content == null) { - options = options.override(.{ .max_size_content = .size(options.min_size_contentGet()) }); - } - - // padding is interpreted as the padding for the TextLayoutWidget, but - // we also need to add it to content size because TextLayoutWidget is - // inside the scroll area - const padding = options.paddingGet(); - options.padding = null; - options.min_size_content.?.w += padding.x + padding.w; - options.min_size_content.?.h += padding.y + padding.h; - if (options.max_size_content != null) { - options.max_size_content.?.w += padding.x + padding.w; - options.max_size_content.?.h += padding.y + padding.h; - } - - const wd = WidgetData.init(src, .{}, options); - scroll_init_opts.focus_id = wd.id; - - var text: []u8 = undefined; - var find_zero = true; - var len_utf8_boundary: usize = undefined; - switch (init_opts.text) { - .buffer => |b| text = b, - .buffer_dynamic => |b| text = b.backing.*, - .internal => text = dvui.dataGetSliceDefault(null, wd.id, "_buffer", []u8, &.{}), - .array_list => |al| { - find_zero = false; - text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; - len_utf8_boundary = dvui.findUtf8Start(text, al.backing.items.len); - }, - } - - if (find_zero) { - const len_byte = std.mem.findScalar(u8, text, 0) orelse text.len; - len_utf8_boundary = dvui.findUtf8Start(text[0..len_byte], len_byte); - } - - self.* = .{ - .wd = wd, - .padding = padding, - .init_opts = init_opts, - .text = text, - .len = len_utf8_boundary, - - // SAFETY: The following fields are set bellow - .prevClip = undefined, - .scroll = undefined, - .scrollClip = undefined, - .textLayout = undefined, - .textClip = undefined, - }; - - self.data().register(); - - dvui.tabIndexSet(self.data().id, self.data().options.tab_index, self.data().rectScale().r); - - dvui.parentSet(self.widget()); - - if (self.data().options.backgroundGet() or self.data().options.borderGet().nonZero()) { - self.data().borderAndBackground(.{}); - } - - self.prevClip = dvui.clip(self.data().borderRectScale().r); - const borderClip = dvui.clipGet(); - - // We do this dance with last_focused_id_this_frame so scroll will process - // key events we skip (like page up/down). Normally it would not (text - // entry is not a child of scroll). So with this we make scroll think that - // text entry ran as a child. - const focused = (self.data().id == dvui.lastFocusedIdInFrame()); - if (focused) dvui.currentWindow().last_focused_id_this_frame = .zero; - - // scrollbars process mouse events here - self.scroll.init(@src(), scroll_init_opts, self.data().options.strip().override(.{ - .role = .none, - .expand = .both, - .background = false, - .border = Rect{}, - .corner_radius = Rect{}, - .ninepatch_fill = &dvui.Ninepatch.none, - .ninepatch_hover = &dvui.Ninepatch.none, - .ninepatch_press = &dvui.Ninepatch.none, - })); - - if (focused) dvui.currentWindow().last_focused_id_this_frame = self.data().id; - - self.scrollClip = dvui.clipGet(); - - self.edited_outside_last_frame = dvui.dataGetPtrDefault(null, self.data().id, "_edited_outside", bool, false); - if (self.init_opts.cache_layout and self.edited_outside_last_frame.*) { - dvui.log.debug("TextEntryWidget forcing cache_layout false due to text being edited after drawing last frame", .{}); - self.init_opts.cache_layout = false; - self.edited_outside_last_frame.* = false; - self.text_changed = true; // trigger tree_sitter full reparse - } - - self.textLayout.init(@src(), .{ - .break_lines = self.init_opts.break_lines, - .kerning = self.init_opts.kerning, - .touch_edit_just_focused = false, - .cache_layout = self.init_opts.cache_layout, - .focused = self.data().id == dvui.focusedWidgetId(), - .show_touch_draggables = (self.len > 0), - }, self.data().options.strip().override(.{ - .role = .none, - .expand = .both, - .padding = self.padding, - .background = false, - .border = Rect{}, - .corner_radius = Rect{}, - .ninepatch_fill = &dvui.Ninepatch.none, - .ninepatch_hover = &dvui.Ninepatch.none, - .ninepatch_press = &dvui.Ninepatch.none, - })); - - // if textLayout forced cache_layout to false, we need to honor that - self.init_opts.cache_layout = self.textLayout.cache_layout; - - self.textClip = dvui.clipGet(); - - if (self.textLayout.touchEditing()) |floating_widget| { - defer floating_widget.deinit(); - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .corner_radius = dvui.ButtonWidget.defaults.themeOverride(opts.theme).corner_radiusGet(), - .background = true, - .border = dvui.Rect.all(1), - }); - defer hbox.deinit(); - - if (dvui.buttonIcon(@src(), "paste", dvui.entypo.clipboard, .{}, .{}, .{ - .min_size_content = .{ .h = 20 }, - .margin = Rect.all(2), - })) { - self.paste(); - } - - if (dvui.buttonIcon(@src(), "select all", dvui.entypo.swap, .{}, .{}, .{ - .min_size_content = .{ .h = 20 }, - .margin = Rect.all(2), - })) { - self.textLayout.selection.selectAll(); - } - - if (dvui.buttonIcon(@src(), "cut", dvui.entypo.scissors, .{}, .{}, .{ - .min_size_content = .{ .h = 20 }, - .margin = Rect.all(2), - })) { - self.cut(); - } - - if (dvui.buttonIcon(@src(), "copy", dvui.entypo.copy, .{}, .{}, .{ - .min_size_content = .{ .h = 20 }, - .margin = Rect.all(2), - })) { - self.copy(); - } - } - - // don't call textLayout.processEvents here, we forward events inside our own processEvents - - // textLayout is maintaining the selection for us, but if the text - // changed, we need to update the selection to be valid before we - // process any events - var sel = self.textLayout.selection; - sel.start = dvui.findUtf8Start(self.text[0..self.len], sel.start); - sel.cursor = dvui.findUtf8Start(self.text[0..self.len], sel.cursor); - sel.end = dvui.findUtf8Start(self.text[0..self.len], sel.end); - - // textLayout clips to its content, but we need to get events out to our border - dvui.clipSet(borderClip); - if (self.data().accesskit_node()) |ak_node| { - AccessKit.nodeAddAction(ak_node, AccessKit.Action.focus); - AccessKit.nodeAddAction(ak_node, AccessKit.Action.set_value); - AccessKit.nodeAddAction(ak_node, AccessKit.Action.set_text_selection); - AccessKit.nodeAddAction(ak_node, AccessKit.Action.replace_selected_text); - AccessKit.nodeAddAction(ak_node, AccessKit.Action.scroll_into_view); // AK TODO - not yet implemented - AccessKit.nodeSetClipsChildren(ak_node); // AK TODO: Check this is correct? - - if (self.data().options.role != .password_input) { - const str = self.text[0..self.len]; - AccessKit.nodeSetValueWithLength(ak_node, str.ptr, str.len); - } - } -} - -pub fn matchEvent(self: *TextEntryWidget, e: *Event) bool { - // textLayout could be passively listening to events in matchEvent, so - // don't short circuit - const match1 = dvui.eventMatchSimple(e, self.data()); - const match2 = self.scroll.scroll.?.matchEvent(e); - const match3 = self.textLayout.matchEvent(e); - return match1 or match2 or match3; -} - -pub fn processEvents(self: *TextEntryWidget) void { - const evts = dvui.events(); - for (evts) |*e| { - if (!self.matchEvent(e)) - continue; - - self.processEvent(e); - } -} - -pub fn draw(self: *TextEntryWidget) void { - self.drawBeforeText(); - - if (self.len == 0) { - if (self.init_opts.placeholder) |placeholder| { - if (self.data().accesskit_node()) |ak_node| { - AccessKit.nodeSetPlaceholderWithLength(ak_node, placeholder.ptr, placeholder.len); - - // Create an empty text run for the empty text entry. - dvui.currentWindow().accesskit.text_run_parent = self.data().id; - self.textLayout.textRunCreateEmpty(self.data().id, true); - // prevent textLayout from making a text run for the placeholder text - dvui.currentWindow().accesskit.text_run_parent = null; - } - self.textLayout.addText(placeholder, .{ .color_text = self.textLayout.data().options.color(.text).opacity(0.65) }); - } - } - - if (dvui.accesskit_enabled) { - // parent text runs to us - dvui.currentWindow().accesskit.text_run_parent = self.data().id; - } - - if (self.init_opts.password_char) |pc| { - { - // adjust selection for obfuscation - var count: usize = 0; - var bytes: usize = 0; - var sel = self.textLayout.selection; - var sstart: ?usize = null; - var scursor: ?usize = null; - var send: ?usize = null; - var utf8it = (std.unicode.Utf8View.initUnchecked(self.text[0..self.len])).iterator(); - while (utf8it.nextCodepoint()) |codepoint| { - if (sstart == null and sel.start == bytes) sstart = count * pc.len; - if (scursor == null and sel.cursor == bytes) scursor = count * pc.len; - if (send == null and sel.end == bytes) send = count * pc.len; - count += 1; - bytes += std.unicode.utf8CodepointSequenceLength(codepoint) catch unreachable; - } else { - if (sstart == null and sel.start >= bytes) sstart = count * pc.len; - if (scursor == null and sel.cursor >= bytes) scursor = count * pc.len; - if (send == null and sel.end >= bytes) send = count * pc.len; - } - sel.start = sstart.?; - sel.cursor = scursor.?; - sel.end = send.?; - const password_str: ?[]u8 = dvui.currentWindow().lifo().alloc(u8, count * pc.len) catch null; - if (password_str) |pstr| { - defer dvui.currentWindow().lifo().free(pstr); - for (0..count) |i| { - for (0..pc.len) |pci| { - pstr[i * pc.len + pci] = pc[pci]; - } - } - self.textLayout.addText(pstr, self.data().options.strip()); - } else { - dvui.log.warn("Could not allocate password_str, falling back to one single password_str", .{}); - self.textLayout.addText(pc, self.data().options.strip()); - } - } - - self.textLayout.addTextDone(self.data().options.strip()); - - { - // reset selection - var count: usize = 0; - var bytes: usize = 0; - var sel = self.textLayout.selection; - var sstart: ?usize = null; - var scursor: ?usize = null; - var send: ?usize = null; - // NOTE: We assume that all text in the area it valid utf8, loop with exit early on invalid utf8 - var utf8it = (std.unicode.Utf8View.initUnchecked(self.text[0..self.len])).iterator(); - while (utf8it.nextCodepoint()) |codepoint| { - if (sstart == null and sel.start == count * pc.len) sstart = bytes; - if (scursor == null and sel.cursor == count * pc.len) scursor = bytes; - if (send == null and sel.end == count * pc.len) send = bytes; - count += 1; - bytes += std.unicode.utf8CodepointSequenceLength(codepoint) catch unreachable; - } else { - if (sstart == null and sel.start >= count * pc.len) sstart = bytes; - if (scursor == null and sel.cursor >= count * pc.len) scursor = bytes; - if (send == null and sel.end >= count * pc.len) send = bytes; - } - sel.start = sstart.?; - sel.cursor = scursor.?; - sel.end = send.?; - } - - self.drawAfterText(); - return; - } - - if (dvui.useTreeSitter) { - if (self.init_opts.tree_sitter) |ts| { - // syntax highlighting - const parser = dvui.dataGetPtr(null, self.data().id, "parser", TreeSitterParser) orelse blk: { - const p = dvui.c.ts_parser_new(); - _ = dvui.c.ts_parser_set_language(p, ts.language); - const tree = dvui.c.ts_parser_parse_string(p, null, self.text.ptr, @intCast(self.len)); - - var errorOffset: u32 = undefined; - var errorType: dvui.c.TSQueryError = undefined; - const query = dvui.c.ts_query_new(ts.language, ts.queries.ptr, @intCast(ts.queries.len), &errorOffset, &errorType); - if (query == null) { - dvui.log.err("TextEntryWidget tree-sitter query failed at offset {d}: {any}", .{ errorOffset, errorType }); - dvui.c.ts_tree_delete(tree); - dvui.c.ts_parser_delete(p); - break :blk null; - } - - const parser_state: TreeSitterParser = .{ .parser = p.?, .tree = tree.?, .query = query.? }; - dvui.dataSet(null, self.data().id, "parser", parser_state); - dvui.dataSetDeinitFunction(null, self.data().id, "parser", &TreeSitterParser.deinit); - break :blk dvui.dataGetPtr(null, self.data().id, "parser", TreeSitterParser).?; - }; - - if (parser == null) { - self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); - self.textLayout.addTextDone(self.data().options.strip()); - self.drawAfterText(); - return; - } - const ts_parser = parser.?; - - if (self.text_changed and !dvui.firstFrame(self.data().id)) { - if (self.init_opts.cache_layout) { - var edit: dvui.c.TSInputEdit = undefined; - edit.start_byte = @intCast(self.text_changed_start); - edit.old_end_byte = @intCast(self.text_changed_end); - edit.new_end_byte = @intCast(@as(i64, @intCast(self.text_changed_end)) + self.text_changed_added); - - edit.start_point = .{ .row = 0, .column = 0 }; - edit.old_end_point = .{ .row = 0, .column = 0 }; - edit.new_end_point = .{ .row = 0, .column = 0 }; - - dvui.c.ts_tree_edit(ts_parser.tree, &edit); - - const tree = dvui.c.ts_parser_parse_string(ts_parser.parser, ts_parser.tree, self.text.ptr, @intCast(self.len)); - dvui.c.ts_tree_delete(ts_parser.tree); - ts_parser.tree = tree.?; - } else { - const tree = dvui.c.ts_parser_parse_string(ts_parser.parser, null, self.text.ptr, @intCast(self.len)); - dvui.c.ts_tree_delete(ts_parser.tree); - ts_parser.tree = tree.?; - } - } - - // parsing - const root = dvui.c.ts_tree_root_node(ts_parser.tree); - - // queries - const qc = dvui.c.ts_query_cursor_new(); - defer dvui.c.ts_query_cursor_delete(qc); - - if (self.textLayout.cache_layout_bytes) |clb| { - _ = dvui.c.ts_query_cursor_set_byte_range(qc, @intCast(clb.start), @intCast(clb.end)); - } - - dvui.c.ts_query_cursor_exec(qc, ts_parser.query, root); - - const range_start: usize = if (self.textLayout.cache_layout_bytes) |clb| @min(self.len, clb.start) else 0; - const range_end: usize = if (self.textLayout.cache_layout_bytes) |clb| @min(self.len, clb.end) else self.len; - const range_len = range_end - range_start; - - const default_opts = self.data().options.strip(); - - if (range_start > 0) { - self.textLayout.addText(self.text[0..range_start], default_opts); - } - - if (range_len > 0) { - const lifo = dvui.currentWindow().lifo(); - - const CaptureSpan = struct { - start: usize, - end: usize, - capture_index: u32, - specificity: u16, - }; - - var spans: std.ArrayListUnmanaged(CaptureSpan) = .empty; - defer spans.deinit(lifo); - - var match: dvui.c.TSQueryMatch = undefined; - var capture_idx: u32 = undefined; - while (dvui.c.ts_query_cursor_next_capture(qc, &match, &capture_idx)) { - if (!QueryPredicates.patternMatches(ts_parser.query, match.pattern_index, match, self.text)) continue; - - const cap = match.captures[capture_idx]; - const span_start = dvui.c.ts_node_start_byte(cap.node); - const span_end = dvui.c.ts_node_end_byte(cap.node); - if (span_end <= range_start or span_start >= range_end) continue; - - var cap_len: u32 = undefined; - const cap_name = dvui.c.ts_query_capture_name_for_id(ts_parser.query, cap.index, &cap_len); - const highlight = SyntaxHighlight.optsForCapture(cap_name[0..cap_len], ts.highlights); - if (highlight.specificity == 0) continue; - - if (ts.log_captures) { - dvui.log.debug("ts capture @{s} : {s}", .{ cap_name[0..cap_len], self.text[span_start..span_end] }); - } - - spans.append(lifo, .{ - .start = span_start, - .end = span_end, - .capture_index = cap.index, - .specificity = highlight.specificity, - }) catch { - dvui.log.err("tree-sitter highlight span alloc failed", .{}); - break; - }; - } - - const best_spec = lifo.alloc(u16, range_len) catch { - dvui.log.err("tree-sitter highlight buffer alloc failed", .{}); - self.textLayout.addText(self.text[range_start..range_end], default_opts); - if (range_end < self.len) self.textLayout.addText(self.text[range_end..self.len], default_opts); - self.textLayout.addTextDone(default_opts); - self.drawAfterText(); - return; - }; - const best_span_len = lifo.alloc(u16, range_len) catch { - dvui.log.err("tree-sitter highlight buffer alloc failed", .{}); - self.textLayout.addText(self.text[range_start..range_end], default_opts); - if (range_end < self.len) self.textLayout.addText(self.text[range_end..self.len], default_opts); - self.textLayout.addTextDone(default_opts); - self.drawAfterText(); - return; - }; - const best_cap = lifo.alloc(u32, range_len) catch { - dvui.log.err("tree-sitter highlight buffer alloc failed", .{}); - self.textLayout.addText(self.text[range_start..range_end], default_opts); - if (range_end < self.len) self.textLayout.addText(self.text[range_end..self.len], default_opts); - self.textLayout.addTextDone(default_opts); - self.drawAfterText(); - return; - }; - @memset(best_spec, 0); - @memset(best_span_len, std.math.maxInt(u16)); - @memset(best_cap, std.math.maxInt(u32)); - - for (spans.items) |span| { - const apply_start = @max(span.start, range_start); - const apply_end = @min(span.end, range_end); - const span_len: u16 = @intCast(@min(apply_end - apply_start, std.math.maxInt(u16))); - var b = apply_start; - while (b < apply_end) : (b += 1) { - const i = b - range_start; - if (span.specificity > best_spec[i] or - (span.specificity == best_spec[i] and span_len < best_span_len[i])) - { - best_spec[i] = span.specificity; - best_span_len[i] = span_len; - best_cap[i] = span.capture_index; - } - } - } - - var run: usize = 0; - while (run < range_len) { - const spec = best_spec[run]; - const cap_idx = best_cap[run]; - var run_end = run + 1; - while (run_end < range_len and best_spec[run_end] == spec and best_cap[run_end] == cap_idx) : (run_end += 1) {} - - const abs_start = range_start + run; - const abs_end = range_start + run_end; - var opts = default_opts; - if (spec > 0 and cap_idx != std.math.maxInt(u32)) { - var cap_len: u32 = undefined; - const cap_name = dvui.c.ts_query_capture_name_for_id(ts_parser.query, cap_idx, &cap_len); - opts = SyntaxHighlight.optsForCapture(cap_name[0..cap_len], ts.highlights).opts; - } - self.textLayout.addText(self.text[abs_start..abs_end], opts); - run = run_end; - } - } - - if (range_end < self.len) { - self.textLayout.addText(self.text[range_end..self.len], default_opts); - } - - self.textLayout.addTextDone(default_opts); - self.drawAfterText(); - return; - } - } - - // simple text - self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); - self.textLayout.addTextDone(self.data().options.strip()); - - self.drawAfterText(); -} - -pub fn drawBeforeText(self: *TextEntryWidget) void { - const focused = (self.data().id == dvui.focusedWidgetId()); - - if (focused) { - dvui.wantTextInput(self.data().borderRectScale().r.toNatural()); - } - - // set clip back to what textLayout had, so we don't draw over the scrollbars - dvui.clipSet(self.textClip); - - if (self.init_opts.cache_layout) { - self.textLayout.cache_layout_bytes = self.textLayout.bytesNeeded( - self.text_changed_start, - self.text_changed_end, - self.text_changed_added, - ); - } -} - -pub fn drawAfterText(self: *TextEntryWidget) void { - const focused = (self.data().id == dvui.focusedWidgetId()); - if (focused) { - self.drawCursor(); - } - - dvui.clipSet(self.prevClip); - - if (focused and self.init_opts.show_focus_border) { - self.data().focusBorder(); - } -} - -pub fn drawCursor(self: *TextEntryWidget) void { - var sel = self.textLayout.selectionGet(self.len); - if (sel.empty()) { - // the cursor can be slightly outside the textLayout clip - dvui.clipSet(self.scrollClip); - - var crect = self.textLayout.cursor_rect.plus(.{ .x = -1 }); - crect.w = 2; - self.textLayout.screenRectScale(crect).r.fill(.{}, .{ .color = dvui.themeGet().focus, .fade = 1.0 }); - } -} - -pub fn widget(self: *TextEntryWidget) Widget { - return Widget.init(self, data, rectFor, screenRectScale, minSizeForChild); -} - -pub fn data(self: *TextEntryWidget) *WidgetData { - return self.wd.validate(); -} - -pub fn rectFor(self: *TextEntryWidget, id: dvui.Id, min_size: Size, e: Options.Expand, g: Options.Gravity) Rect { - _ = id; - return dvui.placeIn(self.data().contentRect().justSize(), min_size, e, g); -} - -pub fn screenRectScale(self: *TextEntryWidget, rect: Rect) RectScale { - return self.data().contentRectScale().rectToRectScale(rect); -} - -pub fn minSizeForChild(self: *TextEntryWidget, s: Size) void { - self.data().minSizeMax(self.data().options.padSize(s)); -} - -pub fn textChangedRemoved(self: *TextEntryWidget, start: usize, end: usize) void { - self.textChanged(start, end, @as(i64, @intCast(start)) - @as(i64, @intCast(end))); -} - -// Inserting text is at a single point in the previous frame's indexing. -pub fn textChangedAdded(self: *TextEntryWidget, pos: usize, added: usize) void { - self.textChanged(pos, pos, @intCast(added)); -} - -// Only needed when cache_layout is true. We are maintaining an interval of -// bytes from last frame plus a total number added (might be negative) in that -// interval. This is sent to textLayout so it will process at least this -// interval (plus whatever is visible). -pub fn textChanged(self: *TextEntryWidget, start: usize, end: usize, added: i64) void { - self.text_changed = true; - if (end > self.text_changed_start) { - // end is in current bytes, so we update it to previous frame's indexing - var end_old: usize = undefined; - if (self.text_changed_added >= 0) { - end_old = end - @as(usize, @intCast(self.text_changed_added)); - } else { - end_old = end + @as(usize, @intCast(-self.text_changed_added)); - } - // This assumes that the current update happens after (in bytes) all - // previous updates. This is not exact, but will always give an - // interval that includes all the updates. - self.text_changed_end = @max(self.text_changed_end, end_old); - } else { - // before previous updates then indexing is the same - self.text_changed_end = @max(self.text_changed_end, end); - } - - // if we are before the previous updates then the indexing is the same - self.text_changed_start = @min(self.text_changed_start, start); - self.text_changed_added += added; - - if (self.textLayout.add_text_done) { - self.edited_outside_last_frame.* = true; - } - - //std.debug.print("textChanged {d} {d} {d}\n", .{ self.text_changed_start, self.text_changed_end, self.text_changed_added }); -} - -/// Return text as a slice to the backing storage. The returned slice is -/// valid after `deinit`, and is only invalidated by events or functions that -/// change the text (like `textSet` or `paste`). -pub fn textGet(self: *const TextEntryWidget) []u8 { - return self.text[0..self.len]; -} - -/// Deprecated in favor of `textGet`. -pub fn getText(self: *const TextEntryWidget) []u8 { - return self.textGet(); -} - -pub fn textSet(self: *TextEntryWidget, text: []const u8, selected: bool) void { - self.textLayout.selection.selectAll(); - self.textTyped(text, selected); -} - -pub fn textTyped(self: *TextEntryWidget, new: []const u8, selected: bool) void { - // strip out carriage returns, which we get from copy/paste on windows - if (std.mem.findScalar(u8, new, '\r')) |idx| { - self.textTyped(new[0..idx], selected); - self.textTyped(new[idx + 1 ..], selected); - return; - } - - var sel = self.textLayout.selectionGet(self.len); - if (!sel.empty()) { - // delete selection - self.textChangedRemoved(sel.start, sel.end); - @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); - self.len -= (sel.end - sel.start); - sel.end = sel.start; - sel.cursor = sel.start; - } - - const space_left = self.text.len - self.len; - if (space_left < new.len) { - var new_size = realloc_bin_size * (@divTrunc(self.len + new.len, realloc_bin_size) + 1); - switch (self.init_opts.text) { - .buffer => {}, - .buffer_dynamic => |b| { - new_size = @min(new_size, b.limit); - b.backing.* = b.allocator.realloc(self.text, new_size) catch |err| blk: { - dvui.logError(@src(), err, "{x} TextEntryWidget.textTyped failed to realloc backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_size }); - break :blk b.backing.*; - }; - self.text = b.backing.*; - }, - .array_list => |al| { - new_size = @min(new_size, al.limit); - al.backing.ensureTotalCapacity(al.allocator, new_size) catch |err| { - dvui.logError(@src(), err, "{x} TextEntryWidget.textTyped failed to realloc ArrayList backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_size }); - }; - self.text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; - }, - .internal => |i| { - new_size = @min(new_size, i.limit); - // If we are the same size then there is no work to do - // This is important because same sized data allocations will be reused - if (new_size != self.text.len) { - // NOTE: Using prev_text is safe because data is trashed and stays valid until the end of the frame - const prev_text = self.text; - dvui.dataSetSliceCopies(null, self.data().id, "_buffer", &[_]u8{0}, new_size); - self.text = dvui.dataGetSlice(null, self.data().id, "_buffer", []u8).?; - const min_len = @min(prev_text.len, self.text.len); - if (self.text.ptr != prev_text.ptr) { - @memcpy(self.text[0..min_len], prev_text[0..min_len]); - } - } - }, - } - } - var new_len = @min(new.len, self.text.len - self.len); - - // find start of last utf8 char - var last: usize = new_len -| 1; - while (last < new_len and new[last] & 0xc0 == 0x80) { - last -|= 1; - } - - // if the last utf8 char can't fit, don't include it - if (last < new_len) { - const utf8_size = std.unicode.utf8ByteSequenceLength(new[last]) catch 0; - if (utf8_size != (new_len - last)) { - new_len = last; - } - } - - // make room if we can - if (new_len > 0 and sel.cursor + new_len < self.text.len) { - @memmove(self.text[sel.cursor + new_len ..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); - } - - if (new_len > 0) { - self.textChangedAdded(sel.cursor, new_len); - } - - // update our len and maintain 0 termination if possible - self.setLen(self.len + new_len); - - // insert - @memmove(self.text[sel.cursor..][0..new_len], new[0..new_len]); - if (selected) { - sel.start = sel.cursor; - sel.cursor += new_len; - sel.end = sel.cursor; - } else { - sel.cursor += new_len; - sel.end = sel.cursor; - sel.start = sel.cursor; - } - if (std.mem.findScalar(u8, new[0..new_len], '\n') != null) { - sel.affinity = .after; - } - - // we might have dropped to a new line, so make sure the cursor is visible - self.textLayout.scroll_to_cursor_next_frame = true; - dvui.refresh(null, @src(), self.data().id); -} - -/// Remove all characters that not present in filter_chars. -/// Designed to run after event processing and before drawing. -pub fn filterIn(self: *TextEntryWidget, filter_chars: []const u8) void { - if (filter_chars.len == 0) { - return; - } - - var i: usize = 0; - var j: usize = 0; - const n = self.len; - while (i < n) { - if (std.mem.findScalar(u8, filter_chars, self.text[i]) == null) { - self.len -= 1; - var sel = self.textLayout.selection; - if (sel.start > i) sel.start -= 1; - if (sel.cursor > i) sel.cursor -= 1; - if (sel.end > i) sel.end -= 1; - self.text_changed = true; - - i += 1; - } else { - self.text[j] = self.text[i]; - i += 1; - j += 1; - } - } - - if (j < self.text.len) - self.text[j] = 0; -} - -/// Remove all instances of the string needle. -/// Designed to run after event processing and before drawing. -pub fn filterOut(self: *TextEntryWidget, needle: []const u8) void { - if (needle.len == 0) { - return; - } - - var i: usize = 0; - var j: usize = 0; - const n = self.len; - while (i < n) { - if (std.mem.startsWith(u8, self.text[i..], needle)) { - self.len -= needle.len; - var sel = self.textLayout.selection; - if (sel.start > i) sel.start -= needle.len; - if (sel.cursor > i) sel.cursor -= needle.len; - if (sel.end > i) sel.end -= needle.len; - self.text_changed = true; - - i += needle.len; - } else { - self.text[j] = self.text[i]; - i += 1; - j += 1; - } - } - - if (j < self.text.len) - self.text[j] = 0; -} - -/// Sets the new length and does fixups: -/// - add null terminator if there is space -/// - shrink allocation if needed -/// - fixup array_list backing -pub fn setLen(self: *TextEntryWidget, newlen: usize) void { - self.len = newlen; - - // add null terminator if there is space - if (self.len < self.text.len) { - self.text[self.len] = 0; - } - - // shrink allocation if needed - const needed_binds = @divTrunc(self.len, realloc_bin_size) + 1; - const current_bins = @divTrunc(self.text.len, realloc_bin_size); - // dvui.log.debug("TextEntry {x} needs {d} bins, has {d}", .{ self.data().id, needed_binds, current_bins }); - if (self.len == 0 or needed_binds < current_bins) { - // we want to shrink the allocation - const new_len = if (self.len == 0) 0 else realloc_bin_size * needed_binds; - switch (self.init_opts.text) { - .buffer => {}, - .buffer_dynamic => |b| { - if (b.allocator.resize(self.text, new_len)) { - b.backing.*.len = new_len; - self.text.len = new_len; - } else { - dvui.logError(@src(), std.mem.Allocator.Error.OutOfMemory, "{x} TextEntryWidget.textTyped failed to realloc backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_len }); - } - }, - .array_list => |al| { - if (new_len < al.backing.capacity / 2) { - al.backing.items.len = al.backing.capacity; - al.backing.shrinkAndFree(al.allocator, new_len); - self.text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; - } - }, - .internal => { - // NOTE: Using prev_text is safe because data is trashed and stays valid until the end of the frame - const prev_text = self.text; - dvui.dataSetSliceCopies(null, self.data().id, "_buffer", &[_]u8{0}, new_len); - self.text = dvui.dataGetSlice(null, self.data().id, "_buffer", []u8).?; - const min_len = @min(prev_text.len, self.text.len); - @memcpy(self.text[0..min_len], prev_text[0..min_len]); - }, - } - } - - // fixup array_list backing - switch (self.init_opts.text) { - .array_list => |al| { - al.backing.items.len = self.len; - }, - else => {}, - } -} - -pub fn processEvent(self: *TextEntryWidget, e: *Event) void { - // scroll gets first crack, because it is logically outside the text area - self.scroll.scroll.?.processEvent(e); - if (e.handled) return; - - switch (e.evt) { - .key => |ke| blk: { - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("next_widget")) { - e.handle(@src(), self.data()); - dvui.tabIndexNext(e.num); - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("prev_widget")) { - e.handle(@src(), self.data()); - dvui.tabIndexPrev(e.num); - break :blk; - } - - if (ke.action == .down and ke.matchBind("paste")) { - e.handle(@src(), self.data()); - self.paste(); - break :blk; - } - - if (ke.action == .down and ke.matchBind("cut")) { - e.handle(@src(), self.data()); - self.cut(); - break :blk; - } - - if (ke.action == .down and ke.matchBind("copy")) { - e.handle(@src(), self.data()); - self.copy(); - break :blk; - } - - if (ke.action == .down and ke.matchBind("text_start")) { - e.handle(@src(), self.data()); - self.textLayout.selection.moveCursor(0, false); - self.textLayout.scroll_to_cursor = true; - break :blk; - } - - if (ke.action == .down and ke.matchBind("text_end")) { - e.handle(@src(), self.data()); - self.textLayout.selection.moveCursor(std.math.maxInt(usize), false); - self.textLayout.scroll_to_cursor = true; - break :blk; - } - - if (ke.action == .down and ke.matchBind("line_start")) { - e.handle(@src(), self.data()); - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .home } }; - } - break :blk; - } - - if (ke.action == .down and ke.matchBind("line_end")) { - e.handle(@src(), self.data()); - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .end } }; - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_left")) { - e.handle(@src(), self.data()); - if (!self.textLayout.selection.empty()) { - self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); - } else { - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; - } - if (self.textLayout.sel_move == .word_left_right) { - self.textLayout.sel_move.word_left_right.count -= 1; - } - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_right")) { - e.handle(@src(), self.data()); - if (!self.textLayout.selection.empty()) { - self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); - self.textLayout.selection.affinity = .before; - } else { - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; - } - if (self.textLayout.sel_move == .word_left_right) { - self.textLayout.sel_move.word_left_right.count += 1; - } - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_left")) { - e.handle(@src(), self.data()); - if (!self.textLayout.selection.empty()) { - self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); - } else { - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; - } - if (self.textLayout.sel_move == .char_left_right) { - self.textLayout.sel_move.char_left_right.count -= 1; - } - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_right")) { - e.handle(@src(), self.data()); - if (!self.textLayout.selection.empty()) { - self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); - self.textLayout.selection.affinity = .before; - } else { - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; - } - if (self.textLayout.sel_move == .char_left_right) { - self.textLayout.sel_move.char_left_right.count += 1; - } - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_up")) { - e.handle(@src(), self.data()); - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; - } - if (self.textLayout.sel_move == .cursor_updown) { - self.textLayout.sel_move.cursor_updown.count -= 1; - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_down")) { - e.handle(@src(), self.data()); - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; - } - if (self.textLayout.sel_move == .cursor_updown) { - self.textLayout.sel_move.cursor_updown.count += 1; - } - break :blk; - } - - switch (ke.code) { - .backspace => { - if (ke.action == .down or ke.action == .repeat) { - e.handle(@src(), self.data()); - var sel = self.textLayout.selectionGet(self.len); - if (!sel.empty()) { - // just delete selection - self.textChangedRemoved(sel.start, sel.end); - @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); - self.setLen(self.len - (sel.end - sel.start)); - sel.end = sel.start; - sel.cursor = sel.start; - self.textLayout.scroll_to_cursor = true; - } else if (ke.matchBind("delete_prev_word")) { - // delete word before cursor - - const oldcur = sel.cursor; - // find end of last word - if (sel.cursor > 0 and std.mem.findAny(u8, self.text[sel.cursor - 1 ..][0..1], " \n") != null) { - sel.cursor = std.mem.findLastNone(u8, self.text[0..sel.cursor], " \n") orelse 0; - } - - // find start of word - if (std.mem.findLastAny(u8, self.text[0..sel.cursor], " \n")) |last_space| { - sel.cursor = last_space + 1; - } else { - sel.cursor = 0; - } - - // delete from sel.cursor to oldcur - if (sel.cursor != oldcur) self.textChangedRemoved(sel.cursor, oldcur); - @memmove(self.text[sel.cursor..][0 .. self.len - oldcur], self.text[oldcur..self.len]); - self.setLen(self.len - (oldcur - sel.cursor)); - sel.end = sel.cursor; - sel.start = sel.cursor; - self.textLayout.scroll_to_cursor = true; - } else if (sel.cursor > 0) { - // delete character just before cursor - // - // A utf8 char might consist of more than one byte. - // Find the beginning of the last byte by iterating over - // the string backwards. The first byte of a utf8 char - // does not have the pattern 10xxxxxx. - var i: usize = 1; - while (sel.cursor - i > 0 and self.text[sel.cursor - i] & 0xc0 == 0x80) : (i += 1) {} - self.textChangedRemoved(sel.cursor - i, sel.cursor); - @memmove(self.text[sel.cursor - i ..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); - self.setLen(self.len - i); - sel.cursor -= i; - sel.start = sel.cursor; - sel.end = sel.cursor; - self.textLayout.scroll_to_cursor = true; - } - } - }, - .delete => { - if (ke.action == .down or ke.action == .repeat) { - e.handle(@src(), self.data()); - var sel = self.textLayout.selectionGet(self.len); - if (!sel.empty()) { - // just delete selection - self.textChangedRemoved(sel.start, sel.end); - @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); - self.setLen(self.len - (sel.end - sel.start)); - sel.end = sel.start; - sel.cursor = sel.start; - self.textLayout.scroll_to_cursor = true; - } else if (ke.matchBind("delete_next_word")) { - // delete word after cursor - - const oldcur = sel.cursor; - // find start of next word - if (sel.cursor < self.len and std.mem.findAny(u8, self.text[sel.cursor..][0..1], " \n") != null) { - sel.cursor = std.mem.findNonePos(u8, self.text, sel.cursor, " \n") orelse self.len; - } - - // find end of word - if (std.mem.findAny(u8, self.text[sel.cursor..self.len], " \n")) |last_space| { - sel.cursor = sel.cursor + last_space; - } else { - sel.cursor = self.len; - } - - // delete from oldcur to sel.cursor - if (sel.cursor != oldcur) self.textChangedRemoved(oldcur, sel.cursor); - @memmove(self.text[oldcur..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); - self.setLen(self.len - (sel.cursor - oldcur)); - sel.cursor = oldcur; - sel.end = sel.cursor; - sel.start = sel.cursor; - self.textLayout.scroll_to_cursor = true; - } else if (sel.cursor < self.len) { - // delete the character just after the cursor - // - // A utf8 char might consist of more than one byte. - const ii = std.unicode.utf8ByteSequenceLength(self.text[sel.cursor]) catch 1; - const i = @min(ii, self.len - sel.cursor); - - self.textChangedRemoved(sel.cursor, sel.cursor + i); - const remaining = self.len - (sel.cursor + i); - @memmove(self.text[sel.cursor..][0..remaining], self.text[sel.cursor + i ..][0..remaining]); - self.setLen(self.len - i); - self.textLayout.scroll_to_cursor = true; - } - } - }, - .enter => { - if (ke.action == .down or ke.action == .repeat) { - e.handle(@src(), self.data()); - if (self.init_opts.multiline) { - self.textTyped("\n", false); - } else if (ke.action == .down) { - self.enter_pressed = true; - dvui.refresh(null, @src(), self.data().id); - } - } - }, - else => {}, - } - }, - .text => |te| { - switch (te.action) { - .value => |set| { - e.handle(@src(), self.data()); - var new = std.mem.sliceTo(set.txt, 0); - if (self.init_opts.multiline) { - self.textTyped(new, set.selected); - } else { - var i: usize = 0; - while (i < new.len) { - if (std.mem.findScalar(u8, new[i..], '\n')) |idx| { - self.textTyped(new[i..][0..idx], set.selected); - i += idx + 1; - } else { - self.textTyped(new[i..], set.selected); - break; - } - } - } - }, - else => {}, - } - }, - .mouse => |me| { - if (me.action == .focus) { - e.handle(@src(), self.data()); - dvui.focusWidget(self.data().id, null, e.num); - } - }, - else => {}, - } - - if (!e.handled) { - self.textLayout.processEvent(e); - - if (!e.handled and e.evt == .key) { - switch (e.evt.key.code) { - .page_up, .page_down => {}, // handled by scroll container - else => { - // Mark all remaining key events as handled. This allows - // checking a keybind (like "d") after the textEntry, but - // where textEntry will get it first. - e.handle(@src(), self.data()); - }, - } - } - } -} - -pub fn paste(self: *TextEntryWidget) void { - const clip_text = dvui.clipboardText(); - - if (self.init_opts.multiline) { - self.textTyped(clip_text, false); - } else { - var i: usize = 0; - while (i < clip_text.len) { - if (std.mem.findScalar(u8, clip_text[i..], '\n')) |idx| { - self.textTyped(clip_text[i..][0..idx], false); - i += idx + 1; - } else { - self.textTyped(clip_text[i..], false); - break; - } - } - } -} - -pub fn cut(self: *TextEntryWidget) void { - var sel = self.textLayout.selectionGet(self.len); - if (!sel.empty()) { - // copy selection to clipboard - dvui.clipboardTextSet(self.text[sel.start..sel.end]); - - // delete selection - self.textChangedRemoved(sel.start, sel.end); - @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); - self.setLen(self.len - (sel.end - sel.start)); - sel.end = sel.start; - sel.cursor = sel.start; - self.textLayout.scroll_to_cursor = true; - } -} - -/// This could use textLayout.copy(), but that doesn't work if we have a masked -/// password field (textLayout only sees the password char). -pub fn copy(self: *TextEntryWidget) void { - var sel = self.textLayout.selectionGet(self.len); - if (!sel.empty()) { - // copy selection to clipboard - dvui.clipboardTextSet(self.text[sel.start..sel.end]); - } -} - -pub fn deinit(self: *TextEntryWidget) void { - defer if (dvui.widgetIsAllocated(self)) dvui.widgetFree(self); - defer self.* = undefined; - - // set clip back to what textLayout had, because it might need it to set - // the mouse cursor - dvui.clipSet(self.textClip); - self.textLayout.deinit(); - self.scroll.deinit(); - - dvui.clipSet(self.prevClip); - self.data().minSizeSetAndRefresh(); - self.data().minSizeReportToParent(); - dvui.parentReset(self.data().id, self.data().parent); -} - -test { - @import("std").testing.refAllDecls(@This()); -} - -test "text internal" { - var t = try dvui.testing.init(.{}); - defer t.deinit(); - - const Local = struct { - var text: []const u8 = ""; - - // Set a limit that is not a multiple of the bin size - const limit = realloc_bin_size * 5 / 2; - - fn frame() !dvui.App.Result { - var entry: TextEntryWidget = undefined; - entry.init(@src(), .{ - .text = .{ .internal = .{ .limit = limit } }, - }, .{ .tag = "entry" }); - defer entry.deinit(); - - entry.processEvents(); - entry.draw(); - text = entry.getText(); - return .ok; - } - }; - - try dvui.testing.settle(Local.frame); - try dvui.testing.pressKey(.tab, .none); - try dvui.testing.settle(Local.frame); - try dvui.testing.expectFocused("entry"); - - const text = "This is some short sample text!"; - // text length should not be a multiple of the limit or bin size - try std.testing.expect(Local.limit % text.len != 0); - try std.testing.expect(realloc_bin_size % text.len != 0); - - try dvui.testing.writeText(text); - try dvui.testing.settle(Local.frame); - try std.testing.expectEqualStrings(text, Local.text); - - for (0..@divFloor(Local.limit, text.len)) |_| { - // Fill the internal buffer - try dvui.testing.writeText(text); - } - try dvui.testing.settle(Local.frame); - - const full_text_buffer = comptime blk: { - var text_buf: []const u8 = text; - while (text_buf.len < Local.limit) text_buf = text_buf ++ text; - break :blk text_buf; - }[0..Local.limit]; - try std.testing.expectEqualStrings(full_text_buffer, Local.text); -} - -test "text dynamic buffer" { - var t = try dvui.testing.init(.{}); - defer t.deinit(); - - const Local = struct { - var text: []const u8 = ""; - - // Set a limit that is not a multiple of the bin size - const limit = realloc_bin_size * 5 / 2; - - var buffer: [limit]u8 = undefined; - var fba = std.heap.FixedBufferAllocator.init(&buffer); - var backing: []u8 = &.{}; - - fn frame() !dvui.App.Result { - var entry: TextEntryWidget = undefined; - entry.init(@src(), .{ - .text = .{ .buffer_dynamic = .{ - .backing = &backing, - .allocator = fba.allocator(), - .limit = limit, - } }, - }, .{ .tag = "entry" }); - defer entry.deinit(); - - entry.processEvents(); - entry.draw(); - text = entry.getText(); - return .ok; - } - }; - - try dvui.testing.settle(Local.frame); - try dvui.testing.pressKey(.tab, .none); - try dvui.testing.settle(Local.frame); - try dvui.testing.expectFocused("entry"); - - const text = "This is some short sample text!"; - // limit should not be a multiple of the text length - try std.testing.expect(Local.limit % text.len != 0); - try std.testing.expect(realloc_bin_size % text.len != 0); - - try dvui.testing.writeText(text); - try dvui.testing.settle(Local.frame); - try std.testing.expectEqualStrings(text, Local.text); - - for (0..@divFloor(Local.limit, text.len)) |_| { - // Fill the internal buffer - // This verifies that any OOM error is handled by writing past the buffer size - try dvui.testing.writeText(text); - } - try dvui.testing.settle(Local.frame); - - const full_text_buffer = comptime blk: { - var text_buf: []const u8 = text; - while (text_buf.len < Local.limit) text_buf = text_buf ++ text; - break :blk text_buf; - }[0..Local.limit]; - try std.testing.expectEqualStrings(full_text_buffer, Local.text); -} - -test "text buffer" { - var t = try dvui.testing.init(.{}); - defer t.deinit(); - - const Local = struct { - var text: []const u8 = ""; - - // Set a limit that is not a multiple of the bin size - const limit = realloc_bin_size * 5 / 2; - - var buffer: [limit]u8 = undefined; - - fn frame() !dvui.App.Result { - var entry: TextEntryWidget = undefined; - entry.init(@src(), .{ - .text = .{ .buffer = &buffer }, - }, .{ .tag = "entry" }); - defer entry.deinit(); - - entry.processEvents(); - entry.draw(); - text = entry.getText(); - return .ok; - } - }; - - try dvui.testing.settle(Local.frame); - try dvui.testing.pressKey(.tab, .none); - try dvui.testing.settle(Local.frame); - try dvui.testing.expectFocused("entry"); - - const text = "This is some short sample text!"; - // limit should not be a multiple of the text length - try std.testing.expect(Local.limit % text.len != 0); - try std.testing.expect(realloc_bin_size % text.len != 0); - - try dvui.testing.writeText(text); - try dvui.testing.settle(Local.frame); - try std.testing.expectEqualStrings(text, Local.text); - - for (0..@divFloor(Local.limit, text.len)) |_| { - // Fill the internal buffer - // This verifies that any OOM error is handled by writing past the buffer size - try dvui.testing.writeText(text); - } - try dvui.testing.settle(Local.frame); - - const full_text_buffer = comptime blk: { - var text_buf: []const u8 = text; - while (text_buf.len < Local.limit) text_buf = text_buf ++ text; - break :blk text_buf; - }[0..Local.limit]; - try std.testing.expectEqualStrings(full_text_buffer, Local.text); -} - -test "text array_list" { - var t = try dvui.testing.init(.{}); - defer t.deinit(); - - const Local = struct { - var text: []const u8 = ""; - var al: std.ArrayList(u8) = .empty; - - fn frame() !dvui.App.Result { - var entry: TextEntryWidget = undefined; - entry.init(@src(), .{ .text = .{ .array_list = .{ - .backing = &al, - .allocator = std.testing.allocator, - } } }, .{ .tag = "entry" }); - defer entry.deinit(); - - entry.processEvents(); - entry.draw(); - text = entry.getText(); - - return .ok; - } - }; - - defer Local.al.deinit(std.testing.allocator); - - _ = try dvui.testing.step(Local.frame); - try dvui.testing.pressKey(.tab, .none); - _ = try dvui.testing.step(Local.frame); - try dvui.testing.expectFocused("entry"); - - const text = "Testing text"; - try dvui.testing.writeText(text); - _ = try dvui.testing.step(Local.frame); - try std.testing.expectEqualStrings(text, Local.text); -} diff --git a/src/plugins/code/queries/json.scm b/src/plugins/code/queries/json.scm deleted file mode 100644 index 0fe34774..00000000 --- a/src/plugins/code/queries/json.scm +++ /dev/null @@ -1,16 +0,0 @@ -(string) @feppz.string - -(pair - key: (_) @feppz.string.special.key) - -(number) @feppz.number - -[ - (null) - (true) - (false) -] @feppz.keyword.constant.default - -(escape_sequence) @feppz.string.escape - -(comment) @feppz.comment diff --git a/src/plugins/code/queries/zig.scm b/src/plugins/code/queries/zig.scm index 08435bd3..bccb6e62 100644 --- a/src/plugins/code/queries/zig.scm +++ b/src/plugins/code/queries/zig.scm @@ -1,32 +1,19 @@ -; Feppz! / vscode-zig aligned captures for tree-sitter highlighting. -; Capture names mirror TextMate scopes from ziglang.vscode-zig where possible. +; Variables — catch-all first; more specific rules below override (last capture wins). +(identifier) @variable -; --- Functions & calls (before generic identifiers) --- -(function_declaration - name: (identifier) @feppz.entity.name.function) - -(call_expression - function: (identifier) @feppz.entity.name.function) - -(call_expression - function: (field_expression - member: (identifier) @feppz.entity.name.function)) +; Parameters +(parameter + name: (identifier) @variable.parameter) -; const/var name — the identifier immediately after the keyword. -(variable_declaration - [ - "const" - "var" - ] - (identifier) @feppz.variable.definition) +(payload + (identifier) @variable.parameter) -; PascalCase types only when not a dotted path segment (see field_expression below). -((identifier) @feppz.entity.name.type - (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*")) +; Types +(parameter + type: (identifier) @type) (variable_declaration - (identifier) @feppz.entity.name.type - (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*") + (identifier) @type "=" [ (struct_declaration) @@ -35,185 +22,160 @@ (opaque_declaration) ]) -; --- Types --- -(parameter - type: (identifier) @feppz.entity.name.type - (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*")) - [ (builtin_type) "anyframe" - "anyopaque" -] @feppz.keyword.type +] @type.builtin -; --- Parameters & fields --- -(parameter - name: (identifier) @feppz.variable) - -(payload - (identifier) @feppz.variable) +; Constants +[ + "null" + "unreachable" + "undefined" +] @constant.builtin -; Dotted paths: dvui in dvui.TextureTarget, std/mem in std.mem.Allocator (field_expression - object: (identifier) @feppz.variable.namespace - (#match? @feppz.variable.namespace "^[a-z_][a-zA-Z0-9_]*")) + . + member: (identifier) @constant) -(field_expression - (_) - member: (identifier) @feppz.entity.name.type - (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*")) +(enum_declaration + (container_field + type: (identifier) @constant)) -(field_expression - (_) - member: (identifier) @feppz.variable.namespace - (#match? @feppz.variable.namespace "^[a-z_][a-zA-Z0-9_]*")) +; Labels +(block_label + (identifier) @label) + +(break_label + (identifier) @label) +; Fields (field_initializer . - (identifier) @feppz.variable.member) + (identifier) @variable.member) -(container_field - name: (identifier) @feppz.variable.member) - -(enum_declaration - (container_field - type: (identifier) @feppz.variable.enum_member)) +(field_expression + (_) + member: (identifier) @variable.member) -(initializer_list - (assignment_expression - left: (field_expression - . - member: (identifier) @feppz.variable.namespace - (#match? @feppz.variable.namespace "^[a-z_][a-zA-Z0-9_]*")))) +(container_field + name: (identifier) @variable.member) (initializer_list (assignment_expression left: (field_expression . - member: (identifier) @feppz.entity.name.type - (#match? @feppz.entity.name.type "^[A-Z_][a-zA-Z0-9_]*")))) - -; --- Constants --- -((identifier) @feppz.constant - (#match? @feppz.constant "^[A-Z][A-Z_0-9]+$")) - -[ - "null" - "undefined" -] @feppz.keyword.constant.default - -(boolean) @feppz.keyword.constant.bool - -; --- Labels --- -(block_label - (identifier) @feppz.label) - -(break_label - (identifier) @feppz.label) + member: (identifier) @variable.member))) -; --- Builtins & modules --- -(builtin_function - (builtin_identifier) @feppz.support.function.builtin) +; Functions +(builtin_identifier) @function.builtin -(builtin_identifier) @feppz.support.function.builtin +(call_expression + function: (identifier) @function.call) (call_expression - function: (builtin_function - (builtin_identifier) @feppz.support.function.builtin)) + function: (field_expression + member: (identifier) @function.call)) + +(function_declaration + name: (identifier) @function) +; Modules (variable_declaration - (identifier) @feppz.variable.module + (identifier) @module (builtin_function - (builtin_identifier) @feppz.support.function.builtin - (#any-of? @feppz.support.function.builtin "@import" "@cImport"))) + (builtin_identifier) @keyword.import + (#any-of? @keyword.import "@import" "@cImport"))) +; Builtins [ "c" "..." -] @feppz.variable.builtin +] @variable.builtin -((identifier) @feppz.variable.builtin - (#eq? @feppz.variable.builtin "_")) +((identifier) @variable.builtin + (#eq? @variable.builtin "_")) (calling_convention - (identifier) @feppz.variable.builtin) + (identifier) @variable.builtin) -; --- Keywords (vscode-zig scopes) --- +; Keywords [ + "asm" + "defer" + "errdefer" + "test" + "error" "const" "var" - "test" - "and" - "or" -] @feppz.keyword.default - -"fn" @feppz.storage.type.function +] @keyword [ "struct" "union" "enum" "opaque" -] @feppz.keyword.structure +] @keyword.type [ - "extern" - "packed" - "export" - "pub" - "noalias" - "inline" - "comptime" - "volatile" - "align" - "linksection" - "threadlocal" - "allowzero" - "noinline" - "callconv" - "usingnamespace" - "addrspace" -] @feppz.keyword.storage - -"asm" @feppz.keyword.control.flow - -"error" @feppz.keyword.control.flow + "async" + "await" + "suspend" + "nosuspend" + "resume" +] @keyword.coroutine -[ - "break" - "return" - "continue" - "defer" - "errdefer" - "unreachable" -] @feppz.keyword.control.flow +"fn" @keyword.function [ - "while" - "for" -] @feppz.keyword.control.flow + "and" + "or" + "orelse" +] @keyword.operator -[ - "resume" - "suspend" - "nosuspend" - "async" - "await" -] @feppz.keyword.control.flow +"return" @keyword.return [ "if" "else" "switch" - "orelse" -] @feppz.keyword.control.flow +] @keyword.conditional + +[ + "for" + "while" + "break" + "continue" +] @keyword.repeat + +[ + "usingnamespace" + "export" +] @keyword.import [ "try" "catch" -] @feppz.keyword.control.flow +] @keyword.exception -; --- Operators --- +[ + "volatile" + "allowzero" + "noalias" + "addrspace" + "align" + "callconv" + "linksection" + "pub" + "inline" + "noinline" + "extern" + "comptime" + "packed" + "threadlocal" +] @keyword.modifier + +; Operator [ "=" "*=" @@ -244,6 +206,7 @@ ">=" "<=" "<" + "&" "^" "|" "<<" @@ -266,50 +229,53 @@ ".?" "?" ".." -] @feppz.operator +] @operator -; --- Literals --- -(character) @feppz.string.character +; Literals +(character) @character ([ (string) (multiline_string) -] @feppz.string - (#set! "priority" 1)) - -(integer) @feppz.number +] @string + (#set! "priority" 95)) -(float) @feppz.number.float +(integer) @number -(escape_sequence) @feppz.string.escape - (#set! "priority" 95) +(float) @number.float -; --- Punctuation --- -["(" ")"] @feppz.punctuation.round +(boolean) @boolean -["[" "]"] @feppz.punctuation.square +(escape_sequence) @string.escape -["{" "}"] @feppz.punctuation.curly +; Punctuation +[ + "[" + "]" + "(" + ")" + "{" + "}" +] @punctuation.bracket [ ";" + "." "," ":" "=>" "->" -] @feppz.punctuation - -"." @feppz.punctuation.accessor +] @punctuation.delimiter (payload - "|" @feppz.punctuation.square) + "|" @punctuation.bracket) -; --- Comments --- -(comment) @feppz.comment @spell +; Comments +(comment) @comment -((comment) @feppz.comment.documentation - (#match? @feppz.comment.documentation "^//!")) +((comment) @comment.documentation + (#lua-match? @comment.documentation "^//!")) -; --- Fallback identifiers (lowest priority) --- -(identifier) @feppz.variable - (#set! "priority" 0) +; PascalCase identifiers (last capture wins over @variable) +((identifier) @type + (#lua-match? @type "^[A-Z_][a-zA-Z0-9_]*")) diff --git a/src/plugins/code/src/CodeEditor.zig b/src/plugins/code/src/CodeEditor.zig index 5ae99fe3..8c49c9db 100644 --- a/src/plugins/code/src/CodeEditor.zig +++ b/src/plugins/code/src/CodeEditor.zig @@ -1,23 +1,25 @@ -//! Monospace code editor: gutter line numbers + tree-sitter `textEntry`. +//! Monospace code editor: line numbers + dvui `textEntry` with tree-sitter highlighting. const std = @import("std"); const code = @import("../code.zig"); const dvui = code.dvui; -const wdvui = code.core.dvui; const Document = code.Document; const SyntaxHighlight = @import("SyntaxHighlight.zig"); -const editor_padding = dvui.Rect.all(8); -const gutter_pad_x: f32 = 12; +const editor_pad_y: f32 = 8; +const editor_pad_right: f32 = 8; +const line_number_pad_left: f32 = 4; +const code_gap_after_numbers: f32 = 12; + +const text_color = dvui.Color{ .r = 0xdd, .g = 0xdc, .b = 0xd3, .a = 255 }; +const line_number_color = dvui.Color{ .r = 0x58, .g = 0x58, .b = 0x5f, .a = 255 }; /// Tree-sitter + per-token layout is O(file size) each frame without layout caching. -/// Above this size we still edit, but skip syntax highlighting. const syntax_highlight_max_bytes: usize = 512 * 1024; const chromeless = dvui.Options{ .background = false, .margin = dvui.Rect{}, .padding = null, - // override() treats null as "unset", so use empty rects to clear TextEntry defaults. .border = dvui.Rect{}, .corner_radius = dvui.Rect{}, .ninepatch_fill = &dvui.Ninepatch.none, @@ -27,51 +29,40 @@ const chromeless = dvui.Options{ pub fn draw(doc: *Document, id_extra: u64, gpa: std.mem.Allocator) !bool { const font = dvui.Font.theme(.mono); - const theme = SyntaxHighlight.default_theme; - const gutter_w = gutterWidth(doc.line_count, font); const line_height = font.lineHeight(); + const line_num_col = lineNumberColumnWidth(doc.line_count, font); - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, chromeless.override(.{ - .expand = .both, - })); - defer hbox.deinit(); - - _ = dvui.spacer(@src(), .{ - .min_size_content = .{ .w = gutter_w }, - .expand = .vertical, - }); - - const use_syntax = doc.text.items.len <= syntax_highlight_max_bytes; - - var te = wdvui.textEntry(@src(), .{ + var te = dvui.textEntry(@src(), .{ .multiline = true, .break_lines = false, - // Limit layout + tree-sitter query work to the visible scroll range (see dvui Examples/text_entry.zig). .cache_layout = true, .scroll_horizontal = true, - .show_focus_border = false, .text = .{ .array_list = .{ .backing = &doc.text, .allocator = gpa, .limit = max_text_bytes } }, - .tree_sitter = if (use_syntax) SyntaxHighlight.treeSitterOption(doc.path, theme) else null, + .tree_sitter = if (doc.text.items.len <= syntax_highlight_max_bytes) + SyntaxHighlight.treeSitterOption(doc.path) + else + null, }, chromeless.override(.{ .expand = .both, .font = font, - .padding = editor_padding, - .color_text = theme.text, + .padding = .{ + .x = line_num_col, + .y = editor_pad_y, + .w = editor_pad_right, + .h = editor_pad_y, + }, + .color_text = text_color, .id_extra = @intCast(id_extra), })); defer te.deinit(); - const te_rs = te.data().borderRectScale(); - const gutter_rs: dvui.RectScale = .{ - .r = .{ - .x = te_rs.r.x - gutter_w * te_rs.s, - .y = te_rs.r.y, - .w = gutter_w * te_rs.s, - .h = te_rs.r.h, - }, - .s = te_rs.s, - }; - drawLineNumbers(gutter_rs, doc.line_count, te.scroll.si.viewport.y, font, line_height, theme.line_number); + drawLineNumbers( + te.data().borderRectScale(), + doc.line_count, + te.scroll.si.viewport.y, + font, + line_height, + ); if (te.text_changed) doc.refreshLineCount(); return te.text_changed; @@ -79,10 +70,10 @@ pub fn draw(doc: *Document, id_extra: u64, gpa: std.mem.Allocator) !bool { const max_text_bytes: usize = 64 * 1024 * 1024; -fn gutterWidth(line_count: usize, font: dvui.Font) f32 { +fn lineNumberColumnWidth(line_count: usize, font: dvui.Font) f32 { var buf: [16]u8 = undefined; const sample = std.fmt.bufPrint(&buf, "{d}", .{line_count}) catch "9999"; - return font.textSize(sample).w + gutter_pad_x * 2; + return line_number_pad_left + font.textSize(sample).w + code_gap_after_numbers; } fn drawLineNumbers( @@ -91,17 +82,16 @@ fn drawLineNumbers( scroll_y: f32, font: dvui.Font, line_height: f32, - number_color: dvui.Color, ) void { if (rs.r.empty()) return; const prev_clip = dvui.clip(rs.r); defer dvui.clipSet(prev_clip); - const first_line: usize = @intCast(@max(0, @as(i64, @intFromFloat((scroll_y - editor_padding.y) / line_height)))); + const first_line: usize = @intCast(@max(0, @as(i64, @intFromFloat((scroll_y - editor_pad_y) / line_height)))); var line: usize = first_line; - var y: f32 = editor_padding.y + @as(f32, @floatFromInt(line)) * line_height - scroll_y; + var y: f32 = editor_pad_y + @as(f32, @floatFromInt(line)) * line_height - scroll_y; var num_buf: [32]u8 = undefined; @@ -111,14 +101,14 @@ fn drawLineNumbers( }) { const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{line + 1}) catch continue; const text_size = font.textSize(num_str).scale(rs.s, dvui.Size.Physical); - const x = rs.r.x + rs.r.w - editor_padding.w - text_size.w; + const x = rs.r.x + line_number_pad_left * rs.s; const y_phys = rs.r.y + y * rs.s; dvui.renderText(.{ .font = font, .text = num_str, .rs = .{ .r = .{ .x = x, .y = y_phys, .w = text_size.w, .h = text_size.h }, .s = rs.s }, - .color = number_color, + .color = line_number_color, }) catch |err| { dvui.log.err("line number text: {any}", .{err}); }; diff --git a/src/plugins/code/src/SyntaxHighlight.zig b/src/plugins/code/src/SyntaxHighlight.zig index 1f7c6f74..2bb94438 100644 --- a/src/plugins/code/src/SyntaxHighlight.zig +++ b/src/plugins/code/src/SyntaxHighlight.zig @@ -1,11 +1,7 @@ -//! Tree-sitter syntax highlighting for the code editor. -//! -//! Capture names in `queries/zig.scm` mirror vscode-zig / Feppz! TextMate scopes. -//! Colors match the Feppz! theme as shown in VS Code/Cursor. +//! Tree-sitter syntax highlighting via dvui's built-in TextEntry support. const std = @import("std"); const code = @import("../code.zig"); const dvui = code.dvui; -const wdvui = code.core.dvui; const SyntaxHighlight = @This(); @@ -26,134 +22,107 @@ pub const Language = enum { } }; -/// Editor token colors. More specific capture names must appear later in each slice. -pub const Theme = struct { - text: dvui.Color, - line_number: dvui.Color, - zig_highlights: []const wdvui.TextEntryWidget.SyntaxHighlight, - json_highlights: []const wdvui.TextEntryWidget.SyntaxHighlight, -}; - fn rgb(r: u8, g: u8, b: u8) dvui.Color { return .{ .r = r, .g = g, .b = b, .a = 255 }; } -fn hi(name: []const u8, color: dvui.Color) wdvui.TextEntryWidget.SyntaxHighlight { +const ident_gold = rgb(0xd5, 0xc6, 0x83); +const keyword_brown = rgb(0x87, 0x65, 0x60); +const keyword_modifier_brown = rgb(0x61, 0x53, 0x53); +const type_orange = rgb(0xce, 0xa4, 0x7f); +const type_color = rgb(199, 140, 122); +const function_green = rgb(0x4d, 0xa5, 0x86); + +fn hi(name: []const u8, color: dvui.Color) dvui.TextEntryWidget.SyntaxHighlight { return .{ .name = name, .opts = .{ .color_text = color } }; } -// Feppz palette (from Feppz!-color-theme.json + vscode-zig scopes) -const fn_green = rgb(0x4d, 0xa5, 0x86); -const type_orange = rgb(0xd8, 0x8e, 0x79); -const var_yellow = rgb(0xd9, 0xc6, 0x79); -const kw_brown = rgb(0x61, 0x53, 0x53); // keyword.default.zig — const, var -const kw_decl = rgb(0x87, 0x65, 0x60); // pub, fn, struct, storage -const kw_pink = rgb(0xce, 0xa4, 0x7f); // if, for, return, orelse, error, … - -pub const feppz: Theme = .{ - .text = rgb(0xdd, 0xdc, 0xd3), - .line_number = rgb(0x58, 0x58, 0x5f), - .zig_highlights = &feppz_zig_highlights, - .json_highlights = &feppz_json_highlights, -}; - -pub const default_theme = feppz; - -const feppz_zig_highlights = [_]wdvui.TextEntryWidget.SyntaxHighlight{ - hi("feppz.comment", rgb(0x57, 0x5b, 0x65)), - hi("feppz.comment.documentation", rgb(0x7a, 0x7a, 0x78)), - - hi("feppz.punctuation", rgb(0x9c, 0x9d, 0x9d)), - hi("feppz.punctuation.round", rgb(0x85, 0x87, 0x8a)), - hi("feppz.punctuation.square", rgb(0x72, 0x75, 0x7b)), - hi("feppz.punctuation.curly", rgb(0x63, 0x67, 0x6f)), - hi("feppz.punctuation.accessor", rgb(0x9c, 0x9d, 0x9d)), - - hi("feppz.operator", rgb(0xb9, 0xb9, 0xb5)), - - hi("feppz.string", rgb(0x60, 0xc0, 0xd2)), - hi("feppz.string.character", rgb(0x60, 0xd2, 0xbe)), - hi("feppz.string.escape", rgb(0x58, 0x8e, 0x9a)), - hi("feppz.number", rgb(0x60, 0x9a, 0xd2)), - hi("feppz.number.float", rgb(0x60, 0x9a, 0xd2)), - - // Variables, namespace path segments (std.mem), struct fields - hi("feppz.variable", var_yellow), - hi("feppz.variable.definition", var_yellow), - hi("feppz.variable.namespace", var_yellow), - hi("feppz.variable.module", var_yellow), - hi("feppz.variable.member", var_yellow), - hi("feppz.variable.enum_member", rgb(0x53, 0x5c, 0x90)), - hi("feppz.variable.builtin", rgb(0x6a, 0x66, 0x56)), - hi("feppz.constant", rgb(0x60, 0x74, 0xd2)), - hi("feppz.label", rgb(0xc8, 0xc8, 0xc8)), - - hi("feppz.entity.name.function", fn_green), - hi("feppz.support.function.builtin", fn_green), - - // Types: PascalCase names, primitives (u32), anyopaque, … - hi("feppz.entity.name.type", type_orange), - hi("feppz.keyword.type", type_orange), - - // Declaration keywords — brown/tan - hi("feppz.keyword.default", kw_brown), - hi("feppz.storage.type.function", kw_decl), - hi("feppz.keyword.structure", kw_decl), - hi("feppz.keyword.storage", kw_decl), - - // Control flow — pink (return, if, for, orelse, error, …) - hi("feppz.keyword.control.flow", kw_pink), - - hi("feppz.keyword.constant.default", rgb(0x53, 0x5c, 0x90)), - hi("feppz.keyword.constant.bool", rgb(0x53, 0x5c, 0x90)), +/// Zig — capture names match `queries/zig.scm`. +const zig_highlights = [_]dvui.TextEntryWidget.SyntaxHighlight{ + hi("comment", rgb(0x57, 0x5b, 0x65)), + hi("keyword", keyword_brown), + hi("keyword.type", keyword_brown), + hi("keyword.function", keyword_brown), + hi("keyword.modifier", keyword_modifier_brown), + hi("keyword.conditional", type_orange), + hi("keyword.repeat", type_orange), + hi("keyword.return", type_orange), + hi("keyword.operator", type_orange), + hi("keyword.import", keyword_brown), + hi("keyword.exception", type_orange), + hi("keyword.coroutine", type_orange), + hi("variable", ident_gold), + hi("variable.parameter", ident_gold), + hi("variable.member", ident_gold), + hi("variable.builtin", rgb(0x6a, 0x66, 0x56)), + hi("module", ident_gold), + hi("type", type_color), + hi("type.builtin", type_color), + hi("function", function_green), + hi("function.call", function_green), + hi("function.builtin", function_green), + hi("constant", rgb(0x60, 0x74, 0xd2)), + hi("constant.builtin", rgb(0x53, 0x5c, 0x90)), + hi("string", rgb(0x60, 0xc0, 0xd2)), + hi("string.escape", rgb(0x58, 0x8e, 0x9a)), + hi("character", rgb(0x60, 0xd2, 0xbe)), + hi("number", rgb(0x60, 0x9a, 0xd2)), + hi("number.float", rgb(0x60, 0x9a, 0xd2)), + hi("boolean", rgb(0x53, 0x5c, 0x90)), + hi("operator", rgb(0xb9, 0xb9, 0xb5)), + hi("label", rgb(0xc8, 0xc8, 0xc8)), + hi("punctuation", rgb(0x9c, 0x9d, 0x9d)), }; -const feppz_json_highlights = [_]wdvui.TextEntryWidget.SyntaxHighlight{ - hi("feppz.comment", rgb(0x57, 0x5b, 0x65)), - hi("feppz.number", rgb(0x60, 0x9a, 0xd2)), - hi("feppz.constant", rgb(0x60, 0x74, 0xd2)), - hi("feppz.string", rgb(0x60, 0xc0, 0xd2)), - hi("feppz.string.escape", rgb(0x58, 0x8e, 0x9a)), - hi("feppz.keyword.constant.default", rgb(0x53, 0x5c, 0x90)), - hi("feppz.string.special.key", rgb(0xb6, 0x77, 0x6b)), +/// JSON — inline query (same shape as dvui Examples/text_entry.zig). +const json_queries = + \\(string) @string + \\ + \\(pair + \\ key: (_) @string.special.key) + \\ + \\(number) @number + \\ + \\[ + \\ (null) + \\ (true) + \\ (false) + \\] @constant.builtin + \\ + \\(escape_sequence) @escape + \\ + \\(comment) @comment +; + +const json_highlights = [_]dvui.TextEntryWidget.SyntaxHighlight{ + hi("constant", rgb(0x53, 0x5c, 0x90)), + hi("string", rgb(0x60, 0xc0, 0xd2)), + hi("string.special.key", rgb(0xb6, 0x77, 0x6b)), + hi("comment", rgb(0x57, 0x5b, 0x65)), + hi("number", rgb(0x60, 0x9a, 0xd2)), + hi("escape", rgb(0x58, 0x8e, 0x9a)), }; const zig_queries = @embedFile("../queries/zig.scm"); -const json_queries = @embedFile("../queries/json.scm"); const TreeSitter = if (dvui.useTreeSitter) struct { extern fn tree_sitter_zig() callconv(.c) *dvui.c.TSLanguage; extern fn tree_sitter_json() callconv(.c) *dvui.c.TSLanguage; - - fn option( - language: *dvui.c.TSLanguage, - queries: []const u8, - highlights: []const wdvui.TextEntryWidget.SyntaxHighlight, - ) wdvui.TextEntryWidget.InitOptions.TreeSitterOption { - return .{ - .language = language, - .queries = queries, - .highlights = highlights, - }; - } } else struct {}; -pub fn treeSitterOption( - path: []const u8, - theme: Theme, -) ?wdvui.TextEntryWidget.InitOptions.TreeSitterOption { +pub fn treeSitterOption(path: []const u8) ?dvui.TextEntryWidget.InitOptions.TreeSitterOption { if (!dvui.useTreeSitter) return null; return switch (Language.fromPath(path)) { - .zig, .zon => TreeSitter.option( - TreeSitter.tree_sitter_zig(), - zig_queries, - theme.zig_highlights, - ), - .json, .atlas => TreeSitter.option( - TreeSitter.tree_sitter_json(), - json_queries, - theme.json_highlights, - ), + .zig, .zon => .{ + .language = TreeSitter.tree_sitter_zig(), + .queries = zig_queries, + .highlights = &zig_highlights, + }, + .json, .atlas => .{ + .language = TreeSitter.tree_sitter_json(), + .queries = json_queries, + .highlights = &json_highlights, + }, .plain => null, }; } From e0917099b963e17c679c5ab9bce1cceba7c6e6ba Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 12:57:05 -0500 Subject: [PATCH 46/49] color @import as fn --- src/plugins/code/queries/zig.scm | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/plugins/code/queries/zig.scm b/src/plugins/code/queries/zig.scm index bccb6e62..f4d1217a 100644 --- a/src/plugins/code/queries/zig.scm +++ b/src/plugins/code/queries/zig.scm @@ -68,7 +68,9 @@ member: (identifier) @variable.member))) ; Functions -(builtin_identifier) @function.builtin +(call_expression + function: (builtin_function + (builtin_identifier) @function.call)) (call_expression function: (identifier) @function.call) @@ -80,12 +82,12 @@ (function_declaration name: (identifier) @function) -; Modules +; Modules (@import / @cImport — builtin stays @function.builtin) (variable_declaration (identifier) @module (builtin_function - (builtin_identifier) @keyword.import - (#any-of? @keyword.import "@import" "@cImport"))) + (builtin_identifier) @function.builtin + (#any-of? @function.builtin "@import" "@cImport"))) ; Builtins [ @@ -279,3 +281,9 @@ ; PascalCase identifiers (last capture wins over @variable) ((identifier) @type (#lua-match? @type "^[A-Z_][a-zA-Z0-9_]*")) + +; @ builtins (must be last — wins over module/import and variable rules) +(builtin_identifier) @function.builtin + +((identifier) @function.builtin + (#match? @function.builtin "^@")) From 1df41a92afc2a0cc5427e5189765bb2eeb3ac115 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 12:59:03 -0500 Subject: [PATCH 47/49] Own textentrywidget with custom changes --- src/plugins/code/src/CodeEditor.zig | 6 +- src/plugins/code/src/SyntaxHighlight.zig | 9 +- .../code/src/widgets/TextEntryWidget.zig | 1592 +++++++++++++++++ .../src/widgets/TreeSitterQueryPredicates.zig | 147 ++ 4 files changed, 1748 insertions(+), 6 deletions(-) create mode 100644 src/plugins/code/src/widgets/TextEntryWidget.zig create mode 100644 src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig diff --git a/src/plugins/code/src/CodeEditor.zig b/src/plugins/code/src/CodeEditor.zig index 8c49c9db..919e8603 100644 --- a/src/plugins/code/src/CodeEditor.zig +++ b/src/plugins/code/src/CodeEditor.zig @@ -1,9 +1,10 @@ -//! Monospace code editor: line numbers + dvui `textEntry` with tree-sitter highlighting. +//! Monospace code editor: line numbers + local `TextEntryWidget` with tree-sitter highlighting. const std = @import("std"); const code = @import("../code.zig"); const dvui = code.dvui; const Document = code.Document; const SyntaxHighlight = @import("SyntaxHighlight.zig"); +const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); const editor_pad_y: f32 = 8; const editor_pad_right: f32 = 8; @@ -32,11 +33,12 @@ pub fn draw(doc: *Document, id_extra: u64, gpa: std.mem.Allocator) !bool { const line_height = font.lineHeight(); const line_num_col = lineNumberColumnWidth(doc.line_count, font); - var te = dvui.textEntry(@src(), .{ + var te = TextEntryWidget.textEntry(@src(), .{ .multiline = true, .break_lines = false, .cache_layout = true, .scroll_horizontal = true, + .focus_border = false, .text = .{ .array_list = .{ .backing = &doc.text, .allocator = gpa, .limit = max_text_bytes } }, .tree_sitter = if (doc.text.items.len <= syntax_highlight_max_bytes) SyntaxHighlight.treeSitterOption(doc.path) diff --git a/src/plugins/code/src/SyntaxHighlight.zig b/src/plugins/code/src/SyntaxHighlight.zig index 2bb94438..289ee600 100644 --- a/src/plugins/code/src/SyntaxHighlight.zig +++ b/src/plugins/code/src/SyntaxHighlight.zig @@ -2,6 +2,7 @@ const std = @import("std"); const code = @import("../code.zig"); const dvui = code.dvui; +const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); const SyntaxHighlight = @This(); @@ -33,12 +34,12 @@ const type_orange = rgb(0xce, 0xa4, 0x7f); const type_color = rgb(199, 140, 122); const function_green = rgb(0x4d, 0xa5, 0x86); -fn hi(name: []const u8, color: dvui.Color) dvui.TextEntryWidget.SyntaxHighlight { +fn hi(name: []const u8, color: dvui.Color) TextEntryWidget.SyntaxHighlight { return .{ .name = name, .opts = .{ .color_text = color } }; } /// Zig — capture names match `queries/zig.scm`. -const zig_highlights = [_]dvui.TextEntryWidget.SyntaxHighlight{ +const zig_highlights = [_]TextEntryWidget.SyntaxHighlight{ hi("comment", rgb(0x57, 0x5b, 0x65)), hi("keyword", keyword_brown), hi("keyword.type", keyword_brown), @@ -94,7 +95,7 @@ const json_queries = \\(comment) @comment ; -const json_highlights = [_]dvui.TextEntryWidget.SyntaxHighlight{ +const json_highlights = [_]TextEntryWidget.SyntaxHighlight{ hi("constant", rgb(0x53, 0x5c, 0x90)), hi("string", rgb(0x60, 0xc0, 0xd2)), hi("string.special.key", rgb(0xb6, 0x77, 0x6b)), @@ -110,7 +111,7 @@ const TreeSitter = if (dvui.useTreeSitter) struct { extern fn tree_sitter_json() callconv(.c) *dvui.c.TSLanguage; } else struct {}; -pub fn treeSitterOption(path: []const u8) ?dvui.TextEntryWidget.InitOptions.TreeSitterOption { +pub fn treeSitterOption(path: []const u8) ?TextEntryWidget.InitOptions.TreeSitterOption { if (!dvui.useTreeSitter) return null; return switch (Language.fromPath(path)) { .zig, .zon => .{ diff --git a/src/plugins/code/src/widgets/TextEntryWidget.zig b/src/plugins/code/src/widgets/TextEntryWidget.zig new file mode 100644 index 00000000..b3397e68 --- /dev/null +++ b/src/plugins/code/src/widgets/TextEntryWidget.zig @@ -0,0 +1,1592 @@ +//! Vendored from dvui `widgets/TextEntryWidget.zig` with code-editor extensions: +//! tree-sitter predicate filtering, query error fallback, optional focus ring. +const builtin = @import("builtin"); +const std = @import("std"); +const code = @import("../../code.zig"); +const dvui = code.dvui; + +const Event = dvui.Event; +const Options = dvui.Options; +const Rect = dvui.Rect; +const RectScale = dvui.RectScale; +const ScrollInfo = dvui.ScrollInfo; +const Size = dvui.Size; +const Widget = dvui.Widget; +const WidgetData = dvui.WidgetData; +const ScrollAreaWidget = dvui.ScrollAreaWidget; +const TextLayoutWidget = dvui.TextLayoutWidget; +const AccessKit = dvui.AccessKit; + +const TreeSitterQueryPredicates = if (dvui.useTreeSitter) @import("TreeSitterQueryPredicates.zig") else struct { + pub fn matchApplies(_: *const dvui.c.TSQuery, _: dvui.c.TSQueryMatch, _: []const u8) bool { + return true; + } +}; + +const TextEntryWidget = @This(); + +/// If min_size_content is not given, use Font.sizeM(defaultMWidth, 1). +/// If multiline is false and max_size_content is not given, use min_size_content. +pub var defaultMWidth: f32 = 14; + +pub var defaults: Options = .{ + .name = "TextEntry", + .role = .text_input, // can change to multiline in init + .margin = Rect.all(4), + .corner_radius = Rect.all(5), + .border = Rect.all(1), + .padding = Rect.all(6), + .background = true, + .style = .content, + // min_size_content/max_size_content is calculated in init() +}; + +const realloc_bin_size = 100; + +pub const SyntaxHighlight = struct { + name: []const u8, + opts: dvui.Options, +}; + +pub const TreeSitterParser = if (dvui.useTreeSitter) struct { + parser: *dvui.c.TSParser, + tree: *dvui.c.TSTree, + query: *dvui.c.TSQuery, + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + + dvui.c.ts_query_delete(self.query); + dvui.c.ts_tree_delete(self.tree); + dvui.c.ts_parser_delete(self.parser); + } + + pub fn queryCursorCaptureIterator(self: *const TreeSitterParser, qc: *dvui.c.TSQueryCursor, text: []const u8) QueryCursorCaptureIterator { + return .{ + .query_cursor = qc, + .prev_match = null, + .query = self.query, + .text = text, + }; + } + + pub const QueryCursorCaptureIterator = struct { + pub const Match = struct { + iter: *const QueryCursorCaptureIterator, + node: dvui.c.TSNode, + capture_index: u32, + + pub fn captureName(self: *const Match) []const u8 { + var len: u32 = undefined; + const name = dvui.c.ts_query_capture_name_for_id(self.iter.query, self.capture_index, &len); + return name[0..len]; + } + + pub fn debugLog(self: *const Match, comptime kind: []const u8) void { + const start = dvui.c.ts_node_start_byte(self.node); + const end = dvui.c.ts_node_end_byte(self.node); + dvui.log.debug(kind ++ " capture @{s} : {s}", .{ self.captureName(), self.iter.text[start..end] }); + } + }; + + query_cursor: *dvui.c.TSQueryCursor, + prev_match: ?Match, + + // used for debugging + debug: bool = false, + query: *dvui.c.TSQuery, + text: []const u8, + + pub fn next(self: *QueryCursorCaptureIterator) ?Match { + var match: dvui.c.TSQueryMatch = undefined; + var captureIdx: u32 = undefined; + loop: while (dvui.c.ts_query_cursor_next_capture(self.query_cursor, &match, &captureIdx)) { + if (!TreeSitterQueryPredicates.matchApplies(self.query, match, self.text)) + continue :loop; + const capture = match.captures[captureIdx]; + if (self.prev_match) |pm| { + if (dvui.c.ts_node_eq(pm.node, capture.node)) { + // same node as previous + self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; + if (self.debug) self.prev_match.?.debugLog("ts same "); + continue :loop; + } + + // not the same + const ret = self.prev_match; + self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; + if (self.debug) self.prev_match.?.debugLog("ts new "); + return ret; + } else { + // first time + self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; + if (self.debug) self.prev_match.?.debugLog("ts first"); + continue :loop; + } + } + + const ret = self.prev_match; + if (ret) |r| { + if (self.debug) r.debugLog("ts last "); + } + self.prev_match = null; + return ret; + } + }; +} else void; + +pub const InitOptions = struct { + pub const TextOption = union(enum) { + /// Use this slice of bytes, cannot add more. + buffer: []u8, + + /// Use and grow with realloc and shrink with resize as needed. + buffer_dynamic: struct { + backing: *[]u8, + allocator: std.mem.Allocator, + limit: usize = 10_000, + }, + + /// Use std.ArrayList(u8). The limit is total characters, the + /// arraylist might allocate more capacity. ArrayList.items is updated + /// in deinit() (file an issue if this is a problem). + array_list: struct { + backing: *std.ArrayList(u8), + allocator: std.mem.Allocator, + limit: usize = 10_000, + }, + + /// Use internal buffer up to limit. + /// - use getText() to get contents. + internal: struct { + limit: usize = 10_000, + }, + }; + + pub const TreeSitterOption = if (dvui.useTreeSitter) struct { + language: *dvui.c.TSLanguage, + queries: []const u8, + highlights: []const SyntaxHighlight, + /// If true dump all captures to dvui.log.debug + log_captures: bool = false, + } else void; + + text: TextOption = .{ .internal = .{} }, + tree_sitter: ?TreeSitterOption = null, + /// Faded text shown when the textEntry is empty + placeholder: ?[]const u8 = null, + + /// If true, assume text (and text height) is the same (excepting edits we + /// do internally) as we saw last frame and only process what is needed for + /// visibility (and copy). + cache_layout: bool = false, + + break_lines: bool = false, + kerning: ?bool = null, + scroll_vertical: ?bool = null, // default is value of multiline + scroll_vertical_bar: ?ScrollInfo.ScrollBarMode = null, // default .auto + scroll_horizontal: ?bool = null, // default true + scroll_horizontal_bar: ?ScrollInfo.ScrollBarMode = null, // default .auto if multiline, .hide if not + + // must be a single utf8 character + password_char: ?[]const u8 = null, + multiline: bool = false, + /// Draw the theme focus ring when this text entry has keyboard focus. + focus_border: bool = true, +}; + +wd: WidgetData, +prevClip: Rect.Physical = undefined, +scroll: ScrollAreaWidget = undefined, +scrollClip: Rect.Physical = undefined, +textLayout: TextLayoutWidget = undefined, +textClip: Rect.Physical = undefined, +padding: Rect, + +init_opts: InitOptions, +text: []u8, +len: usize, +enter_pressed: bool = false, // not valid if multiline +text_changed: bool = false, + +// see textChanged() +text_changed_start: usize = std.math.maxInt(usize), +text_changed_end: usize = 0, // index of bytes before edits (so matches previous frame) +text_changed_added: i64 = 0, // bytes added +edited_outside_last_frame: *bool = undefined, + +/// It's expected to call this when `self` is `undefined` +pub fn init(self: *TextEntryWidget, src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) void { + var scroll_init_opts = ScrollAreaWidget.InitOpts{ + .vertical = if (init_opts.scroll_vertical orelse init_opts.multiline) .auto else .none, + .vertical_bar = init_opts.scroll_vertical_bar orelse .auto, + .horizontal = if (init_opts.scroll_horizontal orelse true) .auto else .none, + .horizontal_bar = init_opts.scroll_horizontal_bar orelse (if (init_opts.multiline) .auto else .hide), + }; + + var options = defaults.themeOverride(opts.theme).min_sizeM(defaultMWidth, 1); + + if (init_opts.password_char != null) { + options.role = .password_input; + } else if (init_opts.multiline) { + options.role = .multiline_text_input; + } + + options = options.override(opts); + if (!init_opts.multiline and options.max_size_content == null) { + options = options.override(.{ .max_size_content = .size(options.min_size_contentGet()) }); + } + + // padding is interpreted as the padding for the TextLayoutWidget, but + // we also need to add it to content size because TextLayoutWidget is + // inside the scroll area + const padding = options.paddingGet(); + options.padding = null; + options.min_size_content.?.w += padding.x + padding.w; + options.min_size_content.?.h += padding.y + padding.h; + if (options.max_size_content != null) { + options.max_size_content.?.w += padding.x + padding.w; + options.max_size_content.?.h += padding.y + padding.h; + } + + const wd = WidgetData.init(src, .{}, options); + scroll_init_opts.focus_id = wd.id; + + var text: []u8 = undefined; + var find_zero = true; + var len_utf8_boundary: usize = undefined; + switch (init_opts.text) { + .buffer => |b| text = b, + .buffer_dynamic => |b| text = b.backing.*, + .internal => text = dvui.dataGetSliceDefault(null, wd.id, "_buffer", []u8, &.{}), + .array_list => |al| { + find_zero = false; + text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; + len_utf8_boundary = dvui.findUtf8Start(text, al.backing.items.len); + }, + } + + if (find_zero) { + const len_byte = std.mem.findScalar(u8, text, 0) orelse text.len; + len_utf8_boundary = dvui.findUtf8Start(text[0..len_byte], len_byte); + } + + self.* = .{ + .wd = wd, + .padding = padding, + .init_opts = init_opts, + .text = text, + .len = len_utf8_boundary, + + // SAFETY: The following fields are set bellow + .prevClip = undefined, + .scroll = undefined, + .scrollClip = undefined, + .textLayout = undefined, + .textClip = undefined, + }; + + self.data().register(); + + dvui.tabIndexSet(self.data().id, self.data().options.tab_index, self.data().rectScale().r); + + dvui.parentSet(self.widget()); + + self.data().borderAndBackground(.{}); + + self.prevClip = dvui.clip(self.data().borderRectScale().r); + const borderClip = dvui.clipGet(); + + // We do this dance with last_focused_id_this_frame so scroll will process + // key events we skip (like page up/down). Normally it would not (text + // entry is not a child of scroll). So with this we make scroll think that + // text entry ran as a child. + const focused = (self.data().id == dvui.lastFocusedIdInFrame()); + if (focused) dvui.currentWindow().last_focused_id_this_frame = .zero; + + // scrollbars process mouse events here + self.scroll.init(@src(), scroll_init_opts, self.data().options.strip().override(.{ .role = .none, .expand = .both })); + + if (focused) dvui.currentWindow().last_focused_id_this_frame = self.data().id; + + self.scrollClip = dvui.clipGet(); + + self.edited_outside_last_frame = dvui.dataGetPtrDefault(null, self.data().id, "_edited_outside", bool, false); + if (self.init_opts.cache_layout and self.edited_outside_last_frame.*) { + dvui.log.debug("TextEntryWidget forcing cache_layout false due to text being edited after drawing last frame", .{}); + self.init_opts.cache_layout = false; + self.edited_outside_last_frame.* = false; + self.text_changed = true; // trigger tree_sitter full reparse + } + + self.textLayout.init(@src(), .{ + .break_lines = self.init_opts.break_lines, + .kerning = self.init_opts.kerning, + .touch_edit_just_focused = false, + .cache_layout = self.init_opts.cache_layout, + .focused = self.data().id == dvui.focusedWidgetId(), + .show_touch_draggables = (self.len > 0), + }, self.data().options.strip().override(.{ + .role = .none, + .expand = .both, + .padding = self.padding, + })); + + // if textLayout forced cache_layout to false, we need to honor that + self.init_opts.cache_layout = self.textLayout.cache_layout; + + self.textClip = dvui.clipGet(); + + if (self.textLayout.touchEditing()) |floating_widget| { + defer floating_widget.deinit(); + + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .corner_radius = dvui.ButtonWidget.defaults.themeOverride(opts.theme).corner_radiusGet(), + .background = true, + .border = dvui.Rect.all(1), + }); + defer hbox.deinit(); + + if (dvui.buttonIcon(@src(), "paste", dvui.entypo.clipboard, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.paste(); + } + + if (dvui.buttonIcon(@src(), "select all", dvui.entypo.swap, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.textLayout.selection.selectAll(); + } + + if (dvui.buttonIcon(@src(), "cut", dvui.entypo.scissors, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.cut(); + } + + if (dvui.buttonIcon(@src(), "copy", dvui.entypo.copy, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.copy(); + } + } + + // don't call textLayout.processEvents here, we forward events inside our own processEvents + + // textLayout is maintaining the selection for us, but if the text + // changed, we need to update the selection to be valid before we + // process any events + var sel = self.textLayout.selection; + sel.start = dvui.findUtf8Start(self.text[0..self.len], sel.start); + sel.cursor = dvui.findUtf8Start(self.text[0..self.len], sel.cursor); + sel.end = dvui.findUtf8Start(self.text[0..self.len], sel.end); + + // textLayout clips to its content, but we need to get events out to our border + dvui.clipSet(borderClip); + if (self.data().accesskit_node()) |ak_node| { + AccessKit.nodeAddAction(ak_node, AccessKit.Action.focus); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.set_value); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.set_text_selection); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.replace_selected_text); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.scroll_into_view); // AK TODO - not yet implemented + AccessKit.nodeSetClipsChildren(ak_node); // AK TODO: Check this is correct? + + if (self.data().options.role != .password_input) { + const str = self.text[0..self.len]; + AccessKit.nodeSetValueWithLength(ak_node, str.ptr, str.len); + } + } +} + +pub fn matchEvent(self: *TextEntryWidget, e: *Event) bool { + // textLayout could be passively listening to events in matchEvent, so + // don't short circuit + const match1 = dvui.eventMatchSimple(e, self.data()); + const match2 = self.scroll.scroll.?.matchEvent(e); + const match3 = self.textLayout.matchEvent(e); + return match1 or match2 or match3; +} + +pub fn processEvents(self: *TextEntryWidget) void { + const evts = dvui.events(); + for (evts) |*e| { + if (!self.matchEvent(e)) + continue; + + self.processEvent(e); + } +} + +pub fn draw(self: *TextEntryWidget) void { + self.drawBeforeText(); + + if (self.len == 0) { + if (self.init_opts.placeholder) |placeholder| { + if (self.data().accesskit_node()) |ak_node| { + AccessKit.nodeSetPlaceholderWithLength(ak_node, placeholder.ptr, placeholder.len); + + // Create an empty text run for the empty text entry. + dvui.currentWindow().accesskit.text_run_parent = self.data().id; + self.textLayout.textRunCreateEmpty(self.data().id, true); + // prevent textLayout from making a text run for the placeholder text + dvui.currentWindow().accesskit.text_run_parent = null; + } + self.textLayout.addText(placeholder, .{ .color_text = self.textLayout.data().options.color(.text).opacity(0.65) }); + } + } + + if (dvui.accesskit_enabled) { + // parent text runs to us + dvui.currentWindow().accesskit.text_run_parent = self.data().id; + } + + if (self.init_opts.password_char) |pc| { + { + // adjust selection for obfuscation + var count: usize = 0; + var bytes: usize = 0; + var sel = self.textLayout.selection; + var sstart: ?usize = null; + var scursor: ?usize = null; + var send: ?usize = null; + var utf8it = (std.unicode.Utf8View.initUnchecked(self.text[0..self.len])).iterator(); + while (utf8it.nextCodepoint()) |codepoint| { + if (sstart == null and sel.start == bytes) sstart = count * pc.len; + if (scursor == null and sel.cursor == bytes) scursor = count * pc.len; + if (send == null and sel.end == bytes) send = count * pc.len; + count += 1; + bytes += std.unicode.utf8CodepointSequenceLength(codepoint) catch unreachable; + } else { + if (sstart == null and sel.start >= bytes) sstart = count * pc.len; + if (scursor == null and sel.cursor >= bytes) scursor = count * pc.len; + if (send == null and sel.end >= bytes) send = count * pc.len; + } + sel.start = sstart.?; + sel.cursor = scursor.?; + sel.end = send.?; + const password_str: ?[]u8 = dvui.currentWindow().lifo().alloc(u8, count * pc.len) catch null; + if (password_str) |pstr| { + defer dvui.currentWindow().lifo().free(pstr); + for (0..count) |i| { + for (0..pc.len) |pci| { + pstr[i * pc.len + pci] = pc[pci]; + } + } + self.textLayout.addText(pstr, self.data().options.strip()); + } else { + dvui.log.warn("Could not allocate password_str, falling back to one single password_str", .{}); + self.textLayout.addText(pc, self.data().options.strip()); + } + } + + self.textLayout.addTextDone(self.data().options.strip()); + + { + // reset selection + var count: usize = 0; + var bytes: usize = 0; + var sel = self.textLayout.selection; + var sstart: ?usize = null; + var scursor: ?usize = null; + var send: ?usize = null; + // NOTE: We assume that all text in the area it valid utf8, loop with exit early on invalid utf8 + var utf8it = (std.unicode.Utf8View.initUnchecked(self.text[0..self.len])).iterator(); + while (utf8it.nextCodepoint()) |codepoint| { + if (sstart == null and sel.start == count * pc.len) sstart = bytes; + if (scursor == null and sel.cursor == count * pc.len) scursor = bytes; + if (send == null and sel.end == count * pc.len) send = bytes; + count += 1; + bytes += std.unicode.utf8CodepointSequenceLength(codepoint) catch unreachable; + } else { + if (sstart == null and sel.start >= count * pc.len) sstart = bytes; + if (scursor == null and sel.cursor >= count * pc.len) scursor = bytes; + if (send == null and sel.end >= count * pc.len) send = bytes; + } + sel.start = sstart.?; + sel.cursor = scursor.?; + sel.end = send.?; + } + + self.drawAfterText(); + return; + } + + if (dvui.useTreeSitter) { + if (self.init_opts.tree_sitter) |ts| { + if (dvui.dataGet(null, self.data().id, "ts_query_failed", bool)) |failed| { + if (failed) { + self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); + self.textLayout.addTextDone(self.data().options.strip()); + self.drawAfterText(); + return; + } + } + + // syntax highlighting + const parser = dvui.dataGetPtr(null, self.data().id, "parser", TreeSitterParser) orelse blk: { + const p = dvui.c.ts_parser_new(); + _ = dvui.c.ts_parser_set_language(p, ts.language); + const tree = dvui.c.ts_parser_parse_string(p, null, self.text.ptr, @intCast(self.len)); + + var errorOffset: u32 = undefined; + var errorType: dvui.c.TSQueryError = undefined; + const query = dvui.c.ts_query_new(ts.language, ts.queries.ptr, @intCast(ts.queries.len), &errorOffset, &errorType); + + if (query == null) { + dvui.log.err("TextEntry tree-sitter query error {} at offset {}", .{ errorType, errorOffset }); + if (tree) |t| dvui.c.ts_tree_delete(t); + if (p) |parser_ptr| dvui.c.ts_parser_delete(parser_ptr); + dvui.dataSet(null, self.data().id, "ts_query_failed", true); + break :blk null; + } + + const parser: TreeSitterParser = .{ .parser = p.?, .tree = tree.?, .query = query.? }; + dvui.dataSet(null, self.data().id, "parser", parser); + dvui.dataSetDeinitFunction(null, self.data().id, "parser", &TreeSitterParser.deinit); + break :blk dvui.dataGetPtr(null, self.data().id, "parser", TreeSitterParser).?; + }; + + if (parser == null) { + self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); + self.textLayout.addTextDone(self.data().options.strip()); + self.drawAfterText(); + return; + } + + var ts_parser = parser.?; + + // used to output text that's not highlighted + var start: usize = 0; + + if (self.text_changed and !dvui.firstFrame(self.data().id)) { + if (self.init_opts.cache_layout) { + var edit: dvui.c.TSInputEdit = undefined; + edit.start_byte = @intCast(self.text_changed_start); + edit.old_end_byte = @intCast(self.text_changed_end); + edit.new_end_byte = @intCast(@as(i64, @intCast(self.text_changed_end)) + self.text_changed_added); + + edit.start_point = .{ .row = 0, .column = 0 }; + edit.old_end_point = .{ .row = 0, .column = 0 }; + edit.new_end_point = .{ .row = 0, .column = 0 }; + + dvui.c.ts_tree_edit(ts_parser.tree, &edit); + + const tree = dvui.c.ts_parser_parse_string(ts_parser.parser, ts_parser.tree, self.text.ptr, @intCast(self.len)); + dvui.c.ts_tree_delete(ts_parser.tree); + ts_parser.tree = tree.?; + } else { + const tree = dvui.c.ts_parser_parse_string(ts_parser.parser, null, self.text.ptr, @intCast(self.len)); + dvui.c.ts_tree_delete(ts_parser.tree); + ts_parser.tree = tree.?; + } + } + + // parsing + const root = dvui.c.ts_tree_root_node(ts_parser.tree); + + // queries + const qc = dvui.c.ts_query_cursor_new(); + defer dvui.c.ts_query_cursor_delete(qc); + + if (self.textLayout.cache_layout_bytes) |clb| { + _ = dvui.c.ts_query_cursor_set_byte_range(qc, @intCast(clb.start), @intCast(clb.end)); + } + + dvui.c.ts_query_cursor_exec(qc, ts_parser.query, root); + + var iter = ts_parser.queryCursorCaptureIterator(qc.?, self.text); + iter.debug = ts.log_captures; + while (iter.next()) |match| { + const nstart = dvui.c.ts_node_start_byte(match.node); + const nend = dvui.c.ts_node_end_byte(match.node); + if (start < nstart) { + // render non highlighted text up to this node + self.textLayout.addText(self.text[start..nstart], .{}); + } else if (nstart < start) { + // this match is inside (or overlapping) the previous match + // maybe we could be smarter here, but for now drop it + continue; + } + + var opts: dvui.Options = .{}; + const capture_name = match.captureName(); + for (0..ts.highlights.len) |i| { + const sh = ts.highlights[ts.highlights.len - i - 1]; + if (std.mem.startsWith(u8, capture_name, sh.name)) { + opts = sh.opts; + break; + } + } + + self.textLayout.addText(self.text[nstart..nend], opts); + + start = nend; + } + + if (start < self.len) { + // any leftover non highlighted text + self.textLayout.addText(self.text[start..self.len], .{}); + } + + self.textLayout.addTextDone(self.data().options.strip()); + self.drawAfterText(); + return; + } + } + + // simple text + self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); + self.textLayout.addTextDone(self.data().options.strip()); + + self.drawAfterText(); +} + +pub fn drawBeforeText(self: *TextEntryWidget) void { + const focused = (self.data().id == dvui.focusedWidgetId()); + + if (focused) { + dvui.wantTextInput(self.data().borderRectScale().r.toNatural()); + } + + // set clip back to what textLayout had, so we don't draw over the scrollbars + dvui.clipSet(self.textClip); + + if (self.init_opts.cache_layout) { + self.textLayout.cache_layout_bytes = self.textLayout.bytesNeeded( + self.text_changed_start, + self.text_changed_end, + self.text_changed_added, + ); + } +} + +pub fn drawAfterText(self: *TextEntryWidget) void { + const focused = (self.data().id == dvui.focusedWidgetId()); + if (focused) { + self.drawCursor(); + } + + dvui.clipSet(self.prevClip); + + if (focused and self.init_opts.focus_border) { + self.data().focusBorder(); + } +} + +pub fn drawCursor(self: *TextEntryWidget) void { + var sel = self.textLayout.selectionGet(self.len); + if (sel.empty()) { + // the cursor can be slightly outside the textLayout clip + dvui.clipSet(self.scrollClip); + + var crect = self.textLayout.cursor_rect.plus(.{ .x = -1 }); + crect.w = 2; + self.textLayout.screenRectScale(crect).r.fill(.{}, .{ .color = dvui.themeGet().focus, .fade = 1.0 }); + } +} + +pub fn widget(self: *TextEntryWidget) Widget { + return Widget.init(self, data, rectFor, screenRectScale, minSizeForChild); +} + +pub fn data(self: *TextEntryWidget) *WidgetData { + return self.wd.validate(); +} + +pub fn rectFor(self: *TextEntryWidget, id: dvui.Id, min_size: Size, e: Options.Expand, g: Options.Gravity) Rect { + _ = id; + return dvui.placeIn(self.data().contentRect().justSize(), min_size, e, g); +} + +pub fn screenRectScale(self: *TextEntryWidget, rect: Rect) RectScale { + return self.data().contentRectScale().rectToRectScale(rect); +} + +pub fn minSizeForChild(self: *TextEntryWidget, s: Size) void { + self.data().minSizeMax(self.data().options.padSize(s)); +} + +pub fn textChangedRemoved(self: *TextEntryWidget, start: usize, end: usize) void { + self.textChanged(start, end, @as(i64, @intCast(start)) - @as(i64, @intCast(end))); +} + +// Inserting text is at a single point in the previous frame's indexing. +pub fn textChangedAdded(self: *TextEntryWidget, pos: usize, added: usize) void { + self.textChanged(pos, pos, @intCast(added)); +} + +// Only needed when cache_layout is true. We are maintaining an interval of +// bytes from last frame plus a total number added (might be negative) in that +// interval. This is sent to textLayout so it will process at least this +// interval (plus whatever is visible). +pub fn textChanged(self: *TextEntryWidget, start: usize, end: usize, added: i64) void { + self.text_changed = true; + if (end > self.text_changed_start) { + // end is in current bytes, so we update it to previous frame's indexing + var end_old: usize = undefined; + if (self.text_changed_added >= 0) { + end_old = end - @as(usize, @intCast(self.text_changed_added)); + } else { + end_old = end + @as(usize, @intCast(-self.text_changed_added)); + } + // This assumes that the current update happens after (in bytes) all + // previous updates. This is not exact, but will always give an + // interval that includes all the updates. + self.text_changed_end = @max(self.text_changed_end, end_old); + } else { + // before previous updates then indexing is the same + self.text_changed_end = @max(self.text_changed_end, end); + } + + // if we are before the previous updates then the indexing is the same + self.text_changed_start = @min(self.text_changed_start, start); + self.text_changed_added += added; + + if (self.textLayout.add_text_done) { + self.edited_outside_last_frame.* = true; + } + + //std.debug.print("textChanged {d} {d} {d}\n", .{ self.text_changed_start, self.text_changed_end, self.text_changed_added }); +} + +/// Return text as a slice to the backing storage. The returned slice is +/// valid after `deinit`, and is only invalidated by events or functions that +/// change the text (like `textSet` or `paste`). +pub fn textGet(self: *const TextEntryWidget) []u8 { + return self.text[0..self.len]; +} + +/// Deprecated in favor of `textGet`. +pub fn getText(self: *const TextEntryWidget) []u8 { + return self.textGet(); +} + +pub fn textSet(self: *TextEntryWidget, text: []const u8, selected: bool) void { + self.textLayout.selection.selectAll(); + self.textTyped(text, selected); +} + +pub fn textTyped(self: *TextEntryWidget, new: []const u8, selected: bool) void { + // strip out carriage returns, which we get from copy/paste on windows + if (std.mem.findScalar(u8, new, '\r')) |idx| { + self.textTyped(new[0..idx], selected); + self.textTyped(new[idx + 1 ..], selected); + return; + } + + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.len -= (sel.end - sel.start); + sel.end = sel.start; + sel.cursor = sel.start; + } + + const space_left = self.text.len - self.len; + if (space_left < new.len) { + var new_size = realloc_bin_size * (@divTrunc(self.len + new.len, realloc_bin_size) + 1); + switch (self.init_opts.text) { + .buffer => {}, + .buffer_dynamic => |b| { + new_size = @min(new_size, b.limit); + b.backing.* = b.allocator.realloc(self.text, new_size) catch |err| blk: { + dvui.logError(@src(), err, "{x} TextEntryWidget.textTyped failed to realloc backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_size }); + break :blk b.backing.*; + }; + self.text = b.backing.*; + }, + .array_list => |al| { + new_size = @min(new_size, al.limit); + al.backing.ensureTotalCapacity(al.allocator, new_size) catch |err| { + dvui.logError(@src(), err, "{x} TextEntryWidget.textTyped failed to realloc ArrayList backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_size }); + }; + self.text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; + }, + .internal => |i| { + new_size = @min(new_size, i.limit); + // If we are the same size then there is no work to do + // This is important because same sized data allocations will be reused + if (new_size != self.text.len) { + // NOTE: Using prev_text is safe because data is trashed and stays valid until the end of the frame + const prev_text = self.text; + dvui.dataSetSliceCopies(null, self.data().id, "_buffer", &[_]u8{0}, new_size); + self.text = dvui.dataGetSlice(null, self.data().id, "_buffer", []u8).?; + const min_len = @min(prev_text.len, self.text.len); + if (self.text.ptr != prev_text.ptr) { + @memcpy(self.text[0..min_len], prev_text[0..min_len]); + } + } + }, + } + } + var new_len = @min(new.len, self.text.len - self.len); + + // find start of last utf8 char + var last: usize = new_len -| 1; + while (last < new_len and new[last] & 0xc0 == 0x80) { + last -|= 1; + } + + // if the last utf8 char can't fit, don't include it + if (last < new_len) { + const utf8_size = std.unicode.utf8ByteSequenceLength(new[last]) catch 0; + if (utf8_size != (new_len - last)) { + new_len = last; + } + } + + // make room if we can + if (new_len > 0 and sel.cursor + new_len < self.text.len) { + @memmove(self.text[sel.cursor + new_len ..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); + } + + if (new_len > 0) { + self.textChangedAdded(sel.cursor, new_len); + } + + // update our len and maintain 0 termination if possible + self.setLen(self.len + new_len); + + // insert + @memmove(self.text[sel.cursor..][0..new_len], new[0..new_len]); + if (selected) { + sel.start = sel.cursor; + sel.cursor += new_len; + sel.end = sel.cursor; + } else { + sel.cursor += new_len; + sel.end = sel.cursor; + sel.start = sel.cursor; + } + if (std.mem.findScalar(u8, new[0..new_len], '\n') != null) { + sel.affinity = .after; + } + + // we might have dropped to a new line, so make sure the cursor is visible + self.textLayout.scroll_to_cursor_next_frame = true; + dvui.refresh(null, @src(), self.data().id); +} + +/// Remove all characters that not present in filter_chars. +/// Designed to run after event processing and before drawing. +pub fn filterIn(self: *TextEntryWidget, filter_chars: []const u8) void { + if (filter_chars.len == 0) { + return; + } + + var i: usize = 0; + var j: usize = 0; + const n = self.len; + while (i < n) { + if (std.mem.findScalar(u8, filter_chars, self.text[i]) == null) { + self.len -= 1; + var sel = self.textLayout.selection; + if (sel.start > i) sel.start -= 1; + if (sel.cursor > i) sel.cursor -= 1; + if (sel.end > i) sel.end -= 1; + self.text_changed = true; + + i += 1; + } else { + self.text[j] = self.text[i]; + i += 1; + j += 1; + } + } + + if (j < self.text.len) + self.text[j] = 0; +} + +/// Remove all instances of the string needle. +/// Designed to run after event processing and before drawing. +pub fn filterOut(self: *TextEntryWidget, needle: []const u8) void { + if (needle.len == 0) { + return; + } + + var i: usize = 0; + var j: usize = 0; + const n = self.len; + while (i < n) { + if (std.mem.startsWith(u8, self.text[i..], needle)) { + self.len -= needle.len; + var sel = self.textLayout.selection; + if (sel.start > i) sel.start -= needle.len; + if (sel.cursor > i) sel.cursor -= needle.len; + if (sel.end > i) sel.end -= needle.len; + self.text_changed = true; + + i += needle.len; + } else { + self.text[j] = self.text[i]; + i += 1; + j += 1; + } + } + + if (j < self.text.len) + self.text[j] = 0; +} + +/// Sets the new length and does fixups: +/// - add null terminator if there is space +/// - shrink allocation if needed +/// - fixup array_list backing +pub fn setLen(self: *TextEntryWidget, newlen: usize) void { + self.len = newlen; + + // add null terminator if there is space + if (self.len < self.text.len) { + self.text[self.len] = 0; + } + + // shrink allocation if needed + const needed_binds = @divTrunc(self.len, realloc_bin_size) + 1; + const current_bins = @divTrunc(self.text.len, realloc_bin_size); + // dvui.log.debug("TextEntry {x} needs {d} bins, has {d}", .{ self.data().id, needed_binds, current_bins }); + if (self.len == 0 or needed_binds < current_bins) { + // we want to shrink the allocation + const new_len = if (self.len == 0) 0 else realloc_bin_size * needed_binds; + switch (self.init_opts.text) { + .buffer => {}, + .buffer_dynamic => |b| { + if (b.allocator.resize(self.text, new_len)) { + b.backing.*.len = new_len; + self.text.len = new_len; + } else { + dvui.logError(@src(), std.mem.Allocator.Error.OutOfMemory, "{x} TextEntryWidget.textTyped failed to realloc backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_len }); + } + }, + .array_list => |al| { + if (new_len < al.backing.capacity / 2) { + al.backing.items.len = al.backing.capacity; + al.backing.shrinkAndFree(al.allocator, new_len); + self.text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; + } + }, + .internal => { + // NOTE: Using prev_text is safe because data is trashed and stays valid until the end of the frame + const prev_text = self.text; + dvui.dataSetSliceCopies(null, self.data().id, "_buffer", &[_]u8{0}, new_len); + self.text = dvui.dataGetSlice(null, self.data().id, "_buffer", []u8).?; + const min_len = @min(prev_text.len, self.text.len); + @memcpy(self.text[0..min_len], prev_text[0..min_len]); + }, + } + } + + // fixup array_list backing + switch (self.init_opts.text) { + .array_list => |al| { + al.backing.items.len = self.len; + }, + else => {}, + } +} + +pub fn processEvent(self: *TextEntryWidget, e: *Event) void { + // scroll gets first crack, because it is logically outside the text area + self.scroll.scroll.?.processEvent(e); + if (e.handled) return; + + switch (e.evt) { + .key => |ke| blk: { + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("next_widget")) { + e.handle(@src(), self.data()); + dvui.tabIndexNext(e.num); + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("prev_widget")) { + e.handle(@src(), self.data()); + dvui.tabIndexPrev(e.num); + break :blk; + } + + if (ke.action == .down and ke.matchBind("paste")) { + e.handle(@src(), self.data()); + self.paste(); + break :blk; + } + + if (ke.action == .down and ke.matchBind("cut")) { + e.handle(@src(), self.data()); + self.cut(); + break :blk; + } + + if (ke.action == .down and ke.matchBind("copy")) { + e.handle(@src(), self.data()); + self.copy(); + break :blk; + } + + if (ke.action == .down and ke.matchBind("text_start")) { + e.handle(@src(), self.data()); + self.textLayout.selection.moveCursor(0, false); + self.textLayout.scroll_to_cursor = true; + break :blk; + } + + if (ke.action == .down and ke.matchBind("text_end")) { + e.handle(@src(), self.data()); + self.textLayout.selection.moveCursor(std.math.maxInt(usize), false); + self.textLayout.scroll_to_cursor = true; + break :blk; + } + + if (ke.action == .down and ke.matchBind("line_start")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .home } }; + } + break :blk; + } + + if (ke.action == .down and ke.matchBind("line_end")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .end } }; + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_left")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .word_left_right) { + self.textLayout.sel_move.word_left_right.count -= 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_right")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); + self.textLayout.selection.affinity = .before; + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .word_left_right) { + self.textLayout.sel_move.word_left_right.count += 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_left")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .char_left_right) { + self.textLayout.sel_move.char_left_right.count -= 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_right")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); + self.textLayout.selection.affinity = .before; + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .char_left_right) { + self.textLayout.sel_move.char_left_right.count += 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_up")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; + } + if (self.textLayout.sel_move == .cursor_updown) { + self.textLayout.sel_move.cursor_updown.count -= 1; + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_down")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; + } + if (self.textLayout.sel_move == .cursor_updown) { + self.textLayout.sel_move.cursor_updown.count += 1; + } + break :blk; + } + + switch (ke.code) { + .backspace => { + if (ke.action == .down or ke.action == .repeat) { + e.handle(@src(), self.data()); + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // just delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.setLen(self.len - (sel.end - sel.start)); + sel.end = sel.start; + sel.cursor = sel.start; + self.textLayout.scroll_to_cursor = true; + } else if (ke.matchBind("delete_prev_word")) { + // delete word before cursor + + const oldcur = sel.cursor; + // find end of last word + if (sel.cursor > 0 and std.mem.findAny(u8, self.text[sel.cursor - 1 ..][0..1], " \n") != null) { + sel.cursor = std.mem.findLastNone(u8, self.text[0..sel.cursor], " \n") orelse 0; + } + + // find start of word + if (std.mem.findLastAny(u8, self.text[0..sel.cursor], " \n")) |last_space| { + sel.cursor = last_space + 1; + } else { + sel.cursor = 0; + } + + // delete from sel.cursor to oldcur + if (sel.cursor != oldcur) self.textChangedRemoved(sel.cursor, oldcur); + @memmove(self.text[sel.cursor..][0 .. self.len - oldcur], self.text[oldcur..self.len]); + self.setLen(self.len - (oldcur - sel.cursor)); + sel.end = sel.cursor; + sel.start = sel.cursor; + self.textLayout.scroll_to_cursor = true; + } else if (sel.cursor > 0) { + // delete character just before cursor + // + // A utf8 char might consist of more than one byte. + // Find the beginning of the last byte by iterating over + // the string backwards. The first byte of a utf8 char + // does not have the pattern 10xxxxxx. + var i: usize = 1; + while (sel.cursor - i > 0 and self.text[sel.cursor - i] & 0xc0 == 0x80) : (i += 1) {} + self.textChangedRemoved(sel.cursor - i, sel.cursor); + @memmove(self.text[sel.cursor - i ..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); + self.setLen(self.len - i); + sel.cursor -= i; + sel.start = sel.cursor; + sel.end = sel.cursor; + self.textLayout.scroll_to_cursor = true; + } + } + }, + .delete => { + if (ke.action == .down or ke.action == .repeat) { + e.handle(@src(), self.data()); + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // just delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.setLen(self.len - (sel.end - sel.start)); + sel.end = sel.start; + sel.cursor = sel.start; + self.textLayout.scroll_to_cursor = true; + } else if (ke.matchBind("delete_next_word")) { + // delete word after cursor + + const oldcur = sel.cursor; + // find start of next word + if (sel.cursor < self.len and std.mem.findAny(u8, self.text[sel.cursor..][0..1], " \n") != null) { + sel.cursor = std.mem.findNonePos(u8, self.text, sel.cursor, " \n") orelse self.len; + } + + // find end of word + if (std.mem.findAny(u8, self.text[sel.cursor..self.len], " \n")) |last_space| { + sel.cursor = sel.cursor + last_space; + } else { + sel.cursor = self.len; + } + + // delete from oldcur to sel.cursor + if (sel.cursor != oldcur) self.textChangedRemoved(oldcur, sel.cursor); + @memmove(self.text[oldcur..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); + self.setLen(self.len - (sel.cursor - oldcur)); + sel.cursor = oldcur; + sel.end = sel.cursor; + sel.start = sel.cursor; + self.textLayout.scroll_to_cursor = true; + } else if (sel.cursor < self.len) { + // delete the character just after the cursor + // + // A utf8 char might consist of more than one byte. + const ii = std.unicode.utf8ByteSequenceLength(self.text[sel.cursor]) catch 1; + const i = @min(ii, self.len - sel.cursor); + + self.textChangedRemoved(sel.cursor, sel.cursor + i); + const remaining = self.len - (sel.cursor + i); + @memmove(self.text[sel.cursor..][0..remaining], self.text[sel.cursor + i ..][0..remaining]); + self.setLen(self.len - i); + self.textLayout.scroll_to_cursor = true; + } + } + }, + .enter => { + if (ke.action == .down or ke.action == .repeat) { + e.handle(@src(), self.data()); + if (self.init_opts.multiline) { + self.textTyped("\n", false); + } else if (ke.action == .down) { + self.enter_pressed = true; + dvui.refresh(null, @src(), self.data().id); + } + } + }, + else => {}, + } + }, + .text => |te| { + switch (te.action) { + .value => |set| { + e.handle(@src(), self.data()); + var new = std.mem.sliceTo(set.txt, 0); + if (self.init_opts.multiline) { + self.textTyped(new, set.selected); + } else { + var i: usize = 0; + while (i < new.len) { + if (std.mem.findScalar(u8, new[i..], '\n')) |idx| { + self.textTyped(new[i..][0..idx], set.selected); + i += idx + 1; + } else { + self.textTyped(new[i..], set.selected); + break; + } + } + } + }, + else => {}, + } + }, + .mouse => |me| { + if (me.action == .focus) { + e.handle(@src(), self.data()); + dvui.focusWidget(self.data().id, null, e.num); + } + }, + else => {}, + } + + if (!e.handled) { + self.textLayout.processEvent(e); + + if (!e.handled and e.evt == .key) { + switch (e.evt.key.code) { + .page_up, .page_down => {}, // handled by scroll container + else => { + // Mark all remaining key events as handled. This allows + // checking a keybind (like "d") after the textEntry, but + // where textEntry will get it first. + e.handle(@src(), self.data()); + }, + } + } + } +} + +pub fn paste(self: *TextEntryWidget) void { + const clip_text = dvui.clipboardText(); + + if (self.init_opts.multiline) { + self.textTyped(clip_text, false); + } else { + var i: usize = 0; + while (i < clip_text.len) { + if (std.mem.findScalar(u8, clip_text[i..], '\n')) |idx| { + self.textTyped(clip_text[i..][0..idx], false); + i += idx + 1; + } else { + self.textTyped(clip_text[i..], false); + break; + } + } + } +} + +pub fn cut(self: *TextEntryWidget) void { + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // copy selection to clipboard + dvui.clipboardTextSet(self.text[sel.start..sel.end]); + + // delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.setLen(self.len - (sel.end - sel.start)); + sel.end = sel.start; + sel.cursor = sel.start; + self.textLayout.scroll_to_cursor = true; + } +} + +/// This could use textLayout.copy(), but that doesn't work if we have a masked +/// password field (textLayout only sees the password char). +pub fn copy(self: *TextEntryWidget) void { + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // copy selection to clipboard + dvui.clipboardTextSet(self.text[sel.start..sel.end]); + } +} + +pub fn deinit(self: *TextEntryWidget) void { + defer if (dvui.widgetIsAllocated(self)) dvui.widgetFree(self); + defer self.* = undefined; + + // set clip back to what textLayout had, because it might need it to set + // the mouse cursor + dvui.clipSet(self.textClip); + self.textLayout.deinit(); + self.scroll.deinit(); + + dvui.clipSet(self.prevClip); + self.data().minSizeSetAndRefresh(); + self.data().minSizeReportToParent(); + dvui.parentReset(self.data().id, self.data().parent); +} + +/// Same lifecycle as `dvui.textEntry`. +pub fn textEntry(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) *TextEntryWidget { + var ret = dvui.widgetAlloc(TextEntryWidget); + ret.init(src, init_opts, opts); + ret.processEvents(); + ret.draw(); + return ret; +} + +test { + @import("std").testing.refAllDecls(@This()); +} + +test "text internal" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + + // Set a limit that is not a multiple of the bin size + const limit = realloc_bin_size * 5 / 2; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ + .text = .{ .internal = .{ .limit = limit } }, + }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "This is some short sample text!"; + // text length should not be a multiple of the limit or bin size + try std.testing.expect(Local.limit % text.len != 0); + try std.testing.expect(realloc_bin_size % text.len != 0); + + try dvui.testing.writeText(text); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); + + for (0..@divFloor(Local.limit, text.len)) |_| { + // Fill the internal buffer + try dvui.testing.writeText(text); + } + try dvui.testing.settle(Local.frame); + + const full_text_buffer = comptime blk: { + var text_buf: []const u8 = text; + while (text_buf.len < Local.limit) text_buf = text_buf ++ text; + break :blk text_buf; + }[0..Local.limit]; + try std.testing.expectEqualStrings(full_text_buffer, Local.text); +} + +test "text dynamic buffer" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + + // Set a limit that is not a multiple of the bin size + const limit = realloc_bin_size * 5 / 2; + + var buffer: [limit]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buffer); + var backing: []u8 = &.{}; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ + .text = .{ .buffer_dynamic = .{ + .backing = &backing, + .allocator = fba.allocator(), + .limit = limit, + } }, + }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "This is some short sample text!"; + // limit should not be a multiple of the text length + try std.testing.expect(Local.limit % text.len != 0); + try std.testing.expect(realloc_bin_size % text.len != 0); + + try dvui.testing.writeText(text); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); + + for (0..@divFloor(Local.limit, text.len)) |_| { + // Fill the internal buffer + // This verifies that any OOM error is handled by writing past the buffer size + try dvui.testing.writeText(text); + } + try dvui.testing.settle(Local.frame); + + const full_text_buffer = comptime blk: { + var text_buf: []const u8 = text; + while (text_buf.len < Local.limit) text_buf = text_buf ++ text; + break :blk text_buf; + }[0..Local.limit]; + try std.testing.expectEqualStrings(full_text_buffer, Local.text); +} + +test "text buffer" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + + // Set a limit that is not a multiple of the bin size + const limit = realloc_bin_size * 5 / 2; + + var buffer: [limit]u8 = undefined; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ + .text = .{ .buffer = &buffer }, + }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "This is some short sample text!"; + // limit should not be a multiple of the text length + try std.testing.expect(Local.limit % text.len != 0); + try std.testing.expect(realloc_bin_size % text.len != 0); + + try dvui.testing.writeText(text); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); + + for (0..@divFloor(Local.limit, text.len)) |_| { + // Fill the internal buffer + // This verifies that any OOM error is handled by writing past the buffer size + try dvui.testing.writeText(text); + } + try dvui.testing.settle(Local.frame); + + const full_text_buffer = comptime blk: { + var text_buf: []const u8 = text; + while (text_buf.len < Local.limit) text_buf = text_buf ++ text; + break :blk text_buf; + }[0..Local.limit]; + try std.testing.expectEqualStrings(full_text_buffer, Local.text); +} + +test "text array_list" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + var al: std.ArrayList(u8) = .empty; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ .text = .{ .array_list = .{ + .backing = &al, + .allocator = std.testing.allocator, + } } }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + + return .ok; + } + }; + + defer Local.al.deinit(std.testing.allocator); + + _ = try dvui.testing.step(Local.frame); + try dvui.testing.pressKey(.tab, .none); + _ = try dvui.testing.step(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "Testing text"; + try dvui.testing.writeText(text); + _ = try dvui.testing.step(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); +} diff --git a/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig b/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig new file mode 100644 index 00000000..e1ddd0c8 --- /dev/null +++ b/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig @@ -0,0 +1,147 @@ +//! Evaluate standard tree-sitter query text predicates (#eq?, #match?, #lua-match?, #any-of?). +const std = @import("std"); +const code = @import("../../code.zig"); +const dvui = code.dvui; + +const c = dvui.c; + +const Step = c.TSQueryPredicateStep; +const step_done = c.TSQueryPredicateStepTypeDone; +const step_capture = c.TSQueryPredicateStepTypeCapture; +const step_string = c.TSQueryPredicateStepTypeString; + +fn captureText(source: []const u8, node: c.TSNode) []const u8 { + const start: usize = @intCast(c.ts_node_start_byte(node)); + const end: usize = @intCast(c.ts_node_end_byte(node)); + return source[start..end]; +} + +fn textForCaptureId(match: c.TSQueryMatch, source: []const u8, capture_id: u32) ?[]const u8 { + var i: u16 = 0; + while (i < match.capture_count) : (i += 1) { + const cap = match.captures[i]; + if (cap.index == capture_id) return captureText(source, cap.node); + } + return null; +} + +fn queryString(query: *const c.TSQuery, id: u32) []const u8 { + var len: u32 = undefined; + const ptr = c.ts_query_string_value_for_id(query, id, &len); + return ptr[0..len]; +} + +fn isIdentChar(ch: u8) bool { + return std.ascii.isAlphanumeric(ch) or ch == '_'; +} + +fn isPascalTypeName(text: []const u8) bool { + if (text.len == 0) return false; + const c0 = text[0]; + if (c0 != '_' and (c0 < 'A' or c0 > 'Z')) return false; + for (text[1..]) |ch| if (!isIdentChar(ch)) return false; + return true; +} + +fn isCamelFunctionName(text: []const u8) bool { + if (text.len == 0) return false; + const c0 = text[0]; + if (c0 != '_' and (c0 < 'a' or c0 > 'z')) return false; + for (text[1..]) |ch| if (!isIdentChar(ch)) return false; + return true; +} + +fn isScreamingConstant(text: []const u8) bool { + if (text.len == 0) return false; + if (text[0] < 'A' or text[0] > 'Z') return false; + for (text) |ch| { + if (ch >= 'A' and ch <= 'Z') continue; + if (ch >= '0' and ch <= '9') continue; + if (ch == '_') continue; + return false; + } + return true; +} + +fn regexMatch(text: []const u8, pattern: []const u8) bool { + if (std.mem.eql(u8, pattern, "^[A-Z_][a-zA-Z0-9_]*")) return isPascalTypeName(text); + if (std.mem.eql(u8, pattern, "^[A-Z][A-Z_0-9]+$")) return isScreamingConstant(text); + if (std.mem.eql(u8, pattern, "^[a-z_][a-zA-Z0-9_]*$")) return isCamelFunctionName(text); + if (std.mem.eql(u8, pattern, "^//!")) return std.mem.startsWith(u8, text, "//!"); + if (std.mem.startsWith(u8, pattern, "^") and std.mem.endsWith(u8, pattern, "$")) { + return std.mem.eql(u8, text, pattern[1 .. pattern.len - 1]); + } + if (std.mem.startsWith(u8, pattern, "^")) { + return std.mem.startsWith(u8, text, pattern[1..]); + } + return std.mem.eql(u8, text, pattern); +} + +fn evalPredicate( + query: *const c.TSQuery, + match: c.TSQueryMatch, + source: []const u8, + steps: []const Step, +) bool { + if (steps.len == 0) return true; + if (steps[0].type != step_string) return true; + + const op = queryString(query, steps[0].value_id); + + if (std.mem.eql(u8, op, "set!")) return true; + + if (std.mem.eql(u8, op, "eq?") or std.mem.eql(u8, op, "not-eq?")) { + if (steps.len != 3 or steps[1].type != step_capture) return true; + const positive = std.mem.eql(u8, op, "eq?"); + const cap_text = textForCaptureId(match, source, steps[1].value_id) orelse return !positive; + const expected = if (steps[2].type == step_string) + queryString(query, steps[2].value_id) + else + textForCaptureId(match, source, steps[2].value_id) orelse return !positive; + const matched = std.mem.eql(u8, cap_text, expected); + return if (positive) matched else !matched; + } + + if (std.mem.eql(u8, op, "match?") or std.mem.eql(u8, op, "not-match?") or + std.mem.eql(u8, op, "lua-match?") or std.mem.eql(u8, op, "not-lua-match?")) + { + if (steps.len != 3 or steps[1].type != step_capture or steps[2].type != step_string) return true; + const positive = std.mem.eql(u8, op, "match?") or std.mem.eql(u8, op, "lua-match?"); + const cap_text = textForCaptureId(match, source, steps[1].value_id) orelse return !positive; + const pattern = queryString(query, steps[2].value_id); + const matched = regexMatch(cap_text, pattern); + return if (positive) matched else !matched; + } + + if (std.mem.eql(u8, op, "any-of?") or std.mem.eql(u8, op, "not-any-of?")) { + if (steps.len < 3 or steps[1].type != step_capture) return true; + const positive = std.mem.eql(u8, op, "any-of?"); + const cap_text = textForCaptureId(match, source, steps[1].value_id) orelse return !positive; + var i: usize = 2; + while (i < steps.len) : (i += 1) { + if (steps[i].type != step_string) continue; + if (std.mem.eql(u8, cap_text, queryString(query, steps[i].value_id))) { + return positive; + } + } + return !positive; + } + + return true; +} + +pub fn matchApplies(query: *const c.TSQuery, match: c.TSQueryMatch, source: []const u8) bool { + var step_count: u32 = undefined; + const steps = c.ts_query_predicates_for_pattern(query, match.pattern_index, &step_count); + if (step_count == 0) return true; + + var i: u32 = 0; + while (i < step_count) { + const start = i; + while (i < step_count and steps[i].type != step_done) : (i += 1) {} + const pred = steps[start..i]; + if (pred.len > 0 and !evalPredicate(query, match, source, pred)) return false; + i += 1; + } + return true; +} From 595d80c01c7b3a5402152deb0755762a22f74e1a Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 13:19:00 -0500 Subject: [PATCH 48/49] Fix code editor line numbers --- src/plugins/code/src/CodeEditor.zig | 53 ++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/plugins/code/src/CodeEditor.zig b/src/plugins/code/src/CodeEditor.zig index 919e8603..9d6b7610 100644 --- a/src/plugins/code/src/CodeEditor.zig +++ b/src/plugins/code/src/CodeEditor.zig @@ -2,6 +2,7 @@ const std = @import("std"); const code = @import("../code.zig"); const dvui = code.dvui; +const core = code.core; const Document = code.Document; const SyntaxHighlight = @import("SyntaxHighlight.zig"); const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); @@ -33,7 +34,23 @@ pub fn draw(doc: *Document, id_extra: u64, gpa: std.mem.Allocator) !bool { const line_height = font.lineHeight(); const line_num_col = lineNumberColumnWidth(doc.line_count, font); - var te = TextEntryWidget.textEntry(@src(), .{ + var row = dvui.box(@src(), .{ .dir = .horizontal }, chromeless.override(.{ + .expand = .both, + .font = font, + .id_extra = @intCast(id_extra), + })); + defer row.deinit(); + + // Reserve fixed width for the line-number gutter before the text entry init. + const gutter_wd = dvui.spacer(@src(), chromeless.override(.{ + .min_size_content = .{ .w = line_num_col, .h = 1 }, + .expand = .vertical, + .id_extra = @intCast(id_extra + 2), + })); + const gutter_rs = gutter_wd.borderRectScale(); + + var te: TextEntryWidget = undefined; + te.init(@src(), .{ .multiline = true, .break_lines = false, .cache_layout = true, @@ -48,24 +65,30 @@ pub fn draw(doc: *Document, id_extra: u64, gpa: std.mem.Allocator) !bool { .expand = .both, .font = font, .padding = .{ - .x = line_num_col, + .x = 0, .y = editor_pad_y, .w = editor_pad_right, .h = editor_pad_y, }, .color_text = text_color, - .id_extra = @intCast(id_extra), + .id_extra = @intCast(id_extra + 1), })); defer te.deinit(); + te.processEvents(); + te.draw(); drawLineNumbers( - te.data().borderRectScale(), + gutter_rs, doc.line_count, te.scroll.si.viewport.y, font, line_height, ); + const editor_rs = row.data().borderRectScale(); + const scroll_rs = te.scroll.data().contentRectScale(); + drawScrollEdgeShadows(editor_rs, scroll_rs, te.scroll.si); + if (te.text_changed) doc.refreshLineCount(); return te.text_changed; } @@ -78,6 +101,28 @@ fn lineNumberColumnWidth(line_count: usize, font: dvui.Font) f32 { return line_number_pad_left + font.textSize(sample).w + code_gap_after_numbers; } +fn drawScrollEdgeShadows( + vertical_rs: dvui.RectScale, + horizontal_rs: dvui.RectScale, + si: *const dvui.ScrollInfo, +) void { + const vertical_scroll = si.offset(.vertical); + const horizontal_scroll = si.offset(.horizontal); + + if (vertical_scroll > 0.0 and !vertical_rs.r.empty()) { + core.dvui.drawEdgeShadow(vertical_rs, .top, .{}); + } + if (si.virtual_size.h > si.viewport.h and !vertical_rs.r.empty()) { + core.dvui.drawEdgeShadow(vertical_rs, .bottom, .{}); + } + if (si.virtual_size.w > si.viewport.w and !horizontal_rs.r.empty()) { + core.dvui.drawEdgeShadow(horizontal_rs, .right, .{}); + } + if (horizontal_scroll > 0.0 and !horizontal_rs.r.empty()) { + core.dvui.drawEdgeShadow(horizontal_rs, .left, .{}); + } +} + fn drawLineNumbers( rs: dvui.RectScale, line_count: usize, From a416ec38deae7d68d9373bc59126e36bda01b8a7 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 13:47:22 -0500 Subject: [PATCH 49/49] Fix workbench tabs Allow ABI-version mismatches to reject loading plugin Handle loading config area plugins excersize 3rd party plugin Update build files for sdk simplify creating plugin work on rough edges Remove pixel art specific vtable hooks make pixel art specific hooks commands instead finish removing pixel art specifics in EditorAPI begin refining sdk unify builtin and 3rd party plugins split root refine plugin structure across all fix web build fix tab bar on bottom panel make core.gpa not have to be set by plugin authors Fix sprites panel small visual fixes, refresh api refine build.zig's in plugins --- HANDOFF.md | 3 +- build.zig | 1730 +---------------- build.zig.zon | 15 +- build/app.zig | 621 ++++++ build/common.zig | 125 ++ build/exe.zig | 303 +++ build/msvc.zig | 107 + build/package.zig | 261 +++ build/plugins.zig | 12 + build/sdk.zig | 38 + build/web.zig | 210 ++ docs/PLUGINS.md | 537 ++++- docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md | 299 --- docs/PLUGIN_ROUGH_EDGES.md | 232 +++ plugin_sdk.zig | 243 +++ process_assets.zig | 2 +- src/App.zig | 50 +- src/core/dvui.zig | 33 + src/editor/Editor.zig | 442 +++-- src/editor/InstalledPlugins.zig | 89 + src/editor/Menu.zig | 61 +- src/editor/PluginLoader.zig | 193 +- src/editor/PluginLoader_stub.zig | 15 +- src/editor/Settings.zig | 2 +- src/editor/Sidebar.zig | 1 + src/editor/dialogs/Dialogs.zig | 1 + src/editor/dialogs/PluginLoadFailures.zig | 111 ++ src/editor/dialogs/UnsavedClose.zig | 4 +- src/editor/explorer/Explorer.zig | 6 +- src/editor/panel/Panel.zig | 119 +- src/editor/panel/PanelWorkspace.zig | 343 ++++ src/editor/panel/panel_layout.zig | 94 + src/fizzy.zig | 6 +- src/plugins/code/build.zig | 20 + src/plugins/code/build.zig.zon | 20 + src/plugins/code/code.zig | 15 +- src/plugins/code/dylib.zig | 40 - src/plugins/code/module.zig | 10 - src/plugins/code/root.zig | 7 + src/plugins/code/src/Document.zig | 14 +- src/plugins/code/src/Globals.zig | 32 - src/plugins/code/src/State.zig | 8 +- src/plugins/code/src/SyntaxHighlight.zig | 4 +- src/plugins/code/src/plugin.zig | 49 +- .../code/src/widgets/TextEntryWidget.zig | 4 +- .../src/widgets/TreeSitterQueryPredicates.zig | 4 +- src/plugins/code/static/integration.zig | 59 + src/plugins/example/build.zig | 19 + src/plugins/example/build.zig.zon | 19 + src/plugins/example/example.zig | 14 + src/plugins/example/root.zig | 8 + src/plugins/example/src/State.zig | 11 + src/plugins/example/src/plugin.zig | 80 + src/plugins/example/static/integration.zig | 59 + src/plugins/pixelart/dylib.zig | 43 - src/plugins/pixelart/pixelart.zig | 55 - src/plugins/pixelart/src/Colors.zig | 11 - src/plugins/pixelart/src/Globals.zig | 30 - src/plugins/pixi/build.zig | 57 + src/plugins/pixi/build.zig.zon | 28 + .../{pixelart/module.zig => pixi/pixi.zig} | 60 +- src/plugins/pixi/root.zig | 7 + .../{pixelart => pixi}/src/Animation.zig | 0 src/plugins/{pixelart => pixi}/src/Atlas.zig | 0 .../{pixelart => pixi}/src/CanvasData.zig | 61 +- src/plugins/pixi/src/Colors.zig | 11 + src/plugins/{pixelart => pixi}/src/Docs.zig | 8 +- src/plugins/{pixelart => pixi}/src/File.zig | 0 .../{pixelart => pixi}/src/LDTKTileset.zig | 0 src/plugins/{pixelart => pixi}/src/Layer.zig | 0 .../{pixelart => pixi}/src/PackJob.zig | 64 +- src/plugins/{pixelart => pixi}/src/Packer.zig | 78 +- .../{pixelart => pixi}/src/Project.zig | 22 +- .../{pixelart => pixi}/src/Settings.zig | 10 +- src/plugins/{pixelart => pixi}/src/Sprite.zig | 0 src/plugins/{pixelart => pixi}/src/State.zig | 10 +- src/plugins/{pixelart => pixi}/src/Tools.zig | 24 +- .../{pixelart => pixi}/src/Transform.zig | 48 +- .../src/algorithms/algorithms.zig | 0 .../src/algorithms/brezenham.zig | 6 +- .../src/algorithms/reduce.zig | 0 .../{pixelart => pixi}/src/clipboard.zig | 28 +- .../src/deps/msf_gif/fizzy_msf_gif_wasm.c | 0 .../src/deps/msf_gif/msf_gif.c | 0 .../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 => pixi}/src/deps/stbi/zstbi.c | 0 .../src/deps/stbi/zstbi.zig | 0 .../{pixelart => pixi}/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 .../src/deps/zip/src/miniz.h | 0 .../{pixelart => pixi}/src/deps/zip/src/zip.c | 0 .../{pixelart => pixi}/src/deps/zip/src/zip.h | 0 .../{pixelart => pixi}/src/deps/zip/zip.zig | 0 .../{pixelart => pixi}/src/dialogs/Export.zig | 158 +- .../src/dialogs/FlatRasterSaveWarning.zig | 48 +- .../src/dialogs/GridLayout.zig | 118 +- .../src/dialogs/NewFile.zig | 22 +- .../src/dialogs/dimensions_label.zig | 0 .../{pixelart => pixi}/src/doc_bridge.zig | 14 +- .../{pixelart => pixi}/src/doc_lifecycle.zig | 18 +- .../{pixelart => pixi}/src/docs_registry.zig | 14 +- .../src/explorer/project.zig | 69 +- .../src/explorer/sprites.zig | 234 +-- .../{pixelart => pixi}/src/explorer/tools.zig | 160 +- .../{pixelart => pixi}/src/infobar_status.zig | 10 +- .../src/internal/Animation.zig | 0 .../{pixelart => pixi}/src/internal/Atlas.zig | 26 +- .../src/internal/Buffers.zig | 36 +- .../{pixelart => pixi}/src/internal/File.zig | 514 ++--- .../src/internal/History.zig | 134 +- .../{pixelart => pixi}/src/internal/Layer.zig | 90 +- .../src/internal/Palette.zig | 12 +- .../src/internal/Sprite.zig | 0 .../src/internal/grid_layout_validate.zig | 0 .../src/internal/layer_order.zig | 0 .../src/internal/palette_parse.zig | 0 .../{pixelart => pixi}/src/keybind_ticks.zig | 36 +- .../{pixelart => pixi}/src/pack_project.zig | 36 +- .../{pixelart => pixi}/src/panel/sprites.zig | 77 +- src/plugins/{pixelart => pixi}/src/plugin.zig | 223 ++- .../{pixelart => pixi}/src/radial_menu.zig | 52 +- src/plugins/{pixelart => pixi}/src/render.zig | 84 +- src/plugins/pixi/src/runtime.zig | 35 + .../{pixelart => pixi}/src/sprite_render.zig | 22 +- .../{pixelart => pixi}/src/transform_op.zig | 14 +- .../{pixelart => pixi}/src/web_file_io.zig | 8 +- .../src/widgets/CanvasBridge.zig | 12 +- .../src/widgets/FileWidget.zig | 331 ++-- .../src/widgets/ImageWidget.zig | 36 +- src/plugins/pixi/static/integration.zig | 134 ++ src/plugins/shared/build/helpers.zig | 93 + src/plugins/workbench/build.zig | 34 + src/plugins/workbench/build.zig.zon | 24 + src/plugins/workbench/dylib.zig | 44 - src/plugins/workbench/module.zig | 12 - src/plugins/workbench/root.zig | 7 + src/plugins/workbench/src/Globals.zig | 32 - src/plugins/workbench/src/Workbench.zig | 122 +- src/plugins/workbench/src/Workspace.zig | 325 ++-- src/plugins/workbench/src/files.zig | 107 +- src/plugins/workbench/src/plugin.zig | 44 +- src/plugins/workbench/src/runtime.zig | 25 + .../workbench/src/workbench_layout.zig | 10 +- src/plugins/workbench/static/integration.zig | 67 + src/plugins/workbench/workbench.zig | 18 +- src/sdk/EditorAPI.zig | 50 +- src/sdk/Host.zig | 193 +- src/sdk/Plugin.zig | 225 +-- src/sdk/document.zig | 47 + src/sdk/dylib.zig | 265 ++- src/sdk/fingerprint.zig | 150 ++ src/sdk/manifest.zig | 28 + src/sdk/menu.zig | 72 + src/sdk/regions.zig | 34 + src/sdk/runtime.zig | 47 + src/sdk/sdk.zig | 41 +- src/sdk/services/workbench.zig | 78 + src/sdk/version.zig | 57 + src/web_main.zig | 22 +- tests/fizzy_shim.zig | 15 +- tests/integration.zig | 50 +- tests/plugin_loader_integration.zig | 12 +- 169 files changed, 7800 insertions(+), 4792 deletions(-) create mode 100644 build/app.zig create mode 100644 build/common.zig create mode 100644 build/exe.zig create mode 100644 build/msvc.zig create mode 100644 build/package.zig create mode 100644 build/plugins.zig create mode 100644 build/sdk.zig create mode 100644 build/web.zig delete mode 100644 docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md create mode 100644 docs/PLUGIN_ROUGH_EDGES.md create mode 100644 plugin_sdk.zig create mode 100644 src/editor/InstalledPlugins.zig create mode 100644 src/editor/dialogs/PluginLoadFailures.zig create mode 100644 src/editor/panel/PanelWorkspace.zig create mode 100644 src/editor/panel/panel_layout.zig create mode 100644 src/plugins/code/build.zig create mode 100644 src/plugins/code/build.zig.zon delete mode 100644 src/plugins/code/dylib.zig delete mode 100644 src/plugins/code/module.zig create mode 100644 src/plugins/code/root.zig delete mode 100644 src/plugins/code/src/Globals.zig create mode 100644 src/plugins/code/static/integration.zig create mode 100644 src/plugins/example/build.zig create mode 100644 src/plugins/example/build.zig.zon create mode 100644 src/plugins/example/example.zig create mode 100644 src/plugins/example/root.zig create mode 100644 src/plugins/example/src/State.zig create mode 100644 src/plugins/example/src/plugin.zig create mode 100644 src/plugins/example/static/integration.zig delete mode 100644 src/plugins/pixelart/dylib.zig delete mode 100644 src/plugins/pixelart/pixelart.zig delete mode 100644 src/plugins/pixelart/src/Colors.zig delete mode 100644 src/plugins/pixelart/src/Globals.zig create mode 100644 src/plugins/pixi/build.zig create mode 100644 src/plugins/pixi/build.zig.zon rename src/plugins/{pixelart/module.zig => pixi/pixi.zig} (61%) create mode 100644 src/plugins/pixi/root.zig rename src/plugins/{pixelart => pixi}/src/Animation.zig (100%) rename src/plugins/{pixelart => pixi}/src/Atlas.zig (100%) rename src/plugins/{pixelart => pixi}/src/CanvasData.zig (96%) create mode 100644 src/plugins/pixi/src/Colors.zig rename src/plugins/{pixelart => pixi}/src/Docs.zig (87%) rename src/plugins/{pixelart => pixi}/src/File.zig (100%) rename src/plugins/{pixelart => pixi}/src/LDTKTileset.zig (100%) rename src/plugins/{pixelart => pixi}/src/Layer.zig (100%) rename src/plugins/{pixelart => pixi}/src/PackJob.zig (93%) rename src/plugins/{pixelart => pixi}/src/Packer.zig (82%) rename src/plugins/{pixelart => pixi}/src/Project.zig (83%) rename src/plugins/{pixelart => pixi}/src/Settings.zig (97%) rename src/plugins/{pixelart => pixi}/src/Sprite.zig (100%) rename src/plugins/{pixelart => pixi}/src/State.zig (95%) rename src/plugins/{pixelart => pixi}/src/Tools.zig (94%) rename src/plugins/{pixelart => pixi}/src/Transform.zig (86%) rename src/plugins/{pixelart => pixi}/src/algorithms/algorithms.zig (100%) rename src/plugins/{pixelart => pixi}/src/algorithms/brezenham.zig (85%) rename src/plugins/{pixelart => pixi}/src/algorithms/reduce.zig (100%) rename src/plugins/{pixelart => pixi}/src/clipboard.zig (91%) rename src/plugins/{pixelart => pixi}/src/deps/msf_gif/fizzy_msf_gif_wasm.c (100%) rename src/plugins/{pixelart => pixi}/src/deps/msf_gif/msf_gif.c (100%) rename src/plugins/{pixelart => pixi}/src/deps/msf_gif/msf_gif.h (100%) rename src/plugins/{pixelart => pixi}/src/deps/msf_gif/msf_gif.zig (100%) rename src/plugins/{pixelart => pixi}/src/deps/msf_gif/wasm_shim/string.h (100%) rename src/plugins/{pixelart => pixi}/src/deps/stbi/fizzy_stbi_libc.c (100%) rename src/plugins/{pixelart => pixi}/src/deps/stbi/stb_image_resize2.h (100%) rename src/plugins/{pixelart => pixi}/src/deps/stbi/stb_rect_pack.h (100%) rename src/plugins/{pixelart => pixi}/src/deps/stbi/zstbi.c (100%) rename src/plugins/{pixelart => pixi}/src/deps/stbi/zstbi.zig (100%) rename src/plugins/{pixelart => pixi}/src/deps/zip/build.zig (100%) rename src/plugins/{pixelart => pixi}/src/deps/zip/fizzy_zip_libc.c (100%) rename src/plugins/{pixelart => pixi}/src/deps/zip/fizzy_zip_strings.c (100%) rename src/plugins/{pixelart => pixi}/src/deps/zip/fizzy_zip_wasm.h (100%) rename src/plugins/{pixelart => pixi}/src/deps/zip/src/miniz.h (100%) rename src/plugins/{pixelart => pixi}/src/deps/zip/src/zip.c (100%) rename src/plugins/{pixelart => pixi}/src/deps/zip/src/zip.h (100%) rename src/plugins/{pixelart => pixi}/src/deps/zip/zip.zig (100%) rename src/plugins/{pixelart => pixi}/src/dialogs/Export.zig (86%) rename src/plugins/{pixelart => pixi}/src/dialogs/FlatRasterSaveWarning.zig (78%) rename src/plugins/{pixelart => pixi}/src/dialogs/GridLayout.zig (94%) rename src/plugins/{pixelart => pixi}/src/dialogs/NewFile.zig (93%) rename src/plugins/{pixelart => pixi}/src/dialogs/dimensions_label.zig (100%) rename src/plugins/{pixelart => pixi}/src/doc_bridge.zig (91%) rename src/plugins/{pixelart => pixi}/src/doc_lifecycle.zig (92%) rename src/plugins/{pixelart => pixi}/src/docs_registry.zig (67%) rename src/plugins/{pixelart => pixi}/src/explorer/project.zig (89%) rename src/plugins/{pixelart => pixi}/src/explorer/sprites.zig (92%) rename src/plugins/{pixelart => pixi}/src/explorer/tools.zig (91%) rename src/plugins/{pixelart => pixi}/src/infobar_status.zig (93%) rename src/plugins/{pixelart => pixi}/src/internal/Animation.zig (100%) rename src/plugins/{pixelart => pixi}/src/internal/Atlas.zig (80%) rename src/plugins/{pixelart => pixi}/src/internal/Buffers.zig (78%) rename src/plugins/{pixelart => pixi}/src/internal/File.zig (89%) rename src/plugins/{pixelart => pixi}/src/internal/History.zig (88%) rename src/plugins/{pixelart => pixi}/src/internal/Layer.zig (84%) rename src/plugins/{pixelart => pixi}/src/internal/Palette.zig (82%) rename src/plugins/{pixelart => pixi}/src/internal/Sprite.zig (100%) rename src/plugins/{pixelart => pixi}/src/internal/grid_layout_validate.zig (100%) rename src/plugins/{pixelart => pixi}/src/internal/layer_order.zig (100%) rename src/plugins/{pixelart => pixi}/src/internal/palette_parse.zig (100%) rename src/plugins/{pixelart => pixi}/src/keybind_ticks.zig (66%) rename src/plugins/{pixelart => pixi}/src/pack_project.zig (89%) rename src/plugins/{pixelart => pixi}/src/panel/sprites.zig (96%) rename src/plugins/{pixelart => pixi}/src/plugin.zig (79%) rename src/plugins/{pixelart => pixi}/src/radial_menu.zig (81%) rename src/plugins/{pixelart => pixi}/src/render.zig (93%) create mode 100644 src/plugins/pixi/src/runtime.zig rename src/plugins/{pixelart => pixi}/src/sprite_render.zig (98%) rename src/plugins/{pixelart => pixi}/src/transform_op.zig (94%) rename src/plugins/{pixelart => pixi}/src/web_file_io.zig (85%) rename src/plugins/{pixelart => pixi}/src/widgets/CanvasBridge.zig (66%) rename src/plugins/{pixelart => pixi}/src/widgets/FileWidget.zig (95%) rename src/plugins/{pixelart => pixi}/src/widgets/ImageWidget.zig (94%) create mode 100644 src/plugins/pixi/static/integration.zig create mode 100644 src/plugins/shared/build/helpers.zig create mode 100644 src/plugins/workbench/build.zig create mode 100644 src/plugins/workbench/build.zig.zon delete mode 100644 src/plugins/workbench/dylib.zig delete mode 100644 src/plugins/workbench/module.zig create mode 100644 src/plugins/workbench/root.zig delete mode 100644 src/plugins/workbench/src/Globals.zig create mode 100644 src/plugins/workbench/src/runtime.zig create mode 100644 src/plugins/workbench/static/integration.zig create mode 100644 src/sdk/document.zig create mode 100644 src/sdk/fingerprint.zig create mode 100644 src/sdk/manifest.zig create mode 100644 src/sdk/menu.zig create mode 100644 src/sdk/runtime.zig create mode 100644 src/sdk/services/workbench.zig create mode 100644 src/sdk/version.zig diff --git a/HANDOFF.md b/HANDOFF.md index 45952f43..75f7222c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -192,6 +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.x** | dvui fingerprint gate (replaces version string) | ✅ Done — comptime FNV-1a over `@sizeOf` of boundary types (`Window`, `Debug`, `Vertex`, `Texture`, `TextureTarget`, `Rect.Physical`, `Id`) | | **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) | ✅ Done | @@ -204,7 +205,7 @@ Built-ins can remain **statically linked during 5b** and flip to dylib in 5c — |------|------|-------| | **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.3** | **User plugin directory** + discovery | ✅ Done — `Editor.loadUserPlugins` scans `/plugins//plugin.` on launch; ABI + dvui-fingerprint gated; built-in IDs always win; failures logged and skipped | | **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) diff --git a/build.zig b/build.zig index feda858c..61a6daf6 100644 --- a/build.zig +++ b/build.zig @@ -1,139 +1,16 @@ const std = @import("std"); -const zip = @import("src/plugins/pixelart/src/deps/zip/build.zig"); +pub const plugin = @import("plugin_sdk.zig"); const dvui = @import("dvui"); const velopack = @import("velopack_zig"); -const content_dir = "assets/"; - const ProcessAssetsStep = @import("process_assets.zig"); -const update = @import("update.zig"); -const GitDependency = update.GitDependency; -fn update_step(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void { - const deps = &.{ - GitDependency{ - // zig_objc - .url = "https://github.com/foxnne/zig-objc", - .branch = "main", - }, - GitDependency{ - // zigwin32 (kristoff-it fork has the zig 0.16 fix branch) - .url = "https://github.com/kristoff-it/zigwin32", - .branch = "fix/zig16", - }, - GitDependency{ - // icons - .url = "https://github.com/foxnne/zig-lib-icons", - .branch = "dvui", - }, - GitDependency{ - // dvui - .url = "https://github.com/foxnne/dvui-dev", - .branch = "main", - }, - }; - try update.update_dependency(step.owner.allocator, step.owner.graph.io, deps); -} - -/// Installed artifacts go under `zig-out//…` so `packageall` and parallel targets never clobber each other. -/// Uses `arm64` (not `aarch64`) for Apple Silicon / arm64 Linux and Windows to match the six release triples. -/// -/// Segment separator is `-` only: `vpk pack --channel` is merged into filenames that get parsed as NuGet -/// versions (e.g. `1.2.3--full.nupkg`), and NuGet prerelease labels must not contain `_`. -fn zigOutSubdirForTarget(b: *std.Build, rt: std.Build.ResolvedTarget) []const u8 { - const arch_name: []const u8 = switch (rt.result.cpu.arch) { - .x86_64 => "x86-64", - .aarch64 => "arm64", - else => @tagName(rt.result.cpu.arch), - }; - const os_name: []const u8 = switch (rt.result.os.tag) { - .windows => "windows", - .linux => "linux", - .macos => "macos", - else => @tagName(rt.result.os.tag), - }; - const base = b.fmt("{s}-{s}", .{ arch_name, os_name }); - if (std.mem.indexOfScalar(u8, base, '_') == null) - return base; - const buf = b.allocator.alloc(u8, base.len) catch @panic("OOM"); - @memcpy(buf, base); - for (buf) |*byte| { - if (byte.* == '_') byte.* = '-'; - } - return buf; -} - -/// SDL (via dvui → lazy `sdl3`) requires SDK layout when `-Dtarget=*-macos` is not "native" -/// (`target.query.isNative()` is false). Do not set the root `b.sysroot` for that: it skews -/// the main link (objc, libc paths). Forward include / framework / lib paths into dvui instead. -const MacosSdlPaths = struct { - include: std.Build.LazyPath, - framework: std.Build.LazyPath, - lib: std.Build.LazyPath, -}; - -fn resolveMacosSdkPath(b: *std.Build) ![]const u8 { - if (b.graph.environ_map.get("SDKROOT")) |sdk| { - const trimmed = std.mem.trim(u8, sdk, " \t\r\n"); - if (trimmed.len > 0) { - return b.dupePath(trimmed); - } - } - - const argv: []const []const u8 = &.{ - "xcrun", - "--sdk", - "macosx", - "--show-sdk-path", - }; - const run = try std.process.run(b.allocator, b.graph.io, .{ - .argv = argv, - .stdout_limit = std.Io.Limit.limited(4096), - .stderr_limit = std.Io.Limit.limited(4096), - }); - defer { - b.allocator.free(run.stdout); - b.allocator.free(run.stderr); - } - switch (run.term) { - .exited => |code| if (code != 0) { - std.log.err("SDL on macOS: explicit -Dtarget=*-macos needs an SDK path. xcrun exited with code {d}. Install Xcode Command Line Tools or set SDKROOT.", .{code}); - return error.MacosSdkPath; - }, - else => { - std.log.err("SDL on macOS: xcrun --show-sdk-path failed", .{}); - return error.MacosSdkPath; - }, - } - const path = std.mem.trimEnd(u8, run.stdout, " \t\r\n"); - if (path.len == 0) return error.MacosSdkPath; - return b.dupePath(path); -} - -fn macosSdlPathsForExplicitTarget(b: *std.Build, target: std.Build.ResolvedTarget) !?MacosSdlPaths { - if (target.result.os.tag != .macos) return null; - if (b.graph.host.result.os.tag != .macos) return null; - if (target.query.isNative()) return null; - - const sdk = try resolveMacosSdkPath(b); - return MacosSdlPaths{ - .include = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/include" }) }, - .framework = .{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) }, - .lib = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/lib" }) }, - }; -} - pub fn build(b: *std.Build) !void { const windows_msvc_libc_opt = b.option([]const u8, "windows-msvc-libc", "zig libc manifest for *-windows-msvc when cross-compiling; forwarded by packageall for Windows children") orelse null; - // Default depends on host+target and is computed below once `target` is resolved. - // Pass `-Dfetch-msvc=false` on a Windows host to opt out of the auto-download and - // fall back to Zig's system-MSVC auto-detection (if you have Visual Studio installed). - const fetch_msvc_opt = b.option(bool, "fetch-msvc", "If *-windows-msvc libc is missing under .velopack-msvc/, run msvcup-setup first (downloads MSVC+SDK; requires network). Defaults to true on Windows hosts targeting *-windows-msvc."); + const fetch_msvc_opt = b.option(bool, "fetch-msvc", "If *-windows-msvc libc is missing under .velopack-msvc/, run msvcup-setup first (downloads MSVC+SDK; requires network). Defaults to true on Windows hosts targeting *-windows-msvc.") orelse null; - // macOS `vpk pack` codesigning / notarization. Optional: when omitted, packaging produces an - // unsigned bundle. Set all three to sign + notarize a release build. const macos_sign_app_identity = b.option([]const u8, "macos-sign-app", "macOS codesign identity for the app bundle (e.g. 'Developer ID Application: NAME (TEAMID)')") orelse b.graph.environ_map.get("FIZZY_MACOS_SIGN_APP"); const macos_sign_install_identity = b.option([]const u8, "macos-sign-installer", "macOS codesign identity for the installer pkg (e.g. 'Developer ID Installer: NAME (TEAMID)')") orelse @@ -142,1604 +19,23 @@ pub fn build(b: *std.Build) !void { b.graph.environ_map.get("FIZZY_MACOS_NOTARY_PROFILE"); const target = b.standardTargetOptions(.{}); - // Artifacts install to `zig-out/-/` (e.g. arm64-macos, x86-64-windows). Pass `-Dtarget=…` as usual. const optimize = b.standardOptimizeOption(.{}); - const macos_sdl_paths = try macosSdlPathsForExplicitTarget(b, target); - const zig_out_subdir = zigOutSubdirForTarget(b, target); - const zig_out_install_dir: std.Build.InstallDir = .{ .custom = zig_out_subdir }; - - const target_is_windows_msvc = target.result.os.tag == .windows and target.result.abi == .msvc; - const cross_win_msvc = target_is_windows_msvc and b.graph.host.result.os.tag != .windows; - - // Auto-fetch defaults: on Windows hosts targeting *-windows-msvc, downloading the - // MSVC SDK into .velopack-msvc/ is the deterministic path — Zig's auto-detection - // of a system Visual Studio install picks up whatever's currently installed, which - // makes packaged release builds non-reproducible. The same .velopack-msvc/ tree is - // used on macOS/Linux cross-compile hosts, so all three triples land on the same - // SDK headers + libs. Explicit `-Dfetch-msvc=false` opts out (use system VS); an - // explicit `-Dwindows-msvc-libc=...` overrides the discovery entirely. - const fetch_msvc = fetch_msvc_opt orelse (target_is_windows_msvc and windows_msvc_libc_opt == null); - - const win_libc = velopack.resolveWindowsMsvcLibc(b, target, .{ - .explicit_path = windows_msvc_libc_opt, - .install_dir_name = ".velopack-msvc", - .fetch_if_missing = fetch_msvc, - }); - - var effective_win_libc: ?[]const u8 = win_libc.libc_path; - if (effective_win_libc == null) { - if (cross_win_msvc) effective_win_libc = b.libc_file; - } - - // Velopack in the dev/install exe is opt-in (`-Dvelopack=true`). Release - // packaging (`zig build package`) still links Velopack when the ABI supports - // it via a second compile, so `zig build` / `run` / `test` never pull dotnet - // or the static Velopack lib unless you ask. Windows *-gnu targets are - // unchanged (no Velopack prebuilt for that ABI). - const velopack_supported_for_target = !(target.result.os.tag == .windows and target.result.abi != .msvc); - const velopack_enabled = b.option( - bool, - "velopack", - "Link Velopack runtime in the install/run exe (auto-update). Default: false. `package` still produces a Velopack-linked binary when supported.", - ) orelse false; - - if (velopack_enabled and !velopack_supported_for_target) { - std.log.err( - "-Dvelopack=true is unsupported for target ABI {s}: Velopack on Windows requires -Dtarget=x86_64-windows-msvc or -Dtarget=aarch64-windows-msvc.", - .{@tagName(target.result.abi)}, - ); - return error.WindowsMsvcAbiRequired; - } - // Fail loudly when the *-windows-msvc target has no headers/libs to compile against. - // On a non-Windows host this happens whenever `.velopack-msvc/` is missing and the - // user didn't pass `-Dfetch-msvc` or `-Dwindows-msvc-libc=…`. On a Windows host the - // auto-fetch default makes this unreachable unless the user explicitly opted out - // with `-Dfetch-msvc=false` — in which case Zig falls back to system Visual Studio - // auto-detection, which we can't validate here. - const velopack_required_fail: ?*std.Build.Step = if (cross_win_msvc and effective_win_libc == null) - &b.addFail( - \\*-windows-msvc needs MSVC + Windows SDK headers/libs. - \\ One-shot install (macOS/Linux/Windows): zig build msvcup-setup - \\ Then: zig build package -Dtarget=x86_64-windows-msvc (auto-uses .velopack-msvc/zig-libc-x64.ini) - \\ Or auto-download in this build: add -Dfetch-msvc (default on Windows hosts; forwards through packageall) - \\ Or pass: --libc path.ini / -Dwindows-msvc-libc=path.ini - ).step - else - null; - - const no_emit = b.option(bool, "no-emit", "Check for compile errors without emitting any code") orelse false; - - const app_version_opt = b.option([]const u8, "app_version", "App version for vpk packVersion and startup log; defaults to VERSION file"); - - // GitHub repo URL baked into the binary so Velopack's auto-update can find - // the latest release via the GitHub Releases API. Override at build time - // with `-Drepo-url=...` (e.g. when shipping a fork). At runtime, the env - // var `FIZZY_AUTOUPDATE_URL` still overrides this for local feed testing. - const app_repo_url = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (e.g. https://github.com/fizzyedit/fizzy)") orelse "https://github.com/fizzyedit/fizzy"; - - // Comma-separated fallback repo URLs checked (in order) after `app_repo_url` - // yields no update. Lets a build survive a repo move/rename: ship a binary - // whose primary points at the new home and whose fallback points at the old - // one (where the transitional release is published), then transfer the repo. - // Empty by default (no fallback). - const app_repo_url_fallback = b.option([]const u8, "repo-url-fallback", "Comma-separated fallback GitHub repo URLs for Velopack auto-update, tried after -Drepo-url") orelse ""; - - var version_owned: ?[]u8 = null; - defer if (version_owned) |buf| b.allocator.free(buf); - - const app_version: []const u8 = if (app_version_opt) |v| v else blk: { - const raw = b.build_root.handle.readFileAlloc(b.graph.io, "VERSION", b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e}); - version_owned = raw; - break :blk std.mem.trimEnd(u8, raw, "\r\n"); - }; - - const build_opts = b.addOptions(); - build_opts.addOption([]const u8, "app_version", app_version); - 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 static_pixelart = b.option( + const plugin_sdk = b.option( bool, - "static-pixelart", - "Keep pixelart statically registered on native (skip built-in dylib load)", + "plugin_sdk", + "Export core/sdk modules for third-party plugin builds; skips the fizzy app", ) 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; - - const msvcup_before_compile = velopack.addMsvcupSetupStep(b, ".velopack-msvc"); - const msvcup_setup_step = b.step("msvcup-setup", "Download MSVC SDK into .velopack-msvc/ via velopack-zig (writes zig-libc-*.ini)"); - msvcup_setup_step.dependOn(&msvcup_before_compile.step); - - const zip_pkg = zip.package(b, .{}); - - const accesskit = b.option(dvui.AccesskitOptions, "accesskit", "Enable accesskit") orelse .off; - - const assetpack = @import("assetpack"); - const assets_module = assetpack.pack(b, b.path("assets"), .{}); - - // Generated atlas / asset stubs (`src/generated/*.zig`) are imported - // 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/core/generated/"); - const process_assets_step = b.step("process-assets", "generates struct for all assets"); - process_assets_step.dependOn(&assets_processing.step); - - // --------------------------------------------------------------- - // Web (wasm) build — entirely separate from the native exe so it can't disturb - // packaging / SDL / Velopack paths. `zig build web` produces `zig-out/web/{web.wasm, - // web.js, index.html, NotoSansKR-Regular.ttf}`, deployable as-is to a static host. - // - // Checkpoint A: minimal placeholder app, no fizzy editor code yet. Later checkpoints - // will incrementally pull fizzy modules in, gating each native-only path behind a - // `arch != .wasm32` check. - // --------------------------------------------------------------- - { - const web_target = b.resolveTargetQuery(.{ - .cpu_arch = .wasm32, - .os_tag = .freestanding, - .cpu_features_add = std.Target.wasm.featureSet(&.{ - .atomics, - .multivalue, - .bulk_memory, - }), - }); - - const dvui_web_dep = b.dependency("dvui", .{ - .target = web_target, - .optimize = optimize, - .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", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/web_main.zig"), - .target = web_target, - .optimize = optimize, - .link_libc = false, - .single_threaded = true, - .strip = optimize == .ReleaseFast or optimize == .ReleaseSmall, - }), - }); - web_exe.entry = .disabled; - web_exe.root_module.addImport("dvui", dvui_web_dep.module("dvui_web")); - web_exe.root_module.addImport("web-backend", dvui_web_dep.module("web")); - - // Extra wasm exports beyond dvui's own (`dvui_init`/`dvui_update`/etc.). The wasm - // linker only emits symbols listed here, so `export fn` in Zig isn't enough on its - // own — without this line our trackpad pinch entry point would compile cleanly but - // be missing from `instance.exports`, and the JS bootstrap in `web/shell.html` - // would never be able to forward pinch deltas into the canvas widget. - web_exe.root_module.export_symbol_names = &[_][]const u8{ - "FizzyWebTrackpadMagnification", - }; - - // `icons` (pure-Zig icon data) is referenced at file scope in - // `src/dvui.zig` and `src/editor/Infobar.zig`. Wired in so any future - // wasm-reachable code that pulls those files in compiles cleanly. - if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| { - web_exe.root_module.addImport("icons", dep.module("icons")); - } - - // `assets` is generated at build time by assetpack (pure `@embedFile`s, - // target-independent). Same instance as native — no extra build cost. - web_exe.root_module.addImport("assets", assets_module); - - // `build_opts` (app_version, app_repo_url, velopack_enabled) — shared - // with native. velopack_enabled is whatever was passed via `-Dvelopack`; - // wasm path is gated by `arch != .wasm32` in `auto_update.impl`. - web_exe.root_module.addOptions("build_opts", build_opts); - - // `zip` — Zig decls + miniz/zip.c compiled for wasm with `fizzy_zip_libc.c` - // (malloc → dvui_c_alloc). Enables `zip_stream_*` for .fiz open/save in browser. - web_exe.root_module.addImport("zip", zip_pkg.module); - zip.linkWasm(web_exe); - - // `known-folders` is referenced at file scope in a few editor files - // (AboutFizzy, Editor settings paths). It's a pure-Zig wrapper for - // OS-specific user-directory APIs — the file compiles fine on wasm even - // though runtime calls would fail (which we'll never reach on web). - const known_folders_web = b.dependency("known_folders", .{ - .target = web_target, - .optimize = optimize, - }).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); - 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 - // to `fizzy.backend.DialogFileFilter`, those decls became dead — Zig's - // lazy analysis skips file-scope consts that no reachable body uses. - // So no `backend` module is wired in for the web build. - - // `zstbi` for the web build. The C sources include `` / - // `` only when `STBI_NO_STDLIB` is undefined; with the flag - // set, `zstbi.c` routes alloc + qsort through `fizzy_stbi_libc.c` - // (which forwards to DVUI's `dvui_c_alloc` / `dvui_c_free`). Lets the - // Packer compile + run on wasm against the currently-open files. - const zstbi_web_lib = b.addLibrary(.{ - .name = "zstbi-web", - .root_module = b.addModule("zstbi_web", .{ - .target = web_target, - .optimize = optimize, - .root_source_file = b.path("src/plugins/pixelart/src/deps/stbi/zstbi.zig"), - .link_libc = false, - .single_threaded = true, - }), - }); - const zstbi_web_cflags = [_][]const u8{ - "-DSTBI_NO_STDLIB=1", - "-DSTBI_NO_SIMD=1", - }; - zstbi_web_lib.root_module.addCSourceFile(.{ - .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/src/deps/stbi/fizzy_stbi_libc.c"), - .flags = &zstbi_web_cflags, - }); - web_exe.root_module.addImport("zstbi", zstbi_web_lib.root_module); - - const msf_gif_web_lib = b.addLibrary(.{ - .name = "msf_gif-web", - .root_module = b.addModule("msf_gif_web", .{ - .target = web_target, - .optimize = optimize, - .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/src/deps/msf_gif/wasm_shim"}; - msf_gif_web_lib.root_module.addCSourceFile(.{ - .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); - - _ = 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); - 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, - .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, .{ - .dest_dir = .{ .override = web_install_dir }, - }); - - // Cache-buster: stamps a 64-char hash into the index.html / web.js placeholders so - // the browser picks up new wasm builds without manual hard-reloads. Re-implements - // upstream DVUI's `addWebExample` machinery so we don't have to invoke its step. - const cb = b.addExecutable(.{ - .name = "cacheBuster", - .root_module = b.createModule(.{ - .root_source_file = dvui_web_dep.path("src/cacheBuster.zig"), - .target = b.graph.host, - }), - }); - const cb_run = b.addRunArtifact(cb); - cb_run.addFileArg(b.path("web/shell.html")); - cb_run.addFileArg(dvui_web_dep.path("src/backends/web.js")); - cb_run.addFileArg(web_exe.getEmittedBin()); - const index_html_with_hash = cb_run.captureStdOut(.{}); - - const web_step = b.step("web", "Build the fizzy web (wasm) app into zig-out/web/"); - web_step.dependOn(&install_wasm.step); - web_step.dependOn(&b.addInstallFileWithDir( - index_html_with_hash, - web_install_dir, - "index.html", - ).step); - web_step.dependOn(&b.addInstallFileWithDir( - dvui_web_dep.path("src/backends/web.js"), - web_install_dir, - "web.js", - ).step); - web_step.dependOn(&b.addInstallFileWithDir( - dvui_web_dep.path("src/fonts/NotoSansKR-Regular.ttf"), - web_install_dir, - "NotoSansKR-Regular.ttf", - ).step); - - // Compile-only smoke check for the wasm target. Pairs with `check` (unit - // tests). Catches regressions where someone reaches a wasm-incompatible - // code path (thread spawn, std.posix surface, missing module import) - // from the wasm root. No install — just compile. - const check_web_step = b.step("check-web", "Compile fizzy web (wasm) without installing artifacts"); - check_web_step.dependOn(&web_exe.step); - - // Copy zig-out/web into web/app/ for local preview at the production - // `/app/` path: `cd web && python3 -m http.server` then open - // http://localhost:8000/app/. The landing page lives in fizzyedit/website. - const web_docs_step = b.step("web-docs", "Build web app and copy into web/app/ for local /app/ preview"); - web_docs_step.dependOn(web_step); - const cp_web_to_docs = b.addSystemCommand(&.{ "sh", "-c" }); - cp_web_to_docs.addArg("mkdir -p web/app && cp -R zig-out/web/. web/app/"); - cp_web_to_docs.step.dependOn(web_step); - web_docs_step.dependOn(&cp_web_to_docs.step); - - const serve_web_cmd = b.addSystemCommand(&.{ "sh", "scripts/serve-web.sh" }); - serve_web_cmd.step.dependOn(web_step); - _ = b.step( - "serve-web", - "Serve zig-out/web at http://127.0.0.1:8765/ (builds web first; frees stale :8765)", - ).dependOn(&serve_web_cmd.step); - } - - const main_fizzy = try addFizzyExecutableForTarget(b, target, optimize, accesskit, build_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, velopack_enabled); - const exe = main_fizzy.exe; - const zstbi_module = main_fizzy.zstbi_module; - const msf_gif_module = main_fizzy.msf_gif_module; - const known_folders = main_fizzy.known_folders; - - 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); - 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); - 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); - } - 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 }, - }); - - const run_cmd = b.addRunArtifact(exe); - const run_step = b.step("run", "Run the app (does not run Velopack)"); - - 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.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| { - 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 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"); - // 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 - // back to the plain (Velopack-less) exe. vpk would still wrap it as a Velopack - // installer, but the install hook never runs: Setup.exe hangs with "the - // application install hook failed". Fail loudly instead of shipping that trap. - const windows_non_msvc = target.result.os.tag == .windows and target.result.abi != .msvc; - if (velopack_required_fail) |fail_step| { - package_step.dependOn(fail_step); - } else if (windows_non_msvc) { - package_step.dependOn(&b.addFail( - \\`zig build package` for Windows requires the MSVC ABI so Velopack is linked. - \\The default native target resolves to x86_64-windows-gnu, which builds a binary - \\WITHOUT the Velopack runtime. vpk would still wrap it as a Velopack installer, but - \\the install hook never runs and Setup.exe hangs ("the application install hook failed"). - \\ - \\Build with the MSVC target instead: - \\ zig build package -Dtarget=x86_64-windows-msvc -Dfetch-msvc - \\(needs Windows SDK 10.0.26100+ for SDL's GameInput backend.) - ).step); - } else if (no_emit) { - package_step.dependOn(&b.addFail("cannot run `package` with -Dno-emit").step); - } else switch (target.result.os.tag) { - .linux, .macos, .windows => { - // Host strip can't process foreign object files when cross-compiling. - const cross_os = target.result.os.tag != b.graph.host.result.os.tag; - // Same-OS / different-arch (e.g. aarch64-linux from x86_64-linux) also - // breaks host strip — it errors with "Unable to recognise the format". - const cross_for_strip = cross_os or target.result.cpu.arch != b.graph.host.result.cpu.arch; - // Windows hosts don't ship `strip` or `touch`. Skip the external strip - // step entirely there — Zig's linker already drops debug info in - // release builds. Use `cmd /c exit 0` as the no-op and keep the - // dependency on exe_for_package via the step graph. - const host_is_windows = b.graph.host.result.os.tag == .windows; - const skip_strip = host_is_windows or optimize == .Debug or cross_for_strip; - const strip_release_sh = if (host_is_windows) blk: { - const sh = b.addSystemCommand(&.{ "cmd", "/c", "exit", "0" }); - sh.step.dependOn(&exe_for_package.step); - break :blk sh; - } else blk: { - const sh = b.addSystemCommand(&.{if (skip_strip) "touch" else "strip"}); - sh.addFileArg(exe_for_package.getEmittedBin()); - break :blk sh; - }; - - //const dotnet_tool_restore = velopack.addDotnetToolRestoreStep(b); - //const vpk_vendor_repair = velopack.addVpkVendorRepairStep(b); - //vpk_vendor_repair.step.dependOn(&dotnet_tool_restore.step); - - const vpk_pkg_sh = b.addSystemCommand(&.{"dotnet"}); - vpk_pkg_sh.addArg("vpk"); - // When packaging a foreign-OS bundle, vpk needs an OS directive (e.g. `vpk [win] pack ...`) - // because by default it auto-detects from the host OS. - if (cross_os) { - vpk_pkg_sh.addArg(switch (target.result.os.tag) { - .windows => "[win]", - .linux => "[linux]", - .macos => "[osx]", - else => unreachable, - }); - } - vpk_pkg_sh.addArg("pack"); - vpk_pkg_sh.addArg("--packId"); - vpk_pkg_sh.addArg("fizzy"); - vpk_pkg_sh.addArg("--packVersion"); - vpk_pkg_sh.addArg(app_version); - // Channel = zig-out subdir (`-`, NuGet-safe — no underscores). Baked into - // the binary by vpk; the updater matches this to release assets. Distinct per triple - // so parallel `vpk pack` runs don't collide on RELEASES / nupkg names. - vpk_pkg_sh.addArg("--channel"); - vpk_pkg_sh.addArg(zig_out_subdir); - vpk_pkg_sh.addArg("--mainExe"); - vpk_pkg_sh.addArg(switch (target.result.os.tag) { - .windows => "fizzy.exe", - else => "fizzy", - }); - - vpk_pkg_sh.addArg("--delta"); - vpk_pkg_sh.addArg("None"); - vpk_pkg_sh.addArg("--yes"); - - vpk_pkg_sh.addArg("--outputDir"); - // `addOutputDirectoryArg` takes a basename — Zig manages the actual - // path under the run step's cache dir. The `addInstallDirectory` - // below copies that into zig-out//. Previously this passed - // 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.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 - // exe's own icon is already embedded via assets/windows/fizzy.rc. - vpk_pkg_sh.addArg("--icon"); - const ico_path = b.path("assets/windows/fizzy.ico").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("ico path: {}", .{e}); - vpk_pkg_sh.addArg(ico_path); - // Velopack's installer is silent (no shortcut-choice UI). Default is - // Desktop,StartMenu; restrict to StartMenu so we don't drop an - // unrequested icon on the user's desktop. - vpk_pkg_sh.addArg("--shortcuts"); - vpk_pkg_sh.addArg("StartMenu"); - }, - .macos => { - vpk_pkg_sh.addArg("--packTitle"); - vpk_pkg_sh.addArg("fizzy"); - // Bundle id / document types / versions: assets/macos/info.plist (vpk rejects --bundleId with --plist). - vpk_pkg_sh.addArg("--plist"); - const plist_path = b.path("assets/macos/info.plist").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("plist path: {}", .{e}); - vpk_pkg_sh.addArg(plist_path); - vpk_pkg_sh.addArg("--icon"); - const icns_path = b.path("assets/macos/fizzy.icns").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("icns path: {}", .{e}); - vpk_pkg_sh.addArg(icns_path); - - if (macos_sign_app_identity) |id| { - vpk_pkg_sh.addArg("--signAppIdentity"); - vpk_pkg_sh.addArg(id); - // Required for notarization: enables hardened runtime + secure timestamp on - // every nested binary (vpk forwards the file to `codesign --entitlements`). - // Without this, Apple's notary service rejects with "signature does not - // include a secure timestamp" / "hardened runtime not enabled". - vpk_pkg_sh.addArg("--signEntitlements"); - const entitlements_path = b.path("assets/macos/Fizzy.entitlements").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("entitlements path: {}", .{e}); - vpk_pkg_sh.addArg(entitlements_path); - } - if (macos_sign_install_identity) |id| { - vpk_pkg_sh.addArg("--signInstallIdentity"); - vpk_pkg_sh.addArg(id); - } - if (macos_notary_profile) |profile| { - vpk_pkg_sh.addArg("--notaryProfile"); - vpk_pkg_sh.addArg(profile); - } - }, - else => {}, - } - vpk_pkg_sh.setEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major"); - // Stream vpk's stdout/stderr live so failures surface their actual - // diagnostic instead of just an exit-code-N message from the build - // runner. With `addOutputDirectoryArg` in play, `infer_from_args` - // can otherwise capture+drop stdio on certain runner configs. - vpk_pkg_sh.stdio = .inherit; - try velopack.attachMksquashfsToVpkRun(b, vpk_pkg_sh, target); - - //vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step); - vpk_pkg_sh.step.dependOn(pack_stage_tail); - - const build_package_install = b.addInstallDirectory(.{ - .source_dir = vpk_pkg_out_dir, - .install_dir = zig_out_install_dir, - .install_subdir = "", - }); - - package_step.dependOn(&build_package_install.step); - }, - else => { - package_step.dependOn(&b.addFail("Velopack packaging is only supported for Linux, macOS, and Windows targets").step); - }, - } - - const desktop_step = b.step("desktop", "Alias for `zig build package`"); - desktop_step.dependOn(package_step); - - const packageall_step = b.step("packageall", "Six zig build package runs; use -Dwindows-msvc-libc= or -Dfetch-msvc for Windows children from macOS/Linux"); - if (no_emit) { - packageall_step.dependOn(&b.addFail("cannot run `packageall` with -Dno-emit").step); - } else { - const packageall_optimize_arg = b.fmt("-Doptimize={s}", .{@tagName(optimize)}); - - // Build order is deliberately fail-fast: Windows first (most likely to - // fail on a fresh CI runner because of MSVC SDK setup, libc.ini paths, - // and cross-compile ABI surprises), then Linux (mksquashfs / AppImage - // packaging quirks), then macOS last (native, lowest risk). When a - // release run is going to break, this ordering surfaces the failure - // 5-10 minutes sooner than the alphabetical order did. - const packageall_triples = [_][]const u8{ - "x86_64-windows-msvc", - "aarch64-windows-msvc", - "x86_64-linux-gnu", - "aarch64-linux-gnu", - "x86_64-macos", - "aarch64-macos", - }; - - var prev_step: ?*std.Build.Step = null; - for (packageall_triples) |triple| { - const zig_pkg_run = b.addSystemCommand(&.{ - b.graph.zig_exe, - "build", - "package", - packageall_optimize_arg, - b.fmt("-Dtarget={s}", .{triple}), - }); - if (std.mem.endsWith(u8, triple, "-windows-msvc")) { - if (windows_msvc_libc_opt) |libc_path| { - zig_pkg_run.addArg(b.fmt("-Dwindows-msvc-libc={s}", .{libc_path})); - } - if (fetch_msvc) zig_pkg_run.addArg("-Dfetch-msvc"); - } - zig_pkg_run.setCwd(b.path(".")); - if (prev_step) |p| { - zig_pkg_run.step.dependOn(p); - } - prev_step = &zig_pkg_run.step; - } - packageall_step.dependOn(prev_step.?); - } - - // --------------------------------------------------------------- - // Tests - // --------------------------------------------------------------- - // - // Fizzy has two test layers (see tests/README.md): - // - // 1. Unit tests — pure-logic only (math, palette parsing, layer - // order). The test root imports nothing but std + the pure - // modules under test, so it compiles in well under a second - // and never needs dvui/SDL/assets. - // - // 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. - - const test_filters = b.option( - []const []const u8, - "test-filter", - "Skip tests that do not match any filter", - ) orelse &[0][]const u8{}; - - const tests_module = b.addModule("fizzy-tests", .{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("tests/root.zig"), - }); - - // Wire each pure-logic source file as a named module on the test - // target. Zig 0.15 disallows importing source files outside the test - // module's own directory via relative paths, so we expose them by - // name. Each of these files imports only `std`, so they remain free - // of dvui / SDL / globals. - inline for (.{ - .{ "fizzy-direction", "src/core/math/direction.zig" }, - .{ "fizzy-easing", "src/core/math/easing.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/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" }, - .{ "fizzy-plugin-dylib", "src/sdk/dylib.zig" }, - }) |entry| { - tests_module.addAnonymousImport(entry[0], .{ - .root_source_file = b.path(entry[1]), - .target = target, - .optimize = optimize, - }); - } - - const unit_tests = b.addTest(.{ - .name = "fizzy-unit-tests", - .root_module = tests_module, - .filters = test_filters, - }); - - // `zig build test` is the CI entry point and must stay self-contained: pure - // unit tests only, no dvui/SDL/Velopack/MSVC. Integration tests live under - // `zig build test-integration` (Velopack + dvui-testing + comctl32 on Windows - // → needs MSVC SDK on Windows hosts). `zig build test-all` runs both. - const test_step = b.step("test", "Run fizzy unit tests (pure-logic only, no dvui/SDL/Velopack)"); - test_step.dependOn(&b.addRunArtifact(unit_tests).step); - - // `check` mirrors the split so editor compile-error checking matches CI. - const check_step = b.step("check", "Compile fizzy unit tests without running them"); - check_step.dependOn(&unit_tests.step); - - // --------------------------------------------------------------- - // Layer 2: headless integration tests against dvui's testing - // backend. Wired under separate `test-integration` / `check-integration` - // steps so `zig build test` stays MSVC-free on Windows CI runners. Skipped - // when cross-compiling to *-windows-msvc without an MSVC libc INI. - // --------------------------------------------------------------- - const test_integration_step = b.step("test-integration", "Run fizzy headless integration tests (dvui-testing; needs MSVC on Windows)"); - const check_integration_step = b.step("check-integration", "Compile fizzy integration tests without running them"); - const test_all_step = b.step("test-all", "Run unit + integration tests"); - test_all_step.dependOn(test_step); - test_all_step.dependOn(test_integration_step); - - if (velopack_required_fail) |fail_step| { - test_integration_step.dependOn(fail_step); - check_integration_step.dependOn(fail_step); + if (plugin_sdk) { + try plugin.exportModules(b, target, optimize); return; } - const dvui_testing_dep = b.dependency("dvui", .{ - .target = target, - .optimize = optimize, - .backend = .testing, - .accesskit = accesskit, + try @import("build/app.zig").build(b, target, optimize, .{ + .windows_msvc_libc_opt = windows_msvc_libc_opt, + .fetch_msvc_opt = fetch_msvc_opt, + .macos_sign_app_identity = macos_sign_app_identity, + .macos_sign_install_identity = macos_sign_install_identity, + .macos_notary_profile = macos_notary_profile, }); - 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 - // imports (App.zig, Editor.zig, …) reference `dvui`, `assets`, - // `known-folders`, etc. by name, those names must be wired here. - // We point dvui at the *testing* backend so calling drawing - // functions doesn't try to open a real OS window. - const fizzy_test_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("src/fizzy.zig"), - }); - fizzy_test_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); - fizzy_test_module.addImport("backend", dvui_testing_dep.module("testing")); - fizzy_test_module.addImport("assets", assets_module); - fizzy_test_module.addImport("known-folders", known_folders); - fizzy_test_module.addOptions("build_opts", build_opts); - fizzy_test_module.addImport("zstbi", zstbi_module); - fizzy_test_module.addImport("msf_gif", msf_gif_module); - fizzy_test_module.addImport("zip", zip_pkg.module); - 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); - 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, - .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); - 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, - .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| { - fizzy_test_module.addImport("objc", dep.module("objc")); - } - } else if (target.result.os.tag == .windows) { - if (b.lazyDependency("zigwin32", .{})) |dep| { - fizzy_test_module.addImport("win32", dep.module("win32")); - } - } - - const integration_module = b.addModule("fizzy-integration-tests", .{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("tests/integration.zig"), - }); - integration_module.addImport("fizzy", fizzy_test_module); - integration_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); - - const integration_tests = b.addTest(.{ - .name = "fizzy-integration-tests", - .root_module = integration_module, - .filters = test_filters, - }); - - if (target.result.os.tag == .windows) { - integration_tests.root_module.linkSystemLibrary("comctl32", .{}); - } - // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers from - // --libc (vcruntime_typeinfo.h vs libc++ type_info, etc.), so libc++ must be - // off for the msvc ABI regardless of host (cross or native Windows). - integration_tests.root_module.link_libcpp = !target_is_windows_msvc; - zip.link(integration_tests); - if (velopack_enabled) { - try velopack.linkVelopack(b, integration_tests, .{ .target = target, .optimize = optimize }); - } - - integration_tests.step.dependOn(process_assets_step); - - test_integration_step.dependOn(&b.addRunArtifact(integration_tests).step); - check_integration_step.dependOn(&integration_tests.step); - - if (win_libc.needs_setup) { - exe.step.dependOn(&msvcup_before_compile.step); - if (!velopack_enabled and velopack_supported_for_target) { - exe_for_package.step.dependOn(&msvcup_before_compile.step); - } - integration_tests.step.dependOn(&msvcup_before_compile.step); - unit_tests.step.dependOn(&msvcup_before_compile.step); - } - - if (target.result.os.tag == .windows and target.result.abi == .msvc) { - var roots: [4]*std.Build.Step.Compile = undefined; - var n: usize = 0; - roots[n] = exe; - n += 1; - roots[n] = unit_tests; - n += 1; - roots[n] = integration_tests; - n += 1; - if (!velopack_enabled and velopack_supported_for_target) { - roots[n] = exe_for_package; - n += 1; - } - - // Always apply the translate-c shim + SIZE_MAX define for windows-msvc, regardless of - // whether we're using a downloaded SDK or the host's system MSVC. translate-c uses aro - // (not MSVC cl.exe), and aro rejects literals like `0xffffffffffffffffui64` from MSVC's - // . The shim shadows stdint.h via `-I` (search order beats `-isystem`); the - // defineCMacro adds belt-and-suspenders by predefining SIZE_MAX before any include so - // MSVC's stdint.h `#ifndef SIZE_MAX` skips its own definition entirely. - applyMsvcTranslateCShim(b, roots[0..n]) catch |e| { - std.debug.panic("MSVC translate-c shim wiring failed: {s}", .{@errorName(e)}); - }; - - if (effective_win_libc) |ini| { - if (cross_win_msvc) b.libc_file = null; - const libc_lp: std.Build.LazyPath = .{ .cwd_relative = ini }; - velopack.applyWindowsMsvcLibcRecursive(b, roots[0..n], libc_lp); - - const ini_exists = blk: { - b.build_root.handle.access(b.graph.io, ini, .{}) catch break :blk false; - break :blk true; - }; - if (ini_exists) { - // Adds explicit MSVC/UCRT/SDK `-isystem` paths from the libc INI to each reachable - // translate-c step. Only relevant when cross-compiling with .velopack-msvc/; on a - // Windows host with system MSVC, Zig auto-discovers these paths itself. - applyMsvcIncludesToReachableTranslateC(b, roots[0..n], ini) catch |e| { - std.debug.panic("MSVC translate-c include fixup failed: {s}", .{@errorName(e)}); - }; - } else { - // The INI is written by `msvcup-setup` (a make-phase step), but the translate-c - // `-isystem` paths embed the SDK version subdir, which is only known after the SDK - // is installed — so they must be wired at configure time, before that step runs. - // A one-shot `zig build package -Dfetch-msvc` against a clean .velopack-msvc can't - // satisfy that ordering. Fail only the compiles that need it (not `msvcup-setup`, - // which has no such dependency), so running setup first still works. - const fail = &b.addFail( - \\*-windows-msvc has no .velopack-msvc/zig-libc INI yet, so translate-c can't be wired. - \\The SDK install must run as its own step before packaging (it can't be done in one - \\pass — the translate-c include paths depend on the installed SDK version): - \\ zig build msvcup-setup - \\ zig build package -Dtarget=x86_64-windows-msvc - ).step; - for (roots[0..n]) |rc| rc.step.dependOn(fail); - } - } - } -} - -/// Apply the always-on translate-c fixups for windows-msvc targets: the stdint.h shim -/// (so aro doesn't choke on MSVC's `ui64` literal suffix) and a predefined SIZE_MAX. -/// Runs whether or not we have a downloaded SDK — the shim is purely an `-I` injection -/// and a `-D` flag, so it works equally on cross-compile and native windows-host builds. -fn applyMsvcTranslateCShim(b: *std.Build, roots: []const *std.Build.Step.Compile) !void { - var seen = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator); - defer seen.deinit(); - for (roots) |root_compile| { - const graph = root_compile.root_module.getGraph(); - for (graph.modules) |mod| { - const root_src = mod.root_source_file orelse continue; - const gen = switch (root_src) { - .generated => |g| g, - else => continue, - }; - const dep_step = gen.file.step; - if (dep_step.id != .translate_c) continue; - const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step); - const gop = try seen.getOrPut(tc); - if (gop.found_existing) continue; - 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/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 - // a path that bypasses our shim. - tc.defineCMacro("SIZE_MAX", switch (rt.ptrBitWidth()) { - 32 => "4294967295U", - 64 => "18446744073709551615ULL", - else => "UINT_MAX", - }); - } - } -} - -/// Finds every `Step.TranslateC` reachable from each root compile's Zig module graph and adds -/// MSVC / Windows SDK `-isystem` paths from the zig-libc INI. We walk `Module.getGraph()` (imports) -/// rather than `Step.dependencies`: Zig wires `root_source_file` → `TranslateC` only in -/// `createModuleDependencies`, which runs after `build()` returns, so a step BFS from `Compile` -/// would miss DVUI's `dvui-c` / `sdl3-c` translate steps during Configure. -fn applyMsvcIncludesToReachableTranslateC( - b: *std.Build, - roots: []const *std.Build.Step.Compile, - libc_ini_path: []const u8, -) !void { - // `libc_ini_path` is absolute (resolved via `b.pathFromRoot`), so any Dir works as the base. - const data = try b.build_root.handle.readFileAlloc(b.graph.io, libc_ini_path, b.allocator, .unlimited); - - var include_dir: ?[]const u8 = null; - var sys_include_dir: ?[]const u8 = null; - var line_it = std.mem.splitScalar(u8, data, '\n'); - while (line_it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \r\t"); - if (std.mem.startsWith(u8, line, "include_dir=")) { - include_dir = std.mem.trim(u8, line["include_dir=".len..], " \r\t"); - } else if (std.mem.startsWith(u8, line, "sys_include_dir=")) { - sys_include_dir = std.mem.trim(u8, line["sys_include_dir=".len..], " \r\t"); - } - } - if (include_dir == null or sys_include_dir == null) return; - - // `include_dir` points at `.../Windows Kits/10/Include//ucrt`. The Windows SDK's - // um/shared/winrt headers live as siblings of the `ucrt` directory. - const sdk_inc_root = std.fs.path.dirname(include_dir.?) orelse return; - const um_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "um" }); - const shared_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "shared" }); - const winrt_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "winrt" }); - - var seen_translate_c = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator); - defer seen_translate_c.deinit(); - - for (roots) |root_compile| { - const graph = root_compile.root_module.getGraph(); - for (graph.modules) |mod| { - const root_src = mod.root_source_file orelse continue; - const gen = switch (root_src) { - .generated => |g| g, - else => continue, - }; - const dep_step = gen.file.step; - if (dep_step.id != .translate_c) continue; - - const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step); - const gop = try seen_translate_c.getOrPut(tc); - if (gop.found_existing) continue; - - const rt = tc.target.result; - if (rt.os.tag == .windows and rt.abi == .msvc) { - // `translate-c` has no API to pass `--libc `, so `-lc` makes Zig - // auto-detect a system MSVC/SDK install — which fails on a Windows host - // that has no Visual Studio (we use the .velopack-msvc/ tree instead) with - // `WindowsSdkNotFound`. Drop `-lc` here: every MSVC/UCRT/SDK include dir is - // added explicitly below, so the headers still resolve, and the consuming - // exe links libc itself — the translated bindings don't need their own. - tc.link_libc = false; - // Shim + SIZE_MAX define are applied separately by `applyMsvcTranslateCShim`. - // Order matters: MSVC's own headers first (override Windows SDK declarations - // when both exist), then UCRT, then the Windows SDK trio. - tc.addSystemIncludePath(.{ .cwd_relative = sys_include_dir.? }); - tc.addSystemIncludePath(.{ .cwd_relative = include_dir.? }); - tc.addSystemIncludePath(.{ .cwd_relative = um_dir }); - tc.addSystemIncludePath(.{ .cwd_relative = shared_dir }); - tc.addSystemIncludePath(.{ .cwd_relative = winrt_dir }); - } - } - } -} - -/// 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, - 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, - workbench_dylib: ?*std.Build.Step.Compile = null, -}; - -fn addFizzyExecutableForTarget( - b: *std.Build, - resolved_target: std.Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, - accesskit: dvui.AccesskitOptions, - build_opts: *std.Build.Step.Options, - zip_pkg: zip.Package, - assets_module: *std.Build.Module, - process_assets_step: *std.Build.Step, - macos_sdl_paths: ?MacosSdlPaths, - velopack_enabled: bool, -) !FizzyExecutable { - const dvui_dep = if (macos_sdl_paths) |p| - b.dependency("dvui", .{ - .target = resolved_target, - .optimize = optimize, - .backend = .sdl3, - .accesskit = accesskit, - .system_include_path = p.include, - .system_framework_path = p.framework, - .library_path = p.lib, - }) - 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", .{ - .target = resolved_target, - .optimize = optimize, - .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/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/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/src/deps/msf_gif/msf_gif.c") }); - - const exe = b.addExecutable(.{ - .name = "fizzy", - .root_module = b.addModule("App", .{ - .target = resolved_target, - .optimize = optimize, - .root_source_file = .{ .cwd_relative = "src/App.zig" }, - }), - }); - exe.root_module.strip = false; - - exe.root_module.addImport("assets", assets_module); - const known_folders = b.dependency("known_folders", .{ - .target = resolved_target, - .optimize = optimize, - }).module("known-folders"); - exe.root_module.addImport("known-folders", known_folders); - exe.root_module.addOptions("build_opts", build_opts); - exe.step.dependOn(process_assets_step); - - if (optimize != .Debug) { - switch (resolved_target.result.os.tag) { - .windows => { - exe.subsystem = .Windows; - // MSVC's libcmt links `WinMainCRTStartup` (needs `WinMain`) for /SUBSYSTEM:WINDOWS. - // Fizzy exposes `main`, so force the C `main` entry which works for either subsystem. - if (resolved_target.result.abi == .msvc) { - exe.entry = .{ .symbol_name = "mainCRTStartup" }; - } - }, - else => exe.subsystem = .Posix, - } - } - - exe.root_module.addImport("zstbi", zstbi_module); - exe.root_module.addImport("msf_gif", msf_gif_module); - exe.root_module.addImport("zip", zip_pkg.module); - 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); - - 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, - .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); - wireWorkbenchModule(b, resolved_target, optimize, .{ - .dvui = dvui_dep.module("dvui_sdl3"), - .core = core_module, - .sdk = sdk_module, - .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, .{ - .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 = 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_proxy_mod, - .core = core_proxy_module, - .sdk = sdk_proxy_module, - .proxy_bridge = proxy_bridge_plugin_mod, - .icons = icons_module, - .backend = null, - }); - } else null; - - const singleton_app_dep = b.dependency("dvui_singleton_app", .{ - .target = resolved_target, - .optimize = optimize, - }); - exe.root_module.addImport("singleton_app", singleton_app_dep.module("singleton_app")); - - 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 - // same SDK layout for Obj-C sources as for SDL; zig-objc paths do not always reach .m - // compiles (e.g. Security.framework → ). - exe.root_module.addSystemIncludePath(p.include); - exe.root_module.addSystemFrameworkPath(p.framework); - exe.root_module.addLibraryPath(p.lib); - } - if (b.lazyDependency("zig_objc", .{ - .target = resolved_target, - .optimize = optimize, - })) |dep| { - exe.root_module.addImport("objc", dep.module("objc")); - } - 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")); - } - exe.root_module.linkSystemLibrary("comctl32", .{}); - - // Embed assets/windows/fizzy.rc -> fizzy.ico into the exe so Explorer, - // Taskbar, Alt-Tab and the Velopack-generated Start Menu shortcut all - // show the right icon without any runtime work. fizzy.ico must be a - // multi-resolution ICO with 16/32/48/256 px frames (see the README in - // that directory). - exe.root_module.addWin32ResourceFile(.{ - .file = b.path("assets/windows/fizzy.rc"), - }); - } - - // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers - // (vcruntime_typeinfo.h's ::type_info vs libc++'s own, redefined bad_cast, - // etc.). We always feed MSVC's own STL via --libc for *-windows-msvc — on a - // cross host and on a native Windows host using .velopack-msvc alike — so - // libc++ must be off for the msvc ABI regardless of host. - const exe_is_windows_msvc = resolved_target.result.os.tag == .windows and - resolved_target.result.abi == .msvc; - exe.root_module.link_libcpp = !exe_is_windows_msvc; - zip.link(exe); - if (velopack_enabled) { - try velopack.linkVelopack(b, exe, .{ .target = resolved_target, .optimize = optimize }); - } - - return .{ - .exe = exe, - .zstbi_module = zstbi_module, - .msf_gif_module = msf_gif_module, - .known_folders = known_folders, - .sdk_module = sdk_module, - .pixelart_dylib = pixelart_dylib, - .workbench_dylib = workbench_dylib, - }; -} - -/// 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, - proxy_bridge_module: *std.Build.Module, - consumer: ?*std.Build.Module, -) *std.Build.Module { - const sdk_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("src/sdk/sdk.zig"), - }); - sdk_module.addImport("dvui", dvui_module); - sdk_module.addImport("proxy_bridge", proxy_bridge_module); - if (consumer) |c| c.addImport("sdk", sdk_module); - return sdk_module; -} - -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, - msf_gif: *std.Build.Module, - icons: ?*std.Build.Module, - backend: ?*std.Build.Module, -}; - -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, -}; - -/// 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.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); -} - -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, - }); - applyWorkbenchModuleImports(workbench_module, deps); - 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, - 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_render_bridge", - "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); - 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); - 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. - 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_render_bridge", - "fizzy_plugin_set_globals", - }; - return lib; -} - -fn wirePixelartModule( - b: *std.Build, - target: std.Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, - deps: PixelartModuleDeps, - consumer: *std.Build.Module, -) *std.Build.Module { - 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, - }); - applyPixelartModuleImports(pixelart_module, deps); - consumer.addImport("pixelart", pixelart_module); - return pixelart_module; -} - -inline fn thisDir() []const u8 { - return comptime std.fs.path.dirname(@src().file) orelse "."; -} - -fn addImport( - compile: *std.Build.Step.Compile, - name: [:0]const u8, - module: *std.Build.Module, -) void { - compile.root_module.addImport(name, module); -} - diff --git a/build.zig.zon b/build.zig.zon index f6c3e553..43ae1c5d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,8 +1,13 @@ .{ .paths = .{ "src", + "build", "build.zig", "build.zig.zon", + "process_assets.zig", + "update.zig", + "build", + "plugin_sdk.zig", "assets", "libs", }, @@ -27,9 +32,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/3dec1c1b56f71aff41e36588a715dea085d307f5.tar.gz", + .hash = "dvui-0.5.0-dev-AQFJmZhu9wAmUMx9414LO75l0R69z3d8udYXbj72-q3R", + //.path = "../dvui-dev", }, .assetpack = .{ .url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz", @@ -47,5 +52,9 @@ .dvui_singleton_app = .{ .path = "libs/dvui-singleton-app", }, + // Built-in plugins are NOT package dependencies: the root build embeds them by + // importing each plugin's `static/integration.zig` directly (see build/plugins.zig), + // which owns the module graph. Each plugin's own `build.zig` is only for the + // standalone third-party-shape build under `src/plugins//`. }, } diff --git a/build/app.zig b/build/app.zig new file mode 100644 index 00000000..a2510ba6 --- /dev/null +++ b/build/app.zig @@ -0,0 +1,621 @@ +const std = @import("std"); + +const plugin = @import("../plugin_sdk.zig"); +const dvui = @import("dvui"); +const velopack = @import("velopack_zig"); +const ProcessAssetsStep = @import("../process_assets.zig"); + +pub const Options = struct { + windows_msvc_libc_opt: ?[]const u8 = null, + fetch_msvc_opt: ?bool = null, + macos_sign_app_identity: ?[]const u8 = null, + macos_sign_install_identity: ?[]const u8 = null, + macos_notary_profile: ?[]const u8 = null, +}; + +pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, opts: Options) !void { + const windows_msvc_libc_opt = opts.windows_msvc_libc_opt; + const fetch_msvc_opt = opts.fetch_msvc_opt; + const macos_sign_app_identity = opts.macos_sign_app_identity; + const macos_sign_install_identity = opts.macos_sign_install_identity; + const macos_notary_profile = opts.macos_notary_profile; + + const common = @import("common.zig"); + const plugins = @import("plugins.zig"); + const sdk = @import("sdk.zig"); + const fizzy_exe = @import("exe.zig"); + const web = @import("web.zig"); + const package = @import("package.zig"); + const msvc = @import("msvc.zig"); + + const pixi_plugin = plugins.pixi; + const workbench_plugin = plugins.workbench; + const code_plugin = plugins.code; + const example_plugin = plugins.example; + const FizzyExecutable = fizzy_exe.FizzyExecutable; + + // Built-in plugins are embedded by importing their `static/integration.zig` directly + // (via build/plugins.zig); the root build owns the module graph, so there is no plugin + // package dependency to resolve here. Their canonical `build.zig` is only for the + // standalone (`cd src/plugins/ && zig build`) third-party-shape build. + + const macos_sdl_paths = try common.macosSdlPathsForExplicitTarget(b, target); + const zig_out_subdir = common.zigOutSubdirForTarget(b, target); + const zig_out_install_dir: std.Build.InstallDir = .{ .custom = zig_out_subdir }; + + const target_is_windows_msvc = target.result.os.tag == .windows and target.result.abi == .msvc; + const cross_win_msvc = target_is_windows_msvc and b.graph.host.result.os.tag != .windows; + + // Auto-fetch defaults: on Windows hosts targeting *-windows-msvc, downloading the + // MSVC SDK into .velopack-msvc/ is the deterministic path — Zig's auto-detection + // of a system Visual Studio install picks up whatever's currently installed, which + // makes packaged release builds non-reproducible. The same .velopack-msvc/ tree is + // used on macOS/Linux cross-compile hosts, so all three triples land on the same + // SDK headers + libs. Explicit `-Dfetch-msvc=false` opts out (use system VS); an + // explicit `-Dwindows-msvc-libc=...` overrides the discovery entirely. + const fetch_msvc = fetch_msvc_opt orelse (target_is_windows_msvc and windows_msvc_libc_opt == null); + + const win_libc = velopack.resolveWindowsMsvcLibc(b, target, .{ + .explicit_path = windows_msvc_libc_opt, + .install_dir_name = ".velopack-msvc", + .fetch_if_missing = fetch_msvc, + }); + + var effective_win_libc: ?[]const u8 = win_libc.libc_path; + if (effective_win_libc == null) { + if (cross_win_msvc) effective_win_libc = b.libc_file; + } + + // Velopack in the dev/install exe is opt-in (`-Dvelopack=true`). Release + // packaging (`zig build package`) still links Velopack when the ABI supports + // it via a second compile, so `zig build` / `run` / `test` never pull dotnet + // or the static Velopack lib unless you ask. Windows *-gnu targets are + // unchanged (no Velopack prebuilt for that ABI). + const velopack_supported_for_target = !(target.result.os.tag == .windows and target.result.abi != .msvc); + const velopack_enabled = b.option( + bool, + "velopack", + "Link Velopack runtime in the install/run exe (auto-update). Default: false. `package` still produces a Velopack-linked binary when supported.", + ) orelse false; + + if (velopack_enabled and !velopack_supported_for_target) { + std.log.err( + "-Dvelopack=true is unsupported for target ABI {s}: Velopack on Windows requires -Dtarget=x86_64-windows-msvc or -Dtarget=aarch64-windows-msvc.", + .{@tagName(target.result.abi)}, + ); + return error.WindowsMsvcAbiRequired; + } + + // Fail loudly when the *-windows-msvc target has no headers/libs to compile against. + // On a non-Windows host this happens whenever `.velopack-msvc/` is missing and the + // user didn't pass `-Dfetch-msvc` or `-Dwindows-msvc-libc=…`. On a Windows host the + // auto-fetch default makes this unreachable unless the user explicitly opted out + // with `-Dfetch-msvc=false` — in which case Zig falls back to system Visual Studio + // auto-detection, which we can't validate here. + const velopack_required_fail: ?*std.Build.Step = if (cross_win_msvc and effective_win_libc == null) + &b.addFail( + \\*-windows-msvc needs MSVC + Windows SDK headers/libs. + \\ One-shot install (macOS/Linux/Windows): zig build msvcup-setup + \\ Then: zig build package -Dtarget=x86_64-windows-msvc (auto-uses .velopack-msvc/zig-libc-x64.ini) + \\ Or auto-download in this build: add -Dfetch-msvc (default on Windows hosts; forwards through packageall) + \\ Or pass: --libc path.ini / -Dwindows-msvc-libc=path.ini + ).step + else + null; + + const no_emit = b.option(bool, "no-emit", "Check for compile errors without emitting any code") orelse false; + + const app_version_opt = b.option([]const u8, "app_version", "App version for vpk packVersion and startup log; defaults to VERSION file"); + + // GitHub repo URL baked into the binary so Velopack's auto-update can find + // the latest release via the GitHub Releases API. Override at build time + // with `-Drepo-url=...` (e.g. when shipping a fork). At runtime, the env + // var `FIZZY_AUTOUPDATE_URL` still overrides this for local feed testing. + const app_repo_url = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (e.g. https://github.com/fizzyedit/fizzy)") orelse "https://github.com/fizzyedit/fizzy"; + + // Comma-separated fallback repo URLs checked (in order) after `app_repo_url` + // yields no update. Lets a build survive a repo move/rename: ship a binary + // whose primary points at the new home and whose fallback points at the old + // one (where the transitional release is published), then transfer the repo. + // Empty by default (no fallback). + const app_repo_url_fallback = b.option([]const u8, "repo-url-fallback", "Comma-separated fallback GitHub repo URLs for Velopack auto-update, tried after -Drepo-url") orelse ""; + + var version_owned: ?[]u8 = null; + defer if (version_owned) |buf| b.allocator.free(buf); + + const app_version: []const u8 = if (app_version_opt) |v| v else blk: { + const raw = b.build_root.handle.readFileAlloc(b.graph.io, "VERSION", b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e}); + version_owned = raw; + break :blk std.mem.trimEnd(u8, raw, "\r\n"); + }; + + const build_opts = b.addOptions(); + build_opts.addOption([]const u8, "app_version", app_version); + 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 static_pixi = b.option( + bool, + "static-pixi", + "Keep pixi statically registered on native (skip built-in dylib load)", + ) orelse false; + build_opts.addOption(bool, "static_pixi", static_pixi); + 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 static_code = b.option( + bool, + "static-code", + "Keep code plugin statically registered on native (skip built-in dylib load)", + ) orelse false; + build_opts.addOption(bool, "static_code", static_code); + const workbench_file_tree = b.option( + bool, + "workbench-file-tree", + "Register the workbench Files sidebar view (file tree)", + ) orelse true; + const workbench_opts = b.addOptions(); + workbench_opts.addOption(bool, "file_tree", workbench_file_tree); + + common.addUpdateStep(b); + + const msvcup_before_compile = velopack.addMsvcupSetupStep(b, ".velopack-msvc"); + const msvcup_setup_step = b.step("msvcup-setup", "Download MSVC SDK into .velopack-msvc/ via velopack-zig (writes zig-libc-*.ini)"); + msvcup_setup_step.dependOn(&msvcup_before_compile.step); + + const zip_pkg = pixi_plugin.zipPackage(b); + + const accesskit = b.option(dvui.AccesskitOptions, "accesskit", "Enable accesskit") orelse .off; + + const assetpack = @import("assetpack"); + const assets_module = assetpack.pack(b, b.path("assets"), .{}); + + // Generated atlas / asset stubs (`src/generated/*.zig`) are imported + // 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/core/generated/"); + const process_assets_step = b.step("process-assets", "generates struct for all assets"); + process_assets_step.dependOn(&assets_processing.step); + + // --------------------------------------------------------------- + // Web (wasm) build — entirely separate from the native exe so it can't disturb + // packaging / SDL / Velopack paths. `zig build web` produces `zig-out/web/{web.wasm, + // web.js, index.html, NotoSansKR-Regular.ttf}`, deployable as-is to a static host. + // + // Checkpoint A: minimal placeholder app, no fizzy editor code yet. Later checkpoints + // will incrementally pull fizzy modules in, gating each native-only path behind a + // `arch != .wasm32` check. + // --------------------------------------------------------------- + + web.addSteps(b, optimize, build_opts, workbench_opts, zip_pkg, assets_module); + + const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, target, optimize, accesskit, build_opts, workbench_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, velopack_enabled); + const exe = main_fizzy.exe; + const zstbi_module = main_fizzy.zstbi_module; + const msf_gif_module = main_fizzy.msf_gif_module; + const known_folders = main_fizzy.known_folders; + + 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); + pack_opts.addOption(bool, "static_pixi", static_pixi); + pack_opts.addOption(bool, "static_workbench", static_workbench); + pack_opts.addOption(bool, "static_code", static_code); + break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, target, optimize, accesskit, pack_opts, workbench_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); + if (main_fizzy.pixi_dylib) |pixi_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), pixi_dylib, "pixi", plugins_install_dir); + } + if (main_fizzy.workbench_dylib) |workbench_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), workbench_dylib, "workbench", plugins_install_dir); + } + if (main_fizzy.code_dylib) |code_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), code_dylib, "code", plugins_install_dir); + } + } else { + const install_artifact = b.addInstallArtifact(exe, .{ + .dest_dir = .{ .override = zig_out_install_dir }, + }); + + const run_cmd = b.addRunArtifact(exe); + const run_step = b.step("run", "Run the app (does not run Velopack)"); + + run_cmd.step.dependOn(&install_artifact.step); + run_step.dependOn(&run_cmd.step); + b.getInstallStep().dependOn(&install_artifact.step); + + if (main_fizzy.pixi_dylib) |pixi_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), pixi_dylib, "pixi", plugins_install_dir); + common.attachBuiltinPluginInstall(b, &run_cmd.step, pixi_dylib, "pixi", plugins_install_dir); + } + if (main_fizzy.workbench_dylib) |workbench_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), workbench_dylib, "workbench", plugins_install_dir); + common.attachBuiltinPluginInstall(b, &run_cmd.step, workbench_dylib, "workbench", plugins_install_dir); + } + if (main_fizzy.code_dylib) |code_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), code_dylib, "code", plugins_install_dir); + common.attachBuiltinPluginInstall(b, &run_cmd.step, code_dylib, "code", plugins_install_dir); + } + } + + 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 = plugin.installBuiltinPlugin(b, workbench_dylib, "workbench", 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.step); + } + + if (main_fizzy.pixi_dylib) |pixi_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_pixi = plugin.installBuiltinPlugin(b, pixi_dylib, "pixi", plugins_install_dir); + + const pixi_dylib_step = b.step( + "pixi-dylib", + "Build the pixi plugin as a dynamic library into zig-out//plugins/ (native only)", + ); + pixi_dylib_step.dependOn(&install_pixi.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("pixi_dylib", pixi_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(&pixi_dylib.step); + + const test_plugin_loader_step = b.step( + "test-plugin-loader", + "Build pixi dylib and run dlopen/register integration test", + ); + test_plugin_loader_step.dependOn(&run_plugin_loader_tests.step); + } + + if (main_fizzy.code_dylib) |code_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_code = plugin.installBuiltinPlugin(b, code_dylib, "code", plugins_install_dir); + const code_dylib_step = b.step( + "code-dylib", + "Build the code plugin as a dynamic library into zig-out//plugins/ (native only)", + ); + code_dylib_step.dependOn(&install_code.step); + } + + _ = package.addSteps(.{ + .b = b, + .target = target, + .optimize = optimize, + .app_version = app_version, + .zig_out_subdir = zig_out_subdir, + .zig_out_install_dir = zig_out_install_dir, + .no_emit = no_emit, + .velopack_required_fail = velopack_required_fail, + .exe_for_package = exe_for_package, + .package_fizzy = package_fizzy, + .macos_sign_app_identity = macos_sign_app_identity, + .macos_sign_install_identity = macos_sign_install_identity, + .macos_notary_profile = macos_notary_profile, + .windows_msvc_libc_opt = windows_msvc_libc_opt, + .fetch_msvc = fetch_msvc, + }); + + // --------------------------------------------------------------- + // Tests + // --------------------------------------------------------------- + // + // Fizzy has two test layers (see tests/README.md): + // + // 1. Unit tests — pure-logic only (math, palette parsing, layer + // order). The test root imports nothing but std + the pure + // modules under test, so it compiles in well under a second + // and never needs dvui/SDL/assets. + // + // 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. + + const test_filters = b.option( + []const []const u8, + "test-filter", + "Skip tests that do not match any filter", + ) orelse &[0][]const u8{}; + + const tests_module = b.addModule("fizzy-tests", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/root.zig"), + }); + + // Wire each pure-logic source file as a named module on the test + // target. Zig 0.15 disallows importing source files outside the test + // module's own directory via relative paths, so we expose them by + // name. Each of these files imports only `std`, so they remain free + // of dvui / SDL / globals. + inline for (.{ + .{ "fizzy-direction", "src/core/math/direction.zig" }, + .{ "fizzy-easing", "src/core/math/easing.zig" }, + .{ "fizzy-layer-order", "src/plugins/pixi/src/internal/layer_order.zig" }, + .{ "fizzy-palette-parse", "src/plugins/pixi/src/internal/palette_parse.zig" }, + .{ "fizzy-layout-anchor", "src/core/math/layout_anchor.zig" }, + .{ "fizzy-reduce", "src/plugins/pixi/src/algorithms/reduce.zig" }, + .{ "fizzy-grid-validate", "src/plugins/pixi/src/internal/grid_layout_validate.zig" }, + .{ "fizzy-animation", "src/plugins/pixi/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]), + .target = target, + .optimize = optimize, + }); + } + + const unit_tests = b.addTest(.{ + .name = "fizzy-unit-tests", + .root_module = tests_module, + .filters = test_filters, + }); + + // `zig build test` is the CI entry point and must stay self-contained: pure + // unit tests only, no dvui/SDL/Velopack/MSVC. Integration tests live under + // `zig build test-integration` (Velopack + dvui-testing + comctl32 on Windows + // → needs MSVC SDK on Windows hosts). `zig build test-all` runs both. + const test_step = b.step("test", "Run fizzy unit tests (pure-logic only, no dvui/SDL/Velopack)"); + test_step.dependOn(&b.addRunArtifact(unit_tests).step); + + // `check` mirrors the split so editor compile-error checking matches CI. + const check_step = b.step("check", "Compile fizzy unit tests without running them"); + check_step.dependOn(&unit_tests.step); + + // --------------------------------------------------------------- + // Layer 2: headless integration tests against dvui's testing + // backend. Wired under separate `test-integration` / `check-integration` + // steps so `zig build test` stays MSVC-free on Windows CI runners. Skipped + // when cross-compiling to *-windows-msvc without an MSVC libc INI. + // --------------------------------------------------------------- + const test_integration_step = b.step("test-integration", "Run fizzy headless integration tests (dvui-testing; needs MSVC on Windows)"); + const check_integration_step = b.step("check-integration", "Compile fizzy integration tests without running them"); + const test_all_step = b.step("test-all", "Run unit + integration tests"); + test_all_step.dependOn(test_step); + test_all_step.dependOn(test_integration_step); + + const test_sdk_version_step = b.step( + "test-sdk-version", + "Verify SDK version ↔ ABI fingerprint lock (compiles SDK + plugin dylib)", + ); + if (main_fizzy.pixi_dylib) |dylib| { + test_sdk_version_step.dependOn(&dylib.step); + } else { + test_sdk_version_step.dependOn(&exe.step); + } + test_all_step.dependOn(test_sdk_version_step); + + if (velopack_required_fail) |fail_step| { + test_integration_step.dependOn(fail_step); + check_integration_step.dependOn(fail_step); + return; + } + + const dvui_testing_dep = b.dependency("dvui", .{ + .target = target, + .optimize = optimize, + .backend = .testing, + .accesskit = accesskit, + }); + const dvui_test_proxy_bridge = sdk.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 + // imports (App.zig, Editor.zig, …) reference `dvui`, `assets`, + // `known-folders`, etc. by name, those names must be wired here. + // We point dvui at the *testing* backend so calling drawing + // functions doesn't try to open a real OS window. + const fizzy_test_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/fizzy.zig"), + }); + fizzy_test_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + fizzy_test_module.addImport("backend", dvui_testing_dep.module("testing")); + fizzy_test_module.addImport("assets", assets_module); + fizzy_test_module.addImport("known-folders", known_folders); + fizzy_test_module.addOptions("build_opts", build_opts); + fizzy_test_module.addImport("zstbi", zstbi_module); + fizzy_test_module.addImport("msf_gif", msf_gif_module); + fizzy_test_module.addImport("zip", zip_pkg.module); + 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); + const sdk_module_test = sdk.wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), dvui_test_proxy_bridge, core_module_test, fizzy_test_module); + _ = pixi_plugin.addStaticModule(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); + _ = workbench_plugin.addStaticModule(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, + .backend = dvui_testing_dep.module("testing"), + }, workbench_opts, fizzy_test_module); + _ = code_plugin.addStaticModule(b, target, optimize, .{ + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + }, fizzy_test_module); + _ = example_plugin.addStaticModule(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| { + fizzy_test_module.addImport("objc", dep.module("objc")); + } + } else if (target.result.os.tag == .windows) { + if (b.lazyDependency("zigwin32", .{})) |dep| { + fizzy_test_module.addImport("win32", dep.module("win32")); + } + } + + const integration_module = b.addModule("fizzy-integration-tests", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/integration.zig"), + }); + integration_module.addImport("fizzy", fizzy_test_module); + integration_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + + const integration_tests = b.addTest(.{ + .name = "fizzy-integration-tests", + .root_module = integration_module, + .filters = test_filters, + }); + + if (target.result.os.tag == .windows) { + integration_tests.root_module.linkSystemLibrary("comctl32", .{}); + } + // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers from + // --libc (vcruntime_typeinfo.h vs libc++ type_info, etc.), so libc++ must be + // off for the msvc ABI regardless of host (cross or native Windows). + integration_tests.root_module.link_libcpp = !target_is_windows_msvc; + pixi_plugin.linkZipNative(integration_tests); + if (velopack_enabled) { + try velopack.linkVelopack(b, integration_tests, .{ .target = target, .optimize = optimize }); + } + + integration_tests.step.dependOn(process_assets_step); + + test_integration_step.dependOn(&b.addRunArtifact(integration_tests).step); + check_integration_step.dependOn(&integration_tests.step); + + if (win_libc.needs_setup) { + exe.step.dependOn(&msvcup_before_compile.step); + if (!velopack_enabled and velopack_supported_for_target) { + exe_for_package.step.dependOn(&msvcup_before_compile.step); + } + integration_tests.step.dependOn(&msvcup_before_compile.step); + unit_tests.step.dependOn(&msvcup_before_compile.step); + } + + if (target.result.os.tag == .windows and target.result.abi == .msvc) { + var roots: [4]*std.Build.Step.Compile = undefined; + var n: usize = 0; + roots[n] = exe; + n += 1; + roots[n] = unit_tests; + n += 1; + roots[n] = integration_tests; + n += 1; + if (!velopack_enabled and velopack_supported_for_target) { + roots[n] = exe_for_package; + n += 1; + } + + // Always apply the translate-c shim + SIZE_MAX define for windows-msvc, regardless of + // whether we're using a downloaded SDK or the host's system MSVC. translate-c uses aro + // (not MSVC cl.exe), and aro rejects literals like `0xffffffffffffffffui64` from MSVC's + // . The shim shadows stdint.h via `-I` (search order beats `-isystem`); the + // defineCMacro adds belt-and-suspenders by predefining SIZE_MAX before any include so + // MSVC's stdint.h `#ifndef SIZE_MAX` skips its own definition entirely. + msvc.applyMsvcTranslateCShim(b, roots[0..n]) catch |e| { + std.debug.panic("MSVC translate-c shim wiring failed: {s}", .{@errorName(e)}); + }; + + if (effective_win_libc) |ini| { + if (cross_win_msvc) b.libc_file = null; + const libc_lp: std.Build.LazyPath = .{ .cwd_relative = ini }; + velopack.applyWindowsMsvcLibcRecursive(b, roots[0..n], libc_lp); + + const ini_exists = blk: { + b.build_root.handle.access(b.graph.io, ini, .{}) catch break :blk false; + break :blk true; + }; + if (ini_exists) { + // Adds explicit MSVC/UCRT/SDK `-isystem` paths from the libc INI to each reachable + // translate-c step. Only relevant when cross-compiling with .velopack-msvc/; on a + // Windows host with system MSVC, Zig auto-discovers these paths itself. + msvc.applyMsvcIncludesToReachableTranslateC(b, roots[0..n], ini) catch |e| { + std.debug.panic("MSVC translate-c include fixup failed: {s}", .{@errorName(e)}); + }; + } else { + // The INI is written by `msvcup-setup` (a make-phase step), but the translate-c + // `-isystem` paths embed the SDK version subdir, which is only known after the SDK + // is installed — so they must be wired at configure time, before that step runs. + // A one-shot `zig build package -Dfetch-msvc` against a clean .velopack-msvc can't + // satisfy that ordering. Fail only the compiles that need it (not `msvcup-setup`, + // which has no such dependency), so running setup first still works. + const fail = &b.addFail( + \\*-windows-msvc has no .velopack-msvc/zig-libc INI yet, so translate-c can't be wired. + \\The SDK install must run as its own step before packaging (it can't be done in one + \\pass — the translate-c include paths depend on the installed SDK version): + \\ zig build msvcup-setup + \\ zig build package -Dtarget=x86_64-windows-msvc + ).step; + for (roots[0..n]) |rc| rc.step.dependOn(fail); + } + } + } +} diff --git a/build/common.zig b/build/common.zig new file mode 100644 index 00000000..f0cad19c --- /dev/null +++ b/build/common.zig @@ -0,0 +1,125 @@ +const std = @import("std"); + +const plugin = @import("../plugin_sdk.zig"); +const update = @import("../update.zig"); +const GitDependency = update.GitDependency; + +/// Install `{id}.{ext}` flat under a `plugins/` directory (no `lib` prefix). +pub fn attachBuiltinPluginInstall( + b: *std.Build, + parent: *std.Build.Step, + dylib: *std.Build.Step.Compile, + id: []const u8, + plugins_dir: std.Build.InstallDir, +) void { + parent.dependOn(&plugin.installBuiltinPlugin(b, dylib, id, plugins_dir).step); +} + +pub fn addUpdateStep(b: *std.Build) void { + const step = b.step("update", "update git dependencies"); + step.makeFn = updateStep; +} + +fn updateStep(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void { + const deps = &.{ + GitDependency{ + .url = "https://github.com/foxnne/zig-objc", + .branch = "main", + }, + GitDependency{ + .url = "https://github.com/kristoff-it/zigwin32", + .branch = "fix/zig16", + }, + GitDependency{ + .url = "https://github.com/foxnne/zig-lib-icons", + .branch = "dvui", + }, + GitDependency{ + .url = "https://github.com/foxnne/dvui-dev", + .branch = "main", + }, + }; + try update.update_dependency(step.owner.allocator, step.owner.graph.io, deps); +} + +/// Installed artifacts go under `zig-out//…` so `packageall` and parallel targets never clobber each other. +pub fn zigOutSubdirForTarget(b: *std.Build, rt: std.Build.ResolvedTarget) []const u8 { + const arch_name: []const u8 = switch (rt.result.cpu.arch) { + .x86_64 => "x86-64", + .aarch64 => "arm64", + else => @tagName(rt.result.cpu.arch), + }; + const os_name: []const u8 = switch (rt.result.os.tag) { + .windows => "windows", + .linux => "linux", + .macos => "macos", + else => @tagName(rt.result.os.tag), + }; + const base = b.fmt("{s}-{s}", .{ arch_name, os_name }); + if (std.mem.indexOfScalar(u8, base, '_') == null) + return base; + const buf = b.allocator.alloc(u8, base.len) catch @panic("OOM"); + @memcpy(buf, base); + for (buf) |*byte| { + if (byte.* == '_') byte.* = '-'; + } + return buf; +} + +/// SDL (via dvui → lazy `sdl3`) requires SDK layout when `-Dtarget=*-macos` is not "native". +pub const MacosSdlPaths = struct { + include: std.Build.LazyPath, + framework: std.Build.LazyPath, + lib: std.Build.LazyPath, +}; + +fn resolveMacosSdkPath(b: *std.Build) ![]const u8 { + if (b.graph.environ_map.get("SDKROOT")) |sdk| { + const trimmed = std.mem.trim(u8, sdk, " \t\r\n"); + if (trimmed.len > 0) { + return b.dupePath(trimmed); + } + } + + const argv: []const []const u8 = &.{ + "xcrun", + "--sdk", + "macosx", + "--show-sdk-path", + }; + const run = try std.process.run(b.allocator, b.graph.io, .{ + .argv = argv, + .stdout_limit = std.Io.Limit.limited(4096), + .stderr_limit = std.Io.Limit.limited(4096), + }); + defer { + b.allocator.free(run.stdout); + b.allocator.free(run.stderr); + } + switch (run.term) { + .exited => |code| if (code != 0) { + std.log.err("SDL on macOS: explicit -Dtarget=*-macos needs an SDK path. xcrun exited with code {d}. Install Xcode Command Line Tools or set SDKROOT.", .{code}); + return error.MacosSdkPath; + }, + else => { + std.log.err("SDL on macOS: xcrun --show-sdk-path failed", .{}); + return error.MacosSdkPath; + }, + } + const path = std.mem.trimEnd(u8, run.stdout, " \t\r\n"); + if (path.len == 0) return error.MacosSdkPath; + return b.dupePath(path); +} + +pub fn macosSdlPathsForExplicitTarget(b: *std.Build, target: std.Build.ResolvedTarget) !?MacosSdlPaths { + if (target.result.os.tag != .macos) return null; + if (b.graph.host.result.os.tag != .macos) return null; + if (target.query.isNative()) return null; + + const sdk = try resolveMacosSdkPath(b); + return MacosSdlPaths{ + .include = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/include" }) }, + .framework = .{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) }, + .lib = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/lib" }) }, + }; +} diff --git a/build/exe.zig b/build/exe.zig new file mode 100644 index 00000000..eea65f93 --- /dev/null +++ b/build/exe.zig @@ -0,0 +1,303 @@ +const std = @import("std"); +const dvui = @import("dvui"); +const velopack = @import("velopack_zig"); +const plugin = @import("../plugin_sdk.zig"); +const common = @import("common.zig"); +const plugins = @import("plugins.zig"); +const sdk = @import("sdk.zig"); + +const pixi_plugin = plugins.pixi; +const workbench_plugin = plugins.workbench; +const code_plugin = plugins.code; +const example_plugin = plugins.example; +const ZipPackage = plugins.ZipPackage; +const MacosSdlPaths = common.MacosSdlPaths; + +/// Install stripped exe + built-in plugin dylibs for `vpk pack --packDir`. +pub 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.pixi_dylib) |dylib| { + const install_pixi = plugin.installBuiltinPlugin(b, dylib, "pixi", pack_plugins_install_dir); + install_pixi.step.dependOn(tail); + tail = &install_pixi.step; + } + if (fizzy.workbench_dylib) |dylib| { + const install_workbench = plugin.installBuiltinPlugin(b, dylib, "workbench", pack_plugins_install_dir); + install_workbench.step.dependOn(tail); + tail = &install_workbench.step; + } + if (fizzy.code_dylib) |dylib| { + const install_code = plugin.installBuiltinPlugin(b, dylib, "code", pack_plugins_install_dir); + install_code.step.dependOn(tail); + tail = &install_code.step; + } + + return tail; +} + +pub const FizzyExecutable = struct { + exe: *std.Build.Step.Compile, + 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. + pixi_dylib: ?*std.Build.Step.Compile = null, + workbench_dylib: ?*std.Build.Step.Compile = null, + code_dylib: ?*std.Build.Step.Compile = null, +}; + +pub fn addFizzyExecutableForTarget( + b: *std.Build, + resolved_target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + accesskit: dvui.AccesskitOptions, + build_opts: *std.Build.Step.Options, + workbench_opts: *std.Build.Step.Options, + zip_pkg: ZipPackage, + assets_module: *std.Build.Module, + process_assets_step: *std.Build.Step, + macos_sdl_paths: ?MacosSdlPaths, + velopack_enabled: bool, +) !FizzyExecutable { + const dvui_dep = if (macos_sdl_paths) |p| + b.dependency("dvui", .{ + .target = resolved_target, + .optimize = optimize, + .backend = .sdl3, + .accesskit = accesskit, + .system_include_path = p.include, + .system_framework_path = p.framework, + .library_path = p.lib, + }) + 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 = sdk.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_module = pixi_plugin.addZstbiModule(b, resolved_target, optimize, false); + const msf_gif_module = pixi_plugin.addMsfGifModule(b, resolved_target, optimize, false); + + const exe = b.addExecutable(.{ + .name = "fizzy", + .root_module = b.addModule("App", .{ + .target = resolved_target, + .optimize = optimize, + .root_source_file = .{ .cwd_relative = "src/App.zig" }, + }), + }); + exe.root_module.strip = false; + + exe.root_module.addImport("assets", assets_module); + const known_folders = b.dependency("known_folders", .{ + .target = resolved_target, + .optimize = optimize, + }).module("known-folders"); + exe.root_module.addImport("known-folders", known_folders); + exe.root_module.addOptions("build_opts", build_opts); + exe.step.dependOn(process_assets_step); + + if (optimize != .Debug) { + switch (resolved_target.result.os.tag) { + .windows => { + exe.subsystem = .Windows; + // MSVC's libcmt links `WinMainCRTStartup` (needs `WinMain`) for /SUBSYSTEM:WINDOWS. + // Fizzy exposes `main`, so force the C `main` entry which works for either subsystem. + if (resolved_target.result.abi == .msvc) { + exe.entry = .{ .symbol_name = "mainCRTStartup" }; + } + }, + else => exe.subsystem = .Posix, + } + } + + exe.root_module.addImport("zstbi", zstbi_module); + exe.root_module.addImport("msf_gif", msf_gif_module); + exe.root_module.addImport("zip", zip_pkg.module); + 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); + + 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 = sdk.wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), proxy_bridge_host_mod, core_module, exe.root_module); + const sdk_proxy_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_proxy_mod, proxy_bridge_plugin_mod, core_proxy_module, null); + _ = pixi_plugin.addStaticModule(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); + _ = workbench_plugin.addStaticModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + .icons = icons_module, + .backend = dvui_dep.module("sdl3"), + }, workbench_opts, exe.root_module); + _ = code_plugin.addStaticModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + }, exe.root_module); + _ = example_plugin.addStaticModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + }, exe.root_module); + + const pixi_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const dylib = pixi_plugin.addDylib(b, resolved_target, optimize, .{ + .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 = null, + }); + pixi_plugin.linkZipNative(dylib); + break :blk dylib; + } else null; + + const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + break :blk workbench_plugin.addDylib(b, resolved_target, optimize, .{ + .dvui = dvui_proxy_mod, + .core = core_proxy_module, + .sdk = sdk_proxy_module, + .proxy_bridge = proxy_bridge_plugin_mod, + .icons = icons_module, + .backend = null, + }, workbench_opts); + } else null; + + const code_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + break :blk code_plugin.addDylib(b, resolved_target, optimize, .{ + .dvui = dvui_proxy_mod, + .core = core_proxy_module, + .sdk = sdk_proxy_module, + .proxy_bridge = proxy_bridge_plugin_mod, + }); + } else null; + + const singleton_app_dep = b.dependency("dvui_singleton_app", .{ + .target = resolved_target, + .optimize = optimize, + }); + exe.root_module.addImport("singleton_app", singleton_app_dep.module("singleton_app")); + + 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 + // same SDK layout for Obj-C sources as for SDL; zig-objc paths do not always reach .m + // compiles (e.g. Security.framework → ). + exe.root_module.addSystemIncludePath(p.include); + exe.root_module.addSystemFrameworkPath(p.framework); + exe.root_module.addLibraryPath(p.lib); + } + if (b.lazyDependency("zig_objc", .{ + .target = resolved_target, + .optimize = optimize, + })) |dep| { + exe.root_module.addImport("objc", dep.module("objc")); + } + 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")); + } + exe.root_module.linkSystemLibrary("comctl32", .{}); + + // Embed assets/windows/fizzy.rc -> fizzy.ico into the exe so Explorer, + // Taskbar, Alt-Tab and the Velopack-generated Start Menu shortcut all + // show the right icon without any runtime work. fizzy.ico must be a + // multi-resolution ICO with 16/32/48/256 px frames (see the README in + // that directory). + exe.root_module.addWin32ResourceFile(.{ + .file = b.path("assets/windows/fizzy.rc"), + }); + } + + // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers + // (vcruntime_typeinfo.h's ::type_info vs libc++'s own, redefined bad_cast, + // etc.). We always feed MSVC's own STL via --libc for *-windows-msvc — on a + // cross host and on a native Windows host using .velopack-msvc alike — so + // libc++ must be off for the msvc ABI regardless of host. + const exe_is_windows_msvc = resolved_target.result.os.tag == .windows and + resolved_target.result.abi == .msvc; + exe.root_module.link_libcpp = !exe_is_windows_msvc; + pixi_plugin.linkZipNative(exe); + if (velopack_enabled) { + try velopack.linkVelopack(b, exe, .{ .target = resolved_target, .optimize = optimize }); + } + + return .{ + .exe = exe, + .zstbi_module = zstbi_module, + .msf_gif_module = msf_gif_module, + .known_folders = known_folders, + .sdk_module = sdk_module, + .pixi_dylib = pixi_dylib, + .workbench_dylib = workbench_dylib, + .code_dylib = code_dylib, + }; +} diff --git a/build/msvc.zig b/build/msvc.zig new file mode 100644 index 00000000..a4ec853c --- /dev/null +++ b/build/msvc.zig @@ -0,0 +1,107 @@ +const std = @import("std"); + +pub fn applyMsvcTranslateCShim(b: *std.Build, roots: []const *std.Build.Step.Compile) !void { + var seen = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator); + defer seen.deinit(); + for (roots) |root_compile| { + const graph = root_compile.root_module.getGraph(); + for (graph.modules) |mod| { + const root_src = mod.root_source_file orelse continue; + const gen = switch (root_src) { + .generated => |g| g, + else => continue, + }; + const dep_step = gen.file.step; + if (dep_step.id != .translate_c) continue; + const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step); + const gop = try seen.getOrPut(tc); + if (gop.found_existing) continue; + 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/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 + // a path that bypasses our shim. + tc.defineCMacro("SIZE_MAX", switch (rt.ptrBitWidth()) { + 32 => "4294967295U", + 64 => "18446744073709551615ULL", + else => "UINT_MAX", + }); + } + } +} + +/// Finds every `Step.TranslateC` reachable from each root compile's Zig module graph and adds +/// MSVC / Windows SDK `-isystem` paths from the zig-libc INI. We walk `Module.getGraph()` (imports) +/// rather than `Step.dependencies`: Zig wires `root_source_file` → `TranslateC` only in +/// `createModuleDependencies`, which runs after `build()` returns, so a step BFS from `Compile` +/// would miss DVUI's `dvui-c` / `sdl3-c` translate steps during Configure. +pub fn applyMsvcIncludesToReachableTranslateC( + b: *std.Build, + roots: []const *std.Build.Step.Compile, + libc_ini_path: []const u8, +) !void { + // `libc_ini_path` is absolute (resolved via `b.pathFromRoot`), so any Dir works as the base. + const data = try b.build_root.handle.readFileAlloc(b.graph.io, libc_ini_path, b.allocator, .unlimited); + + var include_dir: ?[]const u8 = null; + var sys_include_dir: ?[]const u8 = null; + var line_it = std.mem.splitScalar(u8, data, '\n'); + while (line_it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \r\t"); + if (std.mem.startsWith(u8, line, "include_dir=")) { + include_dir = std.mem.trim(u8, line["include_dir=".len..], " \r\t"); + } else if (std.mem.startsWith(u8, line, "sys_include_dir=")) { + sys_include_dir = std.mem.trim(u8, line["sys_include_dir=".len..], " \r\t"); + } + } + if (include_dir == null or sys_include_dir == null) return; + + // `include_dir` points at `.../Windows Kits/10/Include//ucrt`. The Windows SDK's + // um/shared/winrt headers live as siblings of the `ucrt` directory. + const sdk_inc_root = std.fs.path.dirname(include_dir.?) orelse return; + const um_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "um" }); + const shared_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "shared" }); + const winrt_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "winrt" }); + + var seen_translate_c = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator); + defer seen_translate_c.deinit(); + + for (roots) |root_compile| { + const graph = root_compile.root_module.getGraph(); + for (graph.modules) |mod| { + const root_src = mod.root_source_file orelse continue; + const gen = switch (root_src) { + .generated => |g| g, + else => continue, + }; + const dep_step = gen.file.step; + if (dep_step.id != .translate_c) continue; + + const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step); + const gop = try seen_translate_c.getOrPut(tc); + if (gop.found_existing) continue; + + const rt = tc.target.result; + if (rt.os.tag == .windows and rt.abi == .msvc) { + // `translate-c` has no API to pass `--libc `, so `-lc` makes Zig + // auto-detect a system MSVC/SDK install — which fails on a Windows host + // that has no Visual Studio (we use the .velopack-msvc/ tree instead) with + // `WindowsSdkNotFound`. Drop `-lc` here: every MSVC/UCRT/SDK include dir is + // added explicitly below, so the headers still resolve, and the consuming + // exe links libc itself — the translated bindings don't need their own. + tc.link_libc = false; + // Shim + SIZE_MAX define are applied separately by `applyMsvcTranslateCShim`. + // Order matters: MSVC's own headers first (override Windows SDK declarations + // when both exist), then UCRT, then the Windows SDK trio. + tc.addSystemIncludePath(.{ .cwd_relative = sys_include_dir.? }); + tc.addSystemIncludePath(.{ .cwd_relative = include_dir.? }); + tc.addSystemIncludePath(.{ .cwd_relative = um_dir }); + tc.addSystemIncludePath(.{ .cwd_relative = shared_dir }); + tc.addSystemIncludePath(.{ .cwd_relative = winrt_dir }); + } + } + } +} diff --git a/build/package.zig b/build/package.zig new file mode 100644 index 00000000..267b5241 --- /dev/null +++ b/build/package.zig @@ -0,0 +1,261 @@ +const std = @import("std"); +const velopack = @import("velopack_zig"); +const exe = @import("exe.zig"); + +pub const Options = struct { + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + app_version: []const u8, + zig_out_subdir: []const u8, + zig_out_install_dir: std.Build.InstallDir, + no_emit: bool, + velopack_required_fail: ?*std.Build.Step, + exe_for_package: *std.Build.Step.Compile, + package_fizzy: exe.FizzyExecutable, + macos_sign_app_identity: ?[]const u8, + macos_sign_install_identity: ?[]const u8, + macos_notary_profile: ?[]const u8, + windows_msvc_libc_opt: ?[]const u8, + fetch_msvc: bool, +}; + +pub fn addSteps(opts: Options) *std.Build.Step { + const b = opts.b; + const target = opts.target; + const optimize = opts.optimize; + const app_version = opts.app_version; + const zig_out_subdir = opts.zig_out_subdir; + const zig_out_install_dir = opts.zig_out_install_dir; + const no_emit = opts.no_emit; + const velopack_required_fail = opts.velopack_required_fail; + const exe_for_package = opts.exe_for_package; + const package_fizzy = opts.package_fizzy; + const macos_sign_app_identity = opts.macos_sign_app_identity; + const macos_sign_install_identity = opts.macos_sign_install_identity; + const macos_notary_profile = opts.macos_notary_profile; + const windows_msvc_libc_opt = opts.windows_msvc_libc_opt; + const fetch_msvc = opts.fetch_msvc; + + 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 + // back to the plain (Velopack-less) exe. vpk would still wrap it as a Velopack + // installer, but the install hook never runs: Setup.exe hangs with "the + // application install hook failed". Fail loudly instead of shipping that trap. + const windows_non_msvc = target.result.os.tag == .windows and target.result.abi != .msvc; + if (velopack_required_fail) |fail_step| { + package_step.dependOn(fail_step); + } else if (windows_non_msvc) { + package_step.dependOn(&b.addFail( + \\`zig build package` for Windows requires the MSVC ABI so Velopack is linked. + \\The default native target resolves to x86_64-windows-gnu, which builds a binary + \\WITHOUT the Velopack runtime. vpk would still wrap it as a Velopack installer, but + \\the install hook never runs and Setup.exe hangs ("the application install hook failed"). + \\ + \\Build with the MSVC target instead: + \\ zig build package -Dtarget=x86_64-windows-msvc -Dfetch-msvc + \\(needs Windows SDK 10.0.26100+ for SDL's GameInput backend.) + ).step); + } else if (no_emit) { + package_step.dependOn(&b.addFail("cannot run `package` with -Dno-emit").step); + } else switch (target.result.os.tag) { + .linux, .macos, .windows => { + // Host strip can't process foreign object files when cross-compiling. + const cross_os = target.result.os.tag != b.graph.host.result.os.tag; + // Same-OS / different-arch (e.g. aarch64-linux from x86_64-linux) also + // breaks host strip — it errors with "Unable to recognise the format". + const cross_for_strip = cross_os or target.result.cpu.arch != b.graph.host.result.cpu.arch; + // Windows hosts don't ship `strip` or `touch`. Skip the external strip + // step entirely there — Zig's linker already drops debug info in + // release builds. Use `cmd /c exit 0` as the no-op and keep the + // dependency on exe_for_package via the step graph. + const host_is_windows = b.graph.host.result.os.tag == .windows; + const skip_strip = host_is_windows or optimize == .Debug or cross_for_strip; + const strip_release_sh = if (host_is_windows) blk: { + const sh = b.addSystemCommand(&.{ "cmd", "/c", "exit", "0" }); + sh.step.dependOn(&exe_for_package.step); + break :blk sh; + } else blk: { + const sh = b.addSystemCommand(&.{if (skip_strip) "touch" else "strip"}); + sh.addFileArg(exe_for_package.getEmittedBin()); + break :blk sh; + }; + + //const dotnet_tool_restore = velopack.addDotnetToolRestoreStep(b); + //const vpk_vendor_repair = velopack.addVpkVendorRepairStep(b); + //vpk_vendor_repair.step.dependOn(&dotnet_tool_restore.step); + + const vpk_pkg_sh = b.addSystemCommand(&.{"dotnet"}); + vpk_pkg_sh.addArg("vpk"); + // When packaging a foreign-OS bundle, vpk needs an OS directive (e.g. `vpk [win] pack ...`) + // because by default it auto-detects from the host OS. + if (cross_os) { + vpk_pkg_sh.addArg(switch (target.result.os.tag) { + .windows => "[win]", + .linux => "[linux]", + .macos => "[osx]", + else => unreachable, + }); + } + vpk_pkg_sh.addArg("pack"); + vpk_pkg_sh.addArg("--packId"); + vpk_pkg_sh.addArg("fizzy"); + vpk_pkg_sh.addArg("--packVersion"); + vpk_pkg_sh.addArg(app_version); + // Channel = zig-out subdir (`-`, NuGet-safe — no underscores). Baked into + // the binary by vpk; the updater matches this to release assets. Distinct per triple + // so parallel `vpk pack` runs don't collide on RELEASES / nupkg names. + vpk_pkg_sh.addArg("--channel"); + vpk_pkg_sh.addArg(zig_out_subdir); + vpk_pkg_sh.addArg("--mainExe"); + vpk_pkg_sh.addArg(switch (target.result.os.tag) { + .windows => "fizzy.exe", + else => "fizzy", + }); + + vpk_pkg_sh.addArg("--delta"); + vpk_pkg_sh.addArg("None"); + vpk_pkg_sh.addArg("--yes"); + + vpk_pkg_sh.addArg("--outputDir"); + // `addOutputDirectoryArg` takes a basename — Zig manages the actual + // path under the run step's cache dir. The `addInstallDirectory` + // below copies that into zig-out//. Previously this passed + // 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 = exe.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.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 + // exe's own icon is already embedded via assets/windows/fizzy.rc. + vpk_pkg_sh.addArg("--icon"); + const ico_path = b.path("assets/windows/fizzy.ico").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("ico path: {}", .{e}); + vpk_pkg_sh.addArg(ico_path); + // Velopack's installer is silent (no shortcut-choice UI). Default is + // Desktop,StartMenu; restrict to StartMenu so we don't drop an + // unrequested icon on the user's desktop. + vpk_pkg_sh.addArg("--shortcuts"); + vpk_pkg_sh.addArg("StartMenu"); + }, + .macos => { + vpk_pkg_sh.addArg("--packTitle"); + vpk_pkg_sh.addArg("fizzy"); + // Bundle id / document types / versions: assets/macos/info.plist (vpk rejects --bundleId with --plist). + vpk_pkg_sh.addArg("--plist"); + const plist_path = b.path("assets/macos/info.plist").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("plist path: {}", .{e}); + vpk_pkg_sh.addArg(plist_path); + vpk_pkg_sh.addArg("--icon"); + const icns_path = b.path("assets/macos/fizzy.icns").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("icns path: {}", .{e}); + vpk_pkg_sh.addArg(icns_path); + + if (macos_sign_app_identity) |id| { + vpk_pkg_sh.addArg("--signAppIdentity"); + vpk_pkg_sh.addArg(id); + // Required for notarization: enables hardened runtime + secure timestamp on + // every nested binary (vpk forwards the file to `codesign --entitlements`). + // Without this, Apple's notary service rejects with "signature does not + // include a secure timestamp" / "hardened runtime not enabled". + vpk_pkg_sh.addArg("--signEntitlements"); + const entitlements_path = b.path("assets/macos/Fizzy.entitlements").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("entitlements path: {}", .{e}); + vpk_pkg_sh.addArg(entitlements_path); + } + if (macos_sign_install_identity) |id| { + vpk_pkg_sh.addArg("--signInstallIdentity"); + vpk_pkg_sh.addArg(id); + } + if (macos_notary_profile) |profile| { + vpk_pkg_sh.addArg("--notaryProfile"); + vpk_pkg_sh.addArg(profile); + } + }, + else => {}, + } + vpk_pkg_sh.setEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major"); + // Stream vpk's stdout/stderr live so failures surface their actual + // diagnostic instead of just an exit-code-N message from the build + // runner. With `addOutputDirectoryArg` in play, `infer_from_args` + // can otherwise capture+drop stdio on certain runner configs. + vpk_pkg_sh.stdio = .inherit; + try velopack.attachMksquashfsToVpkRun(b, vpk_pkg_sh, target); + + //vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step); + vpk_pkg_sh.step.dependOn(pack_stage_tail); + + const build_package_install = b.addInstallDirectory(.{ + .source_dir = vpk_pkg_out_dir, + .install_dir = zig_out_install_dir, + .install_subdir = "", + }); + + package_step.dependOn(&build_package_install.step); + }, + else => { + package_step.dependOn(&b.addFail("Velopack packaging is only supported for Linux, macOS, and Windows targets").step); + }, + } + + const desktop_step = b.step("desktop", "Alias for `zig build package`"); + desktop_step.dependOn(package_step); + + const packageall_step = b.step("packageall", "Six zig build package runs; use -Dwindows-msvc-libc= or -Dfetch-msvc for Windows children from macOS/Linux"); + if (no_emit) { + packageall_step.dependOn(&b.addFail("cannot run `packageall` with -Dno-emit").step); + } else { + const packageall_optimize_arg = b.fmt("-Doptimize={s}", .{@tagName(optimize)}); + + // Build order is deliberately fail-fast: Windows first (most likely to + // fail on a fresh CI runner because of MSVC SDK setup, libc.ini paths, + // and cross-compile ABI surprises), then Linux (mksquashfs / AppImage + // packaging quirks), then macOS last (native, lowest risk). When a + // release run is going to break, this ordering surfaces the failure + // 5-10 minutes sooner than the alphabetical order did. + const packageall_triples = [_][]const u8{ + "x86_64-windows-msvc", + "aarch64-windows-msvc", + "x86_64-linux-gnu", + "aarch64-linux-gnu", + "x86_64-macos", + "aarch64-macos", + }; + + var prev_step: ?*std.Build.Step = null; + for (packageall_triples) |triple| { + const zig_pkg_run = b.addSystemCommand(&.{ + b.graph.zig_exe, + "build", + "package", + packageall_optimize_arg, + b.fmt("-Dtarget={s}", .{triple}), + }); + if (std.mem.endsWith(u8, triple, "-windows-msvc")) { + if (windows_msvc_libc_opt) |libc_path| { + zig_pkg_run.addArg(b.fmt("-Dwindows-msvc-libc={s}", .{libc_path})); + } + if (fetch_msvc) zig_pkg_run.addArg("-Dfetch-msvc"); + } + zig_pkg_run.setCwd(b.path(".")); + if (prev_step) |p| { + zig_pkg_run.step.dependOn(p); + } + prev_step = &zig_pkg_run.step; + } + packageall_step.dependOn(prev_step.?); + } + + return package_step; +} diff --git a/build/plugins.zig b/build/plugins.zig new file mode 100644 index 00000000..707ab413 --- /dev/null +++ b/build/plugins.zig @@ -0,0 +1,12 @@ +//! Built-in plugin build integration — the static-embed + bundled-dylib module graph. +//! +//! Each built-in plugin keeps its fizzy-internal static-embed glue self-contained in +//! `src/plugins//static/integration.zig`, separate from the canonical third-party files +//! at the plugin-folder root (the shell's `@import("")` resolves to the root +//! `.zig`). Fizzy root aggregates those integration files here. +pub const pixi = @import("../src/plugins/pixi/static/integration.zig"); +pub const workbench = @import("../src/plugins/workbench/static/integration.zig"); +pub const code = @import("../src/plugins/code/static/integration.zig"); +pub const example = @import("../src/plugins/example/static/integration.zig"); + +pub const ZipPackage = pixi.ZipPackage; diff --git a/build/sdk.zig b/build/sdk.zig new file mode 100644 index 00000000..82490947 --- /dev/null +++ b/build/sdk.zig @@ -0,0 +1,38 @@ +const std = @import("std"); + +pub 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; +} + +pub fn wireSdkModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + dvui_module: *std.Build.Module, + proxy_bridge_module: *std.Build.Module, + core_module: *std.Build.Module, + consumer: ?*std.Build.Module, +) *std.Build.Module { + const sdk_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/sdk/sdk.zig"), + }); + sdk_module.addImport("dvui", dvui_module); + sdk_module.addImport("proxy_bridge", proxy_bridge_module); + sdk_module.addImport("core", core_module); + if (consumer) |c| c.addImport("sdk", sdk_module); + return sdk_module; +} diff --git a/build/web.zig b/build/web.zig new file mode 100644 index 00000000..5d2ab5e2 --- /dev/null +++ b/build/web.zig @@ -0,0 +1,210 @@ +const std = @import("std"); +const plugins = @import("plugins.zig"); +const sdk = @import("sdk.zig"); + +const pixi_plugin = plugins.pixi; +const workbench_plugin = plugins.workbench; +const code_plugin = plugins.code; +const example_plugin = plugins.example; + +pub fn addSteps( + b: *std.Build, + optimize: std.builtin.OptimizeMode, + build_opts: *std.Build.Step.Options, + workbench_opts: *std.Build.Step.Options, + zip_pkg: plugins.ZipPackage, + assets_module: *std.Build.Module, +) void { + const web_target = b.resolveTargetQuery(.{ + .cpu_arch = .wasm32, + .os_tag = .freestanding, + .cpu_features_add = std.Target.wasm.featureSet(&.{ + .atomics, + .multivalue, + .bulk_memory, + }), + }); + + const dvui_web_dep = b.dependency("dvui", .{ + .target = web_target, + .optimize = optimize, + .backend = .web, + .freetype = false, + }); + const dvui_web_proxy_bridge = sdk.addProxyBridgeModule(b, web_target, optimize, dvui_web_dep, dvui_web_dep.module("dvui_web")); + + const web_exe = b.addExecutable(.{ + .name = "web", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/web_main.zig"), + .target = web_target, + .optimize = optimize, + .link_libc = false, + .single_threaded = true, + .strip = optimize == .ReleaseFast or optimize == .ReleaseSmall, + }), + }); + web_exe.entry = .disabled; + web_exe.root_module.addImport("dvui", dvui_web_dep.module("dvui_web")); + web_exe.root_module.addImport("web-backend", dvui_web_dep.module("web")); + + // Extra wasm exports beyond dvui's own (`dvui_init`/`dvui_update`/etc.). The wasm + // linker only emits symbols listed here, so `export fn` in Zig isn't enough on its + // own — without this line our trackpad pinch entry point would compile cleanly but + // be missing from `instance.exports`, and the JS bootstrap in `web/shell.html` + // would never be able to forward pinch deltas into the canvas widget. + web_exe.root_module.export_symbol_names = &[_][]const u8{ + "FizzyWebTrackpadMagnification", + }; + + // `icons` (pure-Zig icon data) is referenced at file scope in + // `src/dvui.zig` and `src/editor/Infobar.zig`. Wired in so any future + // wasm-reachable code that pulls those files in compiles cleanly. + if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| { + web_exe.root_module.addImport("icons", dep.module("icons")); + } + + // `assets` is generated at build time by assetpack (pure `@embedFile`s, + // target-independent). Same instance as native — no extra build cost. + web_exe.root_module.addImport("assets", assets_module); + + // `build_opts` (app_version, app_repo_url, velopack_enabled) — shared + // with native. velopack_enabled is whatever was passed via `-Dvelopack`; + // wasm path is gated by `arch != .wasm32` in `auto_update.impl`. + web_exe.root_module.addOptions("build_opts", build_opts); + + // `zip` — Zig decls + miniz/zip.c compiled for wasm with `fizzy_zip_libc.c` + // (malloc → dvui_c_alloc). Enables `zip_stream_*` for .fiz open/save in browser. + web_exe.root_module.addImport("zip", zip_pkg.module); + pixi_plugin.linkZipWasm(web_exe); + + // `known-folders` is referenced at file scope in a few editor files + // (AboutFizzy, Editor settings paths). It's a pure-Zig wrapper for + // OS-specific user-directory APIs — the file compiles fine on wasm even + // though runtime calls would fail (which we'll never reach on web). + const known_folders_web = b.dependency("known_folders", .{ + .target = web_target, + .optimize = optimize, + }).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); + const sdk_module_web = sdk.wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), dvui_web_proxy_bridge, core_module_web, web_exe.root_module); + + // 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 + // lazy analysis skips file-scope consts that no reachable body uses. + // So no `backend` module is wired in for the web build. + + const zstbi_web_lib_module = pixi_plugin.addZstbiModule(b, web_target, optimize, true); + web_exe.root_module.addImport("zstbi", zstbi_web_lib_module); + + const msf_gif_web_lib_module = pixi_plugin.addMsfGifModule(b, web_target, optimize, true); + web_exe.root_module.addImport("msf_gif", msf_gif_web_lib_module); + + _ = pixi_plugin.addStaticModule(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_module, + .msf_gif = msf_gif_web_lib_module, + .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null, + .backend = null, + }, web_exe.root_module); + _ = workbench_plugin.addStaticModule(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, + .backend = null, + }, workbench_opts, web_exe.root_module); + _ = code_plugin.addStaticModule(b, web_target, optimize, .{ + .dvui = dvui_web_dep.module("dvui_web"), + .core = core_module_web, + .sdk = sdk_module_web, + }, web_exe.root_module); + _ = example_plugin.addStaticModule(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, .{ + .dest_dir = .{ .override = web_install_dir }, + }); + + // Cache-buster: stamps a 64-char hash into the index.html / web.js placeholders so + // the browser picks up new wasm builds without manual hard-reloads. Re-implements + // upstream DVUI's `addWebExample` machinery so we don't have to invoke its step. + const cb = b.addExecutable(.{ + .name = "cacheBuster", + .root_module = b.createModule(.{ + .root_source_file = dvui_web_dep.path("src/cacheBuster.zig"), + .target = b.graph.host, + }), + }); + const cb_run = b.addRunArtifact(cb); + cb_run.addFileArg(b.path("web/shell.html")); + cb_run.addFileArg(dvui_web_dep.path("src/backends/web.js")); + cb_run.addFileArg(web_exe.getEmittedBin()); + const index_html_with_hash = cb_run.captureStdOut(.{}); + + const web_step = b.step("web", "Build the fizzy web (wasm) app into zig-out/web/"); + web_step.dependOn(&install_wasm.step); + web_step.dependOn(&b.addInstallFileWithDir( + index_html_with_hash, + web_install_dir, + "index.html", + ).step); + web_step.dependOn(&b.addInstallFileWithDir( + dvui_web_dep.path("src/backends/web.js"), + web_install_dir, + "web.js", + ).step); + web_step.dependOn(&b.addInstallFileWithDir( + dvui_web_dep.path("src/fonts/NotoSansKR-Regular.ttf"), + web_install_dir, + "NotoSansKR-Regular.ttf", + ).step); + + // Compile-only smoke check for the wasm target. Pairs with `check` (unit + // tests). Catches regressions where someone reaches a wasm-incompatible + // code path (thread spawn, std.posix surface, missing module import) + // from the wasm root. No install — just compile. + const check_web_step = b.step("check-web", "Compile fizzy web (wasm) without installing artifacts"); + check_web_step.dependOn(&web_exe.step); + + // Copy zig-out/web into web/app/ for local preview at the production + // `/app/` path: `cd web && python3 -m http.server` then open + // http://localhost:8000/app/. The landing page lives in fizzyedit/website. + const web_docs_step = b.step("web-docs", "Build web app and copy into web/app/ for local /app/ preview"); + web_docs_step.dependOn(web_step); + const cp_web_to_docs = b.addSystemCommand(&.{ "sh", "-c" }); + cp_web_to_docs.addArg("mkdir -p web/app && cp -R zig-out/web/. web/app/"); + cp_web_to_docs.step.dependOn(web_step); + web_docs_step.dependOn(&cp_web_to_docs.step); + + const serve_web_cmd = b.addSystemCommand(&.{ "sh", "scripts/serve-web.sh" }); + serve_web_cmd.step.dependOn(web_step); + _ = b.step( + "serve-web", + "Serve zig-out/web at http://127.0.0.1:8765/ (builds web first; frees stale :8765)", + ).dependOn(&serve_web_cmd.step); +} diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 2b1c86dc..a5c3b451 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -12,14 +12,14 @@ dynamic library. ``` ┌─────────────────────────────────────────────────────────┐ - │ Shell (Editor) │ - │ window · frame loop · menu/sidebar/panel layout · docs │ - │ │ - │ ┌──────────────┐ ┌──────────────────────────┐ │ - │ │ Host │◄──────►│ EditorAPI │ │ - │ │ registries │ reach │ (shell read/util surface │ │ - │ │ + services │ back │ arena, folder, docs, …) │ │ - │ └──────┬───────┘ └──────────────────────────┘ │ + │ 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 ┌──────────┴───────────────┐ ┌────────────────────────┐ @@ -59,31 +59,192 @@ depend on the SDK, implement the same `Plugin` interface, and ship a loadable li ## 2. Anatomy of a plugin -### Directory layout +### Required files (checklist) + +A plugin is a small, fixed set of files. The SDK owns the boilerplate — the C entry symbols +and the allocator/`*Host` injection — so you really implement just one file. + +| File | Required? | You implement? | +|------|-----------|----------------| +| `build.zig` / `build.zig.zon` | **required** | yes — declare the `fizzy` dep, call `fizzy.plugin.create` + `.install` | +| `root.zig` | **required** | **no** — copy `fizzy/src/plugins/root.zig` (one `exportEntry` call) | +| `src/plugin.zig` | **required** | **yes** — `register(host)` + the `Plugin` vtable; owns your state | +| `src/State.zig`, … | as needed | yes — your feature code | + +**Minimum viable plugin:** `build.zig`, `build.zig.zon`, `root.zig` (copied), `src/plugin.zig`. +The host injects the allocator + `*Host` into the SDK itself (read via `sdk.allocator()` / +`sdk.host()`), so there is no storage file — everything else is optional structure around your +one implementation file. + +> **Built-in plugins use this exact same shape.** A built-in's folder is, file-for-file, a +> third-party plugin (`build.zig`, `build.zig.zon`, `root.zig`, `src/plugin.zig`, …) and it +> builds standalone the same way (`cd src/plugins/ && zig build`). The *only* extra is a +> small amount of fizzy-internal glue, separated out so it never clutters the plugin contract: +> a root `.zig` (the conventional package module + import hub) plus a `static/` subfolder. See [*How built-in plugins are wired*](#how-built-in-plugins-are-wired-fizzy-internal) +> at the end of this section. The in-repo [`example`](../src/plugins/example/) plugin is the +> canonical, always-compiling template — copy that folder to start a new plugin. + +### 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 +my-plugin/ + build.zig + build.zig.zon # fizzy dependency + .paths listing root.zig, src/, … + root.zig # dylib entry — copy from fizzy/src/plugins/root.zig (one exportEntry call) 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 + plugin.zig # register(host) + Plugin vtable; owns its State + State.zig # optional but typical + … +``` + +No storage/`Globals` file: the host injects the allocator + `*Host` into the SDK, so plugin +code reads them through `sdk.allocator()` / `sdk.host()`. The in-repo +[`example`](../src/plugins/example/) plugin is a complete minimal example you can copy; +[markdown](https://github.com/fizzyedit/markdown) is an external one. + +### What each file must contain + +#### `root.zig` (third-party only — copy, don't invent) + +The entire dylib entry is one call to `sdk.dylib.exportEntry`, which emits the five C +symbols the host looks up: + +```zig +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} ``` -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. +| Export | Purpose | +|--------|---------| +| `fizzy_plugin_abi_fingerprint` | Must match host or load is rejected | +| `fizzy_plugin_register` | Calls your `src/plugin.zig` `register(host)` | +| `fizzy_plugin_set_dvui_context` | Host injects live dvui window/io before draw | +| `fizzy_plugin_set_render_bridge` | Host injects dvui proxy render bridge | +| `fizzy_plugin_set_globals` | Host injects allocator + `*Host` into the SDK (`sdk.allocator()` / `sdk.host()`) | + +Copy **`fizzy/src/plugins/root.zig`** into your project root; the `@import("src/plugin.zig")` +is relative to **your** tree (not fizzy's). The export bodies live in the SDK +(`sdk.dylib.exportEntry`), so there is nothing to maintain or keep in sync here. + +Built-in plugins use this **same** `root.zig` (their dylib build goes through it too); they no +longer carry a separate `dylib.zig` or typed `Globals.zig` — they read `sdk.allocator()` / +`sdk.host()` exactly like a third-party plugin. + +#### `src/plugin.zig` — **the contract you own** + +Must provide: + +1. A **`sdk.Plugin` value** — stable `id` (snake_case), `display_name`, `vtable`, and + `state` (set during `register`). +2. **`pub fn register(host: *sdk.Host) !void`** — wire `plugin.state`, call + `host.registerPlugin(&plugin)`, then any `host.registerSidebarView` / + `registerBottomView` / `registerCenterProvider` / `registerMenu` / + `registerSettingsSection` / `registerService` contributions. +3. A **`vtable: sdk.Plugin.VTable`** — only fill hooks your plugin needs; unset fields + stay `null`. + +Minimal skeleton (registers identity only — no documents, no panes): + +```zig +const sdk = @import("sdk"); + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "my_plugin", + .display_name = "My Plugin", +}; + +const vtable: sdk.Plugin.VTable = .{ + .deinit = deinit, +}; + +var plugin_state: State = .{}; // your own singleton; the SDK holds gpa/host for you + +pub fn register(host: *sdk.Host) !void { + plugin.state = @ptrCast(&plugin_state); + try host.registerPlugin(&plugin); +} + +fn deinit(_: *anyopaque) void { plugin_state.deinit(sdk.allocator()); } +``` + +**Editor plugins** (open/save/draw files) also implement document vtable hooks — +`fileTypePriority`, `loadDocument`, `drawDocument`, `saveDocument`, `isDirty`, etc. +**Shell plugins** (workbench-style) skip document hooks and instead register a center +provider or sidebar views. See `Plugin.VTable` in [`src/sdk/Plugin.zig`](../src/sdk/Plugin.zig) +for the full hook list. + +#### Runtime access — **no storage file** + +The shell cannot be imported from plugin code, so the host pushes the allocator and the +`*Host` across the dylib boundary at load (`fizzy_plugin_set_globals`). `exportEntry` +catches them **into the SDK itself**, so plugin code just reads: + +- **`sdk.allocator()`** — the persistent host allocator (see *Memory* below). +- **`sdk.host()`** — the shell `*Host`: registries, services, and the `EditorAPI` read + surface (open folder, active doc, arena allocator, save dialogs). + +Your **own** state is just a variable you own. A singleton is a module-level `var`: + +```zig +var plugin_state: State = .{}; +// in register: plugin.state = @ptrCast(&plugin_state); +// in deinit: plugin_state.deinit(sdk.allocator()); +``` + +If your plugin uses `core`'s allocating helpers (most don't), sync that module's allocator +once in `register`: `core.gpa = sdk.allocator();`. + +Built-in plugins do the same — they call `register(&host)` directly at startup and read +`sdk.allocator()` / `sdk.host()`. (Earlier built-ins kept a typed `Globals.zig` poked from +`App.zig`; that is gone — there is one injection path for everyone now.) + +#### `build.zig` / `build.zig.zon` (third-party) + +`build.zig.zon` — declare **fizzy** as the only shell dependency (dvui arrives +transitively). List every shipped path in `.paths` (`root.zig`, `src`, …). + +`build.zig` — call `fizzy.plugin.create`, attach any extra libs on `lib.root_module`, then +`fizzy.plugin.install` (which renames Zig's `libplugin.dylib` output to the `plugin.` +the loader scans for — no shell `cp`/`mkdir`, works on every host): + +```zig +const lib = fizzy.plugin.create(b, .{ + .target = target, + .optimize = optimize, +}); +lib.root_module.linkLibrary(…); +lib.root_module.addIncludePath(…); +fizzy.plugin.install(b, lib, .{}); +``` + +Then `zig build install --prefix /` lands `plugin.` where the host +expects it, e.g. `~/.config/fizzy/plugins//plugin.dylib` (macOS: +`~/Library/Application Support/fizzy/plugins//plugin.dylib`). + +### Import discipline + +Files inside `src/**` must **not** `@import("fizzy")` or reach into the shell. Allowed: + +- `@import("sdk")`, `@import("core")`, `@import("dvui")` — wired on the dylib module by + `fizzy.plugin.create` +- `@import("State.zig")`, … — sibling files in your `src/` tree +- Built-in only: `@import("../.zig")` for an optional local hub file -### The `register(host)` entry — the one required surface +This is what lets the same sources compile as a standalone dylib. + +### The `register(host)` entry `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 + plugin.state = @ptrCast(&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 @@ -98,9 +259,11 @@ pub fn register(host: *sdk.Host) !void { `*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 +### The `Plugin` vtable — the universal editor protocol -Every field is an optional fn pointer taking the plugin's opaque `state`. Group by purpose: +`Plugin.vtable` is the **universal editor contract**: every field is an optional fn pointer +taking the plugin's opaque `state`, and it holds only hooks that any editor plugin might need. +Group by purpose: - **Lifecycle** — `deinit`, `initPlugin`. - **Document ownership** — `fileTypePriority(ext)` (claim file extensions), `loadDocument` / @@ -112,37 +275,211 @@ Every field is an optional fn pointer taking the plugin's opaque `state`. Group 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). +- **Per-frame phases** — generic frame callbacks (see the lifecycle table below for exactly + when each fires): `beginFrame`, `prepareFrame`, `tickKeybinds`, `tickOpenDocuments`, + `tickActiveDocument`, `drawOverlay`, `endFrame`, `needsContinuousRepaint`. A plugin does its + own domain work *inside* these generic phases. +- **Folder lifecycle** — `onFolderClose` / `onFolderOpen` (fired when the open root folder + changes/closes so a plugin can persist & reload state it keyed to that folder). +- **Save protocol** — `saveNeedsConfirmation(doc)` + `requestSaveConfirmation(doc, mode, …)` + (the owner may present a pre-save confirmation, e.g. a lossy-flatten warning). - **Contributions** — `contributeMenu`, `contributeKeybinds`. -- **Dialogs** — `requestNewDocumentDialog`, `requestGridLayoutDialog`, - `requestFlatRasterSaveWarning` (the shell dispatches; the plugin owns the dialog). +- **New document** — `requestNewDocumentDialog` (the shell dispatches; the plugin owns the dialog). + +Every hook here is generic — none names a domain feature. **Editing actions** (copy, paste, +transform, accept/cancel edit, delete selection) are deliberately *not* hooks: they are +user-invoked and mean different things per editor, so they are `Command`s (see below), not part +of this contract. 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. + +#### Required vs optional + +Every vtable field is an optional fn pointer, so the **type system requires nothing**. But to +function *as an editor* (open / draw / save files) you must implement the document cluster: + +> `fileTypePriority` · `documentStackSize` · `documentStackAlign` · `loadDocument` · +> `documentIdFromBuffer` · `registerOpenDocument` · `documentPtr` · `deinitDocumentBuffer` · +> `drawDocument` · `saveDocument` · `isDirty` + +Everything else is genuinely optional — implement only what your editor needs. (A non-editor +plugin like the workbench implements none of these and contributes panes + a center provider.) + +#### When & where each hook fires + +The model tag tells you how the shell invokes a hook: `[broadcast]` = called for every plugin +at that point; `[active-doc]` = called as `doc.owner.hook(doc)` only for the focused document; +`[requested]` = only fires after you call the paired `host.*` request. The call sites are in +`src/editor/Editor.zig` (verify with `grep` — line numbers drift): + +| Hook | Model | When / where | +|---|---|---| +| `beginFrame` | broadcast | top of the draw, before workspace rebuild (`renderFrame`) | +| `prepareFrame` | requested | after layout, before draw — only when `pending_composite_warmup` was set by `host.requestPrepareFrame()` | +| `needsContinuousRepaint` | broadcast | the shell's "should I keep repainting vs idle" decision | +| `tickOpenDocuments` | broadcast | early per-frame tick; return true → request a follow-up anim frame | +| `drawDocument(doc)` | active-doc | center region, when the workbench draws the focused tab | +| `tickActiveDocument(id)` | broadcast | inside the active document container (has the timer-anchor id) | +| `endFrame` | broadcast | `defer` at the end of the document-container block | +| `tickKeybinds` | broadcast | after the center draw, before the shell's global keybinds | +| `drawOverlay` | broadcast | right after `tickKeybinds`, on top of the frame | + +Outside the frame loop: `onFolderClose` / `onFolderOpen` fire `[broadcast]` from +`setProjectFolder` / `closeProjectFolder`; `saveNeedsConfirmation` / `requestSaveConfirmation` +fire `[active-doc]` from the `save` / close / quit-all paths; `loadDocument` runs on a +**background load-worker thread** (touch only the host allocator + the given buffer, no dvui). + +### Commands — how a plugin contributes its *own* features + +Anything a plugin **invokes** rather than implements as a shell callback — both plugin-specific +features (pixel-art's *Grid Layout*, *Pack Project*) and editing actions whose meaning varies per +editor (*Copy*, *Paste*, *Transform*, *Accept/Cancel Edit*, *Delete Selection*) — is a `Command`, +not a vtable hook. The plugin registers a named [`Command`](../src/sdk/regions.zig) with the Host, +and the shell triggers it by id via `host.runCommand("")` **without knowing what it does**: + +```zig +try host.registerCommand(.{ + .id = "pixelart.packProject", // plugin-namespaced + .owner = &plugin, + .title = "Pack Project", + .run = packProjectCommand, // fn(state) anyerror!void — resolves its own context + .isEnabled = packProjectEnabled, // optional gate +}); +``` -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. +This is the seam that keeps the SDK and shell free of any one plugin's vocabulary: the universal +`VTable` above is what *every* editor implements, and `Command`s are what each plugin adds on top. +A plugin's per-frame domain work (animation, atlas packing) runs inside the generic per-frame +phases; its invocable actions are commands. See `src/plugins/pixelart/src/plugin.zig`. -### Reaching the shell: `Globals` injection +**Per-owner action convention.** The shell's built-in actions on the active document — its Edit +menu / keybinds (*Copy* `copy`, *Paste* `paste`, *Transform* `transform`, accept `acceptEdit`, +cancel `cancelEdit`, delete `deleteSelection`) and *Grid Layout* (`gridLayout`) — dispatch to +`"."`. So focusing a pixel-art doc runs `"pixelart.copy"`; a second +editor answers the same shell actions by registering its own `".copy"`, `…transform`, +etc. An action the owner didn't register is simply a no-op for its documents. This keeps the +shell's standard editing UI while routing every action to whichever editor owns the focused tab. + +### Reaching the shell: SDK-held 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. +startup — the allocator and the `*Host`. `exportEntry` catches them into the SDK, so plugin +code reads `sdk.allocator()` and `sdk.host()` directly (e.g. `sdk.host().` for the +open folder, active doc, arena allocator). Your own data is whatever variable you own. In a +dynamic build the host pushes these across the library boundary via the +`fizzy_plugin_set_globals` C export. + +### Memory: one allocator, one arena + +A plugin manages memory with the host through exactly two allocators, both reached from the +`*Host` it is handed in `register`: + +- **`host.allocator`** — the persistent heap allocator. Use it for anything that outlives a + frame (documents, caches, registry entries). You own every allocation and must free it. This + is the same allocator surfaced as `sdk.allocator()`; the two are interchangeable. +- **`host.arena()`** — a per-frame scratch allocator. It is reset at the end of every frame, so + never free from it and never hold a pointer into it past the current frame. + +**Do not capture `dvui.currentWindow().gpa` as "the allocator."** The shell deliberately creates +the dvui window with `host.allocator`, so today they are the same instance — but treat +`host.allocator` as the contract. Mixing allocators (allocate with one, free with another) is the +one memory bug the type system can't catch and it corrupts the heap. Pick `host.allocator` and +stay with it. ### Building as a dynamic library -`dylib.zig` exports the C entry symbols the loader looks up (`src/sdk/dylib.zig`): +Your `root.zig`'s `sdk.dylib.exportEntry` emits the C entry symbols the loader looks up +(defined in `src/sdk/dylib.zig`): -- `fizzy_plugin_abi_version` → must equal the host's `dylib.abi_version` or the load is rejected. +- `fizzy_plugin_abi_fingerprint` → must equal the host's `dylib.abi_fingerprint` 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). +- `fizzy_plugin_set_globals` / `fizzy_plugin_set_dvui_context` → host injects the allocator + + `*Host` (into the SDK) 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). + +There is **no ABI version to bump.** `dylib.abi_fingerprint` is a compile-time structural hash +over every type that crosses the boundary — the `Host`/`Plugin`/`DocHandle`/`EditorAPI` vtables, +the dvui types passed through them, and the C entry-symbol signatures (see `src/sdk/fingerprint.zig`). +Host and plugin each compute it from their own sources, so changing a vtable hook, a boundary +struct's layout, or the dvui dependency changes the hash automatically and stale plugins are +rejected at load. If you add a brand-new struct that crosses the boundary by value, add it to the +root list in `dylib.zig` so its layout is folded in. + +### Third-party quick start + +Fastest path: **copy the in-repo [`example`](../src/plugins/example/) plugin folder**, rename +the id/name, and replace `src/plugin.zig` with your feature. It is the canonical, always- +compiling template and already has every required file in the right place. See **Required +files**, **Layout**, and **What each file must contain** above. In short: + +1. Copy `fizzy/src/plugins/root.zig` (or `example/root.zig`) → `root.zig` (one `exportEntry` + call, never edited). +2. Implement `src/plugin.zig` (`register` + vtable). Read the host allocator + `*Host` via + `sdk.allocator()` / `sdk.host()`; own your state as a plain `var`. No storage file. +3. Add `build.zig` / `build.zig.zon` with a `fizzy` dependency, `fizzy.plugin.create`, and + `fizzy.plugin.install`. +4. `zig build install --prefix //` so the host finds `plugin.`. + +`fizzy.plugin.create` options: + +| Option | Default | When to override | +|--------|---------|------------------| +| `root_source_file` | `root.zig` | Dylib entry is not at project root or not named `root.zig` | +| `name` | `"plugin"` | Dylib artifact name (output is still `plugin.dylib` when installed) | + +Pin the **fizzy** dependency to the same revision as the host you run against; ABI +mismatch surfaces as a failed load at `fizzy_plugin_abi_fingerprint`, not a semver check. + +### How built-in plugins are wired (fizzy-internal) + +The in-tree plugins (pixi, workbench, code, example) ship inside the signed app and compile +**two ways** — statically into the native/web/test binaries *and* (for desktop) as a bundled +dylib. **Their folder is, file-for-file, the same canonical third-party shape** described +above (`build.zig` via `fizzy.plugin.create`, `build.zig.zon`, `root.zig` → `src/plugin.zig`, +`src/…`), and each builds standalone with `cd src/plugins/ && zig build`. There is no +embed-stub `build.zig` and no `build_standalone.zig` anymore. + +All the fizzy-internal glue is separated out so it never mixes into the plugin contract: -Bump `abi_version` whenever the `Host`/`Plugin`/`DocHandle`/`EditorAPI` layouts or an entry -symbol's meaning change. +``` +src/plugins// + build.zig # canonical third-party build (fizzy.plugin.create + install) + build.zig.zon + root.zig # exportEntry(@import("src/plugin.zig")) + .zig # package module root + intra-plugin import hub (see note below) + src/ + plugin.zig # register + Plugin vtable — identical shape to any third-party plugin + … + static/ # ← fizzy-internal: everything else the static embed needs + integration.zig # builds the static @import("") module + the bundled dylib +``` ---- +- **`static/integration.zig`** — defines `addStaticModule` (the `@import("")` module the + shell links in) and `addDylib` (the bundled dylib). The root build aggregates every plugin's + integration in [`build/plugins.zig`](../build/plugins.zig); `build/exe.zig`, `build/web.zig`, + and `build/app.zig` (tests) call `addStaticModule`. Shared helpers live in + [`src/plugins/shared/build/helpers.zig`](../src/plugins/shared/build/helpers.zig). Because + these only ever run from the fizzy build root, their paths are single fizzy-relative literals + — the old dual-root (`repo_paths`/`pkg_paths`) machinery is gone. +- **`.zig`** (e.g. `pixi.zig`) — the conventional package root: it is BOTH what the shell + resolves `@import("")` to (re-exporting `pub const plugin` + any types the shell reaches + into, e.g. `pixi.State`) AND the intra-plugin import hub that files under `src/` pull in as + `../.zig` for `sdk`/`core`/`dvui` + sibling types. It must sit at the **plugin root**, + not under `static/`: a Zig module cannot import files above its root file's directory, so it + has to be beside `src/` to re-export from it. A purely-dylib third-party plugin only needs it + if it embeds statically or wants a shared hub; a minimal one (`example`) keeps it tiny. +- **Vendored C deps** — a plugin with native deps builds them with `fizzy.plugin.addCModule` + (a Zig bindings module + its C sources), the same helper its `build.zig` and its + `static/integration.zig` both call. See pixi's `zstbi`/`msf_gif` wiring. + +A built-in is then registered statically in [`Editor.zig`](../src/editor/Editor.zig) +`postInit` with `try _mod.plugin.register(&editor.host)`. The pixi/workbench/code paths +additionally try a bundled-dylib load first and fall back to the static registration; the +`example` plugin keeps it simple (static registration only, but still builds as a dylib). + +The shared contract is exactly `src/plugin.zig` + the `Plugin` vtable; everything else above is +build-mode plumbing. See [`src/plugins/example/`](../src/plugins/example/) for the minimal +template and [`src/plugins/code/`](../src/plugins/code/) for an editor (document) plugin. ## 3. How pixelart flows — and uses workbench @@ -228,6 +565,120 @@ registries — not on any plugin knowing about another. | `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/root.zig` | Stock dylib entry template — copy to third-party projects as `root.zig` | +| `src/plugins/pixelart/` | Reference editor plugin (pixi id; owns documents, renders canvas) | | `src/plugins/workbench/` | Reference file-management plugin (tree + tabs/splits + service) | +| `src/sdk/version.zig` | SDK version + ABI fingerprint CI lock | +| `src/sdk/manifest.zig` | `PluginManifest` embedded in dylibs | +| `src/sdk/document.zig` | Document staging helpers for editor plugins | +| `templates/` | Author starter templates (editor / utility profiles) | + +--- + +## Compatibility & versions + +Fizzy uses three independent **versions**: + +| Version | Owner | Purpose | +|---------|-------|---------| +| **App version** | Fizzy release (`build.zig.zon`) | User-facing editor release; does **not** gate plugin loading | +| **SDK version** | `src/sdk/version.zig` | ABI contract; bumps when the plugin boundary changes | +| **Plugin version** | Author `PluginManifest.version` | Plugin's own release semver | + +At load time the host checks, in order: + +1. **ABI fingerprint** (`fizzy_plugin_abi_fingerprint`) — hard reject on mismatch (memory safety) +2. **SDK version** — `host.sdk_version` must satisfy `plugin.min_sdk_version` +3. **Stale build warning** (debug) — optional soft warning when `built_with_sdk_version < host` + +CI enforces that any ABI fingerprint change updates `sdk_version` and `recorded_abi_fingerprint` together (`zig build test-sdk-version`). + +### Plugin dylib layout + +User and built-in plugins install as a **flat** file: + +``` +{config}/plugins/{id}.dylib # macOS +{config}/plugins/{id}.so # Linux +{config}/plugins/{id}.dll # Windows +{exe}/plugins/{id}.{ext} # bundled built-ins +``` + +The declared `manifest.id` must match the filename basename. There is no legacy `{id}/plugin.dylib` layout. + +### Config folders (lowercase) + +``` +{config}/plugins/ +{config}/palettes/ +{config}/themes/ +``` + +### Plugin manifest (dylib + optional sidecar) + +Each plugin embeds metadata via C exports from `PluginManifest`. Optional sidecar for store indexing: + +```json +{ + "id": "markdown", + "name": "Markdown Editor", + "version": "1.2.0", + "min_sdk_version": "0.1.0", + "abi_fingerprint": "0x05f167e314742930", + "author": "…", + "description": "…", + "homepage": "…" +} +``` + +Install with: + +```sh +zig build install --prefix ~/.config/fizzy/plugins +# → ~/.config/fizzy/plugins/markdown.dylib +``` + +### Store registry schema (future) + +Hosted registry JSON (Phase 2 Extensions UI): + +```json +{ + "sdk_version": "0.1.0", + "plugins": [ + { + "id": "markdown", + "name": "Markdown Editor", + "releases": [ + { + "version": "1.2.0", + "min_sdk_version": "0.1.0", + "abi_fingerprint": "0x…", + "published": "2026-06-01", + "downloads": { + "macos-aarch64": "https://…/markdown-1.2.0-macos-aarch64.dylib" + } + } + ] + } + ] +} +``` + +--- + +## Plugin profiles (IDE-shaped contract) + +The shell is **IDE-shaped**: sidebar rail + explorer, menubar, center (`CenterProvider`), bottom panel, infobar. Plugins contribute via `Host.register*` — the shell never hardcodes feature panes. + +| Profile | Implements | Example | +|---------|------------|---------| +| **Editor** | Document vtable cluster + optional panes/commands | `pixi`, `code` | +| **Shell** | Center provider + file tree, no documents | `workbench` | +| **Utility** | Menus/commands/settings only, no document hooks | external markdown menu plugin | + +Use `Plugin.assertEditorVTable(vtable)` / `Plugin.assertUtilityVTable(vtable)` at compile time to catch profile mistakes. + +Built-in plugin id renames (pre-release): runtime id **`pixi`** (was `pixelart`); dylib `pixi.dylib`; settings key `plugins.pixi`; env `FIZZY_STATIC_PIXI`. + | `src/editor/Editor.zig` | The shell: frame loop, `postInit` plugin registration, dylib loading | diff --git a/docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md b/docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md deleted file mode 100644 index c877c7cd..00000000 --- a/docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md +++ /dev/null @@ -1,299 +0,0 @@ -# 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. diff --git a/docs/PLUGIN_ROUGH_EDGES.md b/docs/PLUGIN_ROUGH_EDGES.md new file mode 100644 index 00000000..1431d722 --- /dev/null +++ b/docs/PLUGIN_ROUGH_EDGES.md @@ -0,0 +1,232 @@ +# Plugin Author Rough Edges + +A punch list of friction points a third-party author hits when building a *complex* +editor plugin (a second real editor alongside pixelart). Ordered by pain, with file +references and fix sketches. Cheap correctness fixes (#4, #6, #7) are being done first; +the rest are tracked as backlog. + +Status legend: 🔴 not started · 🟡 in progress · 🟢 done + +--- + +## 1. 🟢 The "stable contract" is pixel-art-shaped — *large* — DONE + +The intermediate `canvas_ext` (a relocated grab-bag that still *named* pixelart concepts) was +replaced with two clean mechanisms, so the SDK names zero domain features: + +1. **Command registry** ([`regions.Command`](../src/sdk/regions.zig) + `Host.registerCommand` / + `runCommand` / `commandEnabled`). Invocable features register as namespaced commands the shell + triggers by id (`"pixelart.transform"`, `"pixelart.gridLayout"`, `"pixelart.packProject"`) + without knowing what they do. Folded into the ABI fingerprint. +2. **Generic per-frame / lifecycle / save protocol** on `Plugin.VTable`, renamed from the + pixelart-flavored hooks: `prepareFrame`, `tickActiveDocument`, `drawOverlay`, `endFrame`, + `needsContinuousRepaint`, `persistProjectState`/`restoreProjectState`, and + `saveNeedsConfirmation`/`requestSaveConfirmation` (mode enum `SaveConfirmMode`). + +Pixelart's pack lifecycle (`tickPackJobs`/`runPackWorkers`) folded into its own `beginFrame` +(the plugin self-drives background work); its pack-status check reads its own state instead of +round-tripping through the host. Dead pack plumbing removed from `EditorAPI`/`Host`/`Editor`. +`EditorAPI.requestCompositeWarmup` → `requestPrepareFrame` to match the new phase name. +`Plugin.CanvasEditorExt` deleted. Verified: native build, `test`, `test-plugin-loader`, `check-web` +all green; a grep of `src/sdk/` shows no residual domain vocabulary on the typed surface. + +Follow-up pass (hook honesty + docs): audited each renamed hook against its real call site — +9/10 are genuinely generic across editor types; `prepareFrame` is borderline and is now +documented as an opt-in `[requested]` pre-draw pass (only fires after `host.requestPrepareFrame`). +Found & fixed a real generality bug: `tickKeybinds` was invoked only on `pixelartPlugin(editor)`, +so a second plugin's per-frame keybinds would never fire — now broadcast to all plugins. Added a +**required-vs-optional** map (the document cluster you must implement to be an editor) and a +`[broadcast]`/`[active-doc]`/`[requested]` invocation tag + call-site/timing table to +[`Plugin.zig`](../src/sdk/Plugin.zig) and [`PLUGINS.md`](PLUGINS.md). This also closes the +original "no map of which of N hooks to implement" complaint. + +Active-doc owner dispatch + verbs-as-commands (done): a design review concluded the editing +actions (`copy`/`paste`/`transform`/`acceptEdit`/`cancelEdit`/`deleteSelection`) are *not* +universal — they're user-invoked and mean different things per editor — so they were **removed +from `Plugin.VTable` and registered as `Command`s** (`"pixelart.copy"`, …). The shell's Edit +menu / keybinds and *Grid Layout* dispatch to `"."` via +`Editor.runActiveDocCommand`, so every editing action routes to whichever editor owns the focused +tab; an owner that registered none is a clean no-op. The `EditorAPI` verb reach-backs are +unchanged (they funnel through `editor.()`, now per-owner command dispatch). + +Folder lifecycle rename (done): the pixelart-flavored `persistProjectState`/`restoreProjectState` +became the shell-event-named `onFolderClose` / `onFolderOpen` (the shell has a *folder* concept; +"project" was pixelart's layer on top). + +**Still open (smaller follow-ups):** +- **New File chooser** — with multiple `requestNewDocumentDialog` providers, present a typed "New > \" chooser (rough-edge #9 / existing `Plugin.zig` TODO). Single-provider dispatch via `Host.requestNewDocument` is done. + +**Resolved in SDK hardening pass:** +- ~~**New File is single-owner**~~ — `Editor.requestNewFileDialog` dispatches via `Host.requestNewDocument`. +- ~~**`initPlugin` not broadcast**~~ — `postInit` calls `initPlugin` on every registered plugin. +- ~~**Menu enablement by owner**~~ — Edit menu gates on `commandEnabled` for active-doc owner commands. +- ~~**No comptime editor profile check**~~ — `Plugin.assertEditorVTable` / `assertUtilityVTable` + templates. + +--- + +### Original note + +[`Plugin.VTable`](../src/sdk/Plugin.zig) is ~60 optional hooks; a large fraction are +pixel-art concepts presented as the neutral SDK: `transform`, `copy`, `paste`, +`startPackProject`, `isPackingActive`, `tickPackJobs`, `runPackWorkers`, +`persistProjectFolder`, `reloadProjectFolder`, `requestGridLayoutDialog`, +`requestFlatRasterSaveWarning`, `shouldConfirmFlatRasterSave`, +`warmupActiveDocumentComposites`, `resetDocumentPeekLayers`, `removeCanvasPane`, +`radialMenu*`, `tickActiveDocumentPlayback`. [`EditorAPI`](../src/sdk/EditorAPI.zig) does +the same (`transform`, `startPackProject`, `isPackingActive`, `requestCompositeWarmup`). + +Every hook is `?`-optional, so the compiler gives zero guidance — a missing hook surfaces +at runtime as a feature silently doing nothing. There is no delineated "minimal editor +plugin" subset. + +**Fix sketch:** split the vtable into a core *editor protocol* (the ~8 hooks every editor +needs) and an optional *pixelart extension* surface; or at minimum document the required +subset and add a comptime check that flags an editor plugin missing a core hook. + +## 2. 🔴 Document-load staging protocol is intricate and thread-unsafe-by-comment — *medium* + +Opening one file requires a correctly-ordered cluster of cooperating hooks whose contract +lives only in field comments: `documentStackSize`/`documentStackAlign` → shell allocates a +raw buffer → `loadDocument(path, out_doc)` constructs in place into shell-owned memory **on +a worker thread** → `documentIdFromBuffer` → `registerOpenDocument` to move to a stable +pointer → plus a separate `loadDocumentFromBytes` for web. Wrong size/align or touching +dvui/globals from the worker thread is UB with no compile-time protection. + +**Fix sketch:** provide an SDK helper that owns the happy path (size/align from the doc +type via comptime), and lift the threading rule out of a field comment into a documented +contract / debug assertion. + +## 3. 🔴 ABI compatibility is all-or-nothing, opaque, pins to an exact commit — *large* + +The structural fingerprint ([`dylib.zig`](../src/sdk/dylib.zig)) rejects every third-party +plugin on *any* dvui bump / boundary-struct tweak / new vtable hook, with a bare +`error.AbiMismatch`. No version range, no skew tolerance, no tool telling the author what +changed or which fizzy build their `.dylib` matches. A plugin is dead the instant the user +updates fizzy. + +**Fix sketch:** keep the fingerprint as the hard gate but layer a human-readable +(fizzy-version, dvui-version) tuple alongside it so diagnostics can say *why* and *what to +rebuild against*; consider a documented "compatible host build" stamp. + +## 4. 🟢 Failure is invisible to the user — *cheap* — DONE + +Implemented: `Editor.loadUserPlugins` now records each failure into `editor.failed_user_plugins` +(`{id, reason}`, owned strings, freed in `unloadPluginLibs`), logs at `.err` with an +actionable reason (`pluginLoadFailureReason` maps each `LoadError` — e.g. AbiMismatch → +"rebuild against this Fizzy build"), and a one-shot startup dialog +(`dialogs/PluginLoadFailures.zig`) lists them so the author isn't left reading logs. + +--- + +### Original note + +[`Editor.loadUserPlugins`](../src/editor/Editor.zig) logs `dvui.log.warn` and silently +skips on every failure (open failed, ABI mismatch, register rejected, OOM). A user whose +plugin doesn't load sees nothing in the UI. ABI mismatch — the most common case — surfaces +only as a log line. + +**Fix sketch:** record `{plugin_id, path, error}` for each failed load on the Editor/Host, +and surface it (settings panel section and/or a startup notice). At minimum keep a +queryable list so the UI can show "N plugins failed to load." + +## 5. 🔴 No hot-reload / unload — brutal dev loop — *large* + +[`PluginLoader.loadAndRegister`](../src/editor/PluginLoader.zig) keeps the DynLib open for +the app lifetime; `registerPlugin` only appends; `deinit` is never called mid-session. Plugin +development means quit + relaunch (and reopen project/files) on every change. + +**Fix sketch:** an unregister path (drop registry entries owned by a plugin id, call +`deinit`, close the lib) + a dev "reload plugin" affordance. Non-trivial because open +documents may be owned by the plugin being unloaded. + +## 6. 🟢 `set_globals` slot overload is a latent footgun — *cheap* — DONE + +Implemented: the two post-`gpa` slots are renamed `arg_b`/`arg_c` across `sdk.dylib.SetGlobalsFn`, +`PluginLoader.PreRegister`, and all `Editor.zig` call sites (matching the existing +`syncLoadedPluginGlobals` vocabulary), each with a doc comment + inline comment stating the +per-plugin convention (third-party: `arg_b` = `*Host`). No more field literally named `.state` +carrying the host. + +--- + +### Original note + +The C entry `set_globals(gpa, state, packer)` has three positional `*anyopaque` slots whose +meaning differs per plugin. Third-party [`exportEntry`](../src/sdk/dylib.zig) reads them as +`(gpa, host, state-ignored)`, so [`Editor.zig`](../src/editor/Editor.zig) smuggles `&host` +through the field named `.state` and `.packer` is dead. Built-ins use the slots differently +again. Works only by convention; it's a raw pointer reinterpret. + +**Fix sketch:** rename `PreRegister`/`SetGlobalsFn`/`installRuntime`/`exportEntry` params to +a single clear contract — `gpa`, `host`, `plugin_state` — and update all call sites. Naming +only; no behavior change. + +## 7. 🟢 Plugin identity vs folder name conflated; no dedup — *cheap* — DONE + +Implemented: `Host.registerPlugin` now rejects a duplicate declared `id` with +`error.DuplicatePluginId` (built-ins register first, so they always win). The dylib loader +turns that into a failed load surfaced via #4, and the declared `id` — not the folder name — +is the source of truth for routing. + +--- + +### Original note + +[`Editor.loadUserPlugins`](../src/editor/Editor.zig) derives `plugin_id` from the directory +name and keys its collision guard on `pluginById(entry.name)`, but plugins register under +their own declared `plugin.id`, and [`registerPlugin`](../src/sdk/Host.zig) does no dedup. A +plugin in folder `foo` declaring `id = "pixelart"` passes the folder guard then +double-registers `"pixelart"`; routing (`pluginById`/`pluginForExtension`) becomes +ambiguous. + +**Fix sketch:** make `registerPlugin` reject a duplicate id (return an error the loader +treats as a failed load — feeds #4), and treat the declared id as the source of truth. + +## 8. 🔴 Service discovery is stringly-typed and unversioned — *medium* + +[`Host.getService(name) -> ?*anyopaque`](../src/sdk/Host.zig) then +`@ptrCast(@alignCast(...))`. The author must know the magic string and the exact cast type, +with nothing binding the two, and the service struct's layout is not in the ABI fingerprint — +so a shape change silently corrupts. Only workbench's service is documented. + +**Fix sketch:** a typed service helper (`getService(T)` keyed on `T.service_name`) and fold +registered service struct layouts into the fingerprint, or attach a per-service version. + +## 9. 🔴 Smaller items — *cheap-ish, batched* + +- **`core.gpa` global** — docs say "sync `core.gpa = sdk.allocator()` if you use core + helpers," but `core` is a first-class import a complex plugin will use; forgetting is UB + with no reminder. Consider asserting/initializing it at load. +- **"New File" is single-owner** — existing TODO in [`Plugin.zig`](../src/sdk/Plugin.zig): + `requestNewDocumentDialog` dispatches to "a plugin that provides one"; a second editor + collides. Needs a typed "New > \" chooser. +- **Install ergonomics / no manifest** — `zig build install --prefix /plugins//` is hand-assembled; no `fizzy install-plugin`, no manifest declaring + name/version/author/min-fizzy-version. Identity comes from the folder the user drops it in. +- **dvui globals across the boundary** — context is re-injected each frame + ([`syncLoadedPluginDvuiContexts`](../src/editor/Editor.zig)); a plugin caching + `currentWindow()`, a font, or an ft2 handle across frames is in undocumented territory. + +## 10. 🟢 Built-in plugins didn't look like third-party plugins — *medium* — DONE + +A built-in's folder used to carry files a third-party plugin never has (an embed-stub +`build.zig` + a separate `build_standalone.zig`, `module.zig`, `dylib.zig`, `Globals.zig`) +and its `build/integration.zig` ran from two roots via dual-path (`repo_paths`/`pkg_paths`) +machinery — so "what files does a plugin need?" had two different answers. + +Now every plugin folder — the built-ins (pixi/workbench/code), the new in-repo `example` +template, and external plugins like markdown — is the **same canonical third-party shape** +(`build.zig` via `fizzy.plugin.create`, `build.zig.zon`, `root.zig` → `src/plugin.zig`, +`src/…`) and builds standalone with `cd src/plugins/ && zig build`. The only +fizzy-internal extras are a root `.zig` (the conventional package module + import hub, +forced to the root by Zig's module-import boundary) and a self-contained `static/` subfolder +(`static/integration.zig`) holding the static-embed + bundled-dylib build graph; the embed stub, +`build_standalone.zig`, `module.zig`, `src/hub.zig`, `dylib.zig`, `Globals.zig`, and the +dual-root path machinery are all gone. Vendored C deps use the reusable `fizzy.plugin.addCModule` +helper. The [`example`](../src/plugins/example/) plugin is the always-compiling copy-me +template. See [PLUGINS.md](PLUGINS.md) §2. + +**Caveat (monorepo only):** building a built-in that vendors C deps shared with fizzy's own +build graph (pixi's `build/deps.zig`) standalone from *inside* the repo would put one file in +two build modules, so pixi's `build.zig` inlines its vendored-dep wiring. A genuine +third-party plugin in its own repo has no such overlap. diff --git a/plugin_sdk.zig b/plugin_sdk.zig new file mode 100644 index 00000000..e0e838f4 --- /dev/null +++ b/plugin_sdk.zig @@ -0,0 +1,243 @@ +//! Build helpers for third-party Fizzy plugin dylibs. +//! +//! Required in your project (see `docs/PLUGINS.md` §2): +//! - `root.zig` — copy from `fizzy/src/plugins/root.zig` (one `sdk.dylib.exportEntry` call) +//! - `src/plugin.zig` — `register(host)` + `Plugin` vtable + `manifest`; read `sdk.allocator()` / `sdk.host()` +//! - `build.zig` / `build.zig.zon` — declare `fizzy`, call `fizzy.plugin.create` + `.install` below +const std = @import("std"); + +/// C-ABI entry symbols every plugin dylib must export. +pub const dylib_exports = [_][]const u8{ + "fizzy_plugin_abi_fingerprint", + "fizzy_plugin_sdk_version", + "fizzy_plugin_min_sdk_version", + "fizzy_plugin_version", + "fizzy_plugin_id", + "fizzy_plugin_register", + "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_render_bridge", + "fizzy_plugin_set_globals", +}; + +pub const Modules = struct { + core: *std.Build.Module, + sdk: *std.Build.Module, + dvui: *std.Build.Module, + proxy_bridge: *std.Build.Module, +}; + +pub const ModulesOptions = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, +}; + +pub const ModuleOptions = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + root_source_file: std.Build.LazyPath, + link_libc: bool = true, +}; + +pub const CreateOptions = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + /// Dylib artifact name and installed filename stem (e.g. `"markdown"` → `markdown.dylib`). + name: []const u8, + link_libc: bool = true, + root_source_file: ?std.Build.LazyPath = null, +}; + +fn fizzyDep(b: *std.Build, opts: ModulesOptions) *std.Build.Dependency { + return b.dependency("fizzy", .{ + .target = opts.target, + .optimize = opts.optimize, + .plugin_sdk = true, + }); +} + +fn modulesFromDep(fizzy_dep: *std.Build.Dependency) Modules { + return .{ + .core = fizzy_dep.module("core"), + .sdk = fizzy_dep.module("sdk"), + .dvui = fizzy_dep.module("dvui"), + .proxy_bridge = fizzy_dep.module("proxy_bridge"), + }; +} + +pub fn modules(b: *std.Build, opts: ModulesOptions) Modules { + return modulesFromDep(fizzyDep(b, opts)); +} + +pub fn addImports(mod: *std.Build.Module, plugin_modules: Modules) void { + mod.addImport("core", plugin_modules.core); + mod.addImport("sdk", plugin_modules.sdk); + mod.addImport("dvui", plugin_modules.dvui); + mod.addImport("proxy_bridge", plugin_modules.proxy_bridge); +} + +fn module( + b: *std.Build, + plugin_modules: Modules, + opts: ModuleOptions, +) *std.Build.Module { + const mod = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = opts.root_source_file, + .link_libc = opts.link_libc, + }); + addImports(mod, plugin_modules); + return mod; +} + +pub fn createModule(b: *std.Build, opts: ModuleOptions) *std.Build.Module { + return module(b, modules(b, .{ + .target = opts.target, + .optimize = opts.optimize, + }), opts); +} + +pub const InstallOptions = struct { + /// Install under `/{name}.{ext}`. Defaults to `lib` compile artifact name. + name: ?[]const u8 = null, +}; + +/// Install `lib` as `{name}.{dylib,dll,so}` under the install prefix. +/// +/// const lib = fizzy.plugin.create(b, .{ .name = "markdown", .target = target, .optimize = optimize }); +/// fizzy.plugin.install(b, lib, .{}); +pub fn install(b: *std.Build, lib: *std.Build.Step.Compile, opts: InstallOptions) void { + const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) { + .windows => "dll", + .macos => "dylib", + else => "so", + }; + const name = opts.name orelse lib.name; + const dest = b.fmt("{s}.{s}", .{ name, ext }); + const install_step = b.addInstallArtifact(lib, .{ + .dest_dir = .{ .override = .prefix }, + .dest_sub_path = dest, + }); + b.getInstallStep().dependOn(&install_step.step); +} + +/// A C source file + its compile flags, for `addCModule`. +pub const CSourceFile = struct { + file: std.Build.LazyPath, + flags: []const []const u8 = &.{}, +}; + +pub const CModuleOptions = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + /// Zig bindings root (e.g. `zstbi.zig`). + root_source_file: std.Build.LazyPath, + /// C translation units compiled into the module. + c_sources: []const CSourceFile = &.{}, + /// `-I` include dirs for the C sources. + include_paths: []const std.Build.LazyPath = &.{}, + link_libc: bool = true, + single_threaded: bool = false, +}; + +/// Build a Zig module backed by vendored C sources (an image/codec/archive lib, etc.) and +/// return it for `mod.addImport(...)`. The C compiles into whatever artifact imports the +/// returned module. All inputs are caller-supplied `LazyPath`s, so this works unchanged whether +/// invoked from the fizzy build root (static embed / bundled dylib) or a standalone plugin +/// build — there is no shared, location-bound build file to collide between the two graphs. +pub fn addCModule(b: *std.Build, opts: CModuleOptions) *std.Build.Module { + const mod = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = opts.root_source_file, + .link_libc = opts.link_libc, + .single_threaded = opts.single_threaded, + }); + for (opts.include_paths) |path| mod.addIncludePath(path); + for (opts.c_sources) |c| mod.addCSourceFile(.{ .file = c.file, .flags = c.flags }); + return mod; +} + +pub fn create(b: *std.Build, opts: CreateOptions) *std.Build.Step.Compile { + const root_source = opts.root_source_file orelse b.path("root.zig"); + const mod = module(b, modules(b, .{ + .target = opts.target, + .optimize = opts.optimize, + }), .{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = root_source, + .link_libc = opts.link_libc, + }); + + const lib = b.addLibrary(.{ + .name = opts.name, + .linkage = .dynamic, + .root_module = mod, + }); + lib.linker_allow_shlib_undefined = true; + lib.root_module.export_symbol_names = &dylib_exports; + return lib; +} + +pub fn exportModules( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, +) !void { + const dvui_dep = b.dependency("dvui", .{ + .target = target, + .optimize = optimize, + .backend = .proxy, + .accesskit = .off, + }); + const dvui_proxy_mod = dvui_dep.module("dvui_proxy"); + const proxy_bridge_mod = dvui_dep.module("proxy_bridge"); + + const known_folders = b.dependency("known_folders", .{ + .target = target, + .optimize = optimize, + }).module("known-folders"); + + const core_mod = b.addModule("core", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + .link_libc = true, + }); + core_mod.addImport("dvui", dvui_proxy_mod); + core_mod.addImport("known-folders", known_folders); + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + core_mod.addImport("icons", dep.module("icons")); + } + + const sdk_mod = b.addModule("sdk", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/sdk/sdk.zig"), + }); + sdk_mod.addImport("dvui", dvui_proxy_mod); + sdk_mod.addImport("proxy_bridge", proxy_bridge_mod); + sdk_mod.addImport("core", core_mod); + + b.modules.put(b.graph.arena, b.dupe("dvui"), dvui_proxy_mod) catch @panic("OOM"); + b.modules.put(b.graph.arena, b.dupe("proxy_bridge"), proxy_bridge_mod) catch @panic("OOM"); +} + +/// Install a built-in plugin dylib as `{name}.{ext}` under `plugins/`. +pub fn installBuiltinPlugin( + b: *std.Build, + lib: *std.Build.Step.Compile, + name: []const u8, + plugins_install_dir: std.Build.InstallDir, +) *std.Build.Step.InstallArtifact { + const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) { + .windows => "dll", + .macos => "dylib", + else => "so", + }; + return b.addInstallArtifact(lib, .{ + .dest_dir = .{ .override = plugins_install_dir }, + .dest_sub_path = b.fmt("{s}.{s}", .{ name, ext }), + }); +} diff --git a/process_assets.zig b/process_assets.zig index 505042f9..08a10636 100644 --- a/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("src/plugins/pixelart/src/Atlas.zig"); +const Atlas = @import("src/plugins/pixi/src/Atlas.zig"); const ProcessAssetsStep = @This(); step: Step, diff --git a/src/App.zig b/src/App.zig index 8782c644..c1c1e5e8 100644 --- a/src/App.zig +++ b/src/App.zig @@ -9,10 +9,8 @@ const icon = assets.files.@"icon.png"; const fizzy = @import("fizzy.zig"); const workbench = @import("workbench"); -const pixelart = @import("pixelart"); +const pixi = @import("pixi"); 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"); @@ -20,7 +18,7 @@ const paths = fizzy.paths; const App = @This(); const Editor = fizzy.Editor; -const Packer = pixelart.Packer; +const Packer = pixi.Packer; // App fields allocator: std.mem.Allocator = undefined, @@ -63,7 +61,14 @@ const start_options_base: dvui.App.StartOptions = .{ fn startOptions() dvui.App.StartOptions { var opts = start_options_base; + // Create the dvui window with the *same* allocator the host hands to plugins + // (`fizzy.app.allocator`). Without this, dvui defaults the window to the runtime's + // `main_init.gpa`, a different allocator instance — so `dvui.currentWindow().gpa` + // and `host.allocator` would be distinct, and a plugin that allocated with one and + // freed with the other would corrupt the heap. Unifying them makes every allocator a + // plugin can reach the same instance. (No-op on wasm, which uses the page allocator.) if (comptime builtin.target.cpu.arch != .wasm32) { + opts.gpa = appAllocator(); const main_init = dvui.App.main_init orelse return opts; if (paths.configFolderZ(&pref_path_buf, main_init.io, fizzy.processEnviron(), ".")) |pref_path| { pref_path_len = pref_path.len; @@ -170,26 +175,13 @@ 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: host + allocator, so workbench code - // reaches the EditorAPI surface without importing `fizzy.zig`. Mirrors pixelart.Globals. - WorkbenchGlobals.gpa = allocator; - 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`. - const pixelart_state = try allocator.create(pixelart.State); - pixelart.Globals.gpa = allocator; - pixelart.Globals.state = pixelart_state; - pixelart_state.* = pixelart.State.init(allocator, &fizzy.editor.host) catch unreachable; - fizzy.editor.pixelart_state = pixelart_state; + // Workbench + pixi shell-owned state: wire before plugin `register`. + workbench.runtime.setWorkbench(&fizzy.editor.workbench); + + const pixi_state = try allocator.create(pixi.State); + pixi.runtime.adoptShellState(pixi_state); + pixi_state.* = pixi.State.init(allocator, &fizzy.editor.host) catch unreachable; + fizzy.editor.pixi_state = pixi_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). @@ -201,8 +193,8 @@ pub fn AppInit(win: *dvui.Window) !void { fizzy.packer = try allocator.create(Packer); fizzy.packer.* = Packer.init(allocator) catch unreachable; - pixelart.Globals.packer = fizzy.packer; - fizzy.editor.syncLoadedPixelartGlobals(); + pixi.runtime.setPacker(fizzy.packer); + fizzy.editor.syncLoadedPixiGlobals(); // 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. @@ -250,12 +242,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.editor.pixelart_state); + pixi.State.persistProject(fizzy.editor.pixi_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.editor.pixelart_state.deinit(fizzy.app.allocator); - fizzy.app.allocator.destroy(fizzy.editor.pixelart_state); + fizzy.editor.pixi_state.deinit(fizzy.app.allocator); + fizzy.app.allocator.destroy(fizzy.editor.pixi_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/core/dvui.zig b/src/core/dvui.zig index f3b415f8..be10dfeb 100644 --- a/src/core/dvui.zig +++ b/src/core/dvui.zig @@ -409,6 +409,26 @@ pub fn windowHeaderCloseInnerSide() f32 { return (row_inner + cap_inner) * 0.5; } +/// Padding around the close / dirty / save indicator in workspace tabs (fixed every frame). +pub const tab_status_inset = dvui.Rect{ .x = 4, .y = 2, .w = 4, .h = 2 }; + +/// Workspace tab close control: fixed size, no margin/shadow (unlike dialog header close). +pub fn tabCloseButtonOptions(over: dvui.Options) dvui.Options { + return windowHeaderCloseButtonOptions(over.override(.{ + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .border = dvui.Rect.all(0), + .box_shadow = null, + .background = false, + .color_fill = .transparent, + .color_fill_hover = .transparent, + .color_fill_press = .transparent, + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, + })); +} + /// Base `Options` for the dialog header close button. Tabs pass `.override(.{ .expand = .none, .min_size_content = …, .id_extra = … })`. pub fn windowHeaderCloseButtonOptions(over: dvui.Options) dvui.Options { const base: dvui.Options = .{ @@ -1101,6 +1121,19 @@ fn drawGradientRect(r: dvui.Rect.Physical, corner_radius: dvui.Rect.Physical, op }; } +/// Active workspace tab indicator: one snapped physical pixel along the tab bottom edge. +pub fn drawTabActiveIndicator(tab: dvui.RectScale, color: dvui.Color) void { + if (tab.r.empty()) return; + const scale = tab.s; + var line = tab.r; + line.h = scale; + line.y = @floor(tab.r.y + tab.r.h - scale); + line.x = @floor(line.x); + line.w = @ceil(line.w); + if (line.w <= 0) return; + line.fill(.{}, .{ .color = color }); +} + pub fn drawEdgeShadow(container: dvui.RectScale, shadow: Shadow, opts: ShadowOptions) void { var rs = container; switch (shadow) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 59e0110c..9545a298 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -16,7 +16,7 @@ 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 pixi = @import("pixi"); const dvui = @import("dvui"); const update_notify = @import("../backend/update_notify.zig"); @@ -31,10 +31,12 @@ pub const Keybinds = @import("Keybinds.zig"); const workbench_mod = @import("workbench"); const code_mod = @import("code"); +const example_mod = @import("example"); const PluginLoader = if (builtin.target.cpu.arch == .wasm32) @import("PluginLoader_stub.zig") else @import("PluginLoader.zig"); +const InstalledPlugins = @import("InstalledPlugins.zig"); pub const Workspace = workbench_mod.Workspace; pub const Explorer = @import("explorer/Explorer.zig"); @@ -65,18 +67,21 @@ atlas: fizzy.core.Atlas, host: Host, /// Pixel-art plugin runtime state (owned by App; wired into `Globals.state`). -pixelart_state: *pixelart.State, +pixi_state: *pixi.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, +/// User plugins that failed to load this session, so the UI can tell the author what +/// went wrong instead of failing silently into the log. Populated by `loadUserPlugins`; +/// strings are owned here and freed in `deinit`. +failed_user_plugins: std.ArrayListUnmanaged(FailedPlugin) = .empty, +/// One-shot guard so the startup "plugin load failures" dialog is raised only once. +plugin_failures_dialog_shown: bool = false, + settings: Settings = undefined, recents: Recents = undefined, @@ -241,7 +246,7 @@ pub fn init( } } } - const palette_folder = std.fs.path.join(fizzy.app.allocator, &.{ config_folder, "Palettes" }) catch config_folder; + const palette_folder = std.fs.path.join(fizzy.app.allocator, &.{ config_folder, "palettes" }) catch config_folder; var editor: Editor = .{ .config_folder = config_folder, @@ -258,7 +263,7 @@ pub fn init( }, .themes = .empty, .host = .init(app.allocator), - .pixelart_state = undefined, + .pixi_state = undefined, .workbench = .init(app.allocator), }; @@ -437,7 +442,7 @@ pub fn init( 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). + // `editor.pixi_state` just after this `Editor.init` returns). try Keybinds.register(); @@ -456,10 +461,10 @@ pub fn init( /// Stable shell-builtin contribution id. pub const view_settings = "shell.settings"; -fn loadPixelartFromDylibEnabled() bool { +fn loadPixiFromDylibEnabled() bool { if (comptime builtin.target.cpu.arch == .wasm32) return false; - if (comptime build_opts.static_pixelart) return false; - if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_PIXELART")) |v| { + if (comptime build_opts.static_pixi) return false; + if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_PIXI")) |v| { defer fizzy.app.allocator.free(v); return v.len == 0 or v[0] == '0'; } else |_| {} @@ -476,6 +481,16 @@ fn loadWorkbenchFromDylibEnabled() bool { return true; } +fn loadCodeFromDylibEnabled() bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + if (comptime build_opts.static_code) return false; + if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_CODE")) |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; @@ -485,8 +500,13 @@ pub fn workbenchPlugin(editor: *Editor) *sdk.Plugin { } /// 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"); +pub fn pixiPlugin(editor: *Editor) *sdk.Plugin { + return editor.host.pluginById("pixi") orelse @panic("pixelart plugin not registered"); +} + +/// Registered code plugin (dylib or static). Panics if missing after `postInit`. +pub fn codePlugin(editor: *Editor) *sdk.Plugin { + return editor.host.pluginById("code") orelse @panic("code plugin not registered"); } /// Push host dvui state into every loaded plugin dylib image. @@ -513,9 +533,9 @@ fn syncLoadedPluginGlobals(editor: *Editor, plugin_id: []const u8, arg_b: *anyop } } -/// 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 + state into a loaded pixi dylib (e.g. after packer init on shared state). +pub fn syncLoadedPixiGlobals(editor: *Editor) void { + syncLoadedPluginGlobals(editor, "pixi", @ptrCast(&editor.host), @ptrCast(editor.pixi_state)); } /// Re-inject host-owned Globals into a loaded workbench dylib. @@ -524,49 +544,217 @@ pub fn syncLoadedWorkbenchGlobals(editor: *Editor) void { } fn appendLoadedPluginLib(editor: *Editor, loaded: PluginLoader.LoadedLib) !void { - try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded); + const id_owned = try fizzy.app.allocator.dupe(u8, loaded.plugin_id); + var stored = loaded; + stored.plugin_id = id_owned; + try editor.loaded_plugin_libs.append(fizzy.app.allocator, stored); } -/// Load `{exe_dir}/plugins/libworkbench.*` and register via dylib entry. +/// Load `{exe_dir}/plugins/workbench.{ext}` and register via dylib entry. pub fn loadWorkbenchDylib(editor: *Editor, exe_dir: []const u8) !void { if (comptime builtin.target.cpu.arch == .wasm32) return; 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), + .arg_b = @ptrCast(&editor.host), // workbench convention: arg_b = *Host + .arg_c = @ptrCast(&editor.workbench), // arg_c = *Workbench + }); + try appendLoadedPluginLib(editor, loaded); + syncLoadedPluginDvuiContexts(editor); + syncLoadedPluginRenderBridge(editor); +} + +/// Load `{exe_dir}/plugins/pixi.{ext}` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry. +pub fn loadPixiDylib(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, "pixi"); + errdefer fizzy.app.allocator.free(path); + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "pixi", .{ + .gpa = &fizzy.app.allocator, + .arg_b = @ptrCast(&editor.host), + .arg_c = @ptrCast(editor.pixi_state), }); try appendLoadedPluginLib(editor, loaded); syncLoadedPluginDvuiContexts(editor); syncLoadedPluginRenderBridge(editor); } -/// Load `{exe_dir}/plugins/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry. -pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void { +/// Load `{exe_dir}/plugins/code.{ext}` and register via dylib entry. +pub fn loadCodeDylib(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"); + const path = try PluginLoader.builtinPluginPath(fizzy.app.allocator, exe_dir, "code"); errdefer fizzy.app.allocator.free(path); - const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "pixelart", .{ + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "code", .{ .gpa = &fizzy.app.allocator, - .state = @ptrCast(editor.pixelart_state), - .packer = null, + .arg_b = @ptrCast(&editor.host), + .arg_c = null, }); try appendLoadedPluginLib(editor, loaded); syncLoadedPluginDvuiContexts(editor); syncLoadedPluginRenderBridge(editor); } +/// Scan `/plugins/` for user-installed plugin dylibs and load each one. +/// +/// Each sub-directory that contains `plugin.` is attempted in iteration order. +/// Failures are logged and skipped — a bad plugin never prevents the others from loading. +/// Built-in plugin IDs ("pixi", "workbench", "code") are never overridden; any +/// user directory whose name collides with an already-registered plugin is skipped. +/// +/// On success each loaded lib is appended to `loaded_plugin_libs` and the dvui context +/// + render bridge are synced once at the end. On wasm this is a no-op. +/// +/// The user plugin directory does not need to exist; a missing directory is silently ignored. +/// A user plugin that failed to load, retained so the UI can surface it. `id` and `reason` +/// are heap-owned (app allocator) and freed in `deinit`. +pub const FailedPlugin = struct { + id: []const u8, + reason: []const u8, + /// Optional version / SDK detail when the dylib could be opened for probing. + detail: ?[]const u8 = null, +}; + +/// Record a failed user-plugin load so the UI can surface it. `id` and `reason` are copied +/// (the caller keeps ownership of its arguments). Best-effort: on OOM the failure is dropped +/// after being logged at the call site. +fn recordPluginFailure(editor: *Editor, id: []const u8, reason: []const u8, detail: ?[]const u8) void { + const id_owned = fizzy.app.allocator.dupe(u8, id) catch return; + const reason_owned = fizzy.app.allocator.dupe(u8, reason) catch { + fizzy.app.allocator.free(id_owned); + return; + }; + const detail_owned: ?[]const u8 = if (detail) |d| fizzy.app.allocator.dupe(u8, d) catch null else null; + if (detail_owned == null and detail != null) { + fizzy.app.allocator.free(id_owned); + fizzy.app.allocator.free(reason_owned); + return; + } + editor.failed_user_plugins.append(fizzy.app.allocator, .{ + .id = id_owned, + .reason = reason_owned, + .detail = detail_owned, + }) catch { + fizzy.app.allocator.free(id_owned); + fizzy.app.allocator.free(reason_owned); + if (detail_owned) |d| fizzy.app.allocator.free(d); + }; +} + +fn formatPluginProbeDetail(allocator: std.mem.Allocator, info: PluginLoader.PluginVersionInfo) ![]const u8 { + return std.fmt.allocPrint(allocator, "plugin {d}.{d}.{d}, min SDK {d}.{d}.{d}", .{ + info.plugin_version.major, + info.plugin_version.minor, + info.plugin_version.patch, + info.min_sdk_version.major, + info.min_sdk_version.minor, + info.min_sdk_version.patch, + }); +} + +/// Human-readable, actionable explanation for a `PluginLoader.LoadError`. +fn pluginLoadFailureReason(err: PluginLoader.LoadError) []const u8 { + return switch (err) { + error.AbiMismatch => "built against an incompatible Fizzy SDK — rebuild the plugin against this Fizzy build", + error.SdkVersionMismatch => "requires a newer Fizzy SDK — update Fizzy or install a matching plugin build", + error.PluginIdMismatch => "plugin id in the dylib does not match its filename — rename the file or fix manifest.id", + error.DylibOpenFailed => "the plugin library could not be opened (missing file, wrong architecture, or unresolved symbols)", + error.RegisterRejected => "the plugin's register() was rejected (often a duplicate plugin id — a built-in or another plugin already claims it)", + error.AbiFingerprintSymbolMissing, + error.RegisterSymbolMissing, + error.SetGlobalsSymbolMissing, + error.SetDvuiContextSymbolMissing, + error.SetRenderBridgeSymbolMissing, + error.SdkVersionSymbolMissing, + => "the plugin is missing required entry symbols — rebuild it from a current root.zig template", + }; +} + +pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + + const plugins_dir = std.fs.path.join(fizzy.app.allocator, &.{ config_folder, "plugins" }) catch return; + defer fizzy.app.allocator.free(plugins_dir); + + var dir = std.Io.Dir.cwd().openDir(dvui.io, plugins_dir, .{ .iterate = true }) catch return; + defer dir.close(dvui.io); + + const ext_suffix: []const u8 = switch (builtin.os.tag) { + .windows => ".dll", + .macos => ".dylib", + else => ".so", + }; + var loaded_any = false; + + var iter = dir.iterate(); + while (iter.next(dvui.io) catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ext_suffix)) continue; + + const dot = std.mem.lastIndexOf(u8, entry.name, ".") orelse continue; + const plugin_id = entry.name[0..dot]; + if (plugin_id.len == 0) continue; + + if (editor.host.pluginById(plugin_id) != null) { + dvui.log.err("user plugin '{s}': id already registered by a built-in; skipped", .{plugin_id}); + editor.recordPluginFailure(plugin_id, "id already registered by a built-in plugin", null); + continue; + } + + const path = std.fs.path.join(fizzy.app.allocator, &.{ plugins_dir, entry.name }) catch continue; + + const loaded = PluginLoader.loadAndRegister(&editor.host, path, plugin_id, .{ + .gpa = &fizzy.app.allocator, + .arg_b = @ptrCast(&editor.host), + .arg_c = null, + }) catch |err| { + const reason = pluginLoadFailureReason(err); + const probe = PluginLoader.probeVersionInfo(path); + const detail_owned: ?[]const u8 = if (probe) |info| + formatPluginProbeDetail(fizzy.app.allocator, info) catch null + else + null; + dvui.log.err("user plugin '{s}' ({s}): load failed: {s} — {s}", .{ plugin_id, path, @errorName(err), reason }); + editor.recordPluginFailure(plugin_id, reason, detail_owned); + fizzy.app.allocator.free(path); + continue; + }; + + appendLoadedPluginLib(editor, loaded) catch { + dvui.log.err("user plugin '{s}': out of memory storing LoadedLib", .{plugin_id}); + editor.recordPluginFailure(plugin_id, "ran out of memory while loading", null); + continue; + }; + dvui.log.info("user plugin '{s}' loaded from {s}", .{ plugin_id, path }); + loaded_any = true; + } + + if (loaded_any) { + syncLoadedPluginDvuiContexts(editor); + syncLoadedPluginRenderBridge(editor); + } +} + fn unloadPluginLibs(editor: *Editor) void { if (comptime builtin.target.cpu.arch == .wasm32) return; for (editor.loaded_plugin_libs.items) |*entry| { entry.lib.close(); + fizzy.app.allocator.free(entry.plugin_id); fizzy.app.allocator.free(entry.path); } editor.loaded_plugin_libs.deinit(fizzy.app.allocator); + + for (editor.failed_user_plugins.items) |f| { + fizzy.app.allocator.free(f.id); + fizzy.app.allocator.free(f.reason); + if (f.detail) |d| fizzy.app.allocator.free(d); + } + editor.failed_user_plugins.deinit(fizzy.app.allocator); } pub fn postInit(editor: *Editor) !void { + sdk.installRuntime(&fizzy.app.allocator, &editor.host, null); + // 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. @@ -593,17 +781,33 @@ pub fn postInit(editor: *Editor) !void { } 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)}); - try pixelart.plugin.register(&editor.host); + if (loadPixiFromDylibEnabled()) { + editor.loadPixiDylib(fizzy.app.root_path) catch |err| { + dvui.log.warn("pixi dylib load failed ({s}); falling back to static plugin", .{@errorName(err)}); + try pixi.plugin.register(&editor.host); }; - try pixelartPlugin(editor).initPlugin(); } else { - try pixelart.plugin.register(&editor.host); - try pixelartPlugin(editor).initPlugin(); + try pixi.plugin.register(&editor.host); } - try code_mod.plugin.register(&editor.host); + if (loadCodeFromDylibEnabled()) { + editor.loadCodeDylib(fizzy.app.root_path) catch |err| { + dvui.log.warn("code dylib load failed ({s}); falling back to static plugin", .{@errorName(err)}); + try code_mod.plugin.register(&editor.host); + }; + } else { + try code_mod.plugin.register(&editor.host); + } + // Example plugin: the minimal built-in / template. Registered statically here; it also + // builds standalone as a dylib (`cd src/plugins/example && zig build`), so it exercises + // both link modes. See docs/PLUGINS.md. + try example_mod.plugin.register(&editor.host); + + // User-installed plugins from `/plugins/{id}.{dylib,so,dll}`. + editor.loadUserPlugins(editor.config_folder); + + try InstalledPlugins.register(&editor.host); + + for (editor.host.plugins.items) |p| try p.initPlugin(); // Shell built-in: Settings (owner = null; not a plugin). try editor.host.registerSidebarView(.{ @@ -617,7 +821,7 @@ pub fn postInit(editor: *Editor) !void { // 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 = "pixi.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 }); @@ -708,13 +912,9 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .drawWorkspaces = shellDrawWorkspaces, .showOpenFolderDialog = shellShowOpenFolderDialog, .showOpenFileDialog = shellShowOpenFileDialog, - .accept = shellAccept, - .cancel = shellCancel, - .copy = shellCopy, - .paste = shellPaste, - .transform = shellTransform, .save = shellSave, - .requestCompositeWarmup = shellRequestCompositeWarmup, + .requestPrepareFrame = shellRequestCompositeWarmup, + .refresh = shellRefresh, .allocUntitledPath = shellAllocUntitledPath, .createDocument = shellCreateDocument, .setExplorerNewFilePath = shellSetExplorerNewFilePath, @@ -726,8 +926,6 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .trackQuitSaveInFlight = shellTrackQuitSaveInFlight, .resumeSaveAllQuit = shellResumeSaveAllQuit, .abortSaveAllQuit = shellAbortSaveAllQuit, - .startPackProject = shellStartPackProject, - .isPackingActive = shellIsPackingActive, }; fn shellCtx(ctx: *anyopaque) *Editor { @@ -880,26 +1078,17 @@ fn shellShowOpenFileDialog( 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(); -} -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(); + shellCtx(ctx).requestPrepareFrame(); +} +fn shellRefresh(ctx: *anyopaque) void { + _ = ctx; + const w = fizzy.app.window; + if (w.extra_frames_needed == 0) w.extra_frames_needed = 1; + w.backend.refresh(); } fn shellAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 { return shellCtx(ctx).allocNextUntitledPath(); @@ -943,12 +1132,6 @@ fn shellResumeSaveAllQuit(ctx: *anyopaque) void { 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(); -} /// 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 { @@ -1010,9 +1193,9 @@ pub fn bindDocToPane(_: *Editor, doc: sdk.DocHandle, canvas_id: dvui.Id, workspa doc.owner.bindDocumentToPane(doc, canvas_id, workspace, center); } -/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). +/// 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" }); + const themes_dir = try std.fs.path.join(gpa, &.{ editor.config_folder, "themes" }); if (!std.fs.path.isAbsolute(themes_dir)) { gpa.free(themes_dir); @@ -1136,7 +1319,7 @@ pub fn markSettingsDirty(editor: *Editor) void { fn activelyDrawing(editor: *Editor) bool { for (editor.host.plugins.items) |plugin| { - if (plugin.isAnyDocumentActivelyDrawing()) return true; + if (plugin.needsContinuousRepaint()) return true; } return false; } @@ -1282,7 +1465,6 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // workspace/file iteration so that a just-loaded file is visible to the rest of this frame. editor.processLoadingJobs(); if (comptime builtin.target.cpu.arch == .wasm32) fizzy.backend.pollWebFileIo(editor); - editor.processPackJob(); // Build workspaces AFTER reaping load jobs so a freshly-loaded file with a new grouping // (e.g. "Open to the side") gets its workspace created on the same frame it lands. @@ -1294,14 +1476,14 @@ pub fn tick(editor: *Editor) !dvui.App.Result { if (editor.pending_composite_warmup) { editor.pending_composite_warmup = false; - for (editor.host.plugins.items) |plugin| plugin.warmupActiveDocumentComposites(); + for (editor.host.plugins.items) |plugin| plugin.prepareFrame(); } { var any_drawing = false; fizzy.perf.draw_stroke_buf_count = 0; for (editor.host.plugins.items) |plugin| { - if (plugin.isAnyDocumentActivelyDrawing()) any_drawing = true; + if (plugin.needsContinuousRepaint()) any_drawing = true; } fizzy.perf.drawFrameBegin(any_drawing); } @@ -1489,12 +1671,12 @@ pub fn tick(editor: *Editor) !dvui.App.Result { defer base_box.deinit(); for (editor.host.plugins.items) |plugin| { - plugin.tickActiveDocumentPlayback(base_box.data().id); + plugin.tickActiveDocument(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.host.plugins.items) |plugin| plugin.resetDocumentPeekLayers(); + defer for (editor.host.plugins.items) |plugin| plugin.endFrame(); // Sidebar area // Since sidebar is drawn before the explorer, and we want to allow expanding the explorer @@ -1636,7 +1818,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { defer editor.panel.paned.deinit(); if (!editor.panel.paned.dragging) { - if (editor.activeDoc() != null) { + const show_panel = editor.activeDoc() != null or editor.host.hasPersistentBottomView(); + if (show_panel) { 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); } @@ -1678,16 +1861,20 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.clearAllWorkspaceCenter(); } - { // Radial Menu (pixel-art plugin) - const pa = pixelartPlugin(editor); - try pa.tickKeybinds(); + { // Plugin keybinds + per-frame overlays (e.g. pixel-art's radial menu) + for (editor.host.plugins.items) |plugin| { + plugin.tickKeybinds() catch |err| { + dvui.log.err("Plugin keybind tick failed: {s}", .{@errorName(err)}); + }; + } Keybinds.tick() catch { dvui.log.err("Failed to tick hotkeys", .{}); }; - pa.processRadialMenuInput(); - if (pa.radialMenuVisible()) { - try pa.drawRadialMenu(); + for (editor.host.plugins.items) |plugin| { + plugin.drawOverlay() catch |err| { + dvui.log.err("Plugin overlay draw failed: {s}", .{@errorName(err)}); + }; } } @@ -1717,14 +1904,17 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // out and removes itself when the timer expires. editor.drawSaveToasts(); + // First frame after startup: if any user plugin failed to load, tell the user once + // (otherwise the only trace is a log line they'll never see). + if (!editor.plugin_failures_dialog_shown and editor.failed_user_plugins.items.len > 0) { + editor.plugin_failures_dialog_shown = true; + Dialogs.PluginLoadFailures.request(); + } + editor.saveSettingsGuarded() catch |err| { dvui.log.err("Failed to autosave settings ({s})", .{@errorName(err)}); }; - if (comptime builtin.target.cpu.arch == .wasm32) { - pixelartPlugin(editor).runPackWorkers(); - } - _ = editor.arena.reset(.retain_capacity); if (editor.pending_app_close) { @@ -1965,11 +2155,11 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { editor.requestSaveAs(); return; } - if (doc.owner.shouldConfirmFlatRasterSave(doc)) { + if (doc.owner.saveNeedsConfirmation(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); - doc.owner.requestFlatRasterSaveWarning(doc, .save_and_close, true); + doc.owner.requestSaveConfirmation(doc, .save_and_close, true); return; } @@ -2038,21 +2228,23 @@ 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); - pixelartPlugin(editor).persistProjectFolder(); + for (editor.host.plugins.items) |plugin| plugin.onFolderClose(); 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_files_view); + if (editor.host.firstVisibleSidebarView()) |view| { + editor.host.setActiveSidebarView(view.id); + } - pixelartPlugin(editor).reloadProjectFolder(fizzy.app.allocator); + for (editor.host.plugins.items) |plugin| plugin.onFolderOpen(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); - pixelartPlugin(editor).persistProjectFolder(); + for (editor.host.plugins.items) |plugin| plugin.onFolderClose(); fizzy.app.allocator.free(folder); editor.folder = null; } @@ -2236,21 +2428,6 @@ pub fn processLoadingJobs(editor: *Editor) void { } } -/// Kick off an async project-pack via the pixel-art plugin vtable. -pub fn startPackProject(editor: *Editor) !void { - try pixelartPlugin(editor).startPackProject(); -} - -/// True while a pack is queued, running, or finished but not yet installed. -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 { - pixelartPlugin(editor).tickPackJobs(); -} - pub fn activeWorkspaceCanvasRectPhysical(editor: *Editor) ?dvui.Rect.Physical { return editor.workbench.activeWorkspaceCanvasRectPhysical(); } @@ -2436,7 +2613,7 @@ pub fn drawLoadingOverlay(editor: *Editor) void { } } -pub fn requestCompositeWarmup(editor: *Editor) void { +pub fn requestPrepareFrame(editor: *Editor) void { editor.pending_composite_warmup = true; } @@ -2445,7 +2622,7 @@ pub fn newFile(editor: *Editor, path: []const u8, grid: sdk.EditorAPI.NewDocGrid return error.FileAlreadyExists; } - const owner = pixelartPlugin(editor); + const owner = editor.host.pluginWithCreateDocument() orelse return error.NoEditorPlugin; const staging = try owner.allocDocumentBuffer(fizzy.app.allocator); defer fizzy.app.allocator.free(staging.backing); @@ -2479,11 +2656,12 @@ pub fn allocNextUntitledPath(editor: *Editor) ![]u8 { return std.fmt.allocPrint(fizzy.app.allocator, "untitled-{d}", .{max_n + 1}); } -/// 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. +/// Runs the active document owner's grid-layout command (`.gridLayout`). Dispatched by +/// the focused doc's owner — never a hardcoded plugin; a no-op when the owner has no such command. pub fn requestGridLayoutDialog(editor: *Editor) void { - const doc = editor.activeDoc() orelse return; - doc.owner.requestGridLayoutDialog(doc); + editor.runActiveDocCommand("gridLayout") catch |err| { + dvui.log.err("Grid layout command failed: {s}", .{@errorName(err)}); + }; } /// Opens the New File dialog via the plugin that provides one (dispatched by `Host`); on confirm @@ -2502,29 +2680,47 @@ pub fn forceCloseFile(editor: *Editor, index: usize) !void { } } +/// Dispatch a generic shell action to the active document owner's command (`.`). +/// No active doc, or an owner that registered no such command, is a clean no-op. This is how the +/// shell's Edit menu / keybinds reach per-editor actions without naming any plugin. +fn runActiveDocCommand(editor: *Editor, action: []const u8) !void { + const doc = editor.activeDoc() orelse return; + const id = try std.fmt.allocPrint(editor.arena.allocator(), "{s}.{s}", .{ doc.owner.id, action }); + try editor.host.runCommand(id); +} + +/// Whether the active document's owner registered `action` as a command. +pub fn activeDocCommandEnabled(editor: *Editor, action: []const u8) bool { + const doc = editor.activeDoc() orelse return false; + var buf: [128]u8 = undefined; + const id = std.fmt.bufPrint(&buf, "{s}.{s}", .{ doc.owner.id, action }) catch return false; + return editor.host.commandEnabled(id); +} + pub fn accept(editor: *Editor) !void { - pixelartPlugin(editor).acceptEdit(); + try editor.runActiveDocCommand("acceptEdit"); } pub fn cancel(editor: *Editor) !void { - pixelartPlugin(editor).cancelEdit(); + try editor.runActiveDocCommand("cancelEdit"); } pub fn copy(editor: *Editor) !void { - try pixelartPlugin(editor).copy(); + try editor.runActiveDocCommand("copy"); } pub fn paste(editor: *Editor) !void { - try pixelartPlugin(editor).paste(); + try editor.runActiveDocCommand("paste"); } pub fn deleteSelectedContents(editor: *Editor) void { - pixelartPlugin(editor).deleteSelection(); + editor.runActiveDocCommand("deleteSelection") catch |err| { + dvui.log.err("deleteSelection command failed: {s}", .{@errorName(err)}); + }; } -/// Begins a transform operation on the currently active file. pub fn transform(editor: *Editor) !void { - try pixelartPlugin(editor).transform(); + try editor.runActiveDocCommand("transform"); } /// Performs a save operation on the currently open file. @@ -2535,8 +2731,8 @@ pub fn save(editor: *Editor) !void { editor.requestSaveAs(); return; } - if (doc.owner.shouldConfirmFlatRasterSave(doc)) { - doc.owner.requestFlatRasterSaveWarning(doc, .editor_save, false); + if (doc.owner.saveNeedsConfirmation(doc)) { + doc.owner.requestSaveConfirmation(doc, .editor_save, false); return; } if (comptime builtin.target.cpu.arch == .wasm32) { @@ -2562,7 +2758,7 @@ pub fn saveAll(editor: *Editor) !void { for (editor.open_files.values()) |doc| { if (!doc.owner.isDirty(doc)) continue; if (!doc.owner.documentHasRecognizedSaveExtension(doc)) continue; - if (doc.owner.shouldConfirmFlatRasterSave(doc)) continue; + if (doc.owner.saveNeedsConfirmation(doc)) continue; doc.owner.saveDocument(doc) catch |err| { dvui.log.err("Save All: file {s} failed: {s}", .{ editor.docPath(doc), @errorName(err) }); }; @@ -2811,6 +3007,8 @@ pub fn deinit(editor: *Editor) !void { editor.settings.deinit(fizzy.app.allocator); editor.explorer.deinit(); + editor.panel.deinit(fizzy.app.allocator); + fizzy.app.allocator.destroy(editor.panel); editor.workbench.deinitWorkspaces(); editor.unloadPluginLibs(); diff --git a/src/editor/InstalledPlugins.zig b/src/editor/InstalledPlugins.zig new file mode 100644 index 00000000..477faaea --- /dev/null +++ b/src/editor/InstalledPlugins.zig @@ -0,0 +1,89 @@ +//! Settings → Plugins: local plugin inventory (no network store yet). +const std = @import("std"); +const dvui = @import("dvui"); +const sdk = @import("sdk"); +const fizzy = @import("../fizzy.zig"); + +const version = sdk.version; +const dylib = sdk.dylib; + +pub fn register(host: *sdk.Host) !void { + try host.registerSettingsSection(.{ + .id = "shell.settings.plugins", + .title = "Plugins", + .draw = drawPlugins, + }); +} + +fn isBundled(id: []const u8) bool { + return std.mem.eql(u8, id, "pixi") or + std.mem.eql(u8, id, "workbench") or + std.mem.eql(u8, id, "code"); +} + +fn drawPlugins(_: ?*anyopaque) anyerror!void { + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer vbox.deinit(); + + var host_sdk_buf: [96]u8 = undefined; + const host_sdk = std.fmt.bufPrint(&host_sdk_buf, "Host SDK {d}.{d}.{d} · ABI 0x{x}", .{ + version.sdk_version.major, + version.sdk_version.minor, + version.sdk_version.patch, + dylib.abi_fingerprint, + }) catch "Host SDK ?"; + dvui.labelNoFmt(@src(), host_sdk, .{}, .{ .margin = .{ .h = 4 } }); + + dvui.labelNoFmt(@src(), "Registered plugins", .{}, .{ + .font = dvui.Font.theme(.heading), + .margin = .{ .y = 8 }, + }); + + for (fizzy.editor.host.plugins.items, 0..) |plugin, i| { + const tag: []const u8 = if (isBundled(plugin.id)) " (bundled)" else ""; + dvui.label(@src(), "• {s} — {s}{s}", .{ plugin.display_name, plugin.id, tag }, .{ .id_extra = i }); + } + + if (fizzy.editor.loaded_plugin_libs.items.len > 0) { + dvui.labelNoFmt(@src(), "User dylibs", .{}, .{ + .font = dvui.Font.theme(.heading), + .margin = .{ .y = 8 }, + }); + for (fizzy.editor.loaded_plugin_libs.items, 0..) |loaded, i| { + const vi = loaded.version_info; + var ver_buf: [32]u8 = undefined; + const ver = std.fmt.bufPrint(&ver_buf, "{d}.{d}.{d}", .{ + vi.plugin_version.major, + vi.plugin_version.minor, + vi.plugin_version.patch, + }) catch "?"; + dvui.label( + @src(), + "• {s} — v{s} (SDK {d}.{d}.{d})", + .{ + loaded.plugin_id, + ver, + vi.built_with_sdk_version.major, + vi.built_with_sdk_version.minor, + vi.built_with_sdk_version.patch, + }, + .{ .id_extra = i }, + ); + } + } + + if (fizzy.editor.failed_user_plugins.items.len > 0) { + dvui.labelNoFmt(@src(), "Load failures", .{}, .{ + .font = dvui.Font.theme(.heading), + .margin = .{ .y = 8 }, + .color_text = dvui.themeGet().color(.err, .text), + }); + for (fizzy.editor.failed_user_plugins.items, 0..) |f, i| { + if (f.detail) |detail| { + dvui.label(@src(), "• {s} — {s} ({s})", .{ f.id, f.reason, detail }, .{ .id_extra = i }); + } else { + dvui.label(@src(), "• {s} — {s}", .{ f.id, f.reason }, .{ .id_extra = i }); + } + } + } +} diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 981473bb..abd06f3c 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -174,7 +174,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { } } -/// Edit menu (pixel-art contribution). +/// Edit menu (pixi contribution). pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { if (menuItem( @src(), @@ -183,7 +183,6 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { .{ .expand = .horizontal, .color_text = dvui.themeGet().color(.control, .text), - //.style = .control, }, )) |r| { var animator = dvui.animate(@src(), .{ @@ -201,32 +200,28 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { @src(), "Copy", dvui.currentWindow().keybinds.get("copy") orelse .{}, - fizzy.editor.activeDoc() != null, + fizzy.editor.activeDocCommandEnabled("copy"), .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeDoc() != null) { - fizzy.editor.copy() catch { - std.log.err("Failed to copy", .{}); - }; - fw.close(); - } + fizzy.editor.copy() catch { + std.log.err("Failed to copy", .{}); + }; + fw.close(); } if (menuItemWithHotkey( @src(), "Paste", dvui.currentWindow().keybinds.get("paste") orelse .{}, - fizzy.editor.activeDoc() != null, + fizzy.editor.activeDocCommandEnabled("paste"), .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeDoc() != null) { - fizzy.editor.paste() catch { - std.log.err("Failed to paste", .{}); - }; - fw.close(); - } + fizzy.editor.paste() catch { + std.log.err("Failed to paste", .{}); + }; + fw.close(); } _ = dvui.separator(@src(), .{ .expand = .horizontal }); @@ -267,16 +262,14 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { @src(), "Transform", dvui.currentWindow().keybinds.get("transform") orelse .{}, - fizzy.editor.activeDoc() != null, + fizzy.editor.activeDocCommandEnabled("transform"), .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeDoc() != null) { - fizzy.editor.transform() catch { - std.log.err("Failed to transform", .{}); - }; - fw.close(); - } + fizzy.editor.transform() catch { + std.log.err("Failed to transform", .{}); + }; + fw.close(); } _ = dvui.separator(@src(), .{ .expand = .horizontal }); @@ -285,15 +278,15 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { @src(), "Grid Layout…", dvui.currentWindow().keybinds.get("grid_layout") orelse .{}, - fizzy.editor.activeDoc() != null, + fizzy.editor.activeDocCommandEnabled("gridLayout"), .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeDoc() != null) { - fizzy.editor.requestGridLayoutDialog(); - fw.close(); - } + fizzy.editor.requestGridLayoutDialog(); + fw.close(); } + + try drawMenuSections("pixi.menu.edit"); } } @@ -333,6 +326,8 @@ pub fn drawViewMenu(_: ?*anyopaque) anyerror!void { fw.close(); } + try drawMenuSections("shell.menu.view"); + _ = dvui.separator(@src(), .{ .expand = .horizontal }); if (menuItem(@src(), "Show DVUI Demo", .{}, .{ .expand = .horizontal }) != null) { @@ -457,3 +452,13 @@ pub fn menuItemWithChevron(src: std.builtin.SourceLocation, label_str: []const u return ret; } + +/// Draw registered menu sections for an open parent menu. +pub fn drawMenuSections(parent_menu_id: []const u8) !void { + for (fizzy.editor.host.menu_sections.items) |*section| { + if (!std.mem.eql(u8, section.parent_menu_id, parent_menu_id)) continue; + section.draw(section.ctx) catch |err| { + dvui.log.err("Menu section '{s}' failed: {any}", .{ section.id, err }); + }; + } +} diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig index 9afc7645..951fd98d 100644 --- a/src/editor/PluginLoader.zig +++ b/src/editor/PluginLoader.zig @@ -1,8 +1,7 @@ //! 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 -//! app's lifetime — vtable hooks live in the dylib image. +//! Opens a prebuilt plugin library, checks the SDK ABI fingerprint and version, and calls +//! `fizzy_plugin_register`. The returned `std.DynLib` must stay open for the app's lifetime. //! //! **Native targets only.** Wasm imports `PluginLoader_stub.zig` instead. const std = @import("std"); @@ -12,23 +11,73 @@ const sdk = @import("sdk"); const Host = sdk.Host; const dylib_api = sdk.dylib; const dvui_context = sdk.dvui_context; +const version = sdk.version; + +/// Zig 0.16.0's `std.DynLib` dropped Windows support; this thin wrapper restores it for +/// Windows while delegating elsewhere. Shape matches `std.DynLib.{open, close, lookup}`. +pub const DynLib = if (builtin.os.tag == .windows) WindowsDynLib else std.DynLib; + +const WindowsDynLib = struct { + const windows = std.os.windows; + + extern "kernel32" fn LoadLibraryW(lpLibFileName: [*:0]const u16) callconv(.winapi) ?windows.HMODULE; + extern "kernel32" fn GetProcAddress(hModule: windows.HMODULE, lpProcName: [*:0]const u8) callconv(.winapi) ?*anyopaque; + extern "kernel32" fn FreeLibrary(hLibModule: windows.HMODULE) callconv(.winapi) windows.BOOL; + + handle: windows.HMODULE, + + pub const Error = error{ FileNotFound, InvalidUtf8 }; + + pub fn open(path: []const u8) Error!WindowsDynLib { + var buf: [windows.PATH_MAX_WIDE:0]u16 = undefined; + const len = std.unicode.wtf8ToWtf16Le(buf[0..], path) catch return error.InvalidUtf8; + if (len >= buf.len) return error.FileNotFound; + buf[len] = 0; + const wide_path: [*:0]const u16 = buf[0..len :0].ptr; + const handle = LoadLibraryW(wide_path) orelse return error.FileNotFound; + return .{ .handle = handle }; + } + + pub fn close(self: *WindowsDynLib) void { + _ = FreeLibrary(self.handle); + self.* = undefined; + } + + pub fn lookup(self: *WindowsDynLib, comptime T: type, name: [:0]const u8) ?T { + if (GetProcAddress(self.handle, name.ptr)) |sym| { + return @as(T, @ptrCast(@alignCast(sym))); + } + return null; + } +}; pub const LoadError = error{ DylibOpenFailed, - AbiSymbolMissing, + AbiFingerprintSymbolMissing, RegisterSymbolMissing, SetGlobalsSymbolMissing, SetDvuiContextSymbolMissing, SetRenderBridgeSymbolMissing, + SdkVersionSymbolMissing, AbiMismatch, + SdkVersionMismatch, + PluginIdMismatch, RegisterRejected, }; +pub const PluginVersionInfo = struct { + plugin_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + built_with_sdk_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + min_sdk_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + declared_id: ?[]const u8 = null, +}; + pub const LoadedLib = struct { - lib: std.DynLib, + lib: DynLib, path: []const u8, - /// Built-in plugin id (`"pixelart"`, `"workbench"`, …). + /// Declared plugin id from the dylib (must match filename basename). plugin_id: []const u8, + version_info: PluginVersionInfo = .{}, set_globals: dylib_api.SetGlobalsFn, set_dvui_context: dvui_context.SetContextFn, set_render_bridge: sdk.render_bridge.SetRenderBridgeFn, @@ -37,21 +86,31 @@ pub const LoadedLib = struct { /// 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, + arg_b: ?*anyopaque = null, + arg_c: ?*anyopaque = null, }; -/// `{exe_dir}/plugins/{pluginFilename(name)}` +/// Platform-specific plugin dylib extension. +pub fn pluginExtension() []const u8 { + return switch (builtin.os.tag) { + .windows => "dll", + .macos => "dylib", + else => "so", + }; +} + +/// `{name}.{ext}` — flat layout under `{dir}/plugins/`. +pub fn pluginFilename(name: []const u8, allocator: std.mem.Allocator) ![]const u8 { + return std.fmt.allocPrint(allocator, "{s}.{s}", .{ name, pluginExtension() }); +} + +/// `{exe_dir}/plugins/{name}.{ext}` 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}), - }; + const file_name = try pluginFilename(name, allocator); defer allocator.free(file_name); return std.fs.path.join(allocator, &.{ exe_dir, "plugins", file_name }); } @@ -78,20 +137,64 @@ fn nativeEnviron() std.process.Environ { return .{ .block = .{ .slice = slice } }; } +fn lookupVersionFn(lib: *DynLib, symbol: [:0]const u8) ?dylib_api.GetSdkVersionFn { + return lib.lookup(dylib_api.GetSdkVersionFn, symbol); +} + +fn lookupPluginIdFn(lib: *DynLib, symbol: [:0]const u8) ?dylib_api.GetPluginIdFn { + return lib.lookup(dylib_api.GetPluginIdFn, symbol); +} + +fn readVersionTriplet(get_fn: ?dylib_api.GetSdkVersionFn) std.SemanticVersion { + if (get_fn) |f| { + return dylib_api.semverFromTriplet(f()); + } + return .{ .major = 0, .minor = 0, .patch = 0 }; +} + pub fn loadAndRegister( host: *Host, path: []const u8, - plugin_id: []const u8, + expected_id: []const u8, pre: ?PreRegister, ) LoadError!LoadedLib { - var lib = std.DynLib.open(path) catch return error.DylibOpenFailed; + var lib = 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 abi_fp_fn = lib.lookup( + dylib_api.GetAbiFingerprintFn, + dylib_api.symbol_abi_fingerprint, + ) orelse return error.AbiFingerprintSymbolMissing; + const plugin_fp = abi_fp_fn(); + if (!dylib_api.fingerprintMatches(plugin_fp)) { + if (allowAbiWarn()) { + std.log.warn("plugin '{s}': ABI fingerprint mismatch (host 0x{x}, plugin 0x{x}) — loading anyway (FIZZY_PLUGIN_ABI_WARN)", .{ + expected_id, + dylib_api.abi_fingerprint, + plugin_fp, + }); + } else { + return error.AbiMismatch; + } + } + + const get_sdk_version = lookupVersionFn(&lib, dylib_api.symbol_sdk_version); + const get_min_sdk = lookupVersionFn(&lib, dylib_api.symbol_min_sdk_version); + const get_plugin_version = lookupVersionFn(&lib, dylib_api.symbol_plugin_version); + const get_plugin_id = lookupPluginIdFn(&lib, dylib_api.symbol_plugin_id); + + const built_with = readVersionTriplet(get_sdk_version); + const min_sdk = readVersionTriplet(get_min_sdk); + const plugin_version = readVersionTriplet(get_plugin_version); + + if (get_min_sdk != null and !version.sdkVersionSatisfies(version.sdk_version, min_sdk)) { + return error.SdkVersionMismatch; + } + + if (get_plugin_id) |id_fn| { + const declared = std.mem.span(id_fn()); + if (!std.mem.eql(u8, declared, expected_id)) return error.PluginIdMismatch; + } const set_globals = lib.lookup( dylib_api.SetGlobalsFn, @@ -116,8 +219,8 @@ pub fn loadAndRegister( if (pre) |inject| { set_globals( if (inject.gpa) |gpa| @ptrCast(gpa) else null, - inject.state, - inject.packer, + inject.arg_b, + inject.arg_c, ); } @@ -125,25 +228,59 @@ pub fn loadAndRegister( switch (status) { .ok => {}, .err_abi_mismatch => return error.AbiMismatch, + .err_sdk_version => return error.SdkVersionMismatch, else => return error.RegisterRejected, } return .{ .lib = lib, .path = path, - .plugin_id = plugin_id, + .plugin_id = expected_id, + .version_info = .{ + .plugin_version = plugin_version, + .built_with_sdk_version = built_with, + .min_sdk_version = min_sdk, + .declared_id = if (get_plugin_id) |f| std.mem.span(f()) else null, + }, .set_globals = set_globals, .set_dvui_context = set_ctx, .set_render_bridge = set_bridge, }; } +fn allowAbiWarn() bool { + if (builtin.mode != .Debug) return false; + if (std.c.getenv("FIZZY_PLUGIN_ABI_WARN")) |v| { + return std.mem.eql(u8, std.mem.span(v), "1"); + } + return false; +} + +/// Best-effort read of version exports from a dylib (for failure diagnostics). +pub fn probeVersionInfo(path: []const u8) ?PluginVersionInfo { + var lib = DynLib.open(path) catch return null; + defer lib.close(); + const get_sdk_version = lookupVersionFn(&lib, dylib_api.symbol_sdk_version); + const get_min_sdk = lookupVersionFn(&lib, dylib_api.symbol_min_sdk_version); + const get_plugin_version = lookupVersionFn(&lib, dylib_api.symbol_plugin_version); + return .{ + .plugin_version = readVersionTriplet(get_plugin_version), + .built_with_sdk_version = readVersionTriplet(get_sdk_version), + .min_sdk_version = readVersionTriplet(get_min_sdk), + }; +} + test "builtin plugin path joins exe_dir/plugins" { - const path = try builtinPluginPath(std.testing.allocator, "/app", "pixelart"); + const path = try builtinPluginPath(std.testing.allocator, "/app", "pixi"); 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), + .windows => try std.testing.expectEqualStrings("/app/plugins/pixi.dll", path), + .macos => try std.testing.expectEqualStrings("/app/plugins/pixi.dylib", path), + else => try std.testing.expectEqualStrings("/app/plugins/pixi.so", path), } } + +test "sdk version satisfy" { + try std.testing.expect(version.sdkVersionSatisfies(.{ .major = 0, .minor = 2, .patch = 0 }, .{ .major = 0, .minor = 1, .patch = 5 })); + try std.testing.expect(!version.sdkVersionSatisfies(.{ .major = 0, .minor = 0, .patch = 9 }, .{ .major = 0, .minor = 1, .patch = 0 })); +} diff --git a/src/editor/PluginLoader_stub.zig b/src/editor/PluginLoader_stub.zig index 753211c9..6ef28fc1 100644 --- a/src/editor/PluginLoader_stub.zig +++ b/src/editor/PluginLoader_stub.zig @@ -1,10 +1,23 @@ -//! Wasm stub — dynamic plugin loading is native-only. +//! Wasm stub — dynamic plugin loading is native-only (no `dlopen` in the browser; web plugins +//! are statically linked). The shell still references these types in cross-platform code +//! (e.g. the Settings → Plugins list), so `LoadedLib` mirrors the read-shape of the real +//! `PluginLoader.LoadedLib`. On wasm `loaded_plugin_libs` is always empty, so the values are +//! never produced — only the type has to satisfy those field accesses. const std = @import("std"); pub const LoadError = error{Unsupported}; +pub const PluginVersionInfo = struct { + plugin_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + built_with_sdk_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + min_sdk_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + declared_id: ?[]const u8 = null, +}; + pub const LoadedLib = struct { path: []const u8, + plugin_id: []const u8 = "", + version_info: PluginVersionInfo = .{}, }; pub fn resolvePluginPath(_: std.mem.Allocator, _: []const u8, _: []const u8) ![]const u8 { diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig index c6ebd68e..b506a23d 100644 --- a/src/editor/Settings.zig +++ b/src/editor/Settings.zig @@ -187,7 +187,7 @@ pub fn loadPluginStore( // 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 { + const key = allocator.dupe(u8, "pixi") catch { allocator.free(legacy_blob); return; }; diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig index 51dad7ea..521995dc 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -37,6 +37,7 @@ pub fn draw(_: Sidebar) !Action { // 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| { + if (view.hidden) continue; const a = try drawOption(view, i, 20); if (a != .none) ret = a; } diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig index 629a9c79..13920b3d 100644 --- a/src/editor/dialogs/Dialogs.zig +++ b/src/editor/dialogs/Dialogs.zig @@ -9,6 +9,7 @@ const Dialogs = @This(); pub const UnsavedClose = @import("UnsavedClose.zig"); pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig"); pub const AboutFizzy = @import("AboutFizzy.zig"); +pub const PluginLoadFailures = @import("PluginLoadFailures.zig"); pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32) @import("WebFolderUnavailable.zig") else diff --git a/src/editor/dialogs/PluginLoadFailures.zig b/src/editor/dialogs/PluginLoadFailures.zig new file mode 100644 index 00000000..8bb1f350 --- /dev/null +++ b/src/editor/dialogs/PluginLoadFailures.zig @@ -0,0 +1,111 @@ +//! Shown once at startup when one or more user plugins failed to load, so an author isn't +//! left guessing why their plugin didn't appear. Reads the recorded failures off the live +//! `fizzy.editor` (populated by `Editor.loadUserPlugins`); the shell calls `request()` after +//! user-plugin loading when `editor.failed_user_plugins` is non-empty. + +const std = @import("std"); +const dvui = @import("dvui"); +const sdk = @import("sdk"); +const fizzy = @import("../../fizzy.zig"); + +const version = sdk.version; +const dylib = sdk.dylib; + +pub fn request() void { + if (active(dvui.currentWindow())) return; + var mutex = fizzy.dvui.dialog(@src(), .{ + .displayFn = dialog, + .callafterFn = callAfter, + .title = "Plugin Load Failures", + .ok_label = "", + .cancel_label = "", + .resizeable = false, + .default = .cancel, + .hide_footer = true, + .header_kind = .err, + }); + mutex.mutex.unlock(dvui.io); +} + +pub fn active(win: *dvui.Window) bool { + var it = win.dialogs.iterator(null); + while (it.next()) |d| { + const df = dvui.dataGet(null, d.id, "_displayFn", fizzy.dvui.DisplayFn) orelse continue; + if (df == dialog) return true; + } + return false; +} + +fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8) bool { + const opts: dvui.Options = .{ + .tab_index = 1, + .style = .control, + .box_shadow = .{ + .color = .black, + .alpha = 0.25, + .offset = .{ .x = -4, .y = 4 }, + .fade = 8, + }, + }; + var button: dvui.ButtonWidget = undefined; + button.init(src, .{}, opts); + defer button.deinit(); + button.processEvents(); + button.drawFocus(); + button.drawBackground(); + dvui.labelNoFmt(src, label_text, .{}, opts.strip().override(button.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 })); + return button.clicked(); +} + +pub fn dialog(_: dvui.Id) anyerror!bool { + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(12) }); + defer outer.deinit(); + + var host_line_buf: [96]u8 = undefined; + const host_line = std.fmt.bufPrint(&host_line_buf, "Host SDK {d}.{d}.{d} · ABI 0x{x}", .{ + version.sdk_version.major, + version.sdk_version.minor, + version.sdk_version.patch, + dylib.abi_fingerprint, + }) catch "Host SDK ?"; + + dvui.labelNoFmt( + @src(), + "Some installed plugins could not be loaded:", + .{}, + .{ .color_text = dvui.themeGet().color(.window, .text), .margin = .{ .h = 8 } }, + ); + dvui.labelNoFmt(@src(), host_line, .{}, .{ + .color_text = dvui.themeGet().color(.window, .text), + .margin = .{ .h = 4 }, + }); + + for (fizzy.editor.failed_user_plugins.items, 0..) |f, i| { + if (f.detail) |detail| { + dvui.label( + @src(), + "• {s} — {s} ({s})", + .{ f.id, f.reason, detail }, + .{ .id_extra = i, .color_text = dvui.themeGet().color(.window, .text), .margin = .{ .h = 4 } }, + ); + } else { + dvui.label( + @src(), + "• {s} — {s}", + .{ f.id, f.reason }, + .{ .id_extra = i, .color_text = dvui.themeGet().color(.window, .text), .margin = .{ .h = 4 } }, + ); + } + } + + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_x = 0.5 }); + defer row.deinit(); + + if (dialogButton(@src(), "OK")) { + fizzy.dvui.closeFloatingDialogAnchored(); + } + + return true; +} + +pub fn callAfter(_: dvui.Id, _: dvui.enums.DialogResponse) anyerror!void {} diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index 8c3d12ba..bcf0dc1e 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -115,9 +115,9 @@ fn onSaveAndClose(file_id: u64) !void { fizzy.editor.requestSaveAs(); return; } - if (doc.owner.shouldConfirmFlatRasterSave(doc)) { + if (doc.owner.saveNeedsConfirmation(doc)) { fizzy.dvui.closeFloatingDialogAnchored(); - doc.owner.requestFlatRasterSaveWarning(doc, .save_and_close, false); + doc.owner.requestSaveConfirmation(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 3d754170..20bb8ec6 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -108,8 +108,10 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { .background = false, }); - if (!fizzy.editor.host.isActiveSidebarView(fizzy.Editor.workbench_files_view)) { - fizzy.editor.resetFileTreeWhenFilesHidden(); + if (comptime workbench.plugin.has_file_tree) { + if (!fizzy.editor.host.isActiveSidebarView(fizzy.Editor.workbench_files_view)) { + fizzy.editor.resetFileTreeWhenFilesHidden(); + } } if (fizzy.editor.host.activeSidebarView()) |view| { diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig index 15ffdb82..b5cedec9 100644 --- a/src/editor/panel/Panel.zig +++ b/src/editor/panel/Panel.zig @@ -1,12 +1,10 @@ const std = @import("std"); -const builtin = @import("builtin"); const dvui = @import("dvui"); const fizzy = @import("../../fizzy.zig"); -const Core = @import("mach").Core; -const App = fizzy.App; -const Editor = fizzy.Editor; +const panel_layout = @import("panel_layout.zig"); +const PanelWorkspace = @import("PanelWorkspace.zig"); pub const Panel = @This(); @@ -15,66 +13,93 @@ scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto, }, +/// Bottom-panel splits keyed by tab-grouping id (mirrors workbench workspaces). +workspaces: std.AutoArrayHashMapUnmanaged(u64, PanelWorkspace) = .empty, +open_workspace_grouping: u64 = 0, +grouping_id_counter: u64 = 0, +/// Which split each registered bottom view belongs to (`view.id` -> grouping). +view_groupings: std.StringArrayHashMapUnmanaged(u64) = .empty, + pub fn init() Panel { return .{}; } -pub fn deinit(_: *Panel) void {} - -pub fn draw(_: *Panel) !dvui.App.Result { - // var scroll_area = dvui.scrollArea(@src(), .{ .scroll_info = &panel.scroll_info }, .{ - // .expand = .both, - // }); - // defer scroll_area.deinit(); - - 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 => {}, - } +pub fn deinit(self: *Panel, allocator: std.mem.Allocator) void { + self.workspaces.deinit(allocator); + self.view_groupings.deinit(allocator); +} +pub fn draw(panel: *Panel) !dvui.App.Result { var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, - .background = true, - .color_fill = content_color, + .background = false, }); defer vbox.deinit(); const host = &fizzy.editor.host; + if (host.bottom_views.items.len == 0) return .ok; - // 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); + panel.ensureViewGroupings(host); + try panel_layout.rebuildWorkspaces(panel, host); - if (host.activeBottomView()) |view| { - try view.draw(view.ctx); + if (panel.workspaces.count() == 0) { + try panel.workspaces.put(fizzy.app.allocator, 0, PanelWorkspace.init(0)); } - return .ok; + return try panel_layout.drawWorkspaces(panel, host, 0); } -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); +pub fn ensureViewGroupings(self: *Panel, host: *fizzy.Editor.Host) void { + for (host.bottom_views.items) |view| { + if (self.view_groupings.get(view.id) == null) { + self.view_groupings.put(fizzy.app.allocator, view.id, 0) catch {}; + } + } +} + +pub fn viewGrouping(self: *Panel, view_id: []const u8) u64 { + return self.view_groupings.get(view_id) orelse 0; +} + +pub fn setViewGrouping(self: *Panel, view_id: []const u8, grouping: u64) void { + if (self.view_groupings.getPtr(view_id)) |g| { + g.* = grouping; + } else { + self.view_groupings.put(fizzy.app.allocator, view_id, grouping) catch {}; + } +} + +pub fn newGroupingID(self: *Panel) u64 { + self.grouping_id_counter += 1; + return self.grouping_id_counter; +} + +pub fn viewIndex(self: *Panel, host: *fizzy.Editor.Host, view_id: []const u8) ?usize { + _ = self; + for (host.bottom_views.items, 0..) |view, i| { + if (std.mem.eql(u8, view.id, view_id)) return i; + } + return null; +} + +pub fn activeViewInGrouping(self: *Panel, host: *fizzy.Editor.Host, grouping: u64) ?*fizzy.Editor.Host.BottomView { + const workspace = self.workspaces.get(grouping) orelse return null; + if (workspace.active_view_id) |active_id| { + for (host.bottom_views.items) |*view| { + if (std.mem.eql(u8, view.id, active_id) and self.viewGrouping(view.id) == grouping) { + return view; + } } } + for (host.bottom_views.items) |*view| { + if (self.viewGrouping(view.id) == grouping) return view; + } + return null; +} + +pub fn swapBottomViews(_: *Panel, host: *fizzy.Editor.Host, a: usize, b: usize) void { + if (a >= host.bottom_views.items.len or b >= host.bottom_views.items.len or a == b) return; + const tmp = host.bottom_views.items[a]; + host.bottom_views.items[a] = host.bottom_views.items[b]; + host.bottom_views.items[b] = tmp; } diff --git a/src/editor/panel/PanelWorkspace.zig b/src/editor/panel/PanelWorkspace.zig new file mode 100644 index 00000000..6b59ca38 --- /dev/null +++ b/src/editor/panel/PanelWorkspace.zig @@ -0,0 +1,343 @@ +//! One bottom-panel split: workspace-style tab strip + active registered view. +const std = @import("std"); +const builtin = @import("builtin"); + +const dvui = @import("dvui"); +const fizzy = @import("../../fizzy.zig"); + +const Panel = @import("Panel.zig"); + +const panel_corner_radius: f32 = 12; + +pub const drag_name = "panel_tab_drag"; + +pub const PanelWorkspace = @This(); + +grouping: u64, +active_view_id: ?[]const u8 = null, + +tabs_drag_index: ?usize = null, +tabs_removed_index: ?usize = null, +tabs_insert_before_index: ?usize = null, + +pub fn init(grouping: u64) PanelWorkspace { + return .{ .grouping = grouping }; +} + +pub fn draw(self: *PanelWorkspace, panel: *Panel, host: *fizzy.Editor.Host) !dvui.App.Result { + var card = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = true, + .color_fill = panelContentColor(), + .corner_radius = dvui.Rect.all(panel_corner_radius), + .padding = .{ .x = 6, .y = 6, .w = 6, .h = 6 }, + .gravity_y = 0.0, + .id_extra = @intCast(self.grouping), + }); + defer card.deinit(); + + for (dvui.events()) |*e| { + if (!card.matchEvent(e)) continue; + if (e.evt == .mouse) { + if (e.evt.mouse.action == .press or (e.evt.mouse.action == .position and e.evt.mouse.mod.matchBind("ctrl/cmd"))) { + panel.open_workspace_grouping = self.grouping; + } + } + } + + if (host.bottom_views.items.len >= 1) self.drawTabs(panel, host); + try self.drawContent(panel, host); + + return .ok; +} + +fn panelContentColor() 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.opacity(fizzy.editor.settings.content_opacity) + else + content_color; + }, + else => {}, + } + return content_color; +} + +fn drawTabs(self: *PanelWorkspace, panel: *Panel, host: *fizzy.Editor.Host) void { + defer self.processTabsDrag(panel, host); + + var tabs_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .id_extra = @intCast(self.grouping), + }); + defer tabs_box.deinit(); + + var scroll_area = dvui.scrollArea(@src(), .{ .horizontal = .auto, .horizontal_bar = .hide, .vertical_bar = .hide }, .{ + .expand = .none, + .background = false, + .style = .content, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .border = dvui.Rect.all(0), + .corner_radius = dvui.Rect.all(0), + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, + .id_extra = @intCast(self.grouping), + }); + defer scroll_area.deinit(); + + var tabs = dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ + .expand = .none, + .background = false, + }); + defer tabs.deinit(); + + var tabs_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .id_extra = @intCast(self.grouping), + }); + defer tabs_hbox.deinit(); + + const active_in_this_group = blk: { + if (panel.open_workspace_grouping != self.grouping) break :blk false; + const active_id = self.active_view_id orelse break :blk false; + if (panel.viewGrouping(active_id) != self.grouping) break :blk false; + break :blk true; + }; + + const active_index = if (active_in_this_group) + panel.viewIndex(host, self.active_view_id.?) orelse null + else + null; + + for (host.bottom_views.items, 0..) |view, i| { + if (panel.viewGrouping(view.id) != self.grouping) continue; + + var reorderable = tabs.reorderable(@src(), .{}, .{ + .expand = .vertical, + .id_extra = i, + .padding = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .border = .all(0), + }); + defer reorderable.deinit(); + + const selected = active_in_this_group and active_index == i; + + // Tabs carry no background in their resting state — selection is shown purely via the + // label color (see `color_text` below). A fill is drawn only while a tab is being + // dragged, as reorder feedback. + const show_tab_fill = reorderable.floating(); + + var hbox: dvui.BoxWidget = undefined; + hbox.init(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .border = dvui.Rect.all(0), + .background = show_tab_fill, + .color_fill = if (show_tab_fill) dvui.themeGet().color(.control, .fill) else .transparent, + .id_extra = i, + .padding = .{ .x = 2, .y = 2, .w = 2, .h = 2 }, + .margin = dvui.Rect.all(0), + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, + }); + defer hbox.deinit(); + + if (reorderable.floating()) { + self.tabs_drag_index = i; + } + if (show_tab_fill) hbox.drawBackground(); + + if (reorderable.removed()) { + self.tabs_removed_index = i; + } else if (reorderable.insertBefore()) { + self.tabs_insert_before_index = i; + } + + var title_buf: [64]u8 = undefined; + const title_upper = if (view.title.len <= title_buf.len) + std.ascii.upperString(&title_buf, view.title) + else + view.title; + + dvui.label(@src(), "{s}", .{title_upper}, .{ + .color_text = if (selected) dvui.themeGet().color(.highlight, .fill) else dvui.themeGet().color(.control, .text), + .font = dvui.Font.theme(.heading), + .padding = dvui.Rect.all(4), + .gravity_y = 0.5, + }); + + loop: for (dvui.events()) |*e| { + if (!hbox.matchEvent(e)) continue; + + switch (e.evt) { + .mouse => |me| { + if (me.action == .press and me.button.pointer()) { + self.active_view_id = view.id; + panel.open_workspace_grouping = self.grouping; + host.setActiveBottomView(view.id); + dvui.refresh(null, @src(), hbox.data().id); + + e.handle(@src(), hbox.data()); + dvui.captureMouse(hbox.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(); + } else if (me.action == .motion) { + if (dvui.captured(hbox.data().id)) { + e.handle(@src(), hbox.data()); + if (dvui.dragging(me.p, null)) |_| { + reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0); + break :loop; + } + } + } + }, + else => {}, + } + } + } + + if (tabs.finalSlot()) { + self.tabs_insert_before_index = host.bottom_views.items.len; + } +} + +fn drawContent(self: *PanelWorkspace, panel: *Panel, host: *fizzy.Editor.Host) !void { + var content_vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = false, + .id_extra = @intCast(self.grouping), + }); + defer { + self.processTabDrag(content_vbox.data(), panel, host); + content_vbox.deinit(); + } + + const view = panel.activeViewInGrouping(host, self.grouping) orelse return; + try view.draw(view.ctx); +} + +fn processTabsDrag(self: *PanelWorkspace, panel: *Panel, host: *fizzy.Editor.Host) void { + if (self.tabs_insert_before_index) |insert_before| { + if (self.tabs_removed_index) |removed| { + if (removed >= host.bottom_views.items.len) return; + if (removed > insert_before) { + panel.swapBottomViews(host, removed, insert_before); + self.active_view_id = host.bottom_views.items[insert_before].id; + } else if (insert_before > 0) { + panel.swapBottomViews(host, removed, insert_before - 1); + self.active_view_id = host.bottom_views.items[insert_before - 1].id; + } else { + panel.swapBottomViews(host, removed, insert_before); + self.active_view_id = host.bottom_views.items[insert_before].id; + } + self.tabs_removed_index = null; + self.tabs_insert_before_index = null; + } else { + for (panel.workspaces.values()) |*workspace| { + if (workspace.tabs_removed_index) |removed| { + if (removed >= host.bottom_views.items.len) return; + const view = host.bottom_views.items[removed]; + if (removed > insert_before) { + panel.swapBottomViews(host, removed, insert_before); + panel.setViewGrouping(view.id, self.grouping); + self.active_view_id = view.id; + } else if (insert_before > 0) { + panel.swapBottomViews(host, removed, insert_before - 1); + panel.setViewGrouping(view.id, self.grouping); + self.active_view_id = view.id; + } else { + panel.swapBottomViews(host, removed, insert_before); + panel.setViewGrouping(view.id, self.grouping); + self.active_view_id = view.id; + } + + self.tabs_removed_index = null; + self.tabs_insert_before_index = null; + workspace.tabs_removed_index = null; + workspace.tabs_insert_before_index = null; + panel.open_workspace_grouping = self.grouping; + host.setActiveBottomView(view.id); + break; + } + } + } + } +} + +fn processTabDrag(self: *PanelWorkspace, data: *dvui.WidgetData, panel: *Panel, host: *fizzy.Editor.Host) void { + if (!dvui.dragName(drag_name)) return; + + const drag_src = blk: { + for (panel.workspaces.values()) |*w| { + if (w.tabs_drag_index) |i| break :blk .{ .ws = w, .index = i }; + } + break :blk null; + }; + if (drag_src == null) return; + const workspace = drag_src.?.ws; + const drag_index = drag_src.?.index; + if (drag_index >= host.bottom_views.items.len) return; + const dragged_view = host.bottom_views.items[drag_index]; + + for (dvui.events()) |*e| { + if (!dvui.eventMatch(e, .{ .id = data.id, .r = data.rectScale().r, .drag_name = drag_name })) continue; + if (e.evt != .mouse) continue; + + var right_side = data.rectScale().r; + right_side.w /= 2; + right_side.x += right_side.w; + + const last_grouping = panel.workspaces.keys()[panel.workspaces.keys().len - 1]; + if (right_side.contains(e.evt.mouse.p) and last_grouping == self.grouping) { + if (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), + }); + } + + if (e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + defer workspace.tabs_drag_index = null; + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + + const new_g = panel.newGroupingID(); + panel.setViewGrouping(dragged_view.id, new_g); + var new_ws = PanelWorkspace.init(new_g); + new_ws.active_view_id = dragged_view.id; + panel.workspaces.put(fizzy.app.allocator, new_g, new_ws) catch {}; + panel.open_workspace_grouping = new_g; + host.setActiveBottomView(dragged_view.id); + } + } else if (data.rectScale().r.contains(e.evt.mouse.p)) { + if (e.evt.mouse.action == .position) { + data.rectScale().r.fill(dvui.Rect.Physical.all(data.rectScale().r.w / 8), .{ + .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), + }); + } + + if (e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + defer workspace.tabs_drag_index = null; + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + + panel.setViewGrouping(dragged_view.id, self.grouping); + self.active_view_id = dragged_view.id; + panel.open_workspace_grouping = self.grouping; + host.setActiveBottomView(dragged_view.id); + } + } + } +} diff --git a/src/editor/panel/panel_layout.zig b/src/editor/panel/panel_layout.zig new file mode 100644 index 00000000..45adf3d8 --- /dev/null +++ b/src/editor/panel/panel_layout.zig @@ -0,0 +1,94 @@ +//! Bottom-panel workspace map maintenance + recursive split drawing. +const std = @import("std"); +const dvui = @import("dvui"); +const fizzy = @import("../../fizzy.zig"); + +const Panel = @import("Panel.zig"); +const PanelWorkspace = @import("PanelWorkspace.zig"); + +const handle_size = 10; +const handle_dist = 60; + +pub fn rebuildWorkspaces(panel: *Panel, host: *fizzy.Editor.Host) !void { + panel.ensureViewGroupings(host); + + var i: usize = 0; + while (i < host.bottom_views.items.len) : (i += 1) { + const view = host.bottom_views.items[i]; + const grouping = panel.viewGrouping(view.id); + if (!panel.workspaces.contains(grouping)) { + var workspace = PanelWorkspace.init(grouping); + workspace.active_view_id = view.id; + try panel.workspaces.put(fizzy.app.allocator, grouping, workspace); + } + } + + for (panel.workspaces.values()) |*workspace| { + if (panel.workspaces.count() == 1) break; + + var contains = false; + for (host.bottom_views.items) |v| { + if (panel.viewGrouping(v.id) == workspace.grouping) { + contains = true; + break; + } + } + + if (!contains) { + if (panel.open_workspace_grouping == workspace.grouping) { + for (panel.workspaces.values()) |*w| { + if (w.grouping != workspace.grouping) { + panel.open_workspace_grouping = w.grouping; + break; + } + } + } + _ = panel.workspaces.orderedRemove(workspace.grouping); + break; + } + } + + for (panel.workspaces.values()) |*workspace| { + if (panel.activeViewInGrouping(host, workspace.grouping)) |active| { + if (panel.viewGrouping(active.id) == workspace.grouping) continue; + } + for (host.bottom_views.items) |v| { + if (panel.viewGrouping(v.id) == workspace.grouping) { + workspace.active_view_id = v.id; + break; + } + } + } +} + +pub fn drawWorkspaces( + panel: *Panel, + host: *fizzy.Editor.Host, + index: usize, +) !dvui.App.Result { + if (index >= panel.workspaces.count()) return .ok; + + var s = fizzy.dvui.paned(@src(), .{ + .direction = .horizontal, + .collapsed_size = if (index == panel.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, + .id_extra = @intCast(panel.workspaces.keys()[index]), + }); + defer s.deinit(); + + if (s.showFirst()) { + const result = try panel.workspaces.values()[index].draw(panel, host); + if (result != .ok) return result; + } + + if (s.showSecond()) { + const result = try drawWorkspaces(panel, host, index + 1); + if (result != .ok) return result; + } + + return .ok; +} diff --git a/src/fizzy.zig b/src/fizzy.zig index cfa78dbb..c443f78c 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -28,13 +28,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. -pub const pixelart_mod = @import("pixelart"); +/// Pixel-art plugin module. Shell code should `@import("pixi")` directly. +pub const pixi_mod = @import("pixi"); // Global pointers pub var app: *App = undefined; pub var editor: *Editor = undefined; -pub var packer: *pixelart_mod.Packer = undefined; +pub var packer: *pixi_mod.Packer = 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/code/build.zig b/src/plugins/code/build.zig new file mode 100644 index 00000000..d1a87ba6 --- /dev/null +++ b/src/plugins/code/build.zig @@ -0,0 +1,20 @@ +//! Standalone build for the code plugin — the canonical third-party shape. +//! `cd src/plugins/code && zig build` produces `code.`. Identical in form to +//! any external plugin: declare `fizzy`, call `fizzy.plugin.create` + `.install`. The +//! fizzy-internal static-embed build lives separately in `static/` and is driven by the +//! root build. See docs/PLUGINS.md. +const std = @import("std"); +const fizzy = @import("fizzy"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const lib = fizzy.plugin.create(b, .{ + .name = "code", + .target = target, + .optimize = optimize, + .root_source_file = b.path("root.zig"), + }); + fizzy.plugin.install(b, lib, .{}); +} diff --git a/src/plugins/code/build.zig.zon b/src/plugins/code/build.zig.zon new file mode 100644 index 00000000..359e73c3 --- /dev/null +++ b/src/plugins/code/build.zig.zon @@ -0,0 +1,20 @@ +.{ + .name = .code, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "root.zig", + "code.zig", + "src", + "queries", + "static", + }, + .fingerprint = 0x77153098cc8cce17, + .dependencies = .{ + .fizzy = .{ + .path = "../../..", + }, + }, +} diff --git a/src/plugins/code/code.zig b/src/plugins/code/code.zig index d394de71..b4af1e06 100644 --- a/src/plugins/code/code.zig +++ b/src/plugins/code/code.zig @@ -1,14 +1,21 @@ -//! Intra-plugin import hub for the code plugin. +//! Code plugin root module **and** intra-plugin import hub. //! -//! 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`. +//! - The shell resolves `@import("code")` to this file when the plugin is compiled into the app +//! (static embed) and reaches its public surface here (`plugin`, document types). +//! - Files under `src/` import it as `../code.zig` for the shared deps (`sdk`/`core`/`dvui`) +//! and sibling types — the conventional `.zig` namespace. +//! +//! It must sit at the plugin root: a Zig module cannot import files above its root file's +//! directory, so this has to be beside `src/` to re-export from it. The build-side static-embed +//! glue lives in `static/`. A minimal/third-party plugin only needs this file if it embeds +//! statically or wants a shared import hub. 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 plugin = @import("src/plugin.zig"); pub const State = @import("src/State.zig"); pub const Document = @import("src/Document.zig"); pub const CodeEditor = @import("src/CodeEditor.zig"); diff --git a/src/plugins/code/dylib.zig b/src/plugins/code/dylib.zig deleted file mode 100644 index 99691a33..00000000 --- a/src/plugins/code/dylib.zig +++ /dev/null @@ -1,40 +0,0 @@ -//! 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 deleted file mode 100644 index 55f18f33..00000000 --- a/src/plugins/code/module.zig +++ /dev/null @@ -1,10 +0,0 @@ -//! 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/root.zig b/src/plugins/code/root.zig new file mode 100644 index 00000000..bd6a675b --- /dev/null +++ b/src/plugins/code/root.zig @@ -0,0 +1,7 @@ +//! Dylib entry for the code plugin — identical in shape to the canonical third-party +//! `src/plugins/root.zig`: one `exportEntry` call wired to `src/plugin.zig`. +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} diff --git a/src/plugins/code/src/Document.zig b/src/plugins/code/src/Document.zig index 8831b3bc..86e35d42 100644 --- a/src/plugins/code/src/Document.zig +++ b/src/plugins/code/src/Document.zig @@ -3,9 +3,9 @@ //! 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 internal = @import("../code.zig"); +const dvui = internal.dvui; +const sdk = internal.sdk; const is_wasm = builtin.target.cpu.arch == .wasm32; @@ -29,14 +29,14 @@ 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(); + const gpa = sdk.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); var doc = Document{ - .id = Globals.host.allocDocId(), + .id = sdk.host().allocDocId(), .path = path_copy, .text = text, }; @@ -52,14 +52,14 @@ pub fn refreshLineCount(self: *Document) void { /// 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 gpa = sdk.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(); + const gpa = sdk.allocator(); gpa.free(self.path); self.text.deinit(gpa); } diff --git a/src/plugins/code/src/Globals.zig b/src/plugins/code/src/Globals.zig deleted file mode 100644 index 5a95fbf0..00000000 --- a/src/plugins/code/src/Globals.zig +++ /dev/null @@ -1,32 +0,0 @@ -//! 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 core = code.core; -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.*; - core.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 index 709cac28..db4bb32e 100644 --- a/src/plugins/code/src/State.zig +++ b/src/plugins/code/src/State.zig @@ -1,10 +1,6 @@ -//! 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. +//! Code plugin runtime state: open text document registry. const std = @import("std"); -const code = @import("../code.zig"); -const sdk = code.sdk; +const sdk = @import("sdk"); const Document = @import("Document.zig"); const State = @This(); diff --git a/src/plugins/code/src/SyntaxHighlight.zig b/src/plugins/code/src/SyntaxHighlight.zig index 289ee600..dc16e9d2 100644 --- a/src/plugins/code/src/SyntaxHighlight.zig +++ b/src/plugins/code/src/SyntaxHighlight.zig @@ -1,7 +1,7 @@ //! Tree-sitter syntax highlighting via dvui's built-in TextEntry support. const std = @import("std"); -const code = @import("../code.zig"); -const dvui = code.dvui; +const internal = @import("../code.zig"); +const dvui = internal.dvui; const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); const SyntaxHighlight = @This(); diff --git a/src/plugins/code/src/plugin.zig b/src/plugins/code/src/plugin.zig index 8557b028..3b9f26c5 100644 --- a/src/plugins/code/src/plugin.zig +++ b/src/plugins/code/src/plugin.zig @@ -2,15 +2,20 @@ //! 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 CodeEditor = code.CodeEditor; +const internal = @import("../code.zig"); +const sdk = internal.sdk; +const dvui = internal.dvui; +const State = internal.State; +const Document = internal.Document; +const CodeEditor = internal.CodeEditor; const DocHandle = sdk.DocHandle; +pub const manifest = sdk.PluginManifest{ + .id = "code", + .name = "Code", + .version = .{ .major = 0, .minor = 1, .patch = 0 }, +}; + var plugin: sdk.Plugin = .{ .state = undefined, .vtable = &vtable, @@ -52,9 +57,18 @@ const vtable: sdk.Plugin.VTable = .{ .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename, }; +comptime { + sdk.Plugin.assertEditorVTable(vtable); +} + 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); + const gpa = host.allocator; + + const st = try gpa.create(State); + errdefer gpa.destroy(st); + st.* = .{}; + plugin.state = @ptrCast(st); + try host.registerPlugin(&plugin); } @@ -65,7 +79,9 @@ pub fn pluginPtr() *sdk.Plugin { fn deinit(state: *anyopaque) void { const st: *State = @ptrCast(@alignCast(state)); - st.deinit(Globals.allocator()); + const gpa = sdk.allocator(); + st.deinit(gpa); + gpa.destroy(st); } // ---- file type ownership ----------------------------------------------------- @@ -87,10 +103,10 @@ 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); + try sdk.document.loadPathInto(Document, path, docBuf(out_doc)); } fn loadDocumentFromBytes(_: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void { - docBuf(out_doc).* = try Document.fromBytes(path, bytes); + try sdk.document.loadBytesInto(Document, path, bytes, docBuf(out_doc)); } fn setDocumentGroupingOnBuffer(_: *anyopaque, doc: *anyopaque, grouping: u64) void { docBuf(doc).grouping = grouping; @@ -107,7 +123,7 @@ fn deinitDocumentBuffer(_: *anyopaque, doc: *anyopaque) void { 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.*); + try st.docs.put(sdk.allocator(), doc.id, doc.*); return st.docs.getPtr(doc.id).?; } fn documentPtr(state: *anyopaque, id: u64) ?*anyopaque { @@ -136,7 +152,7 @@ fn documentPath(_: *anyopaque, handle: DocHandle) []const u8 { } fn setDocumentPath(_: *anyopaque, handle: DocHandle, path: []const u8) anyerror!void { const doc = docFrom(handle) orelse return error.DocumentNotFound; - const gpa = Globals.allocator(); + const gpa = sdk.allocator(); const new_path = try gpa.dupe(u8, path); gpa.free(doc.path); doc.path = new_path; @@ -155,7 +171,7 @@ fn documentHasRecognizedSaveExtension(_: *anyopaque, _: DocHandle) bool { fn drawDocument(_: *anyopaque, handle: DocHandle) anyerror!void { const doc = docFrom(handle) orelse return; - if (try CodeEditor.draw(doc, handle.id, Globals.allocator())) { + if (try CodeEditor.draw(doc, handle.id, sdk.allocator())) { doc.dirty = true; } } @@ -180,5 +196,6 @@ fn docBuf(buf: *anyopaque) *Document { return @ptrCast(@alignCast(buf)); } fn docFrom(handle: DocHandle) ?*Document { - return Globals.state.docById(handle.id); + const st: *State = @ptrCast(@alignCast(plugin.state)); + return st.docById(handle.id); } diff --git a/src/plugins/code/src/widgets/TextEntryWidget.zig b/src/plugins/code/src/widgets/TextEntryWidget.zig index b3397e68..3f64d4f1 100644 --- a/src/plugins/code/src/widgets/TextEntryWidget.zig +++ b/src/plugins/code/src/widgets/TextEntryWidget.zig @@ -2,8 +2,8 @@ //! tree-sitter predicate filtering, query error fallback, optional focus ring. const builtin = @import("builtin"); const std = @import("std"); -const code = @import("../../code.zig"); -const dvui = code.dvui; +const internal = @import("../../code.zig"); +const dvui = internal.dvui; const Event = dvui.Event; const Options = dvui.Options; diff --git a/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig b/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig index e1ddd0c8..3f2d258b 100644 --- a/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig +++ b/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig @@ -1,7 +1,7 @@ //! Evaluate standard tree-sitter query text predicates (#eq?, #match?, #lua-match?, #any-of?). const std = @import("std"); -const code = @import("../../code.zig"); -const dvui = code.dvui; +const internal = @import("../../code.zig"); +const dvui = internal.dvui; const c = dvui.c; diff --git a/src/plugins/code/static/integration.zig b/src/plugins/code/static/integration.zig new file mode 100644 index 00000000..8c7fecc0 --- /dev/null +++ b/src/plugins/code/static/integration.zig @@ -0,0 +1,59 @@ +//! Code plugin — fizzy-internal static-embed + bundled-dylib module graph. +//! Runs only from the fizzy build root, so paths are single fizzy-relative literals. +const std = @import("std"); +const helpers = @import("../../shared/build/helpers.zig"); + +pub const id = "code"; +pub const installDylib = helpers.installDylib; + +const module_path = "src/plugins/code/code.zig"; +const dylib_path = "src/plugins/code/root.zig"; + +pub const ModuleImports = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + proxy_bridge: ?*std.Build.Module = null, +}; + +fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { + module.addImport("dvui", imports.dvui); + module.addImport("core", imports.core); + module.addImport("sdk", imports.sdk); + if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); +} + +/// Static `@import("code")` module for exe / web / tests. +pub fn addStaticModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = helpers.addStaticModule(b, .{ + .import_name = id, + .root_source_file = b.path(module_path), + .target = target, + .optimize = optimize, + }, consumer); + applyImports(mod, imports); + return mod; +} + +/// Native dynamic library bundled beside the app (`code.dylib` / `.dll` / `.so`). +pub fn addDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, +) *std.Build.Step.Compile { + const lib = helpers.addDylib(b, .{ + .name = id, + .root_source_file = b.path(dylib_path), + .target = target, + .optimize = optimize, + }); + applyImports(lib.root_module, imports); + return lib; +} diff --git a/src/plugins/example/build.zig b/src/plugins/example/build.zig new file mode 100644 index 00000000..d84c11fc --- /dev/null +++ b/src/plugins/example/build.zig @@ -0,0 +1,19 @@ +//! Standalone build for the example plugin — the canonical third-party shape, and the simplest +//! possible one: declare `fizzy`, call `fizzy.plugin.create` (defaults its root to `root.zig`), +//! then `fizzy.plugin.install`. `cd src/plugins/example && zig build` produces +//! `example.`. Copy this for a new pure-Zig plugin. See docs/PLUGINS.md. +const std = @import("std"); +const fizzy = @import("fizzy"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const lib = fizzy.plugin.create(b, .{ + .name = "example", + .target = target, + .optimize = optimize, + .root_source_file = b.path("root.zig"), + }); + fizzy.plugin.install(b, lib, .{}); +} diff --git a/src/plugins/example/build.zig.zon b/src/plugins/example/build.zig.zon new file mode 100644 index 00000000..77821601 --- /dev/null +++ b/src/plugins/example/build.zig.zon @@ -0,0 +1,19 @@ +.{ + .name = .example, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "root.zig", + "example.zig", + "src", + "static", + }, + .fingerprint = 0x6eec9b9f328e055f, + .dependencies = .{ + .fizzy = .{ + .path = "../../..", + }, + }, +} diff --git a/src/plugins/example/example.zig b/src/plugins/example/example.zig new file mode 100644 index 00000000..b1428978 --- /dev/null +++ b/src/plugins/example/example.zig @@ -0,0 +1,14 @@ +//! Example plugin root module **and** intra-plugin import hub — the conventional `.zig`. +//! +//! - The shell resolves `@import("example")` to this file when the plugin is compiled into the +//! app (static embed); `example.plugin` is its entry. +//! - Files under `src/` import it as `../example.zig` for shared deps (`sdk`/`dvui`) and types. +//! +//! A minimal plugin keeps this tiny — it grows into the plugin's shared namespace as `src/` +//! gains files. It must sit at the plugin root (a Zig module can't import above its root file's +//! directory). The build-side static-embed glue lives in `static/`. +pub const sdk = @import("sdk"); +pub const dvui = @import("dvui"); + +pub const plugin = @import("src/plugin.zig"); +pub const State = @import("src/State.zig"); diff --git a/src/plugins/example/root.zig b/src/plugins/example/root.zig new file mode 100644 index 00000000..49583a65 --- /dev/null +++ b/src/plugins/example/root.zig @@ -0,0 +1,8 @@ +//! Dylib entry for the example plugin — the canonical third-party shape (identical to +//! `src/plugins/root.zig`): one `exportEntry` call wired to `src/plugin.zig`. Copy this verbatim +//! into a new plugin; you never edit it. +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} diff --git a/src/plugins/example/src/State.zig b/src/plugins/example/src/State.zig new file mode 100644 index 00000000..79a6b72f --- /dev/null +++ b/src/plugins/example/src/State.zig @@ -0,0 +1,11 @@ +//! Example plugin state. A plugin owns whatever state it needs; the host injects only the +//! allocator and `*Host` (read via `sdk.allocator()` / `sdk.host()`), so this is just a plain +//! struct the plugin holds. Trivial here — a real plugin keeps documents, caches, settings, etc. +const std = @import("std"); + +clicks: u64 = 0, + +pub fn deinit(self: *@This(), gpa: std.mem.Allocator) void { + _ = self; + _ = gpa; +} diff --git a/src/plugins/example/src/plugin.zig b/src/plugins/example/src/plugin.zig new file mode 100644 index 00000000..7b7305f0 --- /dev/null +++ b/src/plugins/example/src/plugin.zig @@ -0,0 +1,80 @@ +//! Example plugin — the canonical, minimal Fizzy plugin and the copy-me template for new +//! plugins. It registers a single sidebar view that renders a greeting and a click counter: +//! the smallest useful shape, namely identity + `register` + one `Host.register*` contribution +//! + plugin-owned state. The host injects only the allocator and `*Host` (read through +//! `sdk.allocator()` / `sdk.host()`), so there is no storage file to write. +//! +//! This plugin implements no document hooks — it is a "shell" plugin (contributes a pane), not +//! an "editor" plugin (opens/saves/draws files). For the editor shape, see the `code` plugin. +//! +//! To start a new plugin: copy this folder, rename the id/name, and implement your feature in +//! `src/plugin.zig`. See docs/PLUGINS.md. +const std = @import("std"); +// Shared deps + sibling types come through the plugin's `.zig` hub (`../example.zig`), +// the conventional `@import("")` namespace. A single-file plugin could import `sdk` +// and `dvui` directly; using the hub is what scales as `src/` grows. +const example = @import("../example.zig"); +const sdk = example.sdk; +const dvui = example.dvui; +const State = example.State; + +/// Identity + versions embedded in the dylib (and read by the host on load). +pub const manifest = sdk.PluginManifest{ + .id = "example", + .name = "Example", + .version = .{ .major = 0, .minor = 1, .patch = 0 }, +}; + +/// Stable, plugin-namespaced contribution id. +const view_hello = "example.hello"; + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "example", + .display_name = "Example", +}; + +/// Only the hooks this plugin needs; every other vtable field stays `null`. +const vtable: sdk.Plugin.VTable = .{ + .deinit = deinit, +}; + +/// The plugin's own singleton state — just a variable it owns. The SDK holds gpa/host. +var plugin_state: State = .{}; + +/// Entry point the host calls once at startup (static) or after dlopen (dynamic). Wire state, +/// register the plugin, then add any sidebar/bottom/center/menu/settings contributions. +pub fn register(host: *sdk.Host) !void { + plugin.state = @ptrCast(&plugin_state); + try host.registerPlugin(&plugin); + try host.registerSidebarView(.{ + .id = view_hello, + .owner = &plugin, + .icon = dvui.entypo.rocket, + .title = "Example", + .draw = drawHello, + }); +} + +/// Stable `*Plugin` for constructing `DocHandle.owner` / lookups (unused here, but part of the +/// conventional plugin surface). +pub fn pluginPtr() *sdk.Plugin { + return &plugin; +} + +fn deinit(_: *anyopaque) void { + plugin_state.deinit(sdk.allocator()); +} + +/// Fills the left pane while this sidebar view is active. +fn drawHello(_: ?*anyopaque) anyerror!void { + var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .margin = .all(8) }); + defer box.deinit(); + + dvui.label(@src(), "Hello from the example plugin!", .{}, .{}); + dvui.label(@src(), "Clicks: {d}", .{plugin_state.clicks}, .{}); + if (dvui.button(@src(), "Click me", .{}, .{ .expand = .horizontal })) { + plugin_state.clicks += 1; + } +} diff --git a/src/plugins/example/static/integration.zig b/src/plugins/example/static/integration.zig new file mode 100644 index 00000000..817906c8 --- /dev/null +++ b/src/plugins/example/static/integration.zig @@ -0,0 +1,59 @@ +//! Example plugin — fizzy-internal static-embed + bundled-dylib module graph. +//! Runs only from the fizzy build root, so paths are single fizzy-relative literals. +const std = @import("std"); +const helpers = @import("../../shared/build/helpers.zig"); + +pub const id = "example"; +pub const installDylib = helpers.installDylib; + +const module_path = "src/plugins/example/example.zig"; +const dylib_path = "src/plugins/example/root.zig"; + +pub const ModuleImports = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + proxy_bridge: ?*std.Build.Module = null, +}; + +fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { + module.addImport("dvui", imports.dvui); + module.addImport("core", imports.core); + module.addImport("sdk", imports.sdk); + if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); +} + +/// Static `@import("example")` module for exe / web / tests. +pub fn addStaticModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = helpers.addStaticModule(b, .{ + .import_name = id, + .root_source_file = b.path(module_path), + .target = target, + .optimize = optimize, + }, consumer); + applyImports(mod, imports); + return mod; +} + +/// Native dynamic library bundled beside the app (`example.dylib` / `.dll` / `.so`). +pub fn addDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, +) *std.Build.Step.Compile { + const lib = helpers.addDylib(b, .{ + .name = id, + .root_source_file = b.path(dylib_path), + .target = target, + .optimize = optimize, + }); + applyImports(lib.root_module, imports); + return lib; +} diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig deleted file mode 100644 index a09430aa..00000000 --- a/src/plugins/pixelart/dylib.zig +++ /dev/null @@ -1,43 +0,0 @@ -//! 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. -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); -} - -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, - 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/pixelart.zig b/src/plugins/pixelart/pixelart.zig deleted file mode 100644 index 817a67cb..00000000 --- a/src/plugins/pixelart/pixelart.zig +++ /dev/null @@ -1,55 +0,0 @@ -//! 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 -//! and shared plugin types. The compile-time module root for the build is `module.zig` -//! (`@import("pixelart")`); shell code imports the module directly. -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 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"); - 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/src/Colors.zig b/src/plugins/pixelart/src/Colors.zig deleted file mode 100644 index 6fc49554..00000000 --- a/src/plugins/pixelart/src/Colors.zig +++ /dev/null @@ -1,11 +0,0 @@ -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/Globals.zig b/src/plugins/pixelart/src/Globals.zig deleted file mode 100644 index 3c4de8a8..00000000 --- a/src/plugins/pixelart/src/Globals.zig +++ /dev/null @@ -1,30 +0,0 @@ -//! 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`. -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; -pub var packer: *Packer = 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, - state_ptr: ?*State, - packer_ptr: ?*Packer, -) void { - 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/pixi/build.zig b/src/plugins/pixi/build.zig new file mode 100644 index 00000000..fa97a521 --- /dev/null +++ b/src/plugins/pixi/build.zig @@ -0,0 +1,57 @@ +//! Standalone build for the pixi plugin — the canonical third-party shape. +//! `cd src/plugins/pixi && zig build` produces `pixi.`. Pixi has vendored C +//! deps (stbi, msf_gif, zip) and a packed `assets` module, so its `build.zig` attaches a few +//! extra modules onto the `fizzy.plugin.create` lib — exactly how any third-party plugin with +//! native deps would. +//! +//! `zstbi`/`msf_gif` are built with the shared `fizzy.plugin.addCModule` helper (the same one a +//! third-party C plugin uses), so the build logic isn't duplicated — only the plugin-relative +//! paths are supplied here. `zip` is wired inline (its `zip.zig` module + C compiled into this +//! dylib); it isn't shared with fizzy's own build graph, so there's no two-modules collision. +const std = @import("std"); +const fizzy = @import("fizzy"); +const assetpack = @import("assetpack"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const lib = fizzy.plugin.create(b, .{ + .name = "pixi", + .target = target, + .optimize = optimize, + .root_source_file = b.path("root.zig"), + }); + + // Packed assets (the repo's assets/ tree, three levels up from this plugin). + lib.root_module.addImport("assets", assetpack.pack(b, b.path("../../../assets"), .{})); + + // zstbi (image decode/resize + rect pack) + msf_gif (GIF export) via the shared helper. + lib.root_module.addImport("zstbi", fizzy.plugin.addCModule(b, .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/deps/stbi/zstbi.zig"), + .c_sources = &.{.{ .file = b.path("src/deps/stbi/zstbi.c") }}, + })); + lib.root_module.addImport("msf_gif", fizzy.plugin.addCModule(b, .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/deps/msf_gif/msf_gif.zig"), + .c_sources = &.{.{ .file = b.path("src/deps/msf_gif/msf_gif.c") }}, + })); + + // zip (atlas/project archives). + lib.root_module.addImport("zip", b.createModule(.{ .root_source_file = b.path("src/deps/zip/zip.zig") })); + lib.root_module.link_libc = true; + lib.root_module.addIncludePath(b.path("src/deps/zip/src")); + lib.root_module.addCSourceFile(.{ + .file = b.path("src/deps/zip/src/zip.c"), + .flags = &.{"-fno-sanitize=undefined"}, + }); + + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + lib.root_module.addImport("icons", dep.module("icons")); + } + + fizzy.plugin.install(b, lib, .{}); +} diff --git a/src/plugins/pixi/build.zig.zon b/src/plugins/pixi/build.zig.zon new file mode 100644 index 00000000..24c0e25e --- /dev/null +++ b/src/plugins/pixi/build.zig.zon @@ -0,0 +1,28 @@ +.{ + .name = .pixi, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "root.zig", + "pixi.zig", + "src", + "static", + }, + .fingerprint = 0xdef5a52dbae70684, + .dependencies = .{ + .fizzy = .{ + .path = "../../..", + }, + .assetpack = .{ + .url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz", + .hash = "assetpack-0.2.0-5DA2d1ZkAADJanNVdWrUBOGMhOzUENhrUiqXcHADxY2x", + }, + .icons = .{ + .url = "https://github.com/foxnne/zig-lib-icons/archive/db034786a1286ab28dc35aba534c098aa4f1a3aa.tar.gz", + .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_", + .lazy = true, + }, + }, +} diff --git a/src/plugins/pixelart/module.zig b/src/plugins/pixi/pixi.zig similarity index 61% rename from src/plugins/pixelart/module.zig rename to src/plugins/pixi/pixi.zig index a7e17fc2..3bb94b55 100644 --- a/src/plugins/pixelart/module.zig +++ b/src/plugins/pixi/pixi.zig @@ -1,10 +1,36 @@ -//! Pixel-art plugin compile-time module root. +//! Pixi plugin root module **and** intra-plugin import hub. //! -//! 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; +//! - The shell resolves `@import("pixi")` to this file when the plugin is compiled into the app +//! (static embed) and reaches its public surface here. +//! - Files under `src/` import it as `../pixi.zig` for the shared deps (`sdk`/`core`/`dvui` + +//! core conveniences) and sibling types — the conventional `.zig` namespace. +//! +//! It must sit at the plugin root: a Zig module cannot import files above its root file's +//! directory, so this has to be beside `src/` to re-export from it. The build-side static-embed +//! glue lives in `static/`. +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; + +/// On-disk file format version stamp (kept in sync with `fizzy.version`). +pub const version: std.SemanticVersion = .{ .major = 0, .minor = 2, .patch = 0 }; +/// Layer rename buffer size (was `Editor.Constants.max_name_len`). +pub const max_name_len = 256; + +pub const plugin = @import("src/plugin.zig"); +pub const runtime = @import("src/runtime.zig"); + pub const State = @import("src/State.zig"); pub const Settings = @import("src/Settings.zig"); pub const Docs = @import("src/Docs.zig"); @@ -14,7 +40,15 @@ 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 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"); + +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 dialogs = struct { pub const NewFile = @import("src/dialogs/NewFile.zig"); @@ -34,18 +68,6 @@ pub const widgets = struct { 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"); - -/// 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"); diff --git a/src/plugins/pixi/root.zig b/src/plugins/pixi/root.zig new file mode 100644 index 00000000..08ff548e --- /dev/null +++ b/src/plugins/pixi/root.zig @@ -0,0 +1,7 @@ +//! Dylib entry for the pixi plugin — canonical shape: one `exportEntry` wired to +//! `src/plugin.zig` (see `src/plugins/root.zig`). +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} diff --git a/src/plugins/pixelart/src/Animation.zig b/src/plugins/pixi/src/Animation.zig similarity index 100% rename from src/plugins/pixelart/src/Animation.zig rename to src/plugins/pixi/src/Animation.zig diff --git a/src/plugins/pixelart/src/Atlas.zig b/src/plugins/pixi/src/Atlas.zig similarity index 100% rename from src/plugins/pixelart/src/Atlas.zig rename to src/plugins/pixi/src/Atlas.zig diff --git a/src/plugins/pixelart/src/CanvasData.zig b/src/plugins/pixi/src/CanvasData.zig similarity index 96% rename from src/plugins/pixelart/src/CanvasData.zig rename to src/plugins/pixi/src/CanvasData.zig index 0d869641..e4c26909 100644 --- a/src/plugins/pixelart/src/CanvasData.zig +++ b/src/plugins/pixi/src/CanvasData.zig @@ -15,10 +15,13 @@ 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; +const Clipboard = @import("clipboard.zig"); +const TransformOp = @import("transform_op.zig"); +const DocLifecycle = @import("doc_lifecycle.zig"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); -const File = pixelart.internal.File; +const File = pixi_mod.internal.File; const CanvasData = @This(); @@ -50,8 +53,8 @@ edit_pill_expanded: bool = false, pub fn init(grouping: u64) CanvasData { return .{ - .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", + .columns_drag_name = std.fmt.allocPrint(runtime.allocator(), "column_drag_{d}", .{grouping}) catch "column_drag", + .rows_drag_name = std.fmt.allocPrint(runtime.allocator(), "row_drag_{d}", .{grouping}) catch "row_drag", }; } @@ -62,7 +65,7 @@ pub fn deinit(_: *CanvasData) void {} /// Per-pane chrome for `grouping`, lazily allocated on first document draw. pub fn forGrouping(grouping: u64) *CanvasData { - return Globals.state.canvasForGrouping(grouping); + return runtime.state().canvasForGrouping(grouping); } pub const RulerOrientation = enum { @@ -80,15 +83,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 + Globals.state.settings.ruler_padding; + const base_ruler_size = largest_label_size.w + runtime.state().settings.ruler_padding; const ruler_thickness: f32 = switch (orientation) { .horizontal => blk: { - self.horizontal_ruler_height = font.textSize("M").h + Globals.state.settings.ruler_padding; + self.horizontal_ruler_height = font.textSize("M").h + runtime.state().settings.ruler_padding; break :blk self.horizontal_ruler_height; }, .vertical => blk: { - self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + Globals.state.settings.ruler_padding); + self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + runtime.state().settings.ruler_padding); break :blk self.vertical_ruler_width; }, }; @@ -209,7 +212,7 @@ fn drawRulerContent( .vertical => self.rows_drag_name, }; - var reorder = pixelart.core.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ + var reorder = pixi_mod.core.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ .expand = .both, .margin = dvui.Rect.all(0), .padding = dvui.Rect.all(0), @@ -259,7 +262,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: pixelart.core.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) { + const reorder_mode: pixi_mod.core.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) { .horizontal => .any_y, .vertical => .any_x, }; @@ -297,7 +300,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 (pixelart.core.dvui.hovered(reorderable.data())) { + if (pixi_mod.core.dvui.hovered(reorderable.data())) { button_color = dvui.themeGet().color(.control, .fill_hover); dvui.cursorSet(.hand); } @@ -572,7 +575,7 @@ pub fn drawRulerLabel(_: *CanvasData, options: TextLabelOptions) void { else font.textSize(label).scale(natural, dvui.Size.Physical); - const padding = Globals.state.settings.ruler_padding * natural; + const padding = runtime.state().settings.ruler_padding * natural; var label_rect = rect; @@ -768,14 +771,10 @@ 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 })) { - Globals.state.host.cancel() catch { - dvui.log.err("Failed to cancel transform", .{}); - }; + DocLifecycle.cancelEdit(runtime.state()); } if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) { - Globals.state.host.accept() catch { - dvui.log.err("Failed to accept transform", .{}); - }; + DocLifecycle.acceptEdit(runtime.state()); } } } @@ -787,7 +786,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 = Globals.state.docs.activeFile(Globals.state.host) orelse return; + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; const button_size: f32 = 36; const button_gap: f32 = 6; @@ -845,8 +844,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 (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 ruler_top: f32 = if (runtime.state().settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (runtime.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, @@ -1022,12 +1021,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 => Globals.state.host.save() catch { + .save => runtime.state().host.save() catch { dvui.log.err("Failed to save", .{}); }, .exportd => { // Open the Export dialog (same configuration the `export` keybind uses). - var mutex = pixelart.core.dvui.dialog(@src(), .{ + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ .displayFn = Export.dialog, .callafterFn = Export.callAfter, .title = "Export...", @@ -1046,17 +1045,17 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { .redo => file.history.undoRedo(file, .redo) catch { dvui.log.err("Failed to redo", .{}); }, - .copy => Globals.state.host.copy() catch { + .copy => Clipboard.copy(runtime.state()) catch { dvui.log.err("Failed to copy", .{}); }, - .paste => Globals.state.host.paste() catch { + .paste => Clipboard.paste(runtime.state()) catch { dvui.log.err("Failed to paste", .{}); }, - .transform => Globals.state.host.transform() catch { + .transform => TransformOp.begin(runtime.state()) catch { dvui.log.err("Failed to start transform", .{}); }, .grid_layout => { - if (Globals.state.host.activeDoc()) |doc| GridLayout.request(doc.id); + if (runtime.state().host.activeDoc()) |doc| GridLayout.request(doc.id); }, } } @@ -1071,7 +1070,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 = Globals.state.docs.activeFile(Globals.state.host) orelse return; + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; const pill_button_size: f32 = 36; const pill_padding: f32 = 6; @@ -1085,8 +1084,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 (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 ruler_top: f32 = if (runtime.state().settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (runtime.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/pixi/src/Colors.zig b/src/plugins/pixi/src/Colors.zig new file mode 100644 index 00000000..c55951a7 --- /dev/null +++ b/src/plugins/pixi/src/Colors.zig @@ -0,0 +1,11 @@ +const std = @import("std"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); + +const Self = @This(); + +primary: [4]u8 = .{ 255, 255, 255, 255 }, +secondary: [4]u8 = .{ 0, 0, 0, 255 }, +height: u8 = 0, +palette: ?pixi_mod.internal.Palette = null, +file_tree_palette: ?pixi_mod.internal.Palette = null, diff --git a/src/plugins/pixelart/src/Docs.zig b/src/plugins/pixi/src/Docs.zig similarity index 87% rename from src/plugins/pixelart/src/Docs.zig rename to src/plugins/pixi/src/Docs.zig index 7a351b04..19d845fa 100644 --- a/src/plugins/pixelart/src/Docs.zig +++ b/src/plugins/pixi/src/Docs.zig @@ -3,10 +3,10 @@ //! 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 pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const sdk = pixi_mod.sdk; +const Internal = pixi_mod.internal; const Docs = @This(); diff --git a/src/plugins/pixelart/src/File.zig b/src/plugins/pixi/src/File.zig similarity index 100% rename from src/plugins/pixelart/src/File.zig rename to src/plugins/pixi/src/File.zig diff --git a/src/plugins/pixelart/src/LDTKTileset.zig b/src/plugins/pixi/src/LDTKTileset.zig similarity index 100% rename from src/plugins/pixelart/src/LDTKTileset.zig rename to src/plugins/pixi/src/LDTKTileset.zig diff --git a/src/plugins/pixelart/src/Layer.zig b/src/plugins/pixi/src/Layer.zig similarity index 100% rename from src/plugins/pixelart/src/Layer.zig rename to src/plugins/pixi/src/Layer.zig diff --git a/src/plugins/pixelart/src/PackJob.zig b/src/plugins/pixi/src/PackJob.zig similarity index 93% rename from src/plugins/pixelart/src/PackJob.zig rename to src/plugins/pixi/src/PackJob.zig index e3583213..43d4ee5d 100644 --- a/src/plugins/pixelart/src/PackJob.zig +++ b/src/plugins/pixi/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 `Globals.packer.atlas` via `Editor.processPackJob` once `done` is +//! main thread swaps it into `runtime.packer().atlas` via `Editor.processPackJob` once `done` is //! published. //! //! Ownership / threading model: @@ -19,10 +19,10 @@ const std = @import("std"); const dvui = @import("dvui"); const zstbi = @import("zstbi"); -const perf = pixelart.perf; +const perf = pixi_mod.perf; const reduce_alg = @import("algorithms/reduce.zig"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); const PackJob = @This(); @@ -61,7 +61,7 @@ pub const PackSprite = struct { pub const PackAnimation = struct { name: []u8, - frames: []pixelart.Animation.Frame, + frames: []pixi_mod.Animation.Frame, fn deinit(self: *PackAnimation, allocator: std.mem.Allocator) void { allocator.free(self.name); @@ -82,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 pixelart.internal.File) !PackFile { + pub fn fromOpenFile(allocator: std.mem.Allocator, file: *const pixi_mod.internal.File) !PackFile { const src_layers = file.layers.slice(); var layers = try allocator.alloc(PackLayer, src_layers.len); @@ -98,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 = pixelart.image.pixels(layer.source); + const src_pixels = pixi_mod.image.pixels(layer.source); const name_copy = try allocator.dupe(u8, layer.name); errdefer allocator.free(name_copy); @@ -136,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(pixelart.Animation.Frame, anim.frames); + const frames_copy = try allocator.dupe(pixi_mod.Animation.Frame, anim.frames); anims[a] = .{ .name = name_copy, .frames = frames_copy }; anims_initialized = a + 1; } @@ -156,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 pixelart.internal.File.fromPath(path); + const maybe_file = try pixi_mod.internal.File.fromPath(path); var file = maybe_file orelse return null; defer file.deinit(); return try PackFile.fromOpenFile(allocator, &file); @@ -214,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: ?pixelart.internal.Atlas = null, +result_atlas: ?pixi_mod.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). @@ -239,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 `Globals.packer.atlas`; in that case the new owner is responsible for the + // the atlas into `runtime.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(pixelart.image.bytes(atlas.source)); + a.free(pixi_mod.image.bytes(atlas.source)); for (atlas.data.animations) |*anim| a.free(anim.name); a.free(atlas.data.animations); a.free(atlas.data.sprites); @@ -295,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 `Globals.allocator()` + // Worker-local scratch. The final atlas allocations are made through `runtime.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(Globals.allocator()) catch |e| { + const work = WorkerState.init(runtime.allocator()) catch |e| { job.err = e; job.phase.store(@intFromEnum(Phase.failed), .release); return; @@ -342,7 +342,7 @@ pub fn workerMain(job: *PackJob) void { return; } job.phase.store(@intFromEnum(Phase.loading), .release); - const maybe_pf = PackFile.fromPath(Globals.allocator(), path) catch |e| { + const maybe_pf = PackFile.fromPath(runtime.allocator(), path) catch |e| { job.err = e; job.phase.store(@intFromEnum(Phase.failed), .release); return; @@ -404,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. - 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); + runtime.allocator().free(pixi_mod.image.bytes(atlas.source)); + for (atlas.data.animations) |*anim| runtime.allocator().free(anim.name); + runtime.allocator().free(atlas.data.animations); + runtime.allocator().free(atlas.data.sprites); job.phase.store(@intFromEnum(Phase.cancelled), .release); return; } @@ -441,7 +441,7 @@ const WorkerSprite = struct { const WorkerAnimation = struct { name: []u8, - frames: []pixelart.Animation.Frame, + frames: []pixi_mod.Animation.Frame, fn deinit(self: *WorkerAnimation, allocator: std.mem.Allocator) void { allocator.free(self.name); @@ -591,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(pixelart.Animation.Frame, anim.frames.len); + const frames = try self.allocator.alloc(pixi_mod.Animation.Frame, anim.frames.len); for (frames, anim.frames, 0..) |*current_frame, src_frame, i| { current_frame.* = .{ .sprite_index = new_sprite_index + i, @@ -669,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) !pixelart.internal.Atlas { + fn buildAtlas(self: *WorkerState, tex_size: [2]u16) !pixi_mod.internal.Atlas { const num_pixels: usize = @as(usize, tex_size[0]) * @as(usize, tex_size[1]); - const pixels = try Globals.allocator().alloc([4]u8, num_pixels); - errdefer Globals.allocator().free(pixels); + const pixels = try runtime.allocator().alloc([4]u8, num_pixels); + errdefer runtime.allocator().free(pixels); @memset(pixels, .{ 0, 0, 0, 0 }); const tex_w: usize = tex_size[0]; @@ -699,23 +699,23 @@ const WorkerState = struct { } } - const sprites_out = try Globals.allocator().alloc(pixelart.Atlas.Sprite, self.sprites.items.len); - errdefer Globals.allocator().free(sprites_out); + const sprites_out = try runtime.allocator().alloc(pixi_mod.Atlas.Sprite, self.sprites.items.len); + errdefer runtime.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 Globals.allocator().alloc(pixelart.Animation, self.animations.items.len); + const animations_out = try runtime.allocator().alloc(pixi_mod.Animation, self.animations.items.len); var anims_initialized: usize = 0; errdefer { - for (animations_out[0..anims_initialized]) |*anim| Globals.allocator().free(anim.name); - Globals.allocator().free(animations_out); + for (animations_out[0..anims_initialized]) |*anim| runtime.allocator().free(anim.name); + runtime.allocator().free(animations_out); } for (animations_out, self.animations.items) |*dst, src| { - 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); + dst.name = try runtime.allocator().dupe(u8, src.name); + errdefer runtime.allocator().free(dst.name); + dst.frames = try runtime.allocator().dupe(pixi_mod.Animation.Frame, src.frames); anims_initialized += 1; } diff --git a/src/plugins/pixelart/src/Packer.zig b/src/plugins/pixi/src/Packer.zig similarity index 82% rename from src/plugins/pixelart/src/Packer.zig rename to src/plugins/pixi/src/Packer.zig index 7af26053..11db6f3f 100644 --- a/src/plugins/pixelart/src/Packer.zig +++ b/src/plugins/pixi/src/Packer.zig @@ -1,8 +1,8 @@ const std = @import("std"); const zstbi = @import("zstbi"); const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); pub const LDTKTileset = @import("LDTKTileset.zig"); @@ -32,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(pixelart.Animation), +animations: std.array_list.Managed(pixi_mod.Animation), id_counter: u32 = 0, placeholder: Image, contains_height: bool = false, -open_files: std.array_list.Managed(pixelart.internal.File), +open_files: std.array_list.Managed(pixi_mod.internal.File), target: PackTarget = .project, //camera: fizzy.gfx.Camera = .{}, -atlas: ?pixelart.internal.Atlas = null, +atlas: ?pixi_mod.internal.Atlas = null, -/// Monotonic time (`pixelart.perf.nanoTimestamp`) when the current in-memory atlas was last installed. +/// Monotonic time (`pixi_mod.perf.nanoTimestamp`) when the current in-memory atlas was last installed. last_packed_at_ns: ?i128 = null, ldtk: bool = false, @@ -62,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(pixelart.Animation).init(allocator), - .open_files = std.array_list.Managed(pixelart.internal.File).init(allocator), + .animations = std.array_list.Managed(pixi_mod.Animation).init(allocator), + .open_files = std.array_list.Managed(pixi_mod.internal.File).init(allocator), .placeholder = .{ .width = 2, .height = 2, .pixels = pixels }, .ldtk_tilesets = std.array_list.Managed(LDTKTileset).init(allocator), }; @@ -76,7 +76,7 @@ pub fn newId(self: *Packer) u32 { } pub fn deinit(self: *Packer) void { - Globals.allocator().free(self.placeholder.pixels); + runtime.allocator().free(self.placeholder.pixels); self.clearAndFree(); self.sprites.deinit(); self.frames.deinit(); @@ -86,17 +86,17 @@ pub fn deinit(self: *Packer) void { pub fn clearAndFree(self: *Packer) void { for (self.sprites.items) |*sprite| { - sprite.deinit(Globals.allocator()); + sprite.deinit(runtime.allocator()); } for (self.animations.items) |*animation| { - Globals.allocator().free(animation.name); + runtime.allocator().free(animation.name); } for (self.ldtk_tilesets.items) |*tileset| { for (tileset.layer_paths) |path| { - Globals.allocator().free(path); + runtime.allocator().free(path); } - Globals.allocator().free(tileset.sprites); - Globals.allocator().free(tileset.layer_paths); + runtime.allocator().free(tileset.sprites); + runtime.allocator().free(tileset.layer_paths); } self.frames.clearAndFree(); self.sprites.clearAndFree(); @@ -110,9 +110,9 @@ pub fn clearAndFree(self: *Packer) void { self.open_files.clearAndFree(); } -pub fn append(self: *Packer, file: *pixelart.internal.File) !void { +pub fn append(self: *Packer, file: *pixi_mod.internal.File) !void { std.log.info("Appending file with sprites: {d}", .{file.sprites.slice().len}); - var layer_opt: ?pixelart.Layer = null; + var layer_opt: ?pixi_mod.Layer = null; var index: usize = 0; while (index < file.layers.slice().len) : (index += 1) { var layer = file.layers.get(index); @@ -122,7 +122,7 @@ pub fn append(self: *Packer, file: *pixelart.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 pixelart.Layer.init( + const current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else try pixi_mod.Layer.init( 0, "", file.width(), @@ -177,7 +177,7 @@ pub fn append(self: *Packer, file: *pixelart.internal.File) !void { var image: Image = .{ .width = reduced_src_width, .height = reduced_src_height, - .pixels = try Globals.allocator().alloc([4]u8, reduced_src_width * reduced_src_height), + .pixels = try runtime.allocator().alloc([4]u8, reduced_src_width * reduced_src_height), }; @memset(image.pixels, .{ 0, 0, 0, 0 }); @@ -205,13 +205,13 @@ pub fn append(self: *Packer, file: *pixelart.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 Globals.allocator().alloc(pixelart.Animation.Frame, animation.frames.len); + const frames = try runtime.allocator().alloc(pixi_mod.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(Globals.allocator(), "{s}_{s}", .{ animation.name, layer.name }), + .name = try std.fmt.allocPrint(runtime.allocator(), "{s}_{s}", .{ animation.name, layer.name }), .frames = frames, }); } @@ -250,7 +250,7 @@ pub fn append(self: *Packer, file: *pixelart.internal.File) !void { } pub fn appendProject(packer: *Packer) !void { - if (Globals.state.host.folder()) |root_directory| { + if (runtime.state().host.folder()) |root_directory| { try recurseFiles(packer, root_directory); } } @@ -266,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 (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 (pixi_mod.internal.File.isFizzyExtension(ext)) { + const abs_path = try std.fs.path.joinZ(runtime.allocator(), &.{ directory, entry.name }); + defer runtime.allocator().free(abs_path); - if (Globals.state.docs.fileFromPath(abs_path)) |file| { + if (runtime.state().docs.fileFromPath(abs_path)) |file| { try p.append(file); } else { - if (try pixelart.internal.File.fromPath(abs_path)) |file| { + if (try pixi_mod.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(Globals.allocator(), &[_][]const u8{ directory, entry.name }); - defer Globals.allocator().free(abs_path); + const abs_path = try std.fs.path.joinZ(runtime.allocator(), &[_][]const u8{ directory, entry.name }); + defer runtime.allocator().free(abs_path); try search(p, abs_path); } } @@ -296,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 pixelart.Layer.init( + var atlas_layer = try pixi_mod.Layer.init( 0, "", size[0], @@ -319,9 +319,9 @@ pub fn packAndClear(packer: *Packer) !void { } atlas_layer.invalidate(); - 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), + const atlas: pixi_mod.internal.Atlas = .{ + .sprites = try runtime.allocator().alloc(pixi_mod.internal.Atlas.Sprite, packer.sprites.items.len), + .animations = try runtime.allocator().alloc(pixi_mod.Animation, packer.animations.items.len), }; for (atlas.sprites, packer.sprites.items, packer.frames.items) |*dst, src, src_rect| { @@ -330,8 +330,8 @@ pub fn packAndClear(packer: *Packer) !void { } for (atlas.animations, packer.animations.items) |*dst, src| { - dst.name = try Globals.allocator().dupe(u8, src.name); - dst.frames = try Globals.allocator().dupe(pixelart.Animation.Frame, src.frames); + dst.name = try runtime.allocator().dupe(u8, src.name); + dst.frames = try runtime.allocator().dupe(pixi_mod.Animation.Frame, src.frames); //dst.length = src.length; // dst.start = src.start; } @@ -339,12 +339,12 @@ pub fn packAndClear(packer: *Packer) !void { if (packer.atlas) |*current_atlas| { current_atlas.deinitCheckerboardTile(); for (current_atlas.data.animations) |*animation| { - Globals.allocator().free(animation.name); + runtime.allocator().free(animation.name); } - Globals.allocator().free(current_atlas.data.sprites); - Globals.allocator().free(current_atlas.data.animations); + runtime.allocator().free(current_atlas.data.sprites); + runtime.allocator().free(current_atlas.data.animations); - Globals.allocator().free(pixelart.image.bytes(current_atlas.source)); + runtime.allocator().free(pixi_mod.image.bytes(current_atlas.source)); current_atlas.data = atlas; current_atlas.source = atlas_layer.source; @@ -357,7 +357,7 @@ pub fn packAndClear(packer: *Packer) !void { packer.atlas.?.initCheckerboardTile(); } - packer.last_packed_at_ns = pixelart.perf.nanoTimestamp(); + packer.last_packed_at_ns = pixi_mod.perf.nanoTimestamp(); packer.clearAndFree(); } } diff --git a/src/plugins/pixelart/src/Project.zig b/src/plugins/pixi/src/Project.zig similarity index 83% rename from src/plugins/pixelart/src/Project.zig rename to src/plugins/pixi/src/Project.zig index 767dc0eb..61b1666f 100644 --- a/src/plugins/pixelart/src/Project.zig +++ b/src/plugins/pixi/src/Project.zig @@ -1,8 +1,8 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); const Project = @This(); @@ -23,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 (Globals.state.host.folder()) |folder| { - const file = try std.fs.path.join(Globals.state.host.arena(), &.{ folder, ".fizproject" }); + if (runtime.state().host.folder()) |folder| { + const file = try std.fs.path.join(runtime.state().host.arena(), &.{ folder, ".fizproject" }); - if (pixelart.fs.read(allocator, dvui.io, file) catch null) |r| { + if (pixi_mod.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 }; @@ -61,12 +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 (Globals.state.host.folder()) |folder| { - const file = try std.fs.path.join(Globals.allocator(), &.{ folder, ".fizproject" }); - defer Globals.allocator().free(file); + if (runtime.state().host.folder()) |folder| { + const file = try std.fs.path.join(runtime.allocator(), &.{ folder, ".fizproject" }); + defer runtime.allocator().free(file); const options = std.json.Stringify.Options{}; - const str = try std.json.Stringify.valueAlloc(Globals.allocator(), Project{ + const str = try std.json.Stringify.valueAlloc(runtime.allocator(), Project{ .packed_atlas_output = project.packed_atlas_output, .packed_image_output = project.packed_image_output, //.packed_heightmap_output = project.packed_heightmap_output, @@ -83,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 = Globals.packer.atlas orelse return; + const atlas = runtime.packer().atlas orelse return; if (project.packed_atlas_output) |packed_atlas_output| { try atlas.save(packed_atlas_output, .data); @@ -94,7 +94,7 @@ pub fn exportAssets(project: *Project) !void { } // if (project.packed_heightmap_output) |packed_heightmap_output| { - // const path = try std.fs.path.joinZ(Globals.state.host.arena(), &.{ parent_folder, packed_heightmap_output }); + // const path = try std.fs.path.joinZ(runtime.state().host.arena(), &.{ parent_folder, packed_heightmap_output }); // try atlas.save(path, .heightmap); // } } diff --git a/src/plugins/pixelart/src/Settings.zig b/src/plugins/pixi/src/Settings.zig similarity index 97% rename from src/plugins/pixelart/src/Settings.zig rename to src/plugins/pixi/src/Settings.zig index 59f90919..95d8d155 100644 --- a/src/plugins/pixelart/src/Settings.zig +++ b/src/plugins/pixi/src/Settings.zig @@ -4,14 +4,14 @@ //! never interprets. const std = @import("std"); const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; -const sdk = pixelart.sdk; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const sdk = pixi_mod.sdk; const PixelArtSettings = @This(); /// Per-plugin settings store key (matches `plugin.id`). -pub const plugin_id = "pixelart"; +pub const plugin_id = "pixi"; pub const InputScheme = enum { auto, mouse, trackpad }; @@ -95,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 = Globals.state; + const pa = runtime.state(); var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); defer vbox.deinit(); diff --git a/src/plugins/pixelart/src/Sprite.zig b/src/plugins/pixi/src/Sprite.zig similarity index 100% rename from src/plugins/pixelart/src/Sprite.zig rename to src/plugins/pixi/src/Sprite.zig diff --git a/src/plugins/pixelart/src/State.zig b/src/plugins/pixi/src/State.zig similarity index 95% rename from src/plugins/pixelart/src/State.zig rename to src/plugins/pixi/src/State.zig index 039b1144..95a02fc1 100644 --- a/src/plugins/pixelart/src/State.zig +++ b/src/plugins/pixi/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 -//! plugin code uses `Globals.state`. +//! plugin code uses `runtime.state`. const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); @@ -15,12 +15,13 @@ const Colors = @import("Colors.zig"); const Project = @import("Project.zig"); const Tools = @import("Tools.zig"); const PackJob = @import("PackJob.zig"); +const Packer = @import("Packer.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"); const CanvasData = @import("CanvasData.zig"); -const Globals = @import("Globals.zig"); +const runtime = @import("runtime.zig"); pub const Settings = @import("Settings.zig"); pub const Docs = @import("Docs.zig"); @@ -70,11 +71,14 @@ sprite_clipboard: ?SpriteClipboard = null, /// most recent request produces a visible atlas update. pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty, +/// Project texture atlas packer (owned by App; wired after init). +packer: ?*Packer = null, + /// 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(); + const gpa = runtime.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); diff --git a/src/plugins/pixelart/src/Tools.zig b/src/plugins/pixi/src/Tools.zig similarity index 94% rename from src/plugins/pixelart/src/Tools.zig rename to src/plugins/pixi/src/Tools.zig index 1ba3ac69..9ea11f2c 100644 --- a/src/plugins/pixelart/src/Tools.zig +++ b/src/plugins/pixi/src/Tools.zig @@ -1,7 +1,7 @@ const std = @import("std"); const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); const Tools = @This(); @@ -163,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) { - Globals.state.host.requestCompositeWarmup(); + runtime.state().host.requestPrepareFrame(); } } } @@ -299,7 +299,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 })); defer vbox2.deinit(); - pixelart.core.dvui.labelWithKeybind( + pixi_mod.core.dvui.labelWithKeybind( tool_name, switch (tool) { .pointer => dvui.currentWindow().keybinds.get("pointer") orelse .{}, @@ -335,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(Globals.state.host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; + const atlas_size: dvui.Size = dvui.imageSize(runtime.state().host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; var mode_color = dvui.themeGet().color(.control, .fill_hover); - if (Globals.state.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { mode_color = palette.getDVUIColor(4); } @@ -368,7 +368,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 2 => "COLOR", else => unreachable, }; - const selected = Globals.state.tools.selection_mode == mode; + const selected = runtime.state().tools.selection_mode == mode; var mode_col = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .none, @@ -378,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 => 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], + .box => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_default], + .pixel => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_default], + .color => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_default], }; const uv = dvui.Rect{ .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w, @@ -431,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(Globals.state.host.uiAtlas().source, rs, .{ + dvui.renderImage(runtime.state().host.uiAtlas().source, rs, .{ .uv = uv, .fade = 0.0, }) catch { @@ -439,7 +439,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 }; if (mode_button.clicked()) { - Globals.state.tools.selection_mode = mode; + runtime.state().tools.selection_mode = mode; } } } diff --git a/src/plugins/pixelart/src/Transform.zig b/src/plugins/pixi/src/Transform.zig similarity index 86% rename from src/plugins/pixelart/src/Transform.zig rename to src/plugins/pixi/src/Transform.zig index edbddffb..32835f42 100644 --- a/src/plugins/pixelart/src/Transform.zig +++ b/src/plugins/pixi/src/Transform.zig @@ -1,7 +1,7 @@ const std = @import("std"); const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); pub const Transform = @This(); @@ -35,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 (Globals.state.docs.fileById(self.file_id)) |file| { + if (runtime.state().docs.fileById(self.file_id)) |file| { var layer = file.getLayer(self.layer_id) orelse return; - const t_all: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; + const t_all: i128 = if (pixi_mod.perf.record) pixi_mod.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 (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; + const t_after_gpu: i128 = if (pixi_mod.perf.record) pixi_mod.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 (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; + const t_loop: i128 = if (pixi_mod.perf.record) pixi_mod.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| { @@ -71,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 (Globals.state.tools.current == .selection) { + if (runtime.state().tools.current == .selection) { file.editor.selection_layer.clearMask(); for (pix, 0..) |temp_pixel, pixel_index| { if (temp_pixel.a != 0) { @@ -80,28 +80,28 @@ pub fn accept(self: *Transform) void { } } - const t_after_loop: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; + const t_after_loop: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; - const t_to_change: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; + const t_to_change: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; const change = file.buffers.stroke.toChange(self.layer_id) catch null; - const t_after_to_change: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; + const t_after_to_change: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; - const t_hist: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; + const t_hist: i128 = if (pixi_mod.perf.record) pixi_mod.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 (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(); + const t_end: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; + + if (pixi_mod.perf.record) { + pixi_mod.perf.transform_accept_last_total_ns = @intCast(t_end - t_all); + pixi_mod.perf.transform_accept_last_gpu_read_ns = @intCast(t_after_gpu - t_all); + pixi_mod.perf.transform_accept_last_merge_loop_ns = @intCast(t_after_loop - t_loop); + pixi_mod.perf.transform_accept_last_to_change_ns = @intCast(t_after_to_change - t_to_change); + pixi_mod.perf.transform_accept_last_history_append_ns = @intCast(t_end - t_hist); + pixi_mod.perf.transform_accept_last_layer_pixels = layer_px; + pixi_mod.perf.logTransformAcceptIf(); } layer.invalidate(); @@ -110,14 +110,14 @@ pub fn accept(self: *Transform) void { file.editor.transform_layer.clearMask(); file.editor.transform_layer.invalidate(); file.editor.transform = null; - Globals.allocator().free(pixelart.image.bytes(self.source)); + runtime.allocator().free(pixi_mod.image.bytes(self.source)); self.* = undefined; } } /// Cancels the transform and restores the layer to its original state pub fn cancel(self: *Transform) void { - if (Globals.state.docs.fileById(self.file_id)) |file| { + if (runtime.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| { @@ -130,7 +130,7 @@ pub fn cancel(self: *Transform) void { file.editor.transform_layer.clearMask(); file.editor.transform_layer.invalidate(); file.editor.transform = null; - Globals.allocator().free(pixelart.image.bytes(self.source)); + runtime.allocator().free(pixi_mod.image.bytes(self.source)); self.* = undefined; } } diff --git a/src/plugins/pixelart/src/algorithms/algorithms.zig b/src/plugins/pixi/src/algorithms/algorithms.zig similarity index 100% rename from src/plugins/pixelart/src/algorithms/algorithms.zig rename to src/plugins/pixi/src/algorithms/algorithms.zig diff --git a/src/plugins/pixelart/src/algorithms/brezenham.zig b/src/plugins/pixi/src/algorithms/brezenham.zig similarity index 85% rename from src/plugins/pixelart/src/algorithms/brezenham.zig rename to src/plugins/pixi/src/algorithms/brezenham.zig index 2e7f40b1..189e68e6 100644 --- a/src/plugins/pixelart/src/algorithms/brezenham.zig +++ b/src/plugins/pixi/src/algorithms/brezenham.zig @@ -1,11 +1,11 @@ const std = @import("std"); const dvui = @import("dvui"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); 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(Globals.state.host.arena()); + var output = std.array_list.Managed(dvui.Point).init(runtime.state().host.arena()); // Round input points to nearest integer grid const x0: i32 = @intFromFloat(@floor(start.x)); diff --git a/src/plugins/pixelart/src/algorithms/reduce.zig b/src/plugins/pixi/src/algorithms/reduce.zig similarity index 100% rename from src/plugins/pixelart/src/algorithms/reduce.zig rename to src/plugins/pixi/src/algorithms/reduce.zig diff --git a/src/plugins/pixelart/src/clipboard.zig b/src/plugins/pixi/src/clipboard.zig similarity index 91% rename from src/plugins/pixelart/src/clipboard.zig rename to src/plugins/pixi/src/clipboard.zig index 3cab67d6..03496ce4 100644 --- a/src/plugins/pixelart/src/clipboard.zig +++ b/src/plugins/pixi/src/clipboard.zig @@ -1,11 +1,11 @@ -//! 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. +//! Sprite copy/paste for the pixel-art plugin. Backs the `pixi_mod.copy` / `pixi_mod.paste` +//! commands and pixel-art's own canvas handlers; the shell never owns this 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; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; fn activeFile(st: *State) ?*Internal.File { const doc = st.host.activeDoc() orelse return null; @@ -17,7 +17,7 @@ pub fn copy(st: *State) !void { if (file.editor.transform != null) return; if (st.sprite_clipboard) |*clipboard| { - Globals.allocator().free(pixelart.image.bytes(clipboard.source)); + runtime.allocator().free(pixi_mod.image.bytes(clipboard.source)); st.sprite_clipboard = null; } @@ -86,10 +86,10 @@ pub fn copy(st: *State) !void { 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(); + const gpa = runtime.allocator(); st.sprite_clipboard = .{ - .source = pixelart.image.fromPixelsPMA( + .source = pixi_mod.image.fromPixelsPMA( @ptrCast(file.editor.transform_layer.pixelsFromRect(gpa, reduced_data_rect)), @intFromFloat(reduced_data_rect.w), @intFromFloat(reduced_data_rect.h), @@ -98,7 +98,7 @@ pub fn copy(st: *State) !void { .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_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, file.editor.canvas.id, pixi_mod.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); @@ -111,7 +111,7 @@ pub fn paste(st: *State) !void { 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 dst_rect: dvui.Rect = .fromSize(pixi_mod.image.size(clipboard.source)); var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); while (sprite_iterator.next()) |sprite_index| { @@ -121,7 +121,7 @@ pub fn paste(st: *State) !void { 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 { + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create target texture", .{}); return; }, @@ -164,7 +164,7 @@ pub fn paste(st: *State) !void { 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 { + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create target texture", .{}); return; }, @@ -193,7 +193,7 @@ pub fn paste(st: *State) !void { } file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create target texture", .{}); return; }, diff --git a/src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c b/src/plugins/pixi/src/deps/msf_gif/fizzy_msf_gif_wasm.c similarity index 100% rename from src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c rename to src/plugins/pixi/src/deps/msf_gif/fizzy_msf_gif_wasm.c diff --git a/src/plugins/pixelart/src/deps/msf_gif/msf_gif.c b/src/plugins/pixi/src/deps/msf_gif/msf_gif.c similarity index 100% rename from src/plugins/pixelart/src/deps/msf_gif/msf_gif.c rename to src/plugins/pixi/src/deps/msf_gif/msf_gif.c diff --git a/src/plugins/pixelart/src/deps/msf_gif/msf_gif.h b/src/plugins/pixi/src/deps/msf_gif/msf_gif.h similarity index 100% rename from src/plugins/pixelart/src/deps/msf_gif/msf_gif.h rename to src/plugins/pixi/src/deps/msf_gif/msf_gif.h diff --git a/src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig b/src/plugins/pixi/src/deps/msf_gif/msf_gif.zig similarity index 100% rename from src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig rename to src/plugins/pixi/src/deps/msf_gif/msf_gif.zig diff --git a/src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h b/src/plugins/pixi/src/deps/msf_gif/wasm_shim/string.h similarity index 100% rename from src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h rename to src/plugins/pixi/src/deps/msf_gif/wasm_shim/string.h diff --git a/src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c b/src/plugins/pixi/src/deps/stbi/fizzy_stbi_libc.c similarity index 100% rename from src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c rename to src/plugins/pixi/src/deps/stbi/fizzy_stbi_libc.c diff --git a/src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h b/src/plugins/pixi/src/deps/stbi/stb_image_resize2.h similarity index 100% rename from src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h rename to src/plugins/pixi/src/deps/stbi/stb_image_resize2.h diff --git a/src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h b/src/plugins/pixi/src/deps/stbi/stb_rect_pack.h similarity index 100% rename from src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h rename to src/plugins/pixi/src/deps/stbi/stb_rect_pack.h diff --git a/src/plugins/pixelart/src/deps/stbi/zstbi.c b/src/plugins/pixi/src/deps/stbi/zstbi.c similarity index 100% rename from src/plugins/pixelart/src/deps/stbi/zstbi.c rename to src/plugins/pixi/src/deps/stbi/zstbi.c diff --git a/src/plugins/pixelart/src/deps/stbi/zstbi.zig b/src/plugins/pixi/src/deps/stbi/zstbi.zig similarity index 100% rename from src/plugins/pixelart/src/deps/stbi/zstbi.zig rename to src/plugins/pixi/src/deps/stbi/zstbi.zig diff --git a/src/plugins/pixelart/src/deps/zip/build.zig b/src/plugins/pixi/src/deps/zip/build.zig similarity index 100% rename from src/plugins/pixelart/src/deps/zip/build.zig rename to src/plugins/pixi/src/deps/zip/build.zig diff --git a/src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c b/src/plugins/pixi/src/deps/zip/fizzy_zip_libc.c similarity index 100% rename from src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c rename to src/plugins/pixi/src/deps/zip/fizzy_zip_libc.c diff --git a/src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c b/src/plugins/pixi/src/deps/zip/fizzy_zip_strings.c similarity index 100% rename from src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c rename to src/plugins/pixi/src/deps/zip/fizzy_zip_strings.c diff --git a/src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h b/src/plugins/pixi/src/deps/zip/fizzy_zip_wasm.h similarity index 100% rename from src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h rename to src/plugins/pixi/src/deps/zip/fizzy_zip_wasm.h diff --git a/src/plugins/pixelart/src/deps/zip/src/miniz.h b/src/plugins/pixi/src/deps/zip/src/miniz.h similarity index 100% rename from src/plugins/pixelart/src/deps/zip/src/miniz.h rename to src/plugins/pixi/src/deps/zip/src/miniz.h diff --git a/src/plugins/pixelart/src/deps/zip/src/zip.c b/src/plugins/pixi/src/deps/zip/src/zip.c similarity index 100% rename from src/plugins/pixelart/src/deps/zip/src/zip.c rename to src/plugins/pixi/src/deps/zip/src/zip.c diff --git a/src/plugins/pixelart/src/deps/zip/src/zip.h b/src/plugins/pixi/src/deps/zip/src/zip.h similarity index 100% rename from src/plugins/pixelart/src/deps/zip/src/zip.h rename to src/plugins/pixi/src/deps/zip/src/zip.h diff --git a/src/plugins/pixelart/src/deps/zip/zip.zig b/src/plugins/pixi/src/deps/zip/zip.zig similarity index 100% rename from src/plugins/pixelart/src/deps/zip/zip.zig rename to src/plugins/pixi/src/deps/zip/zip.zig diff --git a/src/plugins/pixelart/src/dialogs/Export.zig b/src/plugins/pixi/src/dialogs/Export.zig similarity index 86% rename from src/plugins/pixelart/src/dialogs/Export.zig rename to src/plugins/pixi/src/dialogs/Export.zig index a732c52c..03b99c46 100644 --- a/src/plugins/pixelart/src/dialogs/Export.zig +++ b/src/plugins/pixi/src/dialogs/Export.zig @@ -6,8 +6,8 @@ const zstbi = @import("zstbi"); const DimensionsLabel = @import("dimensions_label.zig"); const WebFileIo = @import("../web_file_io.zig"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const ExportImageFormat = enum { png, jpg }; @@ -38,7 +38,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: *pixelart.internal.File) ?usize { +fn exportAnimationIndex(file: *pixi_mod.internal.File) ?usize { const idx = file.selected_animation_index orelse return null; if (idx >= file.animations.len) return null; return idx; @@ -48,7 +48,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)) { - Globals.state.tools.set(.pointer); + runtime.state().tools.set(.pointer); } var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); @@ -144,7 +144,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { .all => try allDialog(id), }; - return mode_valid and (Globals.state.docs.activeFile(Globals.state.host) != null); + return mode_valid and (runtime.state().docs.activeFile(runtime.state().host) != null); } pub fn singleDialog(_: dvui.Id) anyerror!bool { @@ -152,14 +152,14 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool { var max_scale: f32 = 16.0; var valid: bool = false; - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.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 (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (file.editor.selected_sprites.findFirstSet()) |sprite_index| { renderExportPreviewSprite(file, sprite_index); } @@ -167,7 +167,7 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool { exportScaleSlider(max_scale); - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.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); @@ -183,7 +183,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { var max_scale: f32 = 16.0; var preview_sprite: ?usize = null; - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.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))), @@ -221,7 +221,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { } } - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (preview_sprite) |sprite_index| { renderExportPreviewSprite(file, sprite_index); } @@ -230,7 +230,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { exportScaleSlider(max_scale); if (preview_sprite) |_| { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.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); @@ -241,20 +241,20 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { } pub fn layerDialog(_: dvui.Id) anyerror!bool { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { renderExportPreview(file, .layer); } - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { exportDimensionsLabelForExport(file.width(), file.height()); } return true; } pub fn allDialog(_: dvui.Id) anyerror!bool { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { renderExportPreview(file, .composite); } - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { exportDimensionsLabelForExport(file.width(), file.height()); } return true; @@ -266,11 +266,11 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void switch (mode) { .animation => { const default = blk: { - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { break :blk "animation.gif"; }; - const default_filename: [:0]const u8 = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.gif", .{ + const default_filename: [:0]const u8 = std.fmt.allocPrintSentinel(runtime.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", .{}); @@ -280,32 +280,32 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void break :blk default_filename; }; - Globals.state.host.showSaveDialog( + runtime.state().host.showSaveDialog( saveAnimationCallback, - &[_]pixelart.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }}, + &[_]pixi_mod.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }}, default, null, // Passing null here means use the last save folder location ); }, .single => { - const file = Globals.state.docs.activeFile(Globals.state.host) orelse return; + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; const sprite_index = file.editor.selected_sprites.findFirstSet() orelse return; - const base = file.spriteExportName(Globals.allocator(), sprite_index) catch { + const base = file.spriteExportName(runtime.allocator(), sprite_index) catch { dvui.log.err("Failed to allocate default export name", .{}); return; }; - defer Globals.allocator().free(base); + defer runtime.allocator().free(base); - const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch { + const default = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{base}, 0) catch { dvui.log.err("Failed to allocate filename", .{}); return; }; - defer Globals.allocator().free(default); + defer runtime.allocator().free(default); - Globals.state.host.showSaveDialog( + runtime.state().host.showSaveDialog( exportCurrentSpriteCallback, - &[_]pixelart.sdk.SaveDialogFilter{ + &[_]pixi_mod.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -314,22 +314,22 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void ); }, .layer => { - const file = Globals.state.docs.activeFile(Globals.state.host) orelse return; - const base = file.layerExportBaseName(Globals.allocator()) catch { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; + const base = file.layerExportBaseName(runtime.allocator()) catch { dvui.log.err("Failed to allocate default export name", .{}); return; }; - defer Globals.allocator().free(base); + defer runtime.allocator().free(base); - const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch { + const default = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{base}, 0) catch { dvui.log.err("Failed to allocate filename", .{}); return; }; - defer Globals.allocator().free(default); + defer runtime.allocator().free(default); - Globals.state.host.showSaveDialog( + runtime.state().host.showSaveDialog( exportLayerCallback, - &[_]pixelart.sdk.SaveDialogFilter{ + &[_]pixi_mod.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -338,22 +338,22 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void ); }, .all => { - const file = Globals.state.docs.activeFile(Globals.state.host) orelse return; - const base = file.allExportBaseName(Globals.allocator()) catch { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; + const base = file.allExportBaseName(runtime.allocator()) catch { dvui.log.err("Failed to allocate default export name", .{}); return; }; - defer Globals.allocator().free(base); + defer runtime.allocator().free(base); - const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch { + const default = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{base}, 0) catch { dvui.log.err("Failed to allocate filename", .{}); return; }; - defer Globals.allocator().free(default); + defer runtime.allocator().free(default); - Globals.state.host.showSaveDialog( + runtime.state().host.showSaveDialog( exportAllCallback, - &[_]pixelart.sdk.SaveDialogFilter{ + &[_]pixi_mod.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -371,7 +371,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: *pixelart.internal.File, sprite_index: usize) void { +fn renderExportPreviewSprite(file: *pixi_mod.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, @@ -412,7 +412,7 @@ fn renderExportPreviewSprite(file: *pixelart.internal.File, sprite_index: usize) 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()); - pixelart.render.renderLayers(.{ + pixi_mod.render.renderLayers(.{ .file = file, .rs = box.data().rectScale(), .uv = uv, @@ -496,8 +496,8 @@ fn exportCheckerboardVertexColor( return tone.lerp(c_corner, t); } -fn exportSpriteAnimationPaletteColor(file: *pixelart.internal.File, sprite_index: usize) ?dvui.Color { - if (Globals.state.colors.file_tree_palette) |*palette| { +fn exportSpriteAnimationPaletteColor(file: *pixi_mod.internal.File, sprite_index: usize) ?dvui.Color { + if (runtime.state().colors.file_tree_palette) |*palette| { var animation_index: ?usize = null; if (file.selected_animation_index) |selected_animation_index| { @@ -529,13 +529,13 @@ fn exportSpriteAnimationPaletteColor(file: *pixelart.internal.File, sprite_index } fn exportCheckerboardCellCornerColor( - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, sprite_index: usize, pal: CheckerboardPalette, u: f32, v: f32, ) dvui.Color { - switch (Globals.state.settings.transparency_effect) { + switch (runtime.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 => { @@ -557,7 +557,7 @@ fn exportCheckerboardCellCornerColor( fn appendCheckerboardCellQuad( builder: *dvui.Triangles.Builder, quad_idx: *usize, - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, sprite_index: usize, pal: CheckerboardPalette, geometry_natural: dvui.Rect, @@ -596,7 +596,7 @@ fn appendCheckerboardCellQuad( } fn drawCheckerboardCell( - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, sprite_index: usize, geometry_natural: dvui.Rect, rs_box: dvui.RectScale, @@ -618,7 +618,7 @@ fn drawCheckerboardCell( }; } -fn drawCheckerboardFileGrid(file: *pixelart.internal.File, rs_box: dvui.RectScale) void { +fn drawCheckerboardFileGrid(file: *pixi_mod.internal.File, rs_box: dvui.RectScale) void { const n = file.spriteCount(); if (n == 0) return; @@ -644,13 +644,13 @@ fn drawCheckerboardFileGrid(file: *pixelart.internal.File, rs_box: dvui.RectScal /// 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: *pixelart.internal.File, kind: ExportFullPreviewKind) void { +fn renderExportPreview(file: *pixi_mod.internal.File, kind: ExportFullPreviewKind) void { const w = file.width(); const h = file.height(); if (w == 0 or h == 0) return; if (kind == .composite) { - pixelart.render.syncLayerComposite(file) catch { + pixi_mod.render.syncLayerComposite(file) catch { dvui.log.err("Export preview: failed to build layer composite", .{}); return; }; @@ -688,13 +688,13 @@ fn renderExportPreview(file: *pixelart.internal.File, kind: ExportFullPreviewKin 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(Globals.allocator()); + var path_tris: dvui.Path.Builder = .init(runtime.allocator()); defer path_tris.deinit(); path_tris.addRect(rs.r, .all(0)); - var tris = path_tris.build().fillConvexTriangles(Globals.allocator(), .{ .color = .white, .fade = 0.0 }) catch { + var tris = path_tris.build().fillConvexTriangles(runtime.allocator(), .{ .color = .white, .fade = 0.0 }) catch { return; }; - defer tris.deinit(Globals.allocator()); + defer tris.deinit(runtime.allocator()); tris.uvFromRectuv(rs.r, full_uv); switch (kind) { @@ -723,20 +723,20 @@ fn renderExportPreview(file: *pixelart.internal.File, kind: ExportFullPreviewKin 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(Globals.allocator()); + var out = std.Io.Writer.Allocating.init(runtime.allocator()); errdefer out.deinit(); switch (format) { - .png => try pixelart.image.writePngToWriter(source, &out.writer, 0), - .jpg => try pixelart.image.writeJpgPpiToWriter(source, &out.writer, 0), + .png => try pixi_mod.image.writePngToWriter(source, &out.writer, 0), + .jpg => try pixi_mod.image.writeJpgPpiToWriter(source, &out.writer, 0), } const bytes = try out.toOwnedSlice(); - defer Globals.allocator().free(bytes); + defer runtime.allocator().free(bytes); try WebFileIo.downloadBytes(path, bytes); return; } switch (format) { - .png => try pixelart.image.writeToPngResolution(source, path, 0), - .jpg => try pixelart.image.writeToJpgPpi(source, path, 0), + .png => try pixi_mod.image.writeToPngResolution(source, path, 0), + .jpg => try pixi_mod.image.writeToJpgPpi(source, path, 0), } } @@ -750,7 +750,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: *pixelart.internal.File, sprite_index: usize) ![][4]u8 { +fn compositedSpritePixels(allocator: std.mem.Allocator, file: *pixi_mod.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); @@ -771,7 +771,7 @@ fn compositedSpritePixels(allocator: std.mem.Allocator, file: *pixelart.internal const layer_pixels = lower.pixelsFromRect(allocator, sprite_rect) orelse continue; defer allocator.free(layer_pixels); - pixelart.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true); + pixi_mod.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true); } return pixels; @@ -831,7 +831,7 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -847,14 +847,14 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void { export_height = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale); } - const pixels = try compositedSpritePixels(Globals.allocator(), file, sprite_index); - defer Globals.allocator().free(pixels); + const pixels = try compositedSpritePixels(runtime.allocator(), file, sprite_index); + defer runtime.allocator().free(pixels); if (scale != 1.0) { - const resized = Globals.allocator().alloc([4]u8, export_width * export_height) catch { + const resized = runtime.allocator().alloc([4]u8, export_width * export_height) catch { return error.OutOfMemory; }; - defer Globals.allocator().free(resized); + defer runtime.allocator().free(resized); if (zstbi.resize( pixels, file.column_width, @@ -893,7 +893,7 @@ pub fn exportLayerToPath(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -913,7 +913,7 @@ pub fn exportAllToPath(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -922,18 +922,18 @@ pub fn exportAllToPath(path: []const u8) anyerror!void { const h = file.height(); if (w == 0 or h == 0) return error.InvalidImageSize; - try pixelart.render.syncLayerComposite(file); + try pixi_mod.render.syncLayerComposite(file); const target = file.editor.layer_composite_target orelse { return error.NoLayerComposite; }; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } - var tmp_layer: pixelart.internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr); + var tmp_layer: pixi_mod.internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr); defer tmp_layer.deinit(); const format: ExportImageFormat = if (is_png) .png else .jpg; @@ -949,7 +949,7 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -961,7 +961,7 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { const animation_index = exportAnimationIndex(file) orelse return error.NoSelectedAnimation; { - const anim: pixelart.internal.Animation = file.animations.get(animation_index); + const anim: pixi_mod.internal.Animation = file.animations.get(animation_index); var export_width = file.column_width; var export_height = file.row_height; @@ -980,11 +980,11 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { msf_gif.msf_gif_alpha_threshold = 240; for (anim.frames) |frame| { - const pixels = compositedSpritePixels(Globals.allocator(), file, frame.sprite_index) catch |err| { + const pixels = compositedSpritePixels(runtime.allocator(), file, frame.sprite_index) catch |err| { if (err == error.NoPixels) continue; return err; }; - defer Globals.allocator().free(pixels); + defer runtime.allocator().free(pixels); { // msf_gif will error if there are only transparent pixels const valid = blk: { @@ -1004,11 +1004,11 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { } if (scale != 1.0) { - const resized_pixels = Globals.allocator().alloc([4]u8, export_width * export_height) catch { + const resized_pixels = runtime.allocator().alloc([4]u8, export_width * export_height) catch { dvui.log.err("Failed to allocate resized pixels", .{}); continue; }; - defer Globals.allocator().free(resized_pixels); + defer runtime.allocator().free(resized_pixels); _ = zstbi.resize( pixels, diff --git a/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixi/src/dialogs/FlatRasterSaveWarning.zig similarity index 78% rename from src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig rename to src/plugins/pixi/src/dialogs/FlatRasterSaveWarning.zig index 213c350b..66cfcafa 100644 --- a/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig +++ b/src/plugins/pixi/src/dialogs/FlatRasterSaveWarning.zig @@ -1,9 +1,9 @@ const std = @import("std"); const dvui = @import("dvui"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); -pub const Mode = pixelart.sdk.Plugin.FlatRasterSaveMode; +pub const Mode = pixi_mod.sdk.Plugin.SaveConfirmMode; pub var pending_mode: Mode = .editor_save; @@ -12,7 +12,7 @@ pub var pending_mode: Mode = .editor_save; /// 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; - var mutex = pixelart.core.dvui.dialog(@src(), .{ + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ .displayFn = dialog, .callafterFn = callAfter, .title = "Save as .fiz or current extension?", @@ -29,8 +29,8 @@ pub fn request(file_id: u64, mode: Mode, from_save_all_quit: bool) void { mutex.mutex.unlock(dvui.io); } -fn fileRef(file_id: u64) ?*pixelart.internal.File { - return Globals.state.docs.fileById(file_id); +fn fileRef(file_id: u64) ?*pixi_mod.internal.File { + return runtime.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 { @@ -110,24 +110,24 @@ pub fn dialog(id: dvui.Id) anyerror!bool { } fn onChooseFizzy(file_id: u64) !void { - const idx = Globals.state.host.docIndex(file_id) orelse return; - Globals.state.host.setActiveDocIndex(idx); + const idx = runtime.state().host.docIndex(file_id) orelse return; + runtime.state().host.setActiveDocIndex(idx); if (pending_mode == .save_and_close) { - Globals.state.host.setPendingCloseDocId(file_id); + runtime.state().host.setPendingCloseDocId(file_id); } - pixelart.core.dvui.closeFloatingDialogAnchored(); - Globals.state.host.requestSaveAs(); + pixi_mod.core.dvui.closeFloatingDialogAnchored(); + runtime.state().host.requestSaveAs(); } fn onChooseFlatRaster(file_id: u64, from_save_all_quit: bool) !void { const f = fileRef(file_id) orelse return; switch (pending_mode) { .editor_save => { - pixelart.core.dvui.closeFloatingDialogAnchored(); + pixi_mod.core.dvui.closeFloatingDialogAnchored(); if (comptime @import("builtin").target.cpu.arch == .wasm32) { - const idx = Globals.state.host.docIndex(file_id) orelse return; - Globals.state.host.setActiveDocIndex(idx); - Globals.state.host.requestWebSave(.save); + const idx = runtime.state().host.docIndex(file_id) orelse return; + runtime.state().host.setActiveDocIndex(idx); + runtime.state().host.requestWebSave(.save); } else { try f.saveAsync(); } @@ -140,32 +140,32 @@ fn onChooseFlatRaster(file_id: u64, from_save_all_quit: bool) !void { // otherwise this is a single-doc save-and-close. f.saveAsync() catch |err| { dvui.log.err("Save failed: {s}", .{@errorName(err)}); - if (from_save_all_quit) Globals.state.host.abortSaveAllQuit(); + if (from_save_all_quit) runtime.state().host.abortSaveAllQuit(); return; }; if (from_save_all_quit) { - Globals.state.host.trackQuitSaveInFlight(file_id) catch |err| { + runtime.state().host.trackQuitSaveInFlight(file_id) catch |err| { dvui.log.err("Save all quit track: {s}", .{@errorName(err)}); - Globals.state.host.abortSaveAllQuit(); + runtime.state().host.abortSaveAllQuit(); return; }; - Globals.state.host.resumeSaveAllQuit(); + runtime.state().host.resumeSaveAllQuit(); } else { - try Globals.state.host.queueCloseAfterSave(file_id); + try runtime.state().host.queueCloseAfterSave(file_id); } - pixelart.core.dvui.closeFloatingDialogAnchored(); + pixi_mod.core.dvui.closeFloatingDialogAnchored(); }, } } fn onCancel() void { - Globals.state.host.cancelPendingSaveDialog(); - pixelart.core.dvui.closeFloatingDialogAnchored(); + runtime.state().host.cancelPendingSaveDialog(); + pixi_mod.core.dvui.closeFloatingDialogAnchored(); } pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) !void { switch (response) { - .cancel => Globals.state.host.cancelPendingSaveDialog(), + .cancel => runtime.state().host.cancelPendingSaveDialog(), else => {}, } } diff --git a/src/plugins/pixelart/src/dialogs/GridLayout.zig b/src/plugins/pixi/src/dialogs/GridLayout.zig similarity index 94% rename from src/plugins/pixelart/src/dialogs/GridLayout.zig rename to src/plugins/pixi/src/dialogs/GridLayout.zig index b45f942c..bda528fd 100644 --- a/src/plugins/pixelart/src/dialogs/GridLayout.zig +++ b/src/plugins/pixi/src/dialogs/GridLayout.zig @@ -10,11 +10,11 @@ const dvui = @import("dvui"); const std = @import("std"); const NewFile = @import("NewFile.zig"); -const CanvasWidget = pixelart.core.dvui.CanvasWidget; +const CanvasWidget = pixi_mod.core.dvui.CanvasWidget; const CanvasBridge = @import("../widgets/CanvasBridge.zig"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; -const FloatingWindowWidget = pixelart.core.dvui.FloatingWindowWidget; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); +const FloatingWindowWidget = pixi_mod.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]pixelart.math.layout_anchor.LayoutAnchor = .{ +const anchors: [9]pixi_mod.math.layout_anchor.LayoutAnchor = .{ .nw, .n, .ne, .w, .c, .e, .sw, .s, .se, @@ -91,10 +91,10 @@ const anchor_labels = [_][]const u8{ "NW", "N", "NE", "W", "C", "E", "SW", "S", /// 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; + const file = runtime.state().docs.fileById(file_id) orelse return; presetFromFile(file); - var mutex = pixelart.core.dvui.dialog(@src(), .{ + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ .displayFn = dialog, .callafterFn = callAfter, .windowFn = windowFn, @@ -113,7 +113,7 @@ pub fn request(file_id: u64) void { } /// 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 { +pub fn presetFromFile(file: *pixi_mod.internal.File) void { resize_form = .{ .column_width = file.column_width, .row_height = file.row_height, @@ -152,9 +152,9 @@ pub fn presetFromFile(file: *pixelart.internal.File) void { /// Same as `Workspace.drawCanvas` / `workspaceMainCanvasVbox` behind the file widget. fn workspaceCanvasChromeColor() dvui.Color { var content_color = dvui.themeGet().color(.window, .fill); - if (Globals.state.host.appliesNativeWindowOpacity()) { - content_color = if (!Globals.state.host.isMaximized()) - content_color.opacity(Globals.state.host.contentOpacity()) + if (runtime.state().host.appliesNativeWindowOpacity()) { + content_color = if (!runtime.state().host.isMaximized()) + content_color.opacity(runtime.state().host.contentOpacity()) else content_color; } @@ -235,7 +235,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: *pixelart.internal.File, + file: *pixi_mod.internal.File, cv: *CanvasWidget, rs_box: dvui.RectScale, cols: u32, @@ -246,7 +246,7 @@ fn drawCheckerboardPreviewTiled( if (cell_w <= 0 or cell_h <= 0 or cols == 0 or rows == 0) return; const pal = previewCheckerboardPalette(); - const te = Globals.state.settings.transparency_effect; + const te = runtime.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; @@ -418,7 +418,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: *pixelart.internal.File, + file: *pixi_mod.internal.File, rs_box: dvui.RectScale, old_cols: u32, old_rows: u32, @@ -428,9 +428,9 @@ fn drawCompositePreviewPerCells( new_rows: u32, new_cw_: u32, new_rh_: u32, - anchor_vis: pixelart.math.layout_anchor.LayoutAnchor, + anchor_vis: pixi_mod.math.layout_anchor.LayoutAnchor, ) void { - pixelart.render.syncLayerComposite(file) catch { + pixi_mod.render.syncLayerComposite(file) catch { dvui.log.err("Grid layout preview: composite failed", .{}); return; }; @@ -450,7 +450,7 @@ fn drawCompositePreviewPerCells( defer builder.deinit(arena); const tint = dvui.Color.PMA.fromColor(dvui.Color.white.opacity(dvui.currentWindow().alpha)); - const blk = pixelart.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw_, new_rh_, anchor_vis); + const blk = pixi_mod.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; @@ -483,9 +483,9 @@ fn drawCompositePreviewPerCells( } /// One quad for the full layer composite (slice preview — no per-cell remapping). -fn drawCompositePreviewFullLayer(file: *pixelart.internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void { +fn drawCompositePreviewFullLayer(file: *pixi_mod.internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void { if (nw <= 0 or nh <= 0) return; - pixelart.render.syncLayerComposite(file) catch { + pixi_mod.render.syncLayerComposite(file) catch { dvui.log.err("Grid layout preview: composite failed", .{}); return; }; @@ -509,7 +509,7 @@ fn drawCompositePreviewFullLayer(file: *pixelart.internal.File, rs_box: dvui.Rec /// 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: *pixelart.internal.File) void { +fn harmonizeSliceStateWithLayer(file: *pixi_mod.internal.File) void { const canvas = file.canvasPixelSize(); const tw = canvas.w; const th = canvas.h; @@ -539,14 +539,14 @@ fn harmonizeSliceStateWithLayer(file: *pixelart.internal.File) void { fn renderPreview( mutex_id: dvui.Id, dlg_id: dvui.Id, - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, nw: u32, nh: u32, new_cw_: u32, new_rh_: u32, new_cols: u32, new_rows: u32, - anchor_vis: pixelart.math.layout_anchor.LayoutAnchor, + anchor_vis: pixi_mod.math.layout_anchor.LayoutAnchor, slice_full_layer: bool, host_rect: dvui.Rect, ) void { @@ -854,7 +854,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 (Globals.state.docs.fileById(fid)) |tf| + if (file_id_for_dialog) |fid| if (runtime.state().docs.fileById(fid)) |tf| harmonizeSliceStateWithLayer(tf); } mode = new_mode; @@ -870,8 +870,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: ?*pixelart.internal.File = if (file_id_for_dialog) |fid| - Globals.state.docs.fileById(fid) + const target_file: ?*pixi_mod.internal.File = if (file_id_for_dialog) |fid| + runtime.state().docs.fileById(fid) else null; @@ -902,10 +902,10 @@ pub fn dialog(id: dvui.Id) anyerror!bool { defer { if (dialog_middle_scroll.offset(.vertical) > 0.0) - pixelart.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .top, .{}); if (dialog_middle_scroll.virtual_size.h > dialog_middle_scroll.viewport.h) - pixelart.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .bottom, .{}); } // Form (intrinsic width, full height) + preview (expands horizontally with the window). @@ -965,18 +965,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) { - pixelart.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{}); } if (left_scroll.virtual_size.h > left_scroll.viewport.h) { - pixelart.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{}); } pane_left.deinit(); if (left_scroll.virtual_size.w > left_scroll.viewport.w) { - pixelart.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{}); + pixi_mod.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{}); } if (h_scroll > 0.0) { - pixelart.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{}); + pixi_mod.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{}); } shell_left.deinit(); } @@ -1012,7 +1012,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(pixelart.math.layout_anchor.LayoutAnchor, .nw) }; + break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(pixi_mod.math.layout_anchor.LayoutAnchor, .nw) }; } break :blk switch (mode) { .slice => .{ @@ -1043,15 +1043,15 @@ pub fn dialog(id: dvui.Id) anyerror!bool { defer { const rs_scroll = preview_host.data().rectScale(); - 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, .{}); + pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .left, .{}); + pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .right, .{}); } if (target_file) |tf| { const host_rect = preview_host.data().contentRect(); - const dims_ok = pixelart.internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows); + const dims_ok = pixi_mod.internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows); if (dims_ok) { renderPreview( id, @@ -1108,7 +1108,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: ?*pixelart.internal.File, + target_file: ?*pixi_mod.internal.File, form_font: dvui.Font, ) bool { var valid: bool = true; @@ -1134,7 +1134,7 @@ fn drawResizeForm( .color_text = dvui.themeGet().color(.control, .text), }); - if (!pixelart.internal.File.validateGridLayoutProposedDims( + if (!pixi_mod.internal.File.validateGridLayoutProposedDims( resize_form.column_width, resize_form.row_height, resize_form.columns, @@ -1327,7 +1327,7 @@ fn drawResizeForm( /// multiply back to the locked total. fn drawSliceForm( unique_id: dvui.Id, - target_file: ?*pixelart.internal.File, + target_file: ?*pixi_mod.internal.File, form_font: dvui.Font, ) bool { var valid: bool = true; @@ -1490,7 +1490,7 @@ fn drawSliceForm( return valid; } -/// Custom window shell for the grid-layout dialog: matches `pixelart.core.dvui.dialogWindow` (open +/// Custom window shell for the grid-layout dialog: matches `pixi_mod.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` @@ -1503,7 +1503,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void { }; if (modal) { - pixelart.core.dvui.modal_dim_titlebar = true; + pixi_mod.core.dvui.modal_dim_titlebar = true; } const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse { @@ -1516,8 +1516,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", pixelart.core.dvui.CallAfterFn); - const displayFn = dvui.dataGet(null, id, "_displayFn", pixelart.core.dvui.DisplayFn); + const callafter = dvui.dataGet(null, id, "_callafter", pixi_mod.core.dvui.CallAfterFn); + const displayFn = dvui.dataGet(null, id, "_displayFn", pixi_mod.core.dvui.DisplayFn); // Default shell: wide enough for form + preview; DVUI autoSize grows to content if larger. const wr = dvui.windowRect(); @@ -1525,7 +1525,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 = pixelart.core.dvui.floatingWindow(@src(), .{ + var win = pixi_mod.core.dvui.floatingWindow(@src(), .{ .modal = modal, .center_on = center_on, .window_avoid = .nudge, @@ -1557,12 +1557,12 @@ pub fn windowFn(id: dvui.Id) anyerror!void { if (dvui.animationGet(win.data().id, "_close_x")) |a| { if (a.done()) { - pixelart.core.dvui.dialog_close_rect_override = null; + pixi_mod.core.dvui.dialog_close_rect_override = null; dvui.dialogRemove(id); } - } else if (pixelart.core.dvui.dialog_close_rect_override) |close_rect| { + } else if (pixi_mod.core.dvui.dialog_close_rect_override) |close_rect| { dvui.dataSet(null, win.data().id, "_close_rect", close_rect); - pixelart.core.dvui.dialog_close_rect_override = null; + pixi_mod.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". @@ -1587,16 +1587,16 @@ pub fn windowFn(id: dvui.Id) anyerror!void { var shell = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); defer shell.deinit(); - 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, + const header_kind: pixi_mod.core.dvui.DialogHeaderKind = switch (dvui.dataGet(null, id, "_header_kind", u8) orelse 0) { + @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.none) => .none, + @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.info) => .info, + @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.warning) => .warning, + @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.err) => .err, else => .none, }; var header_openflag = true; - win.dragAreaSet(pixelart.core.dvui.windowHeader(title, "", &header_openflag, header_kind)); + win.dragAreaSet(pixi_mod.core.dvui.windowHeader(title, "", &header_openflag, header_kind)); if (!header_openflag) { if (callafter) |ca| { ca(id, .cancel) catch { @@ -1629,7 +1629,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void { } } - { // Footer — match `pixelart.core.dvui.dialogWindow` (horizontal strip, gravity_x centered). + { // Footer — match `pixi_mod.core.dvui.dialogWindow` (horizontal strip, gravity_x centered). var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_x = 0.5, .padding = .{ .y = 6, .h = 8 }, @@ -1719,12 +1719,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 = Globals.state.docs.fileById(file_id) orelse return; + const file = runtime.state().docs.fileById(file_id) orelse return; switch (mode) { .slice => { const s = slice_form; - if (!pixelart.internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows)) + if (!pixi_mod.internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows)) return; file.applyGridSliceOnly(.{ .column_width = s.column_width, @@ -1738,7 +1738,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void }, .resize => { const r = resize_form; - if (!pixelart.internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows)) + if (!pixi_mod.internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows)) return; file.applyGridLayout(.{ .column_width = r.column_width, @@ -1754,7 +1754,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void } dvui.refresh(null, @src(), dvui.currentWindow().data().id); - Globals.state.host.requestCompositeWarmup(); + runtime.state().host.requestPrepareFrame(); }, .cancel => {}, else => {}, diff --git a/src/plugins/pixelart/src/dialogs/NewFile.zig b/src/plugins/pixi/src/dialogs/NewFile.zig similarity index 93% rename from src/plugins/pixelart/src/dialogs/NewFile.zig rename to src/plugins/pixi/src/dialogs/NewFile.zig index b2c1aad5..10c068e3 100644 --- a/src/plugins/pixelart/src/dialogs/NewFile.zig +++ b/src/plugins/pixi/src/dialogs/NewFile.zig @@ -2,8 +2,8 @@ const std = @import("std"); const dvui = @import("dvui"); const DimensionsLabel = @import("dimensions_label.zig"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); pub var mode: enum(usize) { single, @@ -22,7 +22,7 @@ pub const min_size: [2]u32 = .{ 1, 1 }; /// 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(), .{ + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ .displayFn = dialog, .callafterFn = callAfter, .title = "New File...", @@ -210,16 +210,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(Globals.allocator(), &.{ parent, "untitled.fiz" }); - defer Globals.allocator().free(new_path); + const new_path = try std.fs.path.join(runtime.allocator(), &.{ parent, "untitled.fiz" }); + defer runtime.allocator().free(new_path); - const doc = try Globals.state.host.createDocument(new_path, .{ + const doc = try runtime.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, }); - const file = Globals.state.docs.fileFrom(doc); + const file = runtime.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). @@ -228,12 +228,12 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void return error.FailedToSaveFile; }; - try Globals.state.host.setExplorerNewFilePath(file.path); + try runtime.state().host.setExplorerNewFilePath(file.path); dvui.refresh(null, @src(), dvui.currentWindow().data().id); } else { - const new_path = try Globals.state.host.allocUntitledPath(); - defer Globals.allocator().free(new_path); - _ = try Globals.state.host.createDocument(new_path, .{ + const new_path = try runtime.state().host.allocUntitledPath(); + defer runtime.allocator().free(new_path); + _ = try runtime.state().host.createDocument(new_path, .{ .column_width = column_width, .row_height = row_height, .columns = if (mode == .single) 1 else columns, diff --git a/src/plugins/pixelart/src/dialogs/dimensions_label.zig b/src/plugins/pixi/src/dialogs/dimensions_label.zig similarity index 100% rename from src/plugins/pixelart/src/dialogs/dimensions_label.zig rename to src/plugins/pixi/src/dialogs/dimensions_label.zig diff --git a/src/plugins/pixelart/src/doc_bridge.zig b/src/plugins/pixi/src/doc_bridge.zig similarity index 91% rename from src/plugins/pixelart/src/doc_bridge.zig rename to src/plugins/pixi/src/doc_bridge.zig index b9d48914..64df28eb 100644 --- a/src/plugins/pixelart/src/doc_bridge.zig +++ b/src/plugins/pixi/src/doc_bridge.zig @@ -2,17 +2,17 @@ //! 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 pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; +const DocHandle = pixi_mod.sdk.DocHandle; fn docFile(st: *State, doc: DocHandle) ?*Internal.File { return st.docs.fileById(doc.id); } -pub fn bindDocumentToPane( +pub fn bindDocumentToWorkspace( st: *State, doc: DocHandle, canvas_id: dvui.Id, @@ -42,7 +42,7 @@ pub fn documentPath(st: *State, doc: DocHandle) []const u8 { pub fn setDocumentPath(st: *State, doc: DocHandle, path: []const u8) !void { const file = docFile(st, doc) orelse return error.DocumentNotFound; - const gpa = Globals.allocator(); + const gpa = runtime.allocator(); gpa.free(file.path); file.path = try gpa.dupe(u8, path); } diff --git a/src/plugins/pixelart/src/doc_lifecycle.zig b/src/plugins/pixi/src/doc_lifecycle.zig similarity index 92% rename from src/plugins/pixelart/src/doc_lifecycle.zig rename to src/plugins/pixi/src/doc_lifecycle.zig index b84071a1..85eb1c15 100644 --- a/src/plugins/pixelart/src/doc_lifecycle.zig +++ b/src/plugins/pixi/src/doc_lifecycle.zig @@ -2,12 +2,12 @@ //! `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 pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; +const DocHandle = pixi_mod.sdk.DocHandle; +const NewDocGrid = pixi_mod.sdk.EditorAPI.NewDocGrid; fn docFile(st: *State, doc: DocHandle) ?*Internal.File { return st.docs.fileById(doc.id); @@ -18,11 +18,11 @@ fn activeFile(st: *State) ?*Internal.File { return docFile(st, doc); } -pub fn documentStackSize(_: *State) usize { +pub fn sizeOfDocument(_: *State) usize { return @sizeOf(Internal.File); } -pub fn documentStackAlign(_: *State) usize { +pub fn alignOfDocument(_: *State) usize { return @alignOf(Internal.File); } @@ -117,7 +117,7 @@ pub fn warmupActiveDocumentComposites(st: *State) void { 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| { + pixi_mod.render.warmupDrawingComposites(file) catch |err| { dvui.log.err("Composite warmup failed: {any}", .{err}); }; } diff --git a/src/plugins/pixelart/src/docs_registry.zig b/src/plugins/pixi/src/docs_registry.zig similarity index 67% rename from src/plugins/pixelart/src/docs_registry.zig rename to src/plugins/pixi/src/docs_registry.zig index b6744e28..c28ef928 100644 --- a/src/plugins/pixelart/src/docs_registry.zig +++ b/src/plugins/pixi/src/docs_registry.zig @@ -1,21 +1,21 @@ //! 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; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; pub fn registerOpenDocument(st: *State, file: *Internal.File) !*Internal.File { - const gpa = Globals.allocator(); + const gpa = runtime.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 { +pub fn documentFromId(st: *State, id: u64) ?*Internal.File { return st.docs.fileById(id); } -pub fn documentByPath(st: *State, path: []const u8) ?*Internal.File { +pub fn documentFromPath(st: *State, path: []const u8) ?*Internal.File { return st.docs.fileFromPath(path); } diff --git a/src/plugins/pixelart/src/explorer/project.zig b/src/plugins/pixi/src/explorer/project.zig similarity index 89% rename from src/plugins/pixelart/src/explorer/project.zig rename to src/plugins/pixi/src/explorer/project.zig index 59ce990a..0aec0f03 100644 --- a/src/plugins/pixelart/src/explorer/project.zig +++ b/src/plugins/pixi/src/explorer/project.zig @@ -3,8 +3,9 @@ const builtin = @import("builtin"); const icons = @import("icons"); const dvui = @import("dvui"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); +const PackProject = @import("../pack_project.zig"); pub fn draw() !void { // On web there's no project folder concept. Render a simplified pane that @@ -15,8 +16,8 @@ pub fn draw() !void { return; } - if (Globals.state.host.folder()) |folder| { - if (Globals.state.project) |_| { + if (runtime.state().host.folder()) |folder| { + if (runtime.state().project) |_| { const tl = dvui.textLayout(@src(), .{}, .{ .expand = .none, .margin = dvui.Rect.all(0), @@ -35,7 +36,7 @@ pub fn draw() !void { } else { var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, - .max_size_content = .{ .w = Globals.state.host.explorerVirtualSize().w, .h = std.math.floatMax(f32) }, + .max_size_content = .{ .w = runtime.state().host.explorerVirtualSize().w, .h = std.math.floatMax(f32) }, }); defer box.deinit(); @@ -45,19 +46,19 @@ pub fn draw() !void { tl.deinit(); if (dvui.button(@src(), "Create Project", .{}, .{ .expand = .horizontal })) { - Globals.state.project = .{}; + runtime.state().project = .{}; } return; } - const packing = Globals.state.host.isPackingActive(); + const packing = PackProject.isActive(runtime.state()); if (packProjectButton(packing)) { - Globals.state.host.startPackProject() catch |err| { + PackProject.start(runtime.state()) catch |err| { dvui.log.err("Failed to start project pack: {any}", .{err}); }; } - if (Globals.packer.atlas != null) { + if (runtime.packer().atlas != null) { drawPackedAtlasStats(); } @@ -68,8 +69,8 @@ pub fn draw() !void { dvui.log.err("Failed to draw path text entry", .{}); }; - if (Globals.state.project) |project| { - if (Globals.packer.atlas) |atlas| { + if (runtime.state().project) |project| { + if (runtime.packer().atlas) |atlas| { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } }); if (dvui.button(@src(), "Export Project", .{ .draw_focus = false }, .{ .expand = .horizontal, @@ -130,13 +131,13 @@ pub fn draw() !void { // break :blk true; // }; - // if (dvui.dialogNativeFileSave(Globals.allocator(), .{ + // if (dvui.dialogNativeFileSave(runtime.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 = Globals.allocator().dupe(u8, path[0..]) catch null; + // project.packed_atlas_output = runtime.allocator().dupe(u8, path[0..]) catch null; // set_text = true; // } else { // dvui.log.err("Project failed to copy new path", .{}); @@ -163,7 +164,7 @@ pub fn draw() !void { // if (te.text_changed) { // const t = te.getText(); // if (t.len > 0) { - // project.packed_atlas_output = Globals.allocator().dupe(u8, t) catch null; + // project.packed_atlas_output = runtime.allocator().dupe(u8, t) catch null; // } else { // project.packed_atlas_output = null; // } @@ -211,13 +212,13 @@ pub fn draw() !void { // break :blk true; // }; - // if (dvui.dialogNativeFileSave(Globals.allocator(), .{ + // if (dvui.dialogNativeFileSave(runtime.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 = Globals.allocator().dupe(u8, path[0..]) catch null; + // project.packed_image_output = runtime.allocator().dupe(u8, path[0..]) catch null; // set_text = true; // } else { // dvui.log.err("Project failed to copy new path", .{}); @@ -244,7 +245,7 @@ pub fn draw() !void { // if (te.text_changed) { // const t = te.getText(); // if (t.len > 0) { - // project.packed_image_output = Globals.allocator().dupe(u8, t) catch null; + // project.packed_image_output = runtime.allocator().dupe(u8, t) catch null; // } else { // project.packed_image_output = null; // } @@ -259,7 +260,7 @@ const PathType = enum { }; fn pathTextEntry(path_type: PathType) !void { - if (Globals.state.project) |*project| { + if (runtime.state().project) |*project| { const output_path = switch (path_type) { .atlas => &project.packed_atlas_output, .image => &project.packed_image_output, @@ -316,7 +317,7 @@ fn pathTextEntry(path_type: PathType) !void { break :blk true; }; - Globals.state.host.showSaveDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{ + runtime.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; @@ -343,7 +344,7 @@ fn pathTextEntry(path_type: PathType) !void { if (te.text_changed) { const t = te.getText(); if (t.len > 0) { - output_path.* = Globals.allocator().dupe(u8, t) catch null; + output_path.* = runtime.allocator().dupe(u8, t) catch null; } else { output_path.* = null; } @@ -352,8 +353,8 @@ fn pathTextEntry(path_type: PathType) !void { } fn drawPackedAtlasStats() void { - const atlas = &Globals.packer.atlas.?; - const image_size = pixelart.image.size(atlas.source); + const atlas = &runtime.packer().atlas.?; + const image_size = pixi_mod.image.size(atlas.source); const atlas_w: u32 = @intFromFloat(image_size.w); const atlas_h: u32 = @intFromFloat(image_size.h); @@ -372,7 +373,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 (Globals.packer.last_packed_at_ns) |packed_at_ns| { + if (runtime.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); @@ -397,7 +398,7 @@ fn drawPackedAtlasStats() void { } fn formatLastPacked(buf: []u8, packed_at_ns: i128) []const u8 { - const elapsed_s = @divTrunc(pixelart.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s); + const elapsed_s = @divTrunc(pixi_mod.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s); if (elapsed_s < 10) { return std.fmt.bufPrint(buf, "just now", .{}) catch "recently"; } @@ -443,7 +444,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) { - pixelart.core.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{ + pixi_mod.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, @@ -456,24 +457,24 @@ fn packProjectButton(packing: bool) bool { } pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void { - if (Globals.state.project) |*project| { + if (runtime.state().project) |*project| { const output_path = &project.packed_atlas_output; if (paths) |paths_| { for (paths_) |path| { - output_path.* = Globals.allocator().dupe(u8, path) catch null; + output_path.* = runtime.allocator().dupe(u8, path) catch null; } } } } pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void { - if (Globals.state.project) |*project| { + if (runtime.state().project) |*project| { const output_path = &project.packed_image_output; if (paths) |paths_| { for (paths_) |path| { - output_path.* = Globals.allocator().dupe(u8, path) catch null; + output_path.* = runtime.allocator().dupe(u8, path) catch null; } } } @@ -483,7 +484,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 (Globals.state.host.openDocCount() == 0) { + if (runtime.state().host.openDocCount() == 0) { dvui.labelNoFmt( @src(), "Open one or more files to pack.", @@ -501,19 +502,19 @@ fn drawWeb() !void { .style = .highlight, }; - const packing = Globals.state.host.isPackingActive(); + const packing = PackProject.isActive(runtime.state()); if (packProjectButton(packing)) { - Globals.state.host.startPackProject() catch |err| { + PackProject.start(runtime.state()) catch |err| { dvui.log.err("Failed to pack open files: {any}", .{err}); }; } - if (Globals.packer.atlas != null) { + if (runtime.packer().atlas != null) { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } }); drawPackedAtlasStats(); } - if (Globals.packer.atlas) |atlas| { + if (runtime.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/src/explorer/sprites.zig b/src/plugins/pixi/src/explorer/sprites.zig similarity index 92% rename from src/plugins/pixelart/src/explorer/sprites.zig rename to src/plugins/pixi/src/explorer/sprites.zig index c431669f..121571da 100644 --- a/src/plugins/pixelart/src/explorer/sprites.zig +++ b/src/plugins/pixi/src/explorer/sprites.zig @@ -1,8 +1,8 @@ const std = @import("std"); const dvui = @import("dvui"); const icons = @import("icons"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const Sprites = @This(); @@ -90,7 +90,7 @@ pub fn init() Sprites { return .{}; } -fn selectionUiKey(file: *pixelart.internal.File) u64 { +fn selectionUiKey(file: *pixi_mod.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; @@ -100,7 +100,7 @@ fn selectionUiKey(file: *pixelart.internal.File) u64 { return (@as(u64, c) << 48) ^ (@as(u64, first) << 24) ^ @as(u64, last); } -fn selectionOriginsDifferFrom(file: *pixelart.internal.File, indices: []const usize, old_vals: []const [2]f32) bool { +fn selectionOriginsDifferFrom(file: *pixi_mod.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; @@ -112,36 +112,36 @@ fn freeOriginAxisDragSnapshot(self: *Sprites, axis: enum { x, y }) void { switch (axis) { .x => { if (self.origin_x_drag_indices) |s| { - Globals.allocator().free(s); + runtime.allocator().free(s); self.origin_x_drag_indices = null; } if (self.origin_x_drag_old_vals) |v| { - Globals.allocator().free(v); + runtime.allocator().free(v); self.origin_x_drag_old_vals = null; } }, .y => { if (self.origin_y_drag_indices) |s| { - Globals.allocator().free(s); + runtime.allocator().free(s); self.origin_y_drag_indices = null; } if (self.origin_y_drag_old_vals) |v| { - Globals.allocator().free(v); + runtime.allocator().free(v); self.origin_y_drag_old_vals = null; } }, } } -fn beginOriginAxisDragSnapshot(self: *Sprites, file: *pixelart.internal.File, axis: enum { x, y }) !void { +fn beginOriginAxisDragSnapshot(self: *Sprites, file: *pixi_mod.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 Globals.allocator().alloc(usize, count); - errdefer Globals.allocator().free(indices); - const old_vals = try Globals.allocator().alloc([2]f32, count); + const indices = try runtime.allocator().alloc(usize, count); + errdefer runtime.allocator().free(indices); + const old_vals = try runtime.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) { @@ -160,15 +160,15 @@ fn beginOriginAxisDragSnapshot(self: *Sprites, file: *pixelart.internal.File, ax } } -fn appendOriginsHistory(file: *pixelart.internal.File, indices: []usize, old_vals: [][2]f32) !void { +fn appendOriginsHistory(file: *pixi_mod.internal.File, indices: []usize, old_vals: [][2]f32) !void { file.history.append(.{ .origins = .{ .indices = indices, .values = old_vals } }) catch |err| { - Globals.allocator().free(indices); - Globals.allocator().free(old_vals); + runtime.allocator().free(indices); + runtime.allocator().free(old_vals); return err; }; } -fn applySpriteOriginAxisNoHistory(file: *pixelart.internal.File, axis: enum { x, y }, new_val: f32) void { +fn applySpriteOriginAxisNoHistory(file: *pixi_mod.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) { @@ -185,7 +185,7 @@ fn applySpriteOriginAxisNoHistory(file: *pixelart.internal.File, axis: enum { x, } } -fn commitSpriteOriginAxis(file: *pixelart.internal.File, axis: enum { x, y }, new_val: f32) !void { +fn commitSpriteOriginAxis(file: *pixi_mod.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) { @@ -197,10 +197,10 @@ fn commitSpriteOriginAxis(file: *pixelart.internal.File, axis: enum { x, y }, ne const count = file.editor.selected_sprites.count(); if (count == 0) return; - 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); + const indices = try runtime.allocator().alloc(usize, count); + errdefer runtime.allocator().free(indices); + const old_vals = try runtime.allocator().alloc([2]f32, count); + errdefer runtime.allocator().free(old_vals); var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); var i: usize = 0; @@ -220,14 +220,14 @@ fn commitSpriteOriginAxis(file: *pixelart.internal.File, axis: enum { x, y }, ne for (indices, 0..) |si, j| { file.sprites.items(.origin)[si] = old_vals[j]; } - Globals.allocator().free(indices); - Globals.allocator().free(old_vals); + runtime.allocator().free(indices); + runtime.allocator().free(old_vals); return err; }; } pub fn draw(self: *Sprites) !void { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { const parent_height = dvui.parentGet().data().rect.h - 2.0 * dvui.currentWindow().natural_scale; const parent_data = dvui.parentGet().data(); @@ -288,7 +288,7 @@ pub fn draw(self: *Sprites) !void { } pub fn drawOriginControls(self: *Sprites) !void { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (file.editor.selected_sprites.count() == 0) return; const key = selectionUiKey(file); @@ -417,8 +417,8 @@ pub fn drawOriginControls(self: *Sprites) !void { if (selectionOriginsDifferFrom(file, indices, old_vals)) { try appendOriginsHistory(file, indices, old_vals); } else { - Globals.allocator().free(indices); - Globals.allocator().free(old_vals); + runtime.allocator().free(indices); + runtime.allocator().free(old_vals); } } } @@ -488,8 +488,8 @@ pub fn drawOriginControls(self: *Sprites) !void { if (selectionOriginsDifferFrom(file, indices, old_vals)) { try appendOriginsHistory(file, indices, old_vals); } else { - Globals.allocator().free(indices); - Globals.allocator().free(old_vals); + runtime.allocator().free(indices); + runtime.allocator().free(old_vals); } } } @@ -506,7 +506,7 @@ pub fn drawAnimationControls(self: *Sprites) !void { const icon_color = dvui.themeGet().color(.control, .text); - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { { var add_animation_button: dvui.ButtonWidget = undefined; add_animation_button.init(@src(), .{}, .{ @@ -697,7 +697,7 @@ pub fn drawAnimations(self: *Sprites) !void { controls_box.deinit(); - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { // Make sure to update the prev anim count! defer self.prev_anim_count = file.animations.len; @@ -731,17 +731,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)) { - pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{}); } if (file.editor.animations_scroll_info.offset(.horizontal) > 0.0) { - pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{}); } } } const vertical_scroll = file.editor.animations_scroll_info.offset(.vertical); - var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ + var tree = pixi_mod.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ .expand = .horizontal, .background = false, }); @@ -768,8 +768,8 @@ pub fn drawAnimations(self: *Sprites) !void { } } - var moved = try Globals.allocator().alloc(pixelart.internal.Animation, sources.len); - defer Globals.allocator().free(moved); + var moved = try runtime.allocator().alloc(pixi_mod.internal.Animation, sources.len); + defer runtime.allocator().free(moved); for (sources, 0..) |s, i| { moved[i] = file.animations.get(s); } @@ -780,11 +780,11 @@ pub fn drawAnimations(self: *Sprites) !void { file.animations.orderedRemove(sources[ri]); } - const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); + const target_raw = pixi_mod.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); const target = @min(target_raw, file.animations.len); for (moved, 0..) |anim, i| { - file.animations.insert(Globals.allocator(), target + i, anim) catch { + file.animations.insert(runtime.allocator(), target + i, anim) catch { dvui.log.err("Failed to insert animation", .{}); }; } @@ -795,7 +795,7 @@ pub fn drawAnimations(self: *Sprites) !void { file.editor.selected_animation_indices.clearRetainingCapacity(); for (0..moved.len) |i| { - file.editor.selected_animation_indices.append(Globals.allocator(), target + i) catch { + file.editor.selected_animation_indices.append(runtime.allocator(), target + i) catch { dvui.log.err("Failed to update animation selection", .{}); }; } @@ -828,7 +828,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 (Globals.state.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(anim_id)); } @@ -993,13 +993,13 @@ pub fn drawAnimations(self: *Sprites) !void { file.history.append(.{ .animation_name = .{ .index = anim_index, - .name = try Globals.allocator().dupe(u8, file.animations.items(.name)[anim_index]), + .name = try runtime.allocator().dupe(u8, file.animations.items(.name)[anim_index]), }, }) catch { dvui.log.err("Failed to append history", .{}); }; - Globals.allocator().free(file.animations.items(.name)[anim_index]); - file.animations.items(.name)[anim_index] = try Globals.allocator().dupe(u8, te.getText()); + runtime.allocator().free(file.animations.items(.name)[anim_index]); + file.animations.items(.name)[anim_index] = try runtime.allocator().dupe(u8, te.getText()); } if (te.enter_pressed) { file.selected_animation_index = anim_index; @@ -1049,10 +1049,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) - pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); + pixi_mod.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) - pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); } } @@ -1062,7 +1062,7 @@ pub fn drawFrameControls(_: *Sprites) !void { }); defer box.deinit(); - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { const index = if (file.selected_animation_index) |i| i else 0; var animation = file.animations.get(index); @@ -1108,8 +1108,8 @@ pub fn drawFrameControls(_: *Sprites) !void { dvui.alphaSet(alpha); if (sort_anim_asc_button.clicked()) { - const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); - std.mem.sort(pixelart.internal.Animation.Frame, animation.frames, {}, FrameSort.asc); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); + std.mem.sort(pixi_mod.Animation.Frame, animation.frames, {}, FrameSort.asc); if (!animation.eqlFrames(prev_order)) { file.history.append(.{ @@ -1123,7 +1123,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.animations.set(index, animation); } else { - Globals.allocator().free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1168,8 +1168,8 @@ pub fn drawFrameControls(_: *Sprites) !void { dvui.alphaSet(alpha); if (sort_anim_desc_button.clicked()) { - const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); - std.mem.sort(pixelart.internal.Animation.Frame, animation.frames, {}, FrameSort.desc); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); + std.mem.sort(pixi_mod.Animation.Frame, animation.frames, {}, FrameSort.desc); if (!animation.eqlFrames(prev_order)) { file.history.append(.{ @@ -1183,7 +1183,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.animations.set(index, animation); } else { - Globals.allocator().free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1231,7 +1231,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(pixelart.internal.Animation.Frame).init(dvui.currentWindow().arena()); + var frames = std.array_list.Managed(pixi_mod.Animation.Frame).init(dvui.currentWindow().arena()); while (iter.next()) |sprite_index| { frames.append(.{ .sprite_index = sprite_index, @@ -1242,9 +1242,9 @@ pub fn drawFrameControls(_: *Sprites) !void { }; } - const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); - animation.appendFrames(Globals.allocator(), frames.items) catch { + animation.appendFrames(runtime.allocator(), frames.items) catch { dvui.log.err("Failed to append frames", .{}); }; @@ -1260,7 +1260,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.animations.set(index, animation); } else { - Globals.allocator().free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1319,12 +1319,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 Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); while (iter.next()) |sprite_index| { for (animation.frames) |frame| { if (frame.sprite_index == sprite_index) { - try animation.appendFrame(Globals.allocator(), .{ + try animation.appendFrame(runtime.allocator(), .{ .sprite_index = frame.sprite_index, .ms = frame.ms, }); @@ -1345,7 +1345,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.selected_animation_frame_index = 0; file.animations.set(index, animation); } else { - Globals.allocator().free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1391,13 +1391,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 Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); + const prev_order = try runtime.allocator().dupe(pixi_mod.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(Globals.allocator(), i - 1); + animation.removeFrame(runtime.allocator(), i - 1); break; } } @@ -1415,7 +1415,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.selected_animation_frame_index = 0; file.animations.set(index, animation); } else { - Globals.allocator().free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1423,7 +1423,7 @@ pub fn drawFrameControls(_: *Sprites) !void { } pub fn drawFrames(self: *Sprites) !void { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { var anim = dvui.animate(@src(), .{ .kind = .horizontal, .duration = 450_000, .easing = dvui.easing.outBack }, .{}); defer anim.deinit(); @@ -1481,7 +1481,7 @@ pub fn drawFrames(self: *Sprites) !void { defer self.prev_sprite_count = animation.frames.len; defer self.prev_anim_id = animation.id; - var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ + var tree = pixi_mod.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ .expand = .horizontal, .background = false, }); @@ -1494,7 +1494,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 Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); defer file.animations.set(animation_index, animation); const primary_before = file.selected_animation_frame_index; @@ -1508,14 +1508,14 @@ pub fn drawFrames(self: *Sprites) !void { } } - var moved = try Globals.allocator().alloc(pixelart.internal.Animation.Frame, sources.len); - defer Globals.allocator().free(moved); + var moved = try runtime.allocator().alloc(pixi_mod.Animation.Frame, sources.len); + defer runtime.allocator().free(moved); for (sources, 0..) |s, i| { moved[i] = animation.frames[s]; } - var remaining = try Globals.allocator().alloc(pixelart.internal.Animation.Frame, animation.frames.len - sources.len); - defer Globals.allocator().free(remaining); + var remaining = try runtime.allocator().alloc(pixi_mod.Animation.Frame, animation.frames.len - sources.len); + defer runtime.allocator().free(remaining); { var ri: usize = 0; var wi: usize = 0; @@ -1534,7 +1534,7 @@ pub fn drawFrames(self: *Sprites) !void { } } - const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); + const target_raw = pixi_mod.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); const target = @min(target_raw, remaining.len); var wi: usize = 0; @@ -1557,7 +1557,7 @@ pub fn drawFrames(self: *Sprites) !void { file.editor.selected_frame_indices.clearRetainingCapacity(); for (0..moved.len) |i| { - file.editor.selected_frame_indices.append(Globals.allocator(), target + i) catch { + file.editor.selected_frame_indices.append(runtime.allocator(), target + i) catch { dvui.log.err("Failed to update frame selection", .{}); }; } @@ -1575,7 +1575,7 @@ pub fn drawFrames(self: *Sprites) !void { dvui.log.err("Failed to append history", .{}); }; } else { - Globals.allocator().free(prev_order); + runtime.allocator().free(prev_order); } self.sprite_insert_before_index = null; @@ -1599,7 +1599,7 @@ pub fn drawFrames(self: *Sprites) !void { for (animation.frames, 0..) |*frame, frame_index| { var anim_color = dvui.themeGet().color(.control, .fill_hover); - if (Globals.state.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { anim_color = palette.getDVUIColor(@intCast(animation.id)); } @@ -1782,10 +1782,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) - pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); + pixi_mod.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) - pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); } } @@ -1799,21 +1799,21 @@ const FrameRowHit = struct { hbox_tl: dvui.Point.Physical, }; -fn frameGestureMatches(file: *const pixelart.internal.File, anim_id: u64) bool { +fn frameGestureMatches(file: *const pixi_mod.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 pixelart.internal.File) void { +fn frameTreeClearGestureKeysOnly(_: *const pixi_mod.internal.File) void { frame_row_gesture = null; } -fn frameTreeResetRowPointerGesture(_: *const pixelart.internal.File) void { +fn frameTreeResetRowPointerGesture(_: *const pixi_mod.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: *pixelart.internal.File, anim_index: usize) void { +fn syncSpritesFromCurrentFrameSelection(file: *pixi_mod.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 +1825,7 @@ fn syncSpritesFromCurrentFrameSelection(file: *pixelart.internal.File, anim_inde /// 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: *pixelart.internal.File, anim_index: usize, anim_id: u64) void { +fn ensureFrameSelection(file: *pixi_mod.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 +1848,7 @@ fn ensureFrameSelection(file: *pixelart.internal.File, anim_index: usize, anim_i 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(Globals.allocator(), i) catch return; + file.editor.selected_frame_indices.append(runtime.allocator(), i) catch return; } } std.sort.pdq(usize, file.editor.selected_frame_indices.items, {}, std.sort.asc(usize)); @@ -1879,11 +1879,11 @@ fn ensureFrameSelection(file: *pixelart.internal.File, anim_index: usize, anim_i } fn applyFrameClick( - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, anim_index: usize, anim_id: u64, clicked: usize, - mode: pixelart.core.dvui.TreeSelection.ClickMode, + mode: pixi_mod.core.dvui.TreeSelection.ClickMode, ) !bool { ensureFrameSelection(file, anim_index, anim_id); @@ -1904,7 +1904,7 @@ fn applyFrameClick( } var out: std.ArrayList(usize) = .empty; - defer out.deinit(Globals.allocator()); + defer out.deinit(runtime.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 +1916,8 @@ fn applyFrameClick( break :blk file.editor.selected_frame_indices.items[0]; } else file.selected_animation_frame_index; - const res = try pixelart.core.dvui.TreeSelection.applyClickUsize( - Globals.allocator(), + const res = try pixi_mod.core.dvui.TreeSelection.applyClickUsize( + runtime.allocator(), prev_multi, primary_for_tree, file.editor.frame_selection_anchor, @@ -1928,7 +1928,7 @@ fn applyFrameClick( ); file.editor.selected_frame_indices.clearRetainingCapacity(); - try file.editor.selected_frame_indices.appendSlice(Globals.allocator(), out.items); + try file.editor.selected_frame_indices.appendSlice(runtime.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 +1936,16 @@ fn applyFrameClick( return false; } -fn narrowFrameSelectionTo(file: *pixelart.internal.File, anim_index: usize, anim_id: u64, clicked: usize) void { +fn narrowFrameSelectionTo(file: *pixi_mod.internal.File, anim_index: usize, anim_id: u64, clicked: usize) void { file.editor.selected_frame_indices.clearRetainingCapacity(); - file.editor.selected_frame_indices.append(Globals.allocator(), clicked) catch return; + file.editor.selected_frame_indices.append(runtime.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 pixelart.internal.File, animation_index: usize, hits: []const FrameRowHit, out: []usize) []usize { +fn buildFrameMultiDragIds(file: *const pixi_mod.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 +1982,8 @@ fn buildFrameMultiDragIds(file: *const pixelart.internal.File, animation_index: } fn processFrameTreePointerEvents( - tree: *pixelart.core.dvui.TreeWidget, - file: *pixelart.internal.File, + tree: *pixi_mod.core.dvui.TreeWidget, + file: *pixi_mod.internal.File, anim_id: u64, animation_index: usize, hits: []const FrameRowHit, @@ -2013,7 +2013,7 @@ fn processFrameTreePointerEvents( frameTreeClearGestureKeysOnly(file); dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod); + const mode = pixi_mod.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 +2143,15 @@ const AnimationRowHit = struct { hbox_tl: dvui.Point.Physical, }; -fn animationGestureMatches(file: *const pixelart.internal.File) bool { +fn animationGestureMatches(file: *const pixi_mod.internal.File) bool { return animation_row_gesture != null and animation_row_gesture.?.file_id == file.id; } -fn animationTreeClearGestureKeysOnly(_: *const pixelart.internal.File) void { +fn animationTreeClearGestureKeysOnly(_: *const pixi_mod.internal.File) void { animation_row_gesture = null; } -fn animationTreeResetRowPointerGesture(_: *const pixelart.internal.File) void { +fn animationTreeResetRowPointerGesture(_: *const pixi_mod.internal.File) void { dvui.dragEnd(); animation_row_gesture = null; } @@ -2174,7 +2174,7 @@ fn animationPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Re return true; } -fn animationTreePointerInTreeSurface(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn animationTreePointerInTreeSurface(tree: *pixi_mod.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 +2182,12 @@ fn animationTreePointerInTreeSurface(tree: *pixelart.core.dvui.TreeWidget, p: dv return true; } -fn animationTreePointerInTreeBorder(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn animationTreePointerInTreeBorder(tree: *pixi_mod.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: *pixelart.core.dvui.TreeWidget, e: *dvui.Event) bool { +fn animationTreeMotionAllowsReorder(tree: *pixi_mod.core.dvui.TreeWidget, e: *dvui.Event) bool { if (e.target_widgetId) |fwid| { if (fwid == tree.data().id) return true; } @@ -2199,7 +2199,7 @@ fn animationTreeMotionAllowsReorder(tree: *pixelart.core.dvui.TreeWidget, e: *dv return in_surface or in_border; } -fn syncAnimationSelectionFrames(file: *pixelart.internal.File, anim_index: usize) void { +fn syncAnimationSelectionFrames(file: *pixi_mod.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 +2210,7 @@ fn syncAnimationSelectionFrames(file: *pixelart.internal.File, anim_index: usize } } -fn animationIndexInMulti(file: *const pixelart.internal.File, anim_index: usize) bool { +fn animationIndexInMulti(file: *const pixi_mod.internal.File, anim_index: usize) bool { for (file.editor.selected_animation_indices.items) |i| { if (i == anim_index) return true; } @@ -2220,7 +2220,7 @@ fn animationIndexInMulti(file: *const pixelart.internal.File, anim_index: usize) /// 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: *pixelart.internal.File) void { +fn ensureAnimationSelection(file: *pixi_mod.internal.File) void { const count = file.animations.len; if (count == 0) { file.editor.selected_animation_indices.clearRetainingCapacity(); @@ -2251,7 +2251,7 @@ fn ensureAnimationSelection(file: *pixelart.internal.File) void { } } if (!found) { - file.editor.selected_animation_indices.append(Globals.allocator(), p) catch return; + file.editor.selected_animation_indices.append(runtime.allocator(), p) catch return; std.sort.pdq(usize, file.editor.selected_animation_indices.items, {}, std.sort.asc(usize)); } } @@ -2263,7 +2263,7 @@ fn ensureAnimationSelection(file: *pixelart.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: *pixelart.internal.File, clicked: usize, mode: pixelart.core.dvui.TreeSelection.ClickMode) !bool { +fn applyAnimationClick(file: *pixi_mod.internal.File, clicked: usize, mode: pixi_mod.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 +2271,20 @@ fn applyAnimationClick(file: *pixelart.internal.File, clicked: usize, mode: pixe const defer_narrow = (mode == .replace and was_multi and was_in_multi); var out: std.ArrayList(usize) = .empty; - defer out.deinit(Globals.allocator()); + defer out.deinit(runtime.allocator()); if (defer_narrow) { - try out.appendSlice(Globals.allocator(), prev_multi); + try out.appendSlice(runtime.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(Globals.allocator(), out.items); + try file.editor.selected_animation_indices.appendSlice(runtime.allocator(), out.items); file.selected_animation_index = clicked; syncAnimationSelectionFrames(file, clicked); return true; } - const res = try pixelart.core.dvui.TreeSelection.applyClickUsize( - Globals.allocator(), + const res = try pixi_mod.core.dvui.TreeSelection.applyClickUsize( + runtime.allocator(), prev_multi, file.selected_animation_index, file.editor.animation_selection_anchor, @@ -2295,16 +2295,16 @@ fn applyAnimationClick(file: *pixelart.internal.File, clicked: usize, mode: pixe ); file.editor.selected_animation_indices.clearRetainingCapacity(); - try file.editor.selected_animation_indices.appendSlice(Globals.allocator(), out.items); + try file.editor.selected_animation_indices.appendSlice(runtime.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: *pixelart.internal.File, clicked: usize) void { +fn narrowAnimationSelectionTo(file: *pixi_mod.internal.File, clicked: usize) void { file.editor.selected_animation_indices.clearRetainingCapacity(); - file.editor.selected_animation_indices.append(Globals.allocator(), clicked) catch return; + file.editor.selected_animation_indices.append(runtime.allocator(), clicked) catch return; file.editor.animation_selection_anchor = clicked; file.selected_animation_index = clicked; syncAnimationSelectionFrames(file, clicked); @@ -2312,7 +2312,7 @@ fn narrowAnimationSelectionTo(file: *pixelart.internal.File, clicked: usize) voi /// 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 pixelart.internal.File, hits: []const AnimationRowHit, out: []usize) []usize { +fn buildAnimationMultiDragIds(file: *const pixi_mod.internal.File, hits: []const AnimationRowHit, out: []usize) []usize { var len: usize = 0; const primary = file.selected_animation_index; if (primary) |p| { @@ -2341,7 +2341,7 @@ fn buildAnimationMultiDragIds(file: *const pixelart.internal.File, hits: []const return out[0..len]; } -fn processAnimationTreePointerEvents(_: *Sprites, tree: *pixelart.core.dvui.TreeWidget, file: *pixelart.internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void { +fn processAnimationTreePointerEvents(_: *Sprites, tree: *pixi_mod.core.dvui.TreeWidget, file: *pixi_mod.internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void { if (!tree.init_options.enable_reordering) return; for (dvui.events()) |*e| { @@ -2367,7 +2367,7 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *pixelart.core.dvui.Tree animationTreeClearGestureKeysOnly(file); dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod); + const mode = pixi_mod.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 +2489,11 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *pixelart.core.dvui.Tree } const FrameSort = struct { - pub fn asc(_: void, a: pixelart.internal.Animation.Frame, b: pixelart.internal.Animation.Frame) bool { + pub fn asc(_: void, a: pixi_mod.Animation.Frame, b: pixi_mod.Animation.Frame) bool { return a.sprite_index < b.sprite_index; } - pub fn desc(_: void, a: pixelart.internal.Animation.Frame, b: pixelart.internal.Animation.Frame) bool { + pub fn desc(_: void, a: pixi_mod.Animation.Frame, b: pixi_mod.Animation.Frame) bool { return a.sprite_index > b.sprite_index; } }; diff --git a/src/plugins/pixelart/src/explorer/tools.zig b/src/plugins/pixi/src/explorer/tools.zig similarity index 91% rename from src/plugins/pixelart/src/explorer/tools.zig rename to src/plugins/pixi/src/explorer/tools.zig index 904b1a32..72818f6e 100644 --- a/src/plugins/pixelart/src/explorer/tools.zig +++ b/src/plugins/pixi/src/explorer/tools.zig @@ -3,8 +3,8 @@ const builtin = @import("builtin"); const dvui = @import("dvui"); const icons = @import("icons"); const assets = @import("assets"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const Tools = @This(); @@ -69,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 (Globals.state.docs.activeFile(Globals.state.host)) |file| file.layers.len else 0; + const layer_count: usize = if (runtime.state().docs.activeFile(runtime.state().host)) |file| file.layers.len else 0; defer prev_layer_count = layer_count; - var paned = pixelart.core.dvui.paned(@src(), .{ + var paned = pixi_mod.core.dvui.paned(@src(), .{ .direction = .vertical, .collapsed_size = 0, .handle_size = 10, @@ -82,7 +82,7 @@ pub fn draw(self: *Tools) !void { if (paned.dragging) { max_split_ratio = paned.split_ratio.*; - Globals.state.layers_ratio = paned.split_ratio.*; + runtime.state().layers_ratio = paned.split_ratio.*; } if (paned.showFirst()) { @@ -98,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 !Globals.state.pinned_palettes) { + if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !runtime.state().pinned_palettes) { if (dvui.firstFrame(paned.data().id) and layer_count == 0) paned.split_ratio.* = 0.0; @@ -109,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; - //Globals.state.layers_ratio = paned.split_ratio.*; + //runtime.state().layers_ratio = paned.split_ratio.*; } else { const ratio = paned.getFirstFittedRatio( .{ @@ -130,9 +130,9 @@ pub fn draw(self: *Tools) !void { if (layer_count == 0) paned.split_ratio.* = 0.0 else - paned.split_ratio.* = Globals.state.layers_ratio; + paned.split_ratio.* = runtime.state().layers_ratio; - Globals.state.layers_ratio = paned.split_ratio.*; + runtime.state().layers_ratio = paned.split_ratio.*; } } @@ -160,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(pixelart.Tools.Tool).len) |i| { - const tool: pixelart.Tools.Tool = @enumFromInt(i); + for (0..std.meta.fields(pixi_mod.Tools.Tool).len) |i| { + const tool: pixi_mod.Tools.Tool = @enumFromInt(i); const id_extra = i; - const selected = Globals.state.tools.current == tool; + const selected = runtime.state().tools.current == tool; var color = dvui.themeGet().color(.control, .fill_hover); - if (Globals.state.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(i); } - 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 selection_sprite = switch (runtime.state().tools.selection_mode) { + .pixel => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_default], + .box => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_default], + .color => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_default], }; const sprite = switch (tool) { - .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], + .pointer => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.cursor_default], + .pencil => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pencil_default], + .eraser => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.eraser_default], + .bucket => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.bucket_default], .selection => selection_sprite, }; var button: dvui.ButtonWidget = undefined; @@ -205,13 +205,13 @@ pub fn drawTools() !void { }); defer button.deinit(); - Globals.state.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {}; + runtime.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(Globals.state.host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; + const size: dvui.Size = dvui.imageSize(runtime.state().host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; const uv = dvui.Rect{ .x = @as(f32, @floatFromInt(sprite.source[0])) / size.w, @@ -233,7 +233,7 @@ pub fn drawTools() !void { rs.r.w = width; rs.r.h = height; - dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{ + dvui.renderImage(runtime.state().host.uiAtlas().source, rs, .{ .uv = uv, .fade = 0.0, }) catch { @@ -241,7 +241,7 @@ pub fn drawTools() !void { }; if (button.clicked()) { - Globals.state.tools.set(tool); + runtime.state().tools.set(tool); } } } @@ -254,7 +254,7 @@ pub fn drawLayerControls() !void { defer box.deinit(); dvui.labelNoFmt(@src(), "LAYERS", .{}, .{ .font = dvui.Font.theme(.heading), .gravity_y = 0.5 }); - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .background = false, @@ -403,7 +403,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { }); defer vbox.deinit(); - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { layer_rename_hit_te_id = null; layer_rename_hit_rect = null; file.editor.layer_drag_preview_removed = null; @@ -425,7 +425,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { const vertical_scroll = file.editor.layers_scroll_info.offset(.vertical); - var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ + var tree = pixi_mod.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ .expand = .horizontal, .background = false, }); @@ -439,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 Globals.allocator().alloc(u64, file.layers.len); + const prev_order = try runtime.allocator().alloc(u64, file.layers.len); for (file.layers.items(.id), 0..) |id, i| { prev_order[i] = id; } @@ -456,8 +456,8 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { } // Snapshot moved layers before any removal so indices stay valid. - var moved = try Globals.allocator().alloc(pixelart.internal.Layer, sources.len); - defer Globals.allocator().free(moved); + var moved = try runtime.allocator().alloc(pixi_mod.internal.Layer, sources.len); + defer runtime.allocator().free(moved); for (sources, 0..) |s, i| { moved[i] = file.layers.get(s); } @@ -469,11 +469,11 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { file.layers.orderedRemove(sources[ri]); } - const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); + const target_raw = pixi_mod.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); const target = @min(target_raw, file.layers.len); for (moved, 0..) |layer, i| { - file.layers.insert(Globals.allocator(), target + i, layer) catch { + file.layers.insert(runtime.allocator(), target + i, layer) catch { dvui.log.err("Failed to insert layer", .{}); }; } @@ -488,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(Globals.allocator(), target + i) catch { + file.editor.selected_layer_indices.append(runtime.allocator(), target + i) catch { dvui.log.err("Failed to update layer selection", .{}); }; } @@ -506,7 +506,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { dvui.log.err("Failed to append history", .{}); }; } else { - Globals.allocator().free(prev_order); + runtime.allocator().free(prev_order); } insert_before_index = null; @@ -540,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 (Globals.state.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(layer_id)); } @@ -590,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 (!Globals.state.tools_pane.layersHovered()) { + } else if (!runtime.state().tools_pane.layersHovered()) { min_layer_index = file.selected_layer_index; } } @@ -720,13 +720,13 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { file.history.append(.{ .layer_name = .{ .index = layer_index, - .name = try Globals.allocator().dupe(u8, file.layers.items(.name)[layer_index]), + .name = try runtime.allocator().dupe(u8, file.layers.items(.name)[layer_index]), }, }) catch { dvui.log.err("Failed to append history", .{}); }; - Globals.allocator().free(file.layers.items(.name)[layer_index]); - file.layers.items(.name)[layer_index] = try Globals.allocator().dupe(u8, te.getText()); + runtime.allocator().free(file.layers.items(.name)[layer_index]); + file.layers.items(.name)[layer_index] = try runtime.allocator().dupe(u8, te.getText()); } if (te.enter_pressed) { file.selected_layer_index = layer_index; @@ -918,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) - pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); + pixi_mod.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)) - pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); } - if (pixelart.core.dvui.hovered(vbox.data())) { + if (pixi_mod.core.dvui.hovered(vbox.data())) { const mp = dvui.currentWindow().mouse_pt; if (tools.layers_scroll_viewport_rect) |vr| { if (!vr.contains(mp)) return null; @@ -946,8 +946,8 @@ pub fn drawColors() !void { }); defer hbox.deinit(); - 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 primary: dvui.Color = .{ .r = runtime.state().colors.primary[0], .g = runtime.state().colors.primary[1], .b = runtime.state().colors.primary[2], .a = runtime.state().colors.primary[3] }; + const secondary: dvui.Color = .{ .r = runtime.state().colors.secondary[0], .g = runtime.state().colors.secondary[1], .b = runtime.state().colors.secondary[2], .a = runtime.state().colors.secondary[3] }; const button_opts: dvui.Options = .{ .expand = .both, @@ -979,7 +979,7 @@ pub fn drawColors() !void { primary_button.init(@src(), .{}, button_opts); defer primary_button.deinit(); - try drawColorPicker(primary_button.data().rectScale().r, &Globals.state.colors.primary, 0); + try drawColorPicker(primary_button.data().rectScale().r, &runtime.state().colors.primary, 0); primary_button.processEvents(); primary_button.drawBackground(); @@ -992,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, &Globals.state.colors.secondary, 1); + try drawColorPicker(secondary_button.data().rectScale().r, &runtime.state().colors.secondary, 1); secondary_button.processEvents(); secondary_button.drawBackground(); @@ -1001,7 +1001,7 @@ pub fn drawColors() !void { } if (clicked) { - std.mem.swap([4]u8, &Globals.state.colors.primary, &Globals.state.colors.secondary); + std.mem.swap([4]u8, &runtime.state().colors.primary, &runtime.state().colors.secondary); } } @@ -1070,9 +1070,9 @@ pub fn drawPaletteControls() !void { .corner_radius = dvui.Rect.all(1000), }, .rotation = std.math.pi * 0.25, - .style = if (Globals.state.pinned_palettes) .highlight else .control, + .style = if (runtime.state().pinned_palettes) .highlight else .control, })) { - Globals.state.pinned_palettes = !Globals.state.pinned_palettes; + runtime.state().pinned_palettes = !runtime.state().pinned_palettes; } } @@ -1104,7 +1104,7 @@ pub fn drawPalettes() !void { .gravity_x = 1.0, }); - if (Globals.state.colors.palette) |*palette| { + if (runtime.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) }); @@ -1134,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)) { - Globals.state.colors.palette = pixelart.internal.Palette.loadFromBytes(Globals.allocator(), entry.name, data) catch |err| { + runtime.state().colors.palette = pixi_mod.internal.Palette.loadFromBytes(runtime.allocator(), entry.name, data) catch |err| { dvui.log.err("Failed to load palette: {s}", .{@errorName(err)}); return error.FailedToLoadPalette; }; @@ -1158,12 +1158,12 @@ pub fn drawPalettes() !void { } { - if (Globals.state.colors.palette) |*palette| { + if (runtime.state().colors.palette) |*palette| { var flex_box = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .expand = .horizontal, .max_size_content = .{ - .w = Globals.state.host.explorerRect().w - 20 * dvui.currentWindow().natural_scale, - .h = Globals.state.host.explorerRect().h - 20 * dvui.currentWindow().natural_scale, + .w = runtime.state().host.explorerRect().w - 20 * dvui.currentWindow().natural_scale, + .h = runtime.state().host.explorerRect().h - 20 * dvui.currentWindow().natural_scale, }, }); @@ -1245,9 +1245,9 @@ pub fn drawPalettes() !void { switch (evt) { .mouse => |mouse_evt| { if (mouse_evt.button.pointer() or mouse_evt.button.touch()) { - @memcpy(&Globals.state.colors.primary, &color); + @memcpy(&runtime.state().colors.primary, &color); } else if (mouse_evt.button == .right) { - @memcpy(&Globals.state.colors.secondary, &color); + @memcpy(&runtime.state().colors.secondary, &color); } }, else => {}, @@ -1268,7 +1268,7 @@ pub fn drawPalettes() !void { } fn searchPalettes(dropdown: *dvui.DropdownWidget) !void { const io = dvui.io; - const palette_folder = Globals.state.host.paletteFolder() orelse return; + const palette_folder = runtime.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); @@ -1281,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 (Globals.state.colors.palette) |*palette| + if (runtime.state().colors.palette) |*palette| palette.deinit(); - Globals.state.colors.palette = pixelart.internal.Palette.loadFromFile(Globals.allocator(), abs_path) catch |err| { + runtime.state().colors.palette = pixi_mod.internal.Palette.loadFromFile(runtime.allocator(), abs_path) catch |err| { dvui.log.err("Failed to load palette: {s}", .{@errorName(err)}); return error.FailedToLoadPalette; }; @@ -1318,12 +1318,12 @@ fn pointerReleaseInRectWithoutSelectionModifier(r: dvui.Rect.Physical) bool { return false; } -fn layerGestureMatches(file: *const pixelart.internal.File) bool { +fn layerGestureMatches(file: *const pixi_mod.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 pixelart.internal.File, layer_index: usize) bool { +fn layerIndexInMulti(file: *const pixi_mod.internal.File, layer_index: usize) bool { for (file.editor.selected_layer_indices.items) |i| { if (i == layer_index) return true; } @@ -1332,7 +1332,7 @@ fn layerIndexInMulti(file: *const pixelart.internal.File, layer_index: usize) bo /// 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: *pixelart.internal.File) void { +fn ensureLayerSelection(file: *pixi_mod.internal.File) void { var sel = &file.editor.selected_layer_indices; // Drop out-of-range entries. @@ -1359,7 +1359,7 @@ fn ensureLayerSelection(file: *pixelart.internal.File) void { } } if (!has_primary and file.layers.len > 0) { - sel.append(Globals.allocator(), file.selected_layer_index) catch return; + sel.append(runtime.allocator(), file.selected_layer_index) catch return; std.sort.pdq(usize, sel.items, {}, std.sort.asc(usize)); } } @@ -1374,9 +1374,9 @@ const LayerClickApplied = struct { }; fn applyLayerClick( - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, clicked: usize, - mode: pixelart.core.dvui.TreeSelection.ClickMode, + mode: pixi_mod.core.dvui.TreeSelection.ClickMode, ) LayerClickApplied { const count_before = file.editor.selected_layer_indices.items.len; @@ -1387,10 +1387,10 @@ fn applyLayerClick( } var tmp: std.ArrayList(usize) = .empty; - defer tmp.deinit(Globals.allocator()); + defer tmp.deinit(runtime.allocator()); - const res = pixelart.core.dvui.TreeSelection.applyClickUsize( - Globals.allocator(), + const res = pixi_mod.core.dvui.TreeSelection.applyClickUsize( + runtime.allocator(), file.editor.selected_layer_indices.items, file.selected_layer_index, file.editor.layer_selection_anchor, @@ -1401,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(Globals.allocator(), tmp.items) catch {}; + file.editor.selected_layer_indices.appendSlice(runtime.allocator(), tmp.items) catch {}; const new_primary = res.primary orelse clicked; file.selected_layer_index = new_primary; @@ -1412,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: *pixelart.internal.File, clicked: usize) void { +fn narrowLayerSelectionTo(file: *pixi_mod.internal.File, clicked: usize) void { file.editor.selected_layer_indices.clearRetainingCapacity(); - file.editor.selected_layer_indices.append(Globals.allocator(), clicked) catch {}; + file.editor.selected_layer_indices.append(runtime.allocator(), clicked) catch {}; file.selected_layer_index = clicked; file.editor.layer_selection_anchor = clicked; } @@ -1424,7 +1424,7 @@ fn narrowLayerSelectionTo(file: *pixelart.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 pixelart.internal.File, + file: *const pixi_mod.internal.File, hits: []const LayerRowHit, out: []usize, ) usize { @@ -1444,12 +1444,12 @@ fn buildLayerMultiDragIds( } /// Clear in-flight gesture only (no `dragEnd`). Used before arming a new row press. -fn layerTreeClearGestureKeysOnly(_: *const pixelart.internal.File) void { +fn layerTreeClearGestureKeysOnly(_: *const pixi_mod.internal.File) void { layer_row_gesture = null; } /// Clear gesture and global `Dragging` (stale prestart/drag from other widgets). -fn layerTreeResetRowPointerGesture(_: *const pixelart.internal.File) void { +fn layerTreeResetRowPointerGesture(_: *const pixi_mod.internal.File) void { dvui.dragEnd(); layer_row_gesture = null; } @@ -1476,7 +1476,7 @@ fn layerPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Rect.P return true; } -fn layerTreePointerInTreeSurface(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn layerTreePointerInTreeSurface(tree: *pixi_mod.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; @@ -1484,14 +1484,14 @@ fn layerTreePointerInTreeSurface(tree: *pixelart.core.dvui.TreeWidget, p: dvui.P return true; } -fn layerTreePointerInTreeBorder(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn layerTreePointerInTreeBorder(tree: *pixi_mod.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: *pixelart.core.dvui.TreeWidget, e: *dvui.Event) bool { +fn layerTreeMotionAllowsLayerReorder(tree: *pixi_mod.core.dvui.TreeWidget, e: *dvui.Event) bool { if (e.target_widgetId) |fwid| { if (fwid == tree.data().id) return true; } @@ -1505,7 +1505,7 @@ fn layerTreeMotionAllowsLayerReorder(tree: *pixelart.core.dvui.TreeWidget, e: *d /// 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: *pixelart.core.dvui.TreeWidget, file: *pixelart.internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void { +fn processLayerTreePointerEvents(tree: *pixi_mod.core.dvui.TreeWidget, file: *pixi_mod.internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void { if (!tree.init_options.enable_reordering) return; for (dvui.events()) |*e| { @@ -1531,7 +1531,7 @@ fn processLayerTreePointerEvents(tree: *pixelart.core.dvui.TreeWidget, file: *pi layerTreeClearGestureKeysOnly(file); dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod); + const mode = pixi_mod.core.dvui.TreeSelection.clickModeFromMod(me.mod); const applied = applyLayerClick(file, h.layer_index, mode); layer_row_gesture = .{ diff --git a/src/plugins/pixelart/src/infobar_status.zig b/src/plugins/pixi/src/infobar_status.zig similarity index 93% rename from src/plugins/pixelart/src/infobar_status.zig rename to src/plugins/pixi/src/infobar_status.zig index 068e6c1f..32e66474 100644 --- a/src/plugins/pixelart/src/infobar_status.zig +++ b/src/plugins/pixi/src/infobar_status.zig @@ -2,11 +2,11 @@ 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 pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; +const DocHandle = pixi_mod.sdk.DocHandle; const DimensionsLabel = @import("dialogs/dimensions_label.zig"); fn docFile(st: *State, doc: DocHandle) ?*Internal.File { diff --git a/src/plugins/pixelart/src/internal/Animation.zig b/src/plugins/pixi/src/internal/Animation.zig similarity index 100% rename from src/plugins/pixelart/src/internal/Animation.zig rename to src/plugins/pixi/src/internal/Animation.zig diff --git a/src/plugins/pixelart/src/internal/Atlas.zig b/src/plugins/pixi/src/internal/Atlas.zig similarity index 80% rename from src/plugins/pixelart/src/internal/Atlas.zig rename to src/plugins/pixi/src/internal/Atlas.zig index dc160a02..3e59872c 100644 --- a/src/plugins/pixelart/src/internal/Atlas.zig +++ b/src/plugins/pixi/src/internal/Atlas.zig @@ -3,14 +3,14 @@ const dvui = @import("dvui"); const Atlas = @This(); const ExternalAtlas = @import("../Atlas.zig"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const alpha_checkerboard_count: u32 = 8; /// The packed atlas texture source: dvui.ImageSource, -canvas: pixelart.core.dvui.CanvasWidget = .{}, +canvas: pixi_mod.core.dvui.CanvasWidget = .{}, /// Checkerboard tile for the project-tab atlas preview (not tied to open files). checkerboard_tile: ?dvui.Texture = null, @@ -23,11 +23,11 @@ data: ExternalAtlas, pub fn initCheckerboardTile(atlas: *Atlas) void { deinitCheckerboardTile(atlas); - atlas.checkerboard_tile = pixelart.image.checkerboardTile( + atlas.checkerboard_tile = pixi_mod.image.checkerboardTile( alpha_checkerboard_count, alpha_checkerboard_count, - Globals.state.settings.checker_color_even, - Globals.state.settings.checker_color_odd, + runtime.state().settings.checker_color_even, + runtime.state().settings.checker_color_odd, ); } @@ -49,16 +49,16 @@ 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 = Globals.state.host.arena(); + const allocator = runtime.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 pixelart.image.writePngToWriter(atlas.source, &out.writer, 72); + try pixi_mod.image.writePngToWriter(atlas.source, &out.writer, 72); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - try pixelart.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72); + try pixi_mod.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72); } else { std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{}); return error.InvalidExtension; @@ -84,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(Globals.state.host.arena(), "{s}", .{path}, 0) catch unreachable; + const write_path = std.fmt.allocPrintSentinel(runtime.state().host.arena(), "{s}", .{path}, 0) catch unreachable; if (std.mem.eql(u8, ext, ".png")) { - try pixelart.image.writeToPng(atlas.source, write_path); + try pixi_mod.image.writeToPng(atlas.source, write_path); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - try pixelart.image.writeToJpg(atlas.source, write_path); + try pixi_mod.image.writeToJpg(atlas.source, write_path); } else { std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{}); return error.InvalidExtension; @@ -102,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(Globals.state.host.arena(), atlas.data, options); + const output = try std.json.Stringify.valueAlloc(runtime.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/src/internal/Buffers.zig b/src/plugins/pixi/src/internal/Buffers.zig similarity index 78% rename from src/plugins/pixelart/src/internal/Buffers.zig rename to src/plugins/pixi/src/internal/Buffers.zig index b498e92f..48b85a61 100644 --- a/src/plugins/pixelart/src/internal/Buffers.zig +++ b/src/plugins/pixi/src/internal/Buffers.zig @@ -1,8 +1,8 @@ const std = @import("std"); const History = @import("History.zig"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const Buffers = @This(); stroke: Stroke, @@ -13,7 +13,7 @@ pub const Stroke = struct { //values: std.ArrayList([4]u8), pixels: std.AutoHashMap(usize, [4]u8), - //canvas: pixelart.file.gui.canvas = .primary, + //canvas: pixi_mod.file.gui.canvas = .primary, pub fn init(allocator: std.mem.Allocator) Stroke { return .{ @@ -25,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 (pixelart.perf.record) { - pixelart.perf.stroke_append_calls += 1; - if (!ptr.found_existing) pixelart.perf.stroke_append_new_keys += 1; + if (pixi_mod.perf.record) { + pixi_mod.perf.stroke_append_calls += 1; + if (!ptr.found_existing) pixi_mod.perf.stroke_append_new_keys += 1; } if (!ptr.found_existing) ptr.value_ptr.* = value; @@ -49,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 (pixelart.perf.record) { - pixelart.perf.stroke_append_calls += 1; - if (!gop.found_existing) pixelart.perf.stroke_append_new_keys += 1; + if (pixi_mod.perf.record) { + pixi_mod.perf.stroke_append_calls += 1; + if (!gop.found_existing) pixi_mod.perf.stroke_append_new_keys += 1; } if (!gop.found_existing) gop.value_ptr.* = value; @@ -68,14 +68,14 @@ pub const Stroke = struct { } pub fn toChange(stroke: *Stroke, layer_id: u64) !History.Change { - const t0: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; + const t0: i128 = if (pixi_mod.perf.record) pixi_mod.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 = 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 indices = runtime.allocator().alloc(usize, n) catch return error.MemoryAllocationFailed; + errdefer runtime.allocator().free(indices); + var values = runtime.allocator().alloc([4]u8, n) catch return error.MemoryAllocationFailed; + errdefer runtime.allocator().free(values); var it = stroke.pixels.iterator(); @@ -88,10 +88,10 @@ pub const Stroke = struct { stroke.pixels.clearAndFree(); - 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; + if (pixi_mod.perf.record) { + pixi_mod.perf.stroke_to_change_ns +%= @intCast(pixi_mod.perf.nanoTimestamp() - t0); + pixi_mod.perf.stroke_to_change_calls += 1; + pixi_mod.perf.stroke_to_change_pixels_out +%= n; } return .{ .pixels = .{ diff --git a/src/plugins/pixelart/src/internal/File.zig b/src/plugins/pixi/src/internal/File.zig similarity index 89% rename from src/plugins/pixelart/src/internal/File.zig rename to src/plugins/pixi/src/internal/File.zig index a61ca14a..01db172b 100644 --- a/src/plugins/pixelart/src/internal/File.zig +++ b/src/plugins/pixi/src/internal/File.zig @@ -10,9 +10,9 @@ const File = @This(); const Layer = @import("Layer.zig"); const Sprite = @import("Sprite.zig"); const Animation = @import("Animation.zig"); -const pixelart = @import("../../pixelart.zig"); +const pixi_mod = @import("../../pixi.zig"); const plugin = @import("../plugin.zig"); -const Globals = pixelart.Globals; +const runtime = @import("../runtime.zig"); const alpha_checkerboard_count: u32 = 8; @@ -64,7 +64,7 @@ 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: pixelart.core.dvui.CanvasWidget = .{}, + canvas: pixi_mod.core.dvui.CanvasWidget = .{}, layers_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, sprites_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, animations_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, @@ -186,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 (`pixelart.perf.nanoTimestamp`): save-complete affordance in tab / tree. + /// Monotonic deadline (`pixi_mod.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, @@ -202,40 +202,40 @@ pub const InitOptions = struct { row_height: u32, }; -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), +pub fn init(path: []const u8, options: InitOptions) !pixi_mod.internal.File { + var internal: pixi_mod.internal.File = .{ + .id = runtime.state().host.allocDocId(), + .path = try runtime.allocator().dupe(u8, path), .columns = options.columns, .rows = options.rows, .column_width = options.column_width, .row_height = options.row_height, - .history = pixelart.internal.File.History.init(Globals.allocator()), - .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), + .history = pixi_mod.internal.File.History.init(runtime.allocator()), + .buffers = pixi_mod.internal.File.Buffers.init(runtime.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(Globals.allocator(), internal.spriteCount()); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount()); - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.width() * internal.height()); + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height()); // Create a layer-sized checkerboard pattern for selection tools for (0..internal.width() * internal.height()) |i| { - const value = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + const value = pixi_mod.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: 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; + internal.layers.append(runtime.allocator(), layer) catch return error.LayerCreateError; } // Initialize sprites for (0..internal.spriteCount()) |_| { - internal.sprites.append(Globals.allocator(), .{ + internal.sprites.append(runtime.allocator(), .{ .origin = .{ 0.0, 0.0 }, }) catch return error.FileLoadError; } @@ -262,11 +262,11 @@ pub fn checkerboardTileTexture(file: *File) ?dvui.Texture { dvui.textureDestroyLater(t); file.editor.checkerboard_tile = null; } - file.editor.checkerboard_tile = pixelart.image.checkerboardTile( + file.editor.checkerboard_tile = pixi_mod.image.checkerboardTile( want.w, want.h, - Globals.state.settings.checker_color_even, - Globals.state.settings.checker_color_odd, + runtime.state().settings.checker_color_even, + runtime.state().settings.checker_color_odd, ); return file.editor.checkerboard_tile; } @@ -294,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 = pixelart.perf.nanoTimestamp(); + const now = pixi_mod.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); @@ -320,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 = pixelart.perf.nanoTimestamp(); + const now = pixi_mod.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) { @@ -351,12 +351,12 @@ pub fn showSaveDoneFlash(file: *const File) bool { return timeSinceSaveComplete(file) != null; } -/// Nanoseconds since save finished (`null` when inactive). Drives [`pixelart.core.dvui.bubbleSpinner`]'s +/// Nanoseconds since save finished (`null` when inactive). Drives [`pixi_mod.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 = pixelart.perf.nanoTimestamp(); + const now = pixi_mod.perf.nanoTimestamp(); if (now >= until) return null; return @max(@as(i128, 0), now - st); } @@ -388,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) !?pixelart.internal.File { +pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?pixi_mod.internal.File { const extension = std.fs.path.extension(path); if (isFlatImageExtension(extension)) { return fromBytesFlatImage(path, file_bytes); @@ -400,7 +400,7 @@ pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?pixelart.internal.F } /// Attempts to load a file from the given path to create a new file -pub fn fromPath(path: []const u8) !?pixelart.internal.File { +pub fn fromPath(path: []const u8) !?pixi_mod.internal.File { const extension = std.fs.path.extension(path[0..path.len]); if (isFlatImageExtension(extension)) { const file = fromPathFlatImage(path) catch |err| { @@ -426,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) !?pixelart.internal.File { +pub fn fromPathFizzy(path: []const u8) !?pixi_mod.internal.File { return loadFizzyZip(path, null); } -pub fn fromBytesFizzy(path: []const u8, file_bytes: []const u8) !?pixelart.internal.File { +pub fn fromBytesFizzy(path: []const u8, file_bytes: []const u8) !?pixi_mod.internal.File { return loadFizzyZip(path, file_bytes); } -fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.File { +fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixi_mod.internal.File { if (!isFizzyExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; const null_terminated_path = if (file_bytes == null) - try Globals.allocator().dupeZ(u8, path) + try runtime.allocator().dupeZ(u8, path) else ""; - defer if (file_bytes == null) Globals.allocator().free(null_terminated_path); + defer if (file_bytes == null) runtime.allocator().free(null_terminated_path); zip_open: { const fizzy_file = if (file_bytes) |bytes| @@ -475,19 +475,19 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F .ignore_unknown_fields = true, }; - var try_parse: ?std.json.Parsed(pixelart.File) = null; - try_parse = std.json.parseFromSlice(pixelart.File, Globals.allocator(), content, options) catch null; + var try_parse: ?std.json.Parsed(pixi_mod.File) = null; + try_parse = std.json.parseFromSlice(pixi_mod.File, runtime.allocator(), content, options) catch null; - var ext: pixelart.File = if (try_parse) |parsed| parsed.value else undefined; + var ext: pixi_mod.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(pixelart.File.FileV3, Globals.allocator(), content, options) catch null) |old_file| { + if (std.json.parseFromSlice(pixi_mod.File.FileV3, runtime.allocator(), content, options) catch null) |old_file| { std.log.info("Loading file v3: {s}", .{path}); - const animations = try Globals.allocator().alloc(pixelart.Animation, old_file.value.animations.len); + const animations = try runtime.allocator().alloc(pixi_mod.Animation, old_file.value.animations.len); for (animations, old_file.value.animations) |*animation, old_animation| { - animation.name = try Globals.allocator().dupe(u8, old_animation.name); - animation.frames = try Globals.allocator().alloc(Animation.Frame, old_animation.frames.len); + animation.name = try runtime.allocator().dupe(u8, old_animation.name); + animation.frames = try runtime.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); @@ -504,12 +504,12 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F .sprites = old_file.value.sprites, .animations = animations, }; - } else if (std.json.parseFromSlice(pixelart.File.FileV2, Globals.allocator(), content, options) catch null) |old_file| { + } else if (std.json.parseFromSlice(pixi_mod.File.FileV2, runtime.allocator(), content, options) catch null) |old_file| { std.log.info("Loading file v2: {s}", .{path}); - const animations = try Globals.allocator().alloc(pixelart.Animation, old_file.value.animations.len); + const animations = try runtime.allocator().alloc(pixi_mod.Animation, old_file.value.animations.len); for (animations, old_file.value.animations) |*animation, old_animation| { - animation.name = try Globals.allocator().dupe(u8, old_animation.name); - animation.frames = try Globals.allocator().alloc(Animation.Frame, old_animation.frames.len); + animation.name = try runtime.allocator().dupe(u8, old_animation.name); + animation.frames = try runtime.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); @@ -526,12 +526,12 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F .sprites = old_file.value.sprites, .animations = animations, }; - } else if (std.json.parseFromSlice(pixelart.File.FileV1, Globals.allocator(), content, options) catch null) |old_file| { + } else if (std.json.parseFromSlice(pixi_mod.File.FileV1, runtime.allocator(), content, options) catch null) |old_file| { std.log.info("Loading file v1: {s}", .{path}); - const animations = try Globals.allocator().alloc(pixelart.Animation, old_file.value.animations.len); + const animations = try runtime.allocator().alloc(pixi_mod.Animation, old_file.value.animations.len); for (animations, 0..) |*animation, i| { - 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); + animation.name = try runtime.allocator().dupe(u8, old_file.value.animations[i].name); + animation.frames = try runtime.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); @@ -555,15 +555,15 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F //defer parsed.deinit(); - var internal: pixelart.internal.File = .{ - .id = Globals.state.host.allocDocId(), - .path = try Globals.allocator().dupe(u8, path), + var internal: pixi_mod.internal.File = .{ + .id = runtime.state().host.allocDocId(), + .path = try runtime.allocator().dupe(u8, path), .columns = ext.columns, .rows = ext.rows, .column_width = ext.column_width, .row_height = ext.row_height, - .history = pixelart.internal.File.History.init(Globals.allocator()), - .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), + .history = pixi_mod.internal.File.History.init(runtime.allocator()), + .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()), }; //Initialize editor layers and selected sprites @@ -572,21 +572,21 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F 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(Globals.allocator(), internal.spriteCount()); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount()); - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.width() * internal.height()); + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height()); // Create a layer-sized checkerboard pattern for selection tools for (0..internal.width() * internal.height()) |i| { - const value = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + const value = pixi_mod.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(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); + const layer_image_name = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.layer", .{l.name}, 0) catch "Memory Allocation Failed"; + defer runtime.allocator().free(layer_image_name); + const png_image_name = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{l.name}, 0) catch "Memory Allocation Failed"; + defer runtime.allocator().free(png_image_name); var img_buf: ?*anyopaque = null; var img_len: usize = 0; @@ -609,7 +609,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F new_layer.setMaskFromTransparency(true); - internal.layers.append(Globals.allocator(), new_layer) catch return error.FileLoadError; + internal.layers.append(runtime.allocator(), new_layer) catch return error.FileLoadError; if (l.visible and !set_layer_index) { internal.selected_layer_index = i; @@ -631,7 +631,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F new_layer.setMaskFromTransparency(true); - internal.layers.append(Globals.allocator(), new_layer) catch return error.FileLoadError; + internal.layers.append(runtime.allocator(), new_layer) catch return error.FileLoadError; if (l.visible and !set_layer_index) { internal.selected_layer_index = i; @@ -645,21 +645,21 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F for (0..internal.spriteCount()) |sprite_index| { if (sprite_index >= ext.sprites.len) { - internal.sprites.append(Globals.allocator(), .{ + internal.sprites.append(runtime.allocator(), .{ .origin = .{ 0, 0 }, }) catch return error.FileLoadError; } else { - internal.sprites.append(Globals.allocator(), .{ + internal.sprites.append(runtime.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(Globals.allocator(), .{ + internal.animations.append(runtime.allocator(), .{ .id = internal.newAnimationID(), - .name = try Globals.allocator().dupe(u8, animation.name), - .frames = try Globals.allocator().dupe(Animation.Frame, animation.frames), + .name = try runtime.allocator().dupe(u8, animation.name), + .frames = try runtime.allocator().dupe(Animation.Frame, animation.frames), }) catch return error.FileLoadError; } return internal; @@ -668,7 +668,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F // var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; // var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; - // if (pixelart.fs.read(Globals.allocator(), path) catch null) |file_bytes| { + // if (pixi_mod.fs.read(runtime.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(), .{ @@ -676,7 +676,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F // .link_name_buffer = &link_name_buffer, // }); - // var json_content = std.array_list.Managed(u8).init(Globals.allocator()); + // var json_content = std.array_list.Managed(u8).init(runtime.allocator()); // defer json_content.deinit(); // while (try iter.next()) |entry| { @@ -691,23 +691,23 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F // .ignore_unknown_fields = true, // }; - // if (std.json.parseFromSlice(pixelart.File, Globals.allocator(), json_content.items, options) catch null) |parsed| { + // if (std.json.parseFromSlice(pixi_mod.File, runtime.allocator(), json_content.items, options) catch null) |parsed| { // defer parsed.deinit(); // std.log.debug("Parsed fizzydata.json!", .{}); // const ext = parsed.value; - // var internal: pixelart.internal.File = .{ - // .id = Globals.state.host.allocDocId(), - // .path = try Globals.allocator().dupe(u8, path), + // var internal: pixi_mod.internal.File = .{ + // .id = runtime.state().host.allocDocId(), + // .path = try runtime.allocator().dupe(u8, path), // .width = ext.width, // .height = ext.height, // .tile_width = ext.tile_width, // .tile_height = ext.tile_height, - // .history = pixelart.internal.File.History.init(Globals.allocator()), - // .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), - // .checkerboard = pixelart.image.init( + // .history = pixi_mod.internal.File.History.init(runtime.allocator()), + // .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()), + // .checkerboard = pixi_mod.image.init( // ext.tile_width * 2, // ext.tile_height * 2, // .{ .r = 0, .g = 0, .b = 0, .a = 0 }, @@ -716,7 +716,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F // .temporary_layer = undefined, // .selection_layer = undefined, // .selected_sprites = try std.DynamicBitSet.initEmpty( - // Globals.allocator(), + // runtime.allocator(), // @divExact(ext.width, ext.tile_width) * @divExact(ext.height, ext.tile_height), // ), // }; @@ -739,15 +739,15 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.F // 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(Globals.allocator()); + // var layer_content = std.array_list.Managed(u8).init(runtime.allocator()); // try entry.writeAll(layer_content.writer()); - // 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; + // var cond: ?pixi_mod.Layer = pixi_mod.Layer.fromPixels(internal.newID(), runtime.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(Globals.allocator(), new_layer.*) catch return error.FileLoadError; + // internal.layers.append(runtime.allocator(), new_layer.*) catch return error.FileLoadError; // } else { // std.log.err("Failed to create layer from pixels", .{}); // } @@ -793,12 +793,12 @@ pub fn shouldConfirmFlatRasterSave(self: File) bool { return requiresFizzyCompatibleSave(self); } -pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?pixelart.internal.File { +pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?pixi_mod.internal.File { if (!isFlatImageExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; const image_layer: Layer = try Layer.fromImageFileBytes( - Globals.state.host.allocDocId(), + runtime.state().host.allocDocId(), "Layer", file_bytes, .ptr, @@ -808,42 +808,42 @@ pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?pixelart.i /// 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) !?pixelart.internal.File { +pub fn fromPathFlatImage(path: []const u8) !?pixi_mod.internal.File { if (!isFlatImageExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; - const image_layer: Layer = try Layer.fromImageFilePath(Globals.state.host.allocDocId(), "Layer", path, .ptr); + const image_layer: Layer = try Layer.fromImageFilePath(runtime.state().host.allocDocId(), "Layer", path, .ptr); return finishFlatImageFile(path, image_layer); } -fn finishFlatImageFile(path: []const u8, image_layer: Layer) !?pixelart.internal.File { +fn finishFlatImageFile(path: []const u8, image_layer: Layer) !?pixi_mod.internal.File { const size = image_layer.size(); const column_width: u32 = @intFromFloat(size.w); const row_height: u32 = @intFromFloat(size.h); - var internal: pixelart.internal.File = .{ - .id = Globals.state.host.allocDocId(), - .path = try Globals.allocator().dupe(u8, path), + var internal: pixi_mod.internal.File = .{ + .id = runtime.state().host.allocDocId(), + .path = try runtime.allocator().dupe(u8, path), .columns = 1, .rows = 1, .column_width = column_width, .row_height = row_height, - .history = pixelart.internal.File.History.init(Globals.allocator()), - .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), + .history = pixi_mod.internal.File.History.init(runtime.allocator()), + .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()), }; - internal.layers.append(Globals.allocator(), image_layer) catch return error.LayerCreateError; + internal.layers.append(runtime.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(Globals.allocator(), internal.spriteCount()); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount()); - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.width() * internal.height()); + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height()); // Create a layer-sized checkerboard pattern for selection tools for (0..internal.width() * internal.height()) |i| { - const value = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); internal.editor.checkerboard.setValue(i, value); } @@ -855,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: ?[][]pixelart.Animation.Frame = null, // If provided, the animation data will be applied to the animations after resizing + animation_data: ?[][]pixi_mod.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 }; @@ -878,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 Globals.allocator().alloc([][4]u8, file.layers.len); + var layer_data = try runtime.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] = Globals.allocator().dupe([4]u8, layer.pixels()) catch return error.MemoryAllocationFailed; + layer_data[layer_index] = runtime.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 Globals.allocator().alloc([]pixelart.Animation.Frame, file.animations.len); + var anim_data = try runtime.allocator().alloc([]pixi_mod.Animation.Frame, file.animations.len); for (0..file.animations.len) |anim_index| { const animation = file.animations.get(anim_index); - anim_data[anim_index] = Globals.allocator().dupe(pixelart.Animation.Frame, animation.frames) catch return error.MemoryAllocationFailed; + anim_data[anim_index] = runtime.allocator().dupe(pixi_mod.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 Globals.allocator().alloc([2]f32, file.spriteCount()); + var sprite_data = try runtime.allocator().alloc([2]f32, file.spriteCount()); for (0..file.spriteCount()) |sprite_index| { sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index]; } @@ -905,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 = Animation.init(Globals.allocator(), current_animation.id, current_animation.name, &.{}) catch return error.AnimationCreateError; + var new_animation = Animation.init(runtime.allocator(), current_animation.id, current_animation.name, &.{}) catch return error.AnimationCreateError; defer file.animations.set(anim_index, new_animation); - defer current_animation.deinit(Globals.allocator()); + defer current_animation.deinit(runtime.allocator()); for (current_data) |frame| { - new_animation.appendFrame(Globals.allocator(), .{ .sprite_index = frame.sprite_index, .ms = frame.ms }) catch return error.AnimationFrameAppendError; + new_animation.appendFrame(runtime.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 = Animation.init(Globals.allocator(), animation.id, animation.name, &.{}) catch return error.AnimationCreateError; + var new_animation = Animation.init(runtime.allocator(), animation.id, animation.name, &.{}) catch return error.AnimationCreateError; defer file.animations.set(anim_index, new_animation); - defer animation.deinit(Globals.allocator()); + defer animation.deinit(runtime.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(Globals.allocator(), .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError; + new_animation.appendFrame(runtime.allocator(), .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError; } } } @@ -929,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| Globals.allocator().free(s); + defer if (old_origins_snapshot) |s| runtime.allocator().free(s); if (options.sprite_data == null) { - const snapshot = try Globals.allocator().alloc([2]f32, old_sprite_count); + const snapshot = try runtime.allocator().alloc([2]f32, old_sprite_count); for (0..old_sprite_count) |i| { snapshot[i] = file.sprites.items(.origin)[i]; } @@ -940,7 +940,7 @@ pub fn resize(file: *File, options: ResizeOptions) !void { } file.sprites.resize( - Globals.allocator(), + runtime.allocator(), new_sprite_count, ) catch return error.MemoryAllocationFailed; @@ -984,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 = pixelart.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i); file.editor.checkerboard.setValue(i, value); } @@ -1312,7 +1312,7 @@ pub fn reorderRows(file: *File, removed_row_index: usize, insert_before_row_inde } pub fn deinit(file: *File) void { - pixelart.render.destroyLayerCompositeResources(file); + pixi_mod.render.destroyLayerCompositeResources(file); strokeUndoFreeSnapshot(file); @@ -1320,15 +1320,15 @@ pub fn deinit(file: *File) void { file.buffers.deinit(); for (file.layers.items(.name)) |name| { - Globals.allocator().free(name); + runtime.allocator().free(name); } for (file.animations.items(.name)) |name| { - Globals.allocator().free(name); + runtime.allocator().free(name); } for (file.animations.items(.frames)) |frames| { - Globals.allocator().free(frames); + runtime.allocator().free(frames); } file.editor.temporary_layer.deinit(); @@ -1339,16 +1339,16 @@ pub fn deinit(file: *File) void { file.editor.checkerboard_tile = null; } - 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.editor.selected_layer_indices.deinit(runtime.allocator()); + file.editor.selected_animation_indices.deinit(runtime.allocator()); + file.editor.selected_frame_indices.deinit(runtime.allocator()); - 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); + file.layers.deinit(runtime.allocator()); + file.deleted_layers.deinit(runtime.allocator()); + file.sprites.deinit(runtime.allocator()); + file.animations.deinit(runtime.allocator()); + file.deleted_animations.deinit(runtime.allocator()); + runtime.allocator().free(file.path); } pub fn dirty(self: File) bool { @@ -1618,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(Globals.allocator(), p) catch return; + file.editor.selected_animation_indices.append(runtime.allocator(), p) catch return; file.editor.animation_selection_anchor = p; } } @@ -1680,9 +1680,9 @@ pub fn selectPoint(file: *File, point: dvui.Point, select_options: SelectOptions } } } else { - var iter = Globals.state.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); + var iter = runtime.state().tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = Globals.state.tools.offset_table[i]; + const offset = runtime.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) { @@ -1729,26 +1729,26 @@ pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_op const stroke_size: usize = @intCast(Tools.max_brush_size); 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; + var mask = runtime.state().tools.stroke; if (select_options.stroke_size > Tools.min_full_stroke_size) { for (0..(stroke_size * stroke_size)) |index| { - if (Globals.state.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { + if (runtime.state().tools.getIndexShapeOffset(center.diff(diff), index)) |i| { mask.unset(i); } } } - if (pixelart.algorithms.brezenham.process(point1, point2) catch null) |points| { + if (pixi_mod.algorithms.brezenham.process(point1, point2) catch null) |points| { for (points, 0..) |point, point_i| { if (select_options.stroke_size < Tools.min_full_stroke_size) { selectPoint(file, point, select_options); } else { - var stroke = if (point_i == 0) Globals.state.tools.stroke else mask; + var stroke = if (point_i == 0) runtime.state().tools.stroke else mask; var iter = stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = Globals.state.tools.offset_table[i]; + const offset = runtime.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) { @@ -1806,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 = pixelart.image.pixelIndex(read_layer.source, p) orelse return; + const start_idx = pixi_mod.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(Globals.allocator(), n); + var visited = try std.DynamicBitSet.initEmpty(runtime.allocator(), n); defer visited.deinit(); - var queue = std.array_list.Managed(dvui.Point).init(Globals.allocator()); + var queue = std.array_list.Managed(dvui.Point).init(runtime.allocator()); defer queue.deinit(); try queue.append(p); @@ -1829,7 +1829,7 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void }; while (queue.pop()) |qp| { - const idx = pixelart.image.pixelIndex(read_layer.source, qp) orelse continue; + const idx = pixi_mod.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); @@ -1837,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 (pixelart.image.pixelIndex(read_layer.source, np)) |ni| { + if (pixi_mod.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); @@ -1943,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| { - Globals.allocator().free(p); + runtime.allocator().free(p); file.editor.stroke_undo_pixels = null; } file.editor.stroke_undo_x = 0; @@ -1970,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 Globals.allocator().alloc(u8, n); + const buf = try runtime.allocator().alloc(u8, n); const layer = file.layers.get(file.selected_layer_index); const pix = layer.pixels(); @@ -2021,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 Globals.allocator().alloc(u8, new_n); + const new_buf = try runtime.allocator().alloc(u8, new_n); const layer = file.layers.get(file.selected_layer_index); const pix = layer.pixels(); @@ -2047,7 +2047,7 @@ pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void { } } - Globals.allocator().free(old_buf); + runtime.allocator().free(old_buf); file.editor.stroke_undo_pixels = new_buf; file.editor.stroke_undo_x = tx; file.editor.stroke_undo_y = ty; @@ -2341,9 +2341,9 @@ pub fn drawPoint(file: *File, point: dvui.Point, layer: DrawLayer, draw_options: } } } else { - var iter = Globals.state.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); + var iter = runtime.state().tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = Globals.state.tools.offset_table[i]; + const offset = runtime.state().tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (clip_rect) |cr| { @@ -2432,17 +2432,17 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw const stroke_size: usize = @intCast(Tools.max_brush_size); 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; + var mask = runtime.state().tools.stroke; if (draw_options.stroke_size > Tools.min_full_stroke_size) { for (0..(stroke_size * stroke_size)) |index| { - if (Globals.state.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { + if (runtime.state().tools.getIndexShapeOffset(center.diff(diff), index)) |i| { mask.unset(i); } } } - if (pixelart.algorithms.brezenham.process(point1, point2) catch null) |points| { + if (pixi_mod.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); @@ -2459,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) Globals.state.tools.stroke else mask; + var stroke = if (point_i == 0) runtime.state().tools.stroke else mask; var iter = stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = Globals.state.tools.offset_table[i]; + const offset = runtime.state().tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (clip_rect) |cr| { @@ -2611,7 +2611,7 @@ pub fn getLayer(self: *File, id: u64) ?Layer { } pub fn deleteLayer(self: *File, index: usize) !void { - try self.deleted_layers.append(Globals.allocator(), self.layers.slice().get(index)); + try self.deleted_layers.append(runtime.allocator(), self.layers.slice().get(index)); self.layers.orderedRemove(index); self.editor.layer_composite_dirty = true; self.editor.split_composite_dirty = true; @@ -2647,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 Globals.allocator().dupe([4]u8, dest.pixels()); - errdefer Globals.allocator().free(dest_pixels_before); + const dest_pixels_before = try runtime.allocator().dupe([4]u8, dest.pixels()); + errdefer runtime.allocator().free(dest_pixels_before); - var dest_mask_before = try dest.mask.clone(Globals.allocator()); + var dest_mask_before = try dest.mask.clone(runtime.allocator()); errdefer dest_mask_before.deinit(); for (0..pix_n) |i| { @@ -2665,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(Globals.allocator(), self.layers.slice().get(src_i)); + try self.deleted_layers.append(runtime.allocator(), self.layers.slice().get(src_i)); self.layers.orderedRemove(src_i); self.editor.layer_composite_dirty = true; @@ -2684,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, } }); - Globals.state.host.setActiveSidebarView(plugin.view_tools); + runtime.state().host.setActiveSidebarView(plugin.view_tools); } pub fn duplicateLayer(self: *File, index: usize) !u64 { @@ -2699,7 +2699,7 @@ pub fn duplicateLayer(self: *File, index: usize) !u64 { @memcpy(new_layer.pixels(), layer.pixels()); - self.layers.insert(Globals.allocator(), 0, new_layer) catch { + self.layers.insert(runtime.allocator(), 0, new_layer) catch { dvui.log.err("Failed to append layer", .{}); }; @@ -2721,7 +2721,7 @@ pub fn duplicateLayer(self: *File, index: usize) !u64 { pub fn createLayer(self: *File) !u64 { 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 { + self.layers.insert(runtime.allocator(), 0, layer) catch { dvui.log.err("Failed to append layer", .{}); }; self.selected_layer_index = 0; @@ -2745,14 +2745,14 @@ pub fn createLayer(self: *File) !u64 { pub fn createAnimation(self: *File) !usize { var animation = Animation.init( - Globals.allocator(), + runtime.allocator(), self.newAnimationID(), "New Animation", &[_]Animation.Frame{}, ) catch return error.FailedToCreateAnimation; if (self.editor.selected_sprites.count() > 0) { - animation.frames = try Globals.allocator().alloc(Animation.Frame, self.editor.selected_sprites.count()); + animation.frames = try runtime.allocator().alloc(Animation.Frame, self.editor.selected_sprites.count()); var iter = self.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); var i: usize = 0; @@ -2761,7 +2761,7 @@ pub fn createAnimation(self: *File) !usize { } } - self.animations.append(Globals.allocator(), animation) catch { + self.animations.append(runtime.allocator(), animation) catch { dvui.log.err("Failed to append animation", .{}); }; return self.animations.len - 1; @@ -2770,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(Globals.allocator(), self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation; - self.animations.insert(Globals.allocator(), index + 1, new_animation) catch { + const new_animation = Animation.init(runtime.allocator(), self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation; + self.animations.insert(runtime.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(Globals.allocator(), self.animations.slice().get(index)); + try self.deleted_animations.append(runtime.allocator(), self.animations.slice().get(index)); self.animations.orderedRemove(index); try self.history.append(.{ .animation_restore_delete = .{ .action = .restore, @@ -2797,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(Globals.allocator()); - defer ext.deinit(Globals.allocator()); + var ext = try self.external(runtime.allocator()); + defer ext.deinit(runtime.allocator()); - const output_path = try Globals.state.host.arena().dupeZ(u8, self.path); + const output_path = try runtime.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(Globals.allocator()); + var json = std.array_list.Managed(u8).init(runtime.allocator()); const out_stream = json.writer(); const options = std.json.StringifyOptions{}; @@ -2828,14 +2828,14 @@ pub fn saveTar(self: *File, window: *dvui.Window) !void { else => return error.InvalidImageSource, }; - try wrt.writeFileBytes(try std.fmt.allocPrintZ(Globals.state.host.arena(), "{s}.layer", .{layer.name}), data, .{}); + try wrt.writeFileBytes(try std.fmt.allocPrintZ(runtime.state().host.arena(), "{s}.layer", .{layer.name}), data, .{}); } } try wrt.finish(); { - const id_mutex = dvui.toastAdd(window, @src(), 0, pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), 0, pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.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); @@ -2852,13 +2852,13 @@ 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 pixelart.render.syncLayerComposite(self); + try pixi_mod.render.syncLayerComposite(self); const target = self.editor.layer_composite_target orelse return error.NoLayerComposite; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } var tmp_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr); @@ -2867,11 +2867,11 @@ fn writeFlattenedLayersToPath(self: *File, out_path: []const u8, window: *dvui.W switch (kind) { .png => { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - try pixelart.image.writeToPngResolution(tmp_layer.source, out_path, r); + try pixi_mod.image.writeToPngResolution(tmp_layer.source, out_path, r); }, .jpg => { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try pixelart.image.writeToJpgPpi(tmp_layer.source, out_path, ppi); + try pixi_mod.image.writeToJpgPpi(tmp_layer.source, out_path, ppi); }, } } @@ -2886,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)), pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.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); @@ -2907,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)), pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.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); @@ -2926,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, Globals.allocator()); - defer snap.deinit(Globals.allocator()); + var snap = try SaveSnapshot.fromFileOnGuiThread(self, runtime.allocator()); + defer snap.deinit(runtime.allocator()); try writeSnapshotToZip(self.id, window, &snap); } @@ -2936,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: pixelart.File, + ext: pixi_mod.File, layer_bytes: [][]u8, layer_entry_names: [][:0]const u8, null_terminated_path: [:0]u8, @@ -3002,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(Globals.allocator(), job); + try self.queue.append(runtime.allocator(), job); self.cond.signal(dvui.io); } }; @@ -3035,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(Globals.allocator()); - Globals.allocator().destroy(job.snap); + job.snap.deinit(runtime.allocator()); + runtime.allocator().destroy(job.snap); } - save_queue.queue.deinit(Globals.allocator()); + save_queue.queue.deinit(runtime.allocator()); } fn saveQueueWorker() void { @@ -3062,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(Globals.allocator()); - Globals.allocator().destroy(job.snap); - if (Globals.state.docs.fileById(job.file_id)) |f| f.setSaving(false); + job.snap.deinit(runtime.allocator()); + runtime.allocator().destroy(job.snap); + if (runtime.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| { @@ -3097,7 +3097,7 @@ fn writeSnapshotToZip(file_id: u64, window: *dvui.Window, snap: *const SaveSnaps zip.zip_close(z); } - if (Globals.state.docs.fileById(file_id)) |f| f.history.bookmark = 0; + if (runtime.state().docs.fileById(file_id)) |f| f.history.bookmark = 0; } fn zipEntryOk(rc: c_int) !void { @@ -3106,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(Globals.allocator(), snap.ext, options); - defer Globals.allocator().free(output); + const output = try std.json.Stringify.valueAlloc(runtime.allocator(), snap.ext, options); + defer runtime.allocator().free(output); try zipEntryOk(zip.zip_entry_open(z, "fizzydata.json")); try zipEntryOk(zip.zip_entry_write(z, output.ptr, output.len)); @@ -3149,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, Globals.allocator()); - defer snap.deinit(Globals.allocator()); - const bytes = try writeSnapshotToZipBytes(&snap, Globals.allocator()); - defer Globals.allocator().free(bytes); + var snap = try SaveSnapshot.fromFileOnGuiThread(self, runtime.allocator()); + defer snap.deinit(runtime.allocator()); + const bytes = try writeSnapshotToZipBytes(&snap, runtime.allocator()); + defer runtime.allocator().free(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); + defer runtime.allocator().free(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); + defer runtime.allocator().free(bytes); try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".jpg", bytes); } else { return; } self.history.bookmark = 0; - const id_mutex = dvui.toastAdd(window, @src(), 0, pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), 0, pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.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); @@ -3179,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 pixelart.render.syncLayerComposite(self); + try pixi_mod.render.syncLayerComposite(self); const target = self.editor.layer_composite_target orelse return error.NoLayerComposite; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } 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(Globals.allocator()); + var out = std.Io.Writer.Allocating.init(runtime.allocator()); errdefer out.deinit(); switch (kind) { .png => { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - try pixelart.image.writePngToWriter(tmp_layer.source, &out.writer, r); + try pixi_mod.image.writePngToWriter(tmp_layer.source, &out.writer, r); }, .jpg => { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try pixelart.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi); + try pixi_mod.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi); }, } return out.toOwnedSlice(); @@ -3216,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 Globals.allocator().dupe(u8, new_path); + const new_owned = try runtime.allocator().dupe(u8, new_path); self.path = new_owned; errdefer { - Globals.allocator().free(self.path[0..self.path.len]); + runtime.allocator().free(self.path[0..self.path.len]); self.path = old_path; } if (comptime @import("builtin").target.cpu.arch == .wasm32) { @@ -3227,7 +3227,7 @@ pub fn saveAsFizzy(self: *File, new_path: []const u8, window: *dvui.Window) !voi } else { try saveZip(self, window); } - Globals.allocator().free(old_path[0..old_path.len]); + runtime.allocator().free(old_path[0..old_path.len]); } /// Default filename (with `.fiz`) for a Save As dialog, derived from the current path. @@ -3251,10 +3251,10 @@ fn deinitAllUserLayers(self: *File) void { fn clearAnimationsForSaveAs(self: *File) void { for (self.animations.items(.name)) |n| { - Globals.allocator().free(n); + runtime.allocator().free(n); } for (self.animations.items(.frames)) |frames| { - Globals.allocator().free(frames); + runtime.allocator().free(frames); } self.animations.clearRetainingCapacity(); self.deleted_animations.clearRetainingCapacity(); @@ -3278,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(Globals.allocator(), self.spriteCount()); + self.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), self.spriteCount()); - self.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), self.width() * self.height()); + self.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), self.width() * self.height()); for (0..self.width() * self.height()) |i| { - const value = pixelart.math.checker(.{ .w = @floatFromInt(self.width()), .h = @floatFromInt(self.height()) }, i); + const value = pixi_mod.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(Globals.allocator(), 0); + try self.editor.selected_layer_indices.append(runtime.allocator(), 0); } /// Flattens visible layers (via GPU composite), writes PNG or JPEG to `output_path`, and replaces @@ -3304,16 +3304,16 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo return error.InvalidImageSize; } - try pixelart.render.syncLayerComposite(self); + try pixi_mod.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(Globals.allocator(), target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } const ext = std.fs.path.extension(output_path); @@ -3330,43 +3330,43 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo 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(Globals.allocator()); + var out = std.Io.Writer.Allocating.init(runtime.allocator()); errdefer out.deinit(); - try pixelart.image.writePngToWriter(single_layer.source, &out.writer, r); + try pixi_mod.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(Globals.allocator()); + var out = std.Io.Writer.Allocating.init(runtime.allocator()); errdefer out.deinit(); - try pixelart.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi); + try pixi_mod.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi); break :blk try out.toOwnedSlice(); }; - defer Globals.allocator().free(bytes); + defer runtime.allocator().free(bytes); const dl_ext = if (is_png) ".png" else ".jpg"; 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); + try pixi_mod.image.writeToPngResolution(single_layer.source, output_path, r); } else { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try pixelart.image.writeToJpgPpi(single_layer.source, output_path, ppi); + try pixi_mod.image.writeToJpgPpi(single_layer.source, output_path, ppi); } - pixelart.render.destroyLayerCompositeResources(self); - pixelart.render.destroySplitCompositeResources(self); + pixi_mod.render.destroyLayerCompositeResources(self); + pixi_mod.render.destroySplitCompositeResources(self); deinitAllUserLayers(self); clearAnimationsForSaveAs(self); self.sprites.clearRetainingCapacity(); for (0..self.spriteCount()) |_| { - self.sprites.append(Globals.allocator(), .{ .origin = .{ 0, 0 } }) catch { + self.sprites.append(runtime.allocator(), .{ .origin = .{ 0, 0 } }) catch { single_layer.deinit(); return error.FileLoadError; }; } - const new_path = try Globals.allocator().dupe(u8, output_path); - Globals.allocator().free(self.path[0..self.path.len]); + const new_path = try runtime.allocator().dupe(u8, output_path); + runtime.allocator().free(self.path[0..self.path.len]); self.path = new_path; self.columns = 1; self.rows = 1; @@ -3374,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(Globals.allocator(), single_layer) catch { + self.layers.append(runtime.allocator(), single_layer) catch { single_layer.deinit(); return error.LayerCreateError; }; self.history.deinit(); - self.history = .init(Globals.allocator()); + self.history = .init(runtime.allocator()); try reinitEditorSurfaceForFlatDocument(self); self.editor.layer_composite_dirty = true; @@ -3389,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)), pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.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); } - Globals.state.host.requestCompositeWarmup(); + runtime.state().host.requestPrepareFrame(); } pub const GridLayoutOptions = struct { @@ -3403,7 +3403,7 @@ pub const GridLayoutOptions = struct { row_height: u32, columns: u32, rows: u32, - anchor: pixelart.math.layout_anchor.LayoutAnchor, + anchor: pixi_mod.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. @@ -3417,29 +3417,29 @@ pub fn captureGridLayoutSnapshot(file: *File) !History.Change.GridLayout { @as(usize, file.row_height) * @as(usize, file.rows); const layer_count = file.layers.len; - var layer_ids = try Globals.allocator().alloc(u64, layer_count); - errdefer Globals.allocator().free(layer_ids); + var layer_ids = try runtime.allocator().alloc(u64, layer_count); + errdefer runtime.allocator().free(layer_ids); - var layer_pixels = try Globals.allocator().alloc([][4]u8, layer_count); + var layer_pixels = try runtime.allocator().alloc([][4]u8, layer_count); var allocated: usize = 0; errdefer { - for (layer_pixels[0..allocated]) |buf| Globals.allocator().free(buf); - Globals.allocator().free(layer_pixels); + for (layer_pixels[0..allocated]) |buf| runtime.allocator().free(buf); + runtime.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 Globals.allocator().alloc([4]u8, total); + const dst = try runtime.allocator().alloc([4]u8, total); @memcpy(dst, src); layer_pixels[i] = dst; allocated += 1; } const sprite_count = file.sprites.len; - var sprite_origins = try Globals.allocator().alloc([2]f32, sprite_count); - errdefer Globals.allocator().free(sprite_origins); + var sprite_origins = try runtime.allocator().alloc([2]f32, sprite_count); + errdefer runtime.allocator().free(sprite_origins); for (0..sprite_count) |i| sprite_origins[i] = file.sprites.items(.origin)[i]; return .{ @@ -3505,16 +3505,16 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: History.Change.GridLayout) !vo 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(Globals.allocator(), .{ .origin = origin }) catch return error.MemoryAllocationFailed; + file.sprites.append(runtime.allocator(), .{ .origin = origin }) catch return error.MemoryAllocationFailed; } file.editor.selected_sprites.deinit(); - file.editor.selected_sprites = std.DynamicBitSet.initEmpty(Globals.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; + file.editor.selected_sprites = std.DynamicBitSet.initEmpty(runtime.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; file.editor.checkerboard.deinit(); - file.editor.checkerboard = std.DynamicBitSet.initEmpty(Globals.allocator(), total) catch return error.MemoryAllocationFailed; + file.editor.checkerboard = std.DynamicBitSet.initEmpty(runtime.allocator(), total) catch return error.MemoryAllocationFailed; for (0..total) |idx| { - const value = pixelart.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); file.editor.checkerboard.setValue(idx, value); } @@ -3530,7 +3530,7 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: History.Change.GridLayout) !vo file.columns = snap.columns; file.rows = snap.rows; - pixelart.render.destroyLayerCompositeResources(file); + pixi_mod.render.destroyLayerCompositeResources(file); file.invalidateActiveLayerTransparencyMaskCache(); } @@ -3585,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(Globals.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; + file.sprites.resize(runtime.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; if (new_sprite_count > old_sprite_count) { var i: usize = old_sprite_count; @@ -3594,7 +3594,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { } } - var new_selected = try std.DynamicBitSet.initEmpty(Globals.allocator(), new_sprite_count); + var new_selected = try std.DynamicBitSet.initEmpty(runtime.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); @@ -3607,7 +3607,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { file.columns = new_cols; file.rows = new_rows; - pixelart.render.destroyLayerCompositeResources(file); + pixi_mod.render.destroyLayerCompositeResources(file); file.invalidateActiveLayerTransparencyMaskCache(); if (snapshot_opt) |snap| { @@ -3695,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 = pixelart.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw, new_rh, options.anchor); + const blk = pixi_mod.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; @@ -3734,16 +3734,16 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { 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(Globals.allocator(), .{ .origin = .{ 0.0, 0.0 } }) catch return error.MemoryAllocationFailed; + file.sprites.append(runtime.allocator(), .{ .origin = .{ 0.0, 0.0 } }) catch return error.MemoryAllocationFailed; } file.editor.selected_sprites.deinit(); - file.editor.selected_sprites = std.DynamicBitSet.initEmpty(Globals.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; + file.editor.selected_sprites = std.DynamicBitSet.initEmpty(runtime.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; file.editor.checkerboard.deinit(); - file.editor.checkerboard = std.DynamicBitSet.initEmpty(Globals.allocator(), @as(usize, new_w) * @as(usize, new_h)) catch return error.MemoryAllocationFailed; + file.editor.checkerboard = std.DynamicBitSet.initEmpty(runtime.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 = pixelart.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); file.editor.checkerboard.setValue(idx, value); } @@ -3758,7 +3758,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { file.columns = new_cols; file.rows = new_rows; - pixelart.render.destroyLayerCompositeResources(file); + pixi_mod.render.destroyLayerCompositeResources(file); file.invalidateActiveLayerTransparencyMaskCache(); if (snapshot_opt) |snap| { @@ -3790,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 = Globals.allocator().create(SaveSnapshot) catch |err| { + const snap_ptr = runtime.allocator().create(SaveSnapshot) catch |err| { self.setSaving(false); return err; }; - snap_ptr.* = SaveSnapshot.fromFileOnGuiThread(self, Globals.allocator()) catch |err| { - Globals.allocator().destroy(snap_ptr); + snap_ptr.* = SaveSnapshot.fromFileOnGuiThread(self, runtime.allocator()) catch |err| { + runtime.allocator().destroy(snap_ptr); self.setSaving(false); return err; }; @@ -3814,8 +3814,8 @@ pub fn saveAsync(self: *File) !void { .window = dvui.currentWindow(), .snap = snap_ptr, }) catch |err| { - snap_ptr.deinit(Globals.allocator()); - Globals.allocator().destroy(snap_ptr); + snap_ptr.deinit(runtime.allocator()); + runtime.allocator().destroy(snap_ptr); self.setSaving(false); return err; }; @@ -3827,10 +3827,10 @@ pub fn saveAsync(self: *File) !void { } } -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); +pub fn external(self: File, allocator: std.mem.Allocator) !pixi_mod.File { + const layers = try allocator.alloc(pixi_mod.Layer, self.layers.slice().len); + const sprites = try allocator.alloc(pixi_mod.Sprite, self.sprites.slice().len); + const animations = try allocator.alloc(pixi_mod.Animation, self.animations.slice().len); for (layers, 0..) |*working_layer, i| { working_layer.name = try allocator.dupe(u8, self.layers.items(.name)[i]); @@ -3848,7 +3848,7 @@ pub fn external(self: File, allocator: std.mem.Allocator) !pixelart.File { } return .{ - .version = pixelart.version, + .version = pixi_mod.version, .columns = self.columns, .rows = self.rows, .column_width = self.column_width, diff --git a/src/plugins/pixelart/src/internal/History.zig b/src/plugins/pixi/src/internal/History.zig similarity index 88% rename from src/plugins/pixelart/src/internal/History.zig rename to src/plugins/pixi/src/internal/History.zig index 8b9e501a..e19563a1 100644 --- a/src/plugins/pixelart/src/internal/History.zig +++ b/src/plugins/pixi/src/internal/History.zig @@ -3,9 +3,9 @@ const zgui = @import("zgui"); const History = @This(); const dvui = @import("dvui"); const Layer = @import("Layer.zig"); -const pixelart = @import("../../pixelart.zig"); +const pixi_mod = @import("../../pixi.zig"); const plugin = @import("../plugin.zig"); -const Globals = pixelart.Globals; +const runtime = @import("../runtime.zig"); 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: []pixelart.Animation.Frame, + frames: []pixi_mod.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} ** pixelart.max_name_len, + .name = [_:0]u8{0} ** pixi_mod.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| { - Globals.allocator().free(pixels.indices); - Globals.allocator().free(pixels.values); + runtime.allocator().free(pixels.indices); + runtime.allocator().free(pixels.values); }, .origins => |*origins| { - Globals.allocator().free(origins.indices); - Globals.allocator().free(origins.values); + runtime.allocator().free(origins.indices); + runtime.allocator().free(origins.values); }, .layers_order => |*layers_order| { - Globals.allocator().free(layers_order.order); + runtime.allocator().free(layers_order.order); }, .layer_merge => |*layer_merge| { - Globals.allocator().free(layer_merge.dest_pixels_before); + runtime.allocator().free(layer_merge.dest_pixels_before); layer_merge.dest_mask_before.deinit(); }, .grid_layout => |*gl| { - 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); + for (gl.layer_pixels) |buf| runtime.allocator().free(buf); + runtime.allocator().free(gl.layer_pixels); + runtime.allocator().free(gl.layer_ids); + runtime.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([][]pixelart.Animation.Frame), -redo_animation_data_stack: std.array_list.Managed([][]pixelart.Animation.Frame), +undo_animation_data_stack: std.array_list.Managed([][]pixi_mod.Animation.Frame), +redo_animation_data_stack: std.array_list.Managed([][]pixi_mod.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([][]pixelart.Animation.Frame).init(allocator), - .redo_animation_data_stack = std.array_list.Managed([][]pixelart.Animation.Frame).init(allocator), + .undo_animation_data_stack = std.array_list.Managed([][]pixi_mod.Animation.Frame).init(allocator), + .redo_animation_data_stack = std.array_list.Managed([][]pixi_mod.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 = pixelart.perf.record and std.meta.activeTag(change) == .pixels; + const track_pixels = pixi_mod.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) pixelart.perf.nanoTimestamp() else 0; + const t_hist: i128 = if (track_pixels) pixi_mod.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| { - Globals.allocator().free(layer); + runtime.allocator().free(layer); } - Globals.allocator().free(data); + runtime.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) { - 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; + pixi_mod.perf.history_append_pixels_ns +%= @intCast(pixi_mod.perf.nanoTimestamp() - t_hist); + pixi_mod.perf.history_append_pixels_calls += 1; + pixi_mod.perf.history_append_pixels_slots +%= pixel_slots; } } -fn layerMergeUndo(file: *pixelart.internal.File, lm: *Change.LayerMerge) !void { +fn layerMergeUndo(file: *pixi_mod.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: *pixelart.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(Globals.allocator()); + dest.mask = try lm.dest_mask_before.clone(runtime.allocator()); dest.invalidate(); file.layers.set(dest_i, dest); const restored = file.deleted_layers.pop() orelse return error.InvalidLayerMerge; - try file.layers.insert(Globals.allocator(), lm.source_index, restored); + try file.layers.insert(runtime.allocator(), lm.source_index, restored); file.editor.layer_composite_dirty = true; file.editor.split_composite_dirty = true; file.selected_layer_index = lm.source_index; - Globals.state.host.setActiveSidebarView(plugin.view_tools); + runtime.state().host.setActiveSidebarView(plugin.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); } -fn layerMergeRedo(file: *pixelart.internal.File, lm: *Change.LayerMerge) !void { +fn layerMergeRedo(file: *pixi_mod.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: *pixelart.internal.File, lm: *Change.LayerMerge) !void { dest.invalidate(); file.layers.set(dest_i, dest); - try file.deleted_layers.append(Globals.allocator(), file.layers.slice().get(src_i)); + try file.deleted_layers.append(runtime.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: *pixelart.internal.File, lm: *Change.LayerMerge) !void { .up => dest_i, .down => dest_i - 1, }; - Globals.state.host.setActiveSidebarView(plugin.view_tools); + runtime.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: *pixelart.internal.File, action: Action) !void { +pub fn undoRedo(self: *History, file: *pixi_mod.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: *pixelart.internal.File, action: Action) ! // 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(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 ts_us: u128 = @intCast(@divTrunc(pixi_mod.perf.nanoTimestamp(), 1000)); + const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), @truncate(ts_us), file.editor.canvas.id, pixi_mod.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: *pixelart.internal.File, action: Action) ! //try file.editor.selected_sprites.append(sprite_index); } - Globals.state.host.setActiveSidebarView(plugin.view_sprites); + runtime.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 Globals.allocator().alloc(u64, layers_order.order.len); + var new_order = try runtime.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: *pixelart.internal.File, action: Action) ! } @memcpy(layers_order.order, new_order); - Globals.allocator().free(new_order); + runtime.allocator().free(new_order); file.invalidateActiveLayerTransparencyMaskCache(); }, .layer_restore_delete => |*layer_restore_delete| { @@ -665,24 +665,24 @@ pub fn undoRedo(self: *History, file: *pixelart.internal.File, action: Action) ! const a = layer_restore_delete.action; switch (a) { .restore => { - try file.layers.insert(Globals.allocator(), layer_restore_delete.index, file.deleted_layers.pop().?); + try file.layers.insert(runtime.allocator(), layer_restore_delete.index, file.deleted_layers.pop().?); layer_restore_delete.action = .delete; }, .delete => { - try file.deleted_layers.append(Globals.allocator(), file.layers.slice().get(layer_restore_delete.index)); + try file.deleted_layers.append(runtime.allocator(), file.layers.slice().get(layer_restore_delete.index)); file.layers.orderedRemove(layer_restore_delete.index); layer_restore_delete.action = .restore; }, } - Globals.state.host.setActiveSidebarView(plugin.view_tools); + runtime.state().host.setActiveSidebarView(plugin.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); }, .layer_name => |*layer_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); + const name = try runtime.allocator().dupe(u8, file.layers.items(.name)[layer_name.index]); + runtime.allocator().free(file.layers.items(.name)[layer_name.index]); + file.layers.items(.name)[layer_name.index] = try runtime.allocator().dupe(u8, layer_name.name); layer_name.name = name; - Globals.state.host.setActiveSidebarView(plugin.view_tools); + runtime.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: *pixelart.internal.File, action: Action) ! if (visibility_changed) { file.editor.split_composite_dirty = true; } - Globals.state.host.setActiveSidebarView(plugin.view_tools); + runtime.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(Globals.allocator(), animation_restore_delete.index, animation); + try file.animations.insert(runtime.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(Globals.allocator(), animation); + try file.deleted_animations.append(runtime.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: *pixelart.internal.File, action: Action) ! } }, } - Globals.state.host.setActiveSidebarView(plugin.view_sprites); + runtime.state().host.setActiveSidebarView(plugin.view_sprites); }, .animation_name => |*animation_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); + const name = try runtime.allocator().dupe(u8, file.animations.items(.name)[animation_name.index]); + runtime.allocator().free(file.animations.items(.name)[animation_name.index]); + file.animations.items(.name)[animation_name.index] = try runtime.allocator().dupe(u8, animation_name.name); animation_name.name = name; - Globals.state.host.setActiveSidebarView(plugin.view_sprites); + runtime.state().host.setActiveSidebarView(plugin.view_sprites); }, .animation_settings => {}, .animation_order => |*animation_order| { @@ -772,7 +772,7 @@ pub fn undoRedo(self: *History, file: *pixelart.internal.File, action: Action) ! const history_frames = &animation_frames.frames; const current_frames = &file.animations.items(.frames)[animation_frames.index]; - std.mem.swap([]pixelart.Animation.Frame, history_frames, current_frames); + std.mem.swap([]pixi_mod.Animation.Frame, history_frames, current_frames); file.selected_animation_index = animation_frames.index; }, @@ -783,7 +783,7 @@ pub fn undoRedo(self: *History, file: *pixelart.internal.File, action: Action) ! resize.height = file.height(); var layer_data: ?[][][4]u8 = null; - var animation_data: ?[][]pixelart.Animation.Frame = null; + var animation_data: ?[][]pixi_mod.Animation.Frame = null; var sprite_data: ?[][2]f32 = null; switch (action) { @@ -796,9 +796,9 @@ pub fn undoRedo(self: *History, file: *pixelart.internal.File, action: Action) ! if (self.undo_animation_data_stack.pop()) |ad| { animation_data = ad; - var anim_data = try Globals.allocator().alloc([]pixelart.Animation.Frame, file.animations.len); + var anim_data = try runtime.allocator().alloc([]pixi_mod.Animation.Frame, file.animations.len); for (0..file.animations.len) |animation_index| { - anim_data[animation_index] = Globals.allocator().dupe(pixelart.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; + anim_data[animation_index] = runtime.allocator().dupe(pixi_mod.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: *pixelart.internal.File, action: Action) ! if (self.undo_sprite_data_stack.pop()) |sd| { sprite_data = sd; - const new_sprite_data = try Globals.allocator().alloc([2]f32, file.spriteCount()); + const new_sprite_data = try runtime.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: *pixelart.internal.File, action: Action) ! if (self.redo_animation_data_stack.pop()) |ad| { animation_data = ad; - var anim_data = try Globals.allocator().alloc([]pixelart.Animation.Frame, file.animations.len); + var anim_data = try runtime.allocator().alloc([]pixi_mod.Animation.Frame, file.animations.len); for (0..file.animations.len) |animation_index| { - anim_data[animation_index] = Globals.allocator().dupe(pixelart.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; + anim_data[animation_index] = runtime.allocator().dupe(pixi_mod.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 Globals.allocator().alloc([2]f32, file.spriteCount()); + const new_sprite_data = try runtime.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: *pixelart.internal.File, action: Action) ! }) catch return error.ResizeError; if (animation_data) |ad| { - Globals.allocator().free(ad); + runtime.allocator().free(ad); } if (sprite_data) |sd| { - Globals.allocator().free(sd); + runtime.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| { - Globals.allocator().free(layer); + runtime.allocator().free(layer); } - Globals.allocator().free(data); + runtime.allocator().free(data); } for (self.redo_layer_data_stack.items) |data| { for (data) |layer| { - Globals.allocator().free(layer); + runtime.allocator().free(layer); } - Globals.allocator().free(data); + runtime.allocator().free(data); } self.undo_layer_data_stack.deinit(); diff --git a/src/plugins/pixelart/src/internal/Layer.zig b/src/plugins/pixi/src/internal/Layer.zig similarity index 84% rename from src/plugins/pixelart/src/internal/Layer.zig rename to src/plugins/pixi/src/internal/Layer.zig index 29a2bd66..5cd26482 100644 --- a/src/plugins/pixelart/src/internal/Layer.zig +++ b/src/plugins/pixi/src/internal/Layer.zig @@ -1,8 +1,8 @@ const std = @import("std"); const dvui = @import("dvui"); const zip = @import("zip"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const Layer = @This(); @@ -34,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 = Globals.allocator().alloc([4]u8, num_pixels) catch return error.MemoryAllocationFailed; + const p = runtime.allocator().alloc([4]u8, num_pixels) catch return error.MemoryAllocationFailed; @memset(p, default_color.toRGBA()); return .{ .id = id, - .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = .{ .pixelsPMA = .{ .rgba = @ptrCast(p), @@ -50,29 +50,29 @@ pub fn init(id: u64, name: []const u8, width: u32, height: u32, default_color: d .invalidation = invalidation, }, }, - .mask = std.DynamicBitSet.initEmpty(Globals.allocator(), num_pixels) catch return error.MemoryAllocationFailed, + .mask = std.DynamicBitSet.initEmpty(runtime.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 = pixelart.image.fromImageFilePath(name, path, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; + const source = pixi_mod.image.fromImageFilePath(name, path, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.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 = pixelart.image.fromImageFileBytes(name, image_bytes, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; + const source = pixi_mod.image.fromImageFileBytes(name, image_bytes, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; @@ -80,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 = 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; + const source = pixi_mod.image.fromPixelsPMA(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; @@ -93,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 = 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; + const source = pixi_mod.image.fromPixels(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.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 = pixelart.fs.sourceFromTexture(name, texture, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; + const source = pixi_mod.fs.sourceFromTexture(name, texture, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; @@ -122,53 +122,53 @@ pub fn size(self: Layer) dvui.Size { pub fn deinit(self: *Layer) void { switch (self.source) { - .imageFile => |image| Globals.allocator().free(image.bytes), - .pixels => |p| Globals.allocator().free(p.rgba), - .pixelsPMA => |p| Globals.allocator().free(p.rgba), + .imageFile => |image| runtime.allocator().free(image.bytes), + .pixels => |p| runtime.allocator().free(p.rgba), + .pixelsPMA => |p| runtime.allocator().free(p.rgba), .texture => |t| dvui.textureDestroyLater(t), } - Globals.allocator().free(self.name); + runtime.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 pixelart.image.pixels(self.source); + return pixi_mod.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 pixelart.image.pixelsFromRect(allocator, self.source, rect); + return pixi_mod.image.pixelsFromRect(allocator, self.source, rect); } /// Casts the source pixels into a slice of bytes pub fn bytes(self: *const Layer) []u8 { - return pixelart.image.bytes(self.source); + return pixi_mod.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 pixelart.image.pixelIndex(self.source, p); + return pixi_mod.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 pixelart.image.point(self.source, index); + return pixi_mod.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 pixelart.image.pixel(self.source, p); + return pixi_mod.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 { - pixelart.image.setPixel(self.source, p, color); + pixi_mod.image.setPixel(self.source, p, color); } /// Sets the mask at the given point @@ -218,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(Globals.allocator()); + var queue = std.array_list.Managed(dvui.Point).init(runtime.allocator()); defer queue.deinit(); queue.append(p) catch return error.MemoryAllocationFailed; @@ -250,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 { - pixelart.image.setPixelIndex(self.source, index, color); + pixi_mod.image.setPixelIndex(self.source, index, color); } pub const ShapeOffsetResult = struct { @@ -267,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 = Globals.state.tools.stroke_shape; - const s: i32 = @intCast(Globals.state.tools.stroke_size); + const shape = runtime.state().tools.stroke_shape; + const s: i32 = @intCast(runtime.state().tools.stroke_size); if (s == 1) { if (current_index != 0) @@ -323,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 = pixelart.math.blendPmaSrcOver; +pub const blendPmaSrcOver = pixi_mod.math.blendPmaSrcOver; pub fn clearRect(self: *Layer, rect: dvui.Rect) void { - pixelart.image.clearRect(self.source, rect); + pixi_mod.image.clearRect(self.source, rect); self.invalidate(); } pub fn setRect(self: *Layer, rect: dvui.Rect, color: [4]u8) void { - pixelart.image.setRect(self.source, rect, color); + pixi_mod.image.setRect(self.source, rect, color); self.invalidate(); } @@ -413,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(Globals.state.host.arena()); + var writer = std.Io.Writer.Allocating.init(runtime.state().host.arena()); - try pixelart.image.ensurePngWriterBuffer(&writer.writer); - try dvui.PNGEncoder.writeWithResolution(&writer.writer, pixelart.image.bytes(source), @intCast(w), @intCast(h), resolution); + try pixi_mod.image.ensurePngWriterBuffer(&writer.writer); + try dvui.PNGEncoder.writeWithResolution(&writer.writer, pixi_mod.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)); @@ -424,7 +424,7 @@ pub fn writeSourceToZip( } pub fn writeSourceToPng(layer: *const Layer, path: []const u8) !void { - return pixelart.fs.writeSourceToPng(layer.source, path); + return pixi_mod.fs.writeSourceToPng(layer.source, path); } pub fn resize(layer: *Layer, new_size: dvui.Size) !void { @@ -433,7 +433,7 @@ pub fn resize(layer: *Layer, new_size: dvui.Size) !void { var new_layer = Layer.init( layer.id, - Globals.allocator().dupe(u8, layer.name) catch return error.MemoryAllocationFailed, + runtime.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 }, @@ -461,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 `pixelart.algorithms.reduce.reduce` so it can be exercised by +/// Pure scalar logic lives in `pixi_mod.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 = pixelart.algorithms.reduce.reduce(layer.pixels(), layer_w, layer_h, .{ + const r = pixi_mod.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/src/internal/Palette.zig b/src/plugins/pixi/src/internal/Palette.zig similarity index 82% rename from src/plugins/pixelart/src/internal/Palette.zig rename to src/plugins/pixi/src/internal/Palette.zig index bc15b826..a2bbf77f 100644 --- a/src/plugins/pixelart/src/internal/Palette.zig +++ b/src/plugins/pixi/src/internal/Palette.zig @@ -2,8 +2,8 @@ const std = @import("std"); const dvui = @import("dvui"); const palette_parse = @import("palette_parse.zig"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); pub const Palette = @This(); @@ -20,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 (pixelart.fs.read(Globals.allocator(), dvui.io, file) catch null) |read| { - defer Globals.allocator().free(read); + if (pixi_mod.fs.read(runtime.allocator(), dvui.io, file) catch null) |read| { + defer runtime.allocator().free(read); return loadFromBytes(allocator, std.fs.path.basename(file), read); } @@ -47,6 +47,6 @@ pub fn loadFromBytes(allocator: std.mem.Allocator, name: []const u8, bytes: []co } pub fn deinit(self: *Palette) void { - Globals.allocator().free(self.name); - Globals.allocator().free(self.colors); + runtime.allocator().free(self.name); + runtime.allocator().free(self.colors); } diff --git a/src/plugins/pixelart/src/internal/Sprite.zig b/src/plugins/pixi/src/internal/Sprite.zig similarity index 100% rename from src/plugins/pixelart/src/internal/Sprite.zig rename to src/plugins/pixi/src/internal/Sprite.zig diff --git a/src/plugins/pixelart/src/internal/grid_layout_validate.zig b/src/plugins/pixi/src/internal/grid_layout_validate.zig similarity index 100% rename from src/plugins/pixelart/src/internal/grid_layout_validate.zig rename to src/plugins/pixi/src/internal/grid_layout_validate.zig diff --git a/src/plugins/pixelart/src/internal/layer_order.zig b/src/plugins/pixi/src/internal/layer_order.zig similarity index 100% rename from src/plugins/pixelart/src/internal/layer_order.zig rename to src/plugins/pixi/src/internal/layer_order.zig diff --git a/src/plugins/pixelart/src/internal/palette_parse.zig b/src/plugins/pixi/src/internal/palette_parse.zig similarity index 100% rename from src/plugins/pixelart/src/internal/palette_parse.zig rename to src/plugins/pixi/src/internal/palette_parse.zig diff --git a/src/plugins/pixelart/src/keybind_ticks.zig b/src/plugins/pixi/src/keybind_ticks.zig similarity index 66% rename from src/plugins/pixelart/src/keybind_ticks.zig rename to src/plugins/pixi/src/keybind_ticks.zig index fff4a09f..ba6ca046 100644 --- a/src/plugins/pixelart/src/keybind_ticks.zig +++ b/src/plugins/pixi/src/keybind_ticks.zig @@ -1,8 +1,8 @@ //! 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 pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const Tools = pixi_mod.Tools; const Export = @import("dialogs/Export.zig"); pub fn tick() !void { @@ -12,7 +12,7 @@ pub fn tick() !void { switch (e.evt) { .key => |ke| { if (ke.matchBind("quick_tools")) { - const rm = &Globals.state.tools.radial_menu; + const rm = &runtime.state().tools.radial_menu; switch (ke.action) { .down => { const mp = dvui.currentWindow().mouse_pt; @@ -30,15 +30,15 @@ pub fn tick() !void { } 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 (runtime.state().tools.current != .selection or runtime.state().tools.selection_mode == .pixel) { + if (runtime.state().tools.stroke_size < Tools.max_brush_size - 1) + runtime.state().tools.stroke_size += 1; + runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size); } } if (ke.matchBind("export") and ke.action == .down) { - var mutex = pixelart.core.dvui.dialog(@src(), .{ + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ .displayFn = Export.dialog, .callafterFn = Export.callAfter, .title = "Export...", @@ -53,27 +53,27 @@ pub fn tick() !void { } 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 (runtime.state().tools.current != .selection or runtime.state().tools.selection_mode == .pixel) { + if (runtime.state().tools.stroke_size > 1) + runtime.state().tools.stroke_size -= 1; + runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size); } } if (ke.matchBind("pencil") and ke.action == .down) { - Globals.state.tools.set(.pencil); + runtime.state().tools.set(.pencil); } if (ke.matchBind("eraser") and ke.action == .down) { - Globals.state.tools.set(.eraser); + runtime.state().tools.set(.eraser); } if (ke.matchBind("bucket") and ke.action == .down) { - Globals.state.tools.set(.bucket); + runtime.state().tools.set(.bucket); } if (ke.matchBind("pointer") and ke.action == .down) { - Globals.state.tools.set(.pointer); + runtime.state().tools.set(.pointer); } if (ke.matchBind("selection") and ke.action == .down) { - Globals.state.tools.set(.selection); + runtime.state().tools.set(.selection); } }, else => {}, diff --git a/src/plugins/pixelart/src/pack_project.zig b/src/plugins/pixi/src/pack_project.zig similarity index 89% rename from src/plugins/pixelart/src/pack_project.zig rename to src/plugins/pixi/src/pack_project.zig index 303c1c64..c72edc33 100644 --- a/src/plugins/pixelart/src/pack_project.zig +++ b/src/plugins/pixi/src/pack_project.zig @@ -3,20 +3,20 @@ 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 pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; const PackJob = @import("PackJob.zig"); -const Internal = pixelart.internal; +const Internal = pixi_mod.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; + if (runtime.state().host.activeDoc()) |doc| { + if (runtime.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_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, anchor, pixi_mod.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); @@ -24,7 +24,7 @@ fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void { } fn appendOpenPackInputs(st: *State, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void { - const gpa = Globals.allocator(); + const gpa = runtime.allocator(); const host = st.host; var i: usize = 0; while (i < host.openDocCount()) : (i += 1) { @@ -39,7 +39,7 @@ 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 gpa = runtime.allocator(); const host = st.host; var i: usize = 0; while (i < host.openDocCount()) : (i += 1) { @@ -61,7 +61,7 @@ fn gatherPackInputs( inputs: *std.ArrayListUnmanaged(PackJob.PackInput), directory: []const u8, ) !void { - const gpa = Globals.allocator(); + const gpa = runtime.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); @@ -88,7 +88,7 @@ fn gatherPackInputs( } pub fn start(st: *State) !void { - const gpa = Globals.allocator(); + const gpa = runtime.allocator(); var inputs: std.ArrayListUnmanaged(PackJob.PackInput) = .empty; errdefer { for (inputs.items) |*input| input.deinit(gpa); @@ -159,7 +159,7 @@ pub fn runWasmWorkers(st: *State) void { pub fn tick(st: *State) void { if (st.pack_jobs.items.len == 0) return; - const gpa = Globals.allocator(); + const gpa = runtime.allocator(); var install_index: ?usize = null; { var i = st.pack_jobs.items.len; @@ -178,23 +178,23 @@ pub fn tick(st: *State) void { if (install_index) |idx| { const job = st.pack_jobs.items[idx]; const new_atlas = job.result_atlas.?; - if (Globals.packer.atlas) |*current_atlas| { + if (runtime.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)); + gpa.free(pixi_mod.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(); + runtime.packer().atlas = new_atlas; + runtime.packer().atlas.?.initCheckerboardTile(); } - Globals.packer.last_packed_at_ns = pixelart.perf.nanoTimestamp(); + runtime.packer().last_packed_at_ns = pixi_mod.perf.nanoTimestamp(); job.result_consumed = true; - st.host.setActiveSidebarView("pixelart.project"); + st.host.setActiveSidebarView("pixi_mod.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 diff --git a/src/plugins/pixelart/src/panel/sprites.zig b/src/plugins/pixi/src/panel/sprites.zig similarity index 96% rename from src/plugins/pixelart/src/panel/sprites.zig rename to src/plugins/pixi/src/panel/sprites.zig index f2b6b06e..8c47d777 100644 --- a/src/plugins/pixelart/src/panel/sprites.zig +++ b/src/plugins/pixi/src/panel/sprites.zig @@ -1,11 +1,11 @@ const std = @import("std"); const icons = @import("icons"); const dvui = @import("dvui"); -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 pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); +const ReflectionLagSample = pixi_mod.sprite_render.ReflectionLagSample; +const reflection_surface_cols = pixi_mod.sprite_render.reflection_surface_cols; +const wsurf = pixi_mod.water_surface; const Sprites = @This(); @@ -114,12 +114,12 @@ const SpriteSlot = struct { } }; -/// Cover-flow scrub momentum tuning (sprite-index units). See `pixelart.Fling`. +/// Cover-flow scrub momentum tuning (sprite-index units). See `pixi_mod.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: pixelart.Fling.Tuning = .{ +const sprite_fling: pixi_mod.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: pixelart.Fling.Tuning = .{ +const sprite_fling_touch: pixi_mod.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: pixelart.Fling = .{}, +fling: pixi_mod.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,26 +209,18 @@ prev_scroll_pos: f32 = 0.0, shelf_vel: f32 = 0.0, pub fn draw(self: *Sprites) !void { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { - const prev_clip = dvui.clip(dvui.parentGet().data().rectScale().r); - defer dvui.clipSet(prev_clip); - - if (dvui.parentGet().data().rect.h < 32.0) { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { + const content_slot = dvui.parentGet().data(); + const parent = content_slot.contentRect(); + if (parent.h < 32.0) { return; } - self.drawAnimationControlsDialog(); + const prev_clip = dvui.clip(content_slot.rectScale().r); + defer dvui.clipSet(prev_clip); - // 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 { - 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 }); - } + self.drawAnimationControlsDialog(); - const parent = dvui.parentGet().data().rect; const parent_height = parent.h; const mode = scrollMode(file); @@ -243,7 +235,7 @@ pub fn draw(self: *Sprites) !void { // the frame playback starts/stops. ---- const playing = file.editor.playing; const flown = sideCardsFlown(playing); - const panel_id = dvui.parentGet().data().id; + const panel_id = content_slot.id; if (flown != self.was_flown) { const cur: f32 = if (dvui.animationGet(panel_id, "play_fly")) |a| a.value() else (if (self.was_flown) 1.0 else 0.0); self.fly_anim_out = flown; @@ -275,7 +267,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 = Globals.state.settings.zoom_steps; + const steps = runtime.state().settings.zoom_steps; const sprite_width = src_rect.w; const sprite_height = src_rect.h; const target_width = parent.w * 0.34; @@ -438,12 +430,23 @@ pub fn draw(self: *Sprites) !void { return; } - const perf_sp = pixelart.perf.spritePreviewBegin(); - defer pixelart.perf.spritePreviewEnd(perf_sp); + const perf_sp = pixi_mod.perf.spritePreviewBegin(); + defer pixi_mod.perf.spritePreviewEnd(perf_sp); const center_x = parent.center().x; - // Lift the row a little so the reflection has room below it. - const center_y = parent.center().y - item_h * 0.10; + // Card rects are positioned in the content slot's *content-local* space, where + // y = 0 is the top of the content area (below the tab strip). So the vertical + // center is half the content height, NOT `parent.center().y`: `parent.y` is the + // slot's offset under the tabs, and including it would push the cards down by a + // fixed tab-height that grows as a fraction of the pane as it shrinks (the + // "drifts off the bottom when small" bug). Horizontal centering uses + // `parent.center().x` only because the slot has no left offset (`parent.x ≈ 0`). + // + // Nudge the centerline up by a fraction of the card height so the reflection + // hanging below the baseline doesn't read as bottom-heavy. The nudge scales + // with `item_h`, so it stays proportional across pane sizes (a fixed pixel + // offset would drift the cards as the pane shrank). + const center_y = parent.h / 2.0 - item_h * 0.10; // The waterline: the shared bottom edge every card stands on (the focus // card's full-height bottom). Side cards pin their bottom here too. const baseline_y = center_y + item_h / 2.0; @@ -749,7 +752,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); - _ = pixelart.sprite_render.sprite(SpriteSlot.src(), .{ + _ = pixi_mod.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 +806,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 !Globals.state.settings.scrolling_cards; + return playing or drawingToolActive() or !runtime.state().settings.scrolling_cards; } /// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee). fn drawingToolActive() bool { - return switch (Globals.state.tools.current) { + return switch (runtime.state().tools.current) { .pointer, .selection => false, .pencil, .eraser, .bucket => true, }; @@ -1050,7 +1053,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 (pixelart.core.dvui.canvasPointerInputSuppressed()) { + if (pixi_mod.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 +1193,7 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px } pub fn drawAnimationControlsDialog(_: *Sprites) void { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { const rect = dvui.parentGet().data().rectScale().r; if (dvui.parentGet().data().rect.h < 48.0) { @@ -1237,8 +1240,8 @@ pub fn drawAnimationControlsDialog(_: *Sprites) void { !fly_forced, flown, ) and !fly_forced) { - Globals.state.settings.scrolling_cards = !Globals.state.settings.scrolling_cards; - Globals.state.settings.save(Globals.state.host); + runtime.state().settings.scrolling_cards = !runtime.state().settings.scrolling_cards; + runtime.state().settings.save(runtime.state().host); dvui.refresh(null, @src(), dvui.parentGet().data().id); } } diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixi/src/plugin.zig similarity index 79% rename from src/plugins/pixelart/src/plugin.zig rename to src/plugins/pixi/src/plugin.zig index 2b77a2df..ac91820e 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixi/src/plugin.zig @@ -4,10 +4,10 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const sdk = pixelart.sdk; -const Globals = pixelart.Globals; -const State = pixelart.State; +const internal = @import("../pixi.zig"); +const sdk = internal.sdk; +const runtime = @import("runtime.zig"); +const State = internal.State; const CanvasData = @import("CanvasData.zig"); const FileWidget = @import("widgets/FileWidget.zig"); const ImageWidget = @import("widgets/ImageWidget.zig"); @@ -26,19 +26,25 @@ const FlatRasterSaveWarning = @import("dialogs/FlatRasterSaveWarning.zig"); const NewFile = @import("dialogs/NewFile.zig"); const DocHandle = sdk.DocHandle; -const Internal = pixelart.internal; +const Internal = internal.internal; + +pub const manifest = sdk.PluginManifest{ + .id = "pixi", + .name = "pixi", + .version = .{ .major = 0, .minor = 1, .patch = 0 }, +}; /// 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"; +pub const view_tools = "internal.tools"; +pub const view_sprites = "internal.sprites"; +pub const view_project = "internal.project"; +pub const bottom_sprites = "internal.sprites_panel"; var plugin: sdk.Plugin = .{ .state = undefined, .vtable = &vtable, - .id = "pixelart", - .display_name = "Pixel Art", + .id = "pixi", + .display_name = "pixi", }; const vtable: sdk.Plugin.VTable = .{ @@ -75,48 +81,37 @@ const vtable: sdk.Plugin.VTable = .{ .documentHasRecognizedSaveExtension = documentHasRecognizedSaveExtension, .showsSaveStatusIndicator = showsSaveStatusIndicator, .isDocumentSaving = isDocumentSaving, - .shouldConfirmFlatRasterSave = shouldConfirmFlatRasterSave, .saveDocumentAsync = saveDocumentAsync, .timeSinceSaveCompleteNs = timeSinceSaveCompleteNs, .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename, .saveDocumentAs = saveDocumentAs, .resetDocumentSaveUIState = resetDocumentSaveUIState, .requestNewDocumentDialog = requestNewDocumentDialog, - .requestGridLayoutDialog = requestGridLayoutDialog, - .requestFlatRasterSaveWarning = requestFlatRasterSaveWarning, .drawDocument = drawDocument, .drawDocumentInfobar = drawDocumentInfobar, + // universal per-frame phases (pixel-art does its raster/canvas work inside them) .beginFrame = beginFrame, + .prepareFrame = warmupActiveDocumentComposites, .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, - .runPackWorkers = pluginRunPackWorkers, - .persistProjectFolder = pluginPersistProjectFolder, - .reloadProjectFolder = pluginReloadProjectFolder, + .tickActiveDocument = tickActiveDocumentPlayback, + .drawOverlay = drawOverlay, + .endFrame = resetDocumentPeekLayers, + .needsContinuousRepaint = isAnyDocumentActivelyDrawing, + // folder lifecycle + save protocol + .onFolderClose = pluginPersistProjectFolder, + .onFolderOpen = pluginReloadProjectFolder, + .saveNeedsConfirmation = shouldConfirmFlatRasterSave, + .requestSaveConfirmation = requestSaveConfirmation, }; /// 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 Globals.state.docs.fileById(doc.id).?; + return runtime.state().docs.fileById(doc.id).?; } -/// Priority for opening `ext` (lower wins). Pixel art owns its native `.fiz`/`.pixi` +/// Priority for opening `ext` (lower wins). pixi 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 { @@ -159,7 +154,7 @@ fn closeDocument(_: *anyopaque, doc: DocHandle) void { /// 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 +/// `canvas.id` / `workspace_handle` / `center` before routing here; pixi 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` (keyed by workbench pane `grouping` on `State`). @@ -176,18 +171,18 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { defer chrome.processColumnReorder(file); defer chrome.processRowReorder(file); - pixelart.perf.canvasPaneDrawn(); + internal.perf.canvasPaneDrawn(); - if (Globals.state.settings.show_rulers and !dvui.firstFrame(container.id)) { - defer pixelart.core.dvui.drawEdgeShadow(container.rectScale(), .top, .{}); + if (runtime.state().settings.show_rulers and !dvui.firstFrame(container.id)) { + defer internal.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 (Globals.state.settings.show_rulers and !dvui.firstFrame(container.id)) { - defer pixelart.core.dvui.drawEdgeShadow(container.rectScale(), .left, .{}); + if (runtime.state().settings.show_rulers and !dvui.firstFrame(container.id)) { + defer internal.core.dvui.drawEdgeShadow(container.rectScale(), .left, .{}); chrome.drawRuler(file, .vertical); } @@ -223,17 +218,17 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { fn drawProjectView(_: ?*anyopaque, pane: *sdk.WorkbenchPaneView) anyerror!void { var content_color = dvui.themeGet().color(.window, .fill); - if (Globals.state.host.appliesNativeWindowOpacity()) { - content_color = if (!Globals.state.host.isMaximized()) - content_color.opacity(Globals.state.host.contentOpacity()) + if (runtime.state().host.appliesNativeWindowOpacity()) { + content_color = if (!runtime.state().host.isMaximized()) + content_color.opacity(runtime.state().host.contentOpacity()) else content_color; } const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32) - Globals.packer.atlas != null + runtime.packer().atlas != null else - Globals.state.host.folder() != null and Globals.packer.atlas != null; + runtime.state().host.folder() != null and runtime.packer().atlas != null; var canvas_vbox = sdk.pane_layout.mainCanvasVbox(content_color, show_packed_atlas, pane.grouping); defer { @@ -243,7 +238,7 @@ fn drawProjectView(_: ?*anyopaque, pane: *sdk.WorkbenchPaneView) anyerror!void { } if (show_packed_atlas) { - const atlas = &Globals.packer.atlas.?; + const atlas = &runtime.packer().atlas.?; var image_widget = ImageWidget.init(@src(), .{ .source = atlas.source, .canvas = &atlas.canvas, @@ -273,7 +268,7 @@ fn drawProjectView(_: ?*anyopaque, pane: *sdk.WorkbenchPaneView) anyerror!void { const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32) "Pack open files to see the preview." - else if (Globals.state.host.folder() == null) + else if (runtime.state().host.folder() == null) "Open a project folder, then pack to see the preview." else "Pack the project to see the preview."; @@ -318,10 +313,7 @@ fn canRedo(state: *anyopaque, doc: DocHandle) bool { } 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). - plugin.state = @ptrCast(@alignCast(Globals.state)); + plugin.state = @ptrCast(@alignCast(runtime.state())); try host.registerPlugin(&plugin); try host.registerFileRowFillColor(.{ .color = &fileRowFillColor }); try host.registerSidebarView(.{ @@ -351,13 +343,41 @@ pub fn register(host: *sdk.Host) !void { .owner = &plugin, .title = "Sprites", .draw = drawSpritesPanel, + .persistent = true, }); try host.registerSettingsSection(.{ - .id = "pixelart.settings", + .id = "internal.settings", .owner = &plugin, - .title = "Pixel Art", + .title = "pixi", .draw = PixelArtSettings.draw, }); + + // Pixel-art's invocable, plugin-specific features. The shell/menus/keybinds trigger these + // by id via `host.runCommand` without naming them. (Generic active-doc editing verbs like + // `transform`/`copy`/`paste` are *not* commands — they are `Plugin.VTable` hooks the shell + // dispatches to the focused document's owner.) + try host.registerCommand(.{ + .id = "internal.gridLayout", + .owner = &plugin, + .title = "Grid Layout…", + .run = gridLayoutCommand, + }); + try host.registerCommand(.{ + .id = "internal.packProject", + .owner = &plugin, + .title = "Pack Project", + .run = packProjectCommand, + .isEnabled = packProjectEnabled, + }); + + // Editing verbs the shell's Edit menu / keybinds dispatch to per active-doc owner + // (`.`). These are pixel-art's answers; another editor registers its own. + try host.registerCommand(.{ .id = "internal.copy", .owner = &plugin, .title = "Copy", .run = pluginCopy }); + try host.registerCommand(.{ .id = "internal.paste", .owner = &plugin, .title = "Paste", .run = pluginPaste }); + try host.registerCommand(.{ .id = "internal.transform", .owner = &plugin, .title = "Transform", .run = pluginTransform }); + try host.registerCommand(.{ .id = "internal.acceptEdit", .owner = &plugin, .title = "Accept Edit", .run = pluginAcceptEdit }); + try host.registerCommand(.{ .id = "internal.cancelEdit", .owner = &plugin, .title = "Cancel Edit", .run = pluginCancelEdit }); + try host.registerCommand(.{ .id = "internal.deleteSelection", .owner = &plugin, .title = "Delete Selection", .run = pluginDeleteSelection }); } /// Stable `*Plugin` for constructing `DocHandle.owner` fields. @@ -366,39 +386,34 @@ pub fn pluginPtr() *sdk.Plugin { } fn fileRowFillColor(_: ?*anyopaque, color_index: usize) ?dvui.Color { - if (Globals.state.colors.palette) |*palette| { + if (runtime.state().colors.palette) |*palette| { return palette.getDVUIColor(color_index); } return null; } fn drawTools(_: ?*anyopaque) anyerror!void { - try Globals.state.tools_pane.draw(); + try runtime.state().tools_pane.draw(); } fn drawSprites(_: ?*anyopaque) anyerror!void { - try Globals.state.sprites_pane.draw(); + try runtime.state().sprites_pane.draw(); } fn drawProject(_: ?*anyopaque) anyerror!void { - try pixelart.explorer.project.draw(); + try internal.explorer.project.draw(); } fn drawSpritesPanel(_: ?*anyopaque) anyerror!void { - try Globals.state.sprites_panel.draw(); + try runtime.state().sprites_panel.draw(); } fn tickKeybinds(_: *anyopaque) anyerror!void { try KeybindTicks.tick(); } -fn processRadialMenuInput(_: *anyopaque) void { +/// Pixel-art's per-frame overlay: the radial tool menu (processes its hold-to-open input, +/// then draws while visible). Wired to the universal `Plugin.drawOverlay` phase. +fn drawOverlay(_: *anyopaque) anyerror!void { RadialMenu.processHoldOpenInput(); -} - -fn radialMenuVisible(_: *anyopaque) bool { - return RadialMenu.visible(); -} - -fn drawRadialMenu(_: *anyopaque) anyerror!void { - try RadialMenu.draw(); + if (RadialMenu.visible()) try RadialMenu.draw(); } fn pluginCopy(state: *anyopaque) anyerror!void { @@ -420,12 +435,12 @@ fn registerOpenDocument(state: *anyopaque, file: *anyopaque) anyerror!*anyopaque fn documentPtr(state: *anyopaque, id: u64) ?*anyopaque { const st: *State = @ptrCast(@alignCast(state)); - return DocsRegistry.documentPtr(st, id); + return DocsRegistry.documentFromId(st, id); } fn documentByPath(state: *anyopaque, path: []const u8) ?*anyopaque { const st: *State = @ptrCast(@alignCast(state)); - return DocsRegistry.documentByPath(st, path); + return DocsRegistry.documentFromPath(st, path); } fn unregisterDocument(state: *anyopaque, id: u64) void { @@ -435,7 +450,7 @@ fn unregisterDocument(state: *anyopaque, id: u64) void { 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); + DocBridge.bindDocumentToWorkspace(st, doc, canvas_id, workspace_handle, center); } fn documentGrouping(state: *anyopaque, doc: DocHandle) u64 { @@ -510,12 +525,12 @@ fn pluginInit(state: *anyopaque) anyerror!void { fn documentStackSize(state: *anyopaque) usize { const st: *State = @ptrCast(@alignCast(state)); - return DocLifecycle.documentStackSize(st); + return DocLifecycle.sizeOfDocument(st); } fn documentStackAlign(state: *anyopaque) usize { const st: *State = @ptrCast(@alignCast(state)); - return DocLifecycle.documentStackAlign(st); + return DocLifecycle.alignOfDocument(st); } fn documentIdFromBuffer(state: *anyopaque, doc: *anyopaque) u64 { @@ -557,18 +572,36 @@ fn requestNewDocumentDialog(_: *anyopaque, parent_path: ?[]const u8, id_extra: u NewFile.request(parent_path, id_extra); } -fn requestGridLayoutDialog(_: *anyopaque, doc: DocHandle) void { +/// Command body for `internal.gridLayout` — opens the grid-layout dialog for the active doc. +fn gridLayoutCommand(_: *anyopaque) anyerror!void { + const doc = runtime.state().host.activeDoc() orelse return; GridLayout.request(doc.id); } -fn requestFlatRasterSaveWarning(_: *anyopaque, doc: DocHandle, mode: sdk.Plugin.FlatRasterSaveMode, from_save_all_quit: bool) void { +fn requestSaveConfirmation(_: *anyopaque, doc: DocHandle, mode: sdk.Plugin.SaveConfirmMode, from_save_all_quit: bool) void { FlatRasterSaveWarning.request(doc.id, mode, from_save_all_quit); } fn beginFrame(state: *anyopaque) void { - _ = state; + const st: *State = @ptrCast(@alignCast(state)); // Advance the per-frame render clock used as a composite-cache invalidation key. - pixelart.render.frame_index +%= 1; + internal.render.frame_index +%= 1; + // Sweep any in-flight atlas-pack jobs. The shell no longer orchestrates packing — the + // plugin drives its own background work from this universal per-frame phase. + PackProject.tick(st); + if (comptime @import("builtin").target.cpu.arch == .wasm32) PackProject.runWasmWorkers(st); +} + +/// Command body for `internal.packProject`. +fn packProjectCommand(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + try PackProject.start(st); +} + +/// `internal.packProject` is enabled only when no pack is already in flight. +fn packProjectEnabled(state: *anyopaque) bool { + const st: *State = @ptrCast(@alignCast(state)); + return !PackProject.isActive(st); } fn tickOpenDocuments(state: *anyopaque) bool { @@ -596,19 +629,17 @@ fn isAnyDocumentActivelyDrawing(state: *anyopaque) bool { return DocLifecycle.isAnyDocumentActivelyDrawing(st); } -fn pluginAcceptEdit(state: *anyopaque) void { - const st: *State = @ptrCast(@alignCast(state)); - DocLifecycle.acceptEdit(st); +// Editing-verb command bodies (registered in `register`). `anyerror!void` to match `Command.run`. +fn pluginAcceptEdit(state: *anyopaque) anyerror!void { + DocLifecycle.acceptEdit(@ptrCast(@alignCast(state))); } -fn pluginCancelEdit(state: *anyopaque) void { - const st: *State = @ptrCast(@alignCast(state)); - DocLifecycle.cancelEdit(st); +fn pluginCancelEdit(state: *anyopaque) anyerror!void { + DocLifecycle.cancelEdit(@ptrCast(@alignCast(state))); } -fn pluginDeleteSelection(state: *anyopaque) void { - const st: *State = @ptrCast(@alignCast(state)); - DocLifecycle.deleteSelection(st); +fn pluginDeleteSelection(state: *anyopaque) anyerror!void { + DocLifecycle.deleteSelection(@ptrCast(@alignCast(state))); } fn pluginPersistProjectFolder(state: *anyopaque) void { @@ -626,26 +657,6 @@ fn pluginPaste(state: *anyopaque) anyerror!void { 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. diff --git a/src/plugins/pixelart/src/radial_menu.zig b/src/plugins/pixi/src/radial_menu.zig similarity index 81% rename from src/plugins/pixelart/src/radial_menu.zig rename to src/plugins/pixi/src/radial_menu.zig index 103c7e2a..a277989f 100644 --- a/src/plugins/pixelart/src/radial_menu.zig +++ b/src/plugins/pixi/src/radial_menu.zig @@ -2,16 +2,16 @@ 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; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const Tools = pixi_mod.Tools; pub fn visible() bool { - return Globals.state.tools.radial_menu.visible; + return runtime.state().tools.radial_menu.visible; } pub fn processHoldOpenInput() void { - const rm = &Globals.state.tools.radial_menu; + const rm = &runtime.state().tools.radial_menu; if (!rm.visible or !rm.opened_by_press) { rm.outside_click_press_p = null; return; @@ -69,7 +69,7 @@ pub fn draw() !void { 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 center = fw.data().rectScale().pointFromPhysical(runtime.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; @@ -102,7 +102,7 @@ pub fn draw() !void { box.deinit(); outer_anim.deinit(); - const ui_atlas = Globals.state.host.uiAtlas(); + const ui_atlas = runtime.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 }, .{ @@ -115,7 +115,7 @@ pub fn draw() !void { } var color = dvui.themeGet().color(.control, .fill_hover); - if (Globals.state.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(i); } @@ -134,8 +134,8 @@ pub fn draw() !void { .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_fill = if (tool == runtime.state().tools.current) dvui.themeGet().color(.content, .fill) else .transparent, + .box_shadow = if (tool == runtime.state().tools.current) .{ .color = .black, .offset = .{ .x = -2.5, .y = 2.5 }, .fade = 4.0, @@ -146,19 +146,19 @@ pub fn draw() !void { .margin = .all(0), }); - Globals.state.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {}; + runtime.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 selection_sprite = switch (runtime.state().tools.selection_mode) { + .box => ui_atlas.sprites[pixi_mod.atlas.sprites.box_selection_default], + .pixel => ui_atlas.sprites[pixi_mod.atlas.sprites.pixel_selection_default], + .color => ui_atlas.sprites[pixi_mod.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], + .pointer => ui_atlas.sprites[pixi_mod.atlas.sprites.cursor_default], + .pencil => ui_atlas.sprites[pixi_mod.atlas.sprites.pencil_default], + .eraser => ui_atlas.sprites[pixi_mod.atlas.sprites.eraser_default], + .bucket => ui_atlas.sprites[pixi_mod.atlas.sprites.bucket_default], .selection => selection_sprite, }; @@ -192,11 +192,11 @@ pub fn draw() !void { angle += step; if (button.hovered()) { - Globals.state.tools.set(tool); + runtime.state().tools.set(tool); } if (button.clicked()) { - Globals.state.tools.set(tool); - Globals.state.tools.radial_menu.close(); + runtime.state().tools.set(tool); + runtime.state().tools.radial_menu.close(); } button.deinit(); @@ -213,8 +213,8 @@ pub fn draw() !void { 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 (runtime.state().host.activeDoc()) |doc| { + if (runtime.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), @@ -229,8 +229,8 @@ pub fn draw() !void { .rect = rect, })) { file.editor.playing = !file.editor.playing; - if (Globals.state.tools.radial_menu.opened_by_press) { - Globals.state.tools.radial_menu.close(); + if (runtime.state().tools.radial_menu.opened_by_press) { + runtime.state().tools.radial_menu.close(); } } } diff --git a/src/plugins/pixelart/src/render.zig b/src/plugins/pixi/src/render.zig similarity index 93% rename from src/plugins/pixelart/src/render.zig rename to src/plugins/pixi/src/render.zig index 4cefd7e2..4ce6bfe9 100644 --- a/src/plugins/pixelart/src/render.zig +++ b/src/plugins/pixi/src/render.zig @@ -1,15 +1,15 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; -const perf = pixelart.perf; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const perf = pixi_mod.perf; /// Monotonic frame counter, incremented once per frame from Editor.tick. pub var frame_index: u64 = 0; pub const RenderFileOptions = struct { - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, rs: dvui.RectScale, color_mod: dvui.Color = .white, fade: f32 = 0.0, @@ -61,7 +61,7 @@ fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void { uploadSubRectAndSyncCache( source_key, &tex, - pixelart.image.bytes(source).ptr, + pixi_mod.image.bytes(source).ptr, @intFromFloat(dirty.x), @intFromFloat(dirty.y), @intFromFloat(dirty.w), @@ -85,7 +85,7 @@ fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void { uploadSubRectAndSyncCache( temp_key, &tex, - pixelart.image.bytes(temp_source).ptr, + pixi_mod.image.bytes(temp_source).ptr, @intFromFloat(dirty.x), @intFromFloat(dirty.y), @intFromFloat(dirty.w), @@ -113,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 (!Globals.state.tools_pane.layersHovered()) { + } else if (!runtime.state().tools_pane.layersHovered()) { min_layer_index = init_opts.file.selected_layer_index; } } @@ -123,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: *pixelart.internal.File, buf: []usize) ?[]const usize { +fn layerOrderBufForDragPreview(file: *pixi_mod.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; - pixelart.internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]); + pixi_mod.internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]); return buf[0..file.layers.len]; } @@ -289,22 +289,22 @@ pub fn renderLayersMagnifierSample(init_opts: RenderFileOptions) !void { const vs = layerViewStateForRender(init_opts); - var path: dvui.Path.Builder = .init(Globals.allocator()); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(init_opts.rs.r, dvui.Rect.Physical.all(0)); - var triangles = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade }); - defer triangles.deinit(Globals.allocator()); + var triangles = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade }); + defer triangles.deinit(runtime.allocator()); triangles.uvFromRectuv(init_opts.rs.r, init_opts.uv); var dimmed_triangles: ?dvui.Triangles = null; defer { - if (dimmed_triangles) |*dt| dt.deinit(Globals.allocator()); + if (dimmed_triangles) |*dt| dt.deinit(runtime.allocator()); } if (vs.needs_dimmed) { - var dt = try triangles.dupe(Globals.allocator()); + var dt = try triangles.dupe(runtime.allocator()); dt.color(.gray); dimmed_triangles = dt; } @@ -371,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: *pixelart.internal.File) struct { w: u32, h: u32 } { +fn layerCompositeExtent(file: *pixi_mod.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(); @@ -390,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: *pixelart.internal.File) !void { +pub fn syncLayerComposite(file: *pixi_mod.internal.File) !void { const ce = layerCompositeExtent(file); const w = ce.w; const h = ce.h; @@ -442,7 +442,7 @@ pub fn syncLayerComposite(file: *pixelart.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: *pixelart.internal.File) !void { +fn syncSplitComposite(file: *pixi_mod.internal.File) !void { const ce = layerCompositeExtent(file); const w = ce.w; const h = ce.h; @@ -527,7 +527,7 @@ fn syncSplitComposite(file: *pixelart.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: *pixelart.internal.File) !void { +pub fn warmupDrawingComposites(file: *pixi_mod.internal.File) !void { const w0 = perf.nanoTimestamp(); try syncSplitComposite(file); _ = file.editor.temporary_layer.source.getTexture() catch null; @@ -540,7 +540,7 @@ pub fn warmupDrawingComposites(file: *pixelart.internal.File) !void { /// from high index (visually bottom) to low index (visually top). An optional /// `skip_index` excludes a single layer. fn renderLayersIntoTarget( - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, target: dvui.Texture.Target, min_index: usize, max_index: usize, @@ -564,12 +564,12 @@ fn renderLayersIntoTarget( defer dvui.clipSet(prev_clip); dvui.clipSet(image_rect); - var path: dvui.Path.Builder = .init(Globals.allocator()); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); - var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = .white, .fade = 0 }); - defer tris.deinit(Globals.allocator()); + var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = .white, .fade = 0 }); + defer tris.deinit(runtime.allocator()); tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 }); var order_buf: [1024]usize = undefined; @@ -597,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: *pixelart.internal.File) !void { +pub fn syncPreviewComposite(file: *pixi_mod.internal.File) !void { const ce = layerCompositeExtent(file); const w = ce.w; const h = ce.h; @@ -659,32 +659,32 @@ pub fn syncPreviewComposite(file: *pixelart.internal.File) !void { // 1) Opaque content-fill base — the transparency backdrop, matching the card. { - var path: dvui.Path.Builder = .init(Globals.allocator()); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); - var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 0 }); - defer tris.deinit(Globals.allocator()); + var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 0 }); + defer tris.deinit(runtime.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(Globals.allocator()); + var path: dvui.Path.Builder = .init(runtime.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(Globals.allocator(), .{ .color = tint, .fade = 0 }); - defer tris.deinit(Globals.allocator()); + var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = tint, .fade = 0 }); + defer tris.deinit(runtime.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(Globals.allocator()); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); - var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = .white, .fade = 0 }); - defer tris.deinit(Globals.allocator()); + var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = .white, .fade = 0 }); + defer tris.deinit(runtime.allocator()); tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 }); if (file.editor.layer_composite_target) |ct| { @@ -701,7 +701,7 @@ pub fn syncPreviewComposite(file: *pixelart.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: *pixelart.internal.File) ?dvui.Texture { +pub fn spritePreviewComposite(file: *pixi_mod.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; @@ -713,7 +713,7 @@ pub fn spritePreviewComposite(file: *pixelart.internal.File) ?dvui.Texture { return dvui.Texture.fromTargetTemp(t) catch null; } -pub fn destroyLayerCompositeResources(file: *pixelart.internal.File) void { +pub fn destroyLayerCompositeResources(file: *pixi_mod.internal.File) void { if (file.editor.layer_composite_target) |t| { t.destroyLater(); file.editor.layer_composite_target = null; @@ -729,7 +729,7 @@ pub fn destroyLayerCompositeResources(file: *pixelart.internal.File) void { destroySplitCompositeResources(file); } -pub fn destroySplitCompositeResources(file: *pixelart.internal.File) void { +pub fn destroySplitCompositeResources(file: *pixi_mod.internal.File) void { if (file.editor.split_composite_below) |t| { t.destroyLater(); file.editor.split_composite_below = null; @@ -767,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(Globals.allocator()); + var qpath: dvui.Path.Builder = .init(runtime.allocator()); defer qpath.deinit(); qpath.addPoint(q[0]); qpath.addPoint(q[1]); qpath.addPoint(q[2]); qpath.addPoint(q[3]); - break :blk try pixelart.sprite_render.pathToSubdividedQuad(qpath.build(), Globals.allocator(), .{ + break :blk try pixi_mod.sprite_render.pathToSubdividedQuad(qpath.build(), runtime.allocator(), .{ .subdivisions = init_opts.quad_subdivisions, .uv = init_opts.uv, .color_mod = init_opts.color_mod, }); } else blk: { - var path: dvui.Path.Builder = .init(Globals.allocator()); + var path: dvui.Path.Builder = .init(runtime.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(Globals.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade }); + var t = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade }); t.uvFromRectuv(content_rs.r, init_opts.uv); break :blk t; }; - defer triangles.deinit(Globals.allocator()); + defer triangles.deinit(runtime.allocator()); var dimmed_triangles: ?dvui.Triangles = null; defer { - if (dimmed_triangles) |*dt| dt.deinit(Globals.allocator()); + if (dimmed_triangles) |*dt| dt.deinit(runtime.allocator()); } if (needs_dimmed) { - var dt = try triangles.dupe(Globals.allocator()); + var dt = try triangles.dupe(runtime.allocator()); dt.color(.gray); dimmed_triangles = dt; } diff --git a/src/plugins/pixi/src/runtime.zig b/src/plugins/pixi/src/runtime.zig new file mode 100644 index 00000000..47f28cef --- /dev/null +++ b/src/plugins/pixi/src/runtime.zig @@ -0,0 +1,35 @@ +//! Runtime accessors — backed by `sdk.runtime` and shell-owned state. +const std = @import("std"); +const sdk = @import("sdk"); +const State = @import("State.zig"); +const Packer = @import("Packer.zig"); + +var shell_state: ?*State = null; + +/// Static embed: App creates state and calls this before `postInit`. +pub fn adoptShellState(st: *State) void { + shell_state = st; +} + +pub fn allocator() std.mem.Allocator { + return sdk.allocator(); +} + +pub fn host() *sdk.Host { + return sdk.host(); +} + +pub fn state() *State { + if (shell_state) |s| return s; + if (sdk.injectedState(State)) |s| return s; + const pl = sdk.host().pluginById("pixi") orelse @panic("pixi plugin not registered"); + return @ptrCast(@alignCast(pl.state)); +} + +pub fn packer() *Packer { + return state().packer orelse @panic("pixi packer not wired"); +} + +pub fn setPacker(p: *Packer) void { + if (shell_state) |s| s.packer = p; +} diff --git a/src/plugins/pixelart/src/sprite_render.zig b/src/plugins/pixi/src/sprite_render.zig similarity index 98% rename from src/plugins/pixelart/src/sprite_render.zig rename to src/plugins/pixi/src/sprite_render.zig index 2b0d705e..b59a639f 100644 --- a/src/plugins/pixelart/src/sprite_render.zig +++ b/src/plugins/pixi/src/sprite_render.zig @@ -2,17 +2,17 @@ //! //! Heavy rendering on top of `core.Sprite` rects: layer compositing, file previews, //! reflections, and water-surface meshes. Shell/workbench UI icons use -//! `pixelart.core_sprite.draw` from core instead of this module. +//! `pixi_mod.core_sprite.draw` from core instead of this module. const std = @import("std"); const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); pub const SpriteInitOptions = struct { source: dvui.ImageSource, - file: ?*pixelart.internal.File = null, + file: ?*pixi_mod.internal.File = null, alpha_source: ?dvui.ImageSource = null, - sprite: pixelart.core_sprite, + sprite: pixi_mod.core_sprite, scale: f32 = 1.0, depth: f32 = 0.0, // -1.0 is front, 1.0 is back reflection: bool = false, @@ -34,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 = pixelart.water_surface.reflection_surface_cols; +pub const reflection_surface_cols = pixi_mod.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 @@ -145,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| pixelart.render.spritePreviewComposite(f) else null; + const preview_tex: ?dvui.Texture = if (init_opts.file) |f| pixi_mod.render.spritePreviewComposite(f) else null; if (init_opts.reflection) { var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena()); @@ -238,7 +238,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt }; if (init_opts.file) |file| { - const preview_opts = pixelart.render.RenderFileOptions{ + const preview_opts = pixi_mod.render.RenderFileOptions{ .file = file, .rs = .{ .r = wd.contentRectScale().r, @@ -247,7 +247,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt .uv = uv, .corner_radius = .all(0), }; - pixelart.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| { + pixi_mod.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| { dvui.log.err("Failed to render reflection layer stack: {any}", .{err}); }; @@ -329,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| { - pixelart.render.renderLayers(.{ + pixi_mod.render.renderLayers(.{ .file = file, .rs = .{ .r = wd.contentRectScale().r, @@ -647,7 +647,7 @@ pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, optio return builder.build(); } -pub fn renderSprite(source: dvui.ImageSource, s: pixelart.core_sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void { +pub fn renderSprite(source: dvui.ImageSource, s: pixi_mod.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/src/transform_op.zig b/src/plugins/pixi/src/transform_op.zig similarity index 94% rename from src/plugins/pixelart/src/transform_op.zig rename to src/plugins/pixi/src/transform_op.zig index ad03b262..193c6d94 100644 --- a/src/plugins/pixelart/src/transform_op.zig +++ b/src/plugins/pixi/src/transform_op.zig @@ -1,9 +1,9 @@ //! 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; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; fn activeFile(st: *State) ?*Internal.File { const doc = st.host.activeDoc() orelse return null; @@ -89,9 +89,9 @@ pub fn begin(st: *State) !void { 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(); + const gpa = runtime.allocator(); file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create target texture", .{}); return; }, @@ -105,7 +105,7 @@ pub fn begin(st: *State) !void { reduced_data_rect.center(), reduced_data_rect.center(), }, - .source = pixelart.image.fromPixelsPMA( + .source = pixi_mod.image.fromPixelsPMA( @ptrCast(file.editor.transform_layer.pixelsFromRect(gpa, reduced_data_rect)), @intFromFloat(reduced_data_rect.w), @intFromFloat(reduced_data_rect.h), diff --git a/src/plugins/pixelart/src/web_file_io.zig b/src/plugins/pixi/src/web_file_io.zig similarity index 85% rename from src/plugins/pixelart/src/web_file_io.zig rename to src/plugins/pixi/src/web_file_io.zig index 62718bfc..2e009273 100644 --- a/src/plugins/pixelart/src/web_file_io.zig +++ b/src/plugins/pixi/src/web_file_io.zig @@ -2,8 +2,8 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); fn downloadNameWithExtension(allocator: std.mem.Allocator, filename: []const u8, ext: []const u8) ![]const u8 { if (std.ascii.eqlIgnoreCase(std.fs.path.extension(filename), ext)) { @@ -24,7 +24,7 @@ pub fn downloadBytes(filename: []const u8, data: []const u8) !void { 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); + const name = try downloadNameWithExtension(runtime.allocator(), filename, ext); + defer runtime.allocator().free(name); try downloadBytes(name, data); } diff --git a/src/plugins/pixelart/src/widgets/CanvasBridge.zig b/src/plugins/pixi/src/widgets/CanvasBridge.zig similarity index 66% rename from src/plugins/pixelart/src/widgets/CanvasBridge.zig rename to src/plugins/pixi/src/widgets/CanvasBridge.zig index 7fe8869a..3218788a 100644 --- a/src/plugins/pixelart/src/widgets/CanvasBridge.zig +++ b/src/plugins/pixi/src/widgets/CanvasBridge.zig @@ -1,13 +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 pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; -const CanvasWidget = pixelart.core.dvui.CanvasWidget; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); +const CanvasWidget = pixi_mod.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 (pixelart.Settings.resolvedPanZoomScheme(&Globals.state.settings, Globals.state.host)) { + return switch (pixi_mod.Settings.resolvedPanZoomScheme(&runtime.state().settings, runtime.state().host)) { .mouse => .mouse, .trackpad => .trackpad, }; @@ -15,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 pixelart.core.dvui.canvasPointerInputSuppressed(); + return pixi_mod.core.dvui.canvasPointerInputSuppressed(); } /// Suppression hook for a dialog-scope canvas (embedded previews like Grid Layout). pub fn dialogSuppressed(_: ?*anyopaque) bool { - return pixelart.core.dvui.dialogCanvasPointerInputSuppressed(); + return pixi_mod.core.dvui.dialogCanvasPointerInputSuppressed(); } diff --git a/src/plugins/pixelart/src/widgets/FileWidget.zig b/src/plugins/pixi/src/widgets/FileWidget.zig similarity index 95% rename from src/plugins/pixelart/src/widgets/FileWidget.zig rename to src/plugins/pixi/src/widgets/FileWidget.zig index 0936378e..88637f46 100644 --- a/src/plugins/pixelart/src/widgets/FileWidget.zig +++ b/src/plugins/pixi/src/widgets/FileWidget.zig @@ -14,25 +14,26 @@ const ScrollContainerWidget = dvui.ScrollContainerWidget; const ScaleWidget = dvui.ScaleWidget; pub const FileWidget = @This(); -const CanvasWidget = pixelart.core.dvui.CanvasWidget; +const CanvasWidget = pixi_mod.core.dvui.CanvasWidget; const CanvasBridge = @import("CanvasBridge.zig"); const CanvasData = @import("../CanvasData.zig"); +const DocLifecycle = @import("../doc_lifecycle.zig"); const icons = @import("icons"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); // ---- 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 { - Globals.state.host.cancel() catch {}; + DocLifecycle.cancelEdit(runtime.state()); } /// 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 = &Globals.state.tools.radial_menu; + const rm = &runtime.state().tools.radial_menu; rm.mouse_position = press_p; rm.center = press_p; rm.visible = true; @@ -44,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 Globals.state.tools.current == .pointer; + return runtime.state().tools.current == .pointer; } init_options: InitOptions, @@ -75,7 +76,7 @@ const SpriteReorderMode = enum { }; pub const InitOptions = struct { - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, center: bool = false, }; @@ -240,7 +241,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: *pixelart.internal.File, point: dvui.Point) void { +pub fn peekLayerAtPoint(file: *pixi_mod.internal.File, point: dvui.Point) void { if (file.editor.isolate_layer) return; var layer_index: usize = file.layers.len; @@ -260,7 +261,7 @@ pub fn peekLayerAtPoint(file: *pixelart.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: *pixelart.internal.File, + file: *pixi_mod.internal.File, point: dvui.Point, change_layer: bool, apply_primary: bool, @@ -272,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 (!Globals.state.tools_pane.layersHovered()) { + } else if (!runtime.state().tools_pane.layersHovered()) { min_layer_index = file.selected_layer_index; } } @@ -292,7 +293,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(Globals.allocator(), layer_index) catch {}; + file.editor.selected_layer_indices.append(runtime.allocator(), layer_index) catch {}; file.editor.layer_selection_anchor = layer_index; } } @@ -306,27 +307,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 (Globals.state.tools.current != .pointer) { - Globals.state.tools.set(.pointer); + if (runtime.state().tools.current != .pointer) { + runtime.state().tools.set(.pointer); } } else if (color[3] == 0) { - if (Globals.state.tools.current != .eraser) { - Globals.state.tools.set(.eraser); + if (runtime.state().tools.current != .eraser) { + runtime.state().tools.set(.eraser); } } else { - Globals.state.colors.primary = color; - if (switch (Globals.state.tools.current) { + runtime.state().colors.primary = color; + if (switch (runtime.state().tools.current) { .pencil, .bucket => false, else => true, }) - Globals.state.tools.set(Globals.state.tools.previous_drawing_tool); + runtime.state().tools.set(runtime.state().tools.previous_drawing_tool); } } else if (apply_primary and color[3] > 0) { - Globals.state.colors.primary = color; + runtime.state().colors.primary = color; } } -fn sample(self: *FileWidget, file: *pixelart.internal.File, point: dvui.Point, screen_p: dvui.Point.Physical, change_layer: bool, change_tool: bool) void { +fn sample(self: *FileWidget, file: *pixi_mod.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; @@ -348,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 (Globals.state.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 (runtime.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| { @@ -377,7 +378,7 @@ pub fn processAnimationSelection(self: *FileWidget) void { } pub fn processCellReorder(self: *FileWidget) void { - if (Globals.state.tools.current != .pointer) return; + if (runtime.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; @@ -443,12 +444,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| { - Globals.allocator().free(insert_before_sprite_indices); + runtime.allocator().free(insert_before_sprite_indices); self.insert_before_sprite_indices = null; } // This will actually trigger the drag/drop - var insert_before_sprite_indices = Globals.allocator().alloc(usize, file.editor.selected_sprites.count()) catch { + var insert_before_sprite_indices = runtime.allocator().alloc(usize, file.editor.selected_sprites.count()) catch { dvui.log.err("Failed to allocate insert before sprite indices", .{}); return; }; @@ -473,11 +474,11 @@ pub fn processCellReorder(self: *FileWidget) void { file.history.append(.{ .reorder_cell = .{ - .removed_sprite_indices = Globals.allocator().dupe(usize, removed_sprite_indices) catch { + .removed_sprite_indices = runtime.allocator().dupe(usize, removed_sprite_indices) catch { dvui.log.err("Failed to duplicate removed sprite indices", .{}); return; }, - .insert_before_sprite_indices = Globals.allocator().dupe(usize, insert_before_sprite_indices) catch { + .insert_before_sprite_indices = runtime.allocator().dupe(usize, insert_before_sprite_indices) catch { dvui.log.err("Failed to duplicate insert before sprite indices", .{}); return; }, @@ -501,7 +502,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 = Globals.allocator().alloc(usize, file.editor.selected_sprites.count()) catch { + var removed_sprite_indices = runtime.allocator().alloc(usize, file.editor.selected_sprites.count()) catch { dvui.log.err("Failed to allocate removed sprite indices", .{}); return; }; @@ -528,7 +529,7 @@ pub fn processCellReorder(self: *FileWidget) void { /// /// Supports add/remove, drag selection, etc. pub fn processSpriteSelection(self: *FileWidget) void { - if (Globals.state.tools.current != .pointer) return; + if (runtime.state().tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -603,9 +604,7 @@ pub fn processSpriteSelection(self: *FileWidget) void { file.editor.primary_sprite_index = sprite_index; } } else if (!file.editor.canvas.hovered) { - Globals.state.host.cancel() catch { - dvui.log.err("Failed to cancel", .{}); - }; + DocLifecycle.cancelEdit(runtime.state()); } } @@ -679,7 +678,7 @@ const BubblePanShared = struct { /// The pixel-art per-pane `CanvasData` for the pane drawing this file, or null if none is /// allocated yet. Holds the column/row reorder drag state this widget reads while previewing. fn canvasData(self: *FileWidget) ?*CanvasData { - return Globals.state.canvas_by_grouping.get(self.init_options.file.editor.grouping); + return runtime.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. @@ -698,7 +697,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 = Globals.state.tools.current != .pointer; + const tool_not_pointer = runtime.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; @@ -870,10 +869,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 = Globals.state.tools.current != .pointer; + const tool_not_pointer = runtime.state().tools.current != .pointer; const mod_shift = cw.modifiers.matchBind("shift"); const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd"); - const radial_visible = Globals.state.tools.radial_menu.visible; + const radial_visible = runtime.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(); @@ -1089,7 +1088,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: *pixelart.internal.File, + file: *pixi_mod.internal.File, sprite_index: usize, sprite_rect: dvui.Rect, accs: ?*BubbleAccs, @@ -1126,7 +1125,7 @@ fn drawSpriteBubbleForRow( if (animation_index) |ai| { const id = file.animations.get(ai).id; - if (Globals.state.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(id)); } if (file.selected_animation_index == ai) { @@ -1432,7 +1431,7 @@ pub fn drawSpriteBubble( var add_rem_message: ?[]const u8 = null; var border_color = dvui.themeGet().color(.control, .fill_hover); - if (Globals.state.colors.file_tree_palette) |*palette| { + if (runtime.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 { @@ -1535,7 +1534,7 @@ pub fn drawSpriteBubble( var anim = self.init_options.file.animations.get(anim_index); - var frames = std.array_list.Managed(pixelart.Animation.Frame).init(Globals.allocator()); + var frames = std.array_list.Managed(pixi_mod.Animation.Frame).init(runtime.allocator()); frames.appendSlice(anim.frames) catch { dvui.log.err("Failed to append frames", .{}); return false; @@ -1612,7 +1611,7 @@ pub fn drawSpriteBubble( self.init_options.file.history.append(.{ .animation_frames = .{ .index = anim_index, - .frames = Globals.allocator().dupe(pixelart.Animation.Frame, anim.frames) catch { + .frames = runtime.allocator().dupe(pixi_mod.Animation.Frame, anim.frames) catch { dvui.log.err("Failed to dupe frames", .{}); return false; }, @@ -1621,7 +1620,7 @@ pub fn drawSpriteBubble( dvui.log.err("Failed to append history", .{}); }; - Globals.allocator().free(anim.frames); + runtime.allocator().free(anim.frames); anim.frames = frames.toOwnedSlice() catch { dvui.log.err("Failed to free frames", .{}); return false; @@ -1634,12 +1633,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; - 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); + runtime.state().sprites_pane.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index]; + runtime.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(Globals.allocator(), .{ .sprite_index = sprite_index, .ms = temp_ms }) catch { + anim.appendFrame(runtime.allocator(), .{ .sprite_index = sprite_index, .ms = temp_ms }) catch { dvui.log.err("Failed to append frame", .{}); return false; }; @@ -1773,7 +1772,7 @@ pub fn drawSpriteBubble( /// Draw the highlight colored selection box for each selected sprite. pub fn drawSpriteSelection(self: *FileWidget) void { - if (Globals.state.tools.current != .pointer) return; + if (runtime.state().tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -1903,8 +1902,8 @@ fn strokePolylineDashedPhysical( } fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void { - if (Globals.state.tools.current != .selection) return; - if (Globals.state.tools.selection_mode != .box) return; + if (runtime.state().tools.current != .selection) return; + if (runtime.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; @@ -1949,8 +1948,8 @@ fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void { /// Preview for rectangular selection while dragging (box mode). fn applySelectionBoxPreview( - file: *pixelart.internal.File, - active_layer: *const pixelart.internal.Layer, + file: *pixi_mod.internal.File, + active_layer: *const pixi_mod.internal.Layer, start: dvui.Point, end: dvui.Point, mod: dvui.enums.Mod, @@ -1993,7 +1992,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 (Globals.state.tools.current) { + if (switch (runtime.state().tools.current) { .selection, => false, else => true, @@ -2016,7 +2015,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 (Globals.state.tools.selection_mode == .pixel or Globals.state.tools.selection_mode == .color) { + if (runtime.state().tools.selection_mode == .pixel or runtime.state().tools.selection_mode == .color) { @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); file.editor.temporary_layer.clearMask(); @@ -2036,21 +2035,21 @@ pub fn processSelection(self: *FileWidget) void { switch (e.evt) { .key => |ke| { var update: bool = false; - if (Globals.state.tools.selection_mode == .pixel) { + if (runtime.state().tools.selection_mode == .pixel) { if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (Globals.state.tools.stroke_size < pixelart.Tools.max_brush_size - 1) - Globals.state.tools.stroke_size += 1; + if (runtime.state().tools.stroke_size < pixi_mod.Tools.max_brush_size - 1) + runtime.state().tools.stroke_size += 1; - Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size); + runtime.state().tools.setStrokeSize(runtime.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 (Globals.state.tools.stroke_size > 1) - Globals.state.tools.stroke_size -= 1; + if (runtime.state().tools.stroke_size > 1) + runtime.state().tools.stroke_size -= 1; - Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size); + runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size); e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); update = true; } @@ -2073,7 +2072,7 @@ pub fn processSelection(self: *FileWidget) void { .temporary, .{ .mask_only = true, - .stroke_size = Globals.state.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); @@ -2091,8 +2090,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 = Globals.state.tools.selection_mode == .box; - const color_mode = Globals.state.tools.selection_mode == .color; + const box_mode = runtime.state().tools.selection_mode == .box; + const color_mode = runtime.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; @@ -2143,7 +2142,7 @@ pub fn processSelection(self: *FileWidget) void { .temporary, .{ .mask_only = true, - .stroke_size = Globals.state.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); @@ -2174,7 +2173,7 @@ pub fn processSelection(self: *FileWidget) void { if (!widget_active) continue; e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - if (Globals.state.tools.selection_mode == .color) { + if (runtime.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(); @@ -2192,14 +2191,14 @@ pub fn processSelection(self: *FileWidget) void { if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) file.editor.selection_layer.clearMask(); - if (Globals.state.tools.selection_mode == .box) { + if (runtime.state().tools.selection_mode == .box) { self.drag_data_point = current_point; } else { file.selectPoint( current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = Globals.state.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); @@ -2212,23 +2211,23 @@ pub fn processSelection(self: *FileWidget) void { dvui.captureMouse(null, e.num); dvui.dragEnd(); - if (Globals.state.tools.selection_mode == .box) { + if (runtime.state().tools.selection_mode == .box) { if (self.drag_data_point) |start| { file.selectRectBetweenPoints( start, current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = Globals.state.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); } - } else if (Globals.state.tools.selection_mode != .color) { + } else if (runtime.state().tools.selection_mode != .color) { file.selectPoint( current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = Globals.state.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); } @@ -2260,14 +2259,14 @@ pub fn processSelection(self: *FileWidget) void { }); } - if (Globals.state.tools.selection_mode == .pixel) { + if (runtime.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 = Globals.state.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); } @@ -2282,7 +2281,7 @@ pub fn processSelection(self: *FileWidget) void { } } - if (Globals.state.tools.selection_mode == .box) { + if (runtime.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)) { @@ -2304,7 +2303,7 @@ pub fn processSelection(self: *FileWidget) void { fn processStrokeDragSegment( self: *FileWidget, - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, previous_point: dvui.Point, current_point: dvui.Point, screen_pt: dvui.Point.Physical, @@ -2365,7 +2364,7 @@ fn processStrokeDragSegment( .stroke_size = stroke_size, }, ); - pixelart.perf.draw_event_count += 1; + pixi_mod.perf.draw_event_count += 1; } else |err| { dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err}); } @@ -2380,7 +2379,7 @@ fn processStrokeDragSegment( { if (self.sample_data_point == null or color[3] == 0) { clearTempPreview(&file.editor); - const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (runtime.state().tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; file.drawPoint( current_point, .temporary, @@ -2401,12 +2400,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 = Globals.state.tools.stroke_size; + const stroke_size = runtime.state().tools.stroke_size; const widget_active = self.active(); if (self.cell_reorder_point != null) return; - if (switch (Globals.state.tools.current) { + if (switch (runtime.state().tools.current) { .pencil, .eraser, => false, @@ -2415,8 +2414,8 @@ pub fn processStroke(self: *FileWidget) void { if (self.sample_key_down or self.right_mouse_down) return; - const color: [4]u8 = switch (Globals.state.tools.current) { - .pencil => Globals.state.colors.primary, + const color: [4]u8 = switch (runtime.state().tools.current) { + .pencil => runtime.state().colors.primary, .eraser => [_]u8{ 0, 0, 0, 0 }, else => unreachable, }; @@ -2561,7 +2560,7 @@ pub fn processStroke(self: *FileWidget) void { self.sample_data_point == null) { clearTempPreview(&file.editor); - const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (runtime.state().tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; file.drawPoint( current_point, .temporary, @@ -2587,10 +2586,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 (Globals.state.tools.current != .bucket) return; + if (runtime.state().tools.current != .bucket) return; if (self.sample_key_down) return; const file = self.init_options.file; - const color = Globals.state.colors.primary; + const color = runtime.state().colors.primary; const widget_active = self.active(); // Skip the cursor-follow temp preview on touch: the finger occludes the pixel and @@ -2600,7 +2599,7 @@ pub fn processFill(self: *FileWidget) void { self.sample_data_point == null) { clearTempPreview(&file.editor); - const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (runtime.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, @@ -2673,7 +2672,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(pixelart.Transform.TransformPoint, @enumFromInt(point_index)); + const transform_point = @as(pixi_mod.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); @@ -2690,7 +2689,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(pixelart.Transform.TransformPoint, @enumFromInt(point_index))) { + if (active_point == @as(pixi_mod.Transform.TransformPoint, @enumFromInt(point_index))) { dvui.cursorSet(.hand); } } @@ -2785,7 +2784,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 = pixelart.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation); + new_point = pixi_mod.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation); const opposite_index: usize = switch (point_index) { 0 => 2, @@ -2836,8 +2835,8 @@ pub fn processTransform(self: *FileWidget) void { const opposite_point = &transform.data_points[opposite_index]; - 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); + var rotation_direction: dvui.Point = pixi_mod.math.rotate(dvui.Point{ .x = 1, .y = 0 }, transform.point(.pivot).*, 0); + var rotation_perp: dvui.Point = pixi_mod.math.rotate(dvui.Point{ .x = 0, .y = 1 }, transform.point(.pivot).*, 0); // Calculate the difference between the adjacent points and the new point @@ -2891,7 +2890,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 = pixelart.math.Direction.fromRadians(transform.rotation); + const direction = pixi_mod.math.Direction.fromRadians(transform.rotation); transform.rotation = switch (direction) { .n => std.math.pi / 2.0, .ne => std.math.pi / 4.0, @@ -3029,7 +3028,7 @@ pub fn drawTransform(self: *FileWidget) void { } var centroid = transform.centroid(); - centroid = pixelart.math.rotate(centroid, transform.point(.pivot).*, transform.rotation); + centroid = pixi_mod.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. @@ -3300,7 +3299,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 = pixelart.math.rotate( + const offset_v = pixi_mod.math.rotate( dvui.Point{ .x = label_off_screen, .y = 0 }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3312,7 +3311,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 = pixelart.math.rotate( + const offset_h = pixi_mod.math.rotate( dvui.Point{ .x = 0, .y = -label_off_screen }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3329,7 +3328,7 @@ pub fn drawTransform(self: *FileWidget) void { const bottom_left = triangles.vertexes[3].pos; const bottom_right = triangles.vertexes[2].pos; - const offset_v = pixelart.math.rotate( + const offset_v = pixi_mod.math.rotate( dvui.Point{ .x = label_off_screen, .y = 0 }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3345,7 +3344,7 @@ pub fn drawTransform(self: *FileWidget) void { ) catch "—"; renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v)); - const offset_h = pixelart.math.rotate( + const offset_h = pixi_mod.math.rotate( dvui.Point{ .x = 0, .y = -label_off_screen }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3445,7 +3444,7 @@ pub fn drawTransform(self: *FileWidget) void { var color = dvui.themeGet().color(.window, .text); if (transform.active_point) |active_point| { - if (active_point == @as(pixelart.Transform.TransformPoint, @enumFromInt(point_index))) { + if (active_point == @as(pixi_mod.Transform.TransformPoint, @enumFromInt(point_index))) { color = dvui.themeGet().color(.highlight, .fill); } } else if (screen_rect.contains(dvui.currentWindow().mouse_pt)) { @@ -3555,7 +3554,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: *pixelart.internal.File, + file: *pixi_mod.internal.File, columns: usize, rows: usize, grid_color: dvui.Color, @@ -3681,7 +3680,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: *pixelart.internal.File) ?struct { +fn fileCanvasVisibleGridParams(file: *pixi_mod.internal.File) ?struct { visible_data: dvui.Rect, row_h: f32, col_w: f32, @@ -3768,7 +3767,7 @@ fn appendHorizontalGridRunsForRow( /// Batches grid lines for the resize-shrink overlay (original layer_rect shown in error tint). fn drawBatchedResizeOverlayGrid( self: *FileWidget, - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, columns: usize, layer_rect: dvui.Rect, grid_thickness: f32, @@ -3855,8 +3854,8 @@ fn checkerboardVertexColor( } /// Animation color for transparency tint; matches bubble arc palette lookup order (selected animation first, else first containing animation). -fn spriteAnimationPaletteColor(file: *pixelart.internal.File, sprite_index: usize) ?dvui.Color { - if (Globals.state.colors.file_tree_palette) |*palette| { +fn spriteAnimationPaletteColor(file: *pixi_mod.internal.File, sprite_index: usize) ?dvui.Color { + if (runtime.state().colors.file_tree_palette) |*palette| { var animation_index: ?usize = null; if (file.selected_animation_index) |selected_animation_index| { @@ -3888,8 +3887,8 @@ fn spriteAnimationPaletteColor(file: *pixelart.internal.File, sprite_index: usiz } fn checkerboardCellCornerColor( - effect: pixelart.Settings.TransparencyEffect, - file: *pixelart.internal.File, + effect: pixi_mod.Settings.TransparencyEffect, + file: *pixi_mod.internal.File, sprite_index: usize, c_tl: dvui.Color, c_tr: dvui.Color, @@ -3930,10 +3929,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: *pixelart.internal.File, sprite_index: usize) dvui.Color { +fn checkerboardTintAtSpriteCellCenter(file: *pixi_mod.internal.File, sprite_index: usize) dvui.Color { const pal = checkerboardGridPalette(); const tone = pal.tone; - switch (Globals.state.settings.transparency_effect) { + switch (runtime.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 }; @@ -3952,11 +3951,11 @@ fn checkerboardTintAtSpriteCellCenter(file: *pixelart.internal.File, sprite_inde /// 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: *pixelart.internal.File) void { +fn drawCheckerboardCellsBatched(file: *pixi_mod.internal.File) void { const n = file.spriteCount(); if (n == 0) return; - const te = Globals.state.settings.transparency_effect; + const te = runtime.state().settings.transparency_effect; const pal = checkerboardGridPalette(); const tone = pal.tone; const rs = file.editor.canvas.screen_rect_scale; @@ -4055,7 +4054,7 @@ fn drawCheckerboardCellsBatched(file: *pixelart.internal.File) void { } pub fn active(self: *FileWidget) bool { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (file.id == self.init_options.file.id) { return true; } @@ -4064,9 +4063,9 @@ pub fn active(self: *FileWidget) bool { } pub fn drawCursor(self: *FileWidget) void { - 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 (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return; + if (runtime.state().tools.current == .pointer and self.sample_data_point == null) return; + if (runtime.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; @@ -4105,20 +4104,20 @@ pub fn drawCursor(self: *FileWidget) void { const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point); - 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], + const selection_sprite = switch (runtime.state().tools.selection_mode) { + .box => if (subtract) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_rem_default] else if (add) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_add_default] else runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_default], + .pixel => if (subtract) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_rem_default] else if (add) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_add_default] else runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_default], + .color => if (subtract) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_rem_default] else if (add) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_add_default] else runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_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], + if (switch (runtime.state().tools.current) { + .pencil => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pencil_default], + .eraser => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.eraser_default], + .bucket => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.bucket_default], .selection => selection_sprite, else => null, }) |sprite| { - const atlas_size = dvui.imageSize(Globals.state.host.uiAtlas().source) catch { + const atlas_size = dvui.imageSize(runtime.state().host.uiAtlas().source) catch { dvui.log.err("Failed to get atlas size", .{}); return; }; @@ -4156,7 +4155,7 @@ pub fn drawCursor(self: *FileWidget) void { const rs = box.data().rectScale(); - dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{ + dvui.renderImage(runtime.state().host.uiAtlas().source, rs, .{ .uv = uv, }) catch { dvui.log.err("Failed to render cursor image", .{}); @@ -4207,7 +4206,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: *pixelart.internal.File, + file: *pixi_mod.internal.File, region_data: dvui.Rect, dest_phys: dvui.Rect.Physical, scale: f32, @@ -4234,7 +4233,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: *pixelart.internal.File, + file: *pixi_mod.internal.File, region_data: dvui.Rect, content_rs: dvui.RectScale, file_w: f32, @@ -4246,18 +4245,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 = pixelart.render.RenderFileOptions{ + const layer_opts_base = pixi_mod.render.RenderFileOptions{ .file = file, .rs = content_rs, .allow_peek = false, }; // Refresh cached layer composites on the screen target (not the magnifier target). - pixelart.render.ensureLayerCompositesForPreview(layer_opts_base) catch { + pixi_mod.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 = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + const target = dvui.textureCreateTarget(.{ .width = w, .height = h, .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create magnifier composite target", .{}); return null; }; @@ -4283,7 +4282,7 @@ fn drawSampleMagnifierCompositeBuild( .w = layer_region.w / file_w, .h = layer_region.h / file_h, }; - pixelart.render.renderLayersMagnifierSample(.{ + pixi_mod.render.renderLayersMagnifierSample(.{ .file = file, .rs = .{ .r = layer_phys, .s = 1.0 }, .uv = uv_rect, @@ -4384,9 +4383,9 @@ fn drawSampleMagnifierPresent( } }, .{ .thickness = 2, .color = .black }); } -pub fn drawSampleMagnifier(file: *pixelart.internal.File, data_point: dvui.Point) void { +pub fn drawSampleMagnifier(file: *pixi_mod.internal.File, data_point: dvui.Point) void { const canvas = &file.editor.canvas; - if (pixelart.core.dvui.canvasPointerInputSuppressed()) return; + if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return; if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return; _ = dvui.cursorSet(.hidden); @@ -4484,8 +4483,8 @@ pub fn updateActiveLayerMask(self: *FileWidget) void { } pub fn drawLayers(self: *FileWidget) void { - const perf_t0 = pixelart.perf.drawLayersBegin(); - defer pixelart.perf.drawLayersEnd(perf_t0); + const perf_t0 = pixi_mod.perf.drawLayersBegin(); + defer pixi_mod.perf.drawLayersEnd(perf_t0); var file = self.init_options.file; var columns: usize = file.columns; @@ -4559,7 +4558,7 @@ pub fn drawLayers(self: *FileWidget) void { self.drawColumnRowReorderPreview(); return; } else { - pixelart.render.renderLayers(.{ + pixi_mod.render.renderLayers(.{ .file = file, .rs = .{ .r = self.init_options.file.editor.canvas.rect, @@ -4611,14 +4610,14 @@ pub fn drawLayers(self: *FileWidget) void { } // Draw the selection box for the selected sprites - if (Globals.state.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) { + if (runtime.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 (Globals.state.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) { + if (runtime.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 }; @@ -4651,7 +4650,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: *pixelart.internal.File, + file: *pixi_mod.internal.File, removed_data_rect: dvui.Rect, strip_phys: dvui.Rect.Physical, axis: ReorderAxis, @@ -4681,7 +4680,7 @@ fn drawCheckerboardReorderFloatingStrip( const c_tr = pal.c_tr; const c_bl = pal.c_bl; const c_br = pal.c_br; - const te = Globals.state.settings.transparency_effect; + const te = runtime.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); @@ -4773,7 +4772,7 @@ fn drawColumnRowReorderPreview(self: *FileWidget) void { fn renderLayersInDataRect( self: *FileWidget, - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, data_rect: dvui.Rect, screen_rect_override: ?dvui.Rect.Physical, ) void { @@ -4781,7 +4780,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); - pixelart.render.renderLayers(.{ + pixi_mod.render.renderLayers(.{ .file = file, .rs = .{ .r = r, .s = scale }, .uv = .{ @@ -4795,7 +4794,7 @@ fn renderLayersInDataRect( fn reorderSegmentRects( axis: ReorderAxis, - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, target_index: usize, removed_index: usize, target_rect: dvui.Rect, @@ -4869,7 +4868,7 @@ fn reorderSegmentRects( fn drawReorderPreviewForAxis( self: *FileWidget, - file: *pixelart.internal.File, + file: *pixi_mod.internal.File, axis: ReorderAxis, target_index: ?usize, removed_index: usize, @@ -5019,10 +5018,10 @@ fn drawReorderPreviewForAxis( }); { - pixelart.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{ + pixi_mod.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{ .opacity = 0.5, }); - pixelart.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{ + pixi_mod.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{ .opacity = 0.5, }); } @@ -5280,22 +5279,22 @@ pub fn drawCellReorderPreview(self: *FileWidget) void { if (left_index) |left_index_value| { if (!temp_selected_sprite.isSet(left_index_value)) { - pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .left, .{ .opacity = 0.35 }); + pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .left, .{ .opacity = 0.35 }); } } if (right_index) |right_index_value| { if (!temp_selected_sprite.isSet(right_index_value)) { - pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 }); + pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 }); } } if (top_index) |top_index_value| { if (!temp_selected_sprite.isSet(top_index_value)) { - pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 }); + pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 }); } } if (bottom_index) |bottom_index_value| { if (!temp_selected_sprite.isSet(bottom_index_value)) { - pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 }); + pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 }); } } } @@ -5475,7 +5474,7 @@ fn autoPanForResize(self: *FileWidget, mouse_pt: dvui.Point.Physical) void { } pub fn processResize(self: *FileWidget) void { - if (Globals.state.tools.current != .pointer) return; + if (runtime.state().tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -5757,7 +5756,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 = !pixelart.core.dvui.canvasPointerInputSuppressed() and + canvas_ptr.hovered = !pixi_mod.core.dvui.canvasPointerInputSuppressed() and canvas_ptr.pointerOverDrawable(mouse_pt); // Cursor-leave: when hover transitions true → false, the last brush/fill preview @@ -5799,18 +5798,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 = pixelart.perf.processEventsBegin(); - defer pixelart.perf.processEventsEnd(pe_t0); + const pe_t0 = pixi_mod.perf.processEventsBegin(); + defer pixi_mod.perf.processEventsEnd(pe_t0); resetTempLayerPreview(&self.init_options.file.editor); { - const mask_t0 = pixelart.perf.updateMaskBegin(); - defer pixelart.perf.updateMaskEnd(mask_t0); + const mask_t0 = pixi_mod.perf.updateMaskBegin(); + defer pixi_mod.perf.updateMaskEnd(mask_t0); self.updateActiveLayerMask(); } - if (Globals.state.tools.current == .selection) { + if (runtime.state().tools.current == .selection) { if (dvui.timerDoneOrNone(self.init_options.file.editor.canvas.scroll_container.data().id)) { self.init_options.file.editor.checkerboard.toggleAll(); @@ -5819,14 +5818,14 @@ pub fn processEvents(self: *FileWidget) void { } if (self.init_options.file.editor.transform == null) { - const tool_t0 = pixelart.perf.toolProcessBegin(); - switch (Globals.state.tools.current) { + const tool_t0 = pixi_mod.perf.toolProcessBegin(); + switch (runtime.state().tools.current) { .bucket => self.processFill(), .pencil, .eraser => self.processStroke(), .selection => self.processSelection(), else => {}, } - pixelart.perf.toolProcessEnd(tool_t0); + pixi_mod.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 @@ -5881,10 +5880,10 @@ pub fn processEvents(self: *FileWidget) void { } // Draw shadows for the scroll container - 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, .{}); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .top, .{ .opacity = 0.15 }); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .left, .{ .opacity = 0.15 }); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .right, .{}); self.drawTransform(); self.processSample(); @@ -5903,7 +5902,7 @@ pub fn deinit(self: *FileWidget) void { } pub fn hovered(self: *FileWidget) bool { - if (pixelart.core.dvui.canvasPointerInputSuppressed()) return false; + if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return false; return self.init_options.file.editor.canvas.hovered; } @@ -5957,7 +5956,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 pixelart.internal.File, stroke_size: usize) dvui.Rect { +fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const pixi_mod.internal.File, stroke_size: usize) dvui.Rect { const vis = canvas.dataFromScreenRect(canvas.rect); const m: f32 = @floatFromInt(stroke_size); const inflated = vis.outsetAll(m); @@ -5966,7 +5965,7 @@ fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const pixelart.intern return dvui.Rect.intersect(inflated, .{ .x = 0, .y = 0, .w = iw, .h = ih }); } -fn expandTempGpuDirtyRect(editor: *pixelart.internal.File.EditorData, rect: dvui.Rect) void { +fn expandTempGpuDirtyRect(editor: *pixi_mod.internal.File.EditorData, rect: dvui.Rect) void { if (editor.temp_gpu_dirty_rect) |existing| { editor.temp_gpu_dirty_rect = existing.unionWith(rect); } else { @@ -5980,10 +5979,10 @@ fn expandTempGpuDirtyRect(editor: *pixelart.internal.File.EditorData, rect: dvui /// 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: *pixelart.internal.File.EditorData) void { +fn clearTempPreview(editor: *pixi_mod.internal.File.EditorData) void { if (editor.temp_preview_dirty_rect) |dirty| { if (dirty.w > 0 and dirty.h > 0) { - pixelart.image.clearRect(editor.temporary_layer.source, dirty); + pixi_mod.image.clearRect(editor.temporary_layer.source, dirty); expandTempGpuDirtyRect(editor, dirty); } } @@ -5991,10 +5990,10 @@ fn clearTempPreview(editor: *pixelart.internal.File.EditorData) void { } /// Clears the temporary brush preview layer and marks GPU/composite dirty. -fn resetTempLayerPreview(editor: *pixelart.internal.File.EditorData) void { +fn resetTempLayerPreview(editor: *pixi_mod.internal.File.EditorData) void { if (editor.temp_preview_dirty_rect) |dirty| { if (dirty.w > 0 and dirty.h > 0) { - pixelart.image.clearRect(editor.temporary_layer.source, dirty); + pixi_mod.image.clearRect(editor.temporary_layer.source, dirty); expandTempGpuDirtyRect(editor, dirty); } editor.temp_preview_dirty_rect = null; diff --git a/src/plugins/pixelart/src/widgets/ImageWidget.zig b/src/plugins/pixi/src/widgets/ImageWidget.zig similarity index 94% rename from src/plugins/pixelart/src/widgets/ImageWidget.zig rename to src/plugins/pixi/src/widgets/ImageWidget.zig index e314d129..9448eb42 100644 --- a/src/plugins/pixelart/src/widgets/ImageWidget.zig +++ b/src/plugins/pixi/src/widgets/ImageWidget.zig @@ -1,5 +1,5 @@ pub const ImageWidget = @This(); -const CanvasWidget = pixelart.core.dvui.CanvasWidget; +const CanvasWidget = pixi_mod.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 (pixelart.image.pixelIndex(self.init_options.source, point)) |index| { - const c = pixelart.image.pixels(self.init_options.source)[index]; + if (pixi_mod.image.pixelIndex(self.init_options.source, point)) |index| { + const c = pixi_mod.image.pixels(self.init_options.source)[index]; if (c[3] > 0) { color = c; } } - Globals.state.colors.primary = color; + runtime.state().colors.primary = color; self.sample_data_point = point; if (color[3] == 0) { - if (Globals.state.tools.current != .eraser) { - Globals.state.tools.set(.eraser); + if (runtime.state().tools.current != .eraser) { + runtime.state().tools.set(.eraser); } } else { - Globals.state.tools.set(Globals.state.tools.previous_drawing_tool); + runtime.state().tools.set(runtime.state().tools.previous_drawing_tool); } } pub fn drawCursor(self: *ImageWidget) void { - if (pixelart.core.dvui.canvasPointerInputSuppressed()) return; + if (pixi_mod.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 (pixelart.core.dvui.canvasPointerInputSuppressed()) return; + if (pixi_mod.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 = pixelart.image.size(source); + const size = pixi_mod.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 (Globals.packer.atlas) |atlas| return atlas.checkerboard_tile; + if (runtime.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 `pixelart.render.renderLayers` against the cached `canvas.rect` + // its layer textures via `pixi_mod.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(); - 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 }); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .bottom, .{ .opacity = 0.15 }); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .left, .{}); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .right, .{ .opacity = 0.15 }); self.drawCursor(); self.drawSample(); @@ -470,8 +470,8 @@ const std = @import("std"); const math = std.math; const dvui = @import("dvui"); const builtin = @import("builtin"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/plugins/pixi/static/integration.zig b/src/plugins/pixi/static/integration.zig new file mode 100644 index 00000000..c88440b7 --- /dev/null +++ b/src/plugins/pixi/static/integration.zig @@ -0,0 +1,134 @@ +//! Pixi plugin — fizzy-internal static-embed + bundled-dylib module graph. +//! Runs only from the fizzy build root, so paths are single fizzy-relative literals. +//! +//! The vendored `zstbi`/`msf_gif` modules are built via the reusable `fizzy.plugin.addCModule` +//! helper (same one a third-party C plugin uses, and the one pixi's own standalone `build.zig` +//! calls) — so the build *logic* lives in one place. `zip` keeps its purpose-built +//! `src/deps/zip/build.zig` (a distinct "C into the consumer + wasm libc shim" pattern). +const std = @import("std"); +const helpers = @import("../../shared/build/helpers.zig"); +const fizzy_plugin = @import("../../../../plugin_sdk.zig"); +const zip_mod = @import("../src/deps/zip/build.zig"); + +pub const id = "pixi"; +pub const installDylib = helpers.installDylib; + +const deps_root = "src/plugins/pixi/src/deps"; + +pub const ZipPackage = zip_mod.Package; +pub fn zipPackage(b: *std.Build) ZipPackage { + return zip_mod.package(b, .{}); +} +pub fn linkZipNative(exe: *std.Build.Step.Compile) void { + zip_mod.link(exe); +} +pub fn linkZipWasm(exe: *std.Build.Step.Compile) void { + zip_mod.linkWasm(exe); +} + +pub fn addZstbiModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + web: bool, +) *std.Build.Module { + const web_cflags = [_][]const u8{ "-DSTBI_NO_STDLIB=1", "-DSTBI_NO_SIMD=1" }; + const c_sources = if (web) &[_]fizzy_plugin.CSourceFile{ + .{ .file = b.path(deps_root ++ "/stbi/zstbi.c"), .flags = &web_cflags }, + .{ .file = b.path(deps_root ++ "/stbi/fizzy_stbi_libc.c"), .flags = &web_cflags }, + } else &[_]fizzy_plugin.CSourceFile{ + .{ .file = b.path(deps_root ++ "/stbi/zstbi.c") }, + }; + return fizzy_plugin.addCModule(b, .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path(deps_root ++ "/stbi/zstbi.zig"), + .c_sources = c_sources, + .link_libc = !web, + .single_threaded = web, + }); +} + +pub fn addMsfGifModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + web: bool, +) *std.Build.Module { + const web_cflags = [_][]const u8{"-I" ++ deps_root ++ "/msf_gif/wasm_shim"}; + const c_sources = if (web) &[_]fizzy_plugin.CSourceFile{ + .{ .file = b.path(deps_root ++ "/msf_gif/fizzy_msf_gif_wasm.c"), .flags = &web_cflags }, + } else &[_]fizzy_plugin.CSourceFile{ + .{ .file = b.path(deps_root ++ "/msf_gif/msf_gif.c") }, + }; + return fizzy_plugin.addCModule(b, .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path(deps_root ++ "/msf_gif/msf_gif.zig"), + .c_sources = c_sources, + .link_libc = !web, + .single_threaded = web, + }); +} + +const module_path = "src/plugins/pixi/pixi.zig"; +const dylib_path = "src/plugins/pixi/root.zig"; + +pub const ModuleImports = 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, + msf_gif: *std.Build.Module, + icons: ?*std.Build.Module = null, + backend: ?*std.Build.Module = null, +}; + +fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { + module.addImport("dvui", imports.dvui); + module.addImport("core", imports.core); + module.addImport("sdk", imports.sdk); + if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); + module.addImport("assets", imports.assets); + module.addImport("zip", imports.zip); + module.addImport("zstbi", imports.zstbi); + module.addImport("msf_gif", imports.msf_gif); + if (imports.icons) |icons| module.addImport("icons", icons); + if (imports.backend) |backend| module.addImport("backend", backend); +} + +pub fn addStaticModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = helpers.addStaticModule(b, .{ + .import_name = id, + .root_source_file = b.path(module_path), + .target = target, + .optimize = optimize, + }, consumer); + applyImports(mod, imports); + return mod; +} + +pub fn addDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, +) *std.Build.Step.Compile { + const lib = helpers.addDylib(b, .{ + .name = id, + .root_source_file = b.path(dylib_path), + .target = target, + .optimize = optimize, + }); + applyImports(lib.root_module, imports); + return lib; +} diff --git a/src/plugins/shared/build/helpers.zig b/src/plugins/shared/build/helpers.zig new file mode 100644 index 00000000..3551f235 --- /dev/null +++ b/src/plugins/shared/build/helpers.zig @@ -0,0 +1,93 @@ +//! Fizzy-internal build helpers for the static-embed + bundled-dylib graph of built-in +//! plugins. These always run from the fizzy build root, so every path is a single +//! fizzy-relative `b.path(...)` — there is no plugin-package root to disambiguate. +//! Third-party plugins never touch this; they use `fizzy.plugin.create` / `.install`. +const std = @import("std"); + +/// C-ABI entry symbols the host looks up. Kept in sync with `plugin_sdk.dylib_exports` +/// (the third-party path); duplicated here to avoid a deep relative import. +pub const dylib_exports = [_][]const u8{ + "fizzy_plugin_abi_fingerprint", + "fizzy_plugin_sdk_version", + "fizzy_plugin_min_sdk_version", + "fizzy_plugin_version", + "fizzy_plugin_id", + "fizzy_plugin_register", + "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_render_bridge", + "fizzy_plugin_set_globals", +}; + +pub const StaticModuleOptions = struct { + import_name: []const u8, + root_source_file: std.Build.LazyPath, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + options_name: ?[]const u8 = null, + options: ?*std.Build.Step.Options = null, +}; + +pub fn addStaticModule( + b: *std.Build, + opts: StaticModuleOptions, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = opts.root_source_file, + .link_libc = opts.target.result.cpu.arch != .wasm32, + .single_threaded = opts.target.result.cpu.arch == .wasm32, + }); + if (opts.options_name) |name| { + if (opts.options) |o| mod.addOptions(name, o); + } + consumer.addImport(opts.import_name, mod); + return mod; +} + +pub const DylibOptions = struct { + name: []const u8, + root_source_file: std.Build.LazyPath, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + options_name: ?[]const u8 = null, + options: ?*std.Build.Step.Options = null, +}; + +pub fn addDylib( + b: *std.Build, + opts: DylibOptions, +) *std.Build.Step.Compile { + const dylib_module = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = opts.root_source_file, + .link_libc = true, + }); + if (opts.options_name) |name| { + if (opts.options) |o| dylib_module.addOptions(name, o); + } + const lib = b.addLibrary(.{ + .name = opts.name, + .linkage = .dynamic, + .root_module = dylib_module, + }); + lib.linker_allow_shlib_undefined = true; + lib.root_module.export_symbol_names = &dylib_exports; + return lib; +} + +pub fn installDylib(b: *std.Build, lib: *std.Build.Step.Compile, name: []const u8) void { + const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) { + .windows => "dll", + .macos => "dylib", + else => "so", + }; + const dest = b.fmt("{s}.{s}", .{ name, ext }); + const install_step = b.addInstallArtifact(lib, .{ + .dest_dir = .{ .override = .prefix }, + .dest_sub_path = dest, + }); + b.getInstallStep().dependOn(&install_step.step); +} diff --git a/src/plugins/workbench/build.zig b/src/plugins/workbench/build.zig new file mode 100644 index 00000000..fe3d88a5 --- /dev/null +++ b/src/plugins/workbench/build.zig @@ -0,0 +1,34 @@ +//! Standalone build for the workbench plugin — the canonical third-party shape. +//! `cd src/plugins/workbench && zig build` produces `workbench.`. The +//! `-Dworkbench-file-tree` option feeds a `workbench_opts` module the plugin imports; +//! attaching a build-options module to a `fizzy.plugin.create` lib is exactly how any +//! third-party plugin would expose compile-time flags. See docs/PLUGINS.md. +const std = @import("std"); +const fizzy = @import("fizzy"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const file_tree = b.option( + bool, + "workbench-file-tree", + "Register the Files sidebar view at compile time", + ) orelse true; + const workbench_opts = b.addOptions(); + workbench_opts.addOption(bool, "file_tree", file_tree); + + const lib = fizzy.plugin.create(b, .{ + .name = "workbench", + .target = target, + .optimize = optimize, + .root_source_file = b.path("root.zig"), + }); + lib.root_module.addOptions("workbench_opts", workbench_opts); + + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + lib.root_module.addImport("icons", dep.module("icons")); + } + + fizzy.plugin.install(b, lib, .{}); +} diff --git a/src/plugins/workbench/build.zig.zon b/src/plugins/workbench/build.zig.zon new file mode 100644 index 00000000..9e850490 --- /dev/null +++ b/src/plugins/workbench/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .workbench, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "root.zig", + "workbench.zig", + "src", + "static", + }, + .fingerprint = 0xc23e2206858de248, + .dependencies = .{ + .fizzy = .{ + .path = "../../..", + }, + .icons = .{ + .url = "https://github.com/foxnne/zig-lib-icons/archive/db034786a1286ab28dc35aba534c098aa4f1a3aa.tar.gz", + .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_", + .lazy = true, + }, + }, +} diff --git a/src/plugins/workbench/dylib.zig b/src/plugins/workbench/dylib.zig deleted file mode 100644 index da517a17..00000000 --- a/src/plugins/workbench/dylib.zig +++ /dev/null @@ -1,44 +0,0 @@ -//! 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. -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); -} - -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, - 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/module.zig b/src/plugins/workbench/module.zig deleted file mode 100644 index dbdfd671..00000000 --- a/src/plugins/workbench/module.zig +++ /dev/null @@ -1,12 +0,0 @@ -//! Workbench plugin compile-time module root. -//! -//! 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"); -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/root.zig b/src/plugins/workbench/root.zig new file mode 100644 index 00000000..21bedd2b --- /dev/null +++ b/src/plugins/workbench/root.zig @@ -0,0 +1,7 @@ +//! Dylib entry for the workbench plugin — canonical shape: one `exportEntry` wired to +//! `src/plugin.zig` (see `src/plugins/root.zig`). +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig deleted file mode 100644 index 77353152..00000000 --- a/src/plugins/workbench/src/Globals.zig +++ /dev/null @@ -1,32 +0,0 @@ -//! 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`. -//! 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; -const Workbench = @import("Workbench.zig"); -const core = @import("core"); - -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; -} - -/// 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, - workbench_ptr: ?*Workbench, -) void { - 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/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index a317a5e6..ba813ed2 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -12,19 +12,14 @@ const dvui = @import("dvui"); const icons = @import("icons"); const files = @import("files.zig"); const Workspace = @import("Workspace.zig"); -const Globals = @import("Globals.zig"); +const runtime = @import("runtime.zig"); const workbench_layout = @import("workbench_layout.zig"); const sdk = @import("sdk"); -pub const Workbench = @This(); +pub const Api = sdk.services.workbench.Api; +pub const BranchDecorator = Api.BranchDecorator; -/// 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, -}; +pub const Workbench = @This(); allocator: std.mem.Allocator, decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty, @@ -110,13 +105,13 @@ pub fn drawWorkspaces(self: *Workbench, panel: workbench_layout.PanelPanedState, 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 runtime.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 doc = runtime.host().docByIndex(index) orelse return; const grouping = doc.owner.documentGrouping(doc); if (self.workspaces.getPtr(grouping)) |workspace| { self.open_workspace_grouping = grouping; @@ -152,7 +147,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 = Globals.host.docFromPath(path) orelse return; + const doc = runtime.host().docFromPath(path) orelse return; if (doc.owner.showsSaveStatusIndicator(doc)) return; if (!doc.owner.isDirty(doc)) return; dvui.icon(@src(), "explorer_dirty", icons.tvg.lucide.@"circle-small", .{ @@ -166,104 +161,9 @@ fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void { } // ============================================================================ -// workbench-api — the formal Host service +// workbench-api — the formal Host service (layout defined in sdk/services/workbench.zig) // ============================================================================ -/// 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 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, @@ -289,10 +189,10 @@ fn svcOpen(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool { return hostOf(ctx).openFilePath(path, grouping); } fn svcCurrentGrouping(_: *anyopaque) u64 { - return Globals.workbench.currentGroupingID(); + return runtime.workbench().currentGroupingID(); } fn svcNewGrouping(_: *anyopaque) u64 { - return Globals.workbench.newGroupingID(); + return runtime.workbench().newGroupingID(); } fn svcClose(ctx: *anyopaque, id: u64) anyerror!void { return hostOf(ctx).closeDocById(id); @@ -326,5 +226,5 @@ fn svcMove(_: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!boo return files.moveOnePath(path, target_dir, dvui.currentWindow().arena()); } fn svcRegisterBranchDecorator(_: *anyopaque, decorator: BranchDecorator) anyerror!void { - return Globals.workbench.registerBranchDecorator(decorator); + return runtime.workbench().registerBranchDecorator(decorator); } diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index a68c3e0b..96f19304 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -5,7 +5,7 @@ const wb = @import("../workbench.zig"); const dvui = wb.dvui; const wdvui = wb.wdvui; const sdk = wb.sdk; -const Globals = @import("Globals.zig"); +const runtime = @import("runtime.zig"); const icons = @import("icons"); /// Workspaces are drawn recursively inside of the explorer paned widget @@ -37,8 +37,8 @@ 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 { - for (Globals.host.plugins.items) |plugin| { - plugin.removeCanvasPane(self.grouping, Globals.allocator()); + for (runtime.host().plugins.items) |plugin| { + plugin.removeCanvasPane(self.grouping, runtime.allocator()); } } @@ -80,7 +80,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"))) { - Globals.workbench.open_workspace_grouping = self.grouping; + runtime.workbench().open_workspace_grouping = self.grouping; } } } @@ -88,7 +88,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 = Globals.host.activeSidebarView(); + const active = runtime.host().activeSidebarView(); if (active != null and active.?.draw_workspace != null) { var pane_view: sdk.WorkbenchPaneView = .{ .grouping = self.grouping, @@ -116,7 +116,7 @@ pub fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.B } fn drawTabs(self: *Workspace) void { - if (Globals.host.openDocCount() == 0) return; + if (runtime.host().openDocCount() == 0) return; // Handle dragging of tabs between workspace reorderables (tab bars) defer self.processTabsDrag(); @@ -127,6 +127,8 @@ fn drawTabs(self: *Workspace) void { var tabs_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), .id_extra = @intCast(self.grouping), }); defer tabs_box.deinit(); @@ -134,6 +136,9 @@ fn drawTabs(self: *Workspace) void { var scroll_area = dvui.scrollArea(@src(), .{ .horizontal = .auto, .horizontal_bar = .hide, .vertical_bar = .hide }, .{ .expand = .none, .background = false, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .border = dvui.Rect.all(0), .corner_radius = dvui.Rect.all(0), .id_extra = @intCast(self.grouping), }); @@ -148,20 +153,22 @@ fn drawTabs(self: *Workspace) void { var tabs_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), .id_extra = @intCast(self.grouping), }); defer tabs_hbox.deinit(); - const files_len = Globals.host.openDocCount(); + const files_len = runtime.host().openDocCount(); // Find the neighbouring tabs (within this workspace grouping) of the active tab. var prev_same_group_index: ?usize = null; var next_same_group_index: ?usize = null; const active_in_this_group = blk: { - if (Globals.workbench.open_workspace_grouping != self.grouping) break :blk false; + if (runtime.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; + const active_doc = runtime.host().docByIndex(self.open_file_index) orelse break :blk false; if (active_doc.owner.documentGrouping(active_doc) != self.grouping) break :blk false; break :blk true; }; @@ -172,7 +179,7 @@ fn drawTabs(self: *Workspace) void { var j: usize = active_index; while (j > 0) { j -= 1; - const tab_doc = Globals.host.docByIndex(j) orelse continue; + const tab_doc = runtime.host().docByIndex(j) orelse continue; if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) { prev_same_group_index = j; break; @@ -181,7 +188,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; + const tab_doc = runtime.host().docByIndex(j) orelse continue; if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) { next_same_group_index = j; break; @@ -190,7 +197,7 @@ fn drawTabs(self: *Workspace) void { } for (0..files_len) |i| { - const doc = Globals.host.docByIndex(i) orelse continue; + const doc = runtime.host().docByIndex(i) orelse continue; const is_fizzy_file = doc.owner.documentHasNativeExtension(doc); if (doc.owner.documentGrouping(doc) != self.grouping) continue; @@ -200,22 +207,20 @@ fn drawTabs(self: *Workspace) void { .id_extra = i, .padding = dvui.Rect.all(0), .margin = dvui.Rect.all(0), + .border = .all(0), }); defer reorderable.deinit(); - 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(); + const selected = self.open_file_index == i and runtime.workbench().open_workspace_grouping == self.grouping; var hbox: dvui.BoxWidget = undefined; hbox.init(@src(), .{ .dir = .horizontal }, .{ .expand = .none, - .border = .all(0), - .color_fill = if (selected) .transparent else dvui.themeGet().color(.window, .fill).opacity(Globals.host.contentOpacity()), + .border = dvui.Rect.all(0), + .color_fill = if (selected) .transparent else dvui.themeGet().color(.window, .fill).opacity(runtime.host().contentOpacity()), .background = true, .id_extra = i, - .padding = dvui.Rect.all(2), + .padding = .{ .x = 2, .y = 2, .w = 2, .h = 0 }, .margin = dvui.Rect.all(0), }); @@ -223,20 +228,6 @@ fn drawTabs(self: *Workspace) void { const tab_hovered = wdvui.hovered(hbox.data()); - if (selected) { - if (!reorderable.floating()) { - dvui.Path.stroke(.{ - .points = &.{ - hbox.data().rectScale().r.bottomLeft(), - hbox.data().rectScale().r.bottomRight(), - }, - }, .{ - .color = dvui.themeGet().color(.window, .text), - .thickness = 1, - }); - } - } - if (reorderable.floating()) { self.tabs_drag_index = i; hbox.data().options.color_fill = dvui.themeGet().color(.control, .fill); @@ -267,7 +258,7 @@ fn drawTabs(self: *Workspace) void { } if (is_fizzy_file) { - const ui_atlas = Globals.host.uiAtlas(); + const ui_atlas = runtime.host().uiAtlas(); 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, .{ @@ -290,13 +281,13 @@ fn drawTabs(self: *Workspace) void { }); 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 }, .{ .expand = .none, .gravity_y = 0.5, - .min_size_content = .{ .w = tab_status_slot, .h = tab_status_slot }, + .margin = dvui.Rect.all(0), + .padding = wdvui.tab_status_inset, + .min_size_content = .{ .w = close_inner, .h = close_inner }, }); defer status_close_box.deinit(); @@ -335,91 +326,79 @@ fn drawTabs(self: *Workspace) void { }, .{ .complete_elapsed_ns = save_flash_elapsed, }); - } else if (tab_hovered) { + } else { var tab_close_button: dvui.ButtonWidget = undefined; - tab_close_button.init(@src(), .{ .draw_focus = false }, wdvui.windowHeaderCloseButtonOptions(.{ + tab_close_button.init(@src(), .{ .draw_focus = false }, wdvui.tabCloseButtonOptions(.{ .expand = .none, .min_size_content = .{ .w = close_inner, .h = close_inner }, + .gravity_x = 0.5, + .gravity_y = 0.5, .id_extra = i *% 16 + 1, })); defer tab_close_button.deinit(); tab_close_button.processEvents(); - tab_close_button.drawBackground(); - tab_close_button.drawFocus(); - if (tab_close_button.hovered()) { + const dirty = doc.owner.isDirty(doc); + const show_close_visible = tab_hovered or (selected and !dirty); + const err_accent = dvui.themeGet().color(.err, .fill); + const close_hovered = tab_close_button.hovered(); + + if (show_close_visible and (tab_hovered or close_hovered)) { + const rs = tab_close_button.data().borderRectScale(); + rs.r.fill(dvui.Rect.Physical.all(rs.r.h * 0.5), .{ + .color = err_accent, + }); + } + + if (dirty and !show_close_visible) { + dvui.icon(@src(), "dirty_icon", icons.tvg.lucide.@"circle-small", .{ + .stroke_color = dvui.themeGet().color(.window, .text), + }, .{ + .expand = .none, + .min_size_content = .{ .w = close_inner, .h = close_inner }, + .gravity_x = 0.5, + .gravity_y = 0.5, + .id_extra = i *% 16 + 0, + }); + } else { + const icon_color = if (!show_close_visible) + dvui.Color.transparent + else if (tab_hovered or close_hovered) + dvui.Color.white + else + dvui.themeGet().color(.window, .text); dvui.icon(@src(), "close", icons.tvg.lucide.x, .{ - .stroke_color = dvui.themeGet().color(.err, .fill).lighten(if (dvui.themeGet().dark) -10 else 10), - .fill_color = dvui.themeGet().color(.err, .fill).lighten(if (dvui.themeGet().dark) -10 else 10), + .stroke_color = icon_color, + .fill_color = icon_color, }, .{ - .expand = .ratio, + .expand = .none, + .min_size_content = .{ .w = close_inner, .h = close_inner }, .gravity_x = 0.5, .gravity_y = 0.5, .id_extra = i *% 16 + 2, + .background = false, + .border = dvui.Rect.all(0), + .box_shadow = null, + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, }); } if (tab_close_button.clicked()) { - Globals.host.closeDocById(doc.id) catch |err| { + runtime.host().closeDocById(doc.id) catch |err| { dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) }); }; break; } - } 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 }, wdvui.windowHeaderCloseButtonOptions(.{ - .expand = .none, - .min_size_content = .{ .w = close_inner, .h = close_inner }, - .id_extra = i *% 16 + 3, - .style = .window, - .background = false, - .box_shadow = null, - .border = .all(0), - .color_fill = .transparent, - .color_fill_hover = .transparent, - .color_fill_press = .transparent, - .ninepatch_fill = &dvui.Ninepatch.none, - .ninepatch_hover = &dvui.Ninepatch.none, - .ninepatch_press = &dvui.Ninepatch.none, - })); - defer ghost_close.deinit(); - - ghost_close.processEvents(); - // Invisible hit target only — `drawBackground` would run theme ninepatch. - - dvui.icon(@src(), "close", icons.tvg.lucide.x, .{ - .stroke_color = tab_text, - .fill_color = tab_text, - }, .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .id_extra = i *% 16 + 4, - .background = false, - .border = .all(0), - .box_shadow = null, - .ninepatch_fill = &dvui.Ninepatch.none, - .ninepatch_hover = &dvui.Ninepatch.none, - .ninepatch_press = &dvui.Ninepatch.none, - }); + } - if (ghost_close.clicked()) { - Globals.host.closeDocById(doc.id) catch |err| { - dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) }); - }; - break; - } - } else if (doc.owner.isDirty(doc)) { - dvui.icon(@src(), "dirty_icon", icons.tvg.lucide.@"circle-small", .{ - .stroke_color = dvui.themeGet().color(.window, .text), - }, .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .padding = dvui.Rect.all(2), - .id_extra = i *% 16 + 0, - }); + if (selected and !reorderable.floating()) { + wdvui.drawTabActiveIndicator( + reorderable.data().borderRectScale(), + dvui.themeGet().color(.window, .text), + ); } loop: for (dvui.events()) |*e| { @@ -430,7 +409,7 @@ fn drawTabs(self: *Workspace) void { switch (e.evt) { .mouse => |me| { if (me.action == .press and me.button.pointer()) { - Globals.host.setActiveDocIndex(i); + runtime.host().setActiveDocIndex(i); dvui.refresh(null, @src(), hbox.data().id); e.handle(@src(), hbox.data()); @@ -455,7 +434,7 @@ fn drawTabs(self: *Workspace) void { } } if (tabs.finalSlot()) { - self.tabs_insert_before_index = Globals.host.openDocCount(); + self.tabs_insert_before_index = runtime.host().openDocCount(); } } } @@ -465,44 +444,44 @@ 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 > Globals.host.openDocCount()) return; + if (removed > runtime.host().openDocCount()) return; if (removed > insert_before) { - Globals.host.swapDocs(removed, insert_before); - Globals.host.setActiveDocIndex(insert_before); + runtime.host().swapDocs(removed, insert_before); + runtime.host().setActiveDocIndex(insert_before); } else { if (insert_before > 0) { - Globals.host.swapDocs(removed, insert_before - 1); - Globals.host.setActiveDocIndex(insert_before - 1); + runtime.host().swapDocs(removed, insert_before - 1); + runtime.host().setActiveDocIndex(insert_before - 1); } else { - Globals.host.swapDocs(removed, insert_before); - Globals.host.setActiveDocIndex(insert_before); + runtime.host().swapDocs(removed, insert_before); + runtime.host().setActiveDocIndex(insert_before); } } self.tabs_removed_index = null; self.tabs_insert_before_index = null; } else { // Dragging from another workspace - for (Globals.workbench.workspaces.values()) |*workspace| { + for (runtime.workbench().workspaces.values()) |*workspace| { if (workspace.tabs_removed_index) |removed| { if (removed > insert_before) { - Globals.host.swapDocs(removed, insert_before); - if (Globals.host.docByIndex(insert_before)) |d| { + runtime.host().swapDocs(removed, insert_before); + if (runtime.host().docByIndex(insert_before)) |d| { d.owner.setDocumentGrouping(d, self.grouping); } - Globals.host.setActiveDocIndex(insert_before); + runtime.host().setActiveDocIndex(insert_before); } else { if (insert_before > 0) { - Globals.host.swapDocs(removed, insert_before - 1); - if (Globals.host.docByIndex(insert_before - 1)) |d| { + runtime.host().swapDocs(removed, insert_before - 1); + if (runtime.host().docByIndex(insert_before - 1)) |d| { d.owner.setDocumentGrouping(d, self.grouping); } - Globals.host.setActiveDocIndex(insert_before - 1); + runtime.host().setActiveDocIndex(insert_before - 1); } else { - Globals.host.swapDocs(removed, insert_before); - if (Globals.host.docByIndex(insert_before)) |d| { + runtime.host().swapDocs(removed, insert_before); + if (runtime.host().docByIndex(insert_before)) |d| { d.owner.setDocumentGrouping(d, self.grouping); } - Globals.host.setActiveDocIndex(insert_before); + runtime.host().setActiveDocIndex(insert_before); } } @@ -519,12 +498,12 @@ pub fn processTabsDrag(self: *Workspace) void { /// Repoint `open_file_index` on workspaces that were showing the dragged tab as active. fn repointWorkspacesAfterTabDrag(tab_bar_workspace: ?*Workspace, drag_index: usize) void { - const dragged_doc = Globals.host.docByIndex(drag_index) orelse return; + const dragged_doc = runtime.host().docByIndex(drag_index) orelse return; if (tab_bar_workspace) |workspace| { - if (workspace.open_file_index == Globals.host.docIndex(dragged_doc.id)) { + if (workspace.open_file_index == runtime.host().docIndex(dragged_doc.id)) { var i: usize = 0; - while (i < Globals.host.openDocCount()) : (i += 1) { - const doc = Globals.host.docByIndex(i).?; + while (i < runtime.host().openDocCount()) : (i += 1) { + const doc = runtime.host().docByIndex(i).?; if (doc.owner.documentGrouping(doc) == workspace.grouping and doc.id != dragged_doc.id) { workspace.open_file_index = i; break; @@ -532,11 +511,11 @@ fn repointWorkspacesAfterTabDrag(tab_bar_workspace: ?*Workspace, drag_index: usi } } } else { - for (Globals.workbench.workspaces.values()) |*w| { + for (runtime.workbench().workspaces.values()) |*w| { if (w.open_file_index == drag_index) { var i: usize = 0; - while (i < Globals.host.openDocCount()) : (i += 1) { - const doc = Globals.host.docByIndex(i).?; + while (i < runtime.host().openDocCount()) : (i += 1) { + const doc = runtime.host().docByIndex(i).?; if (doc.owner.documentGrouping(doc) == w.grouping and doc.id != dragged_doc.id) { w.open_file_index = i; break; @@ -554,13 +533,13 @@ const WorkspaceTabDragSrc = union(enum) { none, fn resolve() WorkspaceTabDragSrc { - for (Globals.workbench.workspaces.values()) |*w| { + for (runtime.workbench().workspaces.values()) |*w| { if (w.tabs_drag_index) |i| return .{ .tab_bar = .{ .ws = w, .index = i } }; } - if (Globals.workbench.tab_drag_from_tree_path) |p| { + if (runtime.workbench().tab_drag_from_tree_path) |p| { var i: usize = 0; - while (i < Globals.host.openDocCount()) : (i += 1) { - const doc = Globals.host.docByIndex(i).?; + while (i < runtime.host().openDocCount()) : (i += 1) { + const doc = runtime.host().docByIndex(i).?; if (doc.owner.documentByPath(p) != null) { return .{ .tree_open = i }; } @@ -575,7 +554,7 @@ 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")) { - Globals.workbench.clearFileTreeTabDragDropState(); + runtime.workbench().clearFileTreeTabDragDropState(); return; } @@ -598,7 +577,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 Globals.workbench.workspaces.keys()[Globals.workbench.workspaces.keys().len - 1] == self.grouping) { + if (right_side.contains(e.evt.mouse.p) and runtime.workbench().workspaces.keys()[runtime.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), @@ -610,13 +589,13 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - Globals.workbench.clearFileTreeTabDragDropState(); + runtime.workbench().clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(workspace, drag_index); - const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; - const new_g = Globals.workbench.newGroupingID(); + const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue; + const new_g = runtime.workbench().newGroupingID(); dragged_doc.owner.setDocumentGrouping(dragged_doc, new_g); - Globals.workbench.open_workspace_grouping = new_g; + runtime.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) { @@ -630,13 +609,13 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - Globals.workbench.clearFileTreeTabDragDropState(); + runtime.workbench().clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(workspace, drag_index); - const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; + const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue; 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; + runtime.workbench().open_workspace_grouping = self.grouping; + self.open_file_index = runtime.host().docIndex(dragged_doc.id) orelse 0; } } }, @@ -645,7 +624,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 Globals.workbench.workspaces.keys()[Globals.workbench.workspaces.keys().len - 1] == self.grouping) { + if (right_side.contains(e.evt.mouse.p) and runtime.workbench().workspaces.keys()[runtime.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), @@ -656,13 +635,13 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - Globals.workbench.clearFileTreeTabDragDropState(); + runtime.workbench().clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(null, drag_index); - const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; - const new_g = Globals.workbench.newGroupingID(); + const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue; + const new_g = runtime.workbench().newGroupingID(); dragged_doc.owner.setDocumentGrouping(dragged_doc, new_g); - Globals.workbench.open_workspace_grouping = new_g; + runtime.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) { @@ -675,13 +654,13 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - Globals.workbench.clearFileTreeTabDragDropState(); + runtime.workbench().clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(null, drag_index); - const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; + const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue; 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; + runtime.workbench().open_workspace_grouping = self.grouping; + self.open_file_index = runtime.host().docIndex(dragged_doc.id) orelse 0; } } }, @@ -690,7 +669,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 Globals.workbench.workspaces.keys()[Globals.workbench.workspaces.keys().len - 1] == self.grouping) { + if (right_side.contains(e.evt.mouse.p) and runtime.workbench().workspaces.keys()[runtime.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), @@ -701,23 +680,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 = Globals.workbench.newGroupingID(); - const maybe_idx = Globals.host.openOrFocusFileAtGrouping(path, new_g) catch { - Globals.workbench.clearFileTreeTabDragDropState(); + const new_g = runtime.workbench().newGroupingID(); + const maybe_idx = runtime.host().openOrFocusFileAtGrouping(path, new_g) catch { + runtime.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(null, idx); - Globals.workbench.open_workspace_grouping = new_g; + runtime.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. - Globals.workbench.clearFileTreeTabDragDropState(); + runtime.workbench().clearFileTreeTabDragDropState(); } } else if (data.rectScale().r.contains(e.evt.mouse.p)) { if (e.evt == .mouse and e.evt.mouse.action == .position) { @@ -730,8 +709,8 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - const maybe_idx = Globals.host.openOrFocusFileAtGrouping(path, self.grouping) catch { - Globals.workbench.clearFileTreeTabDragDropState(); + const maybe_idx = runtime.host().openOrFocusFileAtGrouping(path, self.grouping) catch { + runtime.workbench().clearFileTreeTabDragDropState(); continue :events_loop; }; if (maybe_idx) |idx| { @@ -741,7 +720,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { // Else: async load into this workspace's existing grouping. The // worker's `processLoadingJobs` focus handler will set the active // file once it lands. - Globals.workbench.clearFileTreeTabDragDropState(); + runtime.workbench().clearFileTreeTabDragDropState(); } } }, @@ -754,15 +733,15 @@ pub fn drawCanvas(self: *Workspace) !void { switch (builtin.os.tag) { .macos => { - content_color = if (!Globals.host.isMaximized()) content_color.opacity(Globals.host.contentOpacity()) else content_color; + content_color = if (!runtime.host().isMaximized()) content_color.opacity(runtime.host().contentOpacity()) else content_color; }, .windows => { - content_color = if (!Globals.host.isMaximized()) content_color.opacity(Globals.host.contentOpacity()) else content_color; + content_color = if (!runtime.host().isMaximized()) content_color.opacity(runtime.host().contentOpacity()) else content_color; }, else => {}, } - const has_files = Globals.host.openDocCount() > 0; + const has_files = runtime.host().openDocCount() > 0; var canvas_vbox = workspaceMainCanvasVbox(content_color, has_files, self.grouping); defer { @@ -773,13 +752,13 @@ pub fn drawCanvas(self: *Workspace) !void { defer self.processTabDrag(canvas_vbox.data()); if (has_files) { - if (self.open_file_index >= Globals.host.openDocCount()) { - self.open_file_index = Globals.host.openDocCount() - 1; + if (self.open_file_index >= runtime.host().openDocCount()) { + self.open_file_index = runtime.host().openDocCount() - 1; } - if (Globals.host.docByIndex(self.open_file_index)) |doc| { - doc.owner.bindDocumentToPane(doc, canvas_vbox.data().id, self, self.center); - _ = try doc.owner.drawDocument(doc); + if (runtime.host().docByIndex(self.open_file_index)) |doc| { + doc.owner.bindDocumentToPane(doc, canvas_vbox.data().id, self, self.center); + _ = try doc.owner.drawDocument(doc); } } else { var box = workspaceEmptyStateCard(content_color, self.grouping); @@ -890,7 +869,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { ); if (button.clicked()) { - Globals.host.requestNewDocument(null, 0); + runtime.host().requestNewDocument(null, 0); } } { @@ -917,7 +896,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { ); if (button.clicked()) { - Globals.host.showOpenFolderDialog(setProjectFolderCallback, null); + runtime.host().showOpenFolderDialog(setProjectFolderCallback, null); } } @@ -945,7 +924,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { ); if (button.clicked()) { - Globals.host.showOpenFileDialog(openFilesCallback, &.{ + runtime.host().showOpenFileDialog(openFilesCallback, &.{ .{ .name = "Image Files", .pattern = "fizzy;png;jpg;jpeg" }, }, "", null); } @@ -970,7 +949,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { }); defer scroll_area.deinit(); - var i: usize = Globals.host.recentFolderCount(); + var i: usize = runtime.host().recentFolderCount(); while (i > 0) : (i -= 1) { var anim = dvui.animate(@src(), .{ .kind = .horizontal, @@ -982,7 +961,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { }); defer anim.deinit(); - const folder = Globals.host.recentFolderAt(i - 1) orelse continue; + const folder = runtime.host().recentFolderAt(i - 1) orelse continue; if (dvui.button(@src(), folder, .{ .draw_focus = false, }, .{ @@ -996,7 +975,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 Globals.host.setProjectFolder(folder); + try runtime.host().setProjectFolder(folder); } } } @@ -1058,7 +1037,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| { - Globals.host.setProjectFolder(f[0]) catch { + runtime.host().setProjectFolder(f[0]) catch { dvui.log.err("Failed to set project folder: {s}", .{f[0]}); }; } @@ -1067,7 +1046,7 @@ pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void { pub fn openFilesCallback(files: ?[][:0]const u8) void { if (files) |f| { for (f) |file| { - _ = Globals.host.openFilePath(file, Globals.workbench.open_workspace_grouping) catch { + _ = runtime.host().openFilePath(file, runtime.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 12496b5d..05741780 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const wb = @import("../workbench.zig"); -const Globals = @import("Globals.zig"); +const runtime = @import("runtime.zig"); const dvui = wb.dvui; const wdvui = wb.wdvui; const icons = @import("icons"); @@ -12,7 +12,7 @@ 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 `Globals.allocator()` so they outlive the dvui arena used during draw. +/// allocated from `runtime.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; @@ -76,10 +76,10 @@ 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 (Globals.host.folder()) |path| { + if (runtime.host().folder()) |path| { try drawFiles(path, tree); } else { - Globals.workbench.file_tree_data_id = null; + runtime.workbench().file_tree_data_id = null; dvui.labelNoFmt( @src(), "Open a project folder to begin.", @@ -89,7 +89,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 Globals.host.setProjectFolder(folder); + try runtime.host().setProjectFolder(folder); } } } @@ -99,7 +99,7 @@ fn drawWeb() !void { var tree = wdvui.TreeWidget.tree(@src(), .{}, .{ .background = false, .expand = .both }); defer tree.deinit(); - const viewport_w = Globals.host.explorerViewportWidth(); + const viewport_w = runtime.host().explorerViewportWidth(); const wrap_w: f32 = if (viewport_w > 0) viewport_w else 200; { @@ -126,7 +126,7 @@ fn drawWeb() !void { .style = .highlight, .min_size_content = .{ .w = 110, .h = 0 }, })) { - Globals.host.showOpenFileDialog( + runtime.host().showOpenFileDialog( struct { fn cb(_: ?[][:0]const u8) void {} }.cb, @@ -139,9 +139,10 @@ fn drawWeb() !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; + runtime.workbench().file_tree_data_id = unique_id; - var filter_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); + // Right margin keeps the entry clear of the overlay scrollbar that draws over the pane's right edge. + var filter_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .w = 10 } }); dvui.icon( @src(), "FilterIcon", @@ -263,7 +264,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u if ((dvui.menuItemLabel(@src(), "Close", .{}, .{ .expand = .horizontal, })) != null) { - Globals.host.closeProjectFolder(); + runtime.host().closeProjectFolder(); fw2.close(); } @@ -271,7 +272,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) { - Globals.host.openInFileBrowser(project_path) catch { + runtime.host().openInFileBrowser(project_path) catch { dvui.log.err("Failed to open file browser", .{}); }; @@ -281,7 +282,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u if ((dvui.menuItemLabel(@src(), "New File...", .{}, .{ .expand = .horizontal })) != null) { defer fw2.close(); - Globals.host.requestNewDocument(project_path, root_branch_id.asUsize()); + runtime.host().requestNewDocument(project_path, root_branch_id.asUsize()); } if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) { @@ -409,7 +410,7 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind .expand = .horizontal, .gravity_y = 0.5, }); - Globals.workbench.drawBranchDecorations(full_path, id_extra); + runtime.workbench().drawBranchDecorations(full_path, id_extra); } else { dvui.label(@src(), "{s}", .{label}, .{ .color_text = color, @@ -459,8 +460,8 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u &.{ directory, entry.name }, ); - if (Globals.host.folder()) |proj_root| { - if (Globals.host.isPathIgnored(proj_root, abs_path, entry.name, entry.kind)) { + if (runtime.host().folder()) |proj_root| { + if (runtime.host().isPathIgnored(proj_root, abs_path, entry.name, entry.kind)) { continue; } } @@ -475,10 +476,10 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u } inner_id_extra.* = dvui.Id.update(tree.data().id, abs_path).asUsize(); - try visible_file_rows_order.append(Globals.allocator(), .{ .id = inner_id_extra.*, .path = abs_path }); + try visible_file_rows_order.append(runtime.allocator(), .{ .id = inner_id_extra.*, .path = abs_path }); var color = dvui.themeGet().color(.control, .fill); - if (Globals.host.fileRowFillColor(color_id.*)) |tint| { + if (runtime.host().fileRowFillColor(color_id.*)) |tint| { color = tint; } @@ -492,7 +493,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u var expanded = false; const expanded_indent: f32 = 14.0; - if (Globals.host.explorerBranchIsOpen(branch_id)) { + if (runtime.host().explorerBranchIsOpen(branch_id)) { expanded = true; } @@ -572,13 +573,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u dvui.dataSetSlice(null, inner_unique_id, "removed_path", abs_path); if (entry.kind == .file and tree.id_branch == inner_id_extra.*) { - if (Globals.workbench.tab_drag_from_tree_path) |old| { + if (runtime.workbench().tab_drag_from_tree_path) |old| { if (!std.mem.eql(u8, old, abs_path)) { - Globals.allocator().free(old); - Globals.workbench.tab_drag_from_tree_path = Globals.allocator().dupe(u8, abs_path) catch null; + runtime.allocator().free(old); + runtime.workbench().tab_drag_from_tree_path = runtime.allocator().dupe(u8, abs_path) catch null; } } else { - Globals.workbench.tab_drag_from_tree_path = Globals.allocator().dupe(u8, abs_path) catch null; + runtime.workbench().tab_drag_from_tree_path = runtime.allocator().dupe(u8, abs_path) catch null; } } } @@ -591,7 +592,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u if (branch.dropInto() and entry.kind == .directory) { try applyFileMove(inner_unique_id, tree, abs_path); // Expand the folder so the dropped item is visible - Globals.host.setExplorerBranchOpen(branch_id, true); + runtime.host().setExplorerBranchOpen(branch_id, true); } { // Add right click context menu for item options @@ -627,7 +628,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u break :blk &[_][]const u8{}; }; for (to_open) |p| { - _ = Globals.host.openFilePath(p, Globals.workbench.currentGroupingID()) catch |e| { + _ = runtime.host().openFilePath(p, runtime.workbench().currentGroupingID()) catch |e| { dvui.log.err("Failed to open file: {any} ({s})", .{ e, p }); }; } @@ -647,13 +648,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u var have_grouping = false; for (to_open) |p| { if (!have_grouping) { - side_grouping = if (Globals.host.openDocCount() == 0) - Globals.workbench.currentGroupingID() + side_grouping = if (runtime.host().openDocCount() == 0) + runtime.workbench().currentGroupingID() else - Globals.workbench.newGroupingID(); + runtime.workbench().newGroupingID(); have_grouping = true; } - _ = Globals.host.openFilePath(p, side_grouping) catch { + _ = runtime.host().openFilePath(p, side_grouping) catch { dvui.log.err("Failed to open file: {s}", .{p}); }; } @@ -665,7 +666,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u } if ((dvui.menuItemLabel(@src(), open_message, .{}, .{ .expand = .horizontal })) != null) { - Globals.host.openInFileBrowser(if (entry.kind == .file) std.fs.path.dirname(abs_path) orelse abs_path else abs_path) catch { + runtime.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", .{}); }; @@ -676,7 +677,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u defer fw2.close(); const parent_dir: []const u8 = if (entry.kind == .directory) abs_path else directory; - Globals.host.requestNewDocument(parent_dir, branch_id.asUsize()); + runtime.host().requestNewDocument(parent_dir, branch_id.asUsize()); } if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) { @@ -737,7 +738,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color; if (ext == .fizzy) { - const ui_atlas = Globals.host.uiAtlas(); + const ui_atlas = runtime.host().uiAtlas(); 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( @@ -763,15 +764,15 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u editableLabel( inner_id_extra.*, - 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), + if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", runtime.host().folder().?, abs_path) catch entry.name else entry.name, + if (runtime.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 (Globals.host.docFromPath(abs_path)) |doc| { + if (runtime.host().docFromPath(abs_path)) |doc| { if (doc.owner.showsSaveStatusIndicator(doc)) { wdvui.bubbleSpinner(@src(), .{ .id_extra = inner_id_extra.* +% 4001, @@ -790,7 +791,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u const mode = detectClickMode(branch.button.data().borderRectScale().r); applyFileClick(inner_id_extra.*, abs_path, mode); if (mode == .replace and openablePath(abs_path)) { - _ = Globals.host.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| { + _ = runtime.host().openFilePath(abs_path, runtime.workbench().currentGroupingID()) catch |err| { dvui.log.err("{any}: {s}", .{ err, abs_path }); }; } @@ -857,7 +858,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u // .alpha = 0.15 * t, // }, })) { - Globals.host.setExplorerBranchOpen(branch_id, true); + runtime.host().setExplorerBranchOpen(branch_id, true); try search( abs_path, tree, @@ -868,13 +869,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u branch, ); } else { - if (Globals.host.explorerBranchIsOpen(branch_id)) { - Globals.host.setExplorerBranchOpen(branch_id, false); + if (runtime.host().explorerBranchIsOpen(branch_id)) { + runtime.host().setExplorerBranchOpen(branch_id, false); } } // Keep open_branches in sync so hover-expand and drop-into expand persist next frame if (branch.expanded) { - Globals.host.setExplorerBranchOpen(branch_id, true); + runtime.host().setExplorerBranchOpen(branch_id, true); } color_id.* = color_id.* + 1; }, @@ -897,26 +898,26 @@ pub fn isFileSelected(id: usize) bool { fn selectionFreeAll() void { var it = selected_paths.iterator(); - while (it.next()) |e| Globals.allocator().free(e.value_ptr.*); + while (it.next()) |e| runtime.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; - Globals.allocator().free(existing.*); - existing.* = Globals.allocator().dupe(u8, path) catch return; + runtime.allocator().free(existing.*); + existing.* = runtime.allocator().dupe(u8, path) catch return; return; } - const copy = Globals.allocator().dupe(u8, path) catch return; - selected_paths.put(Globals.allocator(), id, copy) catch { - Globals.allocator().free(copy); + const copy = runtime.allocator().dupe(u8, path) catch return; + selected_paths.put(runtime.allocator(), id, copy) catch { + runtime.allocator().free(copy); }; } fn selectionRemove(id: usize) bool { if (selected_paths.fetchSwapRemove(id)) |kv| { - Globals.allocator().free(kv.value); + runtime.allocator().free(kv.value); return true; } return false; @@ -1045,7 +1046,7 @@ fn pathIsDirAbsolute(abs: []const u8) bool { /// 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 Globals.host.pluginForExtension(std.fs.path.extension(abs_path)) != null; + return runtime.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 { @@ -1175,7 +1176,7 @@ pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.m return false; }; - if (Globals.host.docFromPath(source_path)) |doc| { + if (runtime.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; @@ -1198,12 +1199,12 @@ 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) }); var di: usize = 0; - while (di < Globals.host.openDocCount()) : (di += 1) { - const doc = Globals.host.docByIndex(di) orelse continue; + while (di < runtime.host().openDocCount()) : (di += 1) { + const doc = runtime.host().docByIndex(di) orelse continue; 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(Globals.allocator(), &.{ new_path, file_name }); + const new_full = try std.fs.path.join(runtime.allocator(), &.{ new_path, file_name }); doc.owner.setDocumentPath(doc, new_full) catch { dvui.log.err("Failed to update open document path", .{}); }; @@ -1213,7 +1214,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 (Globals.host.docFromPath(full_path)) |doc| { + if (runtime.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; @@ -1256,7 +1257,7 @@ pub fn pruneMissingSelections() void { continue; }; if (selected_id == removed.key) selected_id = null; - Globals.allocator().free(removed.value); + runtime.allocator().free(removed.value); continue; }; i += 1; diff --git a/src/plugins/workbench/src/plugin.zig b/src/plugins/workbench/src/plugin.zig index 9e83a078..f4d6d64c 100644 --- a/src/plugins/workbench/src/plugin.zig +++ b/src/plugins/workbench/src/plugin.zig @@ -1,19 +1,23 @@ //! The workbench plugin: file management. Registered from `Editor.postInit`. const std = @import("std"); const dvui = @import("dvui"); -const wb = @import("../workbench.zig"); -const sdk = wb.sdk; -const Globals = @import("Globals.zig"); +const internal = @import("../workbench.zig"); +const runtime = @import("runtime.zig"); +const sdk = internal.sdk; const files = @import("files.zig"); +const workbench_opts = @import("workbench_opts"); + +pub const manifest = sdk.PluginManifest{ + .id = "workbench", + .name = "Workbench", + .version = .{ .major = 0, .minor = 1, .patch = 0 }, +}; + /// Stable contribution ids (plugin-namespaced) referenced across modules. 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, @@ -25,16 +29,21 @@ const vtable: sdk.Plugin.VTable = .{ .contributeKeybinds = contributeKeybinds, }; +/// When false at compile time (`-Dworkbench-file-tree=false`), the Files sidebar is not registered. +pub const has_file_tree = workbench_opts.file_tree; + pub fn register(host: *sdk.Host) !void { + plugin.state = @ptrCast(runtime.workbench()); 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. + if (comptime has_file_tree) { + try host.registerSidebarView(.{ + .id = view_files, + .owner = &plugin, + .icon = dvui.entypo.folder, + .title = "Files", + .draw = drawFiles, + }); + } try host.registerCenterProvider(.{ .id = center_workspaces, .owner = &plugin, @@ -47,14 +56,13 @@ fn drawFiles(_: ?*anyopaque) anyerror!void { } fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result { - return Globals.host.drawWorkspaces(0); + return runtime.host().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 (wb.platform.isMacOS()) { + if (internal.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/runtime.zig b/src/plugins/workbench/src/runtime.zig new file mode 100644 index 00000000..19db7734 --- /dev/null +++ b/src/plugins/workbench/src/runtime.zig @@ -0,0 +1,25 @@ +//! Runtime accessors — backed by `sdk.runtime` and shell-injected workbench pointer. +const std = @import("std"); +const sdk = @import("sdk"); +const Workbench = @import("Workbench.zig"); + +var shell_workbench: ?*Workbench = null; + +/// Static embed: App calls this before `postInit`. +pub fn setWorkbench(w: *Workbench) void { + shell_workbench = w; +} + +pub fn allocator() std.mem.Allocator { + return sdk.allocator(); +} + +pub fn host() *sdk.Host { + return sdk.host(); +} + +pub fn workbench() *Workbench { + if (shell_workbench) |w| return w; + if (sdk.injectedState(Workbench)) |w| return w; + @panic("workbench pointer not wired"); +} diff --git a/src/plugins/workbench/src/workbench_layout.zig b/src/plugins/workbench/src/workbench_layout.zig index 8d1104b0..b92afd6d 100644 --- a/src/plugins/workbench/src/workbench_layout.zig +++ b/src/plugins/workbench/src/workbench_layout.zig @@ -1,8 +1,8 @@ //! Workspace map maintenance + recursive split drawing. const std = @import("std"); const dvui = @import("dvui"); -const wbench = @import("../workbench.zig"); -const Globals = @import("Globals.zig"); +const wb_mod = @import("../workbench.zig"); +const runtime = @import("runtime.zig"); const Workbench = @import("Workbench.zig"); const Workspace = @import("Workspace.zig"); @@ -10,7 +10,7 @@ const handle_size = 10; const handle_dist = 60; pub fn rebuildWorkspaces(wb: *Workbench) !void { - const host = Globals.host; + const host = runtime.host(); var i: usize = 0; while (i < host.openDocCount()) : (i += 1) { @@ -25,7 +25,7 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { workspace.open_file_index = host.docIndex(d.id) orelse 0; } } - try wb.workspaces.put(Globals.allocator(), grouping, workspace); + try wb.workspaces.put(runtime.allocator(), grouping, workspace); } } @@ -83,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 = wbench.wdvui.paned(@src(), .{ + var s = wb_mod.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/static/integration.zig b/src/plugins/workbench/static/integration.zig new file mode 100644 index 00000000..7397ff86 --- /dev/null +++ b/src/plugins/workbench/static/integration.zig @@ -0,0 +1,67 @@ +//! Workbench plugin — fizzy-internal static-embed + bundled-dylib module graph. +//! Runs only from the fizzy build root, so paths are single fizzy-relative literals. +const std = @import("std"); +const helpers = @import("../../shared/build/helpers.zig"); + +pub const id = "workbench"; +pub const installDylib = helpers.installDylib; + +const module_path = "src/plugins/workbench/workbench.zig"; +const dylib_path = "src/plugins/workbench/root.zig"; + +pub const ModuleImports = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + proxy_bridge: ?*std.Build.Module = null, + icons: ?*std.Build.Module = null, + backend: ?*std.Build.Module = null, +}; + +fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { + module.addImport("dvui", imports.dvui); + module.addImport("core", imports.core); + module.addImport("sdk", imports.sdk); + if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); + if (imports.icons) |icons| module.addImport("icons", icons); + if (imports.backend) |backend| module.addImport("backend", backend); +} + +pub fn addStaticModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + workbench_opts: *std.Build.Step.Options, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = helpers.addStaticModule(b, .{ + .import_name = id, + .root_source_file = b.path(module_path), + .target = target, + .optimize = optimize, + .options_name = "workbench_opts", + .options = workbench_opts, + }, consumer); + applyImports(mod, imports); + return mod; +} + +pub fn addDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + workbench_opts: *std.Build.Step.Options, +) *std.Build.Step.Compile { + const lib = helpers.addDylib(b, .{ + .name = id, + .root_source_file = b.path(dylib_path), + .target = target, + .optimize = optimize, + .options_name = "workbench_opts", + .options = workbench_opts, + }); + applyImports(lib.root_module, imports); + return lib; +} diff --git a/src/plugins/workbench/workbench.zig b/src/plugins/workbench/workbench.zig index 8811d7a9..96310f63 100644 --- a/src/plugins/workbench/workbench.zig +++ b/src/plugins/workbench/workbench.zig @@ -1,7 +1,12 @@ -//! Intra-plugin import hub for the workbench plugin. +//! Workbench plugin root module **and** intra-plugin import hub. //! -//! 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`. +//! - The shell resolves `@import("workbench")` to this file when compiled into the app (static +//! embed) and reaches its public surface here. +//! - Files under `src/` import it as `../workbench.zig` for shared deps + types — the +//! conventional `.zig` namespace. +//! +//! Must sit at the plugin root: a Zig module cannot import files above its root file's +//! directory. The build-side static-embed glue lives in `static/`. const std = @import("std"); pub const sdk = @import("sdk"); @@ -16,3 +21,10 @@ pub const Sprite = core.Sprite; /// Shell's custom dvui widgets/helpers (TreeWidget, paned, labelWithKeybind, …). pub const wdvui = core.dvui; + +pub const plugin = @import("src/plugin.zig"); +pub const runtime = @import("src/runtime.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/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index 9df1a345..c35f2027 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -150,14 +150,10 @@ pub const VTable = struct { default_folder: ?[]const u8, ) void, - // ---- 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, + requestPrepareFrame: *const fn (ctx: *anyopaque) void, + /// Wake the app event loop for another frame. Safe from worker threads (PTY readers, etc.). + refresh: *const fn (ctx: *anyopaque) void, // ---- new document ---- /// Heap-owned unique basename like `untitled-1`; caller frees with the app allocator. @@ -176,10 +172,6 @@ pub const VTable = struct { 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 { @@ -344,32 +336,16 @@ pub fn showOpenFileDialog( self.vtable.showOpenFileDialog(self.ctx, cb, filters, default_filename, default_folder); } -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 requestPrepareFrame(self: EditorAPI) void { + self.vtable.requestPrepareFrame(self.ctx); +} + +pub fn refresh(self: EditorAPI) void { + self.vtable.refresh(self.ctx); } pub fn allocUntitledPath(self: EditorAPI) ![]u8 { @@ -415,11 +391,3 @@ pub fn resumeSaveAllQuit(self: EditorAPI) void { 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 39f3b7ff..64ac080a 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -8,6 +8,7 @@ const Plugin = @import("Plugin.zig"); const regions = @import("regions.zig"); const EditorAPI = @import("EditorAPI.zig"); const DocHandle = @import("DocHandle.zig"); +const WorkbenchPaneView = @import("WorkbenchPane.zig").WorkbenchPaneView; pub const Host = @This(); @@ -15,7 +16,9 @@ pub const SidebarView = regions.SidebarView; pub const BottomView = regions.BottomView; pub const CenterProvider = regions.CenterProvider; pub const MenuContribution = regions.MenuContribution; +pub const MenuSectionContribution = regions.MenuSectionContribution; pub const SettingsSection = regions.SettingsSection; +pub const Command = regions.Command; /// Per-plugin opaque settings blobs: plugin id -> serialized JSON. The Host owns the /// key + value strings; the shell persists them verbatim under "plugins" in @@ -62,8 +65,12 @@ 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, +/// Nested items contributed into an open parent menu (e.g. View > Example). +menu_sections: std.ArrayListUnmanaged(MenuSectionContribution) = .empty, /// Settings sections (Settings view renders each under its title, grouped by owner). settings_sections: std.ArrayListUnmanaged(SettingsSection) = .empty, +/// Plugin-contributed commands, invoked by id (menus, keybinds, palette) — see `Command`. +commands: std.ArrayListUnmanaged(Command) = .empty, /// Active selection by contribution id (null = use the first registered). active_sidebar_view: ?[]const u8 = null, @@ -81,7 +88,9 @@ pub fn deinit(self: *Host) void { self.bottom_views.deinit(self.allocator); self.center_providers.deinit(self.allocator); self.menus.deinit(self.allocator); + self.menu_sections.deinit(self.allocator); self.settings_sections.deinit(self.allocator); + self.commands.deinit(self.allocator); self.file_row_fill_colors.deinit(self.allocator); { var it = self.plugin_settings.iterator(); @@ -273,34 +282,17 @@ pub fn showOpenFileDialog( 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(); -} - -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 requestPrepareFrame(self: *Host) void { + if (self.shell_api) |a| a.requestPrepareFrame(); } +pub fn refresh(self: *Host) void { + if (self.shell_api) |a| a.refresh(); +} pub fn allocUntitledPath(self: *Host) ![]u8 { return if (self.shell_api) |a| try a.allocUntitledPath() else error.ShellNotInstalled; @@ -346,14 +338,6 @@ 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 @@ -377,11 +361,17 @@ pub fn storePluginSettings(self: *Host, id: []const u8, json: []const u8) !void self.markSettingsDirty(); } +/// Register a plugin under its self-declared `id`. The `id` is the single source of truth +/// for routing (`pluginById`, `pluginForExtension`); a folder name or dylib path is not. +/// Rejects a second plugin claiming an already-registered `id` so routing can never become +/// ambiguous — the dylib loader turns this into a failed load the user is told about +/// (built-in ids always win, since they register first). pub fn registerPlugin(self: *Host, plugin: *Plugin) !void { + if (self.pluginById(plugin.id) != null) return error.DuplicatePluginId; try self.plugins.append(self.allocator, plugin); } -/// Lookup a registered plugin by stable id (`"pixelart"`, `"workbench"`, …). +/// Lookup a registered plugin by stable id (`"pixi"`, `"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; @@ -389,6 +379,14 @@ pub fn pluginById(self: *Host, id: []const u8) ?*Plugin { return null; } +/// First registered plugin that implements `createDocument` (for shell New File flows). +pub fn pluginWithCreateDocument(self: *Host) ?*Plugin { + for (self.plugins.items) |plugin| { + if (plugin.vtable.createDocument != null) return plugin; + } + return null; +} + pub fn registerFileRowFillColor(self: *Host, resolver: FileRowFillColor) !void { try self.file_row_fill_colors.append(self.allocator, resolver); } @@ -409,6 +407,12 @@ pub fn getService(self: *Host, name: []const u8) ?*anyopaque { return self.services.get(name); } +/// Typed service lookup. `Service` must declare `service_name` and match the registered layout. +pub fn getServiceTyped(self: *Host, comptime Service: type) ?*Service { + const ptr = self.getService(Service.service_name) orelse return null; + return @ptrCast(@alignCast(ptr)); +} + // ---- region registration (called from a plugin's register / postInit) ------- pub fn registerSidebarView(self: *Host, view: SidebarView) !void { @@ -421,6 +425,82 @@ pub fn registerBottomView(self: *Host, view: BottomView) !void { if (self.active_bottom_view == null) self.active_bottom_view = view.id; } +/// Move a bottom-panel tab from `from_index` to `to_index`. +pub fn reorderBottomView(self: *Host, from_index: usize, to_index: usize) void { + if (from_index >= self.bottom_views.items.len or to_index >= self.bottom_views.items.len) return; + if (from_index == to_index) return; + const item = self.bottom_views.items[from_index]; + _ = self.bottom_views.orderedRemove(from_index); + self.bottom_views.insert(self.allocator, to_index, item) catch return; +} + +pub fn setSidebarViewHidden(self: *Host, id: []const u8, hidden: bool) void { + for (self.sidebar_views.items) |*view| { + if (std.mem.eql(u8, view.id, id)) { + view.hidden = hidden; + return; + } + } +} + +/// Fluent sugar — same fields as `SidebarView`, without a new ABI type. +pub fn registerSidebar( + self: *Host, + spec: struct { + id: []const u8, + title: []const u8, + icon: []const u8, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, + owner: ?*Plugin = null, + hidden: bool = false, + draw_workspace: ?*const fn (ctx: ?*anyopaque, pane: *WorkbenchPaneView) anyerror!void = null, + }, +) !void { + try self.registerSidebarView(.{ + .id = spec.id, + .title = spec.title, + .icon = spec.icon, + .draw = spec.draw, + .owner = spec.owner, + .hidden = spec.hidden, + .draw_workspace = spec.draw_workspace, + }); +} + +pub fn registerBottom( + self: *Host, + spec: struct { + id: []const u8, + title: []const u8, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, + owner: ?*Plugin = null, + persistent: bool = false, + }, +) !void { + try self.registerBottomView(.{ + .id = spec.id, + .title = spec.title, + .draw = spec.draw, + .owner = spec.owner, + .persistent = spec.persistent, + }); +} + +pub fn registerCenter( + self: *Host, + spec: struct { + id: []const u8, + draw: *const fn (ctx: ?*anyopaque) anyerror!dvui.App.Result, + owner: ?*Plugin = null, + }, +) !void { + try self.registerCenterProvider(.{ + .id = spec.id, + .draw = spec.draw, + .owner = spec.owner, + }); +} + 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; @@ -430,10 +510,44 @@ pub fn registerMenu(self: *Host, menu: MenuContribution) !void { try self.menus.append(self.allocator, menu); } +pub fn registerMenuSection(self: *Host, section: MenuSectionContribution) !void { + try self.menu_sections.append(self.allocator, section); +} + pub fn registerSettingsSection(self: *Host, section: SettingsSection) !void { try self.settings_sections.append(self.allocator, section); } +// ---- commands -------------------------------------------------------------- + +/// Register a plugin command. Ids should be plugin-namespaced (`"pixelart.packProject"`). +pub fn registerCommand(self: *Host, cmd: Command) !void { + try self.commands.append(self.allocator, cmd); +} + +/// The registered command with `id`, or null. +pub fn command(self: *Host, id: []const u8) ?*Command { + for (self.commands.items) |*c| { + if (std.mem.eql(u8, c.id, id)) return c; + } + return null; +} + +/// Whether `id` is registered and currently enabled (absent `isEnabled` = enabled). +/// Unknown ids are treated as disabled. +pub fn commandEnabled(self: *Host, id: []const u8) bool { + const c = self.command(id) orelse return false; + const owner = c.owner orelse return true; + return if (c.isEnabled) |f| f(owner.state) else true; +} + +/// Run the command `id` (no-op when unknown). The owner's opaque `state` is passed to `run`. +pub fn runCommand(self: *Host, id: []const u8) !void { + const c = self.command(id) orelse return; + const owner = c.owner orelse return; + try c.run(owner.state); +} + // ---- active selection ------------------------------------------------------ pub fn setActiveSidebarView(self: *Host, id: []const u8) void { @@ -445,17 +559,30 @@ pub fn isActiveSidebarView(self: *Host, id: []const u8) bool { return std.mem.eql(u8, active, id); } -/// The currently active sidebar view, or the first registered as a fallback. +/// The currently active sidebar view, or the first visible registered view as 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 self.firstVisibleSidebarView(); +} + +pub fn firstVisibleSidebarView(self: *Host) ?*SidebarView { + for (self.sidebar_views.items) |*v| { + if (!v.hidden) return v; + } return null; } +pub fn hasPersistentBottomView(self: *Host) bool { + for (self.bottom_views.items) |*v| { + if (v.persistent) return true; + } + return false; +} + pub fn setActiveBottomView(self: *Host, id: []const u8) void { self.active_bottom_view = id; } diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index 45dac786..528938df 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -28,11 +28,22 @@ 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 }; - +/// Mode for an owner's pre-save confirmation (`requestSaveConfirmation`). `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 SaveConfirmMode = enum { editor_save, save_and_close }; + +// Every field below is an optional fn pointer, so the type system requires *nothing*. But to +// function as an **editor** (open / draw / save files) a plugin must implement the document +// cluster — `fileTypePriority`, the load+staging hooks (`documentStackSize`/`documentStackAlign`/ +// `loadDocument`/`documentIdFromBuffer`/`registerOpenDocument`/`deinitDocumentBuffer`), +// `drawDocument`, `saveDocument`, `isDirty`, and `documentPtr`. Everything else is genuinely +// optional. Each hook's doc comment tags how the shell invokes it: +// [broadcast] — the shell calls it for every plugin at a fixed point each frame +// [active-doc] — the shell calls `doc.owner.hook(doc)` only for the focused document +// [requested] — only fires after the plugin asks for it via a `host.*` call +// A plugin that is *not* an editor (the workbench file tree) implements none of the document +// hooks; it contributes panes + a center provider instead. pub const VTable = struct { /// Tear down `state`. Called when the plugin is unregistered / app shuts down. deinit: ?*const fn (state: *anyopaque) void = null, @@ -93,7 +104,6 @@ pub const VTable = struct { 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, @@ -104,13 +114,6 @@ pub const VTable = struct { /// document on disk in that folder; `id_extra` disambiguates per-explorer-row launches. /// 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). - 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) ---- // Sidebar/explorer panes and bottom-panel tabs are NOT vtable hooks — plugins @@ -126,35 +129,91 @@ pub const VTable = struct { 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. + // ---- per-frame shell phases (the shell calls these for every plugin each frame, in + // this order). A plugin does its own per-frame work (caches, playback, overlays) + // inside these generic phases; none carry domain meaning. ---- + /// [broadcast] Top of frame, before workspace rebuild / any document drawing. Advance the + /// frame clock / invalidate per-frame caches. beginFrame: ?*const fn (state: *anyopaque) void = null, + /// [requested] A one-shot pre-draw pass: runs after layout but before document draw, and + /// **only on a frame where the plugin asked for it** via `host.requestPrepareFrame()` (not + /// every frame). Use to warm expensive render data for the upcoming draw. A plugin that + /// never calls `requestPrepareFrame` never sees this. + prepareFrame: ?*const fn (state: *anyopaque) void = null, + /// [broadcast] Process the plugin's own per-frame keyboard shortcuts (distinct from + /// `contributeKeybinds`, which registers them once). Runs before the shell's global keybinds. tickKeybinds: ?*const fn (state: *anyopaque) anyerror!void = null, + /// [broadcast] Advance the plugin's open documents; return true to request a follow-up + /// animation frame (e.g. an in-progress save-status fade). 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, - - // ---- 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, - 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, - 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, + /// [broadcast] Advance time-based state for the active document (animation playback, a + /// blinking cursor, …). `timer_host_id` is the active document container's widget id, to + /// anchor any dvui timer/animation the plugin schedules. + tickActiveDocument: ?*const fn (state: *anyopaque, timer_host_id: dvui.Id) void = null, + /// [broadcast] Draw a plugin-owned floating overlay (tool menu, HUD) on top of the frame, + /// after the center region is drawn. + drawOverlay: ?*const fn (state: *anyopaque) anyerror!void = null, + /// [broadcast] End of the center draw — reset per-frame scratch state held across the draw + /// (symmetric counterpart to `beginFrame`). + endFrame: ?*const fn (state: *anyopaque) void = null, + /// [broadcast] True while the plugin needs the shell to keep repainting continuously (an + /// active stroke, a running animation, a background job) rather than idling until input. + needsContinuousRepaint: ?*const fn (state: *anyopaque) bool = null, + + // ---- folder lifecycle ---- + /// [broadcast] Fired just before the open root folder changes or closes — a plugin can + /// persist any state it keyed to that folder (open tabs, view state, …). + onFolderClose: ?*const fn (state: *anyopaque) void = null, + /// [broadcast] Fired after a new root folder has opened (read it via `host.folder()`) — a + /// plugin can load state it keyed to that folder. + onFolderOpen: ?*const fn (state: *anyopaque, allocator: std.mem.Allocator) void = null, + + // ---- save protocol ---- + /// [active-doc] True when the owner wants a confirmation before `saveDocument` (e.g. a save + /// that would flatten lossy data, change encoding, or overwrite an on-disk change). When + /// true the shell calls `requestSaveConfirmation` instead of saving directly. + saveNeedsConfirmation: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + /// [active-doc] Open the owner's pre-save confirmation dialog for `doc` (only called when + /// `saveNeedsConfirmation(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. + requestSaveConfirmation: ?*const fn (state: *anyopaque, doc: DocHandle, mode: SaveConfirmMode, from_save_all_quit: bool) void = null, + + // NOTE: editing actions (copy / paste / transform / accept-edit / cancel-edit / + // delete-selection) are deliberately NOT hooks here. They are user-invoked and their meaning + // varies per editor, so a plugin registers them as `Command`s (e.g. `"pixelart.copy"`) and + // the shell dispatches its Edit-menu / keybinds to `"."`. See the + // commands section in docs/PLUGINS.md. }; +pub fn commandId(comptime plugin_id: []const u8, comptime action: []const u8) [:0]const u8 { + return plugin_id ++ "." ++ action; +} + +/// Comptime check that a vtable implements the document cluster required for an editor plugin. +pub fn assertEditorVTable(comptime vt: VTable) void { + comptime { + if (vt.loadDocument == null) @compileError("Editor vtable missing required hook: loadDocument"); + if (vt.documentStackSize == null) @compileError("Editor vtable missing required hook: documentStackSize"); + if (vt.documentStackAlign == null) @compileError("Editor vtable missing required hook: documentStackAlign"); + if (vt.registerOpenDocument == null) @compileError("Editor vtable missing required hook: registerOpenDocument"); + if (vt.drawDocument == null) @compileError("Editor vtable missing required hook: drawDocument"); + if (vt.documentPtr == null) @compileError("Editor vtable missing required hook: documentPtr"); + if (vt.isDirty == null) @compileError("Editor vtable missing required hook: isDirty"); + if (vt.saveDocument == null) @compileError("Editor vtable missing required hook: saveDocument"); + if (vt.closeDocument == null) @compileError("Editor vtable missing required hook: closeDocument"); + } +} + +/// Comptime check that a vtable does not implement document hooks (menu-only / utility profile). +pub fn assertUtilityVTable(comptime vt: VTable) void { + comptime { + if (vt.loadDocument != null) @compileError("Utility vtable must not implement document hook: loadDocument"); + if (vt.drawDocument != null) @compileError("Utility vtable must not implement document hook: drawDocument"); + if (vt.registerOpenDocument != null) @compileError("Utility vtable must not implement document hook: registerOpenDocument"); + if (vt.createDocument != null) @compileError("Utility vtable must not implement document hook: createDocument"); + } +} + // Thin wrappers so callers don't repeat the optional-vtable dance. pub fn fileTypePriority(self: Plugin, ext: []const u8) ?u8 { @@ -169,44 +228,8 @@ 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); -} - -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 drawOverlay(self: Plugin) !void { + if (self.vtable.drawOverlay) |f| try f(self.state); } pub fn registerOpenDocument(self: Plugin, file: *anyopaque) !*anyopaque { @@ -225,12 +248,12 @@ 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 onFolderClose(self: Plugin) void { + if (self.vtable.onFolderClose) |f| f(self.state); } -pub fn reloadProjectFolder(self: Plugin, allocator: std.mem.Allocator) void { - if (self.vtable.reloadProjectFolder) |f| f(self.state, allocator); +pub fn onFolderOpen(self: Plugin, allocator: std.mem.Allocator) void { + if (self.vtable.onFolderOpen) |f| f(self.state, allocator); } pub fn bindDocumentToPane(self: Plugin, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void { @@ -273,8 +296,8 @@ 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 saveNeedsConfirmation(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.saveNeedsConfirmation) |f| f(self.state, doc) else false; } pub fn saveDocumentAsync(self: Plugin, doc: DocHandle) !void { @@ -401,52 +424,36 @@ pub fn resetDocumentSaveUIState(self: Plugin, doc: DocHandle) void { if (self.vtable.resetDocumentSaveUIState) |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 requestSaveConfirmation(self: Plugin, doc: DocHandle, mode: SaveConfirmMode, from_save_all_quit: bool) void { + if (self.vtable.requestSaveConfirmation) |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 { 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 prepareFrame(self: Plugin) void { + if (self.vtable.prepareFrame) |f| f(self.state); } -pub fn warmupActiveDocumentComposites(self: Plugin) void { - if (self.vtable.warmupActiveDocumentComposites) |f| f(self.state); +pub fn endFrame(self: Plugin) void { + if (self.vtable.endFrame) |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 tickOpenDocuments(self: Plugin) bool { + return if (self.vtable.tickOpenDocuments) |f| f(self.state) else false; } -pub fn cancelEdit(self: Plugin) void { - if (self.vtable.cancelEdit) |f| f(self.state); +pub fn tickActiveDocument(self: Plugin, timer_host_id: dvui.Id) void { + if (self.vtable.tickActiveDocument) |f| f(self.state, timer_host_id); } -pub fn deleteSelection(self: Plugin) void { - if (self.vtable.deleteSelection) |f| f(self.state); +pub fn needsContinuousRepaint(self: Plugin) bool { + return if (self.vtable.needsContinuousRepaint) |f| f(self.state) else false; } /// Allocate a buffer suitable for staging `loadDocument` / `createDocument`. Caller frees `backing`. diff --git a/src/sdk/document.zig b/src/sdk/document.zig new file mode 100644 index 00000000..9ceaf15a --- /dev/null +++ b/src/sdk/document.zig @@ -0,0 +1,47 @@ +//! Document staging helpers for plugin authors. +//! +//! Use these from `loadDocument` / `loadDocumentFromBytes` vtable hooks when your document +//! type is constructed from a path or bytes into a shell-owned staging buffer. +const std = @import("std"); + +const Plugin = @import("Plugin.zig"); + +/// Shell-allocated staging memory for one document load/create. +pub const StagingBuffer = struct { + backing: []u8, + buf: []u8, + + pub fn deinit(self: StagingBuffer, allocator: std.mem.Allocator) void { + allocator.free(self.backing); + } +}; + +pub fn allocStaging(plugin: *Plugin, allocator: std.mem.Allocator) !StagingBuffer { + const staging = try plugin.allocDocumentBuffer(allocator); + return .{ .backing = staging.backing, .buf = staging.buf }; +} + +pub fn loadPathInto(comptime Doc: type, path: []const u8, out: *Doc) !void { + out.* = try Doc.fromPath(path); +} + +pub fn loadBytesInto(comptime Doc: type, path: []const u8, bytes: []const u8, out: *Doc) !void { + out.* = try Doc.fromBytes(path, bytes); +} + +/// Load `path` into the plugin staging buffer at `staging.buf.ptr`. +pub fn loadIntoStaging(plugin: *Plugin, path: []const u8, staging: StagingBuffer) !void { + const handled = try plugin.loadDocument(path, staging.buf.ptr); + if (!handled) return error.Unsupported; +} + +/// Load in-memory bytes into the plugin staging buffer at `staging.buf.ptr`. +pub fn loadBytesIntoStaging( + plugin: *Plugin, + path: []const u8, + bytes: []const u8, + staging: StagingBuffer, +) !void { + const handled = try plugin.loadDocumentFromBytes(path, bytes, staging.buf.ptr); + if (!handled) return error.Unsupported; +} diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig index a5ae9f2f..34087a03 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -5,43 +5,256 @@ //! 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 = 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"; - -/// C ABI — wire plugin-side `Globals` to host-owned pointers (pixelart today). +//! **Compatibility:** a structural `abi_fingerprint` is the hard memory-safety gate; human- +//! readable `sdk_version` (see `version.zig`) tells authors when to rebuild. See +//! `docs/PLUGINS.md` § Compatibility. +const std = @import("std"); +const dvui = @import("dvui"); +const proxy_bridge = @import("proxy_bridge"); +const fingerprint = @import("fingerprint.zig"); +const dvui_context = @import("dvui_context.zig"); +const runtime = @import("runtime.zig"); +const version = @import("version.zig"); +const manifest_mod = @import("manifest.zig"); + +const Host = @import("Host.zig"); +const Plugin = @import("Plugin.zig"); +const DocHandle = @import("DocHandle.zig"); +const EditorAPI = @import("EditorAPI.zig"); +const regions = @import("regions.zig"); +const workbench_service = @import("services/workbench.zig"); + +pub const PluginManifest = manifest_mod.PluginManifest; + +/// C ABI — host loader injects host-owned pointers into the plugin image before `register`. +/// +/// `gpa` is always the app allocator. `arg_b`/`arg_c` are two generic injection slots whose +/// meaning is defined by the receiving plugin's `set_globals` (they are *not* fixed roles). +/// The conventions in this tree: +/// - third-party (`exportEntry`): `arg_b` = the `*Host`, `arg_c` = unused (a plugin owns its state) +/// - workbench / code: `arg_b` = `*Host`, `arg_c` = the plugin's own state +/// - pixi: `arg_b` = the plugin's `*State`, `arg_c` = `*Packer` (historical; takes no host here) pub const SetGlobalsFn = *const fn ( gpa: ?*const anyopaque, - state: ?*anyopaque, - packer: ?*anyopaque, + arg_b: ?*anyopaque, + arg_c: ?*anyopaque, ) callconv(.c) void; -/// Returned by `fizzy_plugin_register`. Stable unsigned values for C callers. +/// C ABI — host loader pushes its render bridge into the plugin's proxy backend. +pub const SetRenderBridgeFn = *const fn (?*const proxy_bridge.RenderBridge) callconv(.c) void; + +/// C ABI — `fizzy_plugin_register`. +pub const RegisterFn = *const fn (?*Host) callconv(.c) u32; + +/// C ABI — `fizzy_plugin_abi_fingerprint`; the loader rejects any value != `abi_fingerprint`. +pub const GetAbiFingerprintFn = *const fn () callconv(.c) u64; + +pub const VersionTriplet = extern struct { + major: u32, + minor: u32, + patch: u32, +}; + +/// C ABI — returns SDK version this plugin was built against. +pub const GetSdkVersionFn = *const fn () callconv(.c) VersionTriplet; + +/// C ABI — returns the plugin's declared minimum host SDK version. +pub const GetMinSdkVersionFn = *const fn () callconv(.c) VersionTriplet; + +/// C ABI — returns the plugin's own release version. +pub const GetPluginVersionFn = *const fn () callconv(.c) VersionTriplet; + +/// C ABI — returns the plugin's stable id (NUL-terminated). +pub const GetPluginIdFn = *const fn () callconv(.c) [*:0]const u8; + +/// dvui data/handle types that cross the boundary by value or through the render bridge. +const dvui_boundary_types = .{ + dvui.Window, + dvui.Debug, + dvui.Vertex, + dvui.Vertex.Index, + dvui.Texture, + dvui.TextureTarget, + dvui.Rect.Physical, + dvui.Id, +}; + +/// SDK types whose full structure is part of the contract. +const sdk_boundary_types = .{ + Host, + Plugin, + Plugin.VTable, + DocHandle, + EditorAPI, + EditorAPI.VTable, + regions.SidebarView, + regions.BottomView, + regions.CenterProvider, + regions.MenuContribution, + regions.MenuSectionContribution, + regions.SettingsSection, + regions.Command, + Host.FileRowFillColor, + proxy_bridge.RenderBridge, + workbench_service.Api, + workbench_service.Api.VTable, + VersionTriplet, +}; + +const entry_symbol_types = .{ + RegisterFn, + SetGlobalsFn, + SetRenderBridgeFn, + GetAbiFingerprintFn, + GetSdkVersionFn, + GetMinSdkVersionFn, + GetPluginVersionFn, + GetPluginIdFn, + dvui_context.SetContextFn, +}; + +pub const abi_fingerprint: u64 = blk: { + @setEvalBranchQuota(1_000_000); + var h = fingerprint.seed; + h = fingerprint.hashAll(h, dvui_boundary_types, 0); + h = fingerprint.hashAll(h, sdk_boundary_types, 6); + h = fingerprint.hashAll(h, entry_symbol_types, 3); + break :blk h; +}; + +pub const symbol_register: [:0]const u8 = "fizzy_plugin_register"; +pub const symbol_set_dvui_context: [:0]const u8 = "fizzy_plugin_set_dvui_context"; +pub const symbol_set_render_bridge: [:0]const u8 = "fizzy_plugin_set_render_bridge"; +pub const symbol_set_globals: [:0]const u8 = "fizzy_plugin_set_globals"; +pub const symbol_abi_fingerprint: [:0]const u8 = "fizzy_plugin_abi_fingerprint"; +pub const symbol_sdk_version: [:0]const u8 = "fizzy_plugin_sdk_version"; +pub const symbol_min_sdk_version: [:0]const u8 = "fizzy_plugin_min_sdk_version"; +pub const symbol_plugin_version: [:0]const u8 = "fizzy_plugin_version"; +pub const symbol_plugin_id: [:0]const u8 = "fizzy_plugin_id"; + 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, + err_sdk_version = 4, }; -pub fn abiMatches(plugin_abi: u32) bool { - return plugin_abi == abi_version; +pub fn fingerprintMatches(plugin_fp: u64) bool { + return plugin_fp == abi_fingerprint; +} + +pub fn tripletFromSemver(v: std.SemanticVersion) VersionTriplet { + return .{ + .major = @intCast(v.major), + .minor = @intCast(v.minor), + .patch = @intCast(v.patch), + }; +} + +pub fn semverFromTriplet(t: VersionTriplet) std.SemanticVersion { + return .{ .major = t.major, .minor = t.minor, .patch = t.patch }; +} + +/// Emit version/id C exports for a built-in dylib that does not use `exportEntry`. +pub fn exportManifestSymbols(comptime manifest: PluginManifest) void { + const IdEntry = struct { + const id_z = manifest.id ++ "\x00"; + fn pluginId() callconv(.c) [*:0]const u8 { + return id_z; + } + }; + const ManifestEntry = struct { + fn sdkVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(version.sdk_version); + } + fn minSdkVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(manifest.min_sdk_version); + } + fn pluginVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(manifest.version); + } + }; + @export(&IdEntry.pluginId, .{ .name = symbol_plugin_id }); + @export(&ManifestEntry.sdkVersion, .{ .name = symbol_sdk_version }); + @export(&ManifestEntry.minSdkVersion, .{ .name = symbol_min_sdk_version }); + @export(&ManifestEntry.pluginVersion, .{ .name = symbol_plugin_version }); +} + +/// Emit the C entry symbols every plugin dylib must export, wired to the plugin's +/// own `register` and `manifest`. +/// +/// `plugin_mod` must expose: +/// - `pub fn register(*Host) !void` +/// - `pub const manifest: PluginManifest` +pub fn exportEntry(comptime plugin_mod: type) void { + comptime { + if (@hasDecl(plugin_mod, "manifest") == false) { + @compileError("plugin module must declare `pub const manifest: sdk.PluginManifest`"); + } + } + const manifest = plugin_mod.manifest; + const IdEntry = struct { + const id_z = manifest.id ++ "\x00"; + fn pluginId() callconv(.c) [*:0]const u8 { + return id_z; + } + }; + + const Entry = struct { + fn abiFingerprint() callconv(.c) u64 { + return abi_fingerprint; + } + fn sdkVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(version.sdk_version); + } + fn minSdkVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(manifest.min_sdk_version); + } + fn pluginVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(manifest.version); + } + fn register(host: ?*Host) callconv(.c) u32 { + if (host == null) return @intFromEnum(RegisterStatus.err_null_host); + if (!version.sdkVersionSatisfies(version.sdk_version, manifest.min_sdk_version)) { + return @intFromEnum(RegisterStatus.err_sdk_version); + } + plugin_mod.register(host.?) catch return @intFromEnum(RegisterStatus.err_register); + return @intFromEnum(RegisterStatus.ok); + } + fn setDvuiContext( + window: ?*dvui.Window, + io: ?*anyopaque, + ft2lib: ?*anyopaque, + debug: ?*dvui.Debug, + ) callconv(.c) void { + dvui_context.inject(window, io, ft2lib, debug); + } + fn setRenderBridge(bridge: ?*const proxy_bridge.RenderBridge) callconv(.c) void { + proxy_bridge.setBridge(bridge); + } + fn setGlobals(gpa: ?*const anyopaque, host: ?*anyopaque, state: ?*anyopaque) callconv(.c) void { + runtime.installRuntime( + if (gpa) |p| @ptrCast(@alignCast(p)) else null, + if (host) |p| @ptrCast(@alignCast(p)) else null, + state, + ); + } + }; + @export(&Entry.abiFingerprint, .{ .name = symbol_abi_fingerprint }); + @export(&Entry.sdkVersion, .{ .name = symbol_sdk_version }); + @export(&Entry.minSdkVersion, .{ .name = symbol_min_sdk_version }); + @export(&Entry.pluginVersion, .{ .name = symbol_plugin_version }); + @export(&IdEntry.pluginId, .{ .name = symbol_plugin_id }); + @export(&Entry.register, .{ .name = symbol_register }); + @export(&Entry.setDvuiContext, .{ .name = symbol_set_dvui_context }); + @export(&Entry.setRenderBridge, .{ .name = symbol_set_render_bridge }); + @export(&Entry.setGlobals, .{ .name = symbol_set_globals }); } -test "plugin ABI version is locked" { - const std = @import("std"); - try std.testing.expect(abi_version == 2); - try std.testing.expect(abiMatches(abi_version)); - try std.testing.expect(!abiMatches(abi_version + 1)); +test "abi fingerprint is non-zero and self-consistent" { + try std.testing.expect(abi_fingerprint != fingerprint.seed); + try std.testing.expect(abi_fingerprint != 0); + try std.testing.expect(fingerprintMatches(abi_fingerprint)); + try std.testing.expect(!fingerprintMatches(abi_fingerprint +% 1)); } diff --git a/src/sdk/fingerprint.zig b/src/sdk/fingerprint.zig new file mode 100644 index 00000000..429c8259 --- /dev/null +++ b/src/sdk/fingerprint.zig @@ -0,0 +1,150 @@ +//! Compile-time structural fingerprint of the plugin ABI boundary. +//! +//! Host and plugin each compile their own copy of the SDK + dvui types, then each +//! computes this fingerprint from those types. The loader rejects any plugin whose +//! fingerprint differs from the host's, so an incompatible layout — a changed vtable +//! hook signature, a reordered struct field, a different dvui struct size — is caught +//! at load time instead of corrupting memory at runtime. This replaces a hand-bumped +//! `abi_version` integer: there is nothing to remember to bump. +//! +//! **Name-free by design.** The hash folds in only `@sizeOf`, `@alignOf`, field +//! names/offsets, enum tag layout, and function-pointer *signatures* (parameter and +//! return types, recursively). It deliberately never hashes `@typeName`, because the +//! host links `dvui_sdl3` while a plugin links `dvui_proxy`; those carry different +//! module-qualified type names for structurally identical types, and hashing names +//! would reject every plugin. Field names come straight from shared source, so they +//! are safe to hash. +//! +//! **What it catches / misses.** Any change to a listed type's size/alignment, its +//! field set/order/offsets, or a vtable hook's parameter or return *types* changes the +//! fingerprint. A signature change that swaps one parameter type for another of the +//! same size/alignment is not caught — acceptable for a load-time guard. Every data +//! type that crosses the boundary should appear in the caller's root list so its own +//! layout is folded in directly (the per-field walk records a field's structural shape +//! one level down, not the full transitive layout of an arbitrarily nested type). +const std = @import("std"); + +/// FNV-1a 64-bit offset basis. Callers seed their accumulator with this. +pub const seed: u64 = 0xcbf29ce484222325; + +const prime: u64 = 0x00000100000001b3; + +fn mixByte(h: u64, b: u8) u64 { + return (h ^ b) *% prime; +} + +fn mixStr(h_in: u64, s: []const u8) u64 { + var h = h_in; + for (s) |b| h = mixByte(h, b); + return h; +} + +fn mixU64(h_in: u64, v: u64) u64 { + var h = h_in; + var x = v; + var i: usize = 0; + while (i < 8) : (i += 1) { + h = mixByte(h, @intCast(x & 0xff)); + x >>= 8; + } + return h; +} + +/// Fold every type in `types` (an anonymous tuple of `type`) into `h_in` at `depth`. +/// `depth` bounds how far function-pointer signatures and by-value aggregates are +/// followed; data types should be listed at a depth that reaches their fields, while +/// large opaque-by-pointer types (e.g. `dvui.Window`) can be folded at depth 0 (size +/// + alignment only), matching the original size-based dvui check. +pub fn hashAll(h_in: u64, comptime types: anytype, comptime depth: comptime_int) u64 { + comptime { + var h = h_in; + for (types) |T| h = hashType(h, T, depth); + return h; + } +} + +fn hashType(h_in: u64, comptime T: type, comptime depth: comptime_int) u64 { + comptime { + const info = @typeInfo(T); + var h = mixU64(h_in, @intFromEnum(std.meta.activeTag(info))); + // Bare function and opaque types are comptime-only / unsized; everything else + // reached here has a concrete size and alignment worth folding in. + if (info != .@"fn" and info != .@"opaque") { + h = mixU64(h, @sizeOf(T)); + h = mixU64(h, @alignOf(T)); + } + if (depth <= 0) return h; + + switch (info) { + .@"struct" => |s| { + h = mixU64(h, s.fields.len); + for (s.fields, 0..) |f, i| { + h = mixStr(h, f.name); + // Packed structs have no byte offsets; fall back to declaration order. + h = mixU64(h, if (s.layout == .@"packed") i else @offsetOf(T, f.name)); + h = hashType(h, f.type, depth - 1); + } + }, + .@"union" => |u| { + h = mixU64(h, u.fields.len); + for (u.fields) |f| { + h = mixStr(h, f.name); + h = hashType(h, f.type, depth - 1); + } + }, + .@"enum" => |e| { + h = mixU64(h, e.fields.len); + for (e.fields, 0..) |f, i| { + h = mixStr(h, f.name); + h = mixU64(h, i); + } + }, + .optional => |o| h = hashType(h, o.child, depth - 1), + .array => |a| { + h = mixU64(h, a.len); + h = hashType(h, a.child, depth - 1); + }, + .pointer => |p| { + h = mixU64(h, @intFromEnum(p.size)); + h = mixU64(h, @intFromBool(p.is_const)); + // Follow function pointers so vtable hook signatures are part of the + // hash, but never follow data pointers: that would deep-walk types we + // only pass by reference (e.g. `*dvui.Window`) and risk reference cycles. + if (@typeInfo(p.child) == .@"fn") h = hashType(h, p.child, depth - 1); + }, + .@"fn" => |fninfo| { + h = mixU64(h, @intFromEnum(std.meta.activeTag(fninfo.calling_convention))); + h = mixU64(h, fninfo.params.len); + for (fninfo.params) |param| { + if (param.type) |pt| { + h = hashType(h, pt, depth - 1); + } else { + h = mixStr(h, "anytype"); + } + } + if (fninfo.return_type) |rt| h = hashType(h, rt, depth - 1); + }, + else => {}, + } + return h; + } +} + +test "fingerprint is stable and order-sensitive" { + const A = struct { x: u32, y: u64 }; + const B = struct { y: u64, x: u32 }; + const a = comptime hashAll(seed, .{A}, 4); + const a2 = comptime hashAll(seed, .{A}, 4); + const b = comptime hashAll(seed, .{B}, 4); + try std.testing.expectEqual(a, a2); + try std.testing.expect(a != b); // field reorder changes the fingerprint + try std.testing.expect(a != seed); +} + +test "fingerprint catches function-pointer signature changes" { + const V1 = struct { call: *const fn (u32) void }; + const V2 = struct { call: *const fn (u64) void }; + const v1 = comptime hashAll(seed, .{V1}, 6); + const v2 = comptime hashAll(seed, .{V2}, 6); + try std.testing.expect(v1 != v2); +} diff --git a/src/sdk/manifest.zig b/src/sdk/manifest.zig new file mode 100644 index 00000000..ac683ecf --- /dev/null +++ b/src/sdk/manifest.zig @@ -0,0 +1,28 @@ +//! Plugin identity and version metadata embedded in dylibs and optional sidecar JSON. +const std = @import("std"); +const version = @import("version.zig"); + +pub const PluginManifest = struct { + /// Stable plugin id (snake_case). Must match the dylib basename (`{id}.dylib`). + id: []const u8, + /// User-facing name shown in UI / store listings. + name: []const u8, + /// Plugin release version (author bumps on publish). + version: std.SemanticVersion, + /// Minimum host SDK version required to load this plugin. + min_sdk_version: std.SemanticVersion = version.sdk_version, +}; + +/// `[major, minor, patch]` for C exports. +pub fn versionTriplet(v: std.SemanticVersion) [3]u32 { + return .{ v.major, v.minor, v.patch }; +} + +test "manifest defaults min sdk to current" { + const m = PluginManifest{ + .id = "test", + .name = "Test", + .version = .{ .major = 1, .minor = 0, .patch = 0 }, + }; + try std.testing.expectEqual(version.sdk_version, m.min_sdk_version); +} diff --git a/src/sdk/menu.zig b/src/sdk/menu.zig new file mode 100644 index 00000000..6c815d8c --- /dev/null +++ b/src/sdk/menu.zig @@ -0,0 +1,72 @@ +//! Thin menu helpers for plugin contributions. Mirrors shell `Menu.zig` patterns +//! without importing the editor. +const std = @import("std"); +const dvui = @import("dvui"); + +pub fn menuItem( + src: std.builtin.SourceLocation, + label_str: []const u8, + init_opts: dvui.MenuItemWidget.InitOptions, + + opts: dvui.Options, +) ?dvui.Rect.Natural { + var mi = dvui.menuItem(src, init_opts, opts); + + var ret: ?dvui.Rect.Natural = null; + if (mi.activeRect()) |r| ret = r; + + var label_opts = opts; + label_opts.margin = dvui.Rect.all(0); + label_opts.padding = dvui.Rect.all(0); + dvui.labelNoFmt(src, label_str, .{}, label_opts); + mi.deinit(); + return ret; +} + +pub fn menuItemWithChevron( + src: std.builtin.SourceLocation, + label_str: []const u8, + init_opts: dvui.MenuItemWidget.InitOptions, + opts: dvui.Options, +) ?dvui.Rect.Natural { + var mi = dvui.menuItem(src, init_opts, opts); + + var ret: ?dvui.Rect.Natural = null; + if (mi.activeRect()) |r| ret = r; + + var label_opts = opts; + label_opts.margin = dvui.Rect.all(0); + label_opts.padding = dvui.Rect.all(0); + dvui.labelNoFmt(src, label_str, .{}, label_opts); + + dvui.icon(src, "chevron_right", dvui.entypo.chevron_small_right, .{ + .stroke_color = dvui.themeGet().color(.control, .text).opacity(0.5), + .fill_color = dvui.themeGet().color(.control, .text).opacity(0.5), + }, .{ + .expand = .none, + .gravity_x = 1.0, + .gravity_y = 0.5, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + }); + + mi.deinit(); + return ret; +} + +pub fn submenu( + src: std.builtin.SourceLocation, + label_str: []const u8, + opts: dvui.Options, + draw_body: *const fn () anyerror!void, +) !void { + if (menuItemWithChevron(src, label_str, .{ .submenu = true }, opts)) |r| { + var anim = dvui.animate(src, .{ .kind = .alpha, .duration = 250_000 }, .{ .expand = .both }); + defer anim.deinit(); + + var fw = dvui.floatingMenu(src, .{ .from = r }, .{}); + defer fw.deinit(); + + try draw_body(); + } +} diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig index 7eeac06f..82d99e26 100644 --- a/src/sdk/regions.zig +++ b/src/sdk/regions.zig @@ -21,6 +21,8 @@ pub const SidebarView = struct { icon: []const u8, /// User-facing title (sidebar tooltip + pane header). title: []const u8, + /// When true the view is registered but omitted from the sidebar icon rail. + hidden: bool = false, ctx: ?*anyopaque = null, draw: *const fn (ctx: ?*anyopaque) anyerror!void, /// Optional: while this view is the active sidebar view, it takes over the workspace @@ -35,6 +37,8 @@ pub const BottomView = struct { id: []const u8, owner: ?*Plugin = null, title: []const u8, + /// When true the bottom panel stays visible even with no active document. + persistent: bool = false, ctx: ?*anyopaque = null, draw: *const fn (ctx: ?*anyopaque) anyerror!void, }; @@ -58,6 +62,36 @@ pub const MenuContribution = struct { draw: *const fn (ctx: ?*anyopaque) anyerror!void, }; +/// Items injected into an already-open parent menu (e.g. shell View). The parent +/// menu's `draw` iterates sections whose `parent_menu_id` matches and calls `draw` +/// while its floating submenu is open. +pub const MenuSectionContribution = struct { + id: []const u8, + /// Parent top-level menu id, e.g. "shell.menu.view". + parent_menu_id: []const u8, + owner: ?*Plugin = null, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, +}; + +/// A named, invocable action a plugin registers with the Host. The shell, menus, and +/// keybindings trigger it by `id` via `Host.runCommand(id)` **without knowing what it +/// does** — this is how a plugin contributes its own features (atlas pack, raster +/// transform, a grid-layout dialog, …) without the SDK or shell naming them. Ids are +/// plugin-namespaced (`"pixelart.packProject"`). The owner resolves any context it needs +/// (active doc, selection, …) inside `run`; the shell passes only the owner's opaque state. +pub const Command = struct { + id: []const u8, + owner: ?*Plugin = null, + /// User-facing label (menus / future command palette). + title: []const u8, + /// Invoke the command. `state` is the owning plugin's opaque state (`owner.state`). + run: *const fn (state: *anyopaque) anyerror!void, + /// Optional enabled-state query — e.g. grey out while busy or with no active document. + /// Absent = always enabled. + isEnabled: ?*const fn (state: *anyopaque) bool = null, +}; + /// 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 diff --git a/src/sdk/runtime.zig b/src/sdk/runtime.zig new file mode 100644 index 00000000..07f25965 --- /dev/null +++ b/src/sdk/runtime.zig @@ -0,0 +1,47 @@ +//! Host-injected plugin runtime: the allocator and `*Host` the shell pushes into a plugin +//! dylib at load (`fizzy_plugin_set_globals`). Plugin code reads them through +//! `sdk.allocator()` and `sdk.host()` — there is no per-plugin file to store them. +//! +//! Each loaded dylib compiles its own `sdk` and `core`, so these statics are private to one +//! plugin image; the host injects them before `register` (and re-injects if they change). +//! `installRuntime` also wires the matching `core.gpa` so allocating `core` helpers work +//! without each plugin remembering to sync it. +const std = @import("std"); +const core = @import("core"); +const Host = @import("Host.zig"); + +var gpa: std.mem.Allocator = undefined; +var host_ptr: *Host = undefined; +/// Shell-owned plugin state injected before `register` (built-in static/dylib path). +var injected_state: ?*anyopaque = null; + +/// The persistent host allocator. Use for anything that outlives a frame; you own every +/// allocation and must free it. Frame-scoped scratch is `host().arena()`. +pub fn allocator() std.mem.Allocator { + return gpa; +} + +/// The shell `*Host` — registries, services, and the `EditorAPI` read surface. +pub fn host() *Host { + return host_ptr; +} + +/// Called by `dylib.exportEntry`'s `fizzy_plugin_set_globals` export. Third-party plugins +/// own their state in `register`; built-ins may inject a shell-owned pointer here. +pub fn installRuntime( + gpa_in: ?*const std.mem.Allocator, + host_in: ?*Host, + state_ptr: ?*anyopaque, +) void { + if (gpa_in) |a| { + gpa = a.*; + core.gpa = a.*; + } + if (host_in) |h| host_ptr = h; + if (state_ptr) |s| injected_state = s; +} + +pub fn injectedState(comptime T: type) ?*T { + const s = injected_state orelse return null; + return @ptrCast(@alignCast(s)); +} diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index 302e70e4..2890b3f7 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -4,6 +4,12 @@ //! 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. + +// Eagerly evaluate the ABI fingerprint lock (see `version.zig`). +comptime { + _ = @import("version.zig"); +} + pub const Host = @import("Host.zig"); pub const Plugin = @import("Plugin.zig"); pub const DocHandle = @import("DocHandle.zig"); @@ -14,7 +20,10 @@ pub const SidebarView = regions.SidebarView; pub const BottomView = regions.BottomView; pub const CenterProvider = regions.CenterProvider; pub const MenuContribution = regions.MenuContribution; +pub const MenuSectionContribution = regions.MenuSectionContribution; pub const SettingsSection = regions.SettingsSection; +pub const Command = regions.Command; +pub const menu = @import("menu.zig"); /// Shell-provided read/utility surface plugins reach through the `Host` /// (arena, folder, shared settings, dirty-marking). @@ -28,8 +37,38 @@ 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`). +/// Host-injected runtime: `sdk.allocator()` (the persistent host allocator) and +/// `sdk.host()` (the shell `*Host`). The dylib entry injects these before `register`; +/// plugin code reads them directly, with no per-plugin storage file. +pub const allocator = @import("runtime.zig").allocator; +pub const host = @import("runtime.zig").host; +pub const installRuntime = @import("runtime.zig").installRuntime; +pub const injectedState = @import("runtime.zig").injectedState; + +/// Wake the app event loop for another frame. Safe from worker threads. +pub fn refresh() void { + host().refresh(); +} + +/// Document staging helpers (`allocStaging`, `loadPathInto`, …). +pub const document = @import("document.zig"); + +/// Plugin identity/version metadata for dylib exports. +pub const manifest = @import("manifest.zig"); +pub const PluginManifest = manifest.PluginManifest; + +/// Workbench inter-plugin service (`"workbench"`). +pub const services = struct { + pub const workbench = @import("services/workbench.zig"); +}; + +/// SDK version + ABI fingerprint lock (`sdk_version`, `recorded_abi_fingerprint`). +pub const version = @import("version.zig"); + +/// Runtime dylib entry contract (`fizzy_plugin_abi_fingerprint` / `fizzy_plugin_register`). pub const dylib = @import("dylib.zig"); +/// Compile-time structural ABI fingerprint used by `dylib.abi_fingerprint`. +pub const fingerprint = @import("fingerprint.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. diff --git a/src/sdk/services/workbench.zig b/src/sdk/services/workbench.zig new file mode 100644 index 00000000..f759283f --- /dev/null +++ b/src/sdk/services/workbench.zig @@ -0,0 +1,78 @@ +//! Workbench inter-plugin service — SDK-facing definition of the `"workbench"` service. +//! +//! The workbench plugin registers an instance via `host.registerService`. Plugin code +//! uses `host.getServiceTyped(workbench.Api)`. The layout is part of the ABI fingerprint. +const std = @import("std"); +const dvui = @import("dvui"); + +pub const Api = struct { + pub const service_name = "workbench"; + + ctx: *anyopaque, + vtable: *const VTable, + + pub const BranchDecorator = struct { + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque, path: []const u8, id_extra: usize) void, + }; + + pub const VTable = struct { + open: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool, + currentGrouping: *const fn (ctx: *anyopaque) u64, + newGrouping: *const fn (ctx: *anyopaque) u64, + close: *const fn (ctx: *anyopaque, id: u64) anyerror!void, + save: *const fn (ctx: *anyopaque) anyerror!void, + isOpen: *const fn (ctx: *anyopaque, path: []const u8) bool, + openCount: *const fn (ctx: *anyopaque) usize, + openPathAt: *const fn (ctx: *anyopaque, index: usize) ?[]const u8, + 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: *const fn (ctx: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool, + registerBranchDecorator: *const fn (ctx: *anyopaque, decorator: BranchDecorator) anyerror!void, + }; + + 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); + } +}; diff --git a/src/sdk/version.zig b/src/sdk/version.zig new file mode 100644 index 00000000..008198c1 --- /dev/null +++ b/src/sdk/version.zig @@ -0,0 +1,57 @@ +//! SDK version and ABI fingerprint lock. +//! +//! `sdk_version` is bumped when the plugin ABI boundary changes. `recorded_abi_fingerprint` +//! must be updated in the same commit — CI fails at compile time if the live fingerprint +//! drifts without an intentional version bump. +const std = @import("std"); +const builtin = @import("builtin"); +const dylib = @import("dylib.zig"); + +pub const VersionTriplet = dylib.VersionTriplet; + +/// ABI contract version. Bump minor (or major for breaking changes) when +/// `recorded_abi_fingerprint` changes. +pub const sdk_version = std.SemanticVersion{ + .major = 0, + .minor = 4, + .patch = 0, +}; + +/// Commit this literal alongside `sdk_version` when the ABI boundary changes. +pub const recorded_abi_fingerprint: u64 = 0x868c117d77f99593; + +comptime { + // The ABI fingerprint guards the *dynamic* plugin-loading boundary, which is native-only + // (no `dlopen` on wasm; web plugins are statically linked into the app). The fingerprint is + // target-dependent — pointer width, etc. — so the recorded literal tracks native targets; + // enforcing it on wasm would fail spuriously. Skip the lock there. + if (builtin.target.cpu.arch != .wasm32 and dylib.abi_fingerprint != recorded_abi_fingerprint) { + @compileError(std.fmt.comptimePrint( + "ABI fingerprint is 0x{x} — bump sdk_version and set recorded_abi_fingerprint in src/sdk/version.zig", + .{dylib.abi_fingerprint}, + )); + } +} + +pub fn sdkVersionTriplet() VersionTriplet { + return .{ + .major = sdk_version.major, + .minor = sdk_version.minor, + .patch = sdk_version.patch, + }; +} + +/// True when `required` (plugin min SDK) is satisfied by `host` (this Fizzy build). +pub fn sdkVersionSatisfies(host: std.SemanticVersion, required: std.SemanticVersion) bool { + if (host.major != required.major) return host.major > required.major; + if (host.minor != required.minor) return host.minor > required.minor; + return host.patch >= required.patch; +} + +pub fn formatVersion(v: std.SemanticVersion, writer: *std.Io.Writer) !void { + try writer.print("{d}.{d}.{d}", .{ v.major, v.minor, v.patch }); +} + +test "sdk version lock is self-consistent" { + try std.testing.expect(dylib.abi_fingerprint == recorded_abi_fingerprint); +} diff --git a/src/web_main.zig b/src/web_main.zig index e810a970..e2f05e34 100644 --- a/src/web_main.zig +++ b/src/web_main.zig @@ -11,8 +11,8 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("fizzy.zig"); -const pixelart = @import("pixelart"); -const Internal = pixelart.internal; +const pixi = @import("pixi"); +const Internal = pixi.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. @@ -28,15 +28,15 @@ comptime { _ = fizzy.atlas; // Algorithms — pure Zig + dvui - _ = pixelart.algorithms.brezenham; - _ = pixelart.algorithms.reduce; + _ = pixi.algorithms.brezenham; + _ = pixi.algorithms.reduce; // Top-level data types (.pixi format on-disk shapes) - _ = pixelart.Animation; - _ = pixelart.Atlas; - _ = pixelart.File; - _ = pixelart.Layer; - _ = pixelart.Sprite; + _ = pixi.Animation; + _ = pixi.Atlas; + _ = pixi.File; + _ = pixi.Layer; + _ = pixi.Sprite; // Internal editor-side data types _ = Internal.Animation; @@ -55,11 +55,11 @@ comptime { _ = fizzy.image.init; _ = fizzy.image.pixels; _ = fizzy.perf.record; - _ = pixelart.render; + _ = pixi.render; // Custom dvui wrapper + widgets — types compile even though the widget files // contain dead `@import("backend")` SDL3 imports at file scope. - _ = pixelart.widgets.FileWidget; + _ = pixi.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 6fbd6b6c..5493d3cb 100644 --- a/tests/fizzy_shim.zig +++ b/tests/fizzy_shim.zig @@ -22,8 +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.pixi_state.deinit(gpa); + gpa.destroy(self.editor.pixi_state); self.editor.arena.deinit(); gpa.destroy(self.editor); gpa.destroy(self.app); @@ -57,12 +57,11 @@ pub fn init(gpa: std.mem.Allocator) !Ctx { 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; + const pixi = fizzy.pixi_mod; + const state_ptr = try gpa.create(pixi.State); + pixi.runtime.adoptShellState(state_ptr); + state_ptr.* = pixi.State.init(gpa, &editor_ptr.host) catch unreachable; + editor_ptr.pixi_state = state_ptr; state_ptr.settings.checker_color_even = .{ 200, 200, 200, 255 }; state_ptr.settings.checker_color_odd = .{ 100, 100, 100, 255 }; diff --git a/tests/integration.zig b/tests/integration.zig index cbf904c2..97ba64f5 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -12,9 +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 pixi = fizzy.pixi_mod; -const Internal = pixelart.internal; +const Internal = pixi.internal; /// Create a small in-memory `Internal.File` suitable for tests. The /// caller must already have a live shim context (so `fizzy.app` / @@ -207,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 `pixelart.File` +// three times. `fromPathFizzy` first tries the current `pixi.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 `pixelart.File` types and +// renames or retypes a field on the public `pixi.File` types and // silently breaks loading older saves. // ------------------------------------------------------------------- -test "pixelart.File parses current-format JSON and round-trips" { +test "pixi.File parses current-format JSON and round-trips" { const json = \\{ \\ "version": { "major": 1, "minor": 0, "patch": 0, "pre": null, "build": null }, @@ -235,7 +235,7 @@ test "pixelart.File parses current-format JSON and round-trips" { ; const parsed = try std.json.parseFromSlice( - pixelart.File, + pixi.File, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -259,7 +259,7 @@ test "pixelart.File parses current-format JSON and round-trips" { defer std.testing.allocator.free(round_tripped); const reparsed = try std.json.parseFromSlice( - pixelart.File, + pixi.File, std.testing.allocator, round_tripped, .{ .ignore_unknown_fields = true }, @@ -276,7 +276,7 @@ test "pixelart.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 "pixelart.File.FileV3 fixture parses" { +test "pixi.File.FileV3 fixture parses" { // V3 keeps the columns/rows shape but uses the older `AnimationV2` // (frame indices + fps) form. const json = @@ -296,7 +296,7 @@ test "pixelart.File.FileV3 fixture parses" { ; const parsed = try std.json.parseFromSlice( - pixelart.File.FileV3, + pixi.File.FileV3, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -308,7 +308,7 @@ test "pixelart.File.FileV3 fixture parses" { try std.testing.expectEqual(@as(f32, 10.0), parsed.value.animations[0].fps); } -test "pixelart.File.FileV2 fixture parses (width/height + tile_size shape)" { +test "pixi.File.FileV2 fixture parses (width/height + tile_size shape)" { const json = \\{ \\ "version": { "major": 0, "minor": 5, "patch": 0, "pre": null, "build": null }, @@ -326,7 +326,7 @@ test "pixelart.File.FileV2 fixture parses (width/height + tile_size shape)" { ; const parsed = try std.json.parseFromSlice( - pixelart.File.FileV2, + pixi.File.FileV2, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -337,7 +337,7 @@ test "pixelart.File.FileV2 fixture parses (width/height + tile_size shape)" { try std.testing.expectEqual(@as(u32, 8), parsed.value.tile_width); } -test "pixelart.File.FileV1 fixture parses (start/length animation shape)" { +test "pixi.File.FileV1 fixture parses (start/length animation shape)" { const json = \\{ \\ "version": { "major": 0, "minor": 1, "patch": 0, "pre": null, "build": null }, @@ -355,7 +355,7 @@ test "pixelart.File.FileV1 fixture parses (start/length animation shape)" { ; const parsed = try std.json.parseFromSlice( - pixelart.File.FileV1, + pixi.File.FileV1, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -470,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 pixelart.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -518,7 +518,7 @@ test "Packer.append: tighten preserves world-space anchor across cells" { } } - var packer = try pixelart.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -553,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 pixelart.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -591,7 +591,7 @@ test "Packer.append skips invisible layers" { .dirty = layer.dirty, }); - var packer = try pixelart.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -634,7 +634,7 @@ test "Packer.packRects: produced rects fit inside the texture and never overlap" } } - var packer = try pixelart.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -812,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 = [_]pixelart.Animation.Frame{.{ + var initial_frames = [_]pixi.Animation.Frame{.{ .sprite_index = 0, .ms = 100, }}; @@ -820,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 = [_]pixelart.Animation.Frame{ + var expect_two = [_]pixi.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 = [_]pixelart.Animation.Frame{ + var expect_three = [_]pixi.Animation.Frame{ .{ .sprite_index = 0, .ms = 100 }, .{ .sprite_index = 9, .ms = 12 }, .{ .sprite_index = 1, .ms = 50 }, @@ -835,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 = [_]pixelart.Animation.Frame{ + var expect_after_remove = [_]pixi.Animation.Frame{ .{ .sprite_index = 9, .ms = 12 }, .{ .sprite_index = 1, .ms = 50 }, }; @@ -984,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 pixelart.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -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. - pixelart.Globals.state.tools.stroke_size = 1; - pixelart.Globals.state.tools.pencil_stroke_size = 1; + ctx.editor.pixi_state.tools.stroke_size = 1; + ctx.editor.pixi_state.tools.pencil_stroke_size = 1; const idx: usize = 3 * 8 + 4; diff --git a/tests/plugin_loader_integration.zig b/tests/plugin_loader_integration.zig index 7dbbf195..6f3ec2aa 100644 --- a/tests/plugin_loader_integration.zig +++ b/tests/plugin_loader_integration.zig @@ -12,20 +12,20 @@ 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. + // Stand-in for app-owned `pixi.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, "pixelart", .{ + var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixi_dylib, "pixi", .{ .gpa = &std.testing.allocator, - .state = &state_buf, - .packer = null, + .arg_b = &state_buf, // pixelart convention: arg_b = *State + .arg_c = null, }); 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); + const pa = host.pluginById("pixi") orelse return error.TestExpectedEqual; + try std.testing.expectEqualStrings("pixi", pa.id); try std.testing.expect(host.sidebar_views.items.len >= 3); loaded.set_dvui_context(null, null, null, null);