From d048de1d14db2710b113a053b3a81689ee255e32 Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 17 Jun 2026 14:42:18 -0500 Subject: [PATCH 01/49] Begin looking at editor split --- spikes/shared-globals/README.md | 40 +++++++++++++++++ spikes/shared-globals/build.zig | 47 ++++++++++++++++++++ spikes/shared-globals/build.zig.zon | 14 ++++++ spikes/shared-globals/core.zig | 50 ++++++++++++++++++++++ spikes/shared-globals/host.zig | 55 ++++++++++++++++++++++++ spikes/shared-globals/plugin.zig | 28 ++++++++++++ src/editor/Editor.zig | 9 ++++ src/fizzy.zig | 3 ++ src/sdk/DocHandle.zig | 17 ++++++++ src/sdk/Host.zig | 58 +++++++++++++++++++++++++ src/sdk/Plugin.zig | 66 +++++++++++++++++++++++++++++ src/sdk/sdk.zig | 9 ++++ 12 files changed, 396 insertions(+) create mode 100644 spikes/shared-globals/README.md create mode 100644 spikes/shared-globals/build.zig create mode 100644 spikes/shared-globals/build.zig.zon create mode 100644 spikes/shared-globals/core.zig create mode 100644 spikes/shared-globals/host.zig create mode 100644 spikes/shared-globals/plugin.zig create mode 100644 src/sdk/DocHandle.zig create mode 100644 src/sdk/Host.zig create mode 100644 src/sdk/Plugin.zig create mode 100644 src/sdk/sdk.zig diff --git a/spikes/shared-globals/README.md b/spikes/shared-globals/README.md new file mode 100644 index 00000000..8c3b635a --- /dev/null +++ b/spikes/shared-globals/README.md @@ -0,0 +1,40 @@ +# Spike: driving host dvui state from a prebuilt plugin dylib + +Validates the load-bearing mechanism for fizzy's runtime native-plugin architecture +(see `~/.claude/plans/i-would-like-to-glowing-stroustrup.md`): can a **prebuilt +plugin dynamic library**, compiling its **own copy** of the dvui-like code, render +into the **host's** dvui state across the `dlopen` boundary? + +`core.zig` stands in for dvui (a `current_window` global, an `ft2lib` global, a +`Window` carrying a per-frame arena, and a `label()` "widget" that uses all three). +The host exe and the plugin dylib each compile `core.zig` independently. + +Run: `zig build run` + +## Findings (macOS/arm64, Zig 0.16.0) + +- **Globals are NOT auto-shared.** Even with `rdynamic` + `allow_shlib_undefined`, + the host and plugin each get their own `current_window` (different addresses). + macOS two-level namespace ⇒ no automatic interposition. So the "one shared + `libdvui`" idea is out. +- **Mechanism B (context injection) works.** The host owns the dvui state; before + invoking the plugin's draw it sets the plugin's `current_window` + `ft2lib`. The + plugin's own statically-compiled `label()` then: + - mutates the **host's** `Window` (`widget_count` 1→4), + - allocates strings in the **host's** arena (round-tripped), + - uses the **host's** `FreeType` handle (`shape_calls` 1→4). +- Works because struct layout is identical (same pinned source/version) and it's + pure pointer-passing — so it ports to Linux/Windows unchanged, and the shared + allocator means **no cross-allocator free hazard**. + +## Design consequence + +Plugins statically compile dvui + the SDK; the host injects its handful of dvui +globals each frame (`current_window` per-frame; `io`/`ft2lib`/`debug` at init — all +public `pub var`, so no dvui patch needed). Pinned Zig + SDK version + a load-time +ABI gate keep struct layouts compatible. + +## Not covered here (validate in-fizzy at Phase 4) + +Real GPU rendering with a live backend — but that's the host's job; the plugin only +records draw commands into the shared Window's render list. diff --git a/spikes/shared-globals/build.zig b/spikes/shared-globals/build.zig new file mode 100644 index 00000000..2f469a9a --- /dev/null +++ b/spikes/shared-globals/build.zig @@ -0,0 +1,47 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // The shared "dvui-like" source. Imported by both artifacts; each compiles + // its own copy (as dvui would be compiled into host and plugin alike). + const core_mod = b.createModule(.{ + .root_source_file = b.path("core.zig"), + .target = target, + .optimize = optimize, + }); + + // Plugin: a dynamic library, prebuilt and dlopen'd at runtime. + const plugin = b.addLibrary(.{ + .name = "plugin", + .linkage = .dynamic, + .root_module = b.createModule(.{ + .root_source_file = b.path("plugin.zig"), + .target = target, + .optimize = optimize, + }), + }); + plugin.root_module.addImport("core", core_mod); + // Allow symbols to be resolved at load time (needed if we test Mechanism A). + plugin.linker_allow_shlib_undefined = true; + b.installArtifact(plugin); + + // Host: the near-empty exe that owns the Window and loads the plugin. + const host = b.addExecutable(.{ + .name = "host", + .root_module = b.createModule(.{ + .root_source_file = b.path("host.zig"), + .target = target, + .optimize = optimize, + }), + }); + host.root_module.addImport("core", core_mod); + // Export the host's dynamic symbols so a plugin could interpose (Mechanism A). + host.rdynamic = true; + b.installArtifact(host); + + const run = b.addRunArtifact(host); + run.step.dependOn(b.getInstallStep()); + b.step("run", "build everything and run the host").dependOn(&run.step); +} diff --git a/spikes/shared-globals/build.zig.zon b/spikes/shared-globals/build.zig.zon new file mode 100644 index 00000000..d4fa5fdb --- /dev/null +++ b/spikes/shared-globals/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .shared_globals_spike, + .version = "0.0.0", + .fingerprint = 0xc23fd395f515e0c8, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "core.zig", + "host.zig", + "plugin.zig", + }, + .dependencies = .{}, +} diff --git a/spikes/shared-globals/core.zig b/spikes/shared-globals/core.zig new file mode 100644 index 00000000..a0d43feb --- /dev/null +++ b/spikes/shared-globals/core.zig @@ -0,0 +1,50 @@ +//! Stand-in for dvui: a global immediate-mode context pointer plus a "widget" +//! call that reads the global and mutates the shared Window. Both the host exe +//! and the plugin dylib compile THIS source independently (as dvui would be +//! compiled into each), so each binary gets its own copy of these globals. +//! The spike answers: can the plugin still drive the host's dvui state — its +//! Window, its per-frame arena allocator, and its FreeType handle? +const std = @import("std"); + +/// Stand-in for dvui's FT_Library handle (`dvui.ft2lib`, dvui.zig:346): a host- +/// owned resource the plugin must use, not reinitialize. +pub const FreeType = struct { + shape_calls: u32 = 0, +}; + +pub const Window = struct { + widget_count: u32 = 0, + magic: u64 = 0xDEADBEEF, + /// Stand-in for dvui's per-frame arena, which lives in the Window. Plugins + /// allocate widget data through this — i.e. the HOST's allocator. + arena: ?std.mem.Allocator = null, +}; + +/// Mirrors `dvui.current_window` (dvui.zig:416) — the shared immediate-mode context. +pub var current_window: ?*Window = null; +/// Mirrors `dvui.ft2lib` — a global library handle that must be injected too. +pub var ft2lib: ?*FreeType = null; + +/// Mirrors a dvui widget constructor: reads the global, allocates label text in +/// the Window's arena, shapes it via the FreeType handle, mutates the Window. +pub fn label(text: []const u8) ![]u8 { + const w = current_window orelse return error.NoCurrentWindow; + std.debug.assert(w.magic == 0xDEADBEEF); // layout/pointer sanity across boundary + const ft = ft2lib orelse return error.NoFreeType; + + const arena = w.arena orelse return error.NoArena; + const copy = try arena.dupe(u8, text); // allocate via the HOST's allocator + ft.shape_calls += 1; // touch the HOST's FreeType handle + w.widget_count += 1; + return copy; +} + +pub fn setCurrentWindow(w: ?*Window) void { + current_window = w; +} +pub fn setFreeType(ft: ?*FreeType) void { + ft2lib = ft; +} +pub fn currentWindowAddr() usize { + return @intFromPtr(¤t_window); +} diff --git a/spikes/shared-globals/host.zig b/spikes/shared-globals/host.zig new file mode 100644 index 00000000..646744f6 --- /dev/null +++ b/spikes/shared-globals/host.zig @@ -0,0 +1,55 @@ +//! The near-empty host exe. It owns the dvui state (Window + per-frame arena + +//! FreeType handle), then dlopens the plugin and lets it draw into that state — +//! modelling fizzy's shell driving a plugin's render across the dylib boundary. +const std = @import("std"); +const builtin = @import("builtin"); +const core = @import("core"); + +pub fn main() !void { + // The host owns the per-frame arena (as dvui's Window owns its arena). + var arena_inst = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_inst.deinit(); + + var ft = core.FreeType{}; // host owns the FreeType handle + var win = core.Window{ .arena = arena_inst.allocator() }; + core.setCurrentWindow(&win); + core.setFreeType(&ft); + _ = try core.label("host-drawn"); // host renders 1 widget itself + std.debug.print("[host] after host label(): widget_count={d} shape_calls={d}\n", .{ win.widget_count, ft.shape_calls }); + + const ext = switch (builtin.os.tag) { + .macos => "dylib", + .windows => "dll", + else => "so", + }; + var buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&buf, "zig-out/lib/libplugin.{s}", .{ext}); + + var lib = try std.DynLib.open(path); + defer lib.close(); + + const set_ctx = lib.lookup(*const fn (?*core.Window, ?*core.FreeType) callconv(.c) void, "plugin_set_context") orelse return error.SymMissing; + const draw = lib.lookup(*const fn () callconv(.c) usize, "plugin_draw") orelse return error.SymMissing; + const plugin_global_addr = lib.lookup(*const fn () callconv(.c) usize, "plugin_current_window_addr") orelse return error.SymMissing; + + std.debug.print("[host] host current_window @ {x}, plugin current_window @ {x} ({s})\n", .{ + core.currentWindowAddr(), + plugin_global_addr(), + if (core.currentWindowAddr() == plugin_global_addr()) "SHARED" else "SEPARATE → inject", + }); + + // Mechanism B: inject the host's dvui state into the plugin. + set_ctx(&win, &ft); + const last_len = draw(); // plugin renders 3 labels via host arena + host FreeType + + std.debug.print("[host] plugin allocated last string len={d} (expect 9 for \"readme.md\")\n", .{last_len}); + std.debug.print("[host] after plugin draw: widget_count={d} (expect 4) shape_calls={d} (expect 4)\n", .{ win.widget_count, ft.shape_calls }); + + const ok = win.widget_count == 4 and ft.shape_calls == 4 and last_len == 9 and win.magic == 0xDEADBEEF; + if (ok) { + std.debug.print("\n[host] ✅ SUCCESS: plugin drove the host's Window, allocated in the host's arena, and used the host's FreeType handle — across the dylib boundary.\n", .{}); + } else { + std.debug.print("\n[host] ❌ FAIL: count={d} shape={d} len={d} magic={x}\n", .{ win.widget_count, ft.shape_calls, last_len, win.magic }); + return error.SpikeFailed; + } +} diff --git a/spikes/shared-globals/plugin.zig b/spikes/shared-globals/plugin.zig new file mode 100644 index 00000000..54d7d65b --- /dev/null +++ b/spikes/shared-globals/plugin.zig @@ -0,0 +1,28 @@ +//! A prebuilt plugin dylib. It imports `core` (the dvui stand-in) and compiles +//! its OWN copy of that code. It draws by calling core.label(), exactly as a real +//! fizzy plugin would call dvui.label() to render into the host's window — using +//! the host's Window, the host's arena allocator, and the host's FreeType handle. +const std = @import("std"); +const core = @import("core"); + +/// Mechanism B: the host injects its dvui state into the plugin's own globals +/// before asking it to draw. (current_window per-frame; ft2lib at init.) +export fn plugin_set_context(w: ?*core.Window, ft: ?*core.FreeType) callconv(.c) void { + core.setCurrentWindow(w); + core.setFreeType(ft); +} + +/// The plugin "renders" three labels into the current Window. Returns the length +/// of the last allocated string (proving it allocated via the host's arena). +export fn plugin_draw() callconv(.c) usize { + const a = core.label("file.fiz") catch return 0; + const b = core.label("sprite.png") catch return 0; + const c = core.label("readme.md") catch return 0; + _ = a; + _ = b; + return c.len; +} + +export fn plugin_current_window_addr() callconv(.c) usize { + return core.currentWindowAddr(); +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 1efd2106..e964b038 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -40,6 +40,9 @@ pub const Menu = @import("Menu.zig"); pub const FileLoadJob = @import("FileLoadJob.zig"); pub const PackJob = @import("PackJob.zig"); +pub const sdk = fizzy.sdk; +pub const Host = sdk.Host; + /// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels. /// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame arena: std.heap.ArenaAllocator, @@ -49,6 +52,9 @@ palette_folder: []const u8, atlas: fizzy.Internal.Atlas, +/// Plugin registry + service locator exposed to plugins +host: Host, + settings: Settings = undefined, recents: Recents = undefined, @@ -260,6 +266,7 @@ pub fn init( }, .tools = try .init(app.allocator), .themes = .empty, + .host = .init(app.allocator), }; editor.settings = try Settings.load(app.allocator, try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" })); @@ -3372,6 +3379,8 @@ pub fn deinit(editor: *Editor) !void { editor.explorer.deinit(); + editor.host.deinit(); + editor.tools.deinit(fizzy.app.allocator); editor.ignore.deinit(fizzy.app.allocator); diff --git a/src/fizzy.zig b/src/fizzy.zig index a1876ff3..58f670e1 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -71,6 +71,9 @@ pub const Sprite = @import("Sprite.zig"); /// builds, where `builtin.os.tag` is always `.freestanding`. pub const platform = @import("platform.zig"); +/// Plugin SDK surface +pub const sdk = @import("sdk/sdk.zig"); + /// Custom dvui stuff pub const dvui = @import("dvui.zig"); diff --git a/src/sdk/DocHandle.zig b/src/sdk/DocHandle.zig new file mode 100644 index 00000000..bd1d980f --- /dev/null +++ b/src/sdk/DocHandle.zig @@ -0,0 +1,17 @@ +//! An opaque handle to an open document. The shell stores these per tab/workspace +//! and never inspects `ptr` — it only routes operations to `owner` (the plugin +//! that opened the document and knows how to render/save/undo it). For pixel art +//! `ptr` is a `*fizzy.Internal.File`; a text plugin would point it at its own type. +//! +//! Phase 0: defined but not yet produced/consumed anywhere (see the modular-editor +//! plan). Wired into the open/render/save path in Phase 3. +const Plugin = @import("Plugin.zig"); + +pub const DocHandle = @This(); + +/// Plugin-owned, opaque document state. +ptr: *anyopaque, +/// The plugin that owns this document. +owner: *Plugin, +/// Shell-assigned stable identifier for tabs/workspaces. +id: u64, diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig new file mode 100644 index 00000000..e2cdc065 --- /dev/null +++ b/src/sdk/Host.zig @@ -0,0 +1,58 @@ +//! The services the shell exposes to plugins, and the registries it owns. Plugins +//! receive a `*Host` instead of reaching into editor globals. Today the Host is +//! embedded in `Editor`; as the shell shrinks (Phases 1-3) more of the editor's +//! responsibilities move behind it. +//! +//! Phase 0: holds the plugin registry + service locator. Nothing is registered +//! yet — the existing pixel-art code still uses globals directly. +const std = @import("std"); +const Plugin = @import("Plugin.zig"); + +pub const Host = @This(); + +allocator: std.mem.Allocator, + +/// All registered plugins (static today; runtime-loaded dylibs in Phase 4). +plugins: std.ArrayListUnmanaged(*Plugin) = .empty, + +/// Service locator for inter-plugin APIs: name -> opaque service vtable. E.g. the +/// workbench plugin registers "workbench" so editor plugins can place tabs and +/// draw per-branch explorer decorations without a compile-time dependency on it. +services: std.StringHashMapUnmanaged(*anyopaque) = .empty, + +pub fn init(allocator: std.mem.Allocator) Host { + return .{ .allocator = allocator }; +} + +pub fn deinit(self: *Host) void { + self.plugins.deinit(self.allocator); + self.services.deinit(self.allocator); +} + +pub fn registerPlugin(self: *Host, plugin: *Plugin) !void { + try self.plugins.append(self.allocator, plugin); +} + +pub fn registerService(self: *Host, name: []const u8, service: *anyopaque) !void { + try self.services.put(self.allocator, name, service); +} + +pub fn getService(self: *Host, name: []const u8) ?*anyopaque { + return self.services.get(name); +} + +/// The registered plugin with the highest priority (lowest value) for `ext`, or +/// null if none claims it. Used in Phase 3 to route file opens to the right plugin. +pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin { + var best: ?*Plugin = null; + var best_priority: u8 = 255; + for (self.plugins.items) |plugin| { + if (plugin.fileTypePriority(ext)) |p| { + if (best == null or p < best_priority) { + best = plugin; + best_priority = p; + } + } + } + return best; +} diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig new file mode 100644 index 00000000..d48b96f1 --- /dev/null +++ b/src/sdk/Plugin.zig @@ -0,0 +1,66 @@ +//! A feature module that plugs into the editor shell. Today plugins are compiled +//! in and registered statically; the same vtable shape is what a prebuilt plugin +//! dylib will expose at runtime (validated in `spikes/shared-globals/`). All hooks +//! are optional function pointers taking the plugin's own opaque `state`, so a +//! plugin implements only what it needs (e.g. the workbench plugin has no +//! `drawDocument`; an editor plugin does). +//! +//! Cross-boundary types may be normal Zig types (not strict C-ABI): host and +//! plugins are pinned to the same SDK build, so layouts match. Only the dlopen +//! entry symbols (added in Phase 4) need `callconv(.c)`. +//! +//! Phase 0: type definition only; nothing constructs or calls plugins yet. +const std = @import("std"); +const dvui = @import("dvui"); +const DocHandle = @import("DocHandle.zig"); + +pub const Plugin = @This(); + +/// Opaque, plugin-owned state passed back to every vtable call. +state: *anyopaque, +vtable: *const VTable, + +/// Stable, unique identifier (snake_case), e.g. "pixelart", "workbench". +id: []const u8, +/// User-facing name shown in UI. +display_name: []const u8, + +pub const VTable = struct { + /// Tear down `state`. Called when the plugin is unregistered / app shuts down. + deinit: ?*const fn (state: *anyopaque) void = null, + + /// Priority for opening files with extension `ext` (including the dot, e.g. + /// ".fiz"); lower value wins. `null` = this plugin does not handle `ext`. + /// Mirrors dvui-editor's fileTypePriority. A plugin may claim many extensions. + fileTypePriority: ?*const fn (state: *anyopaque, ext: []const u8) ?u8 = null, + + // ---- document lifecycle (operates on the plugin's own type via DocHandle) ---- + openDocument: ?*const fn (state: *anyopaque, path: []const u8) anyerror!DocHandle = null, + saveDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + closeDocument: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, + isDirty: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + undo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + redo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + + // ---- render hooks (the plugin draws its own dvui UI into the host window) ---- + /// Draw the plugin's explorer/sidebar pane (left region). + drawExplorerPane: ?*const fn (state: *anyopaque) anyerror!void = null, + /// Draw an open document (center/workspace region). + drawDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + /// Draw the plugin's bottom panel content. + drawBottomPanel: ?*const fn (state: *anyopaque) anyerror!void = null, + + // ---- shell contributions ---- + contributeMenu: ?*const fn (state: *anyopaque) anyerror!void = null, + contributeKeybinds: ?*const fn (state: *anyopaque, win: *dvui.Window) anyerror!void = null, +}; + +// Thin wrappers so callers don't repeat the optional-vtable dance. + +pub fn fileTypePriority(self: Plugin, ext: []const u8) ?u8 { + return if (self.vtable.fileTypePriority) |f| f(self.state, ext) else null; +} + +pub fn deinit(self: Plugin) void { + if (self.vtable.deinit) |f| f(self.state); +} diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig new file mode 100644 index 00000000..6e8940aa --- /dev/null +++ b/src/sdk/sdk.zig @@ -0,0 +1,9 @@ +//! Fizzy plugin SDK — the surface a plugin module depends on. +//! +//! Phase 0 of the modular-editor plan: type definitions + registries only. +//! Nothing routes through these yet; the shell still drives pixel art directly. +//! Subsequent phases move file management, the workspace/tabs system, and the +//! pixel-art editor behind this boundary, ending with runtime dylib loading. +pub const Host = @import("Host.zig"); +pub const Plugin = @import("Plugin.zig"); +pub const DocHandle = @import("DocHandle.zig"); From 603bf54f5482cd02abe8ac2feb43f7eb932bac3e Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 17 Jun 2026 14:59:24 -0500 Subject: [PATCH 02/49] phase 1 - file tree and workbench --- src/editor/Workspace.zig | 10 +++++++++- src/editor/widgets/FileWidget.zig | 30 +++++++++++++++++++----------- src/internal/File.zig | 8 ++++++-- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/editor/Workspace.zig b/src/editor/Workspace.zig index a2b0e5de..3b942cf7 100644 --- a/src/editor/Workspace.zig +++ b/src/editor/Workspace.zig @@ -61,6 +61,14 @@ pub fn init(grouping: u64) Workspace { }; } +/// Recover the typed workspace currently drawing `file` from its opaque slot +/// handle (`File.EditorData.workspace_handle`, set each frame in `drawCanvas`). +/// Returns null before the file has been laid out this session. +pub fn ofFile(file: *fizzy.Internal.File) ?*Workspace { + const handle = file.editor.workspace_handle orelse return null; + return @ptrCast(@alignCast(handle)); +} + const handle_size = 10; const handle_dist = 60; @@ -877,7 +885,7 @@ pub fn drawCanvas(self: *Workspace) !void { const file = &fizzy.editor.open_files.values()[self.open_file_index]; file.editor.canvas.id = canvas_vbox.data().id; - file.editor.workspace = self; + file.editor.workspace_handle = self; if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) { defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .top, .{}); diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig index f9a6989c..d24d5e3c 100644 --- a/src/editor/widgets/FileWidget.zig +++ b/src/editor/widgets/FileWidget.zig @@ -17,6 +17,7 @@ const ScaleWidget = dvui.ScaleWidget; pub const FileWidget = @This(); const CanvasWidget = @import("CanvasWidget.zig"); +const Workspace = fizzy.Editor.Workspace; const icons = @import("icons"); init_options: InitOptions, @@ -641,12 +642,19 @@ const BubblePanShared = struct { tool_not_pointer: bool, }; +/// The workspace currently drawing this file, recovered from the file's opaque +/// slot handle. Valid during draw/processEvents — the shell sets the handle each +/// frame (in `Workspace.drawCanvas`) before invoking the widget. +fn workspace(self: *FileWidget) *Workspace { + return Workspace.ofFile(self.init_options.file).?; +} + /// Same read-only state as `drawSpriteBubbles` uses for `BubblePanShared` (no animation side effects). fn bubblePanSharedForGrid(self: *FileWidget) ?BubblePanShared { if (self.init_options.file.editor.transform != null) return null; if (self.resize_data_point != null) return null; - if (self.init_options.file.editor.workspace.columns_drag_index != null) return null; - if (self.init_options.file.editor.workspace.rows_drag_index != null) return null; + if (self.workspace().columns_drag_index != null) return null; + if (self.workspace().rows_drag_index != null) return null; if (self.removed_sprite_indices != null) return null; if (!(self.active() or self.hovered())) return null; @@ -4509,7 +4517,7 @@ pub fn drawLayers(self: *FileWidget) void { if (self.removed_sprite_indices != null) { self.drawCellReorderPreview(); return; - } else if (file.editor.workspace.columns_drag_index != null or file.editor.workspace.rows_drag_index != null) { + } else if (self.workspace().columns_drag_index != null or self.workspace().rows_drag_index != null) { self.drawColumnRowReorderPreview(); return; } else { @@ -4709,17 +4717,17 @@ fn drawCanvasCheckerboardBackground(self: *FileWidget) void { fn drawColumnRowReorderPreview(self: *FileWidget) void { const file = self.init_options.file; - const workspace = file.editor.workspace; - if (workspace.columns_drag_index == null and workspace.rows_drag_index == null) return; + const ws = self.workspace(); + if (ws.columns_drag_index == null and ws.rows_drag_index == null) return; - const axis: ReorderAxis = if (workspace.columns_drag_index != null) .columns else .rows; + const axis: ReorderAxis = if (ws.columns_drag_index != null) .columns else .rows; const target_index = switch (axis) { - .columns => workspace.columns_target_index, - .rows => workspace.rows_target_index, + .columns => ws.columns_target_index, + .rows => ws.rows_target_index, }; const removed_index = switch (axis) { - .columns => workspace.columns_drag_index, - .rows => workspace.rows_drag_index, + .columns => ws.columns_drag_index, + .rows => ws.rows_drag_index, } orelse return; self.drawReorderPreviewForAxis(file, axis, target_index, removed_index); @@ -5629,7 +5637,7 @@ pub fn processResize(self: *FileWidget) void { pub fn processEvents(self: *FileWidget) void { const transform = self.init_options.file.editor.transform != null; - const reorder = self.init_options.file.editor.workspace.columns_drag_index != null or self.init_options.file.editor.workspace.rows_drag_index != null or self.removed_sprite_indices != null; + const reorder = self.workspace().columns_drag_index != null or self.workspace().rows_drag_index != null or self.removed_sprite_indices != null; // Try to ensure that selected animation frame index is valid if (self.init_options.file.selected_animation_index) |ai| { diff --git a/src/internal/File.zig b/src/internal/File.zig index 0747db78..b03f789f 100644 --- a/src/internal/File.zig +++ b/src/internal/File.zig @@ -52,8 +52,12 @@ editor: EditorData = .{}, /// /// Also, the fields here tend to be directly coupled with the UI library pub const EditorData = struct { - // Only valid while file widget is drawing the file - workspace: *fizzy.Editor.Workspace = undefined, + /// Opaque slot handle to the workspace currently drawing this file. Set by the + /// shell each frame before the file is drawn; recovered in the editor layer via + /// `Editor.Workspace.ofFile`. Opaque so this internal data type does not + /// type-depend on the editor's `Workspace` (lets `File` move into a plugin). + /// Only valid while the file widget is drawing the file. + workspace_handle: ?*anyopaque = null, canvas: fizzy.dvui.CanvasWidget = .{}, layers_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, sprites_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, From d2df1974b75377595d70720e97c526c44cbf81ea Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 17 Jun 2026 15:05:12 -0500 Subject: [PATCH 03/49] Extract files.zig + workspace/tabs into a Workbench module --- src/editor/Editor.zig | 11 ++++++ src/editor/explorer/files.zig | 21 ++++++++++++ src/workbench/Workbench.zig | 63 +++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 src/workbench/Workbench.zig diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index e964b038..967347fb 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -43,6 +43,10 @@ pub const PackJob = @import("PackJob.zig"); pub const sdk = fizzy.sdk; pub const Host = sdk.Host; +/// Workbench (Phase 1): file-management home — currently the per-branch +/// decoration registry for the explorer; grows to own files + tabs/splits. +pub const Workbench = @import("../workbench/Workbench.zig"); + /// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels. /// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame arena: std.heap.ArenaAllocator, @@ -55,6 +59,9 @@ atlas: fizzy.Internal.Atlas, /// Plugin registry + service locator exposed to plugins host: Host, +/// File-management workbench (per-branch explorer decorations, …) +workbench: Workbench, + settings: Settings = undefined, recents: Recents = undefined, @@ -267,8 +274,11 @@ pub fn init( .tools = try .init(app.allocator), .themes = .empty, .host = .init(app.allocator), + .workbench = .init(app.allocator), }; + try editor.workbench.registerBuiltins(); + editor.settings = try Settings.load(app.allocator, try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" })); // Start the long-lived save-queue worker. All .fiz async saves get @@ -3380,6 +3390,7 @@ pub fn deinit(editor: *Editor) !void { editor.explorer.deinit(); editor.host.deinit(); + editor.workbench.deinit(); editor.tools.deinit(fizzy.app.allocator); diff --git a/src/editor/explorer/files.zig b/src/editor/explorer/files.zig index 18b2f379..6e5dc33c 100644 --- a/src/editor/explorer/files.zig +++ b/src/editor/explorer/files.zig @@ -435,6 +435,27 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind } } } + } else if (kind == .file) { + // File row: label expands and pushes plugin-registered decorations + // (e.g. the unsaved dot) to the right edge of the row. + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .background = false, + .padding = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .id_extra = id_extra, + }); + defer row.deinit(); + dvui.label(@src(), "{s}", .{label}, .{ + .color_text = color, + .padding = padding, + .margin = dvui.Rect.all(0), + .id_extra = id_extra, + .font = font, + .expand = .horizontal, + .gravity_y = 0.5, + }); + fizzy.editor.workbench.drawBranchDecorations(full_path, id_extra); } else { dvui.label(@src(), "{s}", .{label}, .{ .color_text = color, diff --git a/src/workbench/Workbench.zig b/src/workbench/Workbench.zig new file mode 100644 index 00000000..77b85be9 --- /dev/null +++ b/src/workbench/Workbench.zig @@ -0,0 +1,63 @@ +//! The Workbench owns cross-cutting file-management UI: today the per-branch +//! decoration registry for the file explorer; in later Phase 1 work it grows to +//! own the file tree, the open/load flow, and the tabs/splits system, then becomes +//! a standalone plugin exposing this as a service (`workbench-api`). +//! +//! Per-branch decorations let any plugin draw a right-justified icon on a file row +//! (e.g. the built-in "unsaved" dot). Decorators run inside the row's hbox after +//! the label, so an expanding label pushes them to the right edge. +const std = @import("std"); +const dvui = @import("dvui"); +const icons = @import("icons"); +const fizzy = @import("../fizzy.zig"); + +pub const Workbench = @This(); + +/// A hook to draw a decoration on a file row. `ctx` is decorator-owned (null for +/// stateless built-ins). `path` is the file's absolute path; `id_extra` is the +/// row's disambiguator (pass through to any dvui widget drawn). +pub const BranchDecorator = struct { + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque, path: []const u8, id_extra: usize) void, +}; + +allocator: std.mem.Allocator, +decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty, + +pub fn init(allocator: std.mem.Allocator) Workbench { + return .{ .allocator = allocator }; +} + +pub fn deinit(self: *Workbench) void { + self.decorators.deinit(self.allocator); +} + +/// Register the decorations the shell ships with. Called once after the editor is +/// constructed. (Plugins register their own via `registerBranchDecorator`.) +pub fn registerBuiltins(self: *Workbench) !void { + try self.registerBranchDecorator(.{ .draw = &drawUnsavedDot }); +} + +pub fn registerBranchDecorator(self: *Workbench, decorator: BranchDecorator) !void { + try self.decorators.append(self.allocator, decorator); +} + +/// Called by the file explorer for each file row (inside the row's hbox). +pub fn drawBranchDecorations(self: *Workbench, path: []const u8, id_extra: usize) void { + for (self.decorators.items) |decorator| decorator.draw(decorator.ctx, path, id_extra); +} + +/// Built-in: a dot on rows whose file is open with unsaved changes. Mirrors the +/// tab dirty indicator (`Workspace.zig` ~:528) so the two stay visually consistent. +fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void { + const file = fizzy.editor.getFileFromPath(path) orelse return; + if (!file.dirty()) return; + dvui.icon(@src(), "explorer_dirty", icons.tvg.lucide.@"circle-small", .{ + .stroke_color = dvui.themeGet().color(.window, .text), + }, .{ + .gravity_x = 1.0, + .gravity_y = 0.5, + .padding = dvui.Rect.all(2), + .id_extra = id_extra, + }); +} From cca4e43a0dc50a21c04e28d5c8474bb5d8d8d585 Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 17 Jun 2026 15:19:04 -0500 Subject: [PATCH 04/49] Relocate files.zig + workspace/tabs into Workbench; formal workbench-api Host service --- src/App.zig | 3 + src/editor/Editor.zig | 22 ++- src/editor/explorer/Explorer.zig | 2 +- src/{editor => workbench}/FileLoadJob.zig | 0 src/workbench/Workbench.zig | 188 ++++++++++++++++++- src/{editor => workbench}/Workspace.zig | 0 src/{editor/explorer => workbench}/files.zig | 95 ++++++---- 7 files changed, 269 insertions(+), 41 deletions(-) rename src/{editor => workbench}/FileLoadJob.zig (100%) rename src/{editor => workbench}/Workspace.zig (100%) rename src/{editor/explorer => workbench}/files.zig (94%) diff --git a/src/App.zig b/src/App.zig index 65e64e30..661a68c7 100644 --- a/src/App.zig +++ b/src/App.zig @@ -160,6 +160,9 @@ pub fn AppInit(win: *dvui.Window) !void { fizzy.editor = try allocator.create(Editor); fizzy.editor.* = Editor.init(fizzy.app) catch unreachable; + // Second-stage init that needs the editor at its final heap address (e.g. + // registering the workbench-api service whose `ctx` is this pointer). + fizzy.editor.postInit() catch unreachable; // `Packer` works on web now that `zstbi.c` compiles for wasm32-freestanding // (`STBI_NO_STDLIB` + the `fizzy_stbi_libc.c` shims). The web pack flow diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 967347fb..03949549 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -30,14 +30,14 @@ pub const Dialogs = @import("dialogs/Dialogs.zig"); pub const Transform = @import("Transform.zig"); pub const Keybinds = @import("Keybinds.zig"); -pub const Workspace = @import("Workspace.zig"); +pub const Workspace = @import("../workbench/Workspace.zig"); pub const Explorer = @import("explorer/Explorer.zig"); pub const IgnoreRules = @import("explorer/IgnoreRules.zig"); pub const Panel = @import("panel/Panel.zig"); pub const Sidebar = @import("Sidebar.zig"); pub const Infobar = @import("Infobar.zig"); pub const Menu = @import("Menu.zig"); -pub const FileLoadJob = @import("FileLoadJob.zig"); +pub const FileLoadJob = @import("../workbench/FileLoadJob.zig"); pub const PackJob = @import("PackJob.zig"); pub const sdk = fizzy.sdk; @@ -462,6 +462,24 @@ pub fn init( return editor; } +/// Second-stage init that needs the editor at its FINAL heap address. `init` +/// builds an `Editor` by value and the caller copies it to the heap, so anything +/// that captures `&editor.*` (e.g. a service whose `ctx` is the editor pointer) +/// must run here — not in `init`, where it would point at the stack temporary. +/// Called from `App.AppInit` right after the heap copy. (The built-in branch +/// decorators registered in `init` are exempt: they store fn pointers, not `&editor`.) +pub fn postInit(editor: *Editor) !void { + // The workbench-api is the file explorer's programmatic surface and drives OS + // file management (open/create/rename/delete/move on disk). The web build has + // no filesystem API, so the file explorer / workbench service is left out there + // for now. Keeping the registration behind a comptime gate also keeps the + // service's native-only fn bodies out of wasm analysis entirely (the codebase's + // dead-branch convention; see `web_main.zig`). + if (comptime builtin.target.cpu.arch == .wasm32) return; + editor.workbench.initService(editor); + try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api); +} + /// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void { const themes_dir = try std.fs.path.join(gpa, &.{ editor.config_folder, "Themes" }); diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 56bb771c..a935c0b2 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -13,7 +13,7 @@ const nfd = @import("nfd"); pub const Explorer = @This(); -pub const files = @import("files.zig"); +pub const files = @import("../../workbench/files.zig"); pub const Tools = @import("tools.zig"); pub const Sprites = @import("sprites.zig"); // pub const animations = @import("animations.zig"); diff --git a/src/editor/FileLoadJob.zig b/src/workbench/FileLoadJob.zig similarity index 100% rename from src/editor/FileLoadJob.zig rename to src/workbench/FileLoadJob.zig diff --git a/src/workbench/Workbench.zig b/src/workbench/Workbench.zig index 77b85be9..16dcae66 100644 --- a/src/workbench/Workbench.zig +++ b/src/workbench/Workbench.zig @@ -1,7 +1,9 @@ -//! The Workbench owns cross-cutting file-management UI: today the per-branch -//! decoration registry for the file explorer; in later Phase 1 work it grows to -//! own the file tree, the open/load flow, and the tabs/splits system, then becomes -//! a standalone plugin exposing this as a service (`workbench-api`). +//! The Workbench is the file-management home of the editor. Its module now owns +//! the file tree (`files.zig`), the open/load flow (`FileLoadJob.zig`), and the +//! workspace/tabs/splits system (`Workspace.zig`); in a later phase it becomes a +//! standalone plugin. It exposes its capabilities to other plugins through the +//! `workbench-api` Host service (`Workbench.Api`) so they never reach into the +//! `fizzy.editor` globals. //! //! Per-branch decorations let any plugin draw a right-justified icon on a file row //! (e.g. the built-in "unsaved" dot). Decorators run inside the row's hbox after @@ -10,6 +12,7 @@ const std = @import("std"); const dvui = @import("dvui"); const icons = @import("icons"); const fizzy = @import("../fizzy.zig"); +const files = @import("files.zig"); pub const Workbench = @This(); @@ -24,6 +27,12 @@ pub const BranchDecorator = struct { allocator: std.mem.Allocator, decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty, +/// The `workbench-api` service instance handed to plugins. Its `ctx` must be the +/// editor's FINAL heap address, so it's filled in by `initService` from +/// `Editor.postInit` (after `Editor.init`'s by-value result is copied to the heap), +/// not during `init` where `&editor.*` would point at a stack temporary. +api: Api = undefined, + pub fn init(allocator: std.mem.Allocator) Workbench { return .{ .allocator = allocator }; } @@ -32,6 +41,12 @@ pub fn deinit(self: *Workbench) void { self.decorators.deinit(self.allocator); } +/// Build the `workbench-api` service. `editor_ctx` is the host's heap `*Editor`, +/// passed opaquely so the API has no compile-time dependency back on the editor. +pub fn initService(self: *Workbench, editor_ctx: *anyopaque) void { + self.api = .{ .ctx = editor_ctx, .vtable = &service_vtable }; +} + /// Register the decorations the shell ships with. Called once after the editor is /// constructed. (Plugins register their own via `registerBranchDecorator`.) pub fn registerBuiltins(self: *Workbench) !void { @@ -61,3 +76,168 @@ fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void { .id_extra = id_extra, }); } + +// ============================================================================ +// workbench-api — the formal Host service +// ============================================================================ + +/// The capabilities the workbench exposes to other plugins, retrieved via +/// `host.getService(Workbench.Api.service_name)` and `@ptrCast` to `*Api`. Plugins +/// drive file management through this instead of touching `fizzy.editor`: they open +/// documents, place them in tab groups/splits, mutate the file tree, and decorate +/// explorer rows. +/// +/// Cross-boundary types are normal Zig (host + plugins share one pinned SDK build), +/// so this is a plain vtable struct; only the dlopen entry symbols (Phase 4) need +/// `callconv(.c)`. The implementation lives below; `ctx` is the host's `*Editor`. +pub const Api = struct { + /// Service-locator key for `host.registerService` / `host.getService`. + pub const service_name = "workbench"; + + ctx: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + // ---- open documents + tab/split placement ---- + /// Open `path` into workspace `grouping` (the tab group / split target). + /// Returns true if newly opened (false if already open or unowned). + open: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool, + /// The currently focused workspace grouping — the default placement target. + currentGrouping: *const fn (ctx: *anyopaque) u64, + /// Allocate a fresh grouping id for a new tab group / split. + newGrouping: *const fn (ctx: *anyopaque) u64, + /// Close the open document whose file id is `id`. + close: *const fn (ctx: *anyopaque, id: u64) anyerror!void, + /// Save the active document. + save: *const fn (ctx: *anyopaque) anyerror!void, + /// True if `path` is currently open in some workspace. + isOpen: *const fn (ctx: *anyopaque, path: []const u8) bool, + + // ---- list open documents (no plugin-specific type leaks the boundary) ---- + /// Number of currently open documents. + openCount: *const fn (ctx: *anyopaque) usize, + /// Absolute path of the open document at `index`, or null if out of range. + openPathAt: *const fn (ctx: *anyopaque, index: usize) ?[]const u8, + + // ---- file-tree operations ---- + createFile: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, + createDir: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, + rename: *const fn (ctx: *anyopaque, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) anyerror!void, + delete: *const fn (ctx: *anyopaque, path: []const u8) void, + /// Move `path` into directory `target_dir`. Returns true if it moved. + move: *const fn (ctx: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool, + + // ---- explorer row decorations ---- + registerBranchDecorator: *const fn (ctx: *anyopaque, decorator: BranchDecorator) anyerror!void, + }; + + // Thin wrappers so callers skip the `self.vtable.x(self.ctx, …)` dance. + pub fn open(self: Api, path: []const u8, grouping: u64) !bool { + return self.vtable.open(self.ctx, path, grouping); + } + pub fn currentGrouping(self: Api) u64 { + return self.vtable.currentGrouping(self.ctx); + } + pub fn newGrouping(self: Api) u64 { + return self.vtable.newGrouping(self.ctx); + } + pub fn close(self: Api, id: u64) !void { + return self.vtable.close(self.ctx, id); + } + pub fn save(self: Api) !void { + return self.vtable.save(self.ctx); + } + pub fn isOpen(self: Api, path: []const u8) bool { + return self.vtable.isOpen(self.ctx, path); + } + pub fn openCount(self: Api) usize { + return self.vtable.openCount(self.ctx); + } + pub fn openPathAt(self: Api, index: usize) ?[]const u8 { + return self.vtable.openPathAt(self.ctx, index); + } + pub fn createFile(self: Api, path: []const u8) !void { + return self.vtable.createFile(self.ctx, path); + } + pub fn createDir(self: Api, path: []const u8) !void { + return self.vtable.createDir(self.ctx, path); + } + pub fn rename(self: Api, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) !void { + return self.vtable.rename(self.ctx, path, new_path, kind); + } + pub fn delete(self: Api, path: []const u8) void { + return self.vtable.delete(self.ctx, path); + } + pub fn move(self: Api, path: []const u8, target_dir: []const u8) !bool { + return self.vtable.move(self.ctx, path, target_dir); + } + pub fn registerBranchDecorator(self: Api, decorator: BranchDecorator) !void { + return self.vtable.registerBranchDecorator(self.ctx, decorator); + } +}; + +const service_vtable: Api.VTable = .{ + .open = svcOpen, + .currentGrouping = svcCurrentGrouping, + .newGrouping = svcNewGrouping, + .close = svcClose, + .save = svcSave, + .isOpen = svcIsOpen, + .openCount = svcOpenCount, + .openPathAt = svcOpenPathAt, + .createFile = svcCreateFile, + .createDir = svcCreateDir, + .rename = svcRename, + .delete = svcDelete, + .move = svcMove, + .registerBranchDecorator = svcRegisterBranchDecorator, +}; + +inline fn editorOf(ctx: *anyopaque) *fizzy.Editor { + return @ptrCast(@alignCast(ctx)); +} + +fn svcOpen(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool { + return editorOf(ctx).openFilePath(path, grouping); +} +fn svcCurrentGrouping(ctx: *anyopaque) u64 { + return editorOf(ctx).currentGroupingID(); +} +fn svcNewGrouping(ctx: *anyopaque) u64 { + return editorOf(ctx).newGroupingID(); +} +fn svcClose(ctx: *anyopaque, id: u64) anyerror!void { + return editorOf(ctx).closeFileID(id); +} +fn svcSave(ctx: *anyopaque) anyerror!void { + return editorOf(ctx).save(); +} +fn svcIsOpen(ctx: *anyopaque, path: []const u8) bool { + return editorOf(ctx).getFileFromPath(path) != null; +} +fn svcOpenCount(ctx: *anyopaque) usize { + return editorOf(ctx).open_files.count(); +} +fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 { + const editor = editorOf(ctx); + if (index >= editor.open_files.count()) return null; + return editor.open_files.values()[index].path; +} +fn svcCreateFile(_: *anyopaque, path: []const u8) anyerror!void { + return files.createFilePath(path); +} +fn svcCreateDir(_: *anyopaque, path: []const u8) anyerror!void { + return files.createDirPath(path); +} +fn svcRename(_: *anyopaque, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) anyerror!void { + return files.renamePath(path, new_path, kind); +} +fn svcDelete(_: *anyopaque, path: []const u8) void { + files.deletePath(path); +} +fn svcMove(_: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool { + return files.moveOnePath(path, target_dir, dvui.currentWindow().arena()); +} +fn svcRegisterBranchDecorator(ctx: *anyopaque, decorator: BranchDecorator) anyerror!void { + return editorOf(ctx).workbench.registerBranchDecorator(decorator); +} diff --git a/src/editor/Workspace.zig b/src/workbench/Workspace.zig similarity index 100% rename from src/editor/Workspace.zig rename to src/workbench/Workspace.zig diff --git a/src/editor/explorer/files.zig b/src/workbench/files.zig similarity index 94% rename from src/editor/explorer/files.zig rename to src/workbench/files.zig index 6e5dc33c..ba988b14 100644 --- a/src/editor/explorer/files.zig +++ b/src/workbench/files.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const Editor = fizzy.Editor; const builtin = @import("builtin"); @@ -408,31 +408,7 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind } if (!std.mem.eql(u8, label, te.getText()) and te.getText().len > 0 and valid_path) { - switch (kind) { - .directory => { - std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ label, te.getText() }); - - for (fizzy.editor.open_files.values()) |*file| { - if (std.mem.containsAtLeast(u8, file.path, 1, full_path)) { - const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(file.path)) catch "Failed to duplicate path"; - fizzy.app.allocator.free(file.path); - file.path = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name }); - } - } - }, - .file => { - std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ label, te.getText() }); - - if (fizzy.editor.getFileFromPath(full_path)) |file| { - fizzy.app.allocator.free(file.path); - file.path = fizzy.app.allocator.dupe(u8, new_path) catch { - dvui.log.err("Failed to duplicate path: {s}", .{new_path}); - return error.FailedToDuplicatePath; - }; - } - }, - else => {}, - } + try renamePath(full_path, new_path, kind); } } } else if (kind == .file) { @@ -774,13 +750,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg dvui.log.err("Failed to collect selection paths: {any}", .{err}); break :blk &[_][]const u8{}; }; - for (top) |del_path| { - if (pathIsDirAbsolute(del_path)) { - std.Io.Dir.deleteDirAbsolute(dvui.io, del_path) catch dvui.log.err("Failed to delete folder: {s}", .{del_path}); - } else { - std.Io.Dir.deleteFileAbsolute(dvui.io, del_path) catch dvui.log.err("Failed to delete file: {s}", .{del_path}); - } - } + for (top) |del_path| deletePath(del_path); } } } @@ -1256,7 +1226,7 @@ fn applyFileMove(unique_id: dvui.Id, tree: *fizzy.dvui.TreeWidget, target_dir: [ dvui.dataRemove(null, unique_id, "removed_path"); } -fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.Allocator) !bool { +pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.Allocator) !bool { const base = std.fs.path.basename(source_path); const new_path = try std.fs.path.join(arena, &.{ target_dir, base }); if (std.mem.eql(u8, source_path, new_path)) return false; @@ -1276,6 +1246,63 @@ fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.A return true; } +// ---- workbench-api file-tree operations ------------------------------------- +// The functions below are the disk-mutating primitives behind both the explorer's +// inline actions (rename/delete above) and the `workbench-api` Host service. They +// keep any matching open document's `path` field in sync so tabs don't dangle. + +/// Rename `full_path` to `new_path`. A directory rename rewrites the `path` of +/// every open document beneath it; a file rename rewrites that document. Logs and +/// continues on a filesystem failure (matches the explorer's inline behavior). +pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) !void { + switch (kind) { + .directory => { + std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); + + for (fizzy.editor.open_files.values()) |*file| { + if (std.mem.containsAtLeast(u8, file.path, 1, full_path)) { + const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(file.path)) catch "Failed to duplicate path"; + fizzy.app.allocator.free(file.path); + file.path = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name }); + } + } + }, + .file => { + std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); + + if (fizzy.editor.getFileFromPath(full_path)) |file| { + fizzy.app.allocator.free(file.path); + file.path = fizzy.app.allocator.dupe(u8, new_path) catch { + dvui.log.err("Failed to duplicate path: {s}", .{new_path}); + return error.FailedToDuplicatePath; + }; + } + }, + else => {}, + } +} + +/// Delete `path` from disk (a directory must be empty — mirrors the explorer's +/// inline Delete). Logs and continues on failure. +pub fn deletePath(path: []const u8) void { + if (pathIsDirAbsolute(path)) { + std.Io.Dir.deleteDirAbsolute(dvui.io, path) catch dvui.log.err("Failed to delete folder: {s}", .{path}); + } else { + std.Io.Dir.deleteFileAbsolute(dvui.io, path) catch dvui.log.err("Failed to delete file: {s}", .{path}); + } +} + +/// Create an empty file at absolute `path`. +pub fn createFilePath(path: []const u8) !void { + var handle = try std.Io.Dir.createFileAbsolute(dvui.io, path, .{}); + handle.close(dvui.io); +} + +/// Create a directory at absolute `path` (parents must already exist). +pub fn createDirPath(path: []const u8) !void { + try std.Io.Dir.createDirAbsolute(dvui.io, path, .default_dir); +} + /// Remove stale selections whose underlying file no longer exists (e.g. moved by a multi-drag). pub fn pruneMissingSelections() void { var i: usize = 0; From daa4d1af5a633d2c2c802625a3b8fcfbeb04f172 Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 17 Jun 2026 15:39:36 -0500 Subject: [PATCH 05/49] Begin phase 2 Phase 3a --- src/editor/Editor.zig | 72 ++++++++++++--- src/editor/Keybinds.zig | 53 +++-------- src/editor/Menu.zig | 26 +++++- src/editor/Sidebar.zig | 38 ++++---- src/editor/explorer/Explorer.zig | 39 ++------ src/editor/panel/Panel.zig | 37 ++++++-- src/editor/widgets/FileWidget.zig | 4 +- src/internal/File.zig | 5 +- src/internal/History.zig | 17 ++-- src/pixelart/plugin.zig | 143 ++++++++++++++++++++++++++++++ src/sdk/Host.zig | 104 ++++++++++++++++++++++ src/sdk/Plugin.zig | 18 ++++ src/sdk/regions.zig | 54 +++++++++++ src/sdk/sdk.zig | 7 ++ src/workbench/Workspace.zig | 2 +- src/workbench/plugin.zig | 68 ++++++++++++++ 16 files changed, 552 insertions(+), 135 deletions(-) create mode 100644 src/pixelart/plugin.zig create mode 100644 src/sdk/regions.zig create mode 100644 src/workbench/plugin.zig diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 03949549..0e319873 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -468,16 +468,54 @@ pub fn init( /// must run here — not in `init`, where it would point at the stack temporary. /// Called from `App.AppInit` right after the heap copy. (The built-in branch /// decorators registered in `init` are exempt: they store fn pointers, not `&editor`.) +/// Stable shell-builtin contribution id. +pub const view_settings = "shell.settings"; + pub fn postInit(editor: *Editor) !void { + // Register plugin contributions (sidebar/bottom/center/menus). These are the + // near-empty shell's content: it iterates the Host registries rather than + // hardcoding panes. Web-safe — the draw fns reach the same inline code the + // editor tick already runs on wasm. Order = sidebar order. + try @import("../workbench/plugin.zig").register(&editor.host); + try @import("../pixelart/plugin.zig").register(&editor.host); + + // Shell built-in: Settings (owner = null; not a plugin). + try editor.host.registerSidebarView(.{ + .id = view_settings, + .icon = dvui.entypo.cog, + .title = "Settings", + .draw = drawSettingsPane, + }); + + // Menu bar contributions (non-macOS in-app bar). The draw code still lives in + // the shell's `Menu.zig`; Phase 3 moves the File/Edit bodies into the workbench + // / pixel-art plugins, which will then self-register. Order = bar order. + try editor.host.registerMenu(.{ .id = "workbench.menu.file", .draw = Menu.drawFileMenu }); + try editor.host.registerMenu(.{ .id = "pixelart.menu.edit", .draw = Menu.drawEditMenu }); + try editor.host.registerMenu(.{ .id = "shell.menu.view", .draw = Menu.drawViewMenu }); + try editor.host.registerMenu(.{ .id = "shell.menu.help", .draw = Menu.drawHelpMenu }); + + // Keybind contributions: each plugin registers its own binds into the window's + // keybind map. The shell already registered its global/navigation/region binds + // in `Keybinds.register` (during `init`, before this runs), so the two halves + // are disjoint — no `putNoClobber` clash. Runs on all targets (web included). + const window = dvui.currentWindow(); + for (editor.host.plugins.items) |plugin| try plugin.contributeKeybinds(window); + // The workbench-api is the file explorer's programmatic surface and drives OS // file management (open/create/rename/delete/move on disk). The web build has - // no filesystem API, so the file explorer / workbench service is left out there - // for now. Keeping the registration behind a comptime gate also keeps the - // service's native-only fn bodies out of wasm analysis entirely (the codebase's - // dead-branch convention; see `web_main.zig`). - if (comptime builtin.target.cpu.arch == .wasm32) return; - editor.workbench.initService(editor); - try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api); + // no filesystem API, so the workbench *service* is left out there for now. + // Keeping it behind a comptime gate also keeps its native-only fn bodies out of + // wasm analysis entirely (the codebase's dead-branch convention; see + // `web_main.zig`). + if (comptime builtin.target.cpu.arch != .wasm32) { + editor.workbench.initService(editor); + try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api); + } +} + +fn drawSettingsPane(_: ?*anyopaque) anyerror!void { + try Explorer.settings.draw(); } /// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). @@ -1176,9 +1214,11 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } if (editor.panel.paned.showFirst()) { - const result = try editor.drawWorkspaces(0); - if (result != .ok) { - return result; + if (editor.host.activeCenter()) |center| { + const result = try center.draw(center.ctx); + if (result != .ok) { + return result; + } } } } else { @@ -1943,7 +1983,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { } editor.folder = try fizzy.app.allocator.dupe(u8, path); try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); - editor.explorer.pane = .files; + editor.host.setActiveSidebarView(@import("../workbench/plugin.zig").view_files); editor.project = Project.load(fizzy.app.allocator) catch null; editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); @@ -2329,7 +2369,7 @@ pub fn processPackJob(editor: *Editor) void { } fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp(); job.result_consumed = true; - editor.explorer.pane = .project; + editor.host.setActiveSidebarView(@import("../pixelart/plugin.zig").view_project); const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null; showPackToast("Project packed", toast_canvas); } else blk: { @@ -3255,13 +3295,17 @@ fn processPendingSaveAs(editor: *Editor) void { pub fn undo(editor: *Editor) !void { if (editor.activeFile()) |file| { - try file.history.undoRedo(file, .undo); + if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { + try plugin.undo(.{ .ptr = file, .owner = plugin, .id = file.id }); + } } } pub fn redo(editor: *Editor) !void { if (editor.activeFile()) |file| { - try file.history.undoRedo(file, .redo); + if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { + try plugin.redo(.{ .ptr = file, .owner = plugin, .id = file.id }); + } } } diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index cc66b279..0852fd4c 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -6,60 +6,29 @@ const dvui = @import("dvui"); pub const Keybinds = @This(); +/// Register the shell's own global / navigation / region binds. File-management +/// binds and pixel-art editing binds are contributed by the workbench and +/// pixel-art plugins (their `contributeKeybinds`), which `Editor.postInit` invokes +/// after the plugins register. This runs during `Editor.init`, before postInit, so +/// the shell binds land first; the split is disjoint, so no `putNoClobber` clashes. +/// +/// Runtime mac detection — `builtin.os.tag.isDarwin()` is `false` for +/// wasm32-freestanding, so macOS web users would otherwise get the Windows (Ctrl) +/// bindings. `fizzy.platform.isMacOS()` reads DVUI's `navigator.platform`-derived +/// choice on web and uses `os.tag` on native. pub fn register() !void { const window = dvui.currentWindow(); - // Runtime mac detection — `builtin.os.tag.isDarwin()` is `false` for - // wasm32-freestanding, so macOS web users would otherwise get the Windows - // (Ctrl) bindings. `fizzy.platform.isMacOS()` reads DVUI's `navigator.platform`- - // derived choice on web and uses `os.tag` on native. + // Region toggles (explorer / workspace) are platform-dependent. if (fizzy.platform.isMacOS()) { - try window.keybinds.putNoClobber(window.gpa, "open_folder", .{ .key = .f, .command = true }); - try window.keybinds.putNoClobber(window.gpa, "new_file", .{ .key = .n, .command = true }); - try window.keybinds.putNoClobber(window.gpa, "open_files", .{ .key = .o, .command = true }); - try window.keybinds.putNoClobber(window.gpa, "undo", .{ .key = .z, .command = true, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "redo", .{ .key = .z, .command = true, .shift = true }); - try window.keybinds.putNoClobber(window.gpa, "zoom", .{ .command = true }); - try window.keybinds.putNoClobber(window.gpa, "save", .{ .command = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "save_as", .{ .command = true, .shift = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "save_all", .{ .command = true, .alt = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "sample", .{ .control = true }); - try window.keybinds.putNoClobber(window.gpa, "transform", .{ .command = true, .key = .t }); - try window.keybinds.putNoClobber(window.gpa, "grid_layout", .{ .command = true, .key = .g }); try window.keybinds.putNoClobber(window.gpa, "explorer", .{ .command = true, .key = .e }); try window.keybinds.putNoClobber(window.gpa, "workspace", .{ .command = true, .key = .w }); - try window.keybinds.putNoClobber(window.gpa, "export", .{ .command = true, .key = .p }); - try window.keybinds.putNoClobber(window.gpa, "delete_selection_contents", .{ .key = .backspace }); } else { - try window.keybinds.putNoClobber(window.gpa, "open_folder", .{ .key = .f, .control = true }); - try window.keybinds.putNoClobber(window.gpa, "new_file", .{ .key = .n, .control = true }); - try window.keybinds.putNoClobber(window.gpa, "open_files", .{ .key = .o, .control = true }); - try window.keybinds.putNoClobber(window.gpa, "undo", .{ .key = .z, .control = true, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "redo", .{ .key = .z, .control = true, .shift = true }); - try window.keybinds.putNoClobber(window.gpa, "zoom", .{ .control = true }); - try window.keybinds.putNoClobber(window.gpa, "save", .{ .control = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "save_as", .{ .control = true, .shift = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "save_all", .{ .control = true, .alt = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "sample", .{ .alt = true }); - try window.keybinds.putNoClobber(window.gpa, "transform", .{ .control = true, .key = .t }); - try window.keybinds.putNoClobber(window.gpa, "grid_layout", .{ .control = true, .key = .g }); try window.keybinds.putNoClobber(window.gpa, "explorer", .{ .control = true, .key = .e }); try window.keybinds.putNoClobber(window.gpa, "workspace", .{ .control = true, .key = .w }); - try window.keybinds.putNoClobber(window.gpa, "export", .{ .control = true, .key = .p }); - try window.keybinds.putNoClobber(window.gpa, "delete_selection_contents", .{ .key = .delete }); } try window.keybinds.putNoClobber(window.gpa, "shift", .{ .shift = true }); - try window.keybinds.putNoClobber(window.gpa, "increase_stroke_size", .{ .key = .right_bracket }); - try window.keybinds.putNoClobber(window.gpa, "decrease_stroke_size", .{ .key = .left_bracket }); - - try window.keybinds.putNoClobber(window.gpa, "quick_tools", .{ .key = .space }); - - try window.keybinds.putNoClobber(window.gpa, "pencil", .{ .key = .d, .command = false, .control = false, .alt = false, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "eraser", .{ .key = .e, .command = false, .control = false, .alt = false, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "bucket", .{ .key = .b, .command = false, .control = false, .alt = false, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "selection", .{ .key = .s, .command = false, .control = false, .alt = false, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "pointer", .{ .key = .escape }); try window.keybinds.putNoClobber(window.gpa, "up", .{ .key = .up }); try window.keybinds.putNoClobber(window.gpa, "down", .{ .key = .down }); diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 47a3b99f..2445be5a 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -24,6 +24,19 @@ pub fn draw() !dvui.App.Result { dvui.themeSet(theme); } + // The shell owns only the menu bar container + theme; the top-level menus are + // plugin (and shell built-in) contributions, drawn in registration order. + for (fizzy.editor.host.menus.items) |*menu| { + menu.draw(menu.ctx) catch |err| { + dvui.log.err("Menu contribution failed: {any}", .{err}); + }; + } + + return .ok; +} + +/// File menu (workbench contribution). +pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { if (menuItem(@src(), "File", .{ .submenu = true }, .{ .expand = .horizontal, //.color_accent = dvui.themeGet().color(.window, .fill), @@ -160,7 +173,10 @@ pub fn draw() !dvui.App.Result { fw.close(); } } +} +/// Edit menu (pixel-art contribution). +pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { if (menuItem( @src(), "Edit", @@ -280,7 +296,10 @@ pub fn draw() !dvui.App.Result { } } } +} +/// View menu (shell built-in). +pub fn drawViewMenu(_: ?*anyopaque) anyerror!void { if (menuItem(@src(), "View", .{ .submenu = true }, .{ .expand = .horizontal, .color_text = dvui.themeGet().color(.control, .text), @@ -322,8 +341,11 @@ pub fn draw() !dvui.App.Result { fw.close(); } } +} - // Help — matches the macOS native Help menu so the two menubars stay congruent. +/// Help menu (shell built-in). Matches the macOS native Help menu so the two +/// menubars stay congruent. +pub fn drawHelpMenu(_: ?*anyopaque) anyerror!void { if (menuItem(@src(), "Help", .{ .submenu = true }, .{ .expand = .horizontal, .color_text = dvui.themeGet().color(.control, .text), @@ -354,8 +376,6 @@ pub fn draw() !dvui.App.Result { fw.close(); } } - - return .ok; } pub fn menuItemWithHotkey(src: std.builtin.SourceLocation, label_str: []const u8, hotkey: dvui.enums.Keybind, enabled: bool, init_opts: dvui.MenuItemWidget.InitOptions, opts: dvui.Options) ?dvui.Rect.Natural { diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig index d2cebba4..51dad7ea 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -5,7 +5,7 @@ const dvui = @import("dvui"); const App = fizzy.App; const Editor = fizzy.Editor; -const Pane = @import("explorer/Explorer.zig").Pane; +const SidebarView = fizzy.sdk.SidebarView; pub const Sidebar = @This(); @@ -32,28 +32,20 @@ pub fn draw(_: Sidebar) !Action { }); defer vbox.deinit(); - const options = [_]struct { pane: Pane, icon: []const u8 }{ - .{ .pane = .files, .icon = dvui.entypo.folder }, - .{ .pane = .tools, .icon = dvui.entypo.pencil }, - .{ .pane = .sprites, .icon = dvui.entypo.grid }, - //.{ .pane = .animations, .icon = dvui.entypo.controller_play }, - //.{ .pane = .keyframe_animations, .icon = dvui.entypo.key }, - .{ .pane = .project, .icon = dvui.entypo.box }, - .{ .pane = .settings, .icon = dvui.entypo.cog }, - }; - var ret: Action = .none; - for (options) |option| { - const a = try drawOption(option.pane, option.icon, 20); + // One icon per registered sidebar view (plugins contribute these; the shell + // owns none of them itself). Registration order is the display order. + for (fizzy.editor.host.sidebar_views.items, 0..) |*view, i| { + const a = try drawOption(view, i, 20); if (a != .none) ret = a; } return ret; } -fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { - const selected = option == fizzy.editor.explorer.pane; +fn drawOption(view: *const SidebarView, index: usize, size: f32) !Action { + const selected = fizzy.editor.host.isActiveSidebarView(view.id); var ret: Action = .none; const theme = dvui.themeGet(); @@ -61,7 +53,7 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { var bw: dvui.ButtonWidget = undefined; bw.init(@src(), .{}, .{ - .id_extra = @intFromEnum(option), + .id_extra = index, .min_size_content = .{ .h = size }, }); defer bw.deinit(); @@ -80,16 +72,17 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { dvui.icon( @src(), - @tagName(option), - icon, + view.id, + view.icon, .{ .fill_color = color }, .{ + .id_extra = index, .min_size_content = .{ .h = size }, }, ); if (bw.clicked()) { - // Tapping the icon for the pane that's already showing toggles the explorer + // Tapping the icon for the view that's already showing toggles the explorer // closed (same effect as the floating collapse button). We *report* the intent // here; Editor.zig invokes `peekClose` / `open` after `editor.explorer.paned` has // been recreated for this frame. Doing the call directly here would dereference @@ -98,7 +91,7 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { if (selected and explorer_visible) { ret = .close; } else { - fizzy.editor.explorer.pane = option; + fizzy.editor.host.setActiveSidebarView(view.id); ret = .open; } dvui.refresh(null, @src(), null); @@ -110,7 +103,7 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { .active_rect = bw.data().rectScale().r, .delay = 350_000, }, .{ - .id_extra = @intFromEnum(option), + .id_extra = index, .color_fill = dvui.themeGet().color(.window, .fill), .border = dvui.Rect.all(0), .box_shadow = .{ @@ -144,7 +137,8 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { .background = false, .padding = dvui.Rect.all(4), }); - tl2.format("{s}", .{fizzy.Editor.Explorer.title(option, true)}, .{ + const tip = std.ascii.allocUpperString(dvui.currentWindow().arena(), view.title) catch view.title; + tl2.format("{s}", .{tip}, .{ .font = dvui.Font.theme(.heading), }); tl2.deinit(); diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index a935c0b2..eeeacf9c 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -23,7 +23,6 @@ pub const settings = @import("settings.zig"); sprites: Sprites = .{}, tools: Tools = .{}, -pane: Pane = .files, paned: *fizzy.dvui.PanedWidget = undefined, scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto, @@ -43,16 +42,6 @@ closed: bool = false, peek_open: bool = false, collapse_btn_anim_started: bool = false, -pub const Pane = enum(u32) { - files, - tools, - sprites, - animations, - keyframe_animations, - project, - settings, -}; - pub fn init() Explorer { return .{ .open_branches = .init(fizzy.app.allocator), @@ -64,18 +53,6 @@ pub fn deinit(self: *Explorer) void { self.open_branches.deinit(); } -pub fn title(pane: Pane, all_caps: bool) []const u8 { - return switch (pane) { - .files => if (all_caps) "FILES" else "Files", - .tools => if (all_caps) "TOOLS" else "Tools", - .sprites => if (all_caps) "SPRITES" else "Sprites", - .animations => if (all_caps) "ANIMATIONS" else "Animations", - .keyframe_animations => if (all_caps) "KEYFRAME ANIMATIONS" else "Keyframe Animations", - .project => if (all_caps) "PROJECT" else "Project", - .settings => if (all_caps) "SETTINGS" else "Settings", - }; -} - pub fn close(explorer: *Explorer) void { explorer.paned.animateSplit(0.0, dvui.easing.outQuint); explorer.closed = true; @@ -136,7 +113,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { .background = false, }); - if (explorer.pane != .files) { + if (!fizzy.editor.host.isActiveSidebarView(@import("../../workbench/plugin.zig").view_files)) { fizzy.editor.file_tree_data_id = null; if (fizzy.editor.tab_drag_from_tree_path) |p| { fizzy.app.allocator.free(p); @@ -144,13 +121,8 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { } } - switch (explorer.pane) { - .files => try files.draw(), - .settings => try settings.draw(), - .project => try project.draw(), - .tools => try explorer.tools.draw(), - .sprites => try explorer.sprites.draw(), - else => {}, + if (fizzy.editor.host.activeSidebarView()) |view| { + try view.draw(view.ctx); } const vertical_scroll = scroll.si.offset(.vertical); @@ -269,8 +241,9 @@ pub fn hovered(explorer: *Explorer) bool { return fizzy.dvui.hovered(explorer.paned.data()); } -pub fn drawHeader(explorer: *Explorer) !void { - const header_title = title(explorer.pane, true); +pub fn drawHeader(_: *Explorer) !void { + const view = fizzy.editor.host.activeSidebarView() orelse return; + const header_title = std.ascii.allocUpperString(dvui.currentWindow().arena(), view.title) catch view.title; dvui.labelNoFmt(@src(), header_title, .{}, .{ .font = dvui.Font.theme(.heading) }); } diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig index bb82654d..e4f5496c 100644 --- a/src/editor/panel/Panel.zig +++ b/src/editor/panel/Panel.zig @@ -14,23 +14,18 @@ pub const Panel = @This(); pub const Sprites = @import("sprites.zig"); sprites: Sprites = .{}, -pane: Pane = .sprites, paned: *fizzy.dvui.PanedWidget = undefined, scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto, }, -pub const Pane = enum(u32) { - sprites, -}; - pub fn init() Panel { return .{}; } pub fn deinit(_: *Panel) void {} -pub fn draw(panel: *Panel) !dvui.App.Result { +pub fn draw(_: *Panel) !dvui.App.Result { // var scroll_area = dvui.scrollArea(@src(), .{ .scroll_info = &panel.scroll_info }, .{ // .expand = .both, // }); @@ -55,9 +50,35 @@ pub fn draw(panel: *Panel) !dvui.App.Result { }); defer vbox.deinit(); - switch (panel.pane) { - .sprites => try panel.sprites.draw(), + const host = &fizzy.editor.host; + + // Tab strip across registered bottom views; one active at a time. With a single + // view we skip the strip so the panel looks exactly as before (no lone tab). + if (host.bottom_views.items.len > 1) try drawTabStrip(host); + + if (host.activeBottomView()) |view| { + try view.draw(view.ctx); } return .ok; } + +fn drawTabStrip(host: *fizzy.Editor.Host) !void { + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .background = false, + }); + defer hbox.deinit(); + + const theme = dvui.themeGet(); + for (host.bottom_views.items, 0..) |*view, i| { + const selected = host.isActiveBottomView(view.id); + if (dvui.button(@src(), view.title, .{ .draw_focus = false }, .{ + .id_extra = i, + .style = if (selected) .highlight else .window, + .color_text = if (selected) theme.color(.highlight, .text) else theme.color(.window, .text), + })) { + host.setActiveBottomView(view.id); + } + } +} diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig index d24d5e3c..9bc4415a 100644 --- a/src/editor/widgets/FileWidget.zig +++ b/src/editor/widgets/FileWidget.zig @@ -1597,7 +1597,7 @@ pub fn drawSpriteBubble( self.init_options.file.collapseAnimationSelectionToPrimary(); self.init_options.file.editor.animations_scroll_to_index = anim_index; fizzy.editor.explorer.sprites.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index]; - fizzy.editor.explorer.pane = .sprites; + fizzy.editor.host.setActiveSidebarView(@import("../../pixelart/plugin.zig").view_sprites); var anim = self.init_options.file.animations.get(anim_index); if (anim.frames.len == 0) { @@ -4580,7 +4580,7 @@ pub fn drawLayers(self: *FileWidget) void { const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect); // Draw the origins when in the sprites pane - if (fizzy.editor.explorer.pane == .sprites) { + if (fizzy.editor.host.isActiveSidebarView(@import("../../pixelart/plugin.zig").view_sprites)) { const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] }; const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y }; diff --git a/src/internal/File.zig b/src/internal/File.zig index b03f789f..e78a9881 100644 --- a/src/internal/File.zig +++ b/src/internal/File.zig @@ -1,5 +1,6 @@ const std = @import("std"); const fizzy = @import("../fizzy.zig"); +const pixelart = @import("../pixelart/plugin.zig"); const zip = @import("zip"); const dvui = @import("dvui"); @@ -757,7 +758,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File return error.FileLoadError; } -fn isFlatImageExtension(ext: []const u8) bool { +pub fn isFlatImageExtension(ext: []const u8) bool { return std.mem.eql(u8, ext, ".png") or std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg"); @@ -2678,7 +2679,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: .dest_pixels_before = dest_pixels_before, .dest_mask_before = dest_mask_before, } }); - fizzy.editor.explorer.pane = .tools; + fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); } pub fn duplicateLayer(self: *File, index: usize) !u64 { diff --git a/src/internal/History.zig b/src/internal/History.zig index dc0efa2c..2e2cf362 100644 --- a/src/internal/History.zig +++ b/src/internal/History.zig @@ -1,5 +1,6 @@ const std = @import("std"); const fizzy = @import("../fizzy.zig"); +const pixelart = @import("../pixelart/plugin.zig"); const zgui = @import("zgui"); const History = @This(); const Editor = fizzy.Editor; @@ -387,7 +388,7 @@ fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { file.editor.layer_composite_dirty = true; file.editor.split_composite_dirty = true; file.selected_layer_index = lm.source_index; - fizzy.editor.explorer.pane = .tools; + fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); } @@ -429,7 +430,7 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { .up => dest_i, .down => dest_i - 1, }; - fizzy.editor.explorer.pane = .tools; + fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); } @@ -618,7 +619,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi //try file.editor.selected_sprites.append(sprite_index); } - fizzy.editor.explorer.pane = .sprites; + fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites); }, .layers_order => |*layers_order| { file.editor.layer_composite_dirty = true; @@ -673,7 +674,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi layer_restore_delete.action = .restore; }, } - fizzy.editor.explorer.pane = .tools; + fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); }, .layer_name => |*layer_name| { @@ -681,7 +682,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi fizzy.app.allocator.free(file.layers.items(.name)[layer_name.index]); file.layers.items(.name)[layer_name.index] = try fizzy.app.allocator.dupe(u8, layer_name.name); layer_name.name = name; - fizzy.editor.explorer.pane = .tools; + fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); }, .layer_settings => |*layer_settings| { const idx = layer_settings.index; @@ -700,7 +701,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (visibility_changed) { file.editor.split_composite_dirty = true; } - fizzy.editor.explorer.pane = .tools; + fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); }, .animation_restore_delete => |*animation_restore_delete| { const a = animation_restore_delete.action; @@ -726,14 +727,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi } }, } - fizzy.editor.explorer.pane = .sprites; + fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites); }, .animation_name => |*animation_name| { const name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[animation_name.index]); fizzy.app.allocator.free(file.animations.items(.name)[animation_name.index]); file.animations.items(.name)[animation_name.index] = try fizzy.app.allocator.dupe(u8, animation_name.name); animation_name.name = name; - fizzy.editor.explorer.pane = .sprites; + fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites); }, .animation_settings => {}, .animation_order => |*animation_order| { diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig new file mode 100644 index 00000000..d7c0cb20 --- /dev/null +++ b/src/pixelart/plugin.zig @@ -0,0 +1,143 @@ +//! The pixel-art editor plugin. Phase 2 thin shim — the pixel-art stack still +//! lives inline under `src/editor/` (Phase 3 relocates it whole behind this +//! plugin). For now its contributions point at the existing draw entry points +//! through the `fizzy.*` globals. Registered from `Editor.postInit`. +const std = @import("std"); +const fizzy = @import("../fizzy.zig"); +const dvui = @import("dvui"); +const sdk = fizzy.sdk; + +const DocHandle = sdk.DocHandle; +const Internal = fizzy.Internal; + +/// Stable contribution ids (plugin-namespaced) referenced across modules. +pub const view_tools = "pixelart.tools"; +pub const view_sprites = "pixelart.sprites"; +pub const view_project = "pixelart.project"; +pub const bottom_sprites = "pixelart.sprites_panel"; + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "pixelart", + .display_name = "Pixel Art", +}; + +const vtable: sdk.Plugin.VTable = .{ + .fileTypePriority = fileTypePriority, + .contributeKeybinds = contributeKeybinds, + .isDirty = isDirty, + .undo = undo, + .redo = redo, +}; + +/// A `DocHandle` whose `ptr` is one of this plugin's `*Internal.File`s. The shell +/// gets the owning plugin from the file-type registry and round-trips the document +/// back through these hooks, so it never needs to know the concrete pixel-art type. +fn docFile(doc: DocHandle) *Internal.File { + return @ptrCast(@alignCast(doc.ptr)); +} + +/// Priority for opening `ext` (lower wins). Pixel art owns its native `.fiz`/`.pixi` +/// and flat-image `.png`/`.jpg`/`.jpeg`; native formats win over flat images when +/// some future plugin also claims an image type. +fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 { + if (Internal.File.isFizzyExtension(ext)) return 0; + if (Internal.File.isFlatImageExtension(ext)) return 10; + return null; +} + +fn isDirty(_: *anyopaque, doc: DocHandle) bool { + return docFile(doc).dirty(); +} + +fn undo(_: *anyopaque, doc: DocHandle) anyerror!void { + const file = docFile(doc); + try file.history.undoRedo(file, .undo); +} + +fn redo(_: *anyopaque, doc: DocHandle) anyerror!void { + const file = docFile(doc); + try file.history.undoRedo(file, .redo); +} + +pub fn register(host: *sdk.Host) !void { + try host.registerPlugin(&plugin); + try host.registerSidebarView(.{ + .id = view_tools, + .owner = &plugin, + .icon = dvui.entypo.pencil, + .title = "Tools", + .draw = drawTools, + }); + try host.registerSidebarView(.{ + .id = view_sprites, + .owner = &plugin, + .icon = dvui.entypo.grid, + .title = "Sprites", + .draw = drawSprites, + }); + try host.registerSidebarView(.{ + .id = view_project, + .owner = &plugin, + .icon = dvui.entypo.box, + .title = "Project", + .draw = drawProject, + }); + try host.registerBottomView(.{ + .id = bottom_sprites, + .owner = &plugin, + .title = "Sprites", + .draw = drawSpritesPanel, + }); +} + +fn drawTools(_: ?*anyopaque) anyerror!void { + try fizzy.editor.explorer.tools.draw(); +} +fn drawSprites(_: ?*anyopaque) anyerror!void { + try fizzy.editor.explorer.sprites.draw(); +} +fn drawProject(_: ?*anyopaque) anyerror!void { + try fizzy.Editor.Explorer.project.draw(); +} +fn drawSpritesPanel(_: ?*anyopaque) anyerror!void { + try fizzy.editor.panel.sprites.draw(); +} + +/// Pixel-art editing + tool keybinds. The shell registers its own global/region +/// binds in `Keybinds.register`; this fills in the pixel-art half. Platform: see +/// `Keybinds.register` for why `fizzy.platform.isMacOS()` (not `builtin`) is used. +fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void { + if (fizzy.platform.isMacOS()) { + try win.keybinds.putNoClobber(win.gpa, "new_file", .{ .key = .n, .command = true }); + try win.keybinds.putNoClobber(win.gpa, "undo", .{ .key = .z, .command = true, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "redo", .{ .key = .z, .command = true, .shift = true }); + try win.keybinds.putNoClobber(win.gpa, "zoom", .{ .command = true }); + try win.keybinds.putNoClobber(win.gpa, "sample", .{ .control = true }); + try win.keybinds.putNoClobber(win.gpa, "transform", .{ .command = true, .key = .t }); + try win.keybinds.putNoClobber(win.gpa, "grid_layout", .{ .command = true, .key = .g }); + try win.keybinds.putNoClobber(win.gpa, "export", .{ .command = true, .key = .p }); + try win.keybinds.putNoClobber(win.gpa, "delete_selection_contents", .{ .key = .backspace }); + } else { + try win.keybinds.putNoClobber(win.gpa, "new_file", .{ .key = .n, .control = true }); + try win.keybinds.putNoClobber(win.gpa, "undo", .{ .key = .z, .control = true, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "redo", .{ .key = .z, .control = true, .shift = true }); + try win.keybinds.putNoClobber(win.gpa, "zoom", .{ .control = true }); + try win.keybinds.putNoClobber(win.gpa, "sample", .{ .alt = true }); + try win.keybinds.putNoClobber(win.gpa, "transform", .{ .control = true, .key = .t }); + try win.keybinds.putNoClobber(win.gpa, "grid_layout", .{ .control = true, .key = .g }); + try win.keybinds.putNoClobber(win.gpa, "export", .{ .control = true, .key = .p }); + try win.keybinds.putNoClobber(win.gpa, "delete_selection_contents", .{ .key = .delete }); + } + + try win.keybinds.putNoClobber(win.gpa, "increase_stroke_size", .{ .key = .right_bracket }); + try win.keybinds.putNoClobber(win.gpa, "decrease_stroke_size", .{ .key = .left_bracket }); + try win.keybinds.putNoClobber(win.gpa, "quick_tools", .{ .key = .space }); + + try win.keybinds.putNoClobber(win.gpa, "pencil", .{ .key = .d, .command = false, .control = false, .alt = false, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "eraser", .{ .key = .e, .command = false, .control = false, .alt = false, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "bucket", .{ .key = .b, .command = false, .control = false, .alt = false, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "selection", .{ .key = .s, .command = false, .control = false, .alt = false, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "pointer", .{ .key = .escape }); +} diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index e2cdc065..00bc7bb3 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -7,9 +7,15 @@ //! yet — the existing pixel-art code still uses globals directly. const std = @import("std"); const Plugin = @import("Plugin.zig"); +const regions = @import("regions.zig"); pub const Host = @This(); +pub const SidebarView = regions.SidebarView; +pub const BottomView = regions.BottomView; +pub const CenterProvider = regions.CenterProvider; +pub const MenuContribution = regions.MenuContribution; + allocator: std.mem.Allocator, /// All registered plugins (static today; runtime-loaded dylibs in Phase 4). @@ -20,6 +26,24 @@ plugins: std.ArrayListUnmanaged(*Plugin) = .empty, /// draw per-branch explorer decorations without a compile-time dependency on it. services: std.StringHashMapUnmanaged(*anyopaque) = .empty, +// ---- shell region registries (Phase 2) ------------------------------------- +// The shell iterates these instead of hardcoded enums/switches. Items keep their +// registration order, which is the order they appear in the UI. + +/// Left-region (explorer) views, one per sidebar icon. +sidebar_views: std.ArrayListUnmanaged(SidebarView) = .empty, +/// Bottom-panel views (shown as a tab strip). +bottom_views: std.ArrayListUnmanaged(BottomView) = .empty, +/// Center ("main window") providers; the active one draws the whole center. +center_providers: std.ArrayListUnmanaged(CenterProvider) = .empty, +/// Menubar contributions (non-macOS in-app menu bar). +menus: std.ArrayListUnmanaged(MenuContribution) = .empty, + +/// Active selection by contribution id (null = use the first registered). +active_sidebar_view: ?[]const u8 = null, +active_bottom_view: ?[]const u8 = null, +active_center: ?[]const u8 = null, + pub fn init(allocator: std.mem.Allocator) Host { return .{ .allocator = allocator }; } @@ -27,6 +51,10 @@ pub fn init(allocator: std.mem.Allocator) Host { pub fn deinit(self: *Host) void { self.plugins.deinit(self.allocator); self.services.deinit(self.allocator); + self.sidebar_views.deinit(self.allocator); + self.bottom_views.deinit(self.allocator); + self.center_providers.deinit(self.allocator); + self.menus.deinit(self.allocator); } pub fn registerPlugin(self: *Host, plugin: *Plugin) !void { @@ -41,6 +69,82 @@ pub fn getService(self: *Host, name: []const u8) ?*anyopaque { return self.services.get(name); } +// ---- region registration (called from a plugin's register / postInit) ------- + +pub fn registerSidebarView(self: *Host, view: SidebarView) !void { + try self.sidebar_views.append(self.allocator, view); + if (self.active_sidebar_view == null) self.active_sidebar_view = view.id; +} + +pub fn registerBottomView(self: *Host, view: BottomView) !void { + try self.bottom_views.append(self.allocator, view); + if (self.active_bottom_view == null) self.active_bottom_view = view.id; +} + +pub fn registerCenterProvider(self: *Host, provider: CenterProvider) !void { + try self.center_providers.append(self.allocator, provider); + if (self.active_center == null) self.active_center = provider.id; +} + +pub fn registerMenu(self: *Host, menu: MenuContribution) !void { + try self.menus.append(self.allocator, menu); +} + +// ---- active selection ------------------------------------------------------ + +pub fn setActiveSidebarView(self: *Host, id: []const u8) void { + self.active_sidebar_view = id; +} + +pub fn isActiveSidebarView(self: *Host, id: []const u8) bool { + const active = self.active_sidebar_view orelse return false; + return std.mem.eql(u8, active, id); +} + +/// The currently active sidebar view, or the first registered as a fallback. +pub fn activeSidebarView(self: *Host) ?*SidebarView { + if (self.active_sidebar_view) |id| { + for (self.sidebar_views.items) |*v| { + if (std.mem.eql(u8, v.id, id)) return v; + } + } + if (self.sidebar_views.items.len > 0) return &self.sidebar_views.items[0]; + return null; +} + +pub fn setActiveBottomView(self: *Host, id: []const u8) void { + self.active_bottom_view = id; +} + +pub fn isActiveBottomView(self: *Host, id: []const u8) bool { + const active = self.active_bottom_view orelse return false; + return std.mem.eql(u8, active, id); +} + +pub fn activeBottomView(self: *Host) ?*BottomView { + if (self.active_bottom_view) |id| { + for (self.bottom_views.items) |*v| { + if (std.mem.eql(u8, v.id, id)) return v; + } + } + if (self.bottom_views.items.len > 0) return &self.bottom_views.items[0]; + return null; +} + +pub fn setActiveCenter(self: *Host, id: []const u8) void { + self.active_center = id; +} + +pub fn activeCenter(self: *Host) ?*CenterProvider { + if (self.active_center) |id| { + for (self.center_providers.items) |*p| { + if (std.mem.eql(u8, p.id, id)) return p; + } + } + if (self.center_providers.items.len > 0) return &self.center_providers.items[0]; + return null; +} + /// The registered plugin with the highest priority (lowest value) for `ext`, or /// null if none claims it. Used in Phase 3 to route file opens to the right plugin. pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin { diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index d48b96f1..96b55b46 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -61,6 +61,24 @@ pub fn fileTypePriority(self: Plugin, ext: []const u8) ?u8 { return if (self.vtable.fileTypePriority) |f| f(self.state, ext) else null; } +pub fn contributeKeybinds(self: Plugin, win: *dvui.Window) !void { + if (self.vtable.contributeKeybinds) |f| try f(self.state, win); +} + +// ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ---- + +pub fn isDirty(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.isDirty) |f| f(self.state, doc) else false; +} + +pub fn undo(self: Plugin, doc: DocHandle) !void { + if (self.vtable.undo) |f| try f(self.state, doc); +} + +pub fn redo(self: Plugin, doc: DocHandle) !void { + if (self.vtable.redo) |f| try f(self.state, doc); +} + pub fn deinit(self: Plugin) void { if (self.vtable.deinit) |f| f(self.state); } diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig new file mode 100644 index 00000000..9b09b617 --- /dev/null +++ b/src/sdk/regions.zig @@ -0,0 +1,54 @@ +//! Shell region contributions. A plugin's `register(host)` imperatively adds as +//! many of these as it wants (multiple sidebar icons, bottom-panel views, center +//! providers, menubar entries). The near-empty shell owns no features of its own — +//! it just iterates these registries (see `Host`) and draws whatever plugins +//! contributed. Built-in shell items (e.g. Settings) register with `owner = null`. +//! +//! `ctx` is contribution-owned opaque state passed back to its `draw` fn (null for +//! contributions that reach through the `fizzy.*` globals directly). `id`s are +//! stable and plugin-namespaced (e.g. "pixelart.sprites") so selection state and +//! cross-plugin references survive without a compile-time dependency. +const dvui = @import("dvui"); +const Plugin = @import("Plugin.zig"); + +/// A left-region (explorer) view, selected by its sidebar icon. Exactly one +/// sidebar view is active at a time; its `draw` fills the left pane. +pub const SidebarView = struct { + id: []const u8, + owner: ?*Plugin = null, + /// Icon byte slice (tvg/entypo) shown in the sidebar rail. + icon: []const u8, + /// User-facing title (sidebar tooltip + pane header). + title: []const u8, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, +}; + +/// A bottom-panel view. The panel shows a tab strip across all registered views; +/// the active one's `draw` fills the panel body. +pub const BottomView = struct { + id: []const u8, + owner: ?*Plugin = null, + title: []const u8, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, +}; + +/// A center ("main window") provider. The active provider draws the ENTIRE center +/// region and may render a single view or its own recursive tabs/splits. The +/// workbench registers one (its tabs/splits + canvas); others may take over. +pub const CenterProvider = struct { + id: []const u8, + owner: ?*Plugin = null, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!dvui.App.Result, +}; + +/// A menubar contribution. Its `draw` adds top-level menu(s) to the in-app menu +/// bar (non-macOS). A plugin may register several. +pub const MenuContribution = struct { + id: []const u8, + owner: ?*Plugin = null, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, +}; diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index 6e8940aa..8390a064 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -7,3 +7,10 @@ pub const Host = @import("Host.zig"); pub const Plugin = @import("Plugin.zig"); pub const DocHandle = @import("DocHandle.zig"); + +/// Shell region contribution types (sidebar / bottom / center / menu). +pub const regions = @import("regions.zig"); +pub const SidebarView = regions.SidebarView; +pub const BottomView = regions.BottomView; +pub const CenterProvider = regions.CenterProvider; +pub const MenuContribution = regions.MenuContribution; diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig index 3b942cf7..b85b1226 100644 --- a/src/workbench/Workspace.zig +++ b/src/workbench/Workspace.zig @@ -119,7 +119,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result { } } - if (fizzy.editor.explorer.pane == .project) { + if (fizzy.editor.host.isActiveSidebarView(@import("../pixelart/plugin.zig").view_project)) { self.drawProject(); } else { self.drawTabs(); diff --git a/src/workbench/plugin.zig b/src/workbench/plugin.zig new file mode 100644 index 00000000..8fb6afdd --- /dev/null +++ b/src/workbench/plugin.zig @@ -0,0 +1,68 @@ +//! The workbench plugin: file management. Phase 2 thin shim — its contributions +//! point at the existing draw entry points through the `fizzy.*` globals rather +//! than owning new code. Later phases move more behind it until it becomes a +//! runtime-loaded dylib. Registered from `Editor.postInit`. +const std = @import("std"); +const fizzy = @import("../fizzy.zig"); +const dvui = @import("dvui"); +const sdk = fizzy.sdk; +const files = @import("files.zig"); + +/// Stable contribution ids (plugin-namespaced) referenced across modules. +pub const view_files = "workbench.files"; +pub const center_workspaces = "workbench.workspaces"; + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "workbench", + .display_name = "Workbench", +}; + +const vtable: sdk.Plugin.VTable = .{ + .contributeKeybinds = contributeKeybinds, +}; + +pub fn register(host: *sdk.Host) !void { + try host.registerPlugin(&plugin); + try host.registerSidebarView(.{ + .id = view_files, + .owner = &plugin, + .icon = dvui.entypo.folder, + .title = "Files", + .draw = drawFiles, + }); + // The workbench owns the center "main window": the tabs/splits layout + canvas. + try host.registerCenterProvider(.{ + .id = center_workspaces, + .owner = &plugin, + .draw = drawCenter, + }); +} + +fn drawFiles(_: ?*anyopaque) anyerror!void { + try files.draw(); +} + +fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result { + return fizzy.editor.drawWorkspaces(0); +} + +/// File-management keybinds (open / save). The shell registers its own +/// global/region binds in `Keybinds.register`; this fills in the file half. +/// Platform: see `Keybinds.register` for why `fizzy.platform.isMacOS()` is used. +fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void { + if (fizzy.platform.isMacOS()) { + try win.keybinds.putNoClobber(win.gpa, "open_folder", .{ .key = .f, .command = true }); + try win.keybinds.putNoClobber(win.gpa, "open_files", .{ .key = .o, .command = true }); + try win.keybinds.putNoClobber(win.gpa, "save", .{ .command = true, .key = .s }); + try win.keybinds.putNoClobber(win.gpa, "save_as", .{ .command = true, .shift = true, .key = .s }); + try win.keybinds.putNoClobber(win.gpa, "save_all", .{ .command = true, .alt = true, .key = .s }); + } else { + try win.keybinds.putNoClobber(win.gpa, "open_folder", .{ .key = .f, .control = true }); + try win.keybinds.putNoClobber(win.gpa, "open_files", .{ .key = .o, .control = true }); + try win.keybinds.putNoClobber(win.gpa, "save", .{ .control = true, .key = .s }); + try win.keybinds.putNoClobber(win.gpa, "save_as", .{ .control = true, .shift = true, .key = .s }); + try win.keybinds.putNoClobber(win.gpa, "save_all", .{ .control = true, .alt = true, .key = .s }); + } +} From 468d36fa142ae40c6496c2309648e60c46adb6c7 Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 17 Jun 2026 20:42:46 -0500 Subject: [PATCH 06/49] Phase 3a --- src/editor/Editor.zig | 43 ++++++++++++++++++++++----- src/pixelart/plugin.zig | 34 +++++++++++++++++++++ src/sdk/Plugin.zig | 56 +++++++++++++++++++++++++++++------ src/workbench/FileLoadJob.zig | 32 +++++++++++++------- 4 files changed, 137 insertions(+), 28 deletions(-) diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 0e319873..68689d3f 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2044,8 +2044,16 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { return false; } + // Resolve the owning plugin from the file-type registry before spawning. No owner + // means no plugin claims this extension — reject here rather than spawning a worker + // that would only fail with InvalidFile. + const owner = editor.host.pluginForExtension(std.fs.path.extension(path)) orelse { + dvui.log.warn("No plugin handles file: {s}", .{path}); + return false; + }; + // Spawn a worker. The job owns the path string we'll key the map by. - const job = try FileLoadJob.create(fizzy.app.allocator, path, grouping); + const job = try FileLoadJob.create(fizzy.app.allocator, path, owner, grouping); errdefer job.destroy(); try editor.loading_jobs.put(fizzy.app.allocator, job.path, job); @@ -2082,14 +2090,20 @@ pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, groupin } } - const loaded = fizzy.Internal.File.fromBytes(path, bytes) catch |err| { + const owner = editor.host.pluginForExtension(std.fs.path.extension(path)) orelse { + fizzy.app.allocator.free(path); + return error.InvalidExtension; + }; + + var file: fizzy.Internal.File = undefined; + const handled = owner.loadDocumentFromBytes(path, bytes, &file) catch |err| { fizzy.app.allocator.free(path); return err; }; - var file = loaded orelse { + if (!handled) { fizzy.app.allocator.free(path); return error.InvalidFile; - }; + } file.editor.grouping = grouping; return file; } @@ -3105,7 +3119,9 @@ pub fn save(editor: *Editor) !void { editor.requestWebSaveDialog(.save); return; } - try file.saveAsync(); + if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { + try plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id }); + } } /// Browser: pick download filename/extension before encoding (`processPendingSaveAs`). @@ -3125,7 +3141,8 @@ pub fn saveAll(editor: *Editor) !void { if (!file.dirty()) continue; if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) continue; if (file.shouldConfirmFlatRasterSave()) continue; - file.saveAsync() catch |err| { + const plugin = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse continue; + plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id }) catch |err| { dvui.log.err("Save All: file {s} failed: {s}", .{ file.path, @errorName(err) }); }; } @@ -3332,6 +3349,16 @@ pub fn closeFile(editor: *Editor, index: usize) !void { try editor.closeFileID(file.id); } +/// Tear down a file's resources via its owning plugin, falling back to a direct +/// `deinit` when no plugin claims the extension. The shell still owns removing the +/// entry from `open_files`; this only releases the document's own resources. +fn closeDocumentResources(editor: *Editor, file: *fizzy.Internal.File) void { + if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { + if (plugin.closeDocument(.{ .ptr = file, .owner = plugin, .id = file.id })) return; + } + file.deinit(); +} + pub fn rawCloseFile(editor: *Editor, index: usize) !void { //editor.open_file_index = 0; var file = editor.open_files.values()[index]; @@ -3347,7 +3374,7 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void { } } - file.deinit(); + editor.closeDocumentResources(&file); editor.open_files.orderedRemoveAt(index); } @@ -3365,7 +3392,7 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void { } } } - file.deinit(); + editor.closeDocumentResources(file); _ = editor.open_files.orderedRemove(id); } } diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig index d7c0cb20..b69c2fbb 100644 --- a/src/pixelart/plugin.zig +++ b/src/pixelart/plugin.zig @@ -3,6 +3,7 @@ //! plugin). For now its contributions point at the existing draw entry points //! through the `fizzy.*` globals. Registered from `Editor.postInit`. const std = @import("std"); +const builtin = @import("builtin"); const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const sdk = fizzy.sdk; @@ -26,7 +27,11 @@ var plugin: sdk.Plugin = .{ const vtable: sdk.Plugin.VTable = .{ .fileTypePriority = fileTypePriority, .contributeKeybinds = contributeKeybinds, + .loadDocument = loadDocument, + .loadDocumentFromBytes = loadDocumentFromBytes, .isDirty = isDirty, + .saveDocument = saveDocument, + .closeDocument = closeDocument, .undo = undo, .redo = redo, }; @@ -47,10 +52,39 @@ fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 { return null; } +/// Load `path` into the shell-owned `*Internal.File` at `out_doc`. Runs on the shell's +/// load worker thread; `File.fromPath` is the pixel-art loader (still resident in the +/// editor tree, relocated whole into this plugin in Phase 3b/3c). +fn loadDocument(_: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void { + // Web loads via bytes only (`loadDocumentFromBytes`); the comptime guard keeps the + // disk-reading `File.fromPath` path (Dir.cwd / posix.AT) out of the wasm binary. + if (comptime builtin.target.cpu.arch == .wasm32) return error.Unsupported; + const file = try Internal.File.fromPath(path) orelse return error.InvalidFile; + @as(*Internal.File, @ptrCast(@alignCast(out_doc))).* = file; +} + +/// As `loadDocument`, from in-memory bytes (browser file picker; synchronous). +fn loadDocumentFromBytes(_: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void { + const file = try Internal.File.fromBytes(path, bytes) orelse return error.InvalidFile; + @as(*Internal.File, @ptrCast(@alignCast(out_doc))).* = file; +} + fn isDirty(_: *anyopaque, doc: DocHandle) bool { return docFile(doc).dirty(); } +/// Persist the document. The shell handles the Save-As / flat-raster / web-download +/// policy before routing here; this just runs the pixel-art async save. +fn saveDocument(_: *anyopaque, doc: DocHandle) anyerror!void { + try docFile(doc).saveAsync(); +} + +/// Release the document's resources. The shell removes it from `open_files` and +/// fixes up the active-tab index; this just frees the pixel-art `File`. +fn closeDocument(_: *anyopaque, doc: DocHandle) void { + docFile(doc).deinit(); +} + fn undo(_: *anyopaque, doc: DocHandle) anyerror!void { const file = docFile(doc); try file.history.undoRedo(file, .undo); diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index 96b55b46..dc1836fc 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -1,15 +1,12 @@ //! A feature module that plugs into the editor shell. Today plugins are compiled //! in and registered statically; the same vtable shape is what a prebuilt plugin -//! dylib will expose at runtime (validated in `spikes/shared-globals/`). All hooks -//! are optional function pointers taking the plugin's own opaque `state`, so a -//! plugin implements only what it needs (e.g. the workbench plugin has no -//! `drawDocument`; an editor plugin does). +//! dylib will expose at runtime. All hooks are optional function pointers taking +//! the plugin's own opaque `state`, so a plugin implements only what it needs +//! (e.g. the workbench plugin has no `drawDocument`; an editor plugin does). //! //! Cross-boundary types may be normal Zig types (not strict C-ABI): host and //! plugins are pinned to the same SDK build, so layouts match. Only the dlopen -//! entry symbols (added in Phase 4) need `callconv(.c)`. -//! -//! Phase 0: type definition only; nothing constructs or calls plugins yet. +//! entry symbols need `callconv(.c)`. const std = @import("std"); const dvui = @import("dvui"); const DocHandle = @import("DocHandle.zig"); @@ -31,11 +28,18 @@ pub const VTable = struct { /// Priority for opening files with extension `ext` (including the dot, e.g. /// ".fiz"); lower value wins. `null` = this plugin does not handle `ext`. - /// Mirrors dvui-editor's fileTypePriority. A plugin may claim many extensions. + /// A plugin may claim many extensions. fileTypePriority: ?*const fn (state: *anyopaque, ext: []const u8) ?u8 = null, // ---- document lifecycle (operates on the plugin's own type via DocHandle) ---- - openDocument: ?*const fn (state: *anyopaque, path: []const u8) anyerror!DocHandle = null, + /// Load the document at `path`, constructing the plugin's own document value in + /// place at `out_doc`. The shell owns the typed buffer behind `out_doc` (for pixel + /// art a `*Internal.File`); the SDK stays type-agnostic. Runs on the shell's load + /// worker thread, so it must only touch the host allocator + the given buffer. + loadDocument: ?*const fn (state: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void = null, + /// `loadDocument`, but from in-memory bytes (browser file picker). `path` is used + /// for extension detection + display name. Synchronous (web has no load worker). + loadDocumentFromBytes: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void = null, saveDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, closeDocument: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, isDirty: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, @@ -67,10 +71,44 @@ pub fn contributeKeybinds(self: Plugin, win: *dvui.Window) !void { // ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ---- +/// Load `path` into the shell-owned buffer at `out_doc`. Returns whether the plugin +/// handled it; `false` means this plugin exposes no loader (the shell should treat the +/// open as failed). See the `loadDocument` vtable field for the threading contract. +pub fn loadDocument(self: Plugin, path: []const u8, out_doc: *anyopaque) !bool { + if (self.vtable.loadDocument) |f| { + try f(self.state, path, out_doc); + return true; + } + return false; +} + +/// `loadDocument`, but from in-memory `bytes` (browser file picker). +pub fn loadDocumentFromBytes(self: Plugin, path: []const u8, bytes: []const u8, out_doc: *anyopaque) !bool { + if (self.vtable.loadDocumentFromBytes) |f| { + try f(self.state, path, bytes, out_doc); + return true; + } + return false; +} + pub fn isDirty(self: Plugin, doc: DocHandle) bool { return if (self.vtable.isDirty) |f| f(self.state, doc) else false; } +pub fn saveDocument(self: Plugin, doc: DocHandle) !void { + if (self.vtable.saveDocument) |f| try f(self.state, doc); +} + +/// Tear down an open document. Returns whether the plugin handled it, so the shell +/// can fall back to its own teardown when no plugin claims the document. +pub fn closeDocument(self: Plugin, doc: DocHandle) bool { + if (self.vtable.closeDocument) |f| { + f(self.state, doc); + return true; + } + return false; +} + pub fn undo(self: Plugin, doc: DocHandle) !void { if (self.vtable.undo) |f| try f(self.state, doc); } diff --git a/src/workbench/FileLoadJob.zig b/src/workbench/FileLoadJob.zig index 22b06b50..ef7119cd 100644 --- a/src/workbench/FileLoadJob.zig +++ b/src/workbench/FileLoadJob.zig @@ -1,10 +1,11 @@ -//! Background file-load job. Owns a worker thread that runs `Internal.File.fromPath` off the -//! main thread so large files don't stall the editor. The main thread polls `done` each frame -//! via `Editor.processLoadingJobs`; once true, the result is moved into `editor.open_files`. +//! Background file-load job. Owns a worker thread that runs the owning plugin's loader +//! (`owner.loadDocument`) off the main thread so large files don't stall the editor. The +//! main thread polls `done` each frame via `Editor.processLoadingJobs`; once true, the +//! result is moved into `editor.open_files`. //! -//! Cancellation is best-effort: `Internal.File.fromPath` is monolithic, so we can only -//! observe cancellation AFTER it returns. The worker checks the flag, frees the loaded file -//! if cancelled, and exits. +//! Cancellation is best-effort: the plugin loader is monolithic, so we can only observe +//! cancellation AFTER it returns. The worker checks the flag, frees the loaded file if +//! cancelled, and exits. //! //! Ownership / threading model: //! - `path` is owned by the job, freed in `destroy()`. @@ -33,6 +34,11 @@ allocator: std.mem.Allocator, /// Absolute path. Owned by this job. path: []u8, +/// Plugin that owns this file's extension (resolved on the main thread before spawn). +/// The worker routes the load through `owner.loadDocument` instead of hardcoding the +/// pixel-art loader, so open is decoupled from any one editor plugin. +owner: *fizzy.sdk.Plugin, + /// Workspace grouping the file should land in once loaded. target_grouping: u64, @@ -66,7 +72,7 @@ result: ?fizzy.Internal.File = null, /// Filled by worker iff load failed. Safe to read after `done.load(.acquire)`. err: ?anyerror = null, -pub fn create(allocator: std.mem.Allocator, path: []const u8, target_grouping: u64) !*FileLoadJob { +pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk.Plugin, target_grouping: u64) !*FileLoadJob { const path_copy = try allocator.dupe(u8, path); errdefer allocator.free(path_copy); @@ -74,6 +80,7 @@ pub fn create(allocator: std.mem.Allocator, path: []const u8, target_grouping: u job.* = .{ .allocator = allocator, .path = path_copy, + .owner = owner, .target_grouping = target_grouping, .window = dvui.currentWindow(), .started_at_ns = perf.nanoTimestamp(), @@ -107,17 +114,20 @@ pub fn workerMain(job: *FileLoadJob) void { job.phase.store(@intFromEnum(Phase.reading), .release); - const maybe_file = fizzy.Internal.File.fromPath(job.path) catch |e| { + // Route the actual load through the owning plugin (filled into a stack buffer the + // shell owns; the plugin knows its concrete document type). Mirrors the inline-value + // model below — no heap handoff. + var file: fizzy.Internal.File = undefined; + const handled = job.owner.loadDocument(job.path, &file) catch |e| { job.err = e; job.phase.store(@intFromEnum(Phase.failed), .release); return; }; - - const file = maybe_file orelse { + if (!handled) { job.err = error.InvalidFile; job.phase.store(@intFromEnum(Phase.failed), .release); return; - }; + } // Cancellation check post-load: if the user closed the tab / quit while we were loading, // discard the file rather than publishing it. From 1105e29660a1b2a29e1a2171133334cf2976735a Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 08:05:01 -0500 Subject: [PATCH 07/49] Phase 3b --- src/editor/dialogs/GridLayout.zig | 5 +- src/editor/widgets/CanvasBridge.zig | 23 +++++++++ src/editor/widgets/CanvasWidget.zig | 75 +++++++++++++++++++---------- src/editor/widgets/FileWidget.zig | 34 +++++++++++++ src/editor/widgets/ImageWidget.zig | 3 ++ 5 files changed, 113 insertions(+), 27 deletions(-) create mode 100644 src/editor/widgets/CanvasBridge.zig diff --git a/src/editor/dialogs/GridLayout.zig b/src/editor/dialogs/GridLayout.zig index bb55014e..94e09510 100644 --- a/src/editor/dialogs/GridLayout.zig +++ b/src/editor/dialogs/GridLayout.zig @@ -12,6 +12,7 @@ const std = @import("std"); const NewFile = @import("NewFile.zig"); const CanvasWidget = @import("../widgets/CanvasWidget.zig"); +const CanvasBridge = @import("../widgets/CanvasBridge.zig"); const FloatingWindowWidget = @import("../widgets/FloatingWindowWidget.zig"); const builtin = @import("builtin"); @@ -108,7 +109,7 @@ pub fn presetFromFile(file: *fizzy.Internal.File) void { // `prev_size` matches `data_size` and `second_center` is false, so `install` skips the // rescale/recenter pass and the preview ends up offscreen / at a stale zoom. Resetting to // a fresh widget forces a fit-to-pane on the next frame. - preview_canvas = .{ .pointer_scope = .dialog }; + preview_canvas = .{}; left_scroll = .{ .horizontal = .auto }; dialog_middle_scroll = .{ .horizontal = .auto, .vertical = .auto }; preview_pane_fit_w = 0; @@ -594,6 +595,8 @@ fn renderPreview( .id = dlg_id.update("glp_cv"), .data_size = .{ .w = @floatFromInt(nw), .h = @floatFromInt(nh) }, .center = false, + .pan_zoom_scheme = CanvasBridge.scheme(), + .hooks = .{ .pointerInputSuppressed = CanvasBridge.dialogSuppressed }, }, .{ .expand = .both, .background = true, diff --git a/src/editor/widgets/CanvasBridge.zig b/src/editor/widgets/CanvasBridge.zig new file mode 100644 index 00000000..4b1cf339 --- /dev/null +++ b/src/editor/widgets/CanvasBridge.zig @@ -0,0 +1,23 @@ +//! Bridges the decoupled `CanvasWidget` back to editor/app globals. The canvas takes the +//! pan/zoom scheme as config and input-suppression as a hook so it stays a reusable +//! viewport; these helpers supply the pixel-art editor's wiring at the install sites. +const fizzy = @import("../../fizzy.zig"); +const CanvasWidget = @import("CanvasWidget.zig"); + +/// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum. +pub fn scheme() CanvasWidget.PanZoomScheme { + return switch (fizzy.Editor.Settings.resolvedPanZoomScheme(&fizzy.editor.settings)) { + .mouse => .mouse, + .trackpad => .trackpad, + }; +} + +/// Suppression hook for a main-scope canvas (the document editing surface, image previews). +pub fn mainSuppressed(_: ?*anyopaque) bool { + return fizzy.dvui.canvasPointerInputSuppressed(); +} + +/// Suppression hook for a dialog-scope canvas (embedded previews like Grid Layout). +pub fn dialogSuppressed(_: ?*anyopaque) bool { + return fizzy.dvui.dialogCanvasPointerInputSuppressed(); +} diff --git a/src/editor/widgets/CanvasWidget.zig b/src/editor/widgets/CanvasWidget.zig index c478bbce..48ae84bd 100644 --- a/src/editor/widgets/CanvasWidget.zig +++ b/src/editor/widgets/CanvasWidget.zig @@ -74,8 +74,6 @@ fade_pending: bool = false, // Saved between `install` and `deinit` so the parent alpha is restored exactly. prev_alpha: f32 = 1.0, hovered: bool = false, -/// `.dialog` for embedded previews (Grid Layout); uses `dialogCanvasPointerInputSuppressed`. -pointer_scope: enum { main, dialog } = .main, // Last frame's scroll viewport in physical pixels (latched in `deinit`). Used when the // scroll container is not installed yet this frame (e.g. UI chrome before `FileWidget`). sample_viewport_physical: ?dvui.Rect.Physical = null, @@ -253,10 +251,38 @@ pub fn trackpadPinching(self: *const CanvasWidget) bool { return (dvui.currentWindow().frame_time_ns - self.trackpad_pinch_last_ns) < window_ns; } +/// How wheel/scroll input maps to pan vs. zoom. The owner resolves its own user +/// preference (mouse vs. trackpad) and passes the result; the canvas stays unaware of +/// any settings system. +pub const PanZoomScheme = enum { mouse, trackpad }; + +/// Owner-supplied reactions to viewport gestures the canvas itself has no opinion about. +/// Every field is optional: a plain pan/zoom viewport (e.g. an image preview) supplies +/// none, while an editor supplies hooks that act on its own document/tool state. `ctx` is +/// passed back to each callback so a plugin can reach its state without globals. +pub const Hooks = struct { + ctx: ?*anyopaque = null, + /// An off-artboard press that released without moving or holding (a "tap" on empty + /// space). Pixel art uses this to clear the current selection. + onEmptyTap: ?*const fn (ctx: ?*anyopaque) void = null, + /// An off-artboard press held in place past the hold-menu duration. Pixel art opens + /// its radial tool menu at `press_p`. + onEmptyHold: ?*const fn (ctx: ?*anyopaque, press_p: dvui.Point.Physical) void = null, + /// Whether a modified (ctrl/cmd or shift) off-artboard press should be yielded to the + /// owner instead of starting a viewport pan. Pixel art yields it to the selection + /// marquee when the pointer tool is active. + yieldModifiedEmptyPress: ?*const fn (ctx: ?*anyopaque) bool = null, + /// Whether pointer input to this canvas is currently suppressed (e.g. a modal overlay + /// owns input this frame). Replaces the old built-in main/dialog scope switch. + pointerInputSuppressed: ?*const fn (ctx: ?*anyopaque) bool = null, +}; + pub const InitOptions = struct { id: dvui.Id, data_size: dvui.Size, center: bool = false, + pan_zoom_scheme: PanZoomScheme = .mouse, + hooks: Hooks = .{}, }; pub fn recenter(self: *CanvasWidget) void { @@ -695,10 +721,10 @@ pub fn updateTouchGesture(self: *CanvasWidget) void { dvui.captureMouse(null, 0); } - // Quick off-artboard tap: finger lifted during the eval window. Resolve as - // clear-selection here so we never arm hold state from the replayed press. + // Quick off-artboard tap: finger lifted during the eval window. Hand it to the + // owner (pixel art clears selection) so we never arm hold state from the replayed press. if (released and !self.pointerOverDrawable(press_p)) { - fizzy.editor.cancel() catch {}; + if (self.init_opts.hooks.onEmptyTap) |f| f(self.init_opts.hooks.ctx); } // `addEventPointer` uses `win.mouse_pt` for the event position. Push the press @@ -906,10 +932,8 @@ pub fn mouse(self: *CanvasWidget) ?dvui.Event.Mouse { } fn pointerInputSuppressed(self: *const CanvasWidget) bool { - return switch (self.pointer_scope) { - .main => fizzy.dvui.canvasPointerInputSuppressed(), - .dialog => fizzy.dvui.dialogCanvasPointerInputSuppressed(), - }; + const hooks = self.init_opts.hooks; + return if (hooks.pointerInputSuppressed) |f| f(hooks.ctx) else false; } pub fn processEvents(self: *CanvasWidget) void { @@ -1042,15 +1066,19 @@ pub fn processEvents(self: *CanvasWidget) void { // same scrub-the-viewport feel as the middle-button pan. // // Exception: a left/touch off-artboard press holding ctrl/cmd (add) - // or shift (subtract) while the pointer tool is active belongs to the - // sprite-selection marquee — it already claimed the press earlier in - // FileWidget.processSpriteSelection. Yielding it here keeps our + // or shift (subtract) that the owner wants to claim (pixel art: the + // sprite-selection marquee, which already claimed the press earlier in + // FileWidget.processSpriteSelection). Yielding it here keeps our // `dragPreStart("scroll_drag")` from clobbering the marquee's drag, so // the hotkey draws a selection box instead of panning. Middle-button // pans are never affected. + const owner_yields = if (self.init_opts.hooks.yieldModifiedEmptyPress) |f| + f(self.init_opts.hooks.ctx) + else + false; const sel_marquee_press = me.button.pointer() and me.button != .middle and (me.mod.matchBind("ctrl/cmd") or me.mod.matchBind("shift")) and - fizzy.editor.tools.current == .pointer; + owner_yields; if (me.action == .press and !sel_marquee_press and (me.button == .middle or (me.button.pointer() and !self.pointerOverDrawable(me.p)))) { e.handle(@src(), self.scroll_container.data()); dvui.captureMouse(self.scroll_container.data(), e.num); @@ -1114,7 +1142,7 @@ pub fn processEvents(self: *CanvasWidget) void { } } } else if (me.action == .wheel_y or me.action == .wheel_x) { - switch (fizzy.Editor.Settings.resolvedPanZoomScheme(&fizzy.editor.settings)) { + switch (self.init_opts.pan_zoom_scheme) { .mouse => { const base: f32 = if (me.mod.matchBind("shift")) 1.005 else 1.005; if ((me.mod.matchBind("shift") and me.mod.matchBind("ctrl/cmd")) or !me.mod.matchBind("shift") and !me.mod.matchBind("ctrl/cmd")) { @@ -1182,20 +1210,15 @@ pub fn processEvents(self: *CanvasWidget) void { switch (self.empty) { .pending => { if (!still_down) { - // Lifted without moving or holding → a tap: clear the selection. - fizzy.editor.cancel() catch {}; + // Lifted without moving or holding → a tap: hand to the owner (pixel + // art clears the selection). + if (self.init_opts.hooks.onEmptyTap) |f| f(self.init_opts.hooks.ctx); self.empty = .idle; } else if (dvui.frameTimeNS() - self.empty_press_ns >= dvui.currentWindow().hold_menu_duration_ns) { - // Held in place past the hold duration → open the radial tool menu and - // release our capture so its buttons can be hovered. Editor keeps it - // open until a tool is chosen or the user taps outside. - const rm = &fizzy.editor.tools.radial_menu; - rm.mouse_position = self.empty_press_p; - rm.center = self.empty_press_p; - rm.visible = true; - rm.opened_by_press = true; - rm.suppress_next_pointer_release = true; - rm.outside_click_press_p = null; + // Held in place past the hold duration → tell the owner (pixel art opens + // its radial tool menu at the press point) and release our capture so its + // buttons can be hovered. + if (self.init_opts.hooks.onEmptyHold) |f| f(self.init_opts.hooks.ctx, self.empty_press_p); self.empty = .holding; if (dvui.captured(self.scroll_container.data().id)) { dvui.captureMouse(null, 0); diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig index 9bc4415a..60187f34 100644 --- a/src/editor/widgets/FileWidget.zig +++ b/src/editor/widgets/FileWidget.zig @@ -17,9 +17,36 @@ const ScaleWidget = dvui.ScaleWidget; pub const FileWidget = @This(); const CanvasWidget = @import("CanvasWidget.zig"); +const CanvasBridge = @import("CanvasBridge.zig"); const Workspace = fizzy.Editor.Workspace; const icons = @import("icons"); +// ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is +// otherwise a generic viewport; these supply the editor's behavior at install time. ---- + +/// Off-artboard tap (no move, no hold) → clear the current selection. +fn onEmptyTap(_: ?*anyopaque) void { + fizzy.editor.cancel() catch {}; +} + +/// Off-artboard hold past the hold-menu duration → open the radial tool menu at the press +/// point. The canvas releases its own capture afterward so the menu buttons can be hovered. +fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void { + const rm = &fizzy.editor.tools.radial_menu; + rm.mouse_position = press_p; + rm.center = press_p; + rm.visible = true; + rm.opened_by_press = true; + rm.suppress_next_pointer_release = true; + rm.outside_click_press_p = null; +} + +/// A modified (ctrl/cmd or shift) off-artboard press is the sprite-selection marquee's +/// while the pointer tool is active — yield it instead of starting a viewport pan. +fn yieldModifiedEmptyPress(_: ?*anyopaque) bool { + return fizzy.editor.tools.current == .pointer; +} + init_options: InitOptions, options: Options, drag_data_point: ?dvui.Point = null, @@ -79,6 +106,13 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Optio .h = @floatFromInt(init_opts.file.height()), }, .center = init_opts.center, + .pan_zoom_scheme = CanvasBridge.scheme(), + .hooks = .{ + .onEmptyTap = onEmptyTap, + .onEmptyHold = onEmptyHold, + .yieldModifiedEmptyPress = yieldModifiedEmptyPress, + .pointerInputSuppressed = CanvasBridge.mainSuppressed, + }, }, opts); return fw; diff --git a/src/editor/widgets/ImageWidget.zig b/src/editor/widgets/ImageWidget.zig index 12e8ed47..cf7ec299 100644 --- a/src/editor/widgets/ImageWidget.zig +++ b/src/editor/widgets/ImageWidget.zig @@ -1,5 +1,6 @@ pub const ImageWidget = @This(); const CanvasWidget = @import("CanvasWidget.zig"); +const CanvasBridge = @import("CanvasBridge.zig"); init_options: InitOptions, options: Options, @@ -35,6 +36,8 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Optio .w = size.w, .h = size.h, }, + .pan_zoom_scheme = CanvasBridge.scheme(), + .hooks = .{ .pointerInputSuppressed = CanvasBridge.mainSuppressed }, }, opts); return iw; From 651aa034c213b4e5d08542d74ea57bfb92e606a4 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 08:22:43 -0500 Subject: [PATCH 08/49] Phase 3b continued --- src/App.zig | 1 - src/Assets.zig | 276 ----------------- src/fizzy.zig | 2 - src/tools/watcher/LinuxWatcher.zig | 442 --------------------------- src/tools/watcher/MacosWatcher.zig | 110 ------- src/tools/watcher/WindowsWatcher.zig | 224 -------------- 6 files changed, 1055 deletions(-) delete mode 100644 src/Assets.zig delete mode 100644 src/tools/watcher/LinuxWatcher.zig delete mode 100644 src/tools/watcher/MacosWatcher.zig delete mode 100644 src/tools/watcher/WindowsWatcher.zig diff --git a/src/App.zig b/src/App.zig index 661a68c7..118adb5f 100644 --- a/src/App.zig +++ b/src/App.zig @@ -16,7 +16,6 @@ const paths = @import("paths.zig"); const App = @This(); const Editor = fizzy.Editor; const Packer = fizzy.Packer; -//const Assets = fizzy.Assets; // App fields allocator: std.mem.Allocator = undefined, diff --git a/src/Assets.zig b/src/Assets.zig deleted file mode 100644 index a8cd63af..00000000 --- a/src/Assets.zig +++ /dev/null @@ -1,276 +0,0 @@ -const std = @import("std"); -const zstbi = @import("zstbi"); -const mach = @import("mach"); -const builtin = @import("builtin"); -const fizzy = @import("fizzy.zig"); - -const Assets = @This(); - -pub const AssetType = enum { - texture, - atlas, - unsupported, -}; - -// Mach module, systems, and main -pub const mach_module = .assets; -pub const mach_systems = .{ .init, .listen, .deinit }; -pub const mach_tags = .{ .auto_reload, .path }; - -const log = std.log.scoped(.watcher); -const ListenerFn = fn (self: *Assets, path: []const u8, name: []const u8) void; -const Watcher = switch (builtin.target.os.tag) { - .linux => @import("tools/watcher/LinuxWatcher.zig"), - .macos => @import("tools/watcher/MacosWatcher.zig"), - .windows => @import("tools/watcher/WindowsWatcher.zig"), - else => @compileError("unsupported platform"), -}; - -paths: mach.Objects(.{ .track_fields = false }, struct { value: [:0]const u8 }), -textures: mach.Objects(.{ .track_fields = false }, fizzy.gfx.Texture), -atlases: mach.Objects(.{ .track_fields = false }, fizzy.Atlas), - -allocator: std.mem.Allocator, -watcher: Watcher = undefined, -thread: std.Thread = undefined, -watching: bool = false, - -var gpa: std.heap.DebugAllocator(.{}) = .init; - -pub fn init(assets: *Assets) !void { - const allocator = gpa.allocator(); - - zstbi.init(allocator); - assets.* = .{ - .textures = assets.textures, - .atlases = assets.atlases, - .paths = assets.paths, - .allocator = allocator, - }; -} - -pub fn loadTexture(assets: *Assets, path: []const u8, options: fizzy.gfx.Texture.SamplerOptions) !?mach.ObjectID { - assets.textures.lock(); - defer assets.textures.unlock(); - - const term_path = try assets.allocator.dupeZ(u8, path); - - if (fizzy.gfx.Texture.loadFromFile(term_path, options) catch null) |texture| { - const texture_id = try assets.textures.new(texture); - const path_id = try assets.paths.new(.{ .value = term_path }); - - try assets.textures.setTag(texture_id, Assets, .path, path_id); - - return texture_id; - } - - return null; -} - -pub fn loadAtlas(assets: *Assets, path: []const u8) !?mach.ObjectID { - assets.atlases.lock(); - defer assets.atlases.unlock(); - - const term_path = try assets.allocator.dupeZ(u8, path); - - if (fizzy.Atlas.loadFromFile(assets.allocator, term_path) catch null) |atlas| { - const atlas_id = try assets.atlases.new(atlas); - const path_id = try assets.paths.new(.{ .value = term_path }); - - try assets.atlases.setTag(atlas_id, Assets, .path, path_id); - - return atlas_id; - } - - return null; -} - -pub fn reload(assets: *Assets, id: mach.ObjectID) !void { - if (assets.textures.is(id)) { - var old_texture = assets.textures.getValue(id); - defer old_texture.deinitWithoutClear(); - - if (assets.textures.getTag(id, Assets, .path)) |path_id| { - const path = assets.paths.get(path_id, .value); - - if (fizzy.gfx.Texture.loadFromFile(path, .{ - .address_mode = old_texture.address_mode, - .copy_dst = old_texture.copy_dst, - .copy_src = old_texture.copy_src, - .filter = old_texture.filter, - .format = old_texture.format, - .render_attachment = old_texture.render_attachment, - .storage_binding = old_texture.storage_binding, - .texture_binding = old_texture.texture_binding, - }) catch null) |texture| { - assets.textures.setValueRaw(id, texture); - } - } - } else if (assets.atlases.is(id)) { - var old_atlas = assets.atlases.getValue(id); - defer old_atlas.deinit(assets.allocator); - - if (assets.atlases.getTag(id, Assets, .path)) |path_id| { - const path = assets.paths.get(path_id, .value); - - if (fizzy.Atlas.loadFromFile(assets.allocator, path) catch null) |atlas| { - assets.atlases.setValueRaw(id, atlas); - } - } - } -} - -pub fn getTexture(assets: *Assets, id: mach.ObjectID) fizzy.gfx.Texture { - return assets.textures.getValue(id); -} - -pub fn getAtlas(assets: *Assets, id: mach.ObjectID) fizzy.Atlas { - return assets.atlases.getValue(id); -} - -/// Returns the watch paths for the currently loaded assets. -/// Caller owns the memory. -pub fn getWatchPaths(assets: *Assets, allocator: std.mem.Allocator) ![]const []const u8 { - var out_paths = std.ArrayList([]const u8).init(allocator); - - var paths = assets.paths.slice(); - while (paths.next()) |id| { - const path = paths.objs.get(id, .value); - for (out_paths.items) |out_path| { - if (std.mem.eql(u8, path, out_path)) { - continue; - } - } - try out_paths.append(path); - } - - return out_paths.toOwnedSlice(); -} - -/// Returns the watch directories for the currently loaded assets. -/// Caller owns the memory. -pub fn getWatchDirs(assets: *Assets, allocator: std.mem.Allocator) ![]const []const u8 { - var out_dirs = std.ArrayList([]const u8).init(allocator); - - var paths = assets.paths.slice(); - path_blk: while (paths.next()) |id| { - if (std.fs.path.dirname(paths.objs.get(id, .value))) |new_dir| { - for (out_dirs.items) |dir| { - if (std.mem.eql(u8, dir, new_dir)) { - continue :path_blk; - } - } - - try out_dirs.append(new_dir); - } - } - - return out_dirs.toOwnedSlice(); -} - -/// Spawns a watch thread for all of the currently registered assets -/// If you add or change assets, you need to call stopWatch and then watch again to reset the background thread -pub fn watch(assets: *Assets) !void { - if (!assets.watching) - try spawnWatchThread(assets); -} - -/// Stops the asset watching thread -pub fn stopWatching(assets: *Assets) void { - assets.stopWatchThread(); -} - -fn spawnWatchThread(assets: *Assets) !void { - assets.watcher = try Watcher.init(assets.allocator); - assets.thread = try std.Thread.spawn(.{}, listen, .{assets}); - assets.thread.detach(); - assets.watching = true; -} - -fn stopWatchThread(assets: *Assets) void { - assets.watching = false; - assets.watcher.stop(); - //assets.thread.join(); - //assets.thread = undefined; -} - -/// Kicks off the listening loop, this will not return -pub fn listen(assets: *Assets) !void { - try assets.watcher.listen(assets); -} - -fn comparePaths(allocator: std.mem.Allocator, path1: []const u8, path2: []const u8) !bool { - const rel_1 = try std.fs.path.relative(allocator, fizzy.app.root_path, path1); - const rel_2 = try std.fs.path.relative(allocator, fizzy.app.root_path, path2); - - defer allocator.free(rel_1); - defer allocator.free(rel_2); - - return std.mem.eql(u8, rel_1, rel_2); -} - -/// Called from the watchers when assets change, this is where we reload our assets based on path. -pub fn onAssetChange(assets: *Assets, path: []const u8, name: []const u8) void { - const changed_path = std.fs.path.join(assets.allocator, &.{ path, name }) catch return; - defer assets.allocator.free(changed_path); - - const extension = std.fs.path.extension(name); - - var asset_type: AssetType = .unsupported; - - if (std.mem.eql(u8, extension, ".png") or std.mem.eql(u8, extension, ".jpg")) - asset_type = .texture - else if (std.mem.eql(u8, extension, ".atlas")) - asset_type = .atlas; - - switch (asset_type) { - .texture => { - var textures = assets.textures.slice(); - while (textures.next()) |texture_id| { - if (!assets.textures.hasTag(texture_id, Assets, .auto_reload)) continue; - - if (assets.textures.getTag(texture_id, Assets, .path)) |path_id| { - if (comparePaths(assets.allocator, changed_path, assets.paths.get(path_id, .value)) catch false) { - assets.reload(texture_id) catch log.debug("Texture failed to reload: {s}", .{changed_path}); - } - } - } - }, - .atlas => { - var atlases = assets.atlases.slice(); - while (atlases.next()) |atlas_id| { - if (!assets.atlases.hasTag(atlas_id, Assets, .auto_reload)) continue; - - if (assets.atlases.getTag(atlas_id, Assets, .path)) |path_id| { - if (comparePaths(assets.allocator, changed_path, assets.paths.get(path_id, .value)) catch false) { - assets.reload(atlas_id) catch log.debug("Atlas failed to reload: {s}", .{changed_path}); - } - } - } - }, - .unsupported => {}, - } -} - -pub fn deinit(assets: *Assets) void { - assets.stopWatching(); - - var textures = assets.textures.slice(); - while (textures.next()) |id| { - var t = assets.textures.getValue(id); - t.deinit(); - } - - var atlases = assets.atlases.slice(); - while (atlases.next()) |id| { - var a = assets.atlases.getValue(id); - a.deinit(assets.allocator); - } - - var paths = assets.paths.slice(); - while (paths.next()) |id| { - assets.allocator.free(assets.paths.get(id, .value)); - } - - zstbi.deinit(); -} diff --git a/src/fizzy.zig b/src/fizzy.zig index 58f670e1..62c47a6f 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -23,7 +23,6 @@ pub const water_surface = @import("gfx/water_surface.zig"); pub const math = @import("math/math.zig"); pub const App = @import("App.zig"); -pub const Assets = @import("Assets.zig"); pub const Editor = @import("editor/Editor.zig"); pub const Explorer = @import("editor/explorer/Explorer.zig"); pub const Fling = @import("editor/Fling.zig"); @@ -35,7 +34,6 @@ pub const Sidebar = @import("editor/Sidebar.zig"); pub var app: *App = undefined; pub var editor: *Editor = undefined; pub var packer: *Packer = undefined; -pub var assets: *Assets = undefined; /// Internal types /// These types contain additional data to support the editor diff --git a/src/tools/watcher/LinuxWatcher.zig b/src/tools/watcher/LinuxWatcher.zig deleted file mode 100644 index 790b26a2..00000000 --- a/src/tools/watcher/LinuxWatcher.zig +++ /dev/null @@ -1,442 +0,0 @@ -const LinuxWatcher = @This(); - -const std = @import("std"); -const Assets = @import("../../Assets.zig"); - -const log = std.log.scoped(.watcher); - -notify_fd: std.posix.fd_t, - -/// active watch entries -watch_fds: std.AutoHashMapUnmanaged(std.posix.fd_t, WatchEntry) = .{}, - -/// direct descendant tracker -children_fds: std.AutoHashMapUnmanaged(std.posix.fd_t, std.ArrayListUnmanaged(std.posix.fd_t)) = .{}, - -/// inotify cookie tracker for move events -cookie_fds: std.AutoHashMapUnmanaged(u32, std.posix.fd_t) = .{}, - -const TreeKind = enum { input, output }; - -const WatchEntry = struct { - dir_path: []const u8, - name: []const u8, - kind: TreeKind, -}; - -pub fn stop(_: *LinuxWatcher) void {} - -pub fn init( - _: std.mem.Allocator, -) !LinuxWatcher { - const notify_fd = try std.posix.inotify_init1(0); - return .{ .notify_fd = notify_fd }; -} - -/// Register `child` with the `parent` -fn addChild( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - parent: std.posix.fd_t, - child: std.posix.fd_t, -) !void { - const children = try self.children_fds.getOrPut(gpa, parent); - if (!children.found_existing) { - children.value_ptr.* = .{}; - } - try children.value_ptr.append(gpa, child); -} - -/// Remove `child` from the `parent`, if present -fn removeChild( - self: *LinuxWatcher, - parent: std.posix.fd_t, - child: std.posix.fd_t, -) ?std.posix.fd_t { - if (self.children_fds.getEntry(parent)) |entry| { - for (0.., entry.value_ptr.items) |i, fd| { - if (child == fd) { - return entry.value_ptr.swapRemove(i); - } - } - } - return null; -} - -/// Remove child identified by `name`, if present -fn removeChildByName( - self: *LinuxWatcher, - parent: std.posix.fd_t, - name: []const u8, -) ?std.posix.fd_t { - if (self.children_fds.getEntry(parent)) |entry| { - for (0.., entry.value_ptr.items) |i, fd| { - if (self.watch_fds.get(fd)) |data| { - if (std.mem.eql(u8, data.name, name)) { - return entry.value_ptr.swapRemove(i); - } - } - } - } - return null; -} - -/// Start tracking directory tree and returns the watch descriptor for `root_dir_path` -/// Register children within the tree -/// **NOTE**: caller is expected to register the returned watch fd as a child -fn addTree( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - tree_kind: TreeKind, - root_dir_path: []const u8, -) !std.posix.fd_t { - var root_dir = try std.fs.cwd().openDir(root_dir_path, .{ .iterate = true }); - defer root_dir.close(); - const parent_fd = try self.addDir(gpa, tree_kind, root_dir_path); - - // tracker for fds associated with dir paths - // helps to track children within a recursive walk - var lookup = std.StringHashMap(std.posix.fd_t).init(gpa); - defer lookup.deinit(); - - try lookup.put(root_dir_path, parent_fd); - - var it = try root_dir.walk(gpa); - while (try it.next()) |entry| switch (entry.kind) { - else => continue, - .directory => { - const dir_path = try std.fs.path.join(gpa, &.{ root_dir_path, entry.path }); - const dir_fd = try self.addDir(gpa, tree_kind, dir_path); - const p_dir = std.fs.path.dirname(dir_path).?; - const p_fd = lookup.get(p_dir).?; - - try self.addChild(gpa, p_fd, dir_fd); - try lookup.put(dir_path, dir_fd); - }, - }; - - return parent_fd; -} - -fn addDir( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - tree_kind: TreeKind, - dir_path: []const u8, -) !std.posix.fd_t { - const mask = Mask.all(&.{ - .IN_ONLYDIR, .IN_CLOSE_WRITE, - .IN_MOVE, .IN_MOVE_SELF, - .IN_CREATE, .IN_DELETE, - .IN_EXCL_UNLINK, - }); - const watch_fd = try std.posix.inotify_add_watch( - self.notify_fd, - dir_path, - mask, - ); - const name_copy = try gpa.dupe(u8, std.fs.path.basename(dir_path)); - try self.watch_fds.put(gpa, watch_fd, .{ - .dir_path = dir_path, - .name = name_copy, - .kind = tree_kind, - }); - log.debug("added {s} -> {}", .{ dir_path, watch_fd }); - return watch_fd; -} - -/// Explicitly stop watching a descriptor -/// **NOTE**: should only be called on an active `fd` -fn rmWatch( - self: *LinuxWatcher, - fd: std.posix.fd_t, -) void { - if (self.children_fds.getEntry(fd)) |entry| { - for (entry.value_ptr.items) |child_fd| { - self.rmWatch(child_fd); - } - self.children_fds.removeByPtr(entry.key_ptr); - } - std.posix.inotify_rm_watch(self.notify_fd, fd); -} - -/// Handle the start of the move process -/// Remove `name`-identified fd from children of `from_fd` -/// Register `cookie` for the moved fd for future identification -fn moveDirStart( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - from_fd: std.posix.fd_t, - cookie: u32, - name: []const u8, -) !void { - const moved_fd = self.removeChildByName(from_fd, name).?; - - try self.cookie_fds.put( - gpa, - cookie, - moved_fd, - ); -} - -/// Handle the end of the move process and returns the resulting moved fd -/// Register the moved fd as a child of `to_fd` -fn moveDirEnd( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - to_fd: std.posix.fd_t, - cookie: u32, - name: []const u8, -) !std.posix.fd_t { - const parent = self.watch_fds.get(to_fd).?; - - // known cookie - move within watched directories - if (self.cookie_fds.fetchRemove(cookie)) |entry| { - const moved_fd = entry.value; - - var watch_entry = self.watch_fds.getEntry(moved_fd).?.value_ptr; - gpa.free(watch_entry.name); - const name_copy = try gpa.dupe(u8, name); - watch_entry.name = name_copy; - watch_entry.kind = parent.kind; - - try self.updateDirPath(gpa, moved_fd, parent.dir_path); - try self.addChild(gpa, to_fd, moved_fd); - return moved_fd; - } else { // unknown cookie - move from the outside - const dir_path = try std.fs.path.join(gpa, &.{ parent.dir_path, name }); - const moved_fd = try self.addTree(gpa, parent.kind, dir_path); - try self.addChild(gpa, to_fd, moved_fd); - return moved_fd; - } -} - -/// Cascade path updates for `fd` and its children -fn updateDirPath( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - fd: std.posix.fd_t, - parent_dir: []const u8, -) !void { - var data = self.watch_fds.getEntry(fd).?.value_ptr; - gpa.free(data.dir_path); - const dir_path = try std.fs.path.join(gpa, &.{ parent_dir, data.name }); - data.dir_path = dir_path; - - if (self.children_fds.getEntry(fd)) |entry| { - for (entry.value_ptr.items) |child_fd| { - try self.updateDirPath(gpa, child_fd, dir_path); - } - } -} - -/// Handle the post-move event -/// Remove stale cookie waiting for the `moved_fd`, if present -fn moveDirComplete( - self: *LinuxWatcher, - moved_fd: std.posix.fd_t, -) !void { - var it = self.cookie_fds.iterator(); - while (it.next()) |entry| { - // cookie for fd exists - moved outside the watched directory - if (entry.value_ptr.* == moved_fd) { - self.rmWatch(moved_fd); - self.cookie_fds.removeByPtr(entry.key_ptr); - break; - } - } -} - -/// Clean up `fd`-related bookkeeping -/// **NOTE**: expects `fd` to be a no-longer-watched descriptor -fn dropWatch( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - fd: std.posix.fd_t, -) void { - if (self.watch_fds.fetchRemove(fd)) |entry| { - gpa.free(entry.value.dir_path); - gpa.free(entry.value.name); - } - - var it = self.children_fds.keyIterator(); - while (it.next()) |parent_fd| { - _ = self.removeChild(parent_fd.*, fd); - } - - if (self.children_fds.fetchRemove(fd)) |entry| { - log.warn("Stopping watch for {d} that has known children: {any}", .{ fd, entry.value }); - } -} - -pub fn listen( - self: *LinuxWatcher, - assets: *Assets, -) !void { - for (try assets.getWatchDirs(assets.allocator)) |p| { - _ = try self.addTree(assets.allocator, .input, p); - } - - const Event = std.os.linux.inotify_event; - const event_size = @sizeOf(Event); - while (assets.watching) { - var buffer: [event_size * 10]u8 = undefined; - const len = try std.posix.read(self.notify_fd, &buffer); - if (len < 0) @panic("notify fd read error"); - - var event_data = buffer[0..len]; - while (event_data.len > 0) { - const event: *Event = @alignCast(@ptrCast(event_data[0..event_size])); - const parent = self.watch_fds.get(event.wd).?; - event_data = event_data[event_size + event.len ..]; - - if (Mask.is(event.mask, .IN_IGNORED)) { - log.debug("IGNORE {s}", .{parent.dir_path}); - self.dropWatch(assets.allocator, event.wd); - continue; - } else if (Mask.is(event.mask, .IN_MOVE_SELF)) { - if (event.getName() == null) { - try self.moveDirComplete(event.wd); - } - continue; - } - - if (Mask.is(event.mask, .IN_ISDIR)) { - if (Mask.is(event.mask, .IN_CREATE)) { - const dir_name = event.getName().?; - const dir_path = try std.fs.path.join(assets.allocator, &.{ - parent.dir_path, - dir_name, - }); - - log.debug("ISDIR CREATE {s}", .{dir_path}); - - const new_fd = try self.addTree(assets.allocator, parent.kind, dir_path); - try self.addChild(assets.allocator, event.wd, new_fd); - const data = self.watch_fds.get(new_fd).?; - switch (data.kind) { - .input => { - assets.onAssetChange(data.dir_path, ""); - }, - .output => { - assets.onAssetChange(data.dir_path, ""); - }, - } - continue; - } else if (Mask.is(event.mask, .IN_MOVED_FROM)) { - log.debug("MOVING {s}/{s}", .{ parent.dir_path, event.getName().? }); - try self.moveDirStart(assets.allocator, event.wd, event.cookie, event.getName().?); - continue; - } else if (Mask.is(event.mask, .IN_MOVED_TO)) { - log.debug("MOVED {s}/{s}", .{ parent.dir_path, event.getName().? }); - const moved_fd = try self.moveDirEnd(assets.allocator, event.wd, event.cookie, event.getName().?); - const moved = self.watch_fds.get(moved_fd).?; - switch (moved.kind) { - .input => { - assets.onAssetChange(moved.dir_path, ""); - }, - .output => { - assets.onAssetChange(moved.dir_path, ""); - }, - } - continue; - } - } else { - if (Mask.is(event.mask, .IN_CLOSE_WRITE) or - Mask.is(event.mask, .IN_MOVED_TO)) - { - switch (parent.kind) { - .input => { - const name = event.getName() orelse continue; - assets.onAssetChange(parent.dir_path, name); - }, - .output => { - const name = event.getName() orelse continue; - assets.onAssetChange(parent.dir_path, name); - }, - } - } - } - } - } -} - -const Mask = struct { - pub const IN_ACCESS = 0x00000001; - pub const IN_MODIFY = 0x00000002; - pub const IN_ATTRIB = 0x00000004; - pub const IN_CLOSE_WRITE = 0x00000008; - pub const IN_CLOSE_NOWRITE = 0x00000010; - pub const IN_CLOSE = (IN_CLOSE_WRITE | IN_CLOSE_NOWRITE); - pub const IN_OPEN = 0x00000020; - pub const IN_MOVED_FROM = 0x00000040; - pub const IN_MOVED_TO = 0x00000080; - pub const IN_MOVE = (IN_MOVED_FROM | IN_MOVED_TO); - pub const IN_CREATE = 0x00000100; - pub const IN_DELETE = 0x00000200; - pub const IN_DELETE_SELF = 0x00000400; - pub const IN_MOVE_SELF = 0x00000800; - pub const IN_ALL_EVENTS = 0x00000fff; - - pub const IN_UNMOUNT = 0x00002000; - pub const IN_Q_OVERFLOW = 0x00004000; - pub const IN_IGNORED = 0x00008000; - - pub const IN_ONLYDIR = 0x01000000; - pub const IN_DONT_FOLLOW = 0x02000000; - pub const IN_EXCL_UNLINK = 0x04000000; - pub const IN_MASK_CREATE = 0x10000000; - pub const IN_MASK_ADD = 0x20000000; - - pub const IN_ISDIR = 0x40000000; - pub const IN_ONESHOT = 0x80000000; - - pub fn is(m: u32, comptime flag: std.meta.DeclEnum(Mask)) bool { - const f = @field(Mask, @tagName(flag)); - return (m & f) != 0; - } - - pub fn all(comptime flags: []const std.meta.DeclEnum(Mask)) u32 { - var result: u32 = 0; - inline for (flags) |f| result |= @field(Mask, @tagName(f)); - return result; - } - - pub fn debugPrint(m: u32) void { - const flags = .{ - .IN_ACCESS, - .IN_MODIFY, - .IN_ATTRIB, - .IN_CLOSE_WRITE, - .IN_CLOSE_NOWRITE, - .IN_CLOSE, - .IN_OPEN, - .IN_MOVED_FROM, - .IN_MOVED_TO, - .IN_MOVE, - .IN_CREATE, - .IN_DELETE, - .IN_DELETE_SELF, - .IN_MOVE_SELF, - .IN_ALL_EVENTS, - - .IN_UNMOUNT, - .IN_Q_OVERFLOW, - .IN_IGNORED, - - .IN_ONLYDIR, - .IN_DONT_FOLLOW, - .IN_EXCL_UNLINK, - .IN_MASK_CREATE, - .IN_MASK_ADD, - - .IN_ISDIR, - .IN_ONESHOT, - }; - inline for (flags) |f| { - if (is(m, f)) { - std.debug.print("{s} ", .{@tagName(f)}); - } - } - } -}; diff --git a/src/tools/watcher/MacosWatcher.zig b/src/tools/watcher/MacosWatcher.zig deleted file mode 100644 index a3720b7b..00000000 --- a/src/tools/watcher/MacosWatcher.zig +++ /dev/null @@ -1,110 +0,0 @@ -const MacosWatcher = @This(); - -const std = @import("std"); -const Assets = @import("../../Assets.zig"); -const c = @cImport({ - @cInclude("CoreServices/CoreServices.h"); -}); - -const log = std.log.scoped(.watcher); - -pub fn init( - allocator: std.mem.Allocator, -) !MacosWatcher { - _ = allocator; - - return .{}; -} - -pub fn callback( - streamRef: c.ConstFSEventStreamRef, - clientCallBackInfo: ?*anyopaque, - numEvents: usize, - eventPaths: ?*anyopaque, - eventFlags: ?[*]const c.FSEventStreamEventFlags, - eventIds: ?[*]const c.FSEventStreamEventId, -) callconv(.C) void { - _ = eventIds; - _ = eventFlags; - _ = streamRef; - const ctx: *Context = @alignCast(@ptrCast(clientCallBackInfo)); - - const paths: [*][*:0]u8 = @alignCast(@ptrCast(eventPaths)); - for (paths[0..numEvents]) |p| { - const path = std.mem.span(p); - - const basename = std.fs.path.basename(path); - var base_path = path[0 .. path.len - basename.len]; - if (std.mem.endsWith(u8, base_path, "/")) - base_path = base_path[0 .. base_path.len - 1]; - - ctx.assets.onAssetChange(base_path, basename); - } -} - -pub fn stop(_: *MacosWatcher) void { - c.CFRunLoopStop(c.CFRunLoopGetCurrent()); -} - -const Context = struct { - assets: *Assets, -}; -pub fn listen( - _: *MacosWatcher, - assets: *Assets, -) !void { - const in_paths = try assets.getWatchPaths(assets.allocator); - var macos_paths = try assets.allocator.alloc(c.CFStringRef, in_paths.len); - - for (in_paths, macos_paths[0..]) |str, *ref| { - ref.* = c.CFStringCreateWithCString( - null, - str.ptr, - c.kCFStringEncodingUTF8, - ); - } - - const paths_to_watch: c.CFArrayRef = c.CFArrayCreate( - null, - @ptrCast(macos_paths.ptr), - @intCast(macos_paths.len), - null, - ); - - var ctx: Context = .{ - .assets = assets, - }; - - var stream_context: c.FSEventStreamContext = .{ .info = &ctx }; - const stream: c.FSEventStreamRef = c.FSEventStreamCreate( - null, - &callback, - &stream_context, - paths_to_watch, - c.kFSEventStreamEventIdSinceNow, - 0.05, - c.kFSEventStreamCreateFlagFileEvents, - ); - - c.FSEventStreamScheduleWithRunLoop( - stream, - c.CFRunLoopGetCurrent(), - c.kCFRunLoopDefaultMode, - ); - - if (c.FSEventStreamStart(stream) == 0) { - @panic("failed to start the event stream"); - } - - // Free allocations before entering the run loop, it will not return - assets.allocator.free(macos_paths); - assets.allocator.free(in_paths); - - c.CFRunLoopRun(); - - c.FSEventStreamStop(stream); - c.FSEventStreamInvalidate(stream); - c.FSEventStreamRelease(stream); - - c.CFRelease(paths_to_watch); -} diff --git a/src/tools/watcher/WindowsWatcher.zig b/src/tools/watcher/WindowsWatcher.zig deleted file mode 100644 index ae7c8bb6..00000000 --- a/src/tools/watcher/WindowsWatcher.zig +++ /dev/null @@ -1,224 +0,0 @@ -const WindowsWatcher = @This(); - -const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); -const windows = std.os.windows; -const Assets = @import("../../Assets.zig"); - -const log = std.log.scoped(.watcher); - -const notify_filter = windows.FileNotifyChangeFilter{ - .file_name = true, - .dir_name = true, - .attributes = false, - .size = false, - .last_write = true, - .last_access = false, - .creation = false, - .security = false, -}; - -const Error = error{ InvalidHandle, QueueFailed, WaitFailed }; - -const CompletionKey = usize; -/// Values should be a multiple of `ReadBufferEntrySize` -const ReadBufferIndex = u32; -const ReadBufferEntrySize = 1024; - -const WatchEntry = struct { - dir_path: [:0]const u8, - dir_handle: windows.HANDLE, - - overlap: windows.OVERLAPPED = std.mem.zeroes(windows.OVERLAPPED), - buf_idx: ReadBufferIndex, -}; - -iocp_port: windows.HANDLE, -entries: std.AutoHashMap(CompletionKey, WatchEntry), -read_buffer: []u8, - -pub fn stop(_: *WindowsWatcher) void {} - -pub fn init( - allocator: std.mem.Allocator, -) !WindowsWatcher { - const watcher = WindowsWatcher{ - .iocp_port = windows.INVALID_HANDLE_VALUE, - .entries = std.AutoHashMap(CompletionKey, WatchEntry).init(allocator), - .read_buffer = undefined, - }; - - return watcher; -} - -fn addPath( - path: [:0]const u8, - /// Assumed to increment by 1 after each invocation, starting at 0. - key: CompletionKey, - port: *windows.HANDLE, -) !WatchEntry { - const dir_handle = CreateFileA( - path, - windows.GENERIC_READ, // FILE_LIST_DIRECTORY, - windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE, - null, - windows.OPEN_EXISTING, - windows.FILE_FLAG_BACKUP_SEMANTICS | windows.FILE_FLAG_OVERLAPPED, - null, - ); - if (dir_handle == windows.INVALID_HANDLE_VALUE) { - log.err( - "Unable to open directory {s}: {s}", - .{ path, @tagName(windows.kernel32.GetLastError()) }, - ); - return Error.InvalidHandle; - } - - if (port.* == windows.INVALID_HANDLE_VALUE) { - port.* = try windows.CreateIoCompletionPort(dir_handle, null, key, 0); - } else { - _ = try windows.CreateIoCompletionPort(dir_handle, port.*, key, 0); - } - - return .{ - .dir_path = path, - .dir_handle = dir_handle, - .buf_idx = @intCast(ReadBufferEntrySize * key), - }; -} - -pub fn listen( - watcher: *WindowsWatcher, - assets: *Assets, -) !void { - // Doubles as the number of WatchEntries - var comp_key: CompletionKey = 0; - - const in_paths = try assets.getWatchDirs(assets.allocator); - defer assets.allocator.free(in_paths); - - for (in_paths) |path| { - const in_path = try assets.allocator.dupeZ(u8, path); - //defer assets.allocator.free(in_path); - - try watcher.entries.put( - comp_key, - try addPath(in_path, comp_key, &watcher.iocp_port), - ); - comp_key += 1; - } - - watcher.read_buffer = try assets.allocator.alloc(u8, ReadBufferEntrySize * comp_key); - defer assets.allocator.free(watcher.read_buffer); - // Here we need pointers to both the read_buffer and entry overlapped structs, - // which we can only do after setting up everything else. - watcher.entries.lockPointers(); - for (0..comp_key) |key| { - const entry = watcher.entries.getPtr(key).?; - - if (windows.kernel32.ReadDirectoryChangesW( - entry.dir_handle, - @ptrCast(@alignCast(&watcher.read_buffer[entry.buf_idx])), - ReadBufferEntrySize, - @intFromBool(true), - notify_filter, - null, - &entry.overlap, - null, - ) == 0) { - log.err("ReadDirectoryChanges error: {s}", .{@tagName(windows.kernel32.GetLastError())}); - return Error.QueueFailed; - } - } - - var dont_care: struct { - bytes_transferred: windows.DWORD = undefined, - overlap: ?*windows.OVERLAPPED = undefined, - } = .{}; - - var key: CompletionKey = undefined; - while (assets.watching) { - // Waits here until any of the directory handles associated with the iocp port - // have been updated. - const wait_result = windows.GetQueuedCompletionStatus( - watcher.iocp_port, - &dont_care.bytes_transferred, - &key, - &dont_care.overlap, - windows.INFINITE, - ); - if (wait_result != .Normal) { - log.err("GetQueuedCompletionStatus error: {s}", .{@tagName(wait_result)}); - return Error.WaitFailed; - } - - const entry = watcher.entries.getPtr(key) orelse @panic("Invalid CompletionKey"); - - var info_iter = windows.FileInformationIterator(FILE_NOTIFY_INFORMATION){ - .buf = watcher.read_buffer[entry.buf_idx..][0..ReadBufferEntrySize], - }; - var path_buf: [windows.MAX_PATH]u8 = undefined; - while (info_iter.next()) |info| { - const filename: []const u8 = blk: { - const n = try std.unicode.utf16LeToUtf8( - &path_buf, - @as([*]u16, @ptrCast(&info.FileName))[0 .. info.FileNameLength / 2], - ); - break :blk path_buf[0..n]; - }; - - // const args = .{ entry.dir_path, filename }; - // switch (info.Action) { - // windows.FILE_ACTION_ADDED => log.debug("added {s}/{s}", args), - // windows.FILE_ACTION_REMOVED => log.debug("removed {s}/{s}", args), - // windows.FILE_ACTION_MODIFIED => log.debug("modified {s}/{s}", args), - // windows.FILE_ACTION_RENAMED_OLD_NAME => log.debug("renamed_old_name {s}/{s}", args), - // windows.FILE_ACTION_RENAMED_NEW_NAME => log.debug("renamed_new_name {s}/{s}", args), - // else => log.debug("Unknown Action {s}/{s}", args), - // } - - assets.onAssetChange(entry.dir_path, filename); - } - - // Re-queue the directory entry - if (windows.kernel32.ReadDirectoryChangesW( - entry.dir_handle, - @ptrCast(@alignCast(&watcher.read_buffer[entry.buf_idx])), - ReadBufferEntrySize, - @intFromBool(true), - notify_filter, - null, - &entry.overlap, - null, - ) == 0) { - log.err("ReadDirectoryChanges error: {s}", .{@tagName(windows.kernel32.GetLastError())}); - return Error.QueueFailed; - } - } - - watcher.entries.unlockPointers(); - var iter = watcher.entries.valueIterator(); - while (iter.next()) |entry| { - windows.CloseHandle(entry.dir_handle); - assets.allocator.free(entry.dir_path); - } - watcher.entries.deinit(); -} - -const FILE_NOTIFY_INFORMATION = extern struct { - NextEntryOffset: windows.DWORD, - Action: windows.DWORD, - FileNameLength: windows.DWORD, - /// Flexible array member - FileName: windows.WCHAR, -}; - -extern "kernel32" fn CreateFileA( - lpFileName: windows.LPCSTR, - dwDesiredAccess: windows.DWORD, - dwShareMode: windows.DWORD, - lpSecurityAttributes: ?*windows.SECURITY_ATTRIBUTES, - dwCreationDisposition: windows.DWORD, - dwFlagsAndAttributes: windows.DWORD, - hTemplateFile: ?windows.HANDLE, -) callconv(windows.WINAPI) windows.HANDLE; From 6911509f04ec3614081c584c8cb9c405aec9304a Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 08:36:22 -0500 Subject: [PATCH 09/49] Phase 3c --- src/internal/File.zig | 3 +++ src/pixelart/plugin.zig | 26 ++++++++++++++++++++++++++ src/sdk/Plugin.zig | 12 ++++++++++++ src/workbench/Workspace.zig | 23 +++++------------------ 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/internal/File.zig b/src/internal/File.zig index e78a9881..4d5a66d0 100644 --- a/src/internal/File.zig +++ b/src/internal/File.zig @@ -59,6 +59,9 @@ pub const EditorData = struct { /// type-depend on the editor's `Workspace` (lets `File` move into a plugin). /// Only valid while the file widget is drawing the file. workspace_handle: ?*anyopaque = null, + /// Set by the shell each frame before draw: request the canvas recenter this frame + /// (true while a workspace/panel pane is mid-animation). Read by the document render. + center: bool = false, canvas: fizzy.dvui.CanvasWidget = .{}, layers_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, sprites_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig index b69c2fbb..3137e832 100644 --- a/src/pixelart/plugin.zig +++ b/src/pixelart/plugin.zig @@ -34,6 +34,7 @@ const vtable: sdk.Plugin.VTable = .{ .closeDocument = closeDocument, .undo = undo, .redo = redo, + .drawDocument = drawDocument, }; /// A `DocHandle` whose `ptr` is one of this plugin's `*Internal.File`s. The shell @@ -85,6 +86,31 @@ fn closeDocument(_: *anyopaque, doc: DocHandle) void { docFile(doc).deinit(); } +/// Render the open pixel-art document into the workbench-provided container (the current +/// dvui parent). The workbench sets `canvas.id` / `workspace_handle` and draws the canvas +/// chrome around this; here we instantiate the editing widget and the sample magnifier. +fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { + const file = docFile(doc); + fizzy.perf.canvasPaneDrawn(); + + var file_widget = fizzy.dvui.FileWidget.init(@src(), .{ + .file = file, + .center = file.editor.center, + }, .{ + .expand = .both, + .background = false, + .color_fill = .transparent, + }); + defer file_widget.deinit(); + file_widget.processEvents(); + + if (dvui.dataGet(null, file.editor.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { + if (file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { + fizzy.dvui.FileWidget.drawSampleMagnifier(file, data_pt); + } + } +} + fn undo(_: *anyopaque, doc: DocHandle) anyerror!void { const file = docFile(doc); try file.history.undoRedo(file, .undo); diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index dc1836fc..d4f5ea2f 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -117,6 +117,18 @@ pub fn redo(self: Plugin, doc: DocHandle) !void { if (self.vtable.redo) |f| try f(self.state, doc); } +// ---- render hook wrappers ---- + +/// Draw an open document into the current dvui parent (the workbench sets up the +/// container, then routes here). Returns whether the plugin drew anything. +pub fn drawDocument(self: Plugin, doc: DocHandle) !bool { + if (self.vtable.drawDocument) |f| { + try f(self.state, doc); + return true; + } + return false; +} + pub fn deinit(self: Plugin) void { if (self.vtable.deinit) |f| f(self.state); } diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig index b85b1226..89df2411 100644 --- a/src/workbench/Workspace.zig +++ b/src/workbench/Workspace.zig @@ -886,6 +886,7 @@ pub fn drawCanvas(self: *Workspace) !void { const file = &fizzy.editor.open_files.values()[self.open_file_index]; file.editor.canvas.id = canvas_vbox.data().id; file.editor.workspace_handle = self; + file.editor.center = self.center; if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) { defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .top, .{}); @@ -907,24 +908,10 @@ pub fn drawCanvas(self: *Workspace) !void { if (self.grouping != file.editor.grouping) return; - fizzy.perf.canvasPaneDrawn(); - - var file_widget = fizzy.dvui.FileWidget.init(@src(), .{ - .file = file, - .center = self.center, - }, .{ - .expand = .both, - .background = false, - .color_fill = .transparent, - }); - - defer file_widget.deinit(); - file_widget.processEvents(); - - if (dvui.dataGet(null, file.editor.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { - if (file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { - fizzy.dvui.FileWidget.drawSampleMagnifier(file, data_pt); - } + // Route the document render to its owning plugin (pixel art builds its own + // FileWidget). The workbench owns only the container + canvas chrome above. + if (fizzy.editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { + _ = try plugin.drawDocument(.{ .ptr = file, .owner = plugin, .id = file.id }); } } else { var box = workspaceEmptyStateCard(content_color, self.grouping); From d98879174fa99a15153f742cf3df2b2c7b0f2b2d Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 08:51:03 -0500 Subject: [PATCH 10/49] Phase 3c - part 2 --- src/pixelart/plugin.zig | 32 ++++++++++++++++++++++++--- src/workbench/Workspace.zig | 43 +++++++++++-------------------------- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig index 3137e832..9498c148 100644 --- a/src/pixelart/plugin.zig +++ b/src/pixelart/plugin.zig @@ -86,13 +86,39 @@ fn closeDocument(_: *anyopaque, doc: DocHandle) void { docFile(doc).deinit(); } -/// Render the open pixel-art document into the workbench-provided container (the current -/// dvui parent). The workbench sets `canvas.id` / `workspace_handle` and draws the canvas -/// chrome around this; here we instantiate the editing widget and the sample magnifier. +/// Render the open pixel-art document into the workbench-provided content region (the +/// current dvui parent). The workbench owns only the container + tab/split frame and sets +/// `canvas.id` / `workspace_handle` / `center` before routing here; pixel art owns the +/// entire region: rulers, the canvas hbox, the transform/edit/sample overlays, the editing +/// widget, and the sample magnifier. The per-workspace ruler/overlay state + draw helpers +/// still live on `Workspace` for now (recovered via `ofFile`); they relocate here in 3C/2b. fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { const file = docFile(doc); + const ws = fizzy.Editor.Workspace.ofFile(file) orelse return; + const container = dvui.parentGet().data(); + fizzy.perf.canvasPaneDrawn(); + if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) { + defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .top, .{}); + ws.drawRuler(.horizontal); + } + + var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); + defer canvas_hbox.deinit(); + + if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) { + defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .left, .{}); + ws.drawRuler(.vertical); + } + + ws.drawTransformDialog(container); + ws.drawEditPill(container); + // Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom). + ws.drawSampleButton(container); + + if (ws.grouping != file.editor.grouping) return; + var file_widget = fizzy.dvui.FileWidget.init(@src(), .{ .file = file, .center = file.editor.center, diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig index 89df2411..43160e95 100644 --- a/src/workbench/Workspace.zig +++ b/src/workbench/Workspace.zig @@ -884,32 +884,13 @@ pub fn drawCanvas(self: *Workspace) !void { } const file = &fizzy.editor.open_files.values()[self.open_file_index]; + // The workbench owns only the content region (this container) + tab/split frame; + // bind it to the document and route the entire in-region render to the owning + // plugin (pixel art draws its rulers, overlays, and editing widget itself). file.editor.canvas.id = canvas_vbox.data().id; file.editor.workspace_handle = self; file.editor.center = self.center; - if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) { - defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .top, .{}); - self.drawRuler(.horizontal); - } - - var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); - defer canvas_hbox.deinit(); - - if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) { - defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .left, .{}); - self.drawRuler(.vertical); - } - - self.drawTransformDialog(canvas_vbox); - self.drawEditPill(canvas_vbox); - // Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom). - self.drawSampleButton(canvas_vbox); - - if (self.grouping != file.editor.grouping) return; - - // Route the document render to its owning plugin (pixel art builds its own - // FileWidget). The workbench owns only the container + canvas chrome above. if (fizzy.editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { _ = try plugin.drawDocument(.{ .ptr = file, .owner = plugin, .id = file.id }); } @@ -1560,16 +1541,16 @@ pub fn processRowReorder(self: *Workspace) void { } } -pub fn drawTransformDialog(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { +pub fn drawTransformDialog(self: *Workspace, container: *dvui.WidgetData) void { const file = &fizzy.editor.open_files.values()[self.open_file_index]; if (file.editor.transform) |*transform| { - var rect = canvas_vbox.data().rect; + var rect = container.rect; rect.w = 0; rect.h = 0; var fw: dvui.FloatingWidget = undefined; fw.init(@src(), .{}, .{ - .rect = .{ .x = canvas_vbox.data().rectScale().r.toNatural().x + 10, .y = canvas_vbox.data().rectScale().r.toNatural().y + 10, .w = 0, .h = 0 }, + .rect = .{ .x = container.rectScale().r.toNatural().x + 10, .y = container.rectScale().r.toNatural().y + 10, .w = 0, .h = 0 }, .expand = .none, .background = true, .color_fill = dvui.themeGet().color(.control, .fill), @@ -1653,7 +1634,7 @@ pub fn drawTransformDialog(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void /// with icon-only round buttons sized to match the toolbox buttons. Starts collapsed as a /// single hamburger circle; tapping toggles the row of action buttons in/out with a /// width animation. -pub fn drawEditPill(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { +pub fn drawEditPill(self: *Workspace, container: *dvui.WidgetData) void { const file = fizzy.editor.activeFile() orelse return; const button_size: f32 = 36; @@ -1699,7 +1680,7 @@ pub fn drawEditPill(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { // Drive the expand/collapse with a dvui animation. Look up the current value, and on // a toggle click kick off a new animation between the current value and the target. - const anim_id = dvui.Id.update(canvas_vbox.data().id, "edit_pill_expand"); + const anim_id = dvui.Id.update(container.id, "edit_pill_expand"); var anim_value: f32 = if (self.edit_pill_expanded) 1.0 else 0.0; if (dvui.animationGet(anim_id, "_t")) |a| anim_value = std.math.clamp(a.value(), 0.0, 1.0); @@ -1711,7 +1692,7 @@ pub fn drawEditPill(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { // the pill follows the workspace exactly: as a split is dragged shut the canvas area // shrinks, and once it's narrower than the pill we bail and draw nothing this frame — // so closing splits cleanly hides the menu. - const wb = canvas_vbox.data().rectScale().r.toNatural(); + const wb = container.rectScale().r.toNatural(); const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; const canvas_nat = dvui.Rect{ @@ -1935,7 +1916,7 @@ pub fn drawEditPill(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { /// through to `file.editor.canvas.sample_data_point` so `FileWidget.drawSample` renders /// the existing color-dropper magnifier at the touch location. On release we read the /// color underneath the sample point and apply it to the primary color slot. -pub fn drawSampleButton(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { +pub fn drawSampleButton(self: *Workspace, container: *dvui.WidgetData) void { const file = fizzy.editor.activeFile() orelse return; const pill_button_size: f32 = 36; @@ -1949,7 +1930,7 @@ pub fn drawSampleButton(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { const gap: f32 = 6; // Anchor against the same canvas-scroll-area rect the pill uses. - const wb = canvas_vbox.data().rectScale().r.toNatural(); + const wb = container.rectScale().r.toNatural(); const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; const canvas_nat = dvui.Rect{ @@ -2002,7 +1983,7 @@ pub fn drawSampleButton(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { // Persistent drag state (a press is "drag-sampling" once motion clears the dvui drag // threshold). Stored via dataSet because the button widget is recreated each frame. - const drag_state_id = dvui.Id.update(canvas_vbox.data().id, "sample_button_drag"); + const drag_state_id = dvui.Id.update(container.id, "sample_button_drag"); var is_drag_sampling = dvui.dataGet(null, drag_state_id, "active", bool) orelse false; var did_sample = dvui.dataGet(null, drag_state_id, "did_sample", bool) orelse false; From 5816e6fd60e27529f98cbb8bdac076f80c825033 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 09:15:38 -0500 Subject: [PATCH 11/49] Phase 3c - part 2b --- src/pixelart/plugin.zig | 87 +++++++++++++++++++++++++++++++++ src/sdk/regions.zig | 5 ++ src/workbench/Workspace.zig | 97 ++++++------------------------------- 3 files changed, 107 insertions(+), 82 deletions(-) diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig index 9498c148..098520be 100644 --- a/src/pixelart/plugin.zig +++ b/src/pixelart/plugin.zig @@ -137,6 +137,92 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { } } +/// Take over a workspace pane to show the pixel-art packed-atlas preview (the "Project" +/// sidebar view's `draw_workspace`). The workbench owns the pane frame and routes here when +/// `view_project` is the active sidebar view; we cast the opaque handle back to the document +/// host's `Workspace` and render the whole content region (atlas image or empty-state hint). +/// Mirrors what `Workspace.drawCanvas` does for documents: reuses the workbench's shared +/// canvas vbox / empty-state card helpers so switching project ↔ canvas keeps stable widget ids, +/// and stamps `canvas_rect_physical` (read by the editor's load/save toast overlays). +fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { + const Workspace = fizzy.Editor.Workspace; + const ws: *Workspace = @ptrCast(@alignCast(workspace_handle)); + + var content_color = dvui.themeGet().color(.window, .fill); + + switch (builtin.os.tag) { + .macos => { + content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; + }, + .windows => { + content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; + }, + else => {}, + } + + const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32) + fizzy.packer.atlas != null + else + fizzy.editor.folder != null and fizzy.packer.atlas != null; + + // Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage). + var canvas_vbox = Workspace.workspaceMainCanvasVbox(content_color, show_packed_atlas, ws.grouping); + defer { + ws.canvas_rect_physical = canvas_vbox.data().contentRectScale().r; + dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural()); + canvas_vbox.deinit(); + } + + if (show_packed_atlas) { + const atlas = &fizzy.packer.atlas.?; + var image_widget = fizzy.dvui.ImageWidget.init(@src(), .{ + .source = atlas.source, + .canvas = &atlas.canvas, + .grouping = ws.grouping, + }, .{ + .id_extra = @intCast(ws.grouping), + .expand = .both, + .background = false, + .color_fill = .transparent, + }); + defer image_widget.deinit(); + + image_widget.processEvents(); + + if (dvui.dataGet(null, atlas.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { + if (atlas.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { + fizzy.dvui.ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt); + } + } + } else { + var box = Workspace.workspaceEmptyStateCard(content_color, ws.grouping); + defer box.deinit(); + + const alpha = dvui.alpha(1.0); + dvui.alphaSet(1.0); + defer dvui.alphaSet(alpha); + + const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32) + "Pack open files to see the preview." + else if (fizzy.editor.folder == null) + "Open a project folder, then pack to see the preview." + else + "Pack the project to see the preview."; + + dvui.labelNoFmt( + @src(), + hint, + .{ .align_x = 0.5 }, + .{ + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.control, .text), + .font = dvui.Font.theme(.body), + }, + ); + } +} + fn undo(_: *anyopaque, doc: DocHandle) anyerror!void { const file = docFile(doc); try file.history.undoRedo(file, .undo); @@ -169,6 +255,7 @@ pub fn register(host: *sdk.Host) !void { .icon = dvui.entypo.box, .title = "Project", .draw = drawProject, + .draw_workspace = drawProjectView, }); try host.registerBottomView(.{ .id = bottom_sprites, diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig index 9b09b617..cfe89cb7 100644 --- a/src/sdk/regions.zig +++ b/src/sdk/regions.zig @@ -22,6 +22,11 @@ pub const SidebarView = struct { title: []const u8, ctx: ?*anyopaque = null, draw: *const fn (ctx: ?*anyopaque) anyerror!void, + /// Optional: while this view is the active sidebar view, it takes over the workspace + /// content region instead of the normal document tabs+canvas. The workbench calls this + /// per workspace pane, passing the opaque workspace handle (cast back to the document + /// host's `Workspace`). Used by pixel art's "Project" view to show the packed atlas. + draw_workspace: ?*const fn (ctx: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void = null, }; /// A bottom-panel view. The panel shows a tab strip across all registered views; diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig index 43160e95..dcea40e9 100644 --- a/src/workbench/Workspace.zig +++ b/src/workbench/Workspace.zig @@ -48,7 +48,8 @@ vertical_ruler_width: f32 = 0.0, edit_pill_expanded: bool = false, /// Physical-pixel content rect of this workspace's canvas vbox, captured each frame during -/// `drawCanvas` / `drawProject`. `null` until the workspace has rendered at least once. Used +/// `drawCanvas` (or a sidebar view's `draw_workspace` takeover, e.g. pixel art's Project view). +/// `null` until the workspace has rendered at least once. Used /// by the editor-level load/save toast overlays to center cards over the area the user is /// actually looking at (rather than the OS window rect). canvas_rect_physical: ?dvui.Rect.Physical = null, @@ -119,8 +120,12 @@ pub fn draw(self: *Workspace) !dvui.App.Result { } } - if (fizzy.editor.host.isActiveSidebarView(@import("../pixelart/plugin.zig").view_project)) { - self.drawProject(); + // A sidebar view may optionally take over this workspace pane's content region (e.g. pixel + // art's "Project" view renders the packed atlas here instead of document tabs+canvas). The + // workbench owns only the pane frame; it hands the active view the opaque workspace handle. + const active = fizzy.editor.host.activeSidebarView(); + if (active != null and active.?.draw_workspace != null) { + try active.?.draw_workspace.?(active.?.ctx, self); } else { self.drawTabs(); try self.drawCanvas(); @@ -130,8 +135,11 @@ pub fn draw(self: *Workspace) !dvui.App.Result { } /// Same `@src()` for every call so DVUI sees one stable id when switching between `drawCanvas` and -/// `drawProject` (avoids first-frame min-size / layout flash). Use `grouping` so multi-workspace panes stay distinct. -fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget { +/// a plugin's `draw_workspace` takeover (avoids first-frame min-size / layout flash). Use `grouping` +/// so multi-workspace panes stay distinct. +/// `pub` so a plugin's `draw_workspace` takeover (pixel art's Project view) can reuse the exact same +/// vbox so switching project ↔ canvas does not churn the widget id. +pub fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget { return dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .background = background, @@ -142,7 +150,8 @@ fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping /// Rounded “card” behind the project empty state and the homepage. Shared id base + `grouping` so /// switching project tab ↔ file pane (no open files) does not create a new widget each time. -fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget { +/// `pub` so pixel art's Project-view takeover (`draw_workspace`) reuses the identical empty-state card. +pub fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget { return dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both, .background = true, @@ -153,82 +162,6 @@ fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWi }); } -fn drawProject(self: *Workspace) void { - var content_color = dvui.themeGet().color(.window, .fill); - - switch (builtin.os.tag) { - .macos => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; - }, - .windows => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; - }, - else => {}, - } - - const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32) - fizzy.packer.atlas != null - else - fizzy.editor.folder != null and fizzy.packer.atlas != null; - - // Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage). - var canvas_vbox = workspaceMainCanvasVbox(content_color, show_packed_atlas, self.grouping); - defer { - self.canvas_rect_physical = canvas_vbox.data().contentRectScale().r; - dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural()); - canvas_vbox.deinit(); - } - - if (show_packed_atlas) { - const atlas = &fizzy.packer.atlas.?; - var image_widget = fizzy.dvui.ImageWidget.init(@src(), .{ - .source = atlas.source, - .canvas = &atlas.canvas, - .grouping = self.grouping, - }, .{ - .id_extra = @intCast(self.grouping), - .expand = .both, - .background = false, - .color_fill = .transparent, - }); - defer image_widget.deinit(); - - image_widget.processEvents(); - - if (dvui.dataGet(null, atlas.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { - if (atlas.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { - fizzy.dvui.ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt); - } - } - } else { - var box = workspaceEmptyStateCard(content_color, self.grouping); - defer box.deinit(); - - const alpha = dvui.alpha(1.0); - dvui.alphaSet(1.0); - defer dvui.alphaSet(alpha); - - const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32) - "Pack open files to see the preview." - else if (fizzy.editor.folder == null) - "Open a project folder, then pack to see the preview." - else - "Pack the project to see the preview."; - - dvui.labelNoFmt( - @src(), - hint, - .{ .align_x = 0.5 }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = dvui.themeGet().color(.control, .text), - .font = dvui.Font.theme(.body), - }, - ); - } -} - fn drawTabs(self: *Workspace) void { if (fizzy.editor.open_files.values().len == 0) return; From 8d8aa3293002c4df1157f83cad6b537bab13c33e Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 09:42:20 -0500 Subject: [PATCH 12/49] Phase 3c - final --- src/editor/Editor.zig | 4 + src/editor/widgets/FileWidget.zig | 34 +- src/pixelart/CanvasData.zig | 1287 +++++++++++++++++++++++++++++ src/pixelart/plugin.zig | 25 +- src/workbench/Workspace.zig | 1263 +--------------------------- 5 files changed, 1351 insertions(+), 1262 deletions(-) create mode 100644 src/pixelart/CanvasData.zig diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 68689d3f..f76e6b9e 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -1739,6 +1739,7 @@ pub fn rebuildWorkspaces(editor: *Editor) !void { } } + workspace.deinit(); _ = editor.workspaces.orderedRemove(workspace.grouping); break; } @@ -3478,6 +3479,9 @@ pub fn deinit(editor: *Editor) !void { editor.explorer.deinit(); + for (editor.workspaces.values()) |*workspace| workspace.deinit(); + editor.workspaces.deinit(fizzy.app.allocator); + editor.host.deinit(); editor.workbench.deinit(); diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig index 60187f34..cd4c8590 100644 --- a/src/editor/widgets/FileWidget.zig +++ b/src/editor/widgets/FileWidget.zig @@ -19,6 +19,7 @@ pub const FileWidget = @This(); const CanvasWidget = @import("CanvasWidget.zig"); const CanvasBridge = @import("CanvasBridge.zig"); const Workspace = fizzy.Editor.Workspace; +const CanvasData = @import("../../pixelart/CanvasData.zig"); const icons = @import("icons"); // ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is @@ -683,12 +684,23 @@ fn workspace(self: *FileWidget) *Workspace { return Workspace.ofFile(self.init_options.file).?; } +/// The pixel-art per-pane `CanvasData` for the pane drawing this file, or null if none is +/// attached yet. Holds the column/row reorder drag state this widget reads while previewing. +fn canvasData(self: *FileWidget) ?*CanvasData { + return CanvasData.fromWorkspace(self.workspace()); +} + +/// True while a column or row is mid-drag in this pane's rulers. +fn columnRowReorderActive(self: *FileWidget) bool { + const cd = self.canvasData() orelse return false; + return cd.columns_drag_index != null or cd.rows_drag_index != null; +} + /// Same read-only state as `drawSpriteBubbles` uses for `BubblePanShared` (no animation side effects). fn bubblePanSharedForGrid(self: *FileWidget) ?BubblePanShared { if (self.init_options.file.editor.transform != null) return null; if (self.resize_data_point != null) return null; - if (self.workspace().columns_drag_index != null) return null; - if (self.workspace().rows_drag_index != null) return null; + if (self.columnRowReorderActive()) return null; if (self.removed_sprite_indices != null) return null; if (!(self.active() or self.hovered())) return null; @@ -4551,7 +4563,7 @@ pub fn drawLayers(self: *FileWidget) void { if (self.removed_sprite_indices != null) { self.drawCellReorderPreview(); return; - } else if (self.workspace().columns_drag_index != null or self.workspace().rows_drag_index != null) { + } else if (self.columnRowReorderActive()) { self.drawColumnRowReorderPreview(); return; } else { @@ -4751,17 +4763,17 @@ fn drawCanvasCheckerboardBackground(self: *FileWidget) void { fn drawColumnRowReorderPreview(self: *FileWidget) void { const file = self.init_options.file; - const ws = self.workspace(); - if (ws.columns_drag_index == null and ws.rows_drag_index == null) return; + const cd = self.canvasData() orelse return; + if (cd.columns_drag_index == null and cd.rows_drag_index == null) return; - const axis: ReorderAxis = if (ws.columns_drag_index != null) .columns else .rows; + const axis: ReorderAxis = if (cd.columns_drag_index != null) .columns else .rows; const target_index = switch (axis) { - .columns => ws.columns_target_index, - .rows => ws.rows_target_index, + .columns => cd.columns_target_index, + .rows => cd.rows_target_index, }; const removed_index = switch (axis) { - .columns => ws.columns_drag_index, - .rows => ws.rows_drag_index, + .columns => cd.columns_drag_index, + .rows => cd.rows_drag_index, } orelse return; self.drawReorderPreviewForAxis(file, axis, target_index, removed_index); @@ -5671,7 +5683,7 @@ pub fn processResize(self: *FileWidget) void { pub fn processEvents(self: *FileWidget) void { const transform = self.init_options.file.editor.transform != null; - const reorder = self.workspace().columns_drag_index != null or self.workspace().rows_drag_index != null or self.removed_sprite_indices != null; + const reorder = self.columnRowReorderActive() or self.removed_sprite_indices != null; // Try to ensure that selected animation frame index is valid if (self.init_options.file.selected_animation_index) |ai| { diff --git a/src/pixelart/CanvasData.zig b/src/pixelart/CanvasData.zig new file mode 100644 index 00000000..5367f544 --- /dev/null +++ b/src/pixelart/CanvasData.zig @@ -0,0 +1,1287 @@ +//! The pixel-art plugin's per-workspace-pane data. Each plugin that renders documents into a +//! workbench pane will typically want a struct like this to hold its per-pane state; pixel art +//! uses it for the canvas UI that wraps a document inside the workbench-provided content region: +//! the column/row rulers, the floating Edit pill and color-sample button, the transform dialog, +//! and the grid (column/row) reorder drag state, plus the matching draw helpers. +//! +//! It is pixel-art-owned and lives per pane. The plugin lazily allocates one (`ensure`) and +//! stashes the pointer in the workbench `Workspace.plugin_view_state` opaque slot; the workbench +//! never dereferences it and frees it through `plugin_view_destroy` when the pane is torn down. +//! State the shell itself needs (the pane's physical content rect, used to center load/save +//! toasts) intentionally stays on `Workspace`. +const std = @import("std"); +const dvui = @import("dvui"); +const fizzy = @import("../fizzy.zig"); +const icons = @import("icons"); + +const Workspace = fizzy.Editor.Workspace; +const File = fizzy.Internal.File; + +const CanvasData = @This(); + +// Grid (column/row) reorder drag state. Set by the rulers (`drawRulerContent`), consumed by +// `FileWidget` (reorder preview) and committed by `processColumnReorder`/`processRowReorder`. +columns_drag_name: []const u8 = undefined, +columns_drag_index: ?usize = null, +columns_target_id: ?dvui.Id = null, +columns_target_index: ?usize = null, +columns_removed_index: ?usize = null, +columns_insert_before_index: ?usize = null, + +rows_drag_name: []const u8 = undefined, +rows_drag_index: ?usize = null, +rows_target_id: ?dvui.Id = null, +rows_target_index: ?usize = null, +rows_removed_index: ?usize = null, +rows_insert_before_index: ?usize = null, + +horizontal_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given }, +vertical_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given }, + +horizontal_ruler_height: f32 = 0.0, +vertical_ruler_width: f32 = 0.0, + +/// Floating Edit-pill quick-access bar collapse state. Starts collapsed (single hamburger +/// button); the user toggles to expand the full action row. +edit_pill_expanded: bool = false, + +pub fn init(grouping: u64) CanvasData { + return .{ + .columns_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "column_drag_{d}", .{grouping}) catch "column_drag", + .rows_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "row_drag_{d}", .{grouping}) catch "row_drag", + }; +} + +/// The drag names are intentionally not freed here: `init` may have fallen back to a static +/// string literal on (effectively impossible) OOM, and freeing a literal is UB. This matches +/// the pre-relocation behavior where the names lived on `Workspace` and were never freed. +pub fn deinit(_: *CanvasData) void {} + +/// Get the pixel-art chrome for `ws`, lazily allocating it and registering its teardown on +/// first use. Called from the plugin's `drawDocument` each frame a document pane renders. +pub fn ensure(ws: *Workspace) *CanvasData { + if (ws.plugin_view_state) |p| return @ptrCast(@alignCast(p)); + const self = fizzy.app.allocator.create(CanvasData) catch @panic("OOM allocating CanvasData"); + self.* = CanvasData.init(ws.grouping); + ws.plugin_view_state = self; + ws.plugin_view_destroy = destroyOpaque; + return self; +} + +/// The data already attached to `ws`, or null if none exists yet (e.g. the pane has not +/// drawn a document this session). `FileWidget` uses this for its read-only reorder checks. +/// Only pixel art writes `plugin_view_state`, so the cast is sound. +pub fn fromWorkspace(ws: *Workspace) ?*CanvasData { + const p = ws.plugin_view_state orelse return null; + return @ptrCast(@alignCast(p)); +} + +/// `plugin_view_destroy` target: free the chrome when the workbench tears down its pane. +fn destroyOpaque(state: *anyopaque) void { + const self: *CanvasData = @ptrCast(@alignCast(state)); + self.deinit(); + fizzy.app.allocator.destroy(self); +} + +pub const RulerOrientation = enum { + horizontal, + vertical, +}; + +pub fn drawRuler(self: *CanvasData, file: *File, orientation: RulerOrientation) void { + const font = dvui.Font.theme(.body).larger(-1); + + const largest_label = std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{file.rows - 1}) catch { + dvui.log.err("Failed to allocate largest label", .{}); + return; + }; + const largest_label_size = font.textSize(largest_label); + const natural_scale = dvui.currentWindow().natural_scale; + const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical); + const base_ruler_size = largest_label_size.w + fizzy.editor.settings.ruler_padding; + + const ruler_thickness: f32 = switch (orientation) { + .horizontal => blk: { + self.horizontal_ruler_height = font.textSize("M").h + fizzy.editor.settings.ruler_padding; + break :blk self.horizontal_ruler_height; + }, + .vertical => blk: { + self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.editor.settings.ruler_padding); + break :blk self.vertical_ruler_width; + }, + }; + + switch (orientation) { + .horizontal => { + var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + }); + defer canvas_hbox.deinit(); + + var corner_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .min_size_content = .{ .h = self.vertical_ruler_width, .w = self.vertical_ruler_width }, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill), + }); + corner_box.deinit(); + + var top_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .min_size_content = .{ .h = ruler_thickness, .w = ruler_thickness }, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill), + }); + defer top_box.deinit(); + + self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, null); + }, + .vertical => { + var ruler_box = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .vertical, + .min_size_content = .{ .w = ruler_thickness, .h = 1.0 }, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill), + }); + defer ruler_box.deinit(); + + self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, largest_label_phys); + }, + } +} + +/// `largest_row_index_*` come from `drawRuler` (widest row index string and its measured size in physical pixels). +fn drawRulerContent( + self: *CanvasData, + file: *File, + font: dvui.Font, + orientation: RulerOrientation, + ruler_size: f32, + largest_row_index_label: []const u8, + largest_row_index_size_phys: ?dvui.Size.Physical, +) void { + const scale = file.editor.canvas.scale; + const canvas = file.editor.canvas; + + switch (orientation) { + .horizontal => { + self.horizontal_scroll_info.virtual_size.w = canvas.scroll_info.virtual_size.w; + self.horizontal_scroll_info.virtual_size.h = ruler_size; + self.horizontal_scroll_info.viewport.w = canvas.scroll_info.viewport.w; + self.horizontal_scroll_info.viewport.x = canvas.scroll_info.viewport.x; + }, + .vertical => { + self.vertical_scroll_info.virtual_size.h = canvas.scroll_info.virtual_size.h; + self.vertical_scroll_info.virtual_size.w = ruler_size; + self.vertical_scroll_info.viewport.h = canvas.scroll_info.viewport.h; + self.vertical_scroll_info.viewport.y = canvas.scroll_info.viewport.y; + }, + } + + const scroll_info = switch (orientation) { + .horizontal => &self.horizontal_scroll_info, + .vertical => &self.vertical_scroll_info, + }; + + var scroll_area = dvui.scrollArea(@src(), .{ + .scroll_info = scroll_info, + .container = true, + .process_events_after = true, + .horizontal_bar = .hide, + .vertical_bar = .hide, + }, .{ .expand = .both }); + defer scroll_area.deinit(); + + const scale_rect = switch (orientation) { + .horizontal => dvui.Rect{ .x = -canvas.origin.x, .y = 0, .w = 0, .h = 0 }, + .vertical => dvui.Rect{ .x = 0, .y = -canvas.origin.y, .w = 0, .h = 0 }, + }; + var scaler = dvui.scale(@src(), .{ .scale = &file.editor.canvas.scale }, .{ .rect = scale_rect }); + defer scaler.deinit(); + + const outer_rect: dvui.Rect = switch (orientation) { + .horizontal => .{ + .x = 0, + .y = 0, + .w = @as(f32, @floatFromInt(file.width())), + .h = ruler_size / scale, + }, + .vertical => .{ + .x = 0, + .y = 0, + .w = ruler_size / scale, + .h = @as(f32, @floatFromInt(file.height())), + }, + }; + var outer_box = dvui.box(@src(), .{ .dir = switch (orientation) { + .horizontal => .horizontal, + .vertical => .horizontal, + } }, .{ + .expand = .none, + .rect = outer_rect, + }); + defer outer_box.deinit(); + + const drag_name = switch (orientation) { + .horizontal => self.columns_drag_name, + .vertical => self.rows_drag_name, + }; + + var reorder = fizzy.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ + .expand = .both, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .background = false, + .corner_radius = dvui.Rect.all(0), + }); + defer reorder.deinit(); + + const reorder_box_dir: dvui.enums.Direction = switch (orientation) { + .horizontal => .horizontal, + .vertical => .vertical, + }; + var reorder_box = dvui.box(@src(), .{ .dir = reorder_box_dir }, .{ + .expand = .both, + .background = false, + .corner_radius = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + }); + defer reorder_box.deinit(); + + const ruler_stroke_color = dvui.themeGet().color(.control, .fill_hover).lighten(switch (orientation) { + .horizontal => 2.0, + .vertical => 0.0, + }); + + const edge_stroke_points = switch (orientation) { + .horizontal => .{ + reorder_box.data().rectScale().r.topRight(), + reorder_box.data().rectScale().r.bottomRight(), + }, + .vertical => .{ + reorder_box.data().rectScale().r.bottomRight(), + reorder_box.data().rectScale().r.bottomLeft(), + }, + }; + defer dvui.Path.stroke(.{ .points = &edge_stroke_points }, .{ + .color = ruler_stroke_color, + .thickness = 1.0, + }); + + const count = switch (orientation) { + .horizontal => file.columns, + .vertical => file.rows, + }; + const cell_min_size: dvui.Size = switch (orientation) { + .horizontal => .{ .w = @as(f32, @floatFromInt(file.column_width)), .h = 1.0 }, + .vertical => .{ .w = 1.0, .h = @as(f32, @floatFromInt(file.row_height)) }, + }; + const reorder_mode: fizzy.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) { + .horizontal => .any_y, + .vertical => .any_x, + }; + const reorder_expand: dvui.Options.Expand = switch (orientation) { + .horizontal => .vertical, + .vertical => .horizontal, + }; + + // Shared layout width for every row tick (widest index string); actual glyph size may differ per cell. + const vertical_row_layout_size_phys: ?dvui.Size.Physical = switch (orientation) { + .vertical => largest_row_index_size_phys, + .horizontal => null, + }; + + // Captured during iteration: the highlighted target slot (drop location) screen rect. + var target_rs_screen: ?dvui.RectScale = null; + + var index: usize = 0; + while (index < count) : (index += 1) { + var reorderable = reorder.reorderable(@src(), .{ + .mode = reorder_mode, + .clamp_to_edges = true, + }, .{ + .expand = reorder_expand, + .id_extra = index, + .padding = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .min_size_content = cell_min_size, + }); + defer reorderable.deinit(); + + if (reorderable.targetRectScale()) |trs| { + target_rs_screen = trs; + } + + var button_color = if (reorder.drag_point != null) dvui.themeGet().color(.control, .fill).opacity(0.85) else dvui.themeGet().color(.window, .fill); + + if (fizzy.dvui.hovered(reorderable.data())) { + button_color = dvui.themeGet().color(.control, .fill_hover); + dvui.cursorSet(.hand); + } + + var cell_box: dvui.BoxWidget = undefined; + cell_box.init(@src(), .{ .dir = .horizontal }, .{ + .expand = .both, + .background = true, + .color_fill = button_color, + .id_extra = index, + }); + + switch (orientation) { + .horizontal => { + if (reorderable.floating()) { + self.columns_drag_index = index; + reorder.reorderable_size.h = 0.0; + dvui.cursorSet(.hand); + } + if (reorderable.removed()) self.columns_removed_index = index; + if (reorderable.insertBefore()) self.columns_insert_before_index = index; + if (reorderable.targetID()) |target_id| self.columns_target_id = target_id; + if (self.columns_drag_index) |_| { + var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt); + mouse_pt.y = 0.0; + mouse_pt.x = std.math.clamp(mouse_pt.x, 0.0, @as(f32, @floatFromInt(file.width() - 1))); + self.columns_target_index = file.columnIndex(mouse_pt); + } + }, + .vertical => { + if (reorderable.floating()) { + self.rows_drag_index = index; + reorder.reorderable_size.w = 0.0; + dvui.cursorSet(.hand); + } + if (reorderable.removed()) self.rows_removed_index = index; + if (reorderable.insertBefore()) self.rows_insert_before_index = index; + if (reorderable.targetID()) |target_id| self.rows_target_id = target_id; + if (self.rows_drag_index) |_| { + var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt); + mouse_pt.x = 0.0; + mouse_pt.y = std.math.clamp(mouse_pt.y, 0.0, @as(f32, @floatFromInt(file.height() - 1))); + self.rows_target_index = file.rowIndex(mouse_pt); + } + }, + } + + { + defer cell_box.deinit(); + + // The dragged item's cell_box is parented to the reorderable's floating widget + // (rendered at the mouse position). We collapse that floating widget to h/w = 0 + // above, but `dvui.renderText` is not clipped by that, so the label would still + // appear at the cursor. Skip the visible cell rendering entirely while floating; + // the dragged label is drawn over the highlighted target slot below instead. + if (!reorderable.floating()) { + cell_box.drawBackground(); + + const label = switch (orientation) { + .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(index)) catch { + dvui.log.err("Failed to allocate label", .{}); + return; + }, + .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{index}) catch { + dvui.log.err("Failed to allocate label", .{}); + return; + }, + }; + + self.drawRulerLabel(.{ + .font = font, + .label = label, + .rect = cell_box.data().rectScale().r, + .color = dvui.themeGet().color(.control, .text).opacity(0.5), + .mode = switch (orientation) { + .horizontal => .horizontal, + .vertical => .vertical, + }, + .largest_label = if (orientation == .vertical) largest_row_index_label else null, + .ref_size_physical = vertical_row_layout_size_phys, + }); + + const cell_rect = cell_box.data().rectScale().r; + const cell_stroke_points = switch (orientation) { + .horizontal => .{ cell_rect.topLeft(), cell_rect.bottomLeft() }, + .vertical => .{ cell_rect.topLeft(), cell_rect.topRight() }, + }; + dvui.Path.stroke(.{ .points = &cell_stroke_points }, .{ .color = ruler_stroke_color, .thickness = 2.0 }); + } + + loop: for (dvui.events()) |*e| { + if (!cell_box.matchEvent(e)) continue; + + switch (e.evt) { + .mouse => |me| { + if (me.action == .press and me.button.pointer()) { + e.handle(@src(), cell_box.data()); + dvui.captureMouse(cell_box.data(), e.num); + dvui.dragPreStart(me.p, .{ + .size = reorderable.data().rectScale().r.size(), + .offset = reorderable.data().rectScale().r.topLeft().diff(me.p), + }); + } else if (me.action == .release and me.button.pointer()) { + dvui.captureMouse(null, e.num); + dvui.dragEnd(); + switch (orientation) { + .horizontal => self.columns_drag_index = null, + .vertical => self.rows_drag_index = null, + } + } else if (me.action == .motion) { + if (dvui.captured(cell_box.data().id)) { + e.handle(@src(), cell_box.data()); + if (dvui.dragging(me.p, null)) |_| { + reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0); + break :loop; + } + } + } + }, + else => {}, + } + } + } + } + + const final_slot_id = switch (orientation) { + .horizontal => file.columns, + .vertical => file.rows, + }; + if (reorder.needFinalSlot()) { + var reorderable = reorder.reorderable(@src(), .{ + .mode = reorder_mode, + .last_slot = true, + .clamp_to_edges = true, + }, .{ + .expand = reorder_expand, + .id_extra = final_slot_id, + .padding = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .min_size_content = cell_min_size, + }); + defer reorderable.deinit(); + + if (reorderable.targetRectScale()) |trs| { + target_rs_screen = trs; + } + + if (reorderable.insertBefore()) { + switch (orientation) { + .horizontal => self.columns_insert_before_index = final_slot_id, + .vertical => self.rows_insert_before_index = final_slot_id, + } + } + } + + // Drag overlay: draw the dragged column/row label on the highlighted target slot in + // highlight-text color (no extra fill, the reorderable's own focus fill is the + // background) and a thick err-colored marker line at the dragged-from position in the + // ruler that lines up with the equivalent indicator in the file canvas. + const drag_idx_for_overlay = switch (orientation) { + .horizontal => self.columns_drag_index, + .vertical => self.rows_drag_index, + }; + if (drag_idx_for_overlay) |di| { + const target_idx_opt = switch (orientation) { + .horizontal => self.columns_target_index, + .vertical => self.rows_target_index, + }; + const same_slot = target_idx_opt == di; + + if (target_rs_screen) |trs| { + const drag_label_opt: ?[]const u8 = switch (orientation) { + .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(di)) catch null, + .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{di}) catch null, + }; + if (drag_label_opt) |drag_label| { + if (same_slot) { + // Reorderable still draws theme focus fill for the drop target; paint control + // hover on top so "no move" matches ruler button hover styling. + trs.r.fill(.all(0), .{ .color = dvui.themeGet().color(.control, .fill_hover), .fade = 1.0 }); + } + self.drawRulerLabel(.{ + .font = font, + .label = drag_label, + .rect = trs.r, + .color = if (same_slot) + dvui.themeGet().color(.control, .text).opacity(0.5) + else + dvui.themeGet().color(.highlight, .text), + .mode = switch (orientation) { + .horizontal => .horizontal, + .vertical => .vertical, + }, + .largest_label = if (orientation == .vertical) largest_row_index_label else null, + .ref_size_physical = vertical_row_layout_size_phys, + }); + } + } + + // Use the canvas data->screen mapping for the cross-axis position so the marker + // line aligns exactly with the err indicator drawn over the file canvas grid. + // The other axis uses the ruler's own screen extents so the line fills the ruler. + const target_idx_for_line = switch (orientation) { + .horizontal => self.columns_target_index, + .vertical => self.rows_target_index, + }; + if (target_idx_for_line) |ti| { + if (di != ti) { + const removed_data_rect = switch (orientation) { + .horizontal => file.columnRect(di), + .vertical => file.rowRect(di), + }; + const removed_canvas_screen = file.editor.canvas.screenFromDataRect(removed_data_rect); + const ruler_screen = outer_box.data().contentRectScale().r; + const err_color = dvui.themeGet().color(.err, .fill); + const thickness = 3.0 * dvui.currentWindow().natural_scale; + switch (orientation) { + .horizontal => { + const edge_x = if (di < ti) + removed_canvas_screen.x + else + removed_canvas_screen.x + removed_canvas_screen.w; + dvui.Path.stroke(.{ .points = &.{ + .{ .x = edge_x, .y = ruler_screen.y }, + .{ .x = edge_x, .y = ruler_screen.y + ruler_screen.h }, + } }, .{ .thickness = thickness, .color = err_color }); + }, + .vertical => { + const edge_y = if (di < ti) + removed_canvas_screen.y + else + removed_canvas_screen.y + removed_canvas_screen.h; + dvui.Path.stroke(.{ .points = &.{ + .{ .x = ruler_screen.x, .y = edge_y }, + .{ .x = ruler_screen.x + ruler_screen.w, .y = edge_y }, + } }, .{ .thickness = thickness, .color = err_color }); + }, + } + } + } + } +} + +pub const TextLabelOptions = struct { + pub const Mode = enum { + horizontal, + vertical, + }; + + font: dvui.Font, + label: []const u8, + rect: dvui.Rect.Physical, + color: dvui.Color, + mode: Mode = .horizontal, + /// Widest row index string (e.g. `"99"`); layout cell size uses this, text may be a shorter index. + largest_label: ?[]const u8 = null, + /// When set, layout size for that widest string (already × `natural_scale`); skips `textSize(largest_label)` per cell. + ref_size_physical: ?dvui.Size.Physical = null, +}; + +pub fn drawRulerLabel(_: *CanvasData, options: TextLabelOptions) void { + const font = options.font; + const label = options.label; + const rect = options.rect; + const color = options.color; + const natural = dvui.currentWindow().natural_scale; + + const ref_for_layout = options.largest_label orelse label; + const label_size = options.ref_size_physical orelse font.textSize(ref_for_layout).scale(natural, dvui.Size.Physical); + const actual_label_size = if (std.mem.eql(u8, ref_for_layout, label)) + label_size + else + font.textSize(label).scale(natural, dvui.Size.Physical); + + const padding = fizzy.editor.settings.ruler_padding * natural; + + var label_rect = rect; + + if (label_size.w + padding <= label_rect.w and options.mode == .horizontal) { + label_rect.h = label_size.h + padding; + label_rect.x += (label_rect.w - actual_label_size.w) / 2.0; + label_rect.y += (label_rect.h - actual_label_size.h) / 2.0; + + dvui.renderText(.{ + .text = label, + .font = font, + .color = color, + .rs = .{ + .r = label_rect, + .s = natural, + }, + }) catch { + dvui.log.err("Failed to render text", .{}); + }; + } else if (label_size.h + padding <= label_rect.h and options.mode == .vertical) { + label_rect.w = label_size.h + padding; + label_rect.x += (label_rect.w - actual_label_size.w) / 2.0; + label_rect.y += (label_rect.h - actual_label_size.h) / 2.0; + + dvui.renderText(.{ + .text = label, + .font = font, + .color = color, + .rs = .{ + .r = label_rect, + .s = natural, + }, + }) catch { + dvui.log.err("Failed to render text", .{}); + }; + } +} + +pub fn processColumnReorder(self: *CanvasData, file: *File) void { + if (self.columns_removed_index) |columns_removed_index| { + if (self.columns_insert_before_index) |columns_insert_before_index| { + defer self.columns_removed_index = null; + defer self.columns_insert_before_index = null; + + if (columns_removed_index == columns_insert_before_index or columns_removed_index + 1 == columns_insert_before_index) return; + + file.reorderColumns(columns_removed_index, columns_insert_before_index) catch { + dvui.log.err("Failed to reorder columns", .{}); + return; + }; + + // We'll store the previous indices for clarity. + const prev_removed_index = columns_removed_index; + const prev_insert_before_index = columns_insert_before_index; + + if (prev_removed_index < prev_insert_before_index) { + file.history.append(.{ + .reorder_col_row = .{ + .mode = .columns, + .removed_index = prev_insert_before_index - 1, + .insert_before_index = prev_removed_index, + }, + }) catch { + dvui.log.err("Failed to append history", .{}); + }; + } else { + file.history.append(.{ + .reorder_col_row = .{ + .mode = .columns, + .removed_index = prev_insert_before_index, + .insert_before_index = prev_removed_index + 1, + }, + }) catch { + dvui.log.err("Failed to append history", .{}); + }; + } + } + } +} + +pub fn processRowReorder(self: *CanvasData, file: *File) void { + if (self.rows_removed_index) |rows_removed_index| { + if (self.rows_insert_before_index) |rows_insert_before_index| { + defer self.rows_removed_index = null; + defer self.rows_insert_before_index = null; + if (rows_removed_index == rows_insert_before_index or rows_removed_index + 1 == rows_insert_before_index) return; + + file.reorderRows(rows_removed_index, rows_insert_before_index) catch { + dvui.log.err("Failed to reorder rows", .{}); + return; + }; + + // We'll store the previous indices for clarity. + const prev_removed_index = rows_removed_index; + const prev_insert_before_index = rows_insert_before_index; + + if (prev_removed_index < prev_insert_before_index) { + file.history.append(.{ + .reorder_col_row = .{ + .mode = .rows, + .removed_index = prev_insert_before_index - 1, + .insert_before_index = prev_removed_index, + }, + }) catch { + dvui.log.err("Failed to append history", .{}); + }; + } else { + file.history.append(.{ + .reorder_col_row = .{ + .mode = .rows, + .removed_index = prev_insert_before_index, + .insert_before_index = prev_removed_index + 1, + }, + }) catch { + dvui.log.err("Failed to append history", .{}); + }; + } + } + } +} + +pub fn drawTransformDialog(_: *CanvasData, file: *File, container: *dvui.WidgetData) void { + if (file.editor.transform) |*transform| { + var rect = container.rect; + rect.w = 0; + rect.h = 0; + + var fw: dvui.FloatingWidget = undefined; + fw.init(@src(), .{}, .{ + .rect = .{ .x = container.rectScale().r.toNatural().x + 10, .y = container.rectScale().r.toNatural().y + 10, .w = 0, .h = 0 }, + .expand = .none, + .background = true, + .color_fill = dvui.themeGet().color(.control, .fill), + .corner_radius = dvui.Rect.all(8), + .box_shadow = .{ + .color = .black, + .alpha = 0.2, + .fade = 8, + .corner_radius = dvui.Rect.all(8), + }, + }); + defer fw.deinit(); + + var anim = dvui.animate(@src(), .{ .kind = .vertical, .duration = 450_000, .easing = dvui.easing.outBack }, .{}); + defer anim.deinit(); + + var anim_box = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = false, + }); + defer anim_box.deinit(); + + dvui.labelNoFmt(@src(), "TRANSFORM", .{ .align_x = 0.5 }, .{ + .padding = dvui.Rect.all(4), + .expand = .horizontal, + .font = dvui.Font.theme(.heading).withWeight(.bold), + }); + _ = dvui.separator(@src(), .{ .expand = .horizontal }); + + _ = dvui.spacer(@src(), .{ .expand = .horizontal }); + + var degrees: f32 = std.math.radiansToDegrees(transform.rotation); + + var slider_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .background = false, + }); + + if (dvui.sliderEntry(@src(), "{d:0.0}°", .{ + .value = °rees, + .min = 0, + .max = 360, + .interval = 1, + }, .{ .expand = .horizontal, .color_fill = dvui.themeGet().color(.window, .fill) })) { + transform.rotation = std.math.degreesToRadians(degrees); + } + slider_box.deinit(); + + if (transform.ortho) { + var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ + .expand = .horizontal, + .background = false, + }); + defer box.deinit(); + dvui.label(@src(), "Width: {d:0.0}", .{transform.point(.bottom_left).diff(transform.point(.bottom_right).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) }); + dvui.label(@src(), "Height: {d:0.0}", .{transform.point(.top_left).diff(transform.point(.bottom_left).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) }); + } + + { + var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ + .expand = .horizontal, + .background = false, + }); + defer box.deinit(); + if (dvui.buttonIcon(@src(), "transform_cancel", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .err, .expand = .horizontal })) { + fizzy.editor.cancel() catch { + dvui.log.err("Failed to cancel transform", .{}); + }; + } + if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) { + fizzy.editor.accept() catch { + dvui.log.err("Failed to accept transform", .{}); + }; + } + } + } +} + +/// Floating rounded-pill quick-access bar anchored to the top-right of the workspace +/// canvas. Mirrors the Edit menu (Undo / Redo / Copy / Paste / Transform / Grid Layout) +/// with icon-only round buttons sized to match the toolbox buttons. Starts collapsed as a +/// single hamburger circle; tapping toggles the row of action buttons in/out with a +/// width animation. +pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { + const file = fizzy.editor.activeFile() orelse return; + + const button_size: f32 = 36; + const button_gap: f32 = 6; + const pill_padding: f32 = 6; + const margin: f32 = 10; + // Canvas scroll area uses a non-overlay vertical bar on the right edge; keep the + // pill clear of it (see `CanvasWidget.install` + dvui `ScrollBarWidget` width). + const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w; + // Icons render at ~60% of their previous size — previous padding was 0.22 (icon + // ≈ 56% of button); new padding is 0.33 so the icon ends up ≈ 34% of the button, + // which is roughly 60% of the prior icon footprint. + const icon_padding: f32 = button_size * 0.33; + + const Action = enum { save, exportd, undo, redo, copy, paste, transform, grid_layout }; + const Entry = struct { + action: Action, + tvg: []const u8, + tooltip: []const u8, + }; + + const entries = [_]Entry{ + .{ .action = .save, .tvg = icons.tvg.lucide.save, .tooltip = "Save" }, + .{ .action = .exportd, .tvg = icons.tvg.lucide.@"file-output", .tooltip = "Export" }, + .{ .action = .undo, .tvg = icons.tvg.lucide.undo, .tooltip = "Undo" }, + .{ .action = .redo, .tvg = icons.tvg.lucide.redo, .tooltip = "Redo" }, + .{ .action = .copy, .tvg = icons.tvg.lucide.copy, .tooltip = "Copy" }, + .{ .action = .paste, .tvg = icons.tvg.lucide.@"clipboard-paste", .tooltip = "Paste" }, + .{ .action = .transform, .tvg = icons.tvg.lucide.scaling, .tooltip = "Transform" }, + .{ .action = .grid_layout, .tvg = icons.tvg.lucide.@"layout-grid", .tooltip = "Grid Layout" }, + }; + + // Vertical pill: width is fixed (one button + padding), height animates between a + // single-button "collapsed" state and the full-stack "expanded" state. Most screens + // have more vertical real estate than horizontal, so growing the pill downward keeps + // it from eating into the canvas's working width. + const pill_w: f32 = button_size + 2 * pill_padding; + const collapsed_h: f32 = button_size + 2 * pill_padding; + const expanded_h: f32 = @as(f32, @floatFromInt(entries.len + 1)) * button_size + + @as(f32, @floatFromInt(entries.len)) * button_gap + 2 * pill_padding; + const pill_radius: f32 = pill_w / 2; + const btn_radius: f32 = button_size / 2; + + // Drive the expand/collapse with a dvui animation. Look up the current value, and on + // a toggle click kick off a new animation between the current value and the target. + const anim_id = dvui.Id.update(container.id, "edit_pill_expand"); + var anim_value: f32 = if (self.edit_pill_expanded) 1.0 else 0.0; + if (dvui.animationGet(anim_id, "_t")) |a| anim_value = std.math.clamp(a.value(), 0.0, 1.0); + + const pill_h: f32 = collapsed_h + (expanded_h - collapsed_h) * anim_value; + + // Compute the scroll-area rect — the canvas region inside the rulers. We pull this + // off the live `canvas_vbox` (so the values are this frame's, not a stale latch) and + // subtract the ruler thickness from the top/left. Anchoring against this rect means + // the pill follows the workspace exactly: as a split is dragged shut the canvas area + // shrinks, and once it's narrower than the pill we bail and draw nothing this frame — + // so closing splits cleanly hides the menu. + const wb = container.rectScale().r.toNatural(); + const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; + const canvas_nat = dvui.Rect{ + .x = wb.x + ruler_left, + .y = wb.y + ruler_top, + .w = wb.w - ruler_left, + .h = wb.h - ruler_top, + }; + + if (canvas_nat.w < pill_w + margin + right_margin or canvas_nat.h < collapsed_h + 2 * margin) return; + + const pill_x: f32 = canvas_nat.x + canvas_nat.w - right_margin - pill_w; + const pill_y: f32 = canvas_nat.y + margin; + + // Clamp the bottom edge so the expanded pill never spills past the canvas area — + // FloatingWidget bypasses parent clipping, so we cap the height explicitly. + const max_pill_h: f32 = canvas_nat.h - 2 * margin; + const effective_pill_h: f32 = @min(pill_h, max_pill_h); + + var fw: dvui.FloatingWidget = undefined; + fw.init(@src(), .{}, .{ + .rect = .{ + .x = pill_x, + .y = pill_y, + .w = pill_w, + .h = effective_pill_h, + }, + .expand = .none, + .background = self.edit_pill_expanded, + .color_fill = dvui.themeGet().color(.window, .fill), + .corner_radius = dvui.Rect.all(pill_radius), + .box_shadow = if (self.edit_pill_expanded) .{ + .color = .black, + .alpha = 0.25, + .fade = 10, + .offset = .{ .x = 0, .y = 3 }, + .corner_radius = dvui.Rect.all(pill_radius), + } else null, + }); + defer fw.deinit(); + + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = false, + .padding = dvui.Rect.all(pill_padding), + }); + defer vbox.deinit(); + + // Hamburger toggle is always present at the top of the pill; the stack of action + // buttons grows downward beneath it as the pill expands. + { + var btn: dvui.ButtonWidget = undefined; + btn.init(@src(), .{}, .{ + .id_extra = entries.len, // distinct from action button ids below + .min_size_content = .{ .w = button_size, .h = button_size }, + .expand = .none, + .gravity_x = 0.5, + .gravity_y = 0.0, + .background = true, + .corner_radius = dvui.Rect.all(btn_radius), + .color_fill = dvui.themeGet().color(.content, .fill), + .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), + .color_border = .transparent, + .padding = .all(0), + .margin = .{}, + .box_shadow = .{ + .color = .black, + .alpha = 0.2, + .fade = 4, + .offset = .{ .x = 0, .y = 2 }, + .corner_radius = dvui.Rect.all(btn_radius), + }, + }); + defer btn.deinit(); + btn.processEvents(); + btn.drawBackground(); + + const icon_color = dvui.themeGet().color(.content, .text); + dvui.icon( + @src(), + "edit_pill_toggle", + icons.tvg.lucide.menu, + .{ .stroke_color = icon_color, .fill_color = icon_color }, + .{ + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1.0, .h = 1.0 }, + .padding = dvui.Rect.all(icon_padding), + }, + ); + + if (btn.clicked()) { + self.edit_pill_expanded = !self.edit_pill_expanded; + const target: f32 = if (self.edit_pill_expanded) 1.0 else 0.0; + dvui.animation(anim_id, "_t", .{ + .start_val = anim_value, + .end_val = target, + .end_time = 250_000, + .easing = dvui.easing.outBack, + }); + } + } + + // Action buttons live inside a scroll area so the pill stays the right width and + // never visually "squishes" when there isn't enough vertical room — instead the + // overflow buttons become reachable via vertical scroll inside the pill. Bars are + // hidden to preserve the rounded-pill look; touch / wheel still drives the scroll. + var actions_scroll = dvui.scrollArea(@src(), .{ + .vertical_bar = .hide, + .horizontal_bar = .hide, + }, .{ + .expand = .both, + .background = false, + .padding = .{}, + .margin = .{}, + .border = dvui.Rect.all(0), + .color_fill = .transparent, + }); + defer actions_scroll.deinit(); + + // Action buttons stacked below the hamburger. We draw them all and let the + // scrollArea handle any overflow when the pill is clamped to the canvas height. + for (entries, 0..) |entry, i| { + const enabled: bool = switch (entry.action) { + .save => file.dirty(), + .undo => file.history.undo_stack.items.len > 0, + .redo => file.history.redo_stack.items.len > 0, + else => true, + }; + + var btn: dvui.ButtonWidget = undefined; + btn.init(@src(), .{}, .{ + .id_extra = i, + .min_size_content = .{ .w = button_size, .h = button_size }, + .expand = .none, + .gravity_x = 0.5, + .background = true, + .corner_radius = dvui.Rect.all(btn_radius), + .color_fill = dvui.themeGet().color(.content, .fill), + .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), + .color_border = .transparent, + .padding = .all(0), + .margin = .{ .y = button_gap }, + .box_shadow = .{ + .color = .black, + .alpha = 0.2, + .fade = 4, + .offset = .{ .x = 0, .y = 2 }, + .corner_radius = dvui.Rect.all(btn_radius), + }, + }); + defer btn.deinit(); + btn.processEvents(); + btn.drawBackground(); + + const icon_color = if (enabled) dvui.themeGet().color(.content, .text) else dvui.themeGet().color(.content, .text).opacity(0.35); + + dvui.icon( + @src(), + entry.tooltip, + entry.tvg, + .{ .stroke_color = icon_color, .fill_color = icon_color }, + .{ + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1.0, .h = 1.0 }, + .padding = dvui.Rect.all(icon_padding), + }, + ); + + // Suppress activation while collapsed (or mid-animation) so a stray tap on a + // partially-visible button doesn't fire an Edit action behind the hamburger. + const fully_expanded = anim_value >= 0.999; + if (btn.clicked() and enabled and fully_expanded) { + switch (entry.action) { + .save => fizzy.editor.save() catch { + dvui.log.err("Failed to save", .{}); + }, + .exportd => { + // Open the Export dialog (same configuration the `export` keybind uses). + var mutex = fizzy.dvui.dialog(@src(), .{ + .displayFn = fizzy.Editor.Dialogs.Export.dialog, + .callafterFn = fizzy.Editor.Dialogs.Export.callAfter, + .title = "Export...", + .ok_label = "Export", + .cancel_label = "Cancel", + .resizeable = false, + .modal = false, + .header_kind = .info, + .default = .ok, + }); + mutex.mutex.unlock(dvui.io); + }, + .undo => file.history.undoRedo(file, .undo) catch { + dvui.log.err("Failed to undo", .{}); + }, + .redo => file.history.undoRedo(file, .redo) catch { + dvui.log.err("Failed to redo", .{}); + }, + .copy => fizzy.editor.copy() catch { + dvui.log.err("Failed to copy", .{}); + }, + .paste => fizzy.editor.paste() catch { + dvui.log.err("Failed to paste", .{}); + }, + .transform => fizzy.editor.transform() catch { + dvui.log.err("Failed to start transform", .{}); + }, + .grid_layout => fizzy.editor.requestGridLayoutDialog(), + } + } + } +} + +/// Floating round button anchored just to the left of the Edit pill at the top-right of +/// the canvas. Tapping it shows a tooltip explaining the gesture; the primary action is +/// to drag from the button toward whatever pixel you want to sample. The button itself +/// stays put — instead, while the drag is in progress, we route the touch position +/// through to `file.editor.canvas.sample_data_point` so `FileWidget.drawSample` renders +/// the existing color-dropper magnifier at the touch location. On release we read the +/// color underneath the sample point and apply it to the primary color slot. +pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void { + const file = fizzy.editor.activeFile() orelse return; + + const pill_button_size: f32 = 36; + const pill_padding: f32 = 6; + const pill_outer_w: f32 = pill_button_size + 2 * pill_padding; + const button_size: f32 = 36; + const btn_radius: f32 = button_size / 2; + const icon_padding: f32 = button_size * 0.33; + const margin: f32 = 10; + const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w; + const gap: f32 = 6; + + // Anchor against the same canvas-scroll-area rect the pill uses. + const wb = container.rectScale().r.toNatural(); + const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; + const canvas_nat = dvui.Rect{ + .x = wb.x + ruler_left, + .y = wb.y + ruler_top, + .w = wb.w - ruler_left, + .h = wb.h - ruler_top, + }; + + // Only draw when the canvas area can fit pill + gap + sample button + margins. + if (canvas_nat.w < pill_outer_w + gap + button_size + margin + right_margin) return; + if (canvas_nat.h < button_size + 2 * margin) return; + + const btn_x = canvas_nat.x + canvas_nat.w - right_margin - pill_outer_w - gap - button_size; + // Match the hamburger row inside the pill (pill top + inner vbox padding). + const btn_y = canvas_nat.y + margin + pill_padding; + + var fw: dvui.FloatingWidget = undefined; + fw.init(@src(), .{}, .{ + .rect = .{ .x = btn_x, .y = btn_y, .w = button_size, .h = button_size }, + .expand = .none, + .background = false, + }); + defer fw.deinit(); + + var btn: dvui.ButtonWidget = undefined; + // `touch_drag = true` keeps `ButtonWidget`'s own capture alive while the touch is + // dragging away from the button — without it, dvui's default `clickedEx` releases + // capture as soon as the drag crosses the threshold (treating the gesture as a + // canceled scroll), which would also cancel our custom drag-to-sample handler. + btn.init(@src(), .{ .touch_drag = true }, .{ + .expand = .both, + .background = true, + .min_size_content = .{ .w = button_size, .h = button_size }, + .corner_radius = dvui.Rect.all(btn_radius), + .color_fill = dvui.themeGet().color(.content, .fill), + .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), + .color_border = .transparent, + .padding = .all(0), + .margin = .{}, + .box_shadow = .{ + .color = .black, + .alpha = 0.2, + .fade = 4, + .offset = .{ .x = 0, .y = 2 }, + .corner_radius = dvui.Rect.all(btn_radius), + }, + }); + defer btn.deinit(); + + // Persistent drag state (a press is "drag-sampling" once motion clears the dvui drag + // threshold). Stored via dataSet because the button widget is recreated each frame. + const drag_state_id = dvui.Id.update(container.id, "sample_button_drag"); + var is_drag_sampling = dvui.dataGet(null, drag_state_id, "active", bool) orelse false; + var did_sample = dvui.dataGet(null, drag_state_id, "did_sample", bool) orelse false; + + // The button's screen rect is the "press home base"; events that happen here belong + // to us regardless of whether motion has carried the pointer away. + const btn_rs = btn.data().rectScale(); + + // Custom event handling runs *before* `btn.processEvents()` so we can claim the + // press / motion / release events first. `ButtonWidget.clickedEx` ALWAYS releases + // mouse capture and ends the drag on a release event (regardless of touch_drag) — + // if we ran after it, our release branch would see `dvui.captured(...)` already + // false and the magnifier would stay stuck on screen. Calling `e.handle(...)` here + // makes `clickedEx`'s match-event check skip these events entirely, so the button + // leaves our gesture alone. + for (dvui.events()) |*e| { + if (e.evt != .mouse) continue; + const me = e.evt.mouse; + + switch (me.action) { + .press => { + if (!me.button.pointer()) continue; + if (!btn_rs.r.contains(me.p)) continue; + e.handle(@src(), btn.data()); + dvui.captureMouse(btn.data(), e.num); + dvui.dragPreStart(me.p, .{ .name = "sample_button_drag" }); + is_drag_sampling = false; + did_sample = false; + }, + .motion => { + if (!dvui.captured(btn.data().id)) continue; + if (dvui.dragging(me.p, "sample_button_drag")) |_| { + is_drag_sampling = true; + if (file.editor.canvas.samplePointerInViewport(me.p)) { + const data_pt = file.editor.canvas.dataFromScreenPoint(me.p); + dvui.dataSet(null, file.editor.canvas.id, "sample_data_point", data_pt); + did_sample = true; + } else { + dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point"); + } + dvui.refresh(null, @src(), file.editor.canvas.id); + e.handle(@src(), btn.data()); + } + }, + .release => { + if (!me.button.pointer()) continue; + if (!dvui.captured(btn.data().id)) continue; + e.handle(@src(), btn.data()); + dvui.captureMouse(null, e.num); + dvui.dragEnd(); + + if (is_drag_sampling and did_sample and file.editor.canvas.samplePointerInViewport(me.p)) { + const data_pt = file.editor.canvas.dataFromScreenPoint(me.p); + fizzy.dvui.FileWidget.sampleColorAtPoint(file, data_pt, false, true, true); + } + + // Clear sample state so the magnifier disappears on the next frame. + dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point"); + is_drag_sampling = false; + did_sample = false; + dvui.refresh(null, @src(), file.editor.canvas.id); + }, + else => {}, + } + } + + // Persist the drag state for the next frame's widget recreate. + dvui.dataSet(null, drag_state_id, "active", is_drag_sampling); + dvui.dataSet(null, drag_state_id, "did_sample", did_sample); + + // Now let the button run its own pass to handle hover styling against any remaining + // (non-claimed) events — i.e. plain mouse hover when we're not in a drag. + btn.processEvents(); + btn.drawBackground(); + + const icon_color = dvui.themeGet().color(.content, .text); + dvui.icon( + @src(), + "sample_dropper", + icons.tvg.lucide.pipette, + .{ .stroke_color = icon_color, .fill_color = icon_color }, + .{ + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1.0, .h = 1.0 }, + .padding = dvui.Rect.all(icon_padding), + }, + ); + + // While the drag is in progress, hide the OS cursor entirely so only the canvas + // magnifier (drawn at the touch point via `FileWidget.drawSample`) communicates + // where the sample is happening. Set after `btn.processEvents()` so it overrides + // the `.hand` hover cursor `clickedEx` would otherwise leave in place. + if (is_drag_sampling) { + dvui.cursorSet(.hidden); + } + + // Tooltip prompting the gesture. We hide it during an active sample drag so it + // doesn't compete with the magnifier on screen. + if (!is_drag_sampling) { + var tooltip: dvui.FloatingTooltipWidget = undefined; + tooltip.init(@src(), .{ + .active_rect = btn.data().rectScale().r, + .delay = 350_000, + }, .{ + .color_fill = dvui.themeGet().color(.window, .fill), + .border = dvui.Rect.all(0), + .box_shadow = .{ + .color = .black, + .shrink = 0, + .corner_radius = dvui.Rect.all(8), + .offset = .{ .x = 0, .y = 2 }, + .fade = 4, + .alpha = 0.2, + }, + }); + defer tooltip.deinit(); + + if (tooltip.shown()) { + var anim = dvui.animate(@src(), .{ .kind = .alpha, .duration = 250_000 }, .{ .expand = .both }); + defer anim.deinit(); + + var tl = dvui.textLayout(@src(), .{}, .{ + .background = false, + .padding = dvui.Rect.all(6), + }); + tl.format("Drag to sample color", .{}, .{ .font = dvui.Font.theme(.body) }); + tl.deinit(); + } + } +} diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig index 098520be..0db81045 100644 --- a/src/pixelart/plugin.zig +++ b/src/pixelart/plugin.zig @@ -7,6 +7,7 @@ const builtin = @import("builtin"); const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const sdk = fizzy.sdk; +const CanvasData = @import("CanvasData.zig"); const DocHandle = sdk.DocHandle; const Internal = fizzy.Internal; @@ -90,18 +91,28 @@ fn closeDocument(_: *anyopaque, doc: DocHandle) void { /// current dvui parent). The workbench owns only the container + tab/split frame and sets /// `canvas.id` / `workspace_handle` / `center` before routing here; pixel art owns the /// entire region: rulers, the canvas hbox, the transform/edit/sample overlays, the editing -/// widget, and the sample magnifier. The per-workspace ruler/overlay state + draw helpers -/// still live on `Workspace` for now (recovered via `ofFile`); they relocate here in 3C/2b. +/// widget, and the sample magnifier. The per-pane ruler/overlay/reorder state + draw helpers +/// live on the pixel-art-owned `CanvasData` (stashed in the pane's `plugin_view_state`). fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { const file = docFile(doc); const ws = fizzy.Editor.Workspace.ofFile(file) orelse return; + const chrome = CanvasData.ensure(ws); const container = dvui.parentGet().data(); + // Grid (column/row) reorder is driven by the rulers and consumed by `FileWidget`; commit + // the pending reorder and clear the per-frame drag indices after the whole document (incl. + // the file widget) has drawn. Registered first so they run last, matching the order the + // workbench `Workspace.draw` used before this view was relocated here. + defer chrome.columns_drag_index = null; + defer chrome.rows_drag_index = null; + defer chrome.processColumnReorder(file); + defer chrome.processRowReorder(file); + fizzy.perf.canvasPaneDrawn(); if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) { defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .top, .{}); - ws.drawRuler(.horizontal); + chrome.drawRuler(file, .horizontal); } var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); @@ -109,13 +120,13 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) { defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .left, .{}); - ws.drawRuler(.vertical); + chrome.drawRuler(file, .vertical); } - ws.drawTransformDialog(container); - ws.drawEditPill(container); + chrome.drawTransformDialog(file, container); + chrome.drawEditPill(container); // Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom). - ws.drawSampleButton(container); + chrome.drawSampleButton(container); if (ws.grouping != file.editor.grouping) return; diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig index dcea40e9..75a0cb74 100644 --- a/src/workbench/Workspace.zig +++ b/src/workbench/Workspace.zig @@ -23,29 +23,14 @@ tabs_drag_index: ?usize = null, tabs_removed_index: ?usize = null, tabs_insert_before_index: ?usize = null, -columns_drag_name: []const u8 = undefined, -columns_drag_index: ?usize = null, -columns_target_id: ?dvui.Id = null, -columns_target_index: ?usize = null, -columns_removed_index: ?usize = null, -columns_insert_before_index: ?usize = null, - -rows_drag_name: []const u8 = undefined, -rows_drag_index: ?usize = null, -rows_target_id: ?dvui.Id = null, -rows_target_index: ?usize = null, -rows_removed_index: ?usize = null, -rows_insert_before_index: ?usize = null, - -horizontal_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given }, -vertical_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given }, - -horizontal_ruler_height: f32 = 0.0, -vertical_ruler_width: f32 = 0.0, - -/// Floating Edit-pill quick-access bar collapse state. Starts collapsed (single -/// hamburger button); the user toggles to expand the full action row. -edit_pill_expanded: bool = false, +/// Opaque per-pane state owned by the plugin that renders documents into this pane (today +/// only pixel art, via `CanvasData`: rulers, edit pill, grid-reorder drag, etc.). The +/// workbench never dereferences it — it just frees it through `plugin_view_destroy` when the +/// pane is torn down (`deinit`). Lazily created by the owning plugin on first document draw. +plugin_view_state: ?*anyopaque = null, +/// Teardown for `plugin_view_state`, set by the owner alongside the state. Null when no +/// plugin view has been attached. +plugin_view_destroy: ?*const fn (state: *anyopaque) void = null, /// Physical-pixel content rect of this workspace's canvas vbox, captured each frame during /// `drawCanvas` (or a sidebar view's `draw_workspace` takeover, e.g. pixel art's Project view). @@ -55,11 +40,17 @@ edit_pill_expanded: bool = false, canvas_rect_physical: ?dvui.Rect.Physical = null, pub fn init(grouping: u64) Workspace { - return .{ - .grouping = grouping, - .columns_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "column_drag_{d}", .{grouping}) catch "column_drag", - .rows_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "row_drag_{d}", .{grouping}) catch "row_drag", - }; + return .{ .grouping = grouping }; +} + +/// Release any plugin-owned per-pane view state. Called when a pane is removed +/// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown. +pub fn deinit(self: *Workspace) void { + if (self.plugin_view_state) |state| { + if (self.plugin_view_destroy) |destroy| destroy(state); + self.plugin_view_state = null; + self.plugin_view_destroy = null; + } } /// Recover the typed workspace currently drawing `file` from its opaque slot @@ -92,13 +83,6 @@ const logo_colors: [12]fizzy.math.Color = [_]fizzy.math.Color{ var dragging: bool = false; pub fn draw(self: *Workspace) !dvui.App.Result { - defer self.columns_drag_index = null; - defer self.rows_drag_index = null; - - // Process the column reorder, when both fields are set and we can take action - defer self.processColumnReorder(); - defer self.processRowReorder(); - // Canvas Area var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, @@ -840,1215 +824,6 @@ pub fn drawCanvas(self: *Workspace) !void { } } -pub const RulerOrientation = enum { - horizontal, - vertical, -}; - -pub fn drawRuler(self: *Workspace, orientation: RulerOrientation) void { - const file = &fizzy.editor.open_files.values()[self.open_file_index]; - const font = dvui.Font.theme(.body).larger(-1); - - const largest_label = std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{file.rows - 1}) catch { - dvui.log.err("Failed to allocate largest label", .{}); - return; - }; - const largest_label_size = font.textSize(largest_label); - const natural_scale = dvui.currentWindow().natural_scale; - const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical); - const base_ruler_size = largest_label_size.w + fizzy.editor.settings.ruler_padding; - - const ruler_thickness: f32 = switch (orientation) { - .horizontal => blk: { - self.horizontal_ruler_height = font.textSize("M").h + fizzy.editor.settings.ruler_padding; - break :blk self.horizontal_ruler_height; - }, - .vertical => blk: { - self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.editor.settings.ruler_padding); - break :blk self.vertical_ruler_width; - }, - }; - - switch (orientation) { - .horizontal => { - var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - }); - defer canvas_hbox.deinit(); - - var corner_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .min_size_content = .{ .h = self.vertical_ruler_width, .w = self.vertical_ruler_width }, - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - }); - corner_box.deinit(); - - var top_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .min_size_content = .{ .h = ruler_thickness, .w = ruler_thickness }, - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - }); - defer top_box.deinit(); - - self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, null); - }, - .vertical => { - var ruler_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .vertical, - .min_size_content = .{ .w = ruler_thickness, .h = 1.0 }, - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - }); - defer ruler_box.deinit(); - - self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, largest_label_phys); - }, - } -} - -/// `largest_row_index_*` come from `drawRuler` (widest row index string and its measured size in physical pixels). -fn drawRulerContent( - self: *Workspace, - file: *fizzy.Internal.File, - font: dvui.Font, - orientation: RulerOrientation, - ruler_size: f32, - largest_row_index_label: []const u8, - largest_row_index_size_phys: ?dvui.Size.Physical, -) void { - const scale = file.editor.canvas.scale; - const canvas = file.editor.canvas; - - switch (orientation) { - .horizontal => { - self.horizontal_scroll_info.virtual_size.w = canvas.scroll_info.virtual_size.w; - self.horizontal_scroll_info.virtual_size.h = ruler_size; - self.horizontal_scroll_info.viewport.w = canvas.scroll_info.viewport.w; - self.horizontal_scroll_info.viewport.x = canvas.scroll_info.viewport.x; - }, - .vertical => { - self.vertical_scroll_info.virtual_size.h = canvas.scroll_info.virtual_size.h; - self.vertical_scroll_info.virtual_size.w = ruler_size; - self.vertical_scroll_info.viewport.h = canvas.scroll_info.viewport.h; - self.vertical_scroll_info.viewport.y = canvas.scroll_info.viewport.y; - }, - } - - const scroll_info = switch (orientation) { - .horizontal => &self.horizontal_scroll_info, - .vertical => &self.vertical_scroll_info, - }; - - var scroll_area = dvui.scrollArea(@src(), .{ - .scroll_info = scroll_info, - .container = true, - .process_events_after = true, - .horizontal_bar = .hide, - .vertical_bar = .hide, - }, .{ .expand = .both }); - defer scroll_area.deinit(); - - const scale_rect = switch (orientation) { - .horizontal => dvui.Rect{ .x = -canvas.origin.x, .y = 0, .w = 0, .h = 0 }, - .vertical => dvui.Rect{ .x = 0, .y = -canvas.origin.y, .w = 0, .h = 0 }, - }; - var scaler = dvui.scale(@src(), .{ .scale = &file.editor.canvas.scale }, .{ .rect = scale_rect }); - defer scaler.deinit(); - - const outer_rect: dvui.Rect = switch (orientation) { - .horizontal => .{ - .x = 0, - .y = 0, - .w = @as(f32, @floatFromInt(file.width())), - .h = ruler_size / scale, - }, - .vertical => .{ - .x = 0, - .y = 0, - .w = ruler_size / scale, - .h = @as(f32, @floatFromInt(file.height())), - }, - }; - var outer_box = dvui.box(@src(), .{ .dir = switch (orientation) { - .horizontal => .horizontal, - .vertical => .horizontal, - } }, .{ - .expand = .none, - .rect = outer_rect, - }); - defer outer_box.deinit(); - - const drag_name = switch (orientation) { - .horizontal => self.columns_drag_name, - .vertical => self.rows_drag_name, - }; - - var reorder = fizzy.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ - .expand = .both, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - .background = false, - .corner_radius = dvui.Rect.all(0), - }); - defer reorder.deinit(); - - const reorder_box_dir: dvui.enums.Direction = switch (orientation) { - .horizontal => .horizontal, - .vertical => .vertical, - }; - var reorder_box = dvui.box(@src(), .{ .dir = reorder_box_dir }, .{ - .expand = .both, - .background = false, - .corner_radius = dvui.Rect.all(0), - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }); - defer reorder_box.deinit(); - - const ruler_stroke_color = dvui.themeGet().color(.control, .fill_hover).lighten(switch (orientation) { - .horizontal => 2.0, - .vertical => 0.0, - }); - - const edge_stroke_points = switch (orientation) { - .horizontal => .{ - reorder_box.data().rectScale().r.topRight(), - reorder_box.data().rectScale().r.bottomRight(), - }, - .vertical => .{ - reorder_box.data().rectScale().r.bottomRight(), - reorder_box.data().rectScale().r.bottomLeft(), - }, - }; - defer dvui.Path.stroke(.{ .points = &edge_stroke_points }, .{ - .color = ruler_stroke_color, - .thickness = 1.0, - }); - - const count = switch (orientation) { - .horizontal => file.columns, - .vertical => file.rows, - }; - const cell_min_size: dvui.Size = switch (orientation) { - .horizontal => .{ .w = @as(f32, @floatFromInt(file.column_width)), .h = 1.0 }, - .vertical => .{ .w = 1.0, .h = @as(f32, @floatFromInt(file.row_height)) }, - }; - const reorder_mode: fizzy.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) { - .horizontal => .any_y, - .vertical => .any_x, - }; - const reorder_expand: dvui.Options.Expand = switch (orientation) { - .horizontal => .vertical, - .vertical => .horizontal, - }; - - // Shared layout width for every row tick (widest index string); actual glyph size may differ per cell. - const vertical_row_layout_size_phys: ?dvui.Size.Physical = switch (orientation) { - .vertical => largest_row_index_size_phys, - .horizontal => null, - }; - - // Captured during iteration: the highlighted target slot (drop location) screen rect. - var target_rs_screen: ?dvui.RectScale = null; - - var index: usize = 0; - while (index < count) : (index += 1) { - var reorderable = reorder.reorderable(@src(), .{ - .mode = reorder_mode, - .clamp_to_edges = true, - }, .{ - .expand = reorder_expand, - .id_extra = index, - .padding = dvui.Rect.all(0), - .margin = dvui.Rect.all(0), - .min_size_content = cell_min_size, - }); - defer reorderable.deinit(); - - if (reorderable.targetRectScale()) |trs| { - target_rs_screen = trs; - } - - var button_color = if (reorder.drag_point != null) dvui.themeGet().color(.control, .fill).opacity(0.85) else dvui.themeGet().color(.window, .fill); - - if (fizzy.dvui.hovered(reorderable.data())) { - button_color = dvui.themeGet().color(.control, .fill_hover); - dvui.cursorSet(.hand); - } - - var cell_box: dvui.BoxWidget = undefined; - cell_box.init(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = true, - .color_fill = button_color, - .id_extra = index, - }); - - switch (orientation) { - .horizontal => { - if (reorderable.floating()) { - self.columns_drag_index = index; - reorder.reorderable_size.h = 0.0; - dvui.cursorSet(.hand); - } - if (reorderable.removed()) self.columns_removed_index = index; - if (reorderable.insertBefore()) self.columns_insert_before_index = index; - if (reorderable.targetID()) |target_id| self.columns_target_id = target_id; - if (self.columns_drag_index) |_| { - var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt); - mouse_pt.y = 0.0; - mouse_pt.x = std.math.clamp(mouse_pt.x, 0.0, @as(f32, @floatFromInt(file.width() - 1))); - self.columns_target_index = file.columnIndex(mouse_pt); - } - }, - .vertical => { - if (reorderable.floating()) { - self.rows_drag_index = index; - reorder.reorderable_size.w = 0.0; - dvui.cursorSet(.hand); - } - if (reorderable.removed()) self.rows_removed_index = index; - if (reorderable.insertBefore()) self.rows_insert_before_index = index; - if (reorderable.targetID()) |target_id| self.rows_target_id = target_id; - if (self.rows_drag_index) |_| { - var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt); - mouse_pt.x = 0.0; - mouse_pt.y = std.math.clamp(mouse_pt.y, 0.0, @as(f32, @floatFromInt(file.height() - 1))); - self.rows_target_index = file.rowIndex(mouse_pt); - } - }, - } - - { - defer cell_box.deinit(); - - // The dragged item's cell_box is parented to the reorderable's floating widget - // (rendered at the mouse position). We collapse that floating widget to h/w = 0 - // above, but `dvui.renderText` is not clipped by that, so the label would still - // appear at the cursor. Skip the visible cell rendering entirely while floating; - // the dragged label is drawn over the highlighted target slot below instead. - if (!reorderable.floating()) { - cell_box.drawBackground(); - - const label = switch (orientation) { - .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(index)) catch { - dvui.log.err("Failed to allocate label", .{}); - return; - }, - .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{index}) catch { - dvui.log.err("Failed to allocate label", .{}); - return; - }, - }; - - self.drawRulerLabel(.{ - .font = font, - .label = label, - .rect = cell_box.data().rectScale().r, - .color = dvui.themeGet().color(.control, .text).opacity(0.5), - .mode = switch (orientation) { - .horizontal => .horizontal, - .vertical => .vertical, - }, - .largest_label = if (orientation == .vertical) largest_row_index_label else null, - .ref_size_physical = vertical_row_layout_size_phys, - }); - - const cell_rect = cell_box.data().rectScale().r; - const cell_stroke_points = switch (orientation) { - .horizontal => .{ cell_rect.topLeft(), cell_rect.bottomLeft() }, - .vertical => .{ cell_rect.topLeft(), cell_rect.topRight() }, - }; - dvui.Path.stroke(.{ .points = &cell_stroke_points }, .{ .color = ruler_stroke_color, .thickness = 2.0 }); - } - - loop: for (dvui.events()) |*e| { - if (!cell_box.matchEvent(e)) continue; - - switch (e.evt) { - .mouse => |me| { - if (me.action == .press and me.button.pointer()) { - e.handle(@src(), cell_box.data()); - dvui.captureMouse(cell_box.data(), e.num); - dvui.dragPreStart(me.p, .{ - .size = reorderable.data().rectScale().r.size(), - .offset = reorderable.data().rectScale().r.topLeft().diff(me.p), - }); - } else if (me.action == .release and me.button.pointer()) { - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - switch (orientation) { - .horizontal => self.columns_drag_index = null, - .vertical => self.rows_drag_index = null, - } - } else if (me.action == .motion) { - if (dvui.captured(cell_box.data().id)) { - e.handle(@src(), cell_box.data()); - if (dvui.dragging(me.p, null)) |_| { - reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0); - break :loop; - } - } - } - }, - else => {}, - } - } - } - } - - const final_slot_id = switch (orientation) { - .horizontal => file.columns, - .vertical => file.rows, - }; - if (reorder.needFinalSlot()) { - var reorderable = reorder.reorderable(@src(), .{ - .mode = reorder_mode, - .last_slot = true, - .clamp_to_edges = true, - }, .{ - .expand = reorder_expand, - .id_extra = final_slot_id, - .padding = dvui.Rect.all(0), - .margin = dvui.Rect.all(0), - .min_size_content = cell_min_size, - }); - defer reorderable.deinit(); - - if (reorderable.targetRectScale()) |trs| { - target_rs_screen = trs; - } - - if (reorderable.insertBefore()) { - switch (orientation) { - .horizontal => self.columns_insert_before_index = final_slot_id, - .vertical => self.rows_insert_before_index = final_slot_id, - } - } - } - - // Drag overlay: draw the dragged column/row label on the highlighted target slot in - // highlight-text color (no extra fill, the reorderable's own focus fill is the - // background) and a thick err-colored marker line at the dragged-from position in the - // ruler that lines up with the equivalent indicator in the file canvas. - const drag_idx_for_overlay = switch (orientation) { - .horizontal => self.columns_drag_index, - .vertical => self.rows_drag_index, - }; - if (drag_idx_for_overlay) |di| { - const target_idx_opt = switch (orientation) { - .horizontal => self.columns_target_index, - .vertical => self.rows_target_index, - }; - const same_slot = target_idx_opt == di; - - if (target_rs_screen) |trs| { - const drag_label_opt: ?[]const u8 = switch (orientation) { - .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(di)) catch null, - .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{di}) catch null, - }; - if (drag_label_opt) |drag_label| { - if (same_slot) { - // Reorderable still draws theme focus fill for the drop target; paint control - // hover on top so "no move" matches ruler button hover styling. - trs.r.fill(.all(0), .{ .color = dvui.themeGet().color(.control, .fill_hover), .fade = 1.0 }); - } - self.drawRulerLabel(.{ - .font = font, - .label = drag_label, - .rect = trs.r, - .color = if (same_slot) - dvui.themeGet().color(.control, .text).opacity(0.5) - else - dvui.themeGet().color(.highlight, .text), - .mode = switch (orientation) { - .horizontal => .horizontal, - .vertical => .vertical, - }, - .largest_label = if (orientation == .vertical) largest_row_index_label else null, - .ref_size_physical = vertical_row_layout_size_phys, - }); - } - } - - // Use the canvas data->screen mapping for the cross-axis position so the marker - // line aligns exactly with the err indicator drawn over the file canvas grid. - // The other axis uses the ruler's own screen extents so the line fills the ruler. - const target_idx_for_line = switch (orientation) { - .horizontal => self.columns_target_index, - .vertical => self.rows_target_index, - }; - if (target_idx_for_line) |ti| { - if (di != ti) { - const removed_data_rect = switch (orientation) { - .horizontal => file.columnRect(di), - .vertical => file.rowRect(di), - }; - const removed_canvas_screen = file.editor.canvas.screenFromDataRect(removed_data_rect); - const ruler_screen = outer_box.data().contentRectScale().r; - const err_color = dvui.themeGet().color(.err, .fill); - const thickness = 3.0 * dvui.currentWindow().natural_scale; - switch (orientation) { - .horizontal => { - const edge_x = if (di < ti) - removed_canvas_screen.x - else - removed_canvas_screen.x + removed_canvas_screen.w; - dvui.Path.stroke(.{ .points = &.{ - .{ .x = edge_x, .y = ruler_screen.y }, - .{ .x = edge_x, .y = ruler_screen.y + ruler_screen.h }, - } }, .{ .thickness = thickness, .color = err_color }); - }, - .vertical => { - const edge_y = if (di < ti) - removed_canvas_screen.y - else - removed_canvas_screen.y + removed_canvas_screen.h; - dvui.Path.stroke(.{ .points = &.{ - .{ .x = ruler_screen.x, .y = edge_y }, - .{ .x = ruler_screen.x + ruler_screen.w, .y = edge_y }, - } }, .{ .thickness = thickness, .color = err_color }); - }, - } - } - } - } -} - -pub const TextLabelOptions = struct { - pub const Mode = enum { - horizontal, - vertical, - }; - - font: dvui.Font, - label: []const u8, - rect: dvui.Rect.Physical, - color: dvui.Color, - mode: Mode = .horizontal, - /// Widest row index string (e.g. `"99"`); layout cell size uses this, text may be a shorter index. - largest_label: ?[]const u8 = null, - /// When set, layout size for that widest string (already × `natural_scale`); skips `textSize(largest_label)` per cell. - ref_size_physical: ?dvui.Size.Physical = null, -}; - -pub fn drawRulerLabel(_: *Workspace, options: TextLabelOptions) void { - const font = options.font; - const label = options.label; - const rect = options.rect; - const color = options.color; - const natural = dvui.currentWindow().natural_scale; - - const ref_for_layout = options.largest_label orelse label; - const label_size = options.ref_size_physical orelse font.textSize(ref_for_layout).scale(natural, dvui.Size.Physical); - const actual_label_size = if (std.mem.eql(u8, ref_for_layout, label)) - label_size - else - font.textSize(label).scale(natural, dvui.Size.Physical); - - const padding = fizzy.editor.settings.ruler_padding * natural; - - var label_rect = rect; - - if (label_size.w + padding <= label_rect.w and options.mode == .horizontal) { - label_rect.h = label_size.h + padding; - label_rect.x += (label_rect.w - actual_label_size.w) / 2.0; - label_rect.y += (label_rect.h - actual_label_size.h) / 2.0; - - dvui.renderText(.{ - .text = label, - .font = font, - .color = color, - .rs = .{ - .r = label_rect, - .s = natural, - }, - }) catch { - dvui.log.err("Failed to render text", .{}); - }; - } else if (label_size.h + padding <= label_rect.h and options.mode == .vertical) { - label_rect.w = label_size.h + padding; - label_rect.x += (label_rect.w - actual_label_size.w) / 2.0; - label_rect.y += (label_rect.h - actual_label_size.h) / 2.0; - - dvui.renderText(.{ - .text = label, - .font = font, - .color = color, - .rs = .{ - .r = label_rect, - .s = natural, - }, - }) catch { - dvui.log.err("Failed to render text", .{}); - }; - } -} - -pub fn processColumnReorder(self: *Workspace) void { - if (self.columns_removed_index) |columns_removed_index| { - if (self.columns_insert_before_index) |columns_insert_before_index| { - defer self.columns_removed_index = null; - defer self.columns_insert_before_index = null; - - if (columns_removed_index == columns_insert_before_index or columns_removed_index + 1 == columns_insert_before_index) return; - - const file = &fizzy.editor.open_files.values()[self.open_file_index]; - - file.reorderColumns(columns_removed_index, columns_insert_before_index) catch { - dvui.log.err("Failed to reorder columns", .{}); - return; - }; - - // We'll store the previous indices for clarity. - const prev_removed_index = columns_removed_index; - const prev_insert_before_index = columns_insert_before_index; - - if (prev_removed_index < prev_insert_before_index) { - file.history.append(.{ - .reorder_col_row = .{ - .mode = .columns, - .removed_index = prev_insert_before_index - 1, - .insert_before_index = prev_removed_index, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } else { - file.history.append(.{ - .reorder_col_row = .{ - .mode = .columns, - .removed_index = prev_insert_before_index, - .insert_before_index = prev_removed_index + 1, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } - } - } -} - -pub fn processRowReorder(self: *Workspace) void { - if (self.rows_removed_index) |rows_removed_index| { - if (self.rows_insert_before_index) |rows_insert_before_index| { - defer self.rows_removed_index = null; - defer self.rows_insert_before_index = null; - if (rows_removed_index == rows_insert_before_index or rows_removed_index + 1 == rows_insert_before_index) return; - - const file = &fizzy.editor.open_files.values()[self.open_file_index]; - - file.reorderRows(rows_removed_index, rows_insert_before_index) catch { - dvui.log.err("Failed to reorder rows", .{}); - return; - }; - - // We'll store the previous indices for clarity. - const prev_removed_index = rows_removed_index; - const prev_insert_before_index = rows_insert_before_index; - - if (prev_removed_index < prev_insert_before_index) { - file.history.append(.{ - .reorder_col_row = .{ - .mode = .rows, - .removed_index = prev_insert_before_index - 1, - .insert_before_index = prev_removed_index, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } else { - file.history.append(.{ - .reorder_col_row = .{ - .mode = .rows, - .removed_index = prev_insert_before_index, - .insert_before_index = prev_removed_index + 1, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } - } - } -} - -pub fn drawTransformDialog(self: *Workspace, container: *dvui.WidgetData) void { - const file = &fizzy.editor.open_files.values()[self.open_file_index]; - if (file.editor.transform) |*transform| { - var rect = container.rect; - rect.w = 0; - rect.h = 0; - - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{}, .{ - .rect = .{ .x = container.rectScale().r.toNatural().x + 10, .y = container.rectScale().r.toNatural().y + 10, .w = 0, .h = 0 }, - .expand = .none, - .background = true, - .color_fill = dvui.themeGet().color(.control, .fill), - .corner_radius = dvui.Rect.all(8), - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 8, - .corner_radius = dvui.Rect.all(8), - }, - }); - defer fw.deinit(); - - var anim = dvui.animate(@src(), .{ .kind = .vertical, .duration = 450_000, .easing = dvui.easing.outBack }, .{}); - defer anim.deinit(); - - var anim_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = false, - }); - defer anim_box.deinit(); - - dvui.labelNoFmt(@src(), "TRANSFORM", .{ .align_x = 0.5 }, .{ - .padding = dvui.Rect.all(4), - .expand = .horizontal, - .font = dvui.Font.theme(.heading).withWeight(.bold), - }); - _ = dvui.separator(@src(), .{ .expand = .horizontal }); - - _ = dvui.spacer(@src(), .{ .expand = .horizontal }); - - var degrees: f32 = std.math.radiansToDegrees(transform.rotation); - - var slider_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .background = false, - }); - - if (dvui.sliderEntry(@src(), "{d:0.0}°", .{ - .value = °rees, - .min = 0, - .max = 360, - .interval = 1, - }, .{ .expand = .horizontal, .color_fill = dvui.themeGet().color(.window, .fill) })) { - transform.rotation = std.math.degreesToRadians(degrees); - } - slider_box.deinit(); - - if (transform.ortho) { - var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ - .expand = .horizontal, - .background = false, - }); - defer box.deinit(); - dvui.label(@src(), "Width: {d:0.0}", .{transform.point(.bottom_left).diff(transform.point(.bottom_right).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) }); - dvui.label(@src(), "Height: {d:0.0}", .{transform.point(.top_left).diff(transform.point(.bottom_left).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) }); - } - - { - var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ - .expand = .horizontal, - .background = false, - }); - defer box.deinit(); - if (dvui.buttonIcon(@src(), "transform_cancel", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .err, .expand = .horizontal })) { - fizzy.editor.cancel() catch { - dvui.log.err("Failed to cancel transform", .{}); - }; - } - if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) { - fizzy.editor.accept() catch { - dvui.log.err("Failed to accept transform", .{}); - }; - } - } - } -} - -/// Floating rounded-pill quick-access bar anchored to the top-right of the workspace -/// canvas. Mirrors the Edit menu (Undo / Redo / Copy / Paste / Transform / Grid Layout) -/// with icon-only round buttons sized to match the toolbox buttons. Starts collapsed as a -/// single hamburger circle; tapping toggles the row of action buttons in/out with a -/// width animation. -pub fn drawEditPill(self: *Workspace, container: *dvui.WidgetData) void { - const file = fizzy.editor.activeFile() orelse return; - - const button_size: f32 = 36; - const button_gap: f32 = 6; - const pill_padding: f32 = 6; - const margin: f32 = 10; - // Canvas scroll area uses a non-overlay vertical bar on the right edge; keep the - // pill clear of it (see `CanvasWidget.install` + dvui `ScrollBarWidget` width). - const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w; - // Icons render at ~60% of their previous size — previous padding was 0.22 (icon - // ≈ 56% of button); new padding is 0.33 so the icon ends up ≈ 34% of the button, - // which is roughly 60% of the prior icon footprint. - const icon_padding: f32 = button_size * 0.33; - - const Action = enum { save, exportd, undo, redo, copy, paste, transform, grid_layout }; - const Entry = struct { - action: Action, - tvg: []const u8, - tooltip: []const u8, - }; - - const entries = [_]Entry{ - .{ .action = .save, .tvg = icons.tvg.lucide.save, .tooltip = "Save" }, - .{ .action = .exportd, .tvg = icons.tvg.lucide.@"file-output", .tooltip = "Export" }, - .{ .action = .undo, .tvg = icons.tvg.lucide.undo, .tooltip = "Undo" }, - .{ .action = .redo, .tvg = icons.tvg.lucide.redo, .tooltip = "Redo" }, - .{ .action = .copy, .tvg = icons.tvg.lucide.copy, .tooltip = "Copy" }, - .{ .action = .paste, .tvg = icons.tvg.lucide.@"clipboard-paste", .tooltip = "Paste" }, - .{ .action = .transform, .tvg = icons.tvg.lucide.scaling, .tooltip = "Transform" }, - .{ .action = .grid_layout, .tvg = icons.tvg.lucide.@"layout-grid", .tooltip = "Grid Layout" }, - }; - - // Vertical pill: width is fixed (one button + padding), height animates between a - // single-button "collapsed" state and the full-stack "expanded" state. Most screens - // have more vertical real estate than horizontal, so growing the pill downward keeps - // it from eating into the canvas's working width. - const pill_w: f32 = button_size + 2 * pill_padding; - const collapsed_h: f32 = button_size + 2 * pill_padding; - const expanded_h: f32 = @as(f32, @floatFromInt(entries.len + 1)) * button_size + - @as(f32, @floatFromInt(entries.len)) * button_gap + 2 * pill_padding; - const pill_radius: f32 = pill_w / 2; - const btn_radius: f32 = button_size / 2; - - // Drive the expand/collapse with a dvui animation. Look up the current value, and on - // a toggle click kick off a new animation between the current value and the target. - const anim_id = dvui.Id.update(container.id, "edit_pill_expand"); - var anim_value: f32 = if (self.edit_pill_expanded) 1.0 else 0.0; - if (dvui.animationGet(anim_id, "_t")) |a| anim_value = std.math.clamp(a.value(), 0.0, 1.0); - - const pill_h: f32 = collapsed_h + (expanded_h - collapsed_h) * anim_value; - - // Compute the scroll-area rect — the canvas region inside the rulers. We pull this - // off the live `canvas_vbox` (so the values are this frame's, not a stale latch) and - // subtract the ruler thickness from the top/left. Anchoring against this rect means - // the pill follows the workspace exactly: as a split is dragged shut the canvas area - // shrinks, and once it's narrower than the pill we bail and draw nothing this frame — - // so closing splits cleanly hides the menu. - const wb = container.rectScale().r.toNatural(); - const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; - const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; - const canvas_nat = dvui.Rect{ - .x = wb.x + ruler_left, - .y = wb.y + ruler_top, - .w = wb.w - ruler_left, - .h = wb.h - ruler_top, - }; - - if (canvas_nat.w < pill_w + margin + right_margin or canvas_nat.h < collapsed_h + 2 * margin) return; - - const pill_x: f32 = canvas_nat.x + canvas_nat.w - right_margin - pill_w; - const pill_y: f32 = canvas_nat.y + margin; - - // Clamp the bottom edge so the expanded pill never spills past the canvas area — - // FloatingWidget bypasses parent clipping, so we cap the height explicitly. - const max_pill_h: f32 = canvas_nat.h - 2 * margin; - const effective_pill_h: f32 = @min(pill_h, max_pill_h); - - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{}, .{ - .rect = .{ - .x = pill_x, - .y = pill_y, - .w = pill_w, - .h = effective_pill_h, - }, - .expand = .none, - .background = self.edit_pill_expanded, - .color_fill = dvui.themeGet().color(.window, .fill), - .corner_radius = dvui.Rect.all(pill_radius), - .box_shadow = if (self.edit_pill_expanded) .{ - .color = .black, - .alpha = 0.25, - .fade = 10, - .offset = .{ .x = 0, .y = 3 }, - .corner_radius = dvui.Rect.all(pill_radius), - } else null, - }); - defer fw.deinit(); - - var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = false, - .padding = dvui.Rect.all(pill_padding), - }); - defer vbox.deinit(); - - // Hamburger toggle is always present at the top of the pill; the stack of action - // buttons grows downward beneath it as the pill expands. - { - var btn: dvui.ButtonWidget = undefined; - btn.init(@src(), .{}, .{ - .id_extra = entries.len, // distinct from action button ids below - .min_size_content = .{ .w = button_size, .h = button_size }, - .expand = .none, - .gravity_x = 0.5, - .gravity_y = 0.0, - .background = true, - .corner_radius = dvui.Rect.all(btn_radius), - .color_fill = dvui.themeGet().color(.content, .fill), - .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - .color_border = .transparent, - .padding = .all(0), - .margin = .{}, - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 4, - .offset = .{ .x = 0, .y = 2 }, - .corner_radius = dvui.Rect.all(btn_radius), - }, - }); - defer btn.deinit(); - btn.processEvents(); - btn.drawBackground(); - - const icon_color = dvui.themeGet().color(.content, .text); - dvui.icon( - @src(), - "edit_pill_toggle", - icons.tvg.lucide.menu, - .{ .stroke_color = icon_color, .fill_color = icon_color }, - .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .min_size_content = .{ .w = 1.0, .h = 1.0 }, - .padding = dvui.Rect.all(icon_padding), - }, - ); - - if (btn.clicked()) { - self.edit_pill_expanded = !self.edit_pill_expanded; - const target: f32 = if (self.edit_pill_expanded) 1.0 else 0.0; - dvui.animation(anim_id, "_t", .{ - .start_val = anim_value, - .end_val = target, - .end_time = 250_000, - .easing = dvui.easing.outBack, - }); - } - } - - // Action buttons live inside a scroll area so the pill stays the right width and - // never visually "squishes" when there isn't enough vertical room — instead the - // overflow buttons become reachable via vertical scroll inside the pill. Bars are - // hidden to preserve the rounded-pill look; touch / wheel still drives the scroll. - var actions_scroll = dvui.scrollArea(@src(), .{ - .vertical_bar = .hide, - .horizontal_bar = .hide, - }, .{ - .expand = .both, - .background = false, - .padding = .{}, - .margin = .{}, - .border = dvui.Rect.all(0), - .color_fill = .transparent, - }); - defer actions_scroll.deinit(); - - // Action buttons stacked below the hamburger. We draw them all and let the - // scrollArea handle any overflow when the pill is clamped to the canvas height. - for (entries, 0..) |entry, i| { - const enabled: bool = switch (entry.action) { - .save => file.dirty(), - .undo => file.history.undo_stack.items.len > 0, - .redo => file.history.redo_stack.items.len > 0, - else => true, - }; - - var btn: dvui.ButtonWidget = undefined; - btn.init(@src(), .{}, .{ - .id_extra = i, - .min_size_content = .{ .w = button_size, .h = button_size }, - .expand = .none, - .gravity_x = 0.5, - .background = true, - .corner_radius = dvui.Rect.all(btn_radius), - .color_fill = dvui.themeGet().color(.content, .fill), - .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - .color_border = .transparent, - .padding = .all(0), - .margin = .{ .y = button_gap }, - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 4, - .offset = .{ .x = 0, .y = 2 }, - .corner_radius = dvui.Rect.all(btn_radius), - }, - }); - defer btn.deinit(); - btn.processEvents(); - btn.drawBackground(); - - const icon_color = if (enabled) dvui.themeGet().color(.content, .text) else dvui.themeGet().color(.content, .text).opacity(0.35); - - dvui.icon( - @src(), - entry.tooltip, - entry.tvg, - .{ .stroke_color = icon_color, .fill_color = icon_color }, - .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .min_size_content = .{ .w = 1.0, .h = 1.0 }, - .padding = dvui.Rect.all(icon_padding), - }, - ); - - // Suppress activation while collapsed (or mid-animation) so a stray tap on a - // partially-visible button doesn't fire an Edit action behind the hamburger. - const fully_expanded = anim_value >= 0.999; - if (btn.clicked() and enabled and fully_expanded) { - switch (entry.action) { - .save => fizzy.editor.save() catch { - dvui.log.err("Failed to save", .{}); - }, - .exportd => { - // Open the Export dialog (same configuration the `export` keybind uses). - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = fizzy.Editor.Dialogs.Export.dialog, - .callafterFn = fizzy.Editor.Dialogs.Export.callAfter, - .title = "Export...", - .ok_label = "Export", - .cancel_label = "Cancel", - .resizeable = false, - .modal = false, - .header_kind = .info, - .default = .ok, - }); - mutex.mutex.unlock(dvui.io); - }, - .undo => file.history.undoRedo(file, .undo) catch { - dvui.log.err("Failed to undo", .{}); - }, - .redo => file.history.undoRedo(file, .redo) catch { - dvui.log.err("Failed to redo", .{}); - }, - .copy => fizzy.editor.copy() catch { - dvui.log.err("Failed to copy", .{}); - }, - .paste => fizzy.editor.paste() catch { - dvui.log.err("Failed to paste", .{}); - }, - .transform => fizzy.editor.transform() catch { - dvui.log.err("Failed to start transform", .{}); - }, - .grid_layout => fizzy.editor.requestGridLayoutDialog(), - } - } - } -} - -/// Floating round button anchored just to the left of the Edit pill at the top-right of -/// the canvas. Tapping it shows a tooltip explaining the gesture; the primary action is -/// to drag from the button toward whatever pixel you want to sample. The button itself -/// stays put — instead, while the drag is in progress, we route the touch position -/// through to `file.editor.canvas.sample_data_point` so `FileWidget.drawSample` renders -/// the existing color-dropper magnifier at the touch location. On release we read the -/// color underneath the sample point and apply it to the primary color slot. -pub fn drawSampleButton(self: *Workspace, container: *dvui.WidgetData) void { - const file = fizzy.editor.activeFile() orelse return; - - const pill_button_size: f32 = 36; - const pill_padding: f32 = 6; - const pill_outer_w: f32 = pill_button_size + 2 * pill_padding; - const button_size: f32 = 36; - const btn_radius: f32 = button_size / 2; - const icon_padding: f32 = button_size * 0.33; - const margin: f32 = 10; - const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w; - const gap: f32 = 6; - - // Anchor against the same canvas-scroll-area rect the pill uses. - const wb = container.rectScale().r.toNatural(); - const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; - const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; - const canvas_nat = dvui.Rect{ - .x = wb.x + ruler_left, - .y = wb.y + ruler_top, - .w = wb.w - ruler_left, - .h = wb.h - ruler_top, - }; - - // Only draw when the canvas area can fit pill + gap + sample button + margins. - if (canvas_nat.w < pill_outer_w + gap + button_size + margin + right_margin) return; - if (canvas_nat.h < button_size + 2 * margin) return; - - const btn_x = canvas_nat.x + canvas_nat.w - right_margin - pill_outer_w - gap - button_size; - // Match the hamburger row inside the pill (pill top + inner vbox padding). - const btn_y = canvas_nat.y + margin + pill_padding; - - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{}, .{ - .rect = .{ .x = btn_x, .y = btn_y, .w = button_size, .h = button_size }, - .expand = .none, - .background = false, - }); - defer fw.deinit(); - - var btn: dvui.ButtonWidget = undefined; - // `touch_drag = true` keeps `ButtonWidget`'s own capture alive while the touch is - // dragging away from the button — without it, dvui's default `clickedEx` releases - // capture as soon as the drag crosses the threshold (treating the gesture as a - // canceled scroll), which would also cancel our custom drag-to-sample handler. - btn.init(@src(), .{ .touch_drag = true }, .{ - .expand = .both, - .background = true, - .min_size_content = .{ .w = button_size, .h = button_size }, - .corner_radius = dvui.Rect.all(btn_radius), - .color_fill = dvui.themeGet().color(.content, .fill), - .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - .color_border = .transparent, - .padding = .all(0), - .margin = .{}, - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 4, - .offset = .{ .x = 0, .y = 2 }, - .corner_radius = dvui.Rect.all(btn_radius), - }, - }); - defer btn.deinit(); - - // Persistent drag state (a press is "drag-sampling" once motion clears the dvui drag - // threshold). Stored via dataSet because the button widget is recreated each frame. - const drag_state_id = dvui.Id.update(container.id, "sample_button_drag"); - var is_drag_sampling = dvui.dataGet(null, drag_state_id, "active", bool) orelse false; - var did_sample = dvui.dataGet(null, drag_state_id, "did_sample", bool) orelse false; - - // The button's screen rect is the "press home base"; events that happen here belong - // to us regardless of whether motion has carried the pointer away. - const btn_rs = btn.data().rectScale(); - - // Custom event handling runs *before* `btn.processEvents()` so we can claim the - // press / motion / release events first. `ButtonWidget.clickedEx` ALWAYS releases - // mouse capture and ends the drag on a release event (regardless of touch_drag) — - // if we ran after it, our release branch would see `dvui.captured(...)` already - // false and the magnifier would stay stuck on screen. Calling `e.handle(...)` here - // makes `clickedEx`'s match-event check skip these events entirely, so the button - // leaves our gesture alone. - for (dvui.events()) |*e| { - if (e.evt != .mouse) continue; - const me = e.evt.mouse; - - switch (me.action) { - .press => { - if (!me.button.pointer()) continue; - if (!btn_rs.r.contains(me.p)) continue; - e.handle(@src(), btn.data()); - dvui.captureMouse(btn.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "sample_button_drag" }); - is_drag_sampling = false; - did_sample = false; - }, - .motion => { - if (!dvui.captured(btn.data().id)) continue; - if (dvui.dragging(me.p, "sample_button_drag")) |_| { - is_drag_sampling = true; - if (file.editor.canvas.samplePointerInViewport(me.p)) { - const data_pt = file.editor.canvas.dataFromScreenPoint(me.p); - dvui.dataSet(null, file.editor.canvas.id, "sample_data_point", data_pt); - did_sample = true; - } else { - dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point"); - } - dvui.refresh(null, @src(), file.editor.canvas.id); - e.handle(@src(), btn.data()); - } - }, - .release => { - if (!me.button.pointer()) continue; - if (!dvui.captured(btn.data().id)) continue; - e.handle(@src(), btn.data()); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - - if (is_drag_sampling and did_sample and file.editor.canvas.samplePointerInViewport(me.p)) { - const data_pt = file.editor.canvas.dataFromScreenPoint(me.p); - fizzy.dvui.FileWidget.sampleColorAtPoint(file, data_pt, false, true, true); - } - - // Clear sample state so the magnifier disappears on the next frame. - dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point"); - is_drag_sampling = false; - did_sample = false; - dvui.refresh(null, @src(), file.editor.canvas.id); - }, - else => {}, - } - } - - // Persist the drag state for the next frame's widget recreate. - dvui.dataSet(null, drag_state_id, "active", is_drag_sampling); - dvui.dataSet(null, drag_state_id, "did_sample", did_sample); - - // Now let the button run its own pass to handle hover styling against any remaining - // (non-claimed) events — i.e. plain mouse hover when we're not in a drag. - btn.processEvents(); - btn.drawBackground(); - - const icon_color = dvui.themeGet().color(.content, .text); - dvui.icon( - @src(), - "sample_dropper", - icons.tvg.lucide.pipette, - .{ .stroke_color = icon_color, .fill_color = icon_color }, - .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .min_size_content = .{ .w = 1.0, .h = 1.0 }, - .padding = dvui.Rect.all(icon_padding), - }, - ); - - // While the drag is in progress, hide the OS cursor entirely so only the canvas - // magnifier (drawn at the touch point via `FileWidget.drawSample`) communicates - // where the sample is happening. Set after `btn.processEvents()` so it overrides - // the `.hand` hover cursor `clickedEx` would otherwise leave in place. - if (is_drag_sampling) { - dvui.cursorSet(.hidden); - } - - // Tooltip prompting the gesture. We hide it during an active sample drag so it - // doesn't compete with the magnifier on screen. - if (!is_drag_sampling) { - var tooltip: dvui.FloatingTooltipWidget = undefined; - tooltip.init(@src(), .{ - .active_rect = btn.data().rectScale().r, - .delay = 350_000, - }, .{ - .color_fill = dvui.themeGet().color(.window, .fill), - .border = dvui.Rect.all(0), - .box_shadow = .{ - .color = .black, - .shrink = 0, - .corner_radius = dvui.Rect.all(8), - .offset = .{ .x = 0, .y = 2 }, - .fade = 4, - .alpha = 0.2, - }, - }); - defer tooltip.deinit(); - - if (tooltip.shown()) { - var anim = dvui.animate(@src(), .{ .kind = .alpha, .duration = 250_000 }, .{ .expand = .both }); - defer anim.deinit(); - - var tl = dvui.textLayout(@src(), .{}, .{ - .background = false, - .padding = dvui.Rect.all(6), - }); - tl.format("Drag to sample color", .{}, .{ .font = dvui.Font.theme(.body) }); - tl.deinit(); - } - } -} - pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { const logo_pixel_size = 32; const logo_width = 3; From a9edc39019501644fcd6bca05187f725b9a21ee9 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 09:53:26 -0500 Subject: [PATCH 13/49] Structural changes --- build.zig | 34 +++++++++---------- contributor.md | 6 ++-- src/backend_native.zig | 2 +- src/editor/Brushes.zig | 24 ------------- src/editor/Editor.zig | 24 ++++++------- src/editor/Menu.zig | 1 - src/editor/dialogs/Dialogs.zig | 8 ++--- src/editor/dialogs/UnsavedClose.zig | 2 +- src/editor/explorer/Explorer.zig | 10 +++--- src/editor/panel/Panel.zig | 2 +- src/editor/widgets/Widgets.zig | 4 +-- src/fizzy.zig | 30 ++++++++-------- src/gfx/image.zig | 23 +------------ src/platform.zig | 2 +- src/{ => plugins/pixelart}/Animation.zig | 0 src/{ => plugins/pixelart}/Atlas.zig | 4 +-- src/{ => plugins}/pixelart/CanvasData.zig | 2 +- src/{editor => plugins/pixelart}/Colors.zig | 2 +- src/{ => plugins/pixelart}/File.zig | 2 +- .../pixelart}/LDTKTileset.zig | 2 +- src/{ => plugins/pixelart}/Layer.zig | 0 src/{editor => plugins/pixelart}/PackJob.zig | 6 ++-- src/{tools => plugins/pixelart}/Packer.zig | 2 +- src/{editor => plugins/pixelart}/Project.zig | 2 +- src/{ => plugins/pixelart}/Sprite.zig | 0 src/{editor => plugins/pixelart}/Tools.zig | 2 +- .../pixelart}/Transform.zig | 2 +- .../pixelart}/algorithms/algorithms.zig | 0 .../pixelart}/algorithms/brezenham.zig | 2 +- .../pixelart}/algorithms/reduce.zig | 0 .../deps/msf_gif/fizzy_msf_gif_wasm.c | 0 .../pixelart}/deps/msf_gif/msf_gif.c | 0 .../pixelart}/deps/msf_gif/msf_gif.h | 0 .../pixelart}/deps/msf_gif/msf_gif.zig | 0 .../pixelart}/deps/msf_gif/wasm_shim/string.h | 0 .../pixelart}/deps/stbi/fizzy_stbi_libc.c | 0 .../pixelart}/deps/stbi/stb_image_resize2.h | 0 .../pixelart}/deps/stbi/stb_rect_pack.h | 0 src/{ => plugins/pixelart}/deps/stbi/zstbi.c | 0 .../pixelart}/deps/stbi/zstbi.zig | 0 src/{ => plugins/pixelart}/deps/zip/build.zig | 0 .../pixelart}/deps/zip/fizzy_zip_libc.c | 0 .../pixelart}/deps/zip/fizzy_zip_strings.c | 0 .../pixelart}/deps/zip/fizzy_zip_wasm.h | 0 .../pixelart}/deps/zip/src/miniz.h | 0 src/{ => plugins/pixelart}/deps/zip/src/zip.c | 0 src/{ => plugins/pixelart}/deps/zip/src/zip.h | 0 src/{ => plugins/pixelart}/deps/zip/zip.zig | 0 .../pixelart}/dialogs/Export.zig | 6 ++-- .../dialogs/FlatRasterSaveWarning.zig | 2 +- .../pixelart}/dialogs/GridLayout.zig | 6 ++-- .../pixelart}/dialogs/NewFile.zig | 4 +-- .../pixelart}/explorer/project.zig | 2 +- .../pixelart}/explorer/sprites.zig | 2 +- .../pixelart}/explorer/tools.zig | 2 +- .../pixelart}/internal/Animation.zig | 0 src/{ => plugins/pixelart}/internal/Atlas.zig | 6 ++-- .../pixelart}/internal/Buffers.zig | 2 +- src/{ => plugins/pixelart}/internal/File.zig | 12 +++---- .../pixelart}/internal/History.zig | 4 +-- src/{ => plugins/pixelart}/internal/Layer.zig | 17 ++++++++-- .../pixelart}/internal/Palette.zig | 2 +- .../pixelart}/internal/Sprite.zig | 0 .../internal/grid_layout_validate.zig | 0 .../pixelart}/internal/layer_order.zig | 0 .../pixelart}/internal/palette_parse.zig | 0 .../pixelart}/panel/sprites.zig | 2 +- src/{ => plugins}/pixelart/plugin.zig | 2 +- .../pixelart}/widgets/CanvasBridge.zig | 4 +-- .../pixelart}/widgets/FileWidget.zig | 10 +++--- .../pixelart}/widgets/ImageWidget.zig | 4 +-- src/{ => plugins}/workbench/FileLoadJob.zig | 4 +-- src/{ => plugins}/workbench/Workbench.zig | 4 +-- src/{ => plugins}/workbench/Workspace.zig | 2 +- src/{ => plugins}/workbench/files.zig | 3 +- src/{ => plugins}/workbench/plugin.zig | 2 +- src/tools/process_assets.zig | 2 +- src/{internal => }/window_layout.zig | 3 +- tests/README.md | 4 +-- 79 files changed, 140 insertions(+), 173 deletions(-) delete mode 100644 src/editor/Brushes.zig rename src/{ => plugins/pixelart}/Animation.zig (100%) rename src/{ => plugins/pixelart}/Atlas.zig (97%) rename src/{ => plugins}/pixelart/CanvasData.zig (99%) rename src/{editor => plugins/pixelart}/Colors.zig (85%) rename src/{ => plugins/pixelart}/File.zig (98%) rename src/{tools => plugins/pixelart}/LDTKTileset.zig (87%) rename src/{ => plugins/pixelart}/Layer.zig (100%) rename src/{editor => plugins/pixelart}/PackJob.zig (99%) rename src/{tools => plugins/pixelart}/Packer.zig (99%) rename src/{editor => plugins/pixelart}/Project.zig (99%) rename src/{ => plugins/pixelart}/Sprite.zig (100%) rename src/{editor => plugins/pixelart}/Tools.zig (99%) rename src/{editor => plugins/pixelart}/Transform.zig (99%) rename src/{ => plugins/pixelart}/algorithms/algorithms.zig (100%) rename src/{ => plugins/pixelart}/algorithms/brezenham.zig (96%) rename src/{ => plugins/pixelart}/algorithms/reduce.zig (100%) rename src/{ => plugins/pixelart}/deps/msf_gif/fizzy_msf_gif_wasm.c (100%) rename src/{ => plugins/pixelart}/deps/msf_gif/msf_gif.c (100%) rename src/{ => plugins/pixelart}/deps/msf_gif/msf_gif.h (100%) rename src/{ => plugins/pixelart}/deps/msf_gif/msf_gif.zig (100%) rename src/{ => plugins/pixelart}/deps/msf_gif/wasm_shim/string.h (100%) rename src/{ => plugins/pixelart}/deps/stbi/fizzy_stbi_libc.c (100%) rename src/{ => plugins/pixelart}/deps/stbi/stb_image_resize2.h (100%) rename src/{ => plugins/pixelart}/deps/stbi/stb_rect_pack.h (100%) rename src/{ => plugins/pixelart}/deps/stbi/zstbi.c (100%) rename src/{ => plugins/pixelart}/deps/stbi/zstbi.zig (100%) rename src/{ => plugins/pixelart}/deps/zip/build.zig (100%) rename src/{ => plugins/pixelart}/deps/zip/fizzy_zip_libc.c (100%) rename src/{ => plugins/pixelart}/deps/zip/fizzy_zip_strings.c (100%) rename src/{ => plugins/pixelart}/deps/zip/fizzy_zip_wasm.h (100%) rename src/{ => plugins/pixelart}/deps/zip/src/miniz.h (100%) rename src/{ => plugins/pixelart}/deps/zip/src/zip.c (100%) rename src/{ => plugins/pixelart}/deps/zip/src/zip.h (100%) rename src/{ => plugins/pixelart}/deps/zip/zip.zig (100%) rename src/{editor => plugins/pixelart}/dialogs/Export.zig (99%) rename src/{editor => plugins/pixelart}/dialogs/FlatRasterSaveWarning.zig (99%) rename src/{editor => plugins/pixelart}/dialogs/GridLayout.zig (99%) rename src/{editor => plugins/pixelart}/dialogs/NewFile.zig (98%) rename src/{editor => plugins/pixelart}/explorer/project.zig (99%) rename src/{editor => plugins/pixelart}/explorer/sprites.zig (99%) rename src/{editor => plugins/pixelart}/explorer/tools.zig (99%) rename src/{ => plugins/pixelart}/internal/Animation.zig (100%) rename src/{ => plugins/pixelart}/internal/Atlas.zig (94%) rename src/{ => plugins/pixelart}/internal/Buffers.zig (98%) rename src/{ => plugins/pixelart}/internal/File.zig (99%) rename src/{ => plugins/pixelart}/internal/History.zig (99%) rename src/{ => plugins/pixelart}/internal/Layer.zig (96%) rename src/{ => plugins/pixelart}/internal/Palette.zig (97%) rename src/{ => plugins/pixelart}/internal/Sprite.zig (100%) rename src/{ => plugins/pixelart}/internal/grid_layout_validate.zig (100%) rename src/{ => plugins/pixelart}/internal/layer_order.zig (100%) rename src/{ => plugins/pixelart}/internal/palette_parse.zig (100%) rename src/{editor => plugins/pixelart}/panel/sprites.zig (99%) rename src/{ => plugins}/pixelart/plugin.zig (99%) rename src/{editor => plugins/pixelart}/widgets/CanvasBridge.zig (88%) rename src/{editor => plugins/pixelart}/widgets/FileWidget.zig (99%) rename src/{editor => plugins/pixelart}/widgets/ImageWidget.zig (99%) rename src/{ => plugins}/workbench/FileLoadJob.zig (98%) rename src/{ => plugins}/workbench/Workbench.zig (99%) rename src/{ => plugins}/workbench/Workspace.zig (99%) rename src/{ => plugins}/workbench/files.zig (99%) rename src/{ => plugins}/workbench/plugin.zig (98%) rename src/{internal => }/window_layout.zig (98%) diff --git a/build.zig b/build.zig index 48476dd6..48a3ecde 100644 --- a/build.zig +++ b/build.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const zip = @import("src/deps/zip/build.zig"); +const zip = @import("src/plugins/pixelart/deps/zip/build.zig"); const dvui = @import("dvui"); const velopack = @import("velopack_zig"); @@ -360,7 +360,7 @@ pub fn build(b: *std.Build) !void { .root_module = b.addModule("zstbi_web", .{ .target = web_target, .optimize = optimize, - .root_source_file = b.path("src/deps/stbi/zstbi.zig"), + .root_source_file = b.path("src/plugins/pixelart/deps/stbi/zstbi.zig"), .link_libc = false, .single_threaded = true, }), @@ -370,11 +370,11 @@ pub fn build(b: *std.Build) !void { "-DSTBI_NO_SIMD=1", }; zstbi_web_lib.root_module.addCSourceFile(.{ - .file = std.Build.path(b, "src/deps/stbi/zstbi.c"), + .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/zstbi.c"), .flags = &zstbi_web_cflags, }); zstbi_web_lib.root_module.addCSourceFile(.{ - .file = std.Build.path(b, "src/deps/stbi/fizzy_stbi_libc.c"), + .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c"), .flags = &zstbi_web_cflags, }); web_exe.root_module.addImport("zstbi", zstbi_web_lib.root_module); @@ -384,14 +384,14 @@ pub fn build(b: *std.Build) !void { .root_module = b.addModule("msf_gif_web", .{ .target = web_target, .optimize = optimize, - .root_source_file = b.path("src/deps/msf_gif/msf_gif.zig"), + .root_source_file = b.path("src/plugins/pixelart/deps/msf_gif/msf_gif.zig"), .link_libc = false, .single_threaded = true, }), }); - const msf_gif_wasm_cflags = [_][]const u8{"-Isrc/deps/msf_gif/wasm_shim"}; + const msf_gif_wasm_cflags = [_][]const u8{"-Isrc/plugins/pixelart/deps/msf_gif/wasm_shim"}; msf_gif_web_lib.root_module.addCSourceFile(.{ - .file = std.Build.path(b, "src/deps/msf_gif/fizzy_msf_gif_wasm.c"), + .file = std.Build.path(b, "src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c"), .flags = &msf_gif_wasm_cflags, }); web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module); @@ -739,13 +739,13 @@ pub fn build(b: *std.Build) !void { inline for (.{ .{ "fizzy-direction", "src/math/direction.zig" }, .{ "fizzy-easing", "src/math/easing.zig" }, - .{ "fizzy-layer-order", "src/internal/layer_order.zig" }, - .{ "fizzy-palette-parse", "src/internal/palette_parse.zig" }, + .{ "fizzy-layer-order", "src/plugins/pixelart/internal/layer_order.zig" }, + .{ "fizzy-palette-parse", "src/plugins/pixelart/internal/palette_parse.zig" }, .{ "fizzy-layout-anchor", "src/math/layout_anchor.zig" }, - .{ "fizzy-reduce", "src/algorithms/reduce.zig" }, - .{ "fizzy-grid-validate", "src/internal/grid_layout_validate.zig" }, - .{ "fizzy-animation", "src/Animation.zig" }, - .{ "fizzy-window-layout", "src/internal/window_layout.zig" }, + .{ "fizzy-reduce", "src/plugins/pixelart/algorithms/reduce.zig" }, + .{ "fizzy-grid-validate", "src/plugins/pixelart/internal/grid_layout_validate.zig" }, + .{ "fizzy-animation", "src/plugins/pixelart/Animation.zig" }, + .{ "fizzy-window-layout", "src/window_layout.zig" }, }) |entry| { tests_module.addAnonymousImport(entry[0], .{ .root_source_file = b.path(entry[1]), @@ -1075,22 +1075,22 @@ fn addFizzyExecutableForTarget( .root_module = b.addModule("zstbi", .{ .target = resolved_target, .optimize = optimize, - .root_source_file = .{ .cwd_relative = "src/deps/stbi/zstbi.zig" }, + .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/deps/stbi/zstbi.zig" }, }), }); const zstbi_module = zstbi_lib.root_module; - zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "src/deps/stbi/zstbi.c") }); + zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/zstbi.c") }); const msf_gif_lib = b.addLibrary(.{ .name = "msf_gif", .root_module = b.addModule("msf_gif", .{ .target = resolved_target, .optimize = optimize, - .root_source_file = .{ .cwd_relative = "src/deps/msf_gif/msf_gif.zig" }, + .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/deps/msf_gif/msf_gif.zig" }, }), }); const msf_gif_module = msf_gif_lib.root_module; - msf_gif_module.addCSourceFile(.{ .file = std.Build.path(b, "src/deps/msf_gif/msf_gif.c") }); + msf_gif_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/deps/msf_gif/msf_gif.c") }); const exe = b.addExecutable(.{ .name = "fizzy", diff --git a/contributor.md b/contributor.md index 9bde4d85..3be379a5 100644 --- a/contributor.md +++ b/contributor.md @@ -15,9 +15,9 @@ to have a conversation about Fizzy, please reach out to me on discord or add an Fizzy is built using several game development libraries by others in the Zig community, as well as a C library for handling zipped files. The dependencies are as follows: - ***mach-core***: Handles windowing and input, and uses the new zig package manager. This library and dependencies will be downloaded to the cache on build. - ***nfd_zig***: Native file dialogs wrapper, copied into the src/deps folder. - - ***zgui***: Wrapper for Dear Imgui, which is copied into the src/deps/zig-gamedev folder. - - ***zmath***: Math library, primarily using this for vector math and matrices. As above, this is copied into the src/deps/zig-gamedev folder. - - ***zstbi***: Wrapper for stbi provided by zig-gamedev. This handles loading and resizing images. As above, this is copied into the src/deps/zig-gamedev folder. + - ***zgui***: Wrapper for Dear Imgui, which is copied into the src/plugins/pixelart/deps/zig-gamedev folder. + - ***zmath***: Math library, primarily using this for vector math and matrices. As above, this is copied into the src/plugins/pixelart/deps/zig-gamedev folder. + - ***zstbi***: Wrapper for stbi provided by zig-gamedev. This handles loading and resizing images. As above, this is copied into the src/plugins/pixelart/deps/zig-gamedev folder. - ***zip***: Wrapper for the zip library, copied into the src/deps folder. Outside of the `src` folder, we have `assets` which contain all assets that we would like to be copied over next to the executable and used by Fizzy at runtime. diff --git a/src/backend_native.zig b/src/backend_native.zig index 93d7c6ba..e177739f 100644 --- a/src/backend_native.zig +++ b/src/backend_native.zig @@ -7,7 +7,7 @@ const sdl3 = @import("backend").c; const objc = @import("objc"); const win32 = @import("win32"); const singleton = @import("singleton.zig"); -const window_layout = @import("internal/window_layout.zig"); +const window_layout = @import("window_layout.zig"); // AppKit geometry types for NSView frame/bounds (same layout as Foundation). const NSPoint = extern struct { x: f64, y: f64 }; diff --git a/src/editor/Brushes.zig b/src/editor/Brushes.zig deleted file mode 100644 index fbc7566c..00000000 --- a/src/editor/Brushes.zig +++ /dev/null @@ -1,24 +0,0 @@ -const std = @import("std"); -const fizzy = @import("../fizzy.zig"); -const dvui = @import("dvui"); - -pub const Brushes = @This(); - -pub const Brush = struct { - name: []const u8, - source: dvui.ImageSource, - origin: dvui.Point, -}; - -brushes: std.ArrayList(Brush) = undefined, -selected_brush_index: usize = 0, - -pub fn init() !Brushes { - return .{ - .brushes = std.ArrayList(Brush).init(fizzy.app.allocator), - }; -} - -pub fn deinit(self: *Brushes) void { - self.brushes.deinit(); -} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index f76e6b9e..f310d7e9 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -20,32 +20,32 @@ const update_notify = @import("../update_notify.zig"); const App = fizzy.App; const Editor = @This(); -pub const Colors = @import("Colors.zig"); -pub const Project = @import("Project.zig"); +pub const Colors = @import("../plugins/pixelart/Colors.zig"); +pub const Project = @import("../plugins/pixelart/Project.zig"); pub const Recents = @import("Recents.zig"); pub const Settings = @import("Settings.zig"); -pub const Tools = @import("Tools.zig"); +pub const Tools = @import("../plugins/pixelart/Tools.zig"); pub const Dialogs = @import("dialogs/Dialogs.zig"); -pub const Transform = @import("Transform.zig"); +pub const Transform = @import("../plugins/pixelart/Transform.zig"); pub const Keybinds = @import("Keybinds.zig"); -pub const Workspace = @import("../workbench/Workspace.zig"); +pub const Workspace = @import("../plugins/workbench/Workspace.zig"); pub const Explorer = @import("explorer/Explorer.zig"); pub const IgnoreRules = @import("explorer/IgnoreRules.zig"); pub const Panel = @import("panel/Panel.zig"); pub const Sidebar = @import("Sidebar.zig"); pub const Infobar = @import("Infobar.zig"); pub const Menu = @import("Menu.zig"); -pub const FileLoadJob = @import("../workbench/FileLoadJob.zig"); -pub const PackJob = @import("PackJob.zig"); +pub const FileLoadJob = @import("../plugins/workbench/FileLoadJob.zig"); +pub const PackJob = @import("../plugins/pixelart/PackJob.zig"); pub const sdk = fizzy.sdk; pub const Host = sdk.Host; /// Workbench (Phase 1): file-management home — currently the per-branch /// decoration registry for the explorer; grows to own files + tabs/splits. -pub const Workbench = @import("../workbench/Workbench.zig"); +pub const Workbench = @import("../plugins/workbench/Workbench.zig"); /// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels. /// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame @@ -476,8 +476,8 @@ pub fn postInit(editor: *Editor) !void { // near-empty shell's content: it iterates the Host registries rather than // hardcoding panes. Web-safe — the draw fns reach the same inline code the // editor tick already runs on wasm. Order = sidebar order. - try @import("../workbench/plugin.zig").register(&editor.host); - try @import("../pixelart/plugin.zig").register(&editor.host); + try @import("../plugins/workbench/plugin.zig").register(&editor.host); + try @import("../plugins/pixelart/plugin.zig").register(&editor.host); // Shell built-in: Settings (owner = null; not a plugin). try editor.host.registerSidebarView(.{ @@ -1984,7 +1984,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { } editor.folder = try fizzy.app.allocator.dupe(u8, path); try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); - editor.host.setActiveSidebarView(@import("../workbench/plugin.zig").view_files); + editor.host.setActiveSidebarView(@import("../plugins/workbench/plugin.zig").view_files); editor.project = Project.load(fizzy.app.allocator) catch null; editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); @@ -2384,7 +2384,7 @@ pub fn processPackJob(editor: *Editor) void { } fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp(); job.result_consumed = true; - editor.host.setActiveSidebarView(@import("../pixelart/plugin.zig").view_project); + editor.host.setActiveSidebarView(@import("../plugins/pixelart/plugin.zig").view_project); const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null; showPackToast("Project packed", toast_canvas); } else blk: { diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 2445be5a..09c51b02 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -3,7 +3,6 @@ const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const Editor = fizzy.Editor; const settings = fizzy.settings; -const zstbi = @import("zstbi"); const builtin = @import("builtin"); pub var mouse_distance: f32 = std.math.floatMax(f32); diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig index cdcf0ab2..37f75577 100644 --- a/src/editor/dialogs/Dialogs.zig +++ b/src/editor/dialogs/Dialogs.zig @@ -4,12 +4,12 @@ const dvui = @import("dvui"); const Dialogs = @This(); -pub const NewFile = @import("NewFile.zig"); -pub const Export = @import("Export.zig"); +pub const NewFile = @import("../../plugins/pixelart/dialogs/NewFile.zig"); +pub const Export = @import("../../plugins/pixelart/dialogs/Export.zig"); pub const UnsavedClose = @import("UnsavedClose.zig"); pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig"); -pub const GridLayout = @import("GridLayout.zig"); -pub const FlatRasterSaveWarning = @import("FlatRasterSaveWarning.zig"); +pub const GridLayout = @import("../../plugins/pixelart/dialogs/GridLayout.zig"); +pub const FlatRasterSaveWarning = @import("../../plugins/pixelart/dialogs/FlatRasterSaveWarning.zig"); pub const AboutFizzy = @import("AboutFizzy.zig"); pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32) @import("WebFolderUnavailable.zig") diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index b8aa3466..35210347 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -1,7 +1,7 @@ const std = @import("std"); const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); -const FlatRasterSaveWarning = @import("FlatRasterSaveWarning.zig"); +const FlatRasterSaveWarning = @import("../../plugins/pixelart/dialogs/FlatRasterSaveWarning.zig"); pub fn request(file_id: u64) void { var mutex = fizzy.dvui.dialog(@src(), .{ diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index eeeacf9c..7b85dade 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -13,12 +13,12 @@ const nfd = @import("nfd"); pub const Explorer = @This(); -pub const files = @import("../../workbench/files.zig"); -pub const Tools = @import("tools.zig"); -pub const Sprites = @import("sprites.zig"); +pub const files = @import("../../plugins/workbench/files.zig"); +pub const Tools = @import("../../plugins/pixelart/explorer/tools.zig"); +pub const Sprites = @import("../../plugins/pixelart/explorer/sprites.zig"); // pub const animations = @import("animations.zig"); // pub const keyframe_animations = @import("keyframe_animations.zig"); -pub const project = @import("project.zig"); +pub const project = @import("../../plugins/pixelart/explorer/project.zig"); pub const settings = @import("settings.zig"); sprites: Sprites = .{}, @@ -113,7 +113,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { .background = false, }); - if (!fizzy.editor.host.isActiveSidebarView(@import("../../workbench/plugin.zig").view_files)) { + if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/plugin.zig").view_files)) { fizzy.editor.file_tree_data_id = null; if (fizzy.editor.tab_drag_from_tree_path) |p| { fizzy.app.allocator.free(p); diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig index e4f5496c..0e669db4 100644 --- a/src/editor/panel/Panel.zig +++ b/src/editor/panel/Panel.zig @@ -11,7 +11,7 @@ const Packer = fizzy.Packer; pub const Panel = @This(); -pub const Sprites = @import("sprites.zig"); +pub const Sprites = @import("../../plugins/pixelart/panel/sprites.zig"); sprites: Sprites = .{}, paned: *fizzy.dvui.PanedWidget = undefined, diff --git a/src/editor/widgets/Widgets.zig b/src/editor/widgets/Widgets.zig index 5ef58bf5..b0bc8d97 100644 --- a/src/editor/widgets/Widgets.zig +++ b/src/editor/widgets/Widgets.zig @@ -5,8 +5,8 @@ const dvui = @import("dvui"); pub const Widgets = @This(); -pub const FileWidget = @import("FileWidget.zig"); -pub const ImageWidget = @import("ImageWidget.zig"); +pub const FileWidget = @import("../../plugins/pixelart/widgets/FileWidget.zig"); +pub const ImageWidget = @import("../../plugins/pixelart/widgets/ImageWidget.zig"); pub const CanvasWidget = @import("CanvasWidget.zig"); pub const ReorderWidget = @import("ReorderWidget.zig"); pub const PanedWidget = @import("PanedWidget.zig"); diff --git a/src/fizzy.zig b/src/fizzy.zig index 62c47a6f..341faff0 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -13,7 +13,7 @@ pub const version: std.SemanticVersion = .{ pub const atlas = @import("generated/atlas.zig"); // Other helpers and namespaces -pub const algorithms = @import("algorithms/algorithms.zig"); +pub const algorithms = @import("plugins/pixelart/algorithms/algorithms.zig"); pub const fa = @import("tools/font_awesome.zig"); pub const fs = @import("tools/fs.zig"); pub const image = @import("gfx/image.zig"); @@ -26,7 +26,7 @@ pub const App = @import("App.zig"); pub const Editor = @import("editor/Editor.zig"); pub const Explorer = @import("editor/explorer/Explorer.zig"); pub const Fling = @import("editor/Fling.zig"); -pub const Packer = @import("tools/Packer.zig"); +pub const Packer = @import("plugins/pixelart/Packer.zig"); //pub const Popups = @import("editor/popups/Popups.zig"); pub const Sidebar = @import("editor/Sidebar.zig"); @@ -40,30 +40,30 @@ pub var packer: *Packer = undefined; /// An example of this is File. fizzy.File matches the file type to read from JSON, /// while the fizzy.Internal.File contains cameras, timers, file-specific editor fields. pub const Internal = struct { - pub const Animation = @import("internal/Animation.zig"); - pub const Atlas = @import("internal/Atlas.zig"); - pub const Buffers = @import("internal/Buffers.zig"); - pub const File = @import("internal/File.zig"); - pub const History = @import("internal/History.zig"); - pub const Layer = @import("internal/Layer.zig"); - pub const Palette = @import("internal/Palette.zig"); - pub const Sprite = @import("internal/Sprite.zig"); + pub const Animation = @import("plugins/pixelart/internal/Animation.zig"); + pub const Atlas = @import("plugins/pixelart/internal/Atlas.zig"); + pub const Buffers = @import("plugins/pixelart/internal/Buffers.zig"); + pub const File = @import("plugins/pixelart/internal/File.zig"); + pub const History = @import("plugins/pixelart/internal/History.zig"); + pub const Layer = @import("plugins/pixelart/internal/Layer.zig"); + pub const Palette = @import("plugins/pixelart/internal/Palette.zig"); + pub const Sprite = @import("plugins/pixelart/internal/Sprite.zig"); }; /// Frame-by-frame sprite animation -pub const Animation = @import("Animation.zig"); +pub const Animation = @import("plugins/pixelart/Animation.zig"); /// Contains lists of sprites and animations -pub const Atlas = @import("Atlas.zig"); +pub const Atlas = @import("plugins/pixelart/Atlas.zig"); /// The data that gets written to disk in a .pixi file and read back into this type -pub const File = @import("File.zig"); +pub const File = @import("plugins/pixelart/File.zig"); /// Contains information such as the name, visibility and collapse settings of a texture layer -pub const Layer = @import("Layer.zig"); +pub const Layer = @import("plugins/pixelart/Layer.zig"); /// Source location within the atlas texture and origin location -pub const Sprite = @import("Sprite.zig"); +pub const Sprite = @import("plugins/pixelart/Sprite.zig"); /// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web /// builds, where `builtin.os.tag` is always `.freestanding`. diff --git a/src/gfx/image.zig b/src/gfx/image.zig index b39c0110..f38682d9 100644 --- a/src/gfx/image.zig +++ b/src/gfx/image.zig @@ -1,7 +1,6 @@ const std = @import("std"); const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); -const zip = @import("zip"); pub fn init(width: u32, height: u32, default_color: dvui.Color.PMA, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource { const num_pixels = width * height; @@ -329,32 +328,12 @@ pub fn blitData(src_pixels: [][4]u8, src_width: usize, src_height: usize, dst_pi } } -fn ensurePngWriterBuffer(writer: *std.Io.Writer) !void { +pub fn ensurePngWriterBuffer(writer: *std.Io.Writer) !void { if (writer.buffer.len < dvui.PNGEncoder.min_buffer_size) { try writer.rebase(0, dvui.PNGEncoder.min_buffer_size); } } -pub fn writeToZip( - source: dvui.ImageSource, - zip_file: ?*anyopaque, - resolution: u32, -) !void { - const s: dvui.Size = dvui.imageSize(source) catch .{ .w = 0, .h = 0 }; - - const w = @as(c_int, @intFromFloat(s.w)); - const h = @as(c_int, @intFromFloat(s.h)); - - var writer = std.Io.Writer.Allocating.init(fizzy.editor.arena.allocator()); - - try ensurePngWriterBuffer(&writer.writer); - try dvui.PNGEncoder.writeWithResolution(&writer.writer, fizzy.image.bytes(source), @intCast(w), @intCast(h), resolution); - - if (@as(?*zip.struct_zip_t, @ptrCast(zip_file))) |z| { - _ = zip.zip_entry_write(z, writer.written().ptr, @as(usize, writer.written().len)); - } -} - pub fn writePngToWriter(source: dvui.ImageSource, writer: *std.Io.Writer, resolution: u32) !void { const flat = try flatRgbaForEncode(source); try ensurePngWriterBuffer(writer); diff --git a/src/platform.zig b/src/platform.zig index 575b8fa6..0c809af7 100644 --- a/src/platform.zig +++ b/src/platform.zig @@ -33,7 +33,7 @@ pub fn cacheFromWindow(win: *dvui.Window) void { cached_is_macos = kb.command orelse false; } -/// True iff the running platform is macOS. Use this anywhere fizzy previously +/// True if the running platform is macOS. Use this anywhere fizzy previously /// had `builtin.os.tag == .macos` and the check needs to be right on web. pub inline fn isMacOS() bool { return cached_is_macos; diff --git a/src/Animation.zig b/src/plugins/pixelart/Animation.zig similarity index 100% rename from src/Animation.zig rename to src/plugins/pixelart/Animation.zig diff --git a/src/Atlas.zig b/src/plugins/pixelart/Atlas.zig similarity index 97% rename from src/Atlas.zig rename to src/plugins/pixelart/Atlas.zig index ff1a5346..6e7749d6 100644 --- a/src/Atlas.zig +++ b/src/plugins/pixelart/Atlas.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const fs = @import("tools/fs.zig"); -const fizzy = @import("fizzy.zig"); +const fs = @import("../../tools/fs.zig"); +const fizzy = @import("../../fizzy.zig"); const Atlas = @This(); diff --git a/src/pixelart/CanvasData.zig b/src/plugins/pixelart/CanvasData.zig similarity index 99% rename from src/pixelart/CanvasData.zig rename to src/plugins/pixelart/CanvasData.zig index 5367f544..d674b525 100644 --- a/src/pixelart/CanvasData.zig +++ b/src/plugins/pixelart/CanvasData.zig @@ -11,7 +11,7 @@ //! toasts) intentionally stays on `Workspace`. const std = @import("std"); const dvui = @import("dvui"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const icons = @import("icons"); const Workspace = fizzy.Editor.Workspace; diff --git a/src/editor/Colors.zig b/src/plugins/pixelart/Colors.zig similarity index 85% rename from src/editor/Colors.zig rename to src/plugins/pixelart/Colors.zig index 531f1cb4..5c987ee9 100644 --- a/src/editor/Colors.zig +++ b/src/plugins/pixelart/Colors.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const Self = @This(); diff --git a/src/File.zig b/src/plugins/pixelart/File.zig similarity index 98% rename from src/File.zig rename to src/plugins/pixelart/File.zig index bb4cd017..6f0f786e 100644 --- a/src/File.zig +++ b/src/plugins/pixelart/File.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const File = @This(); diff --git a/src/tools/LDTKTileset.zig b/src/plugins/pixelart/LDTKTileset.zig similarity index 87% rename from src/tools/LDTKTileset.zig rename to src/plugins/pixelart/LDTKTileset.zig index 86c67b96..09303032 100644 --- a/src/tools/LDTKTileset.zig +++ b/src/plugins/pixelart/LDTKTileset.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const core = @import("mach").core; pub const LDTKCompatibility = struct { diff --git a/src/Layer.zig b/src/plugins/pixelart/Layer.zig similarity index 100% rename from src/Layer.zig rename to src/plugins/pixelart/Layer.zig diff --git a/src/editor/PackJob.zig b/src/plugins/pixelart/PackJob.zig similarity index 99% rename from src/editor/PackJob.zig rename to src/plugins/pixelart/PackJob.zig index d5202743..7dc1baa6 100644 --- a/src/editor/PackJob.zig +++ b/src/plugins/pixelart/PackJob.zig @@ -17,11 +17,11 @@ //! - `phase` / `cancelled` are atomic; either side may read or write them. const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const zstbi = @import("zstbi"); -const perf = @import("../gfx/perf.zig"); -const reduce_alg = @import("../algorithms/reduce.zig"); +const perf = @import("../../gfx/perf.zig"); +const reduce_alg = @import("algorithms/reduce.zig"); const PackJob = @This(); diff --git a/src/tools/Packer.zig b/src/plugins/pixelart/Packer.zig similarity index 99% rename from src/tools/Packer.zig rename to src/plugins/pixelart/Packer.zig index b6b5ef01..00f49532 100644 --- a/src/tools/Packer.zig +++ b/src/plugins/pixelart/Packer.zig @@ -2,7 +2,7 @@ const std = @import("std"); const zstbi = @import("zstbi"); const dvui = @import("dvui"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); pub const LDTKTileset = @import("LDTKTileset.zig"); diff --git a/src/editor/Project.zig b/src/plugins/pixelart/Project.zig similarity index 99% rename from src/editor/Project.zig rename to src/plugins/pixelart/Project.zig index f7c63df3..c3cff907 100644 --- a/src/editor/Project.zig +++ b/src/plugins/pixelart/Project.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const Project = @This(); diff --git a/src/Sprite.zig b/src/plugins/pixelart/Sprite.zig similarity index 100% rename from src/Sprite.zig rename to src/plugins/pixelart/Sprite.zig diff --git a/src/editor/Tools.zig b/src/plugins/pixelart/Tools.zig similarity index 99% rename from src/editor/Tools.zig rename to src/plugins/pixelart/Tools.zig index 68555989..8efce232 100644 --- a/src/editor/Tools.zig +++ b/src/plugins/pixelart/Tools.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const Tools = @This(); diff --git a/src/editor/Transform.zig b/src/plugins/pixelart/Transform.zig similarity index 99% rename from src/editor/Transform.zig rename to src/plugins/pixelart/Transform.zig index 38d58931..5fe1550f 100644 --- a/src/editor/Transform.zig +++ b/src/plugins/pixelart/Transform.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); pub const Transform = @This(); diff --git a/src/algorithms/algorithms.zig b/src/plugins/pixelart/algorithms/algorithms.zig similarity index 100% rename from src/algorithms/algorithms.zig rename to src/plugins/pixelart/algorithms/algorithms.zig diff --git a/src/algorithms/brezenham.zig b/src/plugins/pixelart/algorithms/brezenham.zig similarity index 96% rename from src/algorithms/brezenham.zig rename to src/plugins/pixelart/algorithms/brezenham.zig index f61ab318..46f2061b 100644 --- a/src/algorithms/brezenham.zig +++ b/src/plugins/pixelart/algorithms/brezenham.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); pub fn process(start: dvui.Point, end: dvui.Point) ![]dvui.Point { diff --git a/src/algorithms/reduce.zig b/src/plugins/pixelart/algorithms/reduce.zig similarity index 100% rename from src/algorithms/reduce.zig rename to src/plugins/pixelart/algorithms/reduce.zig diff --git a/src/deps/msf_gif/fizzy_msf_gif_wasm.c b/src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c similarity index 100% rename from src/deps/msf_gif/fizzy_msf_gif_wasm.c rename to src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c diff --git a/src/deps/msf_gif/msf_gif.c b/src/plugins/pixelart/deps/msf_gif/msf_gif.c similarity index 100% rename from src/deps/msf_gif/msf_gif.c rename to src/plugins/pixelart/deps/msf_gif/msf_gif.c diff --git a/src/deps/msf_gif/msf_gif.h b/src/plugins/pixelart/deps/msf_gif/msf_gif.h similarity index 100% rename from src/deps/msf_gif/msf_gif.h rename to src/plugins/pixelart/deps/msf_gif/msf_gif.h diff --git a/src/deps/msf_gif/msf_gif.zig b/src/plugins/pixelart/deps/msf_gif/msf_gif.zig similarity index 100% rename from src/deps/msf_gif/msf_gif.zig rename to src/plugins/pixelart/deps/msf_gif/msf_gif.zig diff --git a/src/deps/msf_gif/wasm_shim/string.h b/src/plugins/pixelart/deps/msf_gif/wasm_shim/string.h similarity index 100% rename from src/deps/msf_gif/wasm_shim/string.h rename to src/plugins/pixelart/deps/msf_gif/wasm_shim/string.h diff --git a/src/deps/stbi/fizzy_stbi_libc.c b/src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c similarity index 100% rename from src/deps/stbi/fizzy_stbi_libc.c rename to src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c diff --git a/src/deps/stbi/stb_image_resize2.h b/src/plugins/pixelart/deps/stbi/stb_image_resize2.h similarity index 100% rename from src/deps/stbi/stb_image_resize2.h rename to src/plugins/pixelart/deps/stbi/stb_image_resize2.h diff --git a/src/deps/stbi/stb_rect_pack.h b/src/plugins/pixelart/deps/stbi/stb_rect_pack.h similarity index 100% rename from src/deps/stbi/stb_rect_pack.h rename to src/plugins/pixelart/deps/stbi/stb_rect_pack.h diff --git a/src/deps/stbi/zstbi.c b/src/plugins/pixelart/deps/stbi/zstbi.c similarity index 100% rename from src/deps/stbi/zstbi.c rename to src/plugins/pixelart/deps/stbi/zstbi.c diff --git a/src/deps/stbi/zstbi.zig b/src/plugins/pixelart/deps/stbi/zstbi.zig similarity index 100% rename from src/deps/stbi/zstbi.zig rename to src/plugins/pixelart/deps/stbi/zstbi.zig diff --git a/src/deps/zip/build.zig b/src/plugins/pixelart/deps/zip/build.zig similarity index 100% rename from src/deps/zip/build.zig rename to src/plugins/pixelart/deps/zip/build.zig diff --git a/src/deps/zip/fizzy_zip_libc.c b/src/plugins/pixelart/deps/zip/fizzy_zip_libc.c similarity index 100% rename from src/deps/zip/fizzy_zip_libc.c rename to src/plugins/pixelart/deps/zip/fizzy_zip_libc.c diff --git a/src/deps/zip/fizzy_zip_strings.c b/src/plugins/pixelart/deps/zip/fizzy_zip_strings.c similarity index 100% rename from src/deps/zip/fizzy_zip_strings.c rename to src/plugins/pixelart/deps/zip/fizzy_zip_strings.c diff --git a/src/deps/zip/fizzy_zip_wasm.h b/src/plugins/pixelart/deps/zip/fizzy_zip_wasm.h similarity index 100% rename from src/deps/zip/fizzy_zip_wasm.h rename to src/plugins/pixelart/deps/zip/fizzy_zip_wasm.h diff --git a/src/deps/zip/src/miniz.h b/src/plugins/pixelart/deps/zip/src/miniz.h similarity index 100% rename from src/deps/zip/src/miniz.h rename to src/plugins/pixelart/deps/zip/src/miniz.h diff --git a/src/deps/zip/src/zip.c b/src/plugins/pixelart/deps/zip/src/zip.c similarity index 100% rename from src/deps/zip/src/zip.c rename to src/plugins/pixelart/deps/zip/src/zip.c diff --git a/src/deps/zip/src/zip.h b/src/plugins/pixelart/deps/zip/src/zip.h similarity index 100% rename from src/deps/zip/src/zip.h rename to src/plugins/pixelart/deps/zip/src/zip.h diff --git a/src/deps/zip/zip.zig b/src/plugins/pixelart/deps/zip/zip.zig similarity index 100% rename from src/deps/zip/zip.zig rename to src/plugins/pixelart/deps/zip/zip.zig diff --git a/src/editor/dialogs/Export.zig b/src/plugins/pixelart/dialogs/Export.zig similarity index 99% rename from src/editor/dialogs/Export.zig rename to src/plugins/pixelart/dialogs/Export.zig index 7f009fe4..669b4079 100644 --- a/src/editor/dialogs/Export.zig +++ b/src/plugins/pixelart/dialogs/Export.zig @@ -1,16 +1,16 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const zigimg = @import("zigimg"); const msf_gif = @import("msf_gif"); const zstbi = @import("zstbi"); -const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../WebFileIo.zig") else struct {}; +const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../../../editor/WebFileIo.zig") else struct {}; const ExportImageFormat = enum { png, jpg }; -const Dialogs = @import("Dialogs.zig"); +const Dialogs = @import("../../../editor/dialogs/Dialogs.zig"); pub var mode: enum(usize) { single, diff --git a/src/editor/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig similarity index 99% rename from src/editor/dialogs/FlatRasterSaveWarning.zig rename to src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig index 26de3119..fd38b7b9 100644 --- a/src/editor/dialogs/FlatRasterSaveWarning.zig +++ b/src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); /// When `pending_mode == .save_and_close`, resume `Editor.advanceSaveAllQuit` after flat save. diff --git a/src/editor/dialogs/GridLayout.zig b/src/plugins/pixelart/dialogs/GridLayout.zig similarity index 99% rename from src/editor/dialogs/GridLayout.zig rename to src/plugins/pixelart/dialogs/GridLayout.zig index 94e09510..66b3301f 100644 --- a/src/editor/dialogs/GridLayout.zig +++ b/src/plugins/pixelart/dialogs/GridLayout.zig @@ -6,14 +6,14 @@ //! preview on the right that expands with the window. The preview uses `CanvasWidget` so //! panning / zooming honour `Settings.resolvedPanZoomScheme` (`auto` follows DVUI scroll heuristics). -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const std = @import("std"); const NewFile = @import("NewFile.zig"); -const CanvasWidget = @import("../widgets/CanvasWidget.zig"); +const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig"); const CanvasBridge = @import("../widgets/CanvasBridge.zig"); -const FloatingWindowWidget = @import("../widgets/FloatingWindowWidget.zig"); +const FloatingWindowWidget = @import("../../../editor/widgets/FloatingWindowWidget.zig"); const builtin = @import("builtin"); /// Editable grid fields for one mode (Slice vs Resize each keep their own backing). diff --git a/src/editor/dialogs/NewFile.zig b/src/plugins/pixelart/dialogs/NewFile.zig similarity index 98% rename from src/editor/dialogs/NewFile.zig rename to src/plugins/pixelart/dialogs/NewFile.zig index a4a0a462..f6a591c9 100644 --- a/src/editor/dialogs/NewFile.zig +++ b/src/plugins/pixelart/dialogs/NewFile.zig @@ -1,8 +1,8 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); -const Dialogs = @import("Dialogs.zig"); +const Dialogs = @import("../../../editor/dialogs/Dialogs.zig"); pub var mode: enum(usize) { single, diff --git a/src/editor/explorer/project.zig b/src/plugins/pixelart/explorer/project.zig similarity index 99% rename from src/editor/explorer/project.zig rename to src/plugins/pixelart/explorer/project.zig index ccc1bfe5..e6affcba 100644 --- a/src/editor/explorer/project.zig +++ b/src/plugins/pixelart/explorer/project.zig @@ -2,7 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const icons = @import("icons"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); pub fn draw() !void { diff --git a/src/editor/explorer/sprites.zig b/src/plugins/pixelart/explorer/sprites.zig similarity index 99% rename from src/editor/explorer/sprites.zig rename to src/plugins/pixelart/explorer/sprites.zig index 8e0caea8..eaa90f5d 100644 --- a/src/editor/explorer/sprites.zig +++ b/src/plugins/pixelart/explorer/sprites.zig @@ -2,7 +2,7 @@ const std = @import("std"); const dvui = @import("dvui"); const icons = @import("icons"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const Editor = fizzy.Editor; const Sprites = @This(); diff --git a/src/editor/explorer/tools.zig b/src/plugins/pixelart/explorer/tools.zig similarity index 99% rename from src/editor/explorer/tools.zig rename to src/plugins/pixelart/explorer/tools.zig index 2ec7f3b8..a6ed38d3 100644 --- a/src/editor/explorer/tools.zig +++ b/src/plugins/pixelart/explorer/tools.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const icons = @import("icons"); const assets = @import("assets"); diff --git a/src/internal/Animation.zig b/src/plugins/pixelart/internal/Animation.zig similarity index 100% rename from src/internal/Animation.zig rename to src/plugins/pixelart/internal/Animation.zig diff --git a/src/internal/Atlas.zig b/src/plugins/pixelart/internal/Atlas.zig similarity index 94% rename from src/internal/Atlas.zig rename to src/plugins/pixelart/internal/Atlas.zig index 676e9f1f..d262c0ef 100644 --- a/src/internal/Atlas.zig +++ b/src/plugins/pixelart/internal/Atlas.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const Atlas = @This(); @@ -64,7 +64,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { } const bytes = try out.toOwnedSlice(); defer allocator.free(bytes); - try @import("../editor/WebFileIo.zig").downloadBytes(path, bytes); + try @import("../../../editor/WebFileIo.zig").downloadBytes(path, bytes); }, .data => { if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) { @@ -74,7 +74,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { const options: std.json.Stringify.Options = .{}; const output = try std.json.Stringify.valueAlloc(allocator, atlas.data, options); defer allocator.free(output); - try @import("../editor/WebFileIo.zig").downloadBytes(path, output); + try @import("../../../editor/WebFileIo.zig").downloadBytes(path, output); }, } return; diff --git a/src/internal/Buffers.zig b/src/plugins/pixelart/internal/Buffers.zig similarity index 98% rename from src/internal/Buffers.zig rename to src/plugins/pixelart/internal/Buffers.zig index 88bb3c4f..968c63ca 100644 --- a/src/internal/Buffers.zig +++ b/src/plugins/pixelart/internal/Buffers.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const History = @import("History.zig"); const Buffers = @This(); diff --git a/src/internal/File.zig b/src/plugins/pixelart/internal/File.zig similarity index 99% rename from src/internal/File.zig rename to src/plugins/pixelart/internal/File.zig index 4d5a66d0..5443e17d 100644 --- a/src/internal/File.zig +++ b/src/plugins/pixelart/internal/File.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); -const pixelart = @import("../pixelart/plugin.zig"); +const fizzy = @import("../../../fizzy.zig"); +const pixelart = @import("../plugin.zig"); const zip = @import("zip"); const dvui = @import("dvui"); @@ -3151,15 +3151,15 @@ pub fn saveToDownload(self: *File, window: *dvui.Window) !void { defer snap.deinit(fizzy.app.allocator); const bytes = try writeSnapshotToZipBytes(&snap, fizzy.app.allocator); defer fizzy.app.allocator.free(bytes); - try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes); + try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes); } else if (std.mem.eql(u8, ext, ".png")) { const bytes = try flattenedImageBytes(self, window, .png); defer fizzy.app.allocator.free(bytes); - try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes); + try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { const bytes = try flattenedImageBytes(self, window, .jpg); defer fizzy.app.allocator.free(bytes); - try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes); + try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes); } else { return; } @@ -3341,7 +3341,7 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo }; defer fizzy.app.allocator.free(bytes); const dl_ext = if (is_png) ".png" else ".jpg"; - try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes); + try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes); } else if (is_png) { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); try fizzy.image.writeToPngResolution(single_layer.source, output_path, r); diff --git a/src/internal/History.zig b/src/plugins/pixelart/internal/History.zig similarity index 99% rename from src/internal/History.zig rename to src/plugins/pixelart/internal/History.zig index 2e2cf362..240c515f 100644 --- a/src/internal/History.zig +++ b/src/plugins/pixelart/internal/History.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); -const pixelart = @import("../pixelart/plugin.zig"); +const fizzy = @import("../../../fizzy.zig"); +const pixelart = @import("../plugin.zig"); const zgui = @import("zgui"); const History = @This(); const Editor = fizzy.Editor; diff --git a/src/internal/Layer.zig b/src/plugins/pixelart/internal/Layer.zig similarity index 96% rename from src/internal/Layer.zig rename to src/plugins/pixelart/internal/Layer.zig index 73816b93..52a7275d 100644 --- a/src/internal/Layer.zig +++ b/src/plugins/pixelart/internal/Layer.zig @@ -1,6 +1,6 @@ const std = @import("std"); const dvui = @import("dvui"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const zip = @import("zip"); const Layer = @This(); @@ -416,7 +416,20 @@ pub fn writeSourceToZip( zip_file: ?*anyopaque, resolution: u32, ) !void { - return fizzy.image.writeToZip(layer.source, zip_file, resolution); + const source = layer.source; + const s: dvui.Size = dvui.imageSize(source) catch .{ .w = 0, .h = 0 }; + + const w = @as(c_int, @intFromFloat(s.w)); + const h = @as(c_int, @intFromFloat(s.h)); + + var writer = std.Io.Writer.Allocating.init(fizzy.editor.arena.allocator()); + + try fizzy.image.ensurePngWriterBuffer(&writer.writer); + try dvui.PNGEncoder.writeWithResolution(&writer.writer, fizzy.image.bytes(source), @intCast(w), @intCast(h), resolution); + + if (@as(?*zip.struct_zip_t, @ptrCast(zip_file))) |z| { + _ = zip.zip_entry_write(z, writer.written().ptr, @as(usize, writer.written().len)); + } } pub fn writeSourceToPng(layer: *const Layer, path: []const u8) !void { diff --git a/src/internal/Palette.zig b/src/plugins/pixelart/internal/Palette.zig similarity index 97% rename from src/internal/Palette.zig rename to src/plugins/pixelart/internal/Palette.zig index cefe2c2c..63e1b0f1 100644 --- a/src/internal/Palette.zig +++ b/src/plugins/pixelart/internal/Palette.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const palette_parse = @import("palette_parse.zig"); diff --git a/src/internal/Sprite.zig b/src/plugins/pixelart/internal/Sprite.zig similarity index 100% rename from src/internal/Sprite.zig rename to src/plugins/pixelart/internal/Sprite.zig diff --git a/src/internal/grid_layout_validate.zig b/src/plugins/pixelart/internal/grid_layout_validate.zig similarity index 100% rename from src/internal/grid_layout_validate.zig rename to src/plugins/pixelart/internal/grid_layout_validate.zig diff --git a/src/internal/layer_order.zig b/src/plugins/pixelart/internal/layer_order.zig similarity index 100% rename from src/internal/layer_order.zig rename to src/plugins/pixelart/internal/layer_order.zig diff --git a/src/internal/palette_parse.zig b/src/plugins/pixelart/internal/palette_parse.zig similarity index 100% rename from src/internal/palette_parse.zig rename to src/plugins/pixelart/internal/palette_parse.zig diff --git a/src/editor/panel/sprites.zig b/src/plugins/pixelart/panel/sprites.zig similarity index 99% rename from src/editor/panel/sprites.zig rename to src/plugins/pixelart/panel/sprites.zig index e685370b..3ffcded9 100644 --- a/src/editor/panel/sprites.zig +++ b/src/plugins/pixelart/panel/sprites.zig @@ -1,7 +1,7 @@ const std = @import("std"); const icons = @import("icons"); const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const Editor = fizzy.Editor; const ReflectionLagSample = fizzy.dvui.ReflectionLagSample; const reflection_surface_cols = fizzy.dvui.reflection_surface_cols; diff --git a/src/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig similarity index 99% rename from src/pixelart/plugin.zig rename to src/plugins/pixelart/plugin.zig index 0db81045..130b386f 100644 --- a/src/pixelart/plugin.zig +++ b/src/plugins/pixelart/plugin.zig @@ -4,7 +4,7 @@ //! through the `fizzy.*` globals. Registered from `Editor.postInit`. const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const sdk = fizzy.sdk; const CanvasData = @import("CanvasData.zig"); diff --git a/src/editor/widgets/CanvasBridge.zig b/src/plugins/pixelart/widgets/CanvasBridge.zig similarity index 88% rename from src/editor/widgets/CanvasBridge.zig rename to src/plugins/pixelart/widgets/CanvasBridge.zig index 4b1cf339..08f7aaa8 100644 --- a/src/editor/widgets/CanvasBridge.zig +++ b/src/plugins/pixelart/widgets/CanvasBridge.zig @@ -1,8 +1,8 @@ //! Bridges the decoupled `CanvasWidget` back to editor/app globals. The canvas takes the //! pan/zoom scheme as config and input-suppression as a hook so it stays a reusable //! viewport; these helpers supply the pixel-art editor's wiring at the install sites. -const fizzy = @import("../../fizzy.zig"); -const CanvasWidget = @import("CanvasWidget.zig"); +const fizzy = @import("../../../fizzy.zig"); +const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig"); /// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum. pub fn scheme() CanvasWidget.PanZoomScheme { diff --git a/src/editor/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig similarity index 99% rename from src/editor/widgets/FileWidget.zig rename to src/plugins/pixelart/widgets/FileWidget.zig index cd4c8590..a6ac74c3 100644 --- a/src/editor/widgets/FileWidget.zig +++ b/src/plugins/pixelart/widgets/FileWidget.zig @@ -1,7 +1,7 @@ const std = @import("std"); const math = std.math; const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const builtin = @import("builtin"); const sdl3 = @import("backend").c; @@ -16,10 +16,10 @@ const ScrollContainerWidget = dvui.ScrollContainerWidget; const ScaleWidget = dvui.ScaleWidget; pub const FileWidget = @This(); -const CanvasWidget = @import("CanvasWidget.zig"); +const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig"); const CanvasBridge = @import("CanvasBridge.zig"); const Workspace = fizzy.Editor.Workspace; -const CanvasData = @import("../../pixelart/CanvasData.zig"); +const CanvasData = @import("../CanvasData.zig"); const icons = @import("icons"); // ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is @@ -1643,7 +1643,7 @@ pub fn drawSpriteBubble( self.init_options.file.collapseAnimationSelectionToPrimary(); self.init_options.file.editor.animations_scroll_to_index = anim_index; fizzy.editor.explorer.sprites.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index]; - fizzy.editor.host.setActiveSidebarView(@import("../../pixelart/plugin.zig").view_sprites); + fizzy.editor.host.setActiveSidebarView(@import("../plugin.zig").view_sprites); var anim = self.init_options.file.animations.get(anim_index); if (anim.frames.len == 0) { @@ -4626,7 +4626,7 @@ pub fn drawLayers(self: *FileWidget) void { const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect); // Draw the origins when in the sprites pane - if (fizzy.editor.host.isActiveSidebarView(@import("../../pixelart/plugin.zig").view_sprites)) { + if (fizzy.editor.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) { const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] }; const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y }; diff --git a/src/editor/widgets/ImageWidget.zig b/src/plugins/pixelart/widgets/ImageWidget.zig similarity index 99% rename from src/editor/widgets/ImageWidget.zig rename to src/plugins/pixelart/widgets/ImageWidget.zig index cf7ec299..68373922 100644 --- a/src/editor/widgets/ImageWidget.zig +++ b/src/plugins/pixelart/widgets/ImageWidget.zig @@ -1,5 +1,5 @@ pub const ImageWidget = @This(); -const CanvasWidget = @import("CanvasWidget.zig"); +const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig"); const CanvasBridge = @import("CanvasBridge.zig"); init_options: InitOptions, @@ -469,7 +469,7 @@ const ScaleWidget = dvui.ScaleWidget; const std = @import("std"); const math = std.math; const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const builtin = @import("builtin"); test { diff --git a/src/workbench/FileLoadJob.zig b/src/plugins/workbench/FileLoadJob.zig similarity index 98% rename from src/workbench/FileLoadJob.zig rename to src/plugins/workbench/FileLoadJob.zig index ef7119cd..c8305d7e 100644 --- a/src/workbench/FileLoadJob.zig +++ b/src/plugins/workbench/FileLoadJob.zig @@ -15,9 +15,9 @@ //! but only writes through atomic fields + the worker-only `result`/`err`/`canvas_target_grouping` fields. const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); -const perf = @import("../gfx/perf.zig"); +const perf = @import("../../gfx/perf.zig"); const FileLoadJob = @This(); diff --git a/src/workbench/Workbench.zig b/src/plugins/workbench/Workbench.zig similarity index 99% rename from src/workbench/Workbench.zig rename to src/plugins/workbench/Workbench.zig index 16dcae66..d799a8ef 100644 --- a/src/workbench/Workbench.zig +++ b/src/plugins/workbench/Workbench.zig @@ -11,7 +11,7 @@ const std = @import("std"); const dvui = @import("dvui"); const icons = @import("icons"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const files = @import("files.zig"); pub const Workbench = @This(); @@ -88,7 +88,7 @@ fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void { /// explorer rows. /// /// Cross-boundary types are normal Zig (host + plugins share one pinned SDK build), -/// so this is a plain vtable struct; only the dlopen entry symbols (Phase 4) need +/// so this is a plain vtable struct; only the dlopen entry symbols need /// `callconv(.c)`. The implementation lives below; `ctx` is the host's `*Editor`. pub const Api = struct { /// Service-locator key for `host.registerService` / `host.getService`. diff --git a/src/workbench/Workspace.zig b/src/plugins/workbench/Workspace.zig similarity index 99% rename from src/workbench/Workspace.zig rename to src/plugins/workbench/Workspace.zig index 75a0cb74..38222eb5 100644 --- a/src/workbench/Workspace.zig +++ b/src/plugins/workbench/Workspace.zig @@ -2,7 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const icons = @import("icons"); const App = fizzy.App; diff --git a/src/workbench/files.zig b/src/plugins/workbench/files.zig similarity index 99% rename from src/workbench/files.zig rename to src/plugins/workbench/files.zig index ba988b14..9b2f03ea 100644 --- a/src/workbench/files.zig +++ b/src/plugins/workbench/files.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const Editor = fizzy.Editor; const builtin = @import("builtin"); @@ -7,7 +7,6 @@ const builtin = @import("builtin"); const icons = @import("icons"); const nfd = @import("nfd"); -const zstbi = @import("zstbi"); pub var tree_removed_path: ?[]const u8 = null; pub var selected_id: ?usize = null; diff --git a/src/workbench/plugin.zig b/src/plugins/workbench/plugin.zig similarity index 98% rename from src/workbench/plugin.zig rename to src/plugins/workbench/plugin.zig index 8fb6afdd..8e6ed926 100644 --- a/src/workbench/plugin.zig +++ b/src/plugins/workbench/plugin.zig @@ -3,7 +3,7 @@ //! than owning new code. Later phases move more behind it until it becomes a //! runtime-loaded dylib. Registered from `Editor.postInit`. const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const sdk = fizzy.sdk; const files = @import("files.zig"); diff --git a/src/tools/process_assets.zig b/src/tools/process_assets.zig index 3597b0eb..d596bfdb 100644 --- a/src/tools/process_assets.zig +++ b/src/tools/process_assets.zig @@ -3,7 +3,7 @@ const path = std.fs.path; const Step = std.Build.Step; const Io = std.Io; -const Atlas = @import("../Atlas.zig"); +const Atlas = @import("../plugins/pixelart/Atlas.zig"); const ProcessAssetsStep = @This(); step: Step, diff --git a/src/internal/window_layout.zig b/src/window_layout.zig similarity index 98% rename from src/internal/window_layout.zig rename to src/window_layout.zig index dd15f2f3..4e8cccfe 100644 --- a/src/internal/window_layout.zig +++ b/src/window_layout.zig @@ -2,7 +2,8 @@ //! (`backend_native.zig` + `objc/FizzyWindowMonitor.m`), so the "+/- titlebar //! height" math is testable without a window. std-only — pulled in by //! `tests/root.zig` and called from `backend_native.zig` (which keeps the -//! AppKit/SDL plumbing). See `src/internal/window_layout` notes in the plan. +//! AppKit/SDL plumbing). Shell/native-windowing infra (not pixel-art), so it lives at +//! `src/window_layout.zig` beside `backend_native.zig` rather than under `internal/`. const std = @import("std"); diff --git a/tests/README.md b/tests/README.md index 39241bac..7b226459 100644 --- a/tests/README.md +++ b/tests/README.md @@ -67,9 +67,9 @@ covered: direction encoding, `fromRadians`, rotation inverses. - `[src/math/easing.zig](../src/math/easing.zig)` — `lerp`, `ease`, endpoint pinning, midpoint bias. -- `[src/internal/layer_order.zig](../src/internal/layer_order.zig)` — +- `[src/plugins/pixelart/internal/layer_order.zig](../src/plugins/pixelart/internal/layer_order.zig)` — the layer-reorder algorithm used by the layers tree drag-and-drop. -- `[src/internal/palette_parse.zig](../src/internal/palette_parse.zig)` +- `[src/plugins/pixelart/internal/palette_parse.zig](../src/plugins/pixelart/internal/palette_parse.zig)` — `.hex` palette file parser (valid hex, comments/blanks, malformed input, CRLF). From 879a3657f532dbc9a00c6c3c3e408503a4459205 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 10:39:52 -0500 Subject: [PATCH 14/49] Begin Phase 4 --- src/dvui.zig | 697 +---------------------- src/fizzy.zig | 6 +- src/gfx/image.zig | 2 +- src/math/color.zig | 15 + src/math/math.zig | 1 + src/plugins/pixelart/internal/Layer.zig | 16 +- src/plugins/pixelart/panel/sprites.zig | 6 +- src/{gfx => plugins/pixelart}/render.zig | 4 +- src/plugins/pixelart/sprite_render.zig | 694 ++++++++++++++++++++++ src/plugins/workbench/Workspace.zig | 2 +- src/plugins/workbench/files.zig | 2 +- 11 files changed, 729 insertions(+), 716 deletions(-) rename src/{gfx => plugins/pixelart}/render.zig (99%) create mode 100644 src/plugins/pixelart/sprite_render.zig diff --git a/src/dvui.zig b/src/dvui.zig index 9966490b..87bd287c 100644 --- a/src/dvui.zig +++ b/src/dvui.zig @@ -101,17 +101,10 @@ pub const DialogOptions = struct { }; pub fn defaultDialogDisplay(id: dvui.Id) anyerror!bool { - const valid: bool = true; - + // Placeholder body; every real dialog supplies its own `displayFn`. Kept free + // of plugin (atlas/sprite) draws so the core dialog code stays plugin-agnostic. _ = id; - - _ = fizzy.dvui.sprite(@src(), .{ - .source = fizzy.editor.atlas.source, - .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.fox_default], - .scale = 2.0, - }, .{ .gravity_y = 0.5, .gravity_x = 0.5, .background = false }); - - return valid; + return true; } pub fn defaultDialogCallAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void { @@ -949,690 +942,6 @@ pub fn saveCompleteToastDisplay(id: dvui.Id) !void { } } -pub const SpriteInitOptions = struct { - source: dvui.ImageSource, - file: ?*fizzy.Internal.File = null, - alpha_source: ?dvui.ImageSource = null, - sprite: fizzy.Atlas.Sprite, - scale: f32 = 1.0, - depth: f32 = 0.0, // -1.0 is front, 1.0 is back - reflection: bool = false, - overlap: f32 = 0.0, - /// Overall opacity in [0, 1]; 1.0 is fully opaque. Used to fade cards out - /// toward the background the further they sit from the focus. - opacity: f32 = 1.0, - /// Vertical shift (logical px, positive = down) applied to the reflection - /// only. Lets the reflection slide away from the card — e.g. as a card flies - /// up out of view, its reflection sinks down, like peeling off a waterline. - reflection_offset: f32 = 0.0, - /// Depth-lagged reflection grid (logical px); rows shear while scrolling and ripple on settle. - reflection_lag: ?ReflectionLagSample = null, - /// Reflection mesh density multiplier in (0, 1]. 1.0 = full per-zoom density; - /// lower values coarsen the (O(n²)) mesh. Callers pass <1 for distant/skewed - /// cards so only the head-on focus cards pay for a fine, high-res reflection. - reflection_detail: f32 = 1.0, -}; - -/// Columns the reflection mesh samples across a card's width (waterline strip). -/// Matches `water_surface.cols_per_slot` (+1) so finer ripples render per card. -pub const reflection_surface_cols = fizzy.water_surface.reflection_surface_cols; - -/// Reflection-only waterline sample across the card width (logical px). `cols_dx` -/// is horizontal refraction from surface slope; `cols_dy` is vertical height at -/// the seam (positive = down). The card itself stays flat — only the reflection -/// mesh pins its top edge and propagates ripples downward. -pub const ReflectionLagSample = struct { - cols_dx: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols, - cols_dy: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols, -}; - -pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opts: dvui.Options) dvui.WidgetData { - const source_size: dvui.Size = dvui.imageSize(init_opts.source) catch .{ .w = 0, .h = 0 }; - - const overlap: f32 = 1.0 - init_opts.overlap; - - const uv = dvui.Rect{ - .x = @as(f32, @floatFromInt(init_opts.sprite.source[0])) / source_size.w, - .y = @as(f32, @floatFromInt(init_opts.sprite.source[1])) / source_size.h, - .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) / source_size.w, - .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) / source_size.h, - }; - - const options = (dvui.Options{ .name = "sprite" }).override(opts); - - var size = dvui.Size{}; - if (options.min_size_content) |msc| { - // user gave us a min size, use it - size = msc; - } else { - // user didn't give us one, use natural size - size = .{ .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) * init_opts.scale * overlap, .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) * init_opts.scale * overlap }; - } - - var wd = dvui.WidgetData.init(src, .{}, options.override(.{ .min_size_content = size })); - wd.register(); - - const cr = wd.contentRect(); - const ms = wd.options.min_size_contentGet(); - - var too_big = false; - if (ms.w > cr.w or ms.h > cr.h) { - too_big = true; - } - - var e = wd.options.expandGet(); - const g = wd.options.gravityGet(); - var rect = dvui.placeIn(cr, ms, e, g); - - if (too_big and e != .ratio) { - if (ms.w > cr.w and !e.isHorizontal()) { - rect.w = ms.w; - rect.x -= g.x * (ms.w - cr.w); - } - - if (ms.h > cr.h and !e.isVertical()) { - rect.h = ms.h; - rect.y -= g.y * (ms.h - cr.h); - } - } - - // rect is the content rect, so expand to the whole rect - wd.rect = rect.outset(wd.options.paddingGet()).outset(wd.options.borderGet()).outset(wd.options.marginGet()); - - var renderBackground: ?dvui.Color = if (wd.options.backgroundGet()) wd.options.color(.fill) else null; - - if (wd.options.rotationGet() == 0.0) { - wd.borderAndBackground(.{}); - renderBackground = null; - } else { - if (wd.options.borderGet().nonZero()) { - dvui.log.debug("image {x} can't render border while rotated\n", .{wd.id}); - } - } - - var path: dvui.Path.Builder = .init(dvui.currentWindow().arena()); - defer path.deinit(); - - var top_left = wd.contentRectScale().r.topLeft(); - var top_right = wd.contentRectScale().r.topRight(); - var bottom_right = wd.contentRectScale().r.bottomRight(); - var bottom_left = wd.contentRectScale().r.bottomLeft(); - - if (init_opts.depth > 0) { - top_left = top_left.plus(bottom_right.diff(top_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical)); - bottom_left = bottom_left.plus(top_right.diff(bottom_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical)); - } else { - top_right = top_right.plus(bottom_right.diff(top_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical)); - bottom_right = bottom_right.plus(top_right.diff(bottom_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical)); - } - - const lag_active = init_opts.reflection_lag != null; - const reflection_lag_phys: ?ReflectionLagSample = if (lag_active) reflectionLagSamplePhysical( - init_opts.reflection_lag.?, - wd.contentRectScale().s, - ) else null; - - path.addPoint(top_left); - path.addPoint(top_right); - path.addPoint(bottom_right); - path.addPoint(bottom_left); - - // Distance fade toward transparent: `fade_white` tints textured draws by the - // card opacity, and `op` scales the alpha of solid fills. No-ops at op == 1. - const op = std.math.clamp(init_opts.opacity, 0.0, 1.0); - const fade_white = dvui.Color.white.opacity(op); - - // Cover-flow fast path: when a file's layer stack is fully flattenable, the - // checker + layers + selection + temp are baked into one texture once per - // frame, so each card (front and reflection) is a single textured pass - // instead of several overlapping alpha-blended fills. Null → multi-pass path. - const preview_tex: ?dvui.Texture = if (init_opts.file) |f| fizzy.render.spritePreviewComposite(f) else null; - - if (init_opts.reflection) { - var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena()); - defer path2.deinit(); - - // Direct vertical mirror: reflect each (already skewed) top corner straight - // down through its bottom corner, so the reflection is a true flip of the - // card — same width and skew at every height, sharing the bottom edge — - // rather than a trapezoid that flares outward. pathToSubdividedQuad reads - // these as (tl, tr, br, bl); the far edge (tl, tr) samples the sprite top - // and the near edge (br, bl) the sprite bottom, giving the mirrored uv. - // `refl_off` slides the whole reflection down independently of the card. - const refl_off = dvui.Point.Physical{ .x = 0.0, .y = init_opts.reflection_offset * wd.contentRectScale().s }; - path2.addPoint(bottom_left.plus(bottom_left.diff(top_left)).plus(refl_off)); - path2.addPoint(bottom_right.plus(bottom_right.diff(top_right)).plus(refl_off)); - path2.addPoint(bottom_right.plus(refl_off)); - path2.addPoint(bottom_left.plus(refl_off)); - - const preview_extent = @min(wd.contentRectScale().r.w, wd.contentRectScale().r.h); - // Subdivide in proportion to on-screen size so the *physical* ripple density - // stays constant across zoom — a big (zoomed-in) card gets many more verts, - // rendering the fine field detail instead of undersampling it into coarse - // waves. (The field already carries dense ripples at `cols_per_slot`.) - const base_subdivisions_f = std.math.clamp(preview_extent / 13.0, 14.0, 44.0); - // The mesh is O(subdivisions²) and is rebuilt + rendered per layer for every - // card. Only the head-on focus cards need the fine, high-res ripple; skewed - // shelf cards pass a low `reflection_detail` so they fall to the coarse floor - // and stay cheap, which is what keeps the shelf affordable on slower GPUs. - const detail = std.math.clamp(init_opts.reflection_detail, 0.0, 1.0); - const subdivisions_f = @max(6.0, base_subdivisions_f * detail); - const subdivisions: usize = @intFromFloat(subdivisions_f); - - if (init_opts.alpha_source) |alpha_source| preview: { - const reflection_path = path2.build(); - - const reflection_lag = reflection_lag_phys orelse ReflectionLagSample{}; - const displacement_max = wd.contentRectScale().r.h * 0.52; - const refl_lag = if (lag_active) reflection_lag else null; - - if (preview_tex) |ptex| { - // Single textured pass: checker + layers + selection + temp are - // pre-flattened into the preview composite, so the reflection is one - // draw instead of replaying the whole stack per card. - var refl = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ - .subdivisions = subdivisions, - .uv = uv, - .vertical_fade = true, - .color_mod = fade_white, - .reflection_lag = refl_lag, - .waterline_propagate = true, - .displacement_max = displacement_max, - }) catch unreachable; - defer refl.deinit(dvui.currentWindow().arena()); - dvui.renderTriangles(refl, ptex) catch { - dvui.log.err("Failed to render reflection preview composite", .{}); - }; - break :preview; - } - - // Build two meshes from the same path so vertex positions match (shared - // ripple) but UVs differ: bg uses the full quad for checkerboard alpha, - // layers use the sprite atlas rect. - var reflection_triangles_bg = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ - .subdivisions = subdivisions, - .color_mod = dvui.themeGet().color(.content, .fill).lighten(4.0).opacity(op), - .vertical_fade = true, - .reflection_lag = refl_lag, - .waterline_propagate = true, - .displacement_max = displacement_max, - }) catch unreachable; - defer reflection_triangles_bg.deinit(dvui.currentWindow().arena()); - - var reflection_triangles_layers = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ - .subdivisions = subdivisions, - .uv = uv, - .vertical_fade = true, - .color_mod = fade_white, - .reflection_lag = refl_lag, - .waterline_propagate = true, - .displacement_max = displacement_max, - }) catch unreachable; - defer reflection_triangles_layers.deinit(dvui.currentWindow().arena()); - - var reflection_triangles_layers_dimmed = reflection_triangles_layers.dupe(dvui.currentWindow().arena()) catch unreachable; - defer reflection_triangles_layers_dimmed.deinit(dvui.currentWindow().arena()); - reflection_triangles_layers_dimmed.color(.gray); - - dvui.renderTriangles(reflection_triangles_bg, alpha_source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - - if (init_opts.file) |file| { - const preview_opts = fizzy.render.RenderFileOptions{ - .file = file, - .rs = .{ - .r = wd.contentRectScale().r, - .s = wd.contentRectScale().s, - }, - .uv = uv, - .corner_radius = .all(0), - }; - fizzy.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| { - dvui.log.err("Failed to render reflection layer stack: {any}", .{err}); - }; - - dvui.renderTriangles(reflection_triangles_layers, file.editor.selection_layer.source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - - // Match renderLayers: use cached GPU texture when the canvas has already uploaded this frame. - // Avoids getTexture() on .pixelsPMA sources (would upload when invalidation is .always). - if (file.editor.temp_layer_has_content or file.editor.temp_gpu_dirty_rect != null) { - const temp_src = file.editor.temporary_layer.source; - const temp_key = temp_src.hash(); - if (dvui.textureGetCached(temp_key)) |tex| { - dvui.renderTriangles(reflection_triangles_layers, tex) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } else { - dvui.renderTriangles(reflection_triangles_layers, temp_src.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - } - } else { - dvui.renderTriangles(reflection_triangles_layers, init_opts.source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - } - } - - // The preview composite already bakes the content-fill base + checkerboard, - // so skip the separate base/checker passes when it's in use. - if (preview_tex == null) { - if (init_opts.alpha_source) |alpha_source| { - if (init_opts.depth != 0.0) { - // Skew the opaque base along with the art so no axis-aligned sliver - // of fill colour pokes out past the receding edge. - var base_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ - .subdivisions = 8, - .color_mod = dvui.themeGet().color(.content, .fill).opacity(op), - }) catch unreachable; - defer base_triangles.deinit(dvui.currentWindow().arena()); - dvui.renderTriangles(base_triangles, null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } else { - wd.contentRectScale().r.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill).opacity(op), .fade = 1.5 }); - } - - const alpha_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ - .subdivisions = 8, - .color_mod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(op), - }) catch unreachable; - dvui.renderTriangles(alpha_triangles, alpha_source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - } - - if (preview_tex) |ptex| { - // Front card: one textured pass from the baked preview composite. Skewed - // cards build a subdivided quad so the art tilts like a record on a shelf; - // head-on cards use the plain quad. - const front_path = if (init_opts.depth != 0.0) blk: { - var q: dvui.Path.Builder = .init(dvui.currentWindow().arena()); - q.addPoint(top_left); - q.addPoint(top_right); - q.addPoint(bottom_right); - q.addPoint(bottom_left); - break :blk q.build(); - } else path.build(); - var tris = pathToSubdividedQuad(front_path, dvui.currentWindow().arena(), .{ - .subdivisions = 8, - .uv = uv, - .color_mod = fade_white, - }) catch unreachable; - defer tris.deinit(dvui.currentWindow().arena()); - dvui.renderTriangles(tris, ptex) catch { - dvui.log.err("Failed to render sprite preview composite", .{}); - }; - } else if (init_opts.file) |file| { - fizzy.render.renderLayers(.{ - .file = file, - .rs = .{ - .r = wd.contentRectScale().r, - .s = wd.contentRectScale().s, - }, - .uv = uv, - .corner_radius = .all(0), - .color_mod = fade_white, - // When skewed, render the layer stack into the same quad as the - // background so the art tilts like a record on a shelf. - .quad = if (init_opts.depth != 0.0) .{ top_left, top_right, bottom_right, bottom_left } else null, - }) catch { - dvui.log.err("Failed to render layers", .{}); - }; - } else { - const triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ - .subdivisions = 8, - .uv = uv, - .color_mod = fade_white, - }) catch unreachable; - - dvui.renderTriangles(triangles, init_opts.source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - - path.build().stroke(.{ .color = opts.color_border orelse .transparent, .thickness = 1.0, .closed = true }); - - wd.minSizeSetAndRefresh(); - wd.minSizeReportToParent(); - - return wd; -} - -pub const PathToSubdividedQuadOptions = struct { - subdivisions: usize = 4, - uv: ?dvui.Rect = null, - vertical_fade: bool = false, - color_mod: dvui.Color = .white, - reflection_lag: ?ReflectionLagSample = null, - /// When true, reflection meshes refract ripples deeper below the seam. - waterline_propagate: bool = true, - /// Cap vertex offset (physical px) so ripples stay inside the reflection. - displacement_max: f32 = 0.0, -}; - -fn reflectionLagSamplePhysical(sample: ReflectionLagSample, scale: f32) ReflectionLagSample { - var out = sample; - for (&out.cols_dx) |*c| c.* *= scale; - for (&out.cols_dy) |*c| c.* *= scale; - return out; -} - -/// Linear interpolation across the column strip by horizontal fraction `t_x`. -/// Per-row reflection factors, hoisted out of the per-vertex loop. The two `pow` -/// calls (depth lag + seam pin) depend only on the row (`t_y`), so computing them -/// once per row instead of per vertex removes thousands of `pow` calls per frame. -const ReflectionRow = struct { - low_submerge: bool, - lag: f32, - lag_mix: f32, // already × 0.55 - submerge_scale: f32, // lerp(1, 1.25, submerge) - dx_pin: f32, -}; - -fn reflectionRowFactors(t_y: f32) ReflectionRow { - const submerge = 1.0 - std.math.clamp(t_y, 0, 1); - const seam_t = std.math.clamp(t_y, 0, 1); - return .{ - .low_submerge = submerge <= 0.001, - .lag = std.math.pow(f32, submerge, 1.55) * 0.74, - .lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1) * 0.55, - .submerge_scale = std.math.lerp(1.0, 1.25, submerge), - .dx_pin = 1.0 - std.math.pow(f32, seam_t, 4.5), - }; -} - -/// Horizontal refraction for one vertex using precomputed row factors. Equivalent -/// to `reflectionMeshDisplacement(.x)`, just with the row-constant work hoisted. -fn reflectionRowDx(t_x: f32, dx_seam: f32, row: ReflectionRow, sample: ReflectionLagSample) f32 { - // `dx_seam` (the column's refraction at the seam) is supplied precomputed — it - // depends only on t_x, so the caller resolves it once per column. Only the - // depth-lagged sample, which shifts t_x by the row's phase lag, needs an interp. - const t_lag = if (row.low_submerge) - t_x - else - std.math.clamp(t_x - (if (dx_seam >= 0) row.lag else -row.lag), 0, 1); - const dx_lag = if (row.low_submerge) dx_seam else interpolateReflectionCols(&sample.cols_dx, t_lag); - return std.math.lerp(dx_seam, dx_lag, row.lag_mix) * row.submerge_scale * row.dx_pin; -} - -fn interpolateReflectionCols(cols: []const f32, t_x: f32) f32 { - if (cols.len == 0) return 0; - if (cols.len == 1) return cols[0]; - const f = std.math.clamp(t_x, 0, 1) * @as(f32, @floatFromInt(cols.len - 1)); - const idx0: usize = @intFromFloat(@floor(f)); - const idx1 = @min(idx0 + 1, cols.len - 1); - const t = f - @as(f32, @floatFromInt(idx0)); - return std.math.lerp(cols[idx0], cols[idx1], t); -} - -fn clampDisplacement(d: dvui.Point.Physical, max_mag: f32) dvui.Point.Physical { - if (max_mag <= 0.0001) return d; - const mag = @sqrt(d.x * d.x + d.y * d.y); - if (mag <= max_mag) return d; - const s = max_mag / mag; - return .{ .x = d.x * s, .y = d.y * s }; -} - -/// Depth into the reflection body (0 at the waterline seam, 1 at the far edge). -fn reflectionSubmergeDepth(t_y: f32) f32 { - return 1.0 - std.math.clamp(t_y, 0, 1); -} - -/// Expanding ripple: larger displacement toward the reflection bottom. Rises -/// quickly just below the seam (so the effect is still strong in the upper region -/// that stays on-screen when zoomed in and the reflection's bottom is clipped), -/// then keeps growing toward the far edge for the full zoomed-out slosh. -fn reflectionDepthAmplitude(submerge: f32) f32 { - const d = std.math.clamp(submerge, 0, 1); - return 1.0 + d * (1.8 + 1.4 * d); -} - -/// Phase lag vs depth — deeper rows follow the same wave, slower and larger. -fn reflectionDepthLag(submerge: f32) f32 { - const d = std.math.clamp(submerge, 0, 1); - return std.math.pow(f32, d, 1.55) * 0.74; -} - -/// Sample the surface field with increasing horizontal phase lag at depth. -fn reflectionLaggedTx(t_x: f32, cols_dx: []const f32, submerge: f32) f32 { - if (submerge <= 0.001) return t_x; - const lag = reflectionDepthLag(submerge); - const slope = interpolateReflectionCols(cols_dx, t_x); - const dir: f32 = if (slope >= 0) 1 else -1; - return std.math.clamp(t_x - dir * lag, 0, 1); -} - -/// Reflection mesh: seam pinned at the waterline; the body carries horizontal -/// refraction ripples that phase-lag with depth. cols_dy is not applied. -fn reflectionMeshDisplacement(t_x: f32, t_y: f32, sample: ReflectionLagSample) dvui.Point.Physical { - const submerge = reflectionSubmergeDepth(t_y); - const t_lag = reflectionLaggedTx(t_x, &sample.cols_dx, submerge); - const lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1); - - const seam_t = std.math.clamp(t_y, 0, 1); - // Peak refraction just under the card base (not mid-body / far edge); seam - // corners stay pinned so the base width still matches the card. - const dx_pin = std.math.pow(f32, seam_t, 1.4) * (1.0 - std.math.pow(f32, seam_t, 12.0)); - const dx_seam = interpolateReflectionCols(&sample.cols_dx, t_x); - const dx_lag = interpolateReflectionCols(&sample.cols_dx, t_lag); - const dx = std.math.lerp(dx_seam, dx_lag, lag_mix * 0.55) * std.math.lerp(1.0, 1.25, submerge) * dx_pin; - - return .{ .x = dx, .y = 0 }; -} - -fn waterlineMeshDisplacement( - t_x: f32, - t_y: f32, - sample: ReflectionLagSample, - propagate: bool, -) dvui.Point.Physical { - if (propagate) return reflectionMeshDisplacement(t_x, t_y, sample); - const s = std.math.clamp(t_y, 0, 1); - const strength = s * (0.1 + 0.9 * s); - return .{ - .x = interpolateReflectionCols(&sample.cols_dx, t_x) * strength, - .y = 0, - }; -} - -fn reflectionCombinedDisplacement(t_x: f32, t_y: f32, options: PathToSubdividedQuadOptions) dvui.Point.Physical { - var d: dvui.Point.Physical = .{ .x = 0, .y = 0 }; - if (options.reflection_lag) |sample| { - d = d.plus(waterlineMeshDisplacement(t_x, t_y, sample, options.waterline_propagate)); - } - return clampDisplacement(d, options.displacement_max); -} - -pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, options: PathToSubdividedQuadOptions) std.mem.Allocator.Error!dvui.Triangles { - if (path.points.len != 4) { - return .empty; - } - - const subdivs = options.subdivisions; - const vtx_count = (subdivs + 1) * (subdivs + 1); - const idx_count = 2 * subdivs * subdivs * 3; - - var builder = try dvui.Triangles.Builder.init(allocator, vtx_count, idx_count); - errdefer comptime unreachable; - - // Four quad corners in order: tl, tr, br, bl - const tl = path.points[0]; - const tr = path.points[1]; - const br = path.points[2]; - const bl = path.points[3]; - - // Use given UV or default to (0,0,1,1) - const base_uv = options.uv orelse dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 }; - - { - // The seam refraction for a reflection mesh depends only on the column - // (t_x), so precompute it once per column and reuse it down every row - // instead of re-interpolating cols_dx per vertex. Guarded by the buffer - // size; non-reflection meshes and any unusually fine mesh fall back to the - // inline interp below (`seam_cache` stays false). - var dx_seam_col: [64]f32 = undefined; - const seam_cache = options.reflection_lag != null and options.waterline_propagate and subdivs + 1 <= dx_seam_col.len; - if (seam_cache) { - const sample = options.reflection_lag.?; - var x: usize = 0; - while (x <= subdivs) : (x += 1) { - const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs)); - dx_seam_col[x] = interpolateReflectionCols(&sample.cols_dx, t_x); - } - } - - var y: usize = 0; - while (y <= subdivs) : (y += 1) { // vertical - const t_y = @as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(subdivs)); - // Interpolate between tl/bl for left and tr/br for right - const left = dvui.Point.Physical{ - .x = tl.x + (bl.x - tl.x) * t_y, - .y = tl.y + (bl.y - tl.y) * t_y, - }; - const right = dvui.Point.Physical{ - .x = tr.x + (br.x - tr.x) * t_y, - .y = tr.y + (br.y - tr.y) * t_y, - }; - // Keep each row monotonic in x so a steep ripple pinches instead of - // folding back over itself. Overlapping triangles double-blend the - // semi-transparent reflection, which reads as a too-bright seam where - // the verts cross (most visible on the fly-in splash). - const row_increasing = right.x >= left.x; - // Hoist the per-row (pow-heavy) refraction factors out of the x-loop. - const refl_row: ?ReflectionRow = if (options.reflection_lag != null and options.waterline_propagate) - reflectionRowFactors(t_y) - else - null; - // Vertex tint only depends on the row (vertical fade), so resolve the - // colour and its PMA conversion once per row, not per vertex. - var row_col: dvui.Color = options.color_mod; - if (options.vertical_fade) row_col = row_col.opacity(0.5 * t_y); - const row_col_pma = dvui.Color.PMA.fromColor(row_col); - var prev_x: f32 = 0; - var x: usize = 0; - while (x <= subdivs) : (x += 1) { // horizontal - const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs)); - var pos = dvui.Point.Physical{ - .x = left.x + (right.x - left.x) * t_x, - .y = left.y + (right.y - left.y) * t_x, - }; - if (options.reflection_lag) |sample| { - if (refl_row) |row| { - const dx_seam = if (seam_cache) dx_seam_col[x] else interpolateReflectionCols(&sample.cols_dx, t_x); - var dx = reflectionRowDx(t_x, dx_seam, row, sample); - // The reflection offset is purely horizontal (dy = 0), so the - // magnitude clamp is just |dx| — no Point/​sqrt needed. - const dmax = options.displacement_max; - if (dmax > 0.0001 and @abs(dx) > dmax) dx = std.math.sign(dx) * dmax; - pos.x += dx; - } else { - pos = pos.plus(reflectionCombinedDisplacement(t_x, t_y, options)); - } - if (x > 0) { - if (row_increasing) { - pos.x = @max(pos.x, prev_x); - } else { - pos.x = @min(pos.x, prev_x); - } - } - prev_x = pos.x; - } - - const uv = .{ - base_uv.x + base_uv.w * t_x, - base_uv.y + base_uv.h * t_y, - }; - - builder.appendVertex(.{ - .pos = pos, - .col = row_col_pma, - .uv = uv, - }); - } - } - } - - // Generate indices for quads in row-major order - for (0..subdivs) |j| { - for (0..subdivs) |i| { - const row_stride = subdivs + 1; - const idx0 = j * row_stride + i; - const idx1 = idx0 + 1; - const idx2 = idx0 + row_stride; - const idx3 = idx2 + 1; - // 0---1 - // | / | - // 2---3 - // first triangle (idx0, idx2, idx1) - builder.appendTriangles(&.{ - @intCast(idx0), - @intCast(idx2), - @intCast(idx1), - }); - // second triangle (idx1, idx2, idx3) - builder.appendTriangles(&.{ - @intCast(idx1), - @intCast(idx2), - @intCast(idx3), - }); - } - } - - return builder.build(); -} - -pub fn renderSprite(source: dvui.ImageSource, s: fizzy.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void { - const atlas_size = dvui.imageSize(source) catch { - std.log.err("Failed to get atlas size", .{}); - return; - }; - - var opt = opts; - - const uv = dvui.Rect{ - .x = (@as(f32, @floatFromInt(s.source[0])) / atlas_size.w), - .y = (@as(f32, @floatFromInt(s.source[1])) / atlas_size.h), - .w = (@as(f32, @floatFromInt(s.source[2])) / atlas_size.w), - .h = (@as(f32, @floatFromInt(s.source[3])) / atlas_size.h), - }; - - opt.uv = uv; - - const origin = dvui.Point{ - .x = @as(f32, @floatFromInt(s.origin[0])) * 1 / scale, - .y = @as(f32, @floatFromInt(s.origin[1])) * 1 / scale, - }; - - const position = data_point.diff(origin); - - const box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = .{ - .x = position.x, - .y = position.y, - .w = @as(f32, @floatFromInt(s.source[2])) * scale, - .h = @as(f32, @floatFromInt(s.source[3])) * scale, - }, - .border = dvui.Rect.all(0), - .corner_radius = .{ .x = 0, .y = 0 }, - .padding = .{ .x = 0, .y = 0 }, - .margin = .{ .x = 0, .y = 0 }, - .background = false, - .color_fill = dvui.themeGet().color(.err, .fill), - }); - defer box.deinit(); - - const rs = box.data().rectScale(); - - try dvui.renderImage(source, rs, opt); -} pub fn labelWithKeybind(label_str: []const u8, hotkey: dvui.enums.Keybind, enabled: bool, label_opts: dvui.Options, opts: dvui.Options) void { const box = dvui.box(@src(), .{ .dir = .horizontal }, opts); diff --git a/src/fizzy.zig b/src/fizzy.zig index 341faff0..21e96e9f 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -17,7 +17,11 @@ pub const algorithms = @import("plugins/pixelart/algorithms/algorithms.zig"); pub const fa = @import("tools/font_awesome.zig"); pub const fs = @import("tools/fs.zig"); pub const image = @import("gfx/image.zig"); -pub const render = @import("gfx/render.zig"); +pub const render = @import("plugins/pixelart/render.zig"); + +/// Atlas-consumer sprite rendering library (lives in the pixel-art plugin, +/// consumed by the shell/workbench to draw sprites from a packed atlas). +pub const sprite_render = @import("plugins/pixelart/sprite_render.zig"); pub const perf = @import("gfx/perf.zig"); pub const water_surface = @import("gfx/water_surface.zig"); pub const math = @import("math/math.zig"); diff --git a/src/gfx/image.zig b/src/gfx/image.zig index f38682d9..366d288c 100644 --- a/src/gfx/image.zig +++ b/src/gfx/image.zig @@ -311,7 +311,7 @@ pub fn blitData(src_pixels: [][4]u8, src_width: usize, src_height: usize, dst_pi const bot_c = dvui.Color{ .r = bot_px[0], .g = bot_px[1], .b = bot_px[2], .a = bot_px[3] }; const tpm = dvui.Color.PMA.fromColor(top_c); const bpm = dvui.Color.PMA.fromColor(bot_c); - const out_pma = fizzy.Internal.Layer.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm)); + const out_pma = fizzy.math.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm)); top_px.* = @as(dvui.Color.PMA, @bitCast(out_pma)).toColor().toRGBA(); } } diff --git a/src/math/color.zig b/src/math/color.zig index 76f2a011..6e6f8888 100644 --- a/src/math/color.zig +++ b/src/math/color.zig @@ -1,6 +1,21 @@ //const zm = @import("zmath"); const imgui = @import("zig-imgui"); +/// Porter-Duff "source over" for premultiplied RGBA (`pixelsPMA` byte layout). +/// `top` is composited over `bottom`. Generic byte math, no pixel-art types. +pub fn blendPmaSrcOver(top: [4]u8, bottom: [4]u8) [4]u8 { + const sa: u32 = @intCast(top[3]); + const inv: u32 = 255 - sa; + var out: [4]u8 = undefined; + inline for (0..3) |c| { + const v: u32 = @as(u32, @intCast(top[c])) + @as(u32, @intCast(bottom[c])) * inv / 255; + out[c] = @intCast(@min(255, v)); + } + const a: u32 = sa + @as(u32, @intCast(bottom[3])) * inv / 255; + out[3] = @intCast(@min(255, a)); + return out; +} + pub const Color = struct { value: [4]f32, diff --git a/src/math/math.zig b/src/math/math.zig index 82589dcc..bc64c5b7 100644 --- a/src/math/math.zig +++ b/src/math/math.zig @@ -39,6 +39,7 @@ pub const Direction = @import("direction.zig").Direction; const color = @import("color.zig"); pub const Color = color.Color; pub const Colors = color.Colors; +pub const blendPmaSrcOver = color.blendPmaSrcOver; pub const Point = struct { x: i32, y: i32 }; diff --git a/src/plugins/pixelart/internal/Layer.zig b/src/plugins/pixelart/internal/Layer.zig index 52a7275d..b8562ff5 100644 --- a/src/plugins/pixelart/internal/Layer.zig +++ b/src/plugins/pixelart/internal/Layer.zig @@ -320,19 +320,9 @@ pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usiz } /// Porter–Duff "source over" for premultiplied RGBA (`pixelsPMA` byte layout). -/// `top` is composited over `bottom`. -pub fn blendPmaSrcOver(top: [4]u8, bottom: [4]u8) [4]u8 { - const sa: u32 = @intCast(top[3]); - const inv: u32 = 255 - sa; - var out: [4]u8 = undefined; - inline for (0..3) |c| { - const v: u32 = @as(u32, @intCast(top[c])) + @as(u32, @intCast(bottom[c])) * inv / 255; - out[c] = @intCast(@min(255, v)); - } - const a: u32 = sa + @as(u32, @intCast(bottom[3])) * inv / 255; - out[3] = @intCast(@min(255, a)); - return out; -} +/// `top` is composited over `bottom`. The implementation is generic byte math and +/// lives in `core` math; re-exported here for the pixel-art call sites. +pub const blendPmaSrcOver = fizzy.math.blendPmaSrcOver; pub fn clearRect(self: *Layer, rect: dvui.Rect) void { fizzy.image.clearRect(self.source, rect); diff --git a/src/plugins/pixelart/panel/sprites.zig b/src/plugins/pixelart/panel/sprites.zig index 3ffcded9..9aaa51e3 100644 --- a/src/plugins/pixelart/panel/sprites.zig +++ b/src/plugins/pixelart/panel/sprites.zig @@ -3,8 +3,8 @@ const icons = @import("icons"); const dvui = @import("dvui"); const fizzy = @import("../../../fizzy.zig"); const Editor = fizzy.Editor; -const ReflectionLagSample = fizzy.dvui.ReflectionLagSample; -const reflection_surface_cols = fizzy.dvui.reflection_surface_cols; +const ReflectionLagSample = fizzy.sprite_render.ReflectionLagSample; +const reflection_surface_cols = fizzy.sprite_render.reflection_surface_cols; const wsurf = fizzy.water_surface; const Sprites = @This(); @@ -749,7 +749,7 @@ pub fn draw(self: *Sprites) !void { const tiltness = if (max_depth > 0.0) std.math.clamp(@abs(cd.depth) / max_depth, 0.0, 1.0) else 0.0; const refl_detail = std.math.lerp(1.0, skewed_reflection_detail, tiltness); - _ = fizzy.dvui.sprite(SpriteSlot.src(), .{ + _ = fizzy.sprite_render.sprite(SpriteSlot.src(), .{ .source = file.layers.items(.source)[file.selected_layer_index], .file = file, .alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null, diff --git a/src/gfx/render.zig b/src/plugins/pixelart/render.zig similarity index 99% rename from src/gfx/render.zig rename to src/plugins/pixelart/render.zig index 3631ee62..14a46745 100644 --- a/src/gfx/render.zig +++ b/src/plugins/pixelart/render.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../fizzy.zig"); +const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const perf = fizzy.perf; @@ -772,7 +772,7 @@ pub fn renderLayers(init_opts: RenderFileOptions) !void { qpath.addPoint(q[1]); qpath.addPoint(q[2]); qpath.addPoint(q[3]); - break :blk try fizzy.dvui.pathToSubdividedQuad(qpath.build(), fizzy.app.allocator, .{ + break :blk try fizzy.sprite_render.pathToSubdividedQuad(qpath.build(), fizzy.app.allocator, .{ .subdivisions = init_opts.quad_subdivisions, .uv = init_opts.uv, .color_mod = init_opts.color_mod, diff --git a/src/plugins/pixelart/sprite_render.zig b/src/plugins/pixelart/sprite_render.zig new file mode 100644 index 00000000..58d12c47 --- /dev/null +++ b/src/plugins/pixelart/sprite_render.zig @@ -0,0 +1,694 @@ +//! Sprite/atlas rendering library for the pixel-art plugin. +//! +//! Consumes packed-atlas output (Atlas/Sprite types) and renders sprites +//! (including the cover-flow water reflection mesh). Lives in the pixel-art +//! plugin but is consumed by the shell/workbench to draw sprites from a +//! packed atlas (cursors, icons, document previews). +const std = @import("std"); +const fizzy = @import("../../fizzy.zig"); +const dvui = @import("dvui"); + +pub const SpriteInitOptions = struct { + source: dvui.ImageSource, + file: ?*fizzy.Internal.File = null, + alpha_source: ?dvui.ImageSource = null, + sprite: fizzy.Atlas.Sprite, + scale: f32 = 1.0, + depth: f32 = 0.0, // -1.0 is front, 1.0 is back + reflection: bool = false, + overlap: f32 = 0.0, + /// Overall opacity in [0, 1]; 1.0 is fully opaque. Used to fade cards out + /// toward the background the further they sit from the focus. + opacity: f32 = 1.0, + /// Vertical shift (logical px, positive = down) applied to the reflection + /// only. Lets the reflection slide away from the card — e.g. as a card flies + /// up out of view, its reflection sinks down, like peeling off a waterline. + reflection_offset: f32 = 0.0, + /// Depth-lagged reflection grid (logical px); rows shear while scrolling and ripple on settle. + reflection_lag: ?ReflectionLagSample = null, + /// Reflection mesh density multiplier in (0, 1]. 1.0 = full per-zoom density; + /// lower values coarsen the (O(n²)) mesh. Callers pass <1 for distant/skewed + /// cards so only the head-on focus cards pay for a fine, high-res reflection. + reflection_detail: f32 = 1.0, +}; + +/// Columns the reflection mesh samples across a card's width (waterline strip). +/// Matches `water_surface.cols_per_slot` (+1) so finer ripples render per card. +pub const reflection_surface_cols = fizzy.water_surface.reflection_surface_cols; + +/// Reflection-only waterline sample across the card width (logical px). `cols_dx` +/// is horizontal refraction from surface slope; `cols_dy` is vertical height at +/// the seam (positive = down). The card itself stays flat — only the reflection +/// mesh pins its top edge and propagates ripples downward. +pub const ReflectionLagSample = struct { + cols_dx: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols, + cols_dy: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols, +}; + +pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opts: dvui.Options) dvui.WidgetData { + const source_size: dvui.Size = dvui.imageSize(init_opts.source) catch .{ .w = 0, .h = 0 }; + + const overlap: f32 = 1.0 - init_opts.overlap; + + const uv = dvui.Rect{ + .x = @as(f32, @floatFromInt(init_opts.sprite.source[0])) / source_size.w, + .y = @as(f32, @floatFromInt(init_opts.sprite.source[1])) / source_size.h, + .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) / source_size.w, + .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) / source_size.h, + }; + + const options = (dvui.Options{ .name = "sprite" }).override(opts); + + var size = dvui.Size{}; + if (options.min_size_content) |msc| { + // user gave us a min size, use it + size = msc; + } else { + // user didn't give us one, use natural size + size = .{ .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) * init_opts.scale * overlap, .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) * init_opts.scale * overlap }; + } + + var wd = dvui.WidgetData.init(src, .{}, options.override(.{ .min_size_content = size })); + wd.register(); + + const cr = wd.contentRect(); + const ms = wd.options.min_size_contentGet(); + + var too_big = false; + if (ms.w > cr.w or ms.h > cr.h) { + too_big = true; + } + + var e = wd.options.expandGet(); + const g = wd.options.gravityGet(); + var rect = dvui.placeIn(cr, ms, e, g); + + if (too_big and e != .ratio) { + if (ms.w > cr.w and !e.isHorizontal()) { + rect.w = ms.w; + rect.x -= g.x * (ms.w - cr.w); + } + + if (ms.h > cr.h and !e.isVertical()) { + rect.h = ms.h; + rect.y -= g.y * (ms.h - cr.h); + } + } + + // rect is the content rect, so expand to the whole rect + wd.rect = rect.outset(wd.options.paddingGet()).outset(wd.options.borderGet()).outset(wd.options.marginGet()); + + var renderBackground: ?dvui.Color = if (wd.options.backgroundGet()) wd.options.color(.fill) else null; + + if (wd.options.rotationGet() == 0.0) { + wd.borderAndBackground(.{}); + renderBackground = null; + } else { + if (wd.options.borderGet().nonZero()) { + dvui.log.debug("image {x} can't render border while rotated\n", .{wd.id}); + } + } + + var path: dvui.Path.Builder = .init(dvui.currentWindow().arena()); + defer path.deinit(); + + var top_left = wd.contentRectScale().r.topLeft(); + var top_right = wd.contentRectScale().r.topRight(); + var bottom_right = wd.contentRectScale().r.bottomRight(); + var bottom_left = wd.contentRectScale().r.bottomLeft(); + + if (init_opts.depth > 0) { + top_left = top_left.plus(bottom_right.diff(top_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical)); + bottom_left = bottom_left.plus(top_right.diff(bottom_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical)); + } else { + top_right = top_right.plus(bottom_right.diff(top_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical)); + bottom_right = bottom_right.plus(top_right.diff(bottom_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical)); + } + + const lag_active = init_opts.reflection_lag != null; + const reflection_lag_phys: ?ReflectionLagSample = if (lag_active) reflectionLagSamplePhysical( + init_opts.reflection_lag.?, + wd.contentRectScale().s, + ) else null; + + path.addPoint(top_left); + path.addPoint(top_right); + path.addPoint(bottom_right); + path.addPoint(bottom_left); + + // Distance fade toward transparent: `fade_white` tints textured draws by the + // card opacity, and `op` scales the alpha of solid fills. No-ops at op == 1. + const op = std.math.clamp(init_opts.opacity, 0.0, 1.0); + const fade_white = dvui.Color.white.opacity(op); + + // Cover-flow fast path: when a file's layer stack is fully flattenable, the + // checker + layers + selection + temp are baked into one texture once per + // frame, so each card (front and reflection) is a single textured pass + // instead of several overlapping alpha-blended fills. Null → multi-pass path. + const preview_tex: ?dvui.Texture = if (init_opts.file) |f| fizzy.render.spritePreviewComposite(f) else null; + + if (init_opts.reflection) { + var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena()); + defer path2.deinit(); + + // Direct vertical mirror: reflect each (already skewed) top corner straight + // down through its bottom corner, so the reflection is a true flip of the + // card — same width and skew at every height, sharing the bottom edge — + // rather than a trapezoid that flares outward. pathToSubdividedQuad reads + // these as (tl, tr, br, bl); the far edge (tl, tr) samples the sprite top + // and the near edge (br, bl) the sprite bottom, giving the mirrored uv. + // `refl_off` slides the whole reflection down independently of the card. + const refl_off = dvui.Point.Physical{ .x = 0.0, .y = init_opts.reflection_offset * wd.contentRectScale().s }; + path2.addPoint(bottom_left.plus(bottom_left.diff(top_left)).plus(refl_off)); + path2.addPoint(bottom_right.plus(bottom_right.diff(top_right)).plus(refl_off)); + path2.addPoint(bottom_right.plus(refl_off)); + path2.addPoint(bottom_left.plus(refl_off)); + + const preview_extent = @min(wd.contentRectScale().r.w, wd.contentRectScale().r.h); + // Subdivide in proportion to on-screen size so the *physical* ripple density + // stays constant across zoom — a big (zoomed-in) card gets many more verts, + // rendering the fine field detail instead of undersampling it into coarse + // waves. (The field already carries dense ripples at `cols_per_slot`.) + const base_subdivisions_f = std.math.clamp(preview_extent / 13.0, 14.0, 44.0); + // The mesh is O(subdivisions²) and is rebuilt + rendered per layer for every + // card. Only the head-on focus cards need the fine, high-res ripple; skewed + // shelf cards pass a low `reflection_detail` so they fall to the coarse floor + // and stay cheap, which is what keeps the shelf affordable on slower GPUs. + const detail = std.math.clamp(init_opts.reflection_detail, 0.0, 1.0); + const subdivisions_f = @max(6.0, base_subdivisions_f * detail); + const subdivisions: usize = @intFromFloat(subdivisions_f); + + if (init_opts.alpha_source) |alpha_source| preview: { + const reflection_path = path2.build(); + + const reflection_lag = reflection_lag_phys orelse ReflectionLagSample{}; + const displacement_max = wd.contentRectScale().r.h * 0.52; + const refl_lag = if (lag_active) reflection_lag else null; + + if (preview_tex) |ptex| { + // Single textured pass: checker + layers + selection + temp are + // pre-flattened into the preview composite, so the reflection is one + // draw instead of replaying the whole stack per card. + var refl = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ + .subdivisions = subdivisions, + .uv = uv, + .vertical_fade = true, + .color_mod = fade_white, + .reflection_lag = refl_lag, + .waterline_propagate = true, + .displacement_max = displacement_max, + }) catch unreachable; + defer refl.deinit(dvui.currentWindow().arena()); + dvui.renderTriangles(refl, ptex) catch { + dvui.log.err("Failed to render reflection preview composite", .{}); + }; + break :preview; + } + + // Build two meshes from the same path so vertex positions match (shared + // ripple) but UVs differ: bg uses the full quad for checkerboard alpha, + // layers use the sprite atlas rect. + var reflection_triangles_bg = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ + .subdivisions = subdivisions, + .color_mod = dvui.themeGet().color(.content, .fill).lighten(4.0).opacity(op), + .vertical_fade = true, + .reflection_lag = refl_lag, + .waterline_propagate = true, + .displacement_max = displacement_max, + }) catch unreachable; + defer reflection_triangles_bg.deinit(dvui.currentWindow().arena()); + + var reflection_triangles_layers = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ + .subdivisions = subdivisions, + .uv = uv, + .vertical_fade = true, + .color_mod = fade_white, + .reflection_lag = refl_lag, + .waterline_propagate = true, + .displacement_max = displacement_max, + }) catch unreachable; + defer reflection_triangles_layers.deinit(dvui.currentWindow().arena()); + + var reflection_triangles_layers_dimmed = reflection_triangles_layers.dupe(dvui.currentWindow().arena()) catch unreachable; + defer reflection_triangles_layers_dimmed.deinit(dvui.currentWindow().arena()); + reflection_triangles_layers_dimmed.color(.gray); + + dvui.renderTriangles(reflection_triangles_bg, alpha_source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + + if (init_opts.file) |file| { + const preview_opts = fizzy.render.RenderFileOptions{ + .file = file, + .rs = .{ + .r = wd.contentRectScale().r, + .s = wd.contentRectScale().s, + }, + .uv = uv, + .corner_radius = .all(0), + }; + fizzy.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| { + dvui.log.err("Failed to render reflection layer stack: {any}", .{err}); + }; + + dvui.renderTriangles(reflection_triangles_layers, file.editor.selection_layer.source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + + // Match renderLayers: use cached GPU texture when the canvas has already uploaded this frame. + // Avoids getTexture() on .pixelsPMA sources (would upload when invalidation is .always). + if (file.editor.temp_layer_has_content or file.editor.temp_gpu_dirty_rect != null) { + const temp_src = file.editor.temporary_layer.source; + const temp_key = temp_src.hash(); + if (dvui.textureGetCached(temp_key)) |tex| { + dvui.renderTriangles(reflection_triangles_layers, tex) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } else { + dvui.renderTriangles(reflection_triangles_layers, temp_src.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } + } + } else { + dvui.renderTriangles(reflection_triangles_layers, init_opts.source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } + } + } + + // The preview composite already bakes the content-fill base + checkerboard, + // so skip the separate base/checker passes when it's in use. + if (preview_tex == null) { + if (init_opts.alpha_source) |alpha_source| { + if (init_opts.depth != 0.0) { + // Skew the opaque base along with the art so no axis-aligned sliver + // of fill colour pokes out past the receding edge. + var base_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ + .subdivisions = 8, + .color_mod = dvui.themeGet().color(.content, .fill).opacity(op), + }) catch unreachable; + defer base_triangles.deinit(dvui.currentWindow().arena()); + dvui.renderTriangles(base_triangles, null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } else { + wd.contentRectScale().r.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill).opacity(op), .fade = 1.5 }); + } + + const alpha_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ + .subdivisions = 8, + .color_mod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(op), + }) catch unreachable; + dvui.renderTriangles(alpha_triangles, alpha_source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } + } + + if (preview_tex) |ptex| { + // Front card: one textured pass from the baked preview composite. Skewed + // cards build a subdivided quad so the art tilts like a record on a shelf; + // head-on cards use the plain quad. + const front_path = if (init_opts.depth != 0.0) blk: { + var q: dvui.Path.Builder = .init(dvui.currentWindow().arena()); + q.addPoint(top_left); + q.addPoint(top_right); + q.addPoint(bottom_right); + q.addPoint(bottom_left); + break :blk q.build(); + } else path.build(); + var tris = pathToSubdividedQuad(front_path, dvui.currentWindow().arena(), .{ + .subdivisions = 8, + .uv = uv, + .color_mod = fade_white, + }) catch unreachable; + defer tris.deinit(dvui.currentWindow().arena()); + dvui.renderTriangles(tris, ptex) catch { + dvui.log.err("Failed to render sprite preview composite", .{}); + }; + } else if (init_opts.file) |file| { + fizzy.render.renderLayers(.{ + .file = file, + .rs = .{ + .r = wd.contentRectScale().r, + .s = wd.contentRectScale().s, + }, + .uv = uv, + .corner_radius = .all(0), + .color_mod = fade_white, + // When skewed, render the layer stack into the same quad as the + // background so the art tilts like a record on a shelf. + .quad = if (init_opts.depth != 0.0) .{ top_left, top_right, bottom_right, bottom_left } else null, + }) catch { + dvui.log.err("Failed to render layers", .{}); + }; + } else { + const triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ + .subdivisions = 8, + .uv = uv, + .color_mod = fade_white, + }) catch unreachable; + + dvui.renderTriangles(triangles, init_opts.source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } + + path.build().stroke(.{ .color = opts.color_border orelse .transparent, .thickness = 1.0, .closed = true }); + + wd.minSizeSetAndRefresh(); + wd.minSizeReportToParent(); + + return wd; +} + +pub const PathToSubdividedQuadOptions = struct { + subdivisions: usize = 4, + uv: ?dvui.Rect = null, + vertical_fade: bool = false, + color_mod: dvui.Color = .white, + reflection_lag: ?ReflectionLagSample = null, + /// When true, reflection meshes refract ripples deeper below the seam. + waterline_propagate: bool = true, + /// Cap vertex offset (physical px) so ripples stay inside the reflection. + displacement_max: f32 = 0.0, +}; + +fn reflectionLagSamplePhysical(sample: ReflectionLagSample, scale: f32) ReflectionLagSample { + var out = sample; + for (&out.cols_dx) |*c| c.* *= scale; + for (&out.cols_dy) |*c| c.* *= scale; + return out; +} + +/// Linear interpolation across the column strip by horizontal fraction `t_x`. +/// Per-row reflection factors, hoisted out of the per-vertex loop. The two `pow` +/// calls (depth lag + seam pin) depend only on the row (`t_y`), so computing them +/// once per row instead of per vertex removes thousands of `pow` calls per frame. +const ReflectionRow = struct { + low_submerge: bool, + lag: f32, + lag_mix: f32, // already × 0.55 + submerge_scale: f32, // lerp(1, 1.25, submerge) + dx_pin: f32, +}; + +fn reflectionRowFactors(t_y: f32) ReflectionRow { + const submerge = 1.0 - std.math.clamp(t_y, 0, 1); + const seam_t = std.math.clamp(t_y, 0, 1); + return .{ + .low_submerge = submerge <= 0.001, + .lag = std.math.pow(f32, submerge, 1.55) * 0.74, + .lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1) * 0.55, + .submerge_scale = std.math.lerp(1.0, 1.25, submerge), + .dx_pin = 1.0 - std.math.pow(f32, seam_t, 4.5), + }; +} + +/// Horizontal refraction for one vertex using precomputed row factors. Equivalent +/// to `reflectionMeshDisplacement(.x)`, just with the row-constant work hoisted. +fn reflectionRowDx(t_x: f32, dx_seam: f32, row: ReflectionRow, sample: ReflectionLagSample) f32 { + // `dx_seam` (the column's refraction at the seam) is supplied precomputed — it + // depends only on t_x, so the caller resolves it once per column. Only the + // depth-lagged sample, which shifts t_x by the row's phase lag, needs an interp. + const t_lag = if (row.low_submerge) + t_x + else + std.math.clamp(t_x - (if (dx_seam >= 0) row.lag else -row.lag), 0, 1); + const dx_lag = if (row.low_submerge) dx_seam else interpolateReflectionCols(&sample.cols_dx, t_lag); + return std.math.lerp(dx_seam, dx_lag, row.lag_mix) * row.submerge_scale * row.dx_pin; +} + +fn interpolateReflectionCols(cols: []const f32, t_x: f32) f32 { + if (cols.len == 0) return 0; + if (cols.len == 1) return cols[0]; + const f = std.math.clamp(t_x, 0, 1) * @as(f32, @floatFromInt(cols.len - 1)); + const idx0: usize = @intFromFloat(@floor(f)); + const idx1 = @min(idx0 + 1, cols.len - 1); + const t = f - @as(f32, @floatFromInt(idx0)); + return std.math.lerp(cols[idx0], cols[idx1], t); +} + +fn clampDisplacement(d: dvui.Point.Physical, max_mag: f32) dvui.Point.Physical { + if (max_mag <= 0.0001) return d; + const mag = @sqrt(d.x * d.x + d.y * d.y); + if (mag <= max_mag) return d; + const s = max_mag / mag; + return .{ .x = d.x * s, .y = d.y * s }; +} + +/// Depth into the reflection body (0 at the waterline seam, 1 at the far edge). +fn reflectionSubmergeDepth(t_y: f32) f32 { + return 1.0 - std.math.clamp(t_y, 0, 1); +} + +/// Expanding ripple: larger displacement toward the reflection bottom. Rises +/// quickly just below the seam (so the effect is still strong in the upper region +/// that stays on-screen when zoomed in and the reflection's bottom is clipped), +/// then keeps growing toward the far edge for the full zoomed-out slosh. +fn reflectionDepthAmplitude(submerge: f32) f32 { + const d = std.math.clamp(submerge, 0, 1); + return 1.0 + d * (1.8 + 1.4 * d); +} + +/// Phase lag vs depth — deeper rows follow the same wave, slower and larger. +fn reflectionDepthLag(submerge: f32) f32 { + const d = std.math.clamp(submerge, 0, 1); + return std.math.pow(f32, d, 1.55) * 0.74; +} + +/// Sample the surface field with increasing horizontal phase lag at depth. +fn reflectionLaggedTx(t_x: f32, cols_dx: []const f32, submerge: f32) f32 { + if (submerge <= 0.001) return t_x; + const lag = reflectionDepthLag(submerge); + const slope = interpolateReflectionCols(cols_dx, t_x); + const dir: f32 = if (slope >= 0) 1 else -1; + return std.math.clamp(t_x - dir * lag, 0, 1); +} + +/// Reflection mesh: seam pinned at the waterline; the body carries horizontal +/// refraction ripples that phase-lag with depth. cols_dy is not applied. +fn reflectionMeshDisplacement(t_x: f32, t_y: f32, sample: ReflectionLagSample) dvui.Point.Physical { + const submerge = reflectionSubmergeDepth(t_y); + const t_lag = reflectionLaggedTx(t_x, &sample.cols_dx, submerge); + const lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1); + + const seam_t = std.math.clamp(t_y, 0, 1); + // Peak refraction just under the card base (not mid-body / far edge); seam + // corners stay pinned so the base width still matches the card. + const dx_pin = std.math.pow(f32, seam_t, 1.4) * (1.0 - std.math.pow(f32, seam_t, 12.0)); + const dx_seam = interpolateReflectionCols(&sample.cols_dx, t_x); + const dx_lag = interpolateReflectionCols(&sample.cols_dx, t_lag); + const dx = std.math.lerp(dx_seam, dx_lag, lag_mix * 0.55) * std.math.lerp(1.0, 1.25, submerge) * dx_pin; + + return .{ .x = dx, .y = 0 }; +} + +fn waterlineMeshDisplacement( + t_x: f32, + t_y: f32, + sample: ReflectionLagSample, + propagate: bool, +) dvui.Point.Physical { + if (propagate) return reflectionMeshDisplacement(t_x, t_y, sample); + const s = std.math.clamp(t_y, 0, 1); + const strength = s * (0.1 + 0.9 * s); + return .{ + .x = interpolateReflectionCols(&sample.cols_dx, t_x) * strength, + .y = 0, + }; +} + +fn reflectionCombinedDisplacement(t_x: f32, t_y: f32, options: PathToSubdividedQuadOptions) dvui.Point.Physical { + var d: dvui.Point.Physical = .{ .x = 0, .y = 0 }; + if (options.reflection_lag) |sample| { + d = d.plus(waterlineMeshDisplacement(t_x, t_y, sample, options.waterline_propagate)); + } + return clampDisplacement(d, options.displacement_max); +} + +pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, options: PathToSubdividedQuadOptions) std.mem.Allocator.Error!dvui.Triangles { + if (path.points.len != 4) { + return .empty; + } + + const subdivs = options.subdivisions; + const vtx_count = (subdivs + 1) * (subdivs + 1); + const idx_count = 2 * subdivs * subdivs * 3; + + var builder = try dvui.Triangles.Builder.init(allocator, vtx_count, idx_count); + errdefer comptime unreachable; + + // Four quad corners in order: tl, tr, br, bl + const tl = path.points[0]; + const tr = path.points[1]; + const br = path.points[2]; + const bl = path.points[3]; + + // Use given UV or default to (0,0,1,1) + const base_uv = options.uv orelse dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 }; + + { + // The seam refraction for a reflection mesh depends only on the column + // (t_x), so precompute it once per column and reuse it down every row + // instead of re-interpolating cols_dx per vertex. Guarded by the buffer + // size; non-reflection meshes and any unusually fine mesh fall back to the + // inline interp below (`seam_cache` stays false). + var dx_seam_col: [64]f32 = undefined; + const seam_cache = options.reflection_lag != null and options.waterline_propagate and subdivs + 1 <= dx_seam_col.len; + if (seam_cache) { + const sample = options.reflection_lag.?; + var x: usize = 0; + while (x <= subdivs) : (x += 1) { + const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs)); + dx_seam_col[x] = interpolateReflectionCols(&sample.cols_dx, t_x); + } + } + + var y: usize = 0; + while (y <= subdivs) : (y += 1) { // vertical + const t_y = @as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(subdivs)); + // Interpolate between tl/bl for left and tr/br for right + const left = dvui.Point.Physical{ + .x = tl.x + (bl.x - tl.x) * t_y, + .y = tl.y + (bl.y - tl.y) * t_y, + }; + const right = dvui.Point.Physical{ + .x = tr.x + (br.x - tr.x) * t_y, + .y = tr.y + (br.y - tr.y) * t_y, + }; + // Keep each row monotonic in x so a steep ripple pinches instead of + // folding back over itself. Overlapping triangles double-blend the + // semi-transparent reflection, which reads as a too-bright seam where + // the verts cross (most visible on the fly-in splash). + const row_increasing = right.x >= left.x; + // Hoist the per-row (pow-heavy) refraction factors out of the x-loop. + const refl_row: ?ReflectionRow = if (options.reflection_lag != null and options.waterline_propagate) + reflectionRowFactors(t_y) + else + null; + // Vertex tint only depends on the row (vertical fade), so resolve the + // colour and its PMA conversion once per row, not per vertex. + var row_col: dvui.Color = options.color_mod; + if (options.vertical_fade) row_col = row_col.opacity(0.5 * t_y); + const row_col_pma = dvui.Color.PMA.fromColor(row_col); + var prev_x: f32 = 0; + var x: usize = 0; + while (x <= subdivs) : (x += 1) { // horizontal + const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs)); + var pos = dvui.Point.Physical{ + .x = left.x + (right.x - left.x) * t_x, + .y = left.y + (right.y - left.y) * t_x, + }; + if (options.reflection_lag) |sample| { + if (refl_row) |row| { + const dx_seam = if (seam_cache) dx_seam_col[x] else interpolateReflectionCols(&sample.cols_dx, t_x); + var dx = reflectionRowDx(t_x, dx_seam, row, sample); + // The reflection offset is purely horizontal (dy = 0), so the + // magnitude clamp is just |dx| — no Point/​sqrt needed. + const dmax = options.displacement_max; + if (dmax > 0.0001 and @abs(dx) > dmax) dx = std.math.sign(dx) * dmax; + pos.x += dx; + } else { + pos = pos.plus(reflectionCombinedDisplacement(t_x, t_y, options)); + } + if (x > 0) { + if (row_increasing) { + pos.x = @max(pos.x, prev_x); + } else { + pos.x = @min(pos.x, prev_x); + } + } + prev_x = pos.x; + } + + const uv = .{ + base_uv.x + base_uv.w * t_x, + base_uv.y + base_uv.h * t_y, + }; + + builder.appendVertex(.{ + .pos = pos, + .col = row_col_pma, + .uv = uv, + }); + } + } + } + + // Generate indices for quads in row-major order + for (0..subdivs) |j| { + for (0..subdivs) |i| { + const row_stride = subdivs + 1; + const idx0 = j * row_stride + i; + const idx1 = idx0 + 1; + const idx2 = idx0 + row_stride; + const idx3 = idx2 + 1; + // 0---1 + // | / | + // 2---3 + // first triangle (idx0, idx2, idx1) + builder.appendTriangles(&.{ + @intCast(idx0), + @intCast(idx2), + @intCast(idx1), + }); + // second triangle (idx1, idx2, idx3) + builder.appendTriangles(&.{ + @intCast(idx1), + @intCast(idx2), + @intCast(idx3), + }); + } + } + + return builder.build(); +} + +pub fn renderSprite(source: dvui.ImageSource, s: fizzy.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void { + const atlas_size = dvui.imageSize(source) catch { + std.log.err("Failed to get atlas size", .{}); + return; + }; + + var opt = opts; + + const uv = dvui.Rect{ + .x = (@as(f32, @floatFromInt(s.source[0])) / atlas_size.w), + .y = (@as(f32, @floatFromInt(s.source[1])) / atlas_size.h), + .w = (@as(f32, @floatFromInt(s.source[2])) / atlas_size.w), + .h = (@as(f32, @floatFromInt(s.source[3])) / atlas_size.h), + }; + + opt.uv = uv; + + const origin = dvui.Point{ + .x = @as(f32, @floatFromInt(s.origin[0])) * 1 / scale, + .y = @as(f32, @floatFromInt(s.origin[1])) * 1 / scale, + }; + + const position = data_point.diff(origin); + + const box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .rect = .{ + .x = position.x, + .y = position.y, + .w = @as(f32, @floatFromInt(s.source[2])) * scale, + .h = @as(f32, @floatFromInt(s.source[3])) * scale, + }, + .border = dvui.Rect.all(0), + .corner_radius = .{ .x = 0, .y = 0 }, + .padding = .{ .x = 0, .y = 0 }, + .margin = .{ .x = 0, .y = 0 }, + .background = false, + .color_fill = dvui.themeGet().color(.err, .fill), + }); + defer box.deinit(); + + const rs = box.data().rectScale(); + + try dvui.renderImage(source, rs, opt); +} diff --git a/src/plugins/workbench/Workspace.zig b/src/plugins/workbench/Workspace.zig index 38222eb5..ada28cdf 100644 --- a/src/plugins/workbench/Workspace.zig +++ b/src/plugins/workbench/Workspace.zig @@ -297,7 +297,7 @@ fn drawTabs(self: *Workspace) void { } if (is_fizzy_file) { - _ = fizzy.dvui.sprite(@src(), .{ + _ = fizzy.sprite_render.sprite(@src(), .{ .source = fizzy.editor.atlas.source, .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default], .scale = 2.0, diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/files.zig index 9b2f03ea..901bbdb3 100644 --- a/src/plugins/workbench/files.zig +++ b/src/plugins/workbench/files.zig @@ -773,7 +773,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color; if (ext == .fizzy) { - _ = fizzy.dvui.sprite( + _ = fizzy.sprite_render.sprite( @src(), .{ .source = fizzy.editor.atlas.source, .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default], .scale = 2.0 }, .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false }, From e41167ab7c330dddcecb838d48cc531ef2d83fea Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 11:18:57 -0500 Subject: [PATCH 15/49] Phase 4 stage A3 --- build.zig | 49 +- src/App.zig | 7 +- src/{editor => core}/Fling.zig | 0 src/core/core.zig | 39 + src/{ => core}/dvui.zig | 43 +- src/{tools => core}/fs.zig | 0 src/{ => core}/generated/atlas.zig | 0 src/{ => core}/gfx/image.zig | 22 +- src/{ => core}/gfx/perf.zig | 0 src/{ => core}/gfx/water_surface.zig | 0 src/{ => core}/math/color.zig | 0 src/{ => core}/math/direction.zig | 0 src/{ => core}/math/easing.zig | 0 src/{ => core}/math/layout_anchor.zig | 0 src/{ => core}/math/math.zig | 0 src/{ => core}/paths.zig | 0 src/{ => core}/platform.zig | 0 src/{editor => core}/widgets/CanvasWidget.zig | 11 +- .../widgets/FloatingWindowWidget.zig | 0 src/{editor => core}/widgets/PanedWidget.zig | 0 .../widgets/ReorderWidget.zig | 0 .../widgets/TreeSelection.zig | 0 src/{editor => core}/widgets/TreeWidget.zig | 0 src/editor/Editor.zig | 7 +- src/editor/widgets/Widgets.zig | 15 - src/fizzy.zig | 25 +- src/gfx/gfx.zig | 2 - src/plugins/pixelart/Atlas.zig | 10 +- src/plugins/pixelart/CanvasData.zig | 3 +- src/plugins/pixelart/PackJob.zig | 2 +- src/plugins/pixelart/dialogs/GridLayout.zig | 12 +- src/plugins/pixelart/plugin.zig | 10 +- src/plugins/pixelart/widgets/CanvasBridge.zig | 2 +- src/plugins/pixelart/widgets/FileWidget.zig | 2 +- src/plugins/pixelart/widgets/ImageWidget.zig | 2 +- src/plugins/workbench/FileLoadJob.zig | 2 +- src/plugins/workbench/files.zig | 6 +- src/tools/font_awesome.zig | 1005 ----------------- src/tools/timer.zig | 23 - src/web_main.zig | 3 +- 40 files changed, 177 insertions(+), 1125 deletions(-) rename src/{editor => core}/Fling.zig (100%) create mode 100644 src/core/core.zig rename src/{ => core}/dvui.zig (97%) rename src/{tools => core}/fs.zig (100%) rename src/{ => core}/generated/atlas.zig (100%) rename src/{ => core}/gfx/image.zig (94%) rename src/{ => core}/gfx/perf.zig (100%) rename src/{ => core}/gfx/water_surface.zig (100%) rename src/{ => core}/math/color.zig (100%) rename src/{ => core}/math/direction.zig (100%) rename src/{ => core}/math/easing.zig (100%) rename src/{ => core}/math/layout_anchor.zig (100%) rename src/{ => core}/math/math.zig (100%) rename src/{ => core}/paths.zig (100%) rename src/{ => core}/platform.zig (100%) rename src/{editor => core}/widgets/CanvasWidget.zig (99%) rename src/{editor => core}/widgets/FloatingWindowWidget.zig (100%) rename src/{editor => core}/widgets/PanedWidget.zig (100%) rename src/{editor => core}/widgets/ReorderWidget.zig (100%) rename src/{editor => core}/widgets/TreeSelection.zig (100%) rename src/{editor => core}/widgets/TreeWidget.zig (100%) delete mode 100644 src/editor/widgets/Widgets.zig delete mode 100644 src/gfx/gfx.zig delete mode 100644 src/tools/font_awesome.zig delete mode 100644 src/tools/timer.zig diff --git a/build.zig b/build.zig index 48a3ecde..b7fa39af 100644 --- a/build.zig +++ b/build.zig @@ -258,7 +258,7 @@ pub fn build(b: *std.Build) !void { // unconditionally by `fizzy.zig`, so the process-assets step has to // run before any target that touches fizzy.zig — exe, integration // tests, etc. - const assets_processing = try ProcessAssetsStep.init(b, "assets", "src/generated/"); + const assets_processing = try ProcessAssetsStep.init(b, "assets", "src/core/generated/"); const process_assets_step = b.step("process-assets", "generates struct for all assets"); process_assets_step.dependOn(&assets_processing.step); @@ -344,6 +344,21 @@ pub fn build(b: *std.Build) !void { }).module("known-folders"); web_exe.root_module.addImport("known-folders", known_folders_web); + // Shared `core` module for the wasm build (dvui web backend variant). + const core_module_web = b.createModule(.{ + .target = web_target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + .link_libc = false, + .single_threaded = true, + }); + core_module_web.addImport("dvui", dvui_web_dep.module("dvui_web")); + core_module_web.addImport("known-folders", known_folders_web); + if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| { + core_module_web.addImport("icons", dep.module("icons")); + } + web_exe.root_module.addImport("core", core_module_web); + // Three editor files have `const sdl3 = @import("backend").c;` at file // scope. After refactoring all `sdl3.SDL_DialogFileFilter` references // to `fizzy.backend.DialogFileFilter`, those decls became dead — Zig's @@ -737,11 +752,11 @@ pub fn build(b: *std.Build) !void { // name. Each of these files imports only `std`, so they remain free // of dvui / SDL / globals. inline for (.{ - .{ "fizzy-direction", "src/math/direction.zig" }, - .{ "fizzy-easing", "src/math/easing.zig" }, + .{ "fizzy-direction", "src/core/math/direction.zig" }, + .{ "fizzy-easing", "src/core/math/easing.zig" }, .{ "fizzy-layer-order", "src/plugins/pixelart/internal/layer_order.zig" }, .{ "fizzy-palette-parse", "src/plugins/pixelart/internal/palette_parse.zig" }, - .{ "fizzy-layout-anchor", "src/math/layout_anchor.zig" }, + .{ "fizzy-layout-anchor", "src/core/math/layout_anchor.zig" }, .{ "fizzy-reduce", "src/plugins/pixelart/algorithms/reduce.zig" }, .{ "fizzy-grid-validate", "src/plugins/pixelart/internal/grid_layout_validate.zig" }, .{ "fizzy-animation", "src/plugins/pixelart/Animation.zig" }, @@ -818,6 +833,20 @@ pub fn build(b: *std.Build) !void { if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { fizzy_test_module.addImport("icons", dep.module("icons")); } + + // Shared `core` module for the test build (dvui testing backend variant). + const core_module_test = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + }); + core_module_test.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + core_module_test.addImport("known-folders", known_folders); + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + core_module_test.addImport("icons", dep.module("icons")); + } + fizzy_test_module.addImport("core", core_module_test); + if (target.result.os.tag == .macos) { if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| { fizzy_test_module.addImport("objc", dep.module("objc")); @@ -1131,6 +1160,17 @@ fn addFizzyExecutableForTarget( exe.root_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); exe.root_module.addImport("backend", dvui_dep.module("sdl3")); + // Shared `core` module (gfx/math/fs/generated atlas/platform/paths/dvui hub + + // generic widgets). Imports only `dvui`, `icons`, and `known-folders`. + const core_module = b.createModule(.{ + .target = resolved_target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + }); + core_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); + core_module.addImport("known-folders", known_folders); + exe.root_module.addImport("core", core_module); + const singleton_app_dep = b.dependency("dvui_singleton_app", .{ .target = resolved_target, .optimize = optimize, @@ -1139,6 +1179,7 @@ fn addFizzyExecutableForTarget( if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| { exe.root_module.addImport("icons", dep.module("icons")); + core_module.addImport("icons", dep.module("icons")); } if (resolved_target.result.os.tag == .macos) { diff --git a/src/App.zig b/src/App.zig index 118adb5f..b7275477 100644 --- a/src/App.zig +++ b/src/App.zig @@ -11,7 +11,7 @@ const fizzy = @import("fizzy.zig"); const auto_update = @import("auto_update.zig"); const update_notify = @import("update_notify.zig"); const singleton = @import("singleton.zig"); -const paths = @import("paths.zig"); +const paths = fizzy.paths; const App = @This(); const Editor = fizzy.Editor; @@ -129,6 +129,11 @@ pub fn AppInit(win: *dvui.Window) !void { const allocator = appAllocator(); + // Inject shared infrastructure context into `core` so it stays decoupled from + // the App hub (allocator for gfx, trackpad input for the canvas widget). + fizzy.core.gpa = allocator; + fizzy.core.takeTrackpadPinchRatio = fizzy.backend.takeTrackpadPinchRatio; + const resolved_argv = singleton.consumeStartupArgv(); defer singleton.freeResolvedArgv(allocator, resolved_argv); diff --git a/src/editor/Fling.zig b/src/core/Fling.zig similarity index 100% rename from src/editor/Fling.zig rename to src/core/Fling.zig diff --git a/src/core/core.zig b/src/core/core.zig new file mode 100644 index 00000000..7eb7b3f3 --- /dev/null +++ b/src/core/core.zig @@ -0,0 +1,39 @@ +//! Core module root: shared infrastructure (gfx, math, fs, generated atlas, +//! platform, paths, the generic dvui hub + generic widgets) that both the shell +//! and the plugins depend on. Core never imports the `fizzy` app hub. +//! +//! Cross-cutting app resources (the allocator, platform input) are injected at +//! startup via the context fields below so core stays decoupled from the App. +const std = @import("std"); + +/// Process allocator, set once at startup by the shell (`App`/`web_main`). +/// Core infrastructure (e.g. `gfx.image`) allocates through this instead of +/// reaching into the App hub. +pub var gpa: std.mem.Allocator = undefined; + +/// Trackpad pinch-zoom accessor, wired at startup by the platform backend +/// (native/web). Defaults to a no-op so headless/test builds work without it. +pub var takeTrackpadPinchRatio: *const fn () f32 = defaultTrackpadPinchRatio; + +fn defaultTrackpadPinchRatio() f32 { + return 1.0; +} + +// Shared infrastructure re-exports. +pub const image = @import("gfx/image.zig"); +pub const perf = @import("gfx/perf.zig"); +pub const water_surface = @import("gfx/water_surface.zig"); +pub const math = @import("math/math.zig"); +pub const fs = @import("fs.zig"); +pub const platform = @import("platform.zig"); +pub const paths = @import("paths.zig"); + +/// Generated atlas index (named sprite lookups). Written by the build's +/// process-assets step into `src/core/generated/`. +pub const atlas = @import("generated/atlas.zig"); + +/// Generic dvui hub: dialog framework, helpers, and the generic widgets. +pub const dvui = @import("dvui.zig"); + +/// Generic momentum/fling helper (pan, scrub, cover-flow). +pub const Fling = @import("Fling.zig"); diff --git a/src/dvui.zig b/src/core/dvui.zig similarity index 97% rename from src/dvui.zig rename to src/core/dvui.zig index 87bd287c..37242c67 100644 --- a/src/dvui.zig +++ b/src/core/dvui.zig @@ -1,19 +1,22 @@ const std = @import("std"); -const fizzy = @import("fizzy.zig"); const dvui = @import("dvui"); const builtin = @import("builtin"); const icons = @import("icons"); -const Widgets = @import("editor/widgets/Widgets.zig"); - -pub const FileWidget = Widgets.FileWidget; -pub const TabsWidget = Widgets.TabsWidget; -pub const ImageWidget = Widgets.ImageWidget; -pub const CanvasWidget = Widgets.CanvasWidget; -pub const ReorderWidget = Widgets.ReorderWidget; -pub const PanedWidget = Widgets.PanedWidget; -pub const FloatingWindowWidget = Widgets.FloatingWindowWidget; -pub const TreeWidget = Widgets.TreeWidget; -pub const TreeSelection = Widgets.TreeSelection; +const platform = @import("platform.zig"); + +pub const CanvasWidget = @import("widgets/CanvasWidget.zig"); +pub const ReorderWidget = @import("widgets/ReorderWidget.zig"); +pub const PanedWidget = @import("widgets/PanedWidget.zig"); +pub const FloatingWindowWidget = @import("widgets/FloatingWindowWidget.zig"); +pub const TreeWidget = @import("widgets/TreeWidget.zig"); +pub const TreeSelection = @import("widgets/TreeSelection.zig"); + +/// Core-owned dialog chrome state, set by the dialog framework and read by the +/// shell so core stays decoupled from the editor. When a modal is open the shell +/// dims the titlebar; the optional close-rect overrides the dialog's close +/// animation origin (e.g. the New File flow animating from the tree row). +pub var modal_dim_titlebar: bool = false; +pub var dialog_close_rect_override: ?dvui.Rect.Physical = null; /// Currently this is specialized for the layers paned widget, just includes icon and dragging flag so we know when the pane is dragging pub fn paned(src: std.builtin.SourceLocation, init_opts: PanedWidget.InitOptions, opts: dvui.Options) *PanedWidget { @@ -186,7 +189,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { }; if (modal) { - fizzy.editor.dim_titlebar = true; + modal_dim_titlebar = true; } const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse { @@ -214,7 +217,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { const maxSize = dvui.dataGet(null, id, "_max_size", dvui.Options.MaxSize); const hide_footer = dvui.dataGet(null, id, "_hide_footer", bool) orelse false; - var win = fizzy.dvui.floatingWindow(@src(), .{ + var win = floatingWindow(@src(), .{ .modal = modal, .center_on = center_on, .window_avoid = .nudge, @@ -238,12 +241,12 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { if (dvui.animationGet(win.data().id, "_close_x")) |a| { if (a.done()) { - fizzy.Editor.Explorer.files.new_file_close_rect = null; + dialog_close_rect_override = null; dvui.dialogRemove(id); } - } else if (fizzy.Editor.Explorer.files.new_file_close_rect) |close_rect| { + } else if (dialog_close_rect_override) |close_rect| { dvui.dataSet(null, win.data().id, "_close_rect", close_rect); - fizzy.Editor.Explorer.files.new_file_close_rect = null; + dialog_close_rect_override = null; } else { win.autoSize(); } @@ -261,7 +264,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { }; var header_openflag = true; - win.dragAreaSet(fizzy.dvui.windowHeader(title, "", &header_openflag, header_kind)); + win.dragAreaSet(windowHeader(title, "", &header_openflag, header_kind)); if (!header_openflag) { if (callafter) |ca| { ca(id, .cancel) catch { @@ -1001,7 +1004,7 @@ pub fn keybindLabels(self: *const dvui.enums.Keybind, enabled: bool, opts: dvui. if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()); //if (needs_plus) dvui.labelNoFmt(@src(), "+", .{}, opts.strip()) else needs_plus = true; //if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()) else needs_space = true; - if (fizzy.platform.isMacOS()) { + if (platform.isMacOS()) { dvui.icon(@src(), "cmd", icons.tvg.lucide.command, .{ .stroke_color = color }, .{ .gravity_y = 0.5 }); } else { dvui.labelNoFmt(@src(), "cmd", .{}, second_opts); @@ -1015,7 +1018,7 @@ pub fn keybindLabels(self: *const dvui.enums.Keybind, enabled: bool, opts: dvui. if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()); //if (needs_plus) dvui.labelNoFmt(@src(), "+", .{}, opts.strip()) else needs_plus = true; //if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()) else needs_space = true; - if (fizzy.platform.isMacOS()) { + if (platform.isMacOS()) { dvui.icon(@src(), "option", icons.tvg.lucide.option, .{ .stroke_color = color }, .{ .gravity_y = 0.5 }); } else { dvui.labelNoFmt(@src(), "alt", .{}, second_opts); diff --git a/src/tools/fs.zig b/src/core/fs.zig similarity index 100% rename from src/tools/fs.zig rename to src/core/fs.zig diff --git a/src/generated/atlas.zig b/src/core/generated/atlas.zig similarity index 100% rename from src/generated/atlas.zig rename to src/core/generated/atlas.zig diff --git a/src/gfx/image.zig b/src/core/gfx/image.zig similarity index 94% rename from src/gfx/image.zig rename to src/core/gfx/image.zig index 366d288c..124a7ee8 100644 --- a/src/gfx/image.zig +++ b/src/core/gfx/image.zig @@ -1,11 +1,13 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const core = @import("../core.zig"); +const fs = @import("../fs.zig"); +const math = @import("../math/math.zig"); const dvui = @import("dvui"); pub fn init(width: u32, height: u32, default_color: dvui.Color.PMA, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource { const num_pixels = width * height; if (num_pixels == 0) return error.InvalidImageSize; - const p = fizzy.app.allocator.alloc(dvui.Color.PMA, num_pixels) catch return error.MemoryAllocationFailed; + const p = core.gpa.alloc(dvui.Color.PMA, num_pixels) catch return error.MemoryAllocationFailed; @memset(p, default_color); @@ -33,7 +35,7 @@ pub fn fromImageFileBytes(name: []const u8, file_bytes: []const u8, invalidation return .{ .pixelsPMA = .{ - .rgba = dvui.Color.PMA.sliceFromRGBA(fizzy.app.allocator.dupe(u8, data[0..@intCast(w * h * @sizeOf(dvui.Color.PMA))]) catch return error.MemoryAllocationFailed), + .rgba = dvui.Color.PMA.sliceFromRGBA(core.gpa.dupe(u8, data[0..@intCast(w * h * @sizeOf(dvui.Color.PMA))]) catch return error.MemoryAllocationFailed), .width = @as(u32, @intCast(w)), .height = @as(u32, @intCast(h)), .interpolation = .nearest, @@ -43,15 +45,15 @@ pub fn fromImageFileBytes(name: []const u8, file_bytes: []const u8, invalidation } pub fn fromImageFilePath(name: []const u8, path: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource { - const file_byes = try fizzy.fs.read(fizzy.app.allocator, dvui.io, path); - defer fizzy.app.allocator.free(file_byes); + const file_byes = try fs.read(core.gpa, dvui.io, path); + defer core.gpa.free(file_byes); return fromImageFileBytes(name, file_byes, invalidation); } pub fn fromPixelsPMA(pixel_data: []dvui.Color.PMA, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource { return .{ .pixelsPMA = .{ - .rgba = fizzy.app.allocator.dupe(dvui.Color.PMA, pixel_data) catch return error.MemoryAllocationFailed, + .rgba = core.gpa.dupe(dvui.Color.PMA, pixel_data) catch return error.MemoryAllocationFailed, .interpolation = .nearest, .invalidation = invalidation, .width = width, @@ -63,7 +65,7 @@ pub fn fromPixelsPMA(pixel_data: []dvui.Color.PMA, width: u32, height: u32, inva pub fn fromPixels(pixel_data: []u8, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource { return .{ .pixels = .{ - .rgba = fizzy.app.allocator.dupe(u8, pixel_data) catch return error.MemoryAllocationFailed, + .rgba = core.gpa.dupe(u8, pixel_data) catch return error.MemoryAllocationFailed, .interpolation = .nearest, .invalidation = invalidation, .width = width, @@ -74,7 +76,7 @@ pub fn fromPixels(pixel_data: []u8, width: u32, height: u32, invalidation: dvui. pub fn fromTexture(name: []const u8, texture: dvui.Texture, invalidation: dvui.ImageSource.InvalidationStrategy) dvui.ImageSource { return .{ - .name = fizzy.app.allocator.dupe(u8, name) catch name, + .name = core.gpa.dupe(u8, name) catch name, .texture = texture, .invalidation = invalidation, .interpolation = .nearest, @@ -91,7 +93,7 @@ pub fn checkerboardTile(width: u32, height: u32, even: [4]u8, odd: [4]u8) ?dvui. const size_f: dvui.Size = .{ .w = @floatFromInt(width), .h = @floatFromInt(height) }; for (buf, 0..) |*p, i| { - const rgba = if (fizzy.math.checker(size_f, i)) even else odd; + const rgba = if (math.checker(size_f, i)) even else odd; p.* = @bitCast(rgba); } @@ -311,7 +313,7 @@ pub fn blitData(src_pixels: [][4]u8, src_width: usize, src_height: usize, dst_pi const bot_c = dvui.Color{ .r = bot_px[0], .g = bot_px[1], .b = bot_px[2], .a = bot_px[3] }; const tpm = dvui.Color.PMA.fromColor(top_c); const bpm = dvui.Color.PMA.fromColor(bot_c); - const out_pma = fizzy.math.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm)); + const out_pma = math.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm)); top_px.* = @as(dvui.Color.PMA, @bitCast(out_pma)).toColor().toRGBA(); } } diff --git a/src/gfx/perf.zig b/src/core/gfx/perf.zig similarity index 100% rename from src/gfx/perf.zig rename to src/core/gfx/perf.zig diff --git a/src/gfx/water_surface.zig b/src/core/gfx/water_surface.zig similarity index 100% rename from src/gfx/water_surface.zig rename to src/core/gfx/water_surface.zig diff --git a/src/math/color.zig b/src/core/math/color.zig similarity index 100% rename from src/math/color.zig rename to src/core/math/color.zig diff --git a/src/math/direction.zig b/src/core/math/direction.zig similarity index 100% rename from src/math/direction.zig rename to src/core/math/direction.zig diff --git a/src/math/easing.zig b/src/core/math/easing.zig similarity index 100% rename from src/math/easing.zig rename to src/core/math/easing.zig diff --git a/src/math/layout_anchor.zig b/src/core/math/layout_anchor.zig similarity index 100% rename from src/math/layout_anchor.zig rename to src/core/math/layout_anchor.zig diff --git a/src/math/math.zig b/src/core/math/math.zig similarity index 100% rename from src/math/math.zig rename to src/core/math/math.zig diff --git a/src/paths.zig b/src/core/paths.zig similarity index 100% rename from src/paths.zig rename to src/core/paths.zig diff --git a/src/platform.zig b/src/core/platform.zig similarity index 100% rename from src/platform.zig rename to src/core/platform.zig diff --git a/src/editor/widgets/CanvasWidget.zig b/src/core/widgets/CanvasWidget.zig similarity index 99% rename from src/editor/widgets/CanvasWidget.zig rename to src/core/widgets/CanvasWidget.zig index 48ae84bd..59a2a0f0 100644 --- a/src/editor/widgets/CanvasWidget.zig +++ b/src/core/widgets/CanvasWidget.zig @@ -1,6 +1,7 @@ const std = @import("std"); const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); +const core = @import("../core.zig"); +const Fling = @import("../Fling.zig"); pub const CanvasWidget = @This(); @@ -134,8 +135,8 @@ scroll_pan_end_pending: bool = false, // Momentum for the drag-pan (middle button, or a left/touch drag starting off the // artboard). One coast per axis so a flick keeps gliding after release; see Fling. -pan_fling_x: fizzy.Fling = .{}, -pan_fling_y: fizzy.Fling = .{}, +pan_fling_x: Fling = .{}, +pan_fling_y: Fling = .{}, // Pinch / two-finger pan input accumulated during this frame's `updateTouchGesture`. // Mutating `scale` / `scroll_info.viewport` mid-frame jitters the canvas because the @@ -186,7 +187,7 @@ const touch_eval_duration_ns: i128 = 80 * std.time.ns_per_ms; /// units `scroll_info.viewport.x/y` move in — so the feel scales naturally with zoom. /// Release velocity is measured over a wall-clock position/time window /// (`releaseWindowed`) -const pan_fling: fizzy.Fling.Tuning = .{ +const pan_fling: Fling.Tuning = .{ .decay = 4.0, .min_start = 40.0, .stop = 10.0, @@ -757,7 +758,7 @@ pub fn updateTouchGesture(self: *CanvasWidget) void { // scale-around-point math used by wheel/touch zoom. Focal point is the cursor position // (macOS does not move the cursor during a trackpad gesture, so it represents intent). // No-op on Windows/Linux/web (`takeTrackpadPinchRatio` returns 1.0 there). - const trackpad_ratio = fizzy.backend.takeTrackpadPinchRatio(); + const trackpad_ratio = core.takeTrackpadPinchRatio(); if (trackpad_ratio != 1.0) { const cursor_phys = dvui.currentWindow().mouse_pt; // Only honor the gesture when the cursor is over the canvas viewport — otherwise a diff --git a/src/editor/widgets/FloatingWindowWidget.zig b/src/core/widgets/FloatingWindowWidget.zig similarity index 100% rename from src/editor/widgets/FloatingWindowWidget.zig rename to src/core/widgets/FloatingWindowWidget.zig diff --git a/src/editor/widgets/PanedWidget.zig b/src/core/widgets/PanedWidget.zig similarity index 100% rename from src/editor/widgets/PanedWidget.zig rename to src/core/widgets/PanedWidget.zig diff --git a/src/editor/widgets/ReorderWidget.zig b/src/core/widgets/ReorderWidget.zig similarity index 100% rename from src/editor/widgets/ReorderWidget.zig rename to src/core/widgets/ReorderWidget.zig diff --git a/src/editor/widgets/TreeSelection.zig b/src/core/widgets/TreeSelection.zig similarity index 100% rename from src/editor/widgets/TreeSelection.zig rename to src/core/widgets/TreeSelection.zig diff --git a/src/editor/widgets/TreeWidget.zig b/src/core/widgets/TreeWidget.zig similarity index 100% rename from src/editor/widgets/TreeWidget.zig rename to src/core/widgets/TreeWidget.zig diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index f310d7e9..94710481 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -69,7 +69,6 @@ explorer: *Explorer, panel: *Panel, last_titlebar_color: dvui.Color, -dim_titlebar: bool = false, /// Workspaces stored by their grouping ID workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, @@ -779,7 +778,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.queueNativeMenuAction(action); } - defer editor.dim_titlebar = false; + defer fizzy.dvui.modal_dim_titlebar = false; editor.setTitlebarColor(); editor.setWindowStyle(); @@ -1423,7 +1422,7 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA } pub fn setTitlebarColor(editor: *Editor) void { - const color = if (editor.dim_titlebar) dvui.themeGet().color(.control, .fill).lerp(.black, if (dvui.themeGet().dark) 60.0 / 255.0 else 80.0 / 255.0) else dvui.themeGet().color(.control, .fill); + const color = if (fizzy.dvui.modal_dim_titlebar) dvui.themeGet().color(.control, .fill).lerp(.black, if (dvui.themeGet().dark) 60.0 / 255.0 else 80.0 / 255.0) else dvui.themeGet().color(.control, .fill); if (!std.mem.eql(u8, &editor.last_titlebar_color.toRGBA(), &color.toRGBA())) { editor.last_titlebar_color = color; @@ -2514,7 +2513,7 @@ pub fn drawLoadingOverlay(editor: *Editor) void { // unrelated input (mouse move, etc.) ticks a frame. Schedule a wakeup at the threshold // boundary so the overlay shows on time even with the cursor parked. if (earliest_pending_start_ns) |start_ns| { - const elapsed_ms = @divTrunc(@import("../gfx/perf.zig").nanoTimestamp() - start_ns, std.time.ns_per_ms); + const elapsed_ms = @divTrunc(fizzy.perf.nanoTimestamp() - start_ns, std.time.ns_per_ms); const remaining_ms: i64 = toast_threshold_ms - @as(i64, @intCast(elapsed_ms)); if (remaining_ms > 0) { dvui.timer(dvui.currentWindow().data().id, @intCast(remaining_ms * std.time.us_per_ms)); diff --git a/src/editor/widgets/Widgets.zig b/src/editor/widgets/Widgets.zig deleted file mode 100644 index b0bc8d97..00000000 --- a/src/editor/widgets/Widgets.zig +++ /dev/null @@ -1,15 +0,0 @@ -const std = @import("std"); - -const fizzy = @import("../../fizzy.zig"); -const dvui = @import("dvui"); - -pub const Widgets = @This(); - -pub const FileWidget = @import("../../plugins/pixelart/widgets/FileWidget.zig"); -pub const ImageWidget = @import("../../plugins/pixelart/widgets/ImageWidget.zig"); -pub const CanvasWidget = @import("CanvasWidget.zig"); -pub const ReorderWidget = @import("ReorderWidget.zig"); -pub const PanedWidget = @import("PanedWidget.zig"); -pub const FloatingWindowWidget = @import("FloatingWindowWidget.zig"); -pub const TreeWidget = @import("TreeWidget.zig"); -pub const TreeSelection = @import("TreeSelection.zig"); diff --git a/src/fizzy.zig b/src/fizzy.zig index 21e96e9f..3feeeeb6 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -2,6 +2,10 @@ const std = @import("std"); const mach = @import("mach"); const Core = mach.Core; +/// Shared infrastructure module (gfx, math, fs, generated atlas, platform, +/// paths, the generic dvui hub + widgets). Consumed by the shell and plugins. +pub const core = @import("core"); + pub const version: std.SemanticVersion = .{ .major = 0, .minor = 2, @@ -10,26 +14,25 @@ pub const version: std.SemanticVersion = .{ // Generated files, these contain helpers for autocomplete // So you can get a named index into atlas.sprites -pub const atlas = @import("generated/atlas.zig"); +pub const atlas = core.atlas; // Other helpers and namespaces pub const algorithms = @import("plugins/pixelart/algorithms/algorithms.zig"); -pub const fa = @import("tools/font_awesome.zig"); -pub const fs = @import("tools/fs.zig"); -pub const image = @import("gfx/image.zig"); +pub const fs = core.fs; +pub const image = core.image; pub const render = @import("plugins/pixelart/render.zig"); /// Atlas-consumer sprite rendering library (lives in the pixel-art plugin, /// consumed by the shell/workbench to draw sprites from a packed atlas). pub const sprite_render = @import("plugins/pixelart/sprite_render.zig"); -pub const perf = @import("gfx/perf.zig"); -pub const water_surface = @import("gfx/water_surface.zig"); -pub const math = @import("math/math.zig"); +pub const perf = core.perf; +pub const water_surface = core.water_surface; +pub const math = core.math; pub const App = @import("App.zig"); pub const Editor = @import("editor/Editor.zig"); pub const Explorer = @import("editor/explorer/Explorer.zig"); -pub const Fling = @import("editor/Fling.zig"); +pub const Fling = core.Fling; pub const Packer = @import("plugins/pixelart/Packer.zig"); //pub const Popups = @import("editor/popups/Popups.zig"); pub const Sidebar = @import("editor/Sidebar.zig"); @@ -71,13 +74,13 @@ pub const Sprite = @import("plugins/pixelart/Sprite.zig"); /// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web /// builds, where `builtin.os.tag` is always `.freestanding`. -pub const platform = @import("platform.zig"); +pub const platform = core.platform; /// Plugin SDK surface pub const sdk = @import("sdk/sdk.zig"); /// Custom dvui stuff -pub const dvui = @import("dvui.zig"); +pub const dvui = core.dvui; /// Custom backend stuff. Split per-arch: native uses SDL3 + objc + win32; web is a /// no-op stub layer (no window chrome, no native dialogs, no native menu bar). @@ -88,7 +91,7 @@ pub const backend = if (@import("builtin").target.cpu.arch == .wasm32) else @import("backend_native.zig"); -pub const paths = @import("paths.zig"); +pub const paths = core.paths; /// Returns a `std.process.Environ` populated from the libc `environ` global. /// Used to bridge APIs (like `known-folders.getPath`) that require an diff --git a/src/gfx/gfx.zig b/src/gfx/gfx.zig deleted file mode 100644 index 0673a5b8..00000000 --- a/src/gfx/gfx.zig +++ /dev/null @@ -1,2 +0,0 @@ -const std = @import("std"); -const fizzy = @import("../fizzy.zig"); diff --git a/src/plugins/pixelart/Atlas.zig b/src/plugins/pixelart/Atlas.zig index 6e7749d6..6d159e43 100644 --- a/src/plugins/pixelart/Atlas.zig +++ b/src/plugins/pixelart/Atlas.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const fs = @import("../../tools/fs.zig"); -const fizzy = @import("../../fizzy.zig"); const Atlas = @This(); @@ -25,7 +23,13 @@ const AtlasV1 = struct { }; pub fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, file: []const u8) !Atlas { - const read = try fs.read(allocator, io, file); + const cwd = std.Io.Dir.cwd(); + const handle = try cwd.openFile(io, file, .{}); + defer handle.close(io); + + var buf: [4096]u8 = undefined; + var rdr = handle.reader(io, &buf); + const read = try rdr.interface.allocRemaining(allocator, .unlimited); defer allocator.free(read); return loadFromBytes(allocator, read); diff --git a/src/plugins/pixelart/CanvasData.zig b/src/plugins/pixelart/CanvasData.zig index d674b525..027cddf4 100644 --- a/src/plugins/pixelart/CanvasData.zig +++ b/src/plugins/pixelart/CanvasData.zig @@ -13,6 +13,7 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("../../fizzy.zig"); const icons = @import("icons"); +const FileWidget = @import("widgets/FileWidget.zig"); const Workspace = fizzy.Editor.Workspace; const File = fizzy.Internal.File; @@ -1206,7 +1207,7 @@ pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void { if (is_drag_sampling and did_sample and file.editor.canvas.samplePointerInViewport(me.p)) { const data_pt = file.editor.canvas.dataFromScreenPoint(me.p); - fizzy.dvui.FileWidget.sampleColorAtPoint(file, data_pt, false, true, true); + FileWidget.sampleColorAtPoint(file, data_pt, false, true, true); } // Clear sample state so the magnifier disappears on the next frame. diff --git a/src/plugins/pixelart/PackJob.zig b/src/plugins/pixelart/PackJob.zig index 7dc1baa6..2d3882a6 100644 --- a/src/plugins/pixelart/PackJob.zig +++ b/src/plugins/pixelart/PackJob.zig @@ -20,7 +20,7 @@ const std = @import("std"); const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const zstbi = @import("zstbi"); -const perf = @import("../../gfx/perf.zig"); +const perf = fizzy.perf; const reduce_alg = @import("algorithms/reduce.zig"); const PackJob = @This(); diff --git a/src/plugins/pixelart/dialogs/GridLayout.zig b/src/plugins/pixelart/dialogs/GridLayout.zig index 66b3301f..8dd50c7d 100644 --- a/src/plugins/pixelart/dialogs/GridLayout.zig +++ b/src/plugins/pixelart/dialogs/GridLayout.zig @@ -11,9 +11,9 @@ const dvui = @import("dvui"); const std = @import("std"); const NewFile = @import("NewFile.zig"); -const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig"); +const CanvasWidget = fizzy.dvui.CanvasWidget; const CanvasBridge = @import("../widgets/CanvasBridge.zig"); -const FloatingWindowWidget = @import("../../../editor/widgets/FloatingWindowWidget.zig"); +const FloatingWindowWidget = fizzy.dvui.FloatingWindowWidget; const builtin = @import("builtin"); /// Editable grid fields for one mode (Slice vs Resize each keep their own backing). @@ -1479,7 +1479,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void { }; if (modal) { - fizzy.editor.dim_titlebar = true; + fizzy.dvui.modal_dim_titlebar = true; } const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse { @@ -1533,12 +1533,12 @@ pub fn windowFn(id: dvui.Id) anyerror!void { if (dvui.animationGet(win.data().id, "_close_x")) |a| { if (a.done()) { - fizzy.Editor.Explorer.files.new_file_close_rect = null; + fizzy.dvui.dialog_close_rect_override = null; dvui.dialogRemove(id); } - } else if (fizzy.Editor.Explorer.files.new_file_close_rect) |close_rect| { + } else if (fizzy.dvui.dialog_close_rect_override) |close_rect| { dvui.dataSet(null, win.data().id, "_close_rect", close_rect); - fizzy.Editor.Explorer.files.new_file_close_rect = null; + fizzy.dvui.dialog_close_rect_override = null; } else { // Call `autoSize` only while opening. Doing it every frame leaves `auto_size` true and the // window keeps animating/snapping to content min size — user resize appears "locked". diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig index 130b386f..9f321f9e 100644 --- a/src/plugins/pixelart/plugin.zig +++ b/src/plugins/pixelart/plugin.zig @@ -8,6 +8,8 @@ const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const sdk = fizzy.sdk; const CanvasData = @import("CanvasData.zig"); +const FileWidget = @import("widgets/FileWidget.zig"); +const ImageWidget = @import("widgets/ImageWidget.zig"); const DocHandle = sdk.DocHandle; const Internal = fizzy.Internal; @@ -130,7 +132,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { if (ws.grouping != file.editor.grouping) return; - var file_widget = fizzy.dvui.FileWidget.init(@src(), .{ + var file_widget = FileWidget.init(@src(), .{ .file = file, .center = file.editor.center, }, .{ @@ -143,7 +145,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { if (dvui.dataGet(null, file.editor.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { if (file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { - fizzy.dvui.FileWidget.drawSampleMagnifier(file, data_pt); + FileWidget.drawSampleMagnifier(file, data_pt); } } } @@ -186,7 +188,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { if (show_packed_atlas) { const atlas = &fizzy.packer.atlas.?; - var image_widget = fizzy.dvui.ImageWidget.init(@src(), .{ + var image_widget = ImageWidget.init(@src(), .{ .source = atlas.source, .canvas = &atlas.canvas, .grouping = ws.grouping, @@ -202,7 +204,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { if (dvui.dataGet(null, atlas.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { if (atlas.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { - fizzy.dvui.ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt); + ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt); } } } else { diff --git a/src/plugins/pixelart/widgets/CanvasBridge.zig b/src/plugins/pixelart/widgets/CanvasBridge.zig index 08f7aaa8..c2f655d8 100644 --- a/src/plugins/pixelart/widgets/CanvasBridge.zig +++ b/src/plugins/pixelart/widgets/CanvasBridge.zig @@ -2,7 +2,7 @@ //! pan/zoom scheme as config and input-suppression as a hook so it stays a reusable //! viewport; these helpers supply the pixel-art editor's wiring at the install sites. const fizzy = @import("../../../fizzy.zig"); -const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig"); +const CanvasWidget = fizzy.dvui.CanvasWidget; /// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum. pub fn scheme() CanvasWidget.PanZoomScheme { diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig index a6ac74c3..8c9285c6 100644 --- a/src/plugins/pixelart/widgets/FileWidget.zig +++ b/src/plugins/pixelart/widgets/FileWidget.zig @@ -16,7 +16,7 @@ const ScrollContainerWidget = dvui.ScrollContainerWidget; const ScaleWidget = dvui.ScaleWidget; pub const FileWidget = @This(); -const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig"); +const CanvasWidget = fizzy.dvui.CanvasWidget; const CanvasBridge = @import("CanvasBridge.zig"); const Workspace = fizzy.Editor.Workspace; const CanvasData = @import("../CanvasData.zig"); diff --git a/src/plugins/pixelart/widgets/ImageWidget.zig b/src/plugins/pixelart/widgets/ImageWidget.zig index 68373922..a07d4dab 100644 --- a/src/plugins/pixelart/widgets/ImageWidget.zig +++ b/src/plugins/pixelart/widgets/ImageWidget.zig @@ -1,5 +1,5 @@ pub const ImageWidget = @This(); -const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig"); +const CanvasWidget = fizzy.dvui.CanvasWidget; const CanvasBridge = @import("CanvasBridge.zig"); init_options: InitOptions, diff --git a/src/plugins/workbench/FileLoadJob.zig b/src/plugins/workbench/FileLoadJob.zig index c8305d7e..c2150345 100644 --- a/src/plugins/workbench/FileLoadJob.zig +++ b/src/plugins/workbench/FileLoadJob.zig @@ -17,7 +17,7 @@ const std = @import("std"); const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); -const perf = @import("../../gfx/perf.zig"); +const perf = fizzy.perf; const FileLoadJob = @This(); diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/files.zig index 901bbdb3..34b61388 100644 --- a/src/plugins/workbench/files.zig +++ b/src/plugins/workbench/files.zig @@ -30,10 +30,8 @@ var pending_file_shift_range: ?struct { clicked_path: []const u8, } = null; -/// Set from New File dialog when creating on disk; tree uses this to expand parents, focus rename, and set `new_file_close_rect`. +/// Set from New File dialog when creating on disk; tree uses this to expand parents, focus rename, and set the dialog close-rect override. pub var new_file_path: ?[]const u8 = null; -/// When set, the dialog animates into this rect (explorer row) then closes. -pub var new_file_close_rect: ?dvui.Rect.Physical = null; const open_message = if (builtin.os.tag == .macos) "Reveal in Finder" else "Reveal in File Browser"; @@ -551,7 +549,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg selected_id = inner_id_extra.*; var close_rect = branch.button.data().borderRectScale().r; close_rect.h = @max(10.0, close_rect.h); - new_file_close_rect = close_rect; + fizzy.dvui.dialog_close_rect_override = close_rect; new_file_path = null; } } diff --git a/src/tools/font_awesome.zig b/src/tools/font_awesome.zig deleted file mode 100644 index 8f07f6e0..00000000 --- a/src/tools/font_awesome.zig +++ /dev/null @@ -1,1005 +0,0 @@ -pub const font_icon_filename_far = "fa-regular-400.ttf"; -pub const font_icon_filename_fas = "fa-solid-900.ttf"; - -pub const icon_range_min = 0xf000; -pub const icon_range_max = 0xf976; -pub const ad = "\u{f641}"; -pub const address_book = "\u{f2b9}"; -pub const address_card = "\u{f2bb}"; -pub const adjust = "\u{f042}"; -pub const air_freshener = "\u{f5d0}"; -pub const align_center = "\u{f037}"; -pub const align_justify = "\u{f039}"; -pub const align_left = "\u{f036}"; -pub const align_right = "\u{f038}"; -pub const allergies = "\u{f461}"; -pub const ambulance = "\u{f0f9}"; -pub const american_sign_language_interpreting = "\u{f2a3}"; -pub const anchor = "\u{f13d}"; -pub const angle_double_down = "\u{f103}"; -pub const angle_double_left = "\u{f100}"; -pub const angle_double_right = "\u{f101}"; -pub const angle_double_up = "\u{f102}"; -pub const angle_down = "\u{f107}"; -pub const angle_left = "\u{f104}"; -pub const angle_right = "\u{f105}"; -pub const angle_up = "\u{f106}"; -pub const angry = "\u{f556}"; -pub const ankh = "\u{f644}"; -pub const apple_alt = "\u{f5d1}"; -pub const archive = "\u{f187}"; -pub const archway = "\u{f557}"; -pub const arrow_alt_circle_down = "\u{f358}"; -pub const arrow_alt_circle_left = "\u{f359}"; -pub const arrow_alt_circle_right = "\u{f35a}"; -pub const arrow_alt_circle_up = "\u{f35b}"; -pub const arrow_circle_down = "\u{f0ab}"; -pub const arrow_circle_left = "\u{f0a8}"; -pub const arrow_circle_right = "\u{f0a9}"; -pub const arrow_circle_up = "\u{f0aa}"; -pub const arrow_down = "\u{f063}"; -pub const arrow_left = "\u{f060}"; -pub const arrow_right = "\u{f061}"; -pub const arrow_up = "\u{f062}"; -pub const arrows_alt = "\u{f0b2}"; -pub const arrows_alt_h = "\u{f337}"; -pub const arrows_alt_v = "\u{f338}"; -pub const assistive_listening_systems = "\u{f2a2}"; -pub const asterisk = "\u{f069}"; -pub const at = "\u{f1fa}"; -pub const atlas = "\u{f558}"; -pub const atom = "\u{f5d2}"; -pub const audio_description = "\u{f29e}"; -pub const award = "\u{f559}"; -pub const baby = "\u{f77c}"; -pub const baby_carriage = "\u{f77d}"; -pub const backspace = "\u{f55a}"; -pub const backward = "\u{f04a}"; -pub const bacon = "\u{f7e5}"; -pub const bacteria = "\u{f959}"; -pub const bacterium = "\u{f95a}"; -pub const bahai = "\u{f666}"; -pub const balance_scale = "\u{f24e}"; -pub const balance_scale_left = "\u{f515}"; -pub const balance_scale_right = "\u{f516}"; -pub const ban = "\u{f05e}"; -pub const band_aid = "\u{f462}"; -pub const barcode = "\u{f02a}"; -pub const bars = "\u{f0c9}"; -pub const baseball_ball = "\u{f433}"; -pub const basketball_ball = "\u{f434}"; -pub const bath = "\u{f2cd}"; -pub const battery_empty = "\u{f244}"; -pub const battery_full = "\u{f240}"; -pub const battery_half = "\u{f242}"; -pub const battery_quarter = "\u{f243}"; -pub const battery_three_quarters = "\u{f241}"; -pub const bed = "\u{f236}"; -pub const beer = "\u{f0fc}"; -pub const bell = "\u{f0f3}"; -pub const bell_slash = "\u{f1f6}"; -pub const bezier_curve = "\u{f55b}"; -pub const bible = "\u{f647}"; -pub const bicycle = "\u{f206}"; -pub const biking = "\u{f84a}"; -pub const binoculars = "\u{f1e5}"; -pub const biohazard = "\u{f780}"; -pub const birthday_cake = "\u{f1fd}"; -pub const blender = "\u{f517}"; -pub const blender_phone = "\u{f6b6}"; -pub const blind = "\u{f29d}"; -pub const blog = "\u{f781}"; -pub const bold = "\u{f032}"; -pub const bolt = "\u{f0e7}"; -pub const bomb = "\u{f1e2}"; -pub const bone = "\u{f5d7}"; -pub const bong = "\u{f55c}"; -pub const book = "\u{f02d}"; -pub const book_dead = "\u{f6b7}"; -pub const book_medical = "\u{f7e6}"; -pub const book_open = "\u{f518}"; -pub const book_reader = "\u{f5da}"; -pub const bookmark = "\u{f02e}"; -pub const border_all = "\u{f84c}"; -pub const border_none = "\u{f850}"; -pub const border_style = "\u{f853}"; -pub const bowling_ball = "\u{f436}"; -pub const box = "\u{f466}"; -pub const box_open = "\u{f49e}"; -pub const box_tissue = "\u{f95b}"; -pub const boxes = "\u{f468}"; -pub const braille = "\u{f2a1}"; -pub const brain = "\u{f5dc}"; -pub const bread_slice = "\u{f7ec}"; -pub const briefcase = "\u{f0b1}"; -pub const briefcase_medical = "\u{f469}"; -pub const broadcast_tower = "\u{f519}"; -pub const broom = "\u{f51a}"; -pub const brush = "\u{f55d}"; -pub const bug = "\u{f188}"; -pub const building = "\u{f1ad}"; -pub const bullhorn = "\u{f0a1}"; -pub const bullseye = "\u{f140}"; -pub const burn = "\u{f46a}"; -pub const bus = "\u{f207}"; -pub const bus_alt = "\u{f55e}"; -pub const business_time = "\u{f64a}"; -pub const calculator = "\u{f1ec}"; -pub const calendar = "\u{f133}"; -pub const calendar_alt = "\u{f073}"; -pub const calendar_check = "\u{f274}"; -pub const calendar_day = "\u{f783}"; -pub const calendar_minus = "\u{f272}"; -pub const calendar_plus = "\u{f271}"; -pub const calendar_times = "\u{f273}"; -pub const calendar_week = "\u{f784}"; -pub const camera = "\u{f030}"; -pub const camera_retro = "\u{f083}"; -pub const campground = "\u{f6bb}"; -pub const candy_cane = "\u{f786}"; -pub const cannabis = "\u{f55f}"; -pub const capsules = "\u{f46b}"; -pub const car = "\u{f1b9}"; -pub const car_alt = "\u{f5de}"; -pub const car_battery = "\u{f5df}"; -pub const car_crash = "\u{f5e1}"; -pub const car_side = "\u{f5e4}"; -pub const caravan = "\u{f8ff}"; -pub const caret_down = "\u{f0d7}"; -pub const caret_left = "\u{f0d9}"; -pub const caret_right = "\u{f0da}"; -pub const caret_square_down = "\u{f150}"; -pub const caret_square_left = "\u{f191}"; -pub const caret_square_right = "\u{f152}"; -pub const caret_square_up = "\u{f151}"; -pub const caret_up = "\u{f0d8}"; -pub const carrot = "\u{f787}"; -pub const cart_arrow_down = "\u{f218}"; -pub const cart_plus = "\u{f217}"; -pub const cash_register = "\u{f788}"; -pub const cat = "\u{f6be}"; -pub const certificate = "\u{f0a3}"; -pub const chair = "\u{f6c0}"; -pub const chalkboard = "\u{f51b}"; -pub const chalkboard_teacher = "\u{f51c}"; -pub const charging_station = "\u{f5e7}"; -pub const chart_area = "\u{f1fe}"; -pub const chart_bar = "\u{f080}"; -pub const chart_line = "\u{f201}"; -pub const chart_pie = "\u{f200}"; -pub const check = "\u{f00c}"; -pub const check_circle = "\u{f058}"; -pub const check_double = "\u{f560}"; -pub const check_square = "\u{f14a}"; -pub const cheese = "\u{f7ef}"; -pub const chess = "\u{f439}"; -pub const chess_bishop = "\u{f43a}"; -pub const chess_board = "\u{f43c}"; -pub const chess_king = "\u{f43f}"; -pub const chess_knight = "\u{f441}"; -pub const chess_pawn = "\u{f443}"; -pub const chess_queen = "\u{f445}"; -pub const chess_rook = "\u{f447}"; -pub const chevron_circle_down = "\u{f13a}"; -pub const chevron_circle_left = "\u{f137}"; -pub const chevron_circle_right = "\u{f138}"; -pub const chevron_circle_up = "\u{f139}"; -pub const chevron_down = "\u{f078}"; -pub const chevron_left = "\u{f053}"; -pub const chevron_right = "\u{f054}"; -pub const chevron_up = "\u{f077}"; -pub const child = "\u{f1ae}"; -pub const church = "\u{f51d}"; -pub const circle = "\u{f111}"; -pub const circle_notch = "\u{f1ce}"; -pub const city = "\u{f64f}"; -pub const clinic_medical = "\u{f7f2}"; -pub const clipboard = "\u{f328}"; -pub const clipboard_check = "\u{f46c}"; -pub const clipboard_list = "\u{f46d}"; -pub const clock = "\u{f017}"; -pub const clone = "\u{f24d}"; -pub const closed_captioning = "\u{f20a}"; -pub const cloud = "\u{f0c2}"; -pub const cloud_download_alt = "\u{f381}"; -pub const cloud_meatball = "\u{f73b}"; -pub const cloud_moon = "\u{f6c3}"; -pub const cloud_moon_rain = "\u{f73c}"; -pub const cloud_rain = "\u{f73d}"; -pub const cloud_showers_heavy = "\u{f740}"; -pub const cloud_sun = "\u{f6c4}"; -pub const cloud_sun_rain = "\u{f743}"; -pub const cloud_upload_alt = "\u{f382}"; -pub const cocktail = "\u{f561}"; -pub const code = "\u{f121}"; -pub const code_branch = "\u{f126}"; -pub const coffee = "\u{f0f4}"; -pub const cog = "\u{f013}"; -pub const cogs = "\u{f085}"; -pub const coins = "\u{f51e}"; -pub const columns = "\u{f0db}"; -pub const comment = "\u{f075}"; -pub const comment_alt = "\u{f27a}"; -pub const comment_dollar = "\u{f651}"; -pub const comment_dots = "\u{f4ad}"; -pub const comment_medical = "\u{f7f5}"; -pub const comment_slash = "\u{f4b3}"; -pub const comments = "\u{f086}"; -pub const comments_dollar = "\u{f653}"; -pub const compact_disc = "\u{f51f}"; -pub const compass = "\u{f14e}"; -pub const compress = "\u{f066}"; -pub const compress_alt = "\u{f422}"; -pub const compress_arrows_alt = "\u{f78c}"; -pub const concierge_bell = "\u{f562}"; -pub const cookie = "\u{f563}"; -pub const cookie_bite = "\u{f564}"; -pub const copy = "\u{f0c5}"; -pub const copyright = "\u{f1f9}"; -pub const couch = "\u{f4b8}"; -pub const credit_card = "\u{f09d}"; -pub const crop = "\u{f125}"; -pub const crop_alt = "\u{f565}"; -pub const cross = "\u{f654}"; -pub const crosshairs = "\u{f05b}"; -pub const crow = "\u{f520}"; -pub const crown = "\u{f521}"; -pub const crutch = "\u{f7f7}"; -pub const cube = "\u{f1b2}"; -pub const cubes = "\u{f1b3}"; -pub const cut = "\u{f0c4}"; -pub const database = "\u{f1c0}"; -pub const deaf = "\u{f2a4}"; -pub const democrat = "\u{f747}"; -pub const desktop = "\u{f108}"; -pub const dharmachakra = "\u{f655}"; -pub const diagnoses = "\u{f470}"; -pub const dice = "\u{f522}"; -pub const dice_d20 = "\u{f6cf}"; -pub const dice_d6 = "\u{f6d1}"; -pub const dice_five = "\u{f523}"; -pub const dice_four = "\u{f524}"; -pub const dice_one = "\u{f525}"; -pub const dice_six = "\u{f526}"; -pub const dice_three = "\u{f527}"; -pub const dice_two = "\u{f528}"; -pub const digital_tachograph = "\u{f566}"; -pub const directions = "\u{f5eb}"; -pub const disease = "\u{f7fa}"; -pub const divide = "\u{f529}"; -pub const dizzy = "\u{f567}"; -pub const dna = "\u{f471}"; -pub const dog = "\u{f6d3}"; -pub const dollar_sign = "\u{f155}"; -pub const dolly = "\u{f472}"; -pub const dolly_flatbed = "\u{f474}"; -pub const donate = "\u{f4b9}"; -pub const door_closed = "\u{f52a}"; -pub const door_open = "\u{f52b}"; -pub const dot_circle = "\u{f192}"; -pub const dove = "\u{f4ba}"; -pub const download = "\u{f019}"; -pub const drafting_compass = "\u{f568}"; -pub const dragon = "\u{f6d5}"; -pub const draw_polygon = "\u{f5ee}"; -pub const drum = "\u{f569}"; -pub const drum_steelpan = "\u{f56a}"; -pub const drumstick_bite = "\u{f6d7}"; -pub const dumbbell = "\u{f44b}"; -pub const dumpster = "\u{f793}"; -pub const dumpster_fire = "\u{f794}"; -pub const dungeon = "\u{f6d9}"; -pub const edit = "\u{f044}"; -pub const egg = "\u{f7fb}"; -pub const eject = "\u{f052}"; -pub const ellipsis_h = "\u{f141}"; -pub const ellipsis_v = "\u{f142}"; -pub const envelope = "\u{f0e0}"; -pub const envelope_open = "\u{f2b6}"; -pub const envelope_open_text = "\u{f658}"; -pub const envelope_square = "\u{f199}"; -pub const equals = "\u{f52c}"; -pub const eraser = "\u{f12d}"; -pub const ethernet = "\u{f796}"; -pub const euro_sign = "\u{f153}"; -pub const exchange_alt = "\u{f362}"; -pub const exclamation = "\u{f12a}"; -pub const exclamation_circle = "\u{f06a}"; -pub const exclamation_triangle = "\u{f071}"; -pub const expand = "\u{f065}"; -pub const expand_alt = "\u{f424}"; -pub const expand_arrows_alt = "\u{f31e}"; -pub const external_link_alt = "\u{f35d}"; -pub const external_link_square_alt = "\u{f360}"; -pub const eye = "\u{f06e}"; -pub const eye_dropper = "\u{f1fb}"; -pub const eye_slash = "\u{f070}"; -pub const fan = "\u{f863}"; -pub const fast_backward = "\u{f049}"; -pub const fast_forward = "\u{f050}"; -pub const faucet = "\u{f905}"; -pub const fax = "\u{f1ac}"; -pub const feather = "\u{f52d}"; -pub const feather_alt = "\u{f56b}"; -pub const female = "\u{f182}"; -pub const fighter_jet = "\u{f0fb}"; -pub const file = "\u{f15b}"; -pub const file_alt = "\u{f15c}"; -pub const file_archive = "\u{f1c6}"; -pub const file_audio = "\u{f1c7}"; -pub const file_code = "\u{f1c9}"; -pub const file_contract = "\u{f56c}"; -pub const file_csv = "\u{f6dd}"; -pub const file_download = "\u{f56d}"; -pub const file_excel = "\u{f1c3}"; -pub const file_export = "\u{f56e}"; -pub const file_image = "\u{f1c5}"; -pub const file_import = "\u{f56f}"; -pub const file_invoice = "\u{f570}"; -pub const file_invoice_dollar = "\u{f571}"; -pub const file_medical = "\u{f477}"; -pub const file_medical_alt = "\u{f478}"; -pub const file_pdf = "\u{f1c1}"; -pub const file_powerpoint = "\u{f1c4}"; -pub const file_prescription = "\u{f572}"; -pub const file_signature = "\u{f573}"; -pub const file_upload = "\u{f574}"; -pub const file_video = "\u{f1c8}"; -pub const file_word = "\u{f1c2}"; -pub const fill = "\u{f575}"; -pub const fill_drip = "\u{f576}"; -pub const film = "\u{f008}"; -pub const filter = "\u{f0b0}"; -pub const fingerprint = "\u{f577}"; -pub const fire = "\u{f06d}"; -pub const fire_alt = "\u{f7e4}"; -pub const fire_extinguisher = "\u{f134}"; -pub const first_aid = "\u{f479}"; -pub const fish = "\u{f578}"; -pub const fist_raised = "\u{f6de}"; -pub const flag = "\u{f024}"; -pub const flag_checkered = "\u{f11e}"; -pub const flag_usa = "\u{f74d}"; -pub const flask = "\u{f0c3}"; -pub const flushed = "\u{f579}"; -pub const folder = "\u{f07b}"; -pub const folder_minus = "\u{f65d}"; -pub const folder_open = "\u{f07c}"; -pub const folder_plus = "\u{f65e}"; -pub const font = "\u{f031}"; -pub const font_awesome_logo_full = "\u{f4e6}"; -pub const football_ball = "\u{f44e}"; -pub const forward = "\u{f04e}"; -pub const frog = "\u{f52e}"; -pub const frown = "\u{f119}"; -pub const frown_open = "\u{f57a}"; -pub const funnel_dollar = "\u{f662}"; -pub const futbol = "\u{f1e3}"; -pub const gamepad = "\u{f11b}"; -pub const gas_pump = "\u{f52f}"; -pub const gavel = "\u{f0e3}"; -pub const gem = "\u{f3a5}"; -pub const genderless = "\u{f22d}"; -pub const ghost = "\u{f6e2}"; -pub const gift = "\u{f06b}"; -pub const gifts = "\u{f79c}"; -pub const glass_cheers = "\u{f79f}"; -pub const glass_martini = "\u{f000}"; -pub const glass_martini_alt = "\u{f57b}"; -pub const glass_whiskey = "\u{f7a0}"; -pub const glasses = "\u{f530}"; -pub const globe = "\u{f0ac}"; -pub const globe_africa = "\u{f57c}"; -pub const globe_americas = "\u{f57d}"; -pub const globe_asia = "\u{f57e}"; -pub const globe_europe = "\u{f7a2}"; -pub const golf_ball = "\u{f450}"; -pub const gopuram = "\u{f664}"; -pub const graduation_cap = "\u{f19d}"; -pub const greater_than = "\u{f531}"; -pub const greater_than_equal = "\u{f532}"; -pub const grimace = "\u{f57f}"; -pub const grin = "\u{f580}"; -pub const grin_alt = "\u{f581}"; -pub const grin_beam = "\u{f582}"; -pub const grin_beam_sweat = "\u{f583}"; -pub const grin_hearts = "\u{f584}"; -pub const grin_squint = "\u{f585}"; -pub const grin_squint_tears = "\u{f586}"; -pub const grin_stars = "\u{f587}"; -pub const grin_tears = "\u{f588}"; -pub const grin_tongue = "\u{f589}"; -pub const grin_tongue_squint = "\u{f58a}"; -pub const grin_tongue_wink = "\u{f58b}"; -pub const grin_wink = "\u{f58c}"; -pub const grip_horizontal = "\u{f58d}"; -pub const grip_lines = "\u{f7a4}"; -pub const grip_lines_vertical = "\u{f7a5}"; -pub const grip_vertical = "\u{f58e}"; -pub const guitar = "\u{f7a6}"; -pub const h_square = "\u{f0fd}"; -pub const hamburger = "\u{f805}"; -pub const hammer = "\u{f6e3}"; -pub const hamsa = "\u{f665}"; -pub const hand_holding = "\u{f4bd}"; -pub const hand_holding_heart = "\u{f4be}"; -pub const hand_holding_medical = "\u{f95c}"; -pub const hand_holding_usd = "\u{f4c0}"; -pub const hand_holding_water = "\u{f4c1}"; -pub const hand_lizard = "\u{f258}"; -pub const hand_middle_finger = "\u{f806}"; -pub const hand_paper = "\u{f256}"; -pub const hand_peace = "\u{f25b}"; -pub const hand_point_down = "\u{f0a7}"; -pub const hand_point_left = "\u{f0a5}"; -pub const hand_point_right = "\u{f0a4}"; -pub const hand_point_up = "\u{f0a6}"; -pub const hand_pointer = "\u{f25a}"; -pub const hand_rock = "\u{f255}"; -pub const hand_scissors = "\u{f257}"; -pub const hand_sparkles = "\u{f95d}"; -pub const hand_spock = "\u{f259}"; -pub const hands = "\u{f4c2}"; -pub const hands_helping = "\u{f4c4}"; -pub const hands_wash = "\u{f95e}"; -pub const handshake = "\u{f2b5}"; -pub const handshake_alt_slash = "\u{f95f}"; -pub const handshake_slash = "\u{f960}"; -pub const hanukiah = "\u{f6e6}"; -pub const hard_hat = "\u{f807}"; -pub const hashtag = "\u{f292}"; -pub const hat_cowboy = "\u{f8c0}"; -pub const hat_cowboy_side = "\u{f8c1}"; -pub const hat_wizard = "\u{f6e8}"; -pub const hdd = "\u{f0a0}"; -pub const head_side_cough = "\u{f961}"; -pub const head_side_cough_slash = "\u{f962}"; -pub const head_side_mask = "\u{f963}"; -pub const head_side_virus = "\u{f964}"; -pub const heading = "\u{f1dc}"; -pub const headphones = "\u{f025}"; -pub const headphones_alt = "\u{f58f}"; -pub const headset = "\u{f590}"; -pub const heart = "\u{f004}"; -pub const heart_broken = "\u{f7a9}"; -pub const heartbeat = "\u{f21e}"; -pub const helicopter = "\u{f533}"; -pub const highlighter = "\u{f591}"; -pub const hiking = "\u{f6ec}"; -pub const hippo = "\u{f6ed}"; -pub const history = "\u{f1da}"; -pub const hockey_puck = "\u{f453}"; -pub const holly_berry = "\u{f7aa}"; -pub const home = "\u{f015}"; -pub const horse = "\u{f6f0}"; -pub const horse_head = "\u{f7ab}"; -pub const hospital = "\u{f0f8}"; -pub const hospital_alt = "\u{f47d}"; -pub const hospital_symbol = "\u{f47e}"; -pub const hospital_user = "\u{f80d}"; -pub const hot_tub = "\u{f593}"; -pub const hotdog = "\u{f80f}"; -pub const hotel = "\u{f594}"; -pub const hourglass = "\u{f254}"; -pub const hourglass_end = "\u{f253}"; -pub const hourglass_half = "\u{f252}"; -pub const hourglass_start = "\u{f251}"; -pub const house_damage = "\u{f6f1}"; -pub const house_user = "\u{f965}"; -pub const hryvnia = "\u{f6f2}"; -pub const i_cursor = "\u{f246}"; -pub const ice_cream = "\u{f810}"; -pub const icicles = "\u{f7ad}"; -pub const icons = "\u{f86d}"; -pub const id_badge = "\u{f2c1}"; -pub const id_card = "\u{f2c2}"; -pub const id_card_alt = "\u{f47f}"; -pub const igloo = "\u{f7ae}"; -pub const image = "\u{f03e}"; -pub const images = "\u{f302}"; -pub const inbox = "\u{f01c}"; -pub const indent = "\u{f03c}"; -pub const industry = "\u{f275}"; -pub const infinity = "\u{f534}"; -pub const info = "\u{f129}"; -pub const info_circle = "\u{f05a}"; -pub const italic = "\u{f033}"; -pub const jedi = "\u{f669}"; -pub const joint = "\u{f595}"; -pub const journal_whills = "\u{f66a}"; -pub const kaaba = "\u{f66b}"; -pub const key = "\u{f084}"; -pub const keyboard = "\u{f11c}"; -pub const khanda = "\u{f66d}"; -pub const kiss = "\u{f596}"; -pub const kiss_beam = "\u{f597}"; -pub const kiss_wink_heart = "\u{f598}"; -pub const kiwi_bird = "\u{f535}"; -pub const landmark = "\u{f66f}"; -pub const language = "\u{f1ab}"; -pub const laptop = "\u{f109}"; -pub const laptop_code = "\u{f5fc}"; -pub const laptop_house = "\u{f966}"; -pub const laptop_medical = "\u{f812}"; -pub const laugh = "\u{f599}"; -pub const laugh_beam = "\u{f59a}"; -pub const laugh_squint = "\u{f59b}"; -pub const laugh_wink = "\u{f59c}"; -pub const layer_group = "\u{f5fd}"; -pub const leaf = "\u{f06c}"; -pub const lemon = "\u{f094}"; -pub const less_than = "\u{f536}"; -pub const less_than_equal = "\u{f537}"; -pub const level_down_alt = "\u{f3be}"; -pub const level_up_alt = "\u{f3bf}"; -pub const life_ring = "\u{f1cd}"; -pub const lightbulb = "\u{f0eb}"; -pub const link = "\u{f0c1}"; -pub const lira_sign = "\u{f195}"; -pub const list = "\u{f03a}"; -pub const list_alt = "\u{f022}"; -pub const list_ol = "\u{f0cb}"; -pub const list_ul = "\u{f0ca}"; -pub const location_arrow = "\u{f124}"; -pub const lock = "\u{f023}"; -pub const lock_open = "\u{f3c1}"; -pub const long_arrow_alt_down = "\u{f309}"; -pub const long_arrow_alt_left = "\u{f30a}"; -pub const long_arrow_alt_right = "\u{f30b}"; -pub const long_arrow_alt_up = "\u{f30c}"; -pub const low_vision = "\u{f2a8}"; -pub const luggage_cart = "\u{f59d}"; -pub const lungs = "\u{f604}"; -pub const lungs_virus = "\u{f967}"; -pub const magic = "\u{f0d0}"; -pub const magnet = "\u{f076}"; -pub const mail_bulk = "\u{f674}"; -pub const male = "\u{f183}"; -pub const map = "\u{f279}"; -pub const map_marked = "\u{f59f}"; -pub const map_marked_alt = "\u{f5a0}"; -pub const map_marker = "\u{f041}"; -pub const map_marker_alt = "\u{f3c5}"; -pub const map_pin = "\u{f276}"; -pub const map_signs = "\u{f277}"; -pub const marker = "\u{f5a1}"; -pub const mars = "\u{f222}"; -pub const mars_double = "\u{f227}"; -pub const mars_stroke = "\u{f229}"; -pub const mars_stroke_h = "\u{f22b}"; -pub const mars_stroke_v = "\u{f22a}"; -pub const mask = "\u{f6fa}"; -pub const medal = "\u{f5a2}"; -pub const medkit = "\u{f0fa}"; -pub const meh = "\u{f11a}"; -pub const meh_blank = "\u{f5a4}"; -pub const meh_rolling_eyes = "\u{f5a5}"; -pub const memory = "\u{f538}"; -pub const menorah = "\u{f676}"; -pub const mercury = "\u{f223}"; -pub const meteor = "\u{f753}"; -pub const microchip = "\u{f2db}"; -pub const microphone = "\u{f130}"; -pub const microphone_alt = "\u{f3c9}"; -pub const microphone_alt_slash = "\u{f539}"; -pub const microphone_slash = "\u{f131}"; -pub const microscope = "\u{f610}"; -pub const minus = "\u{f068}"; -pub const minus_circle = "\u{f056}"; -pub const minus_square = "\u{f146}"; -pub const mitten = "\u{f7b5}"; -pub const mobile = "\u{f10b}"; -pub const mobile_alt = "\u{f3cd}"; -pub const money_bill = "\u{f0d6}"; -pub const money_bill_alt = "\u{f3d1}"; -pub const money_bill_wave = "\u{f53a}"; -pub const money_bill_wave_alt = "\u{f53b}"; -pub const money_check = "\u{f53c}"; -pub const money_check_alt = "\u{f53d}"; -pub const monument = "\u{f5a6}"; -pub const moon = "\u{f186}"; -pub const mortar_pestle = "\u{f5a7}"; -pub const mosque = "\u{f678}"; -pub const motorcycle = "\u{f21c}"; -pub const mountain = "\u{f6fc}"; -pub const mouse = "\u{f8cc}"; -pub const mouse_pointer = "\u{f245}"; -pub const mug_hot = "\u{f7b6}"; -pub const music = "\u{f001}"; -pub const network_wired = "\u{f6ff}"; -pub const neuter = "\u{f22c}"; -pub const newspaper = "\u{f1ea}"; -pub const not_equal = "\u{f53e}"; -pub const notes_medical = "\u{f481}"; -pub const object_group = "\u{f247}"; -pub const object_ungroup = "\u{f248}"; -pub const oil_can = "\u{f613}"; -pub const om = "\u{f679}"; -pub const otter = "\u{f700}"; -pub const outdent = "\u{f03b}"; -pub const pager = "\u{f815}"; -pub const paint_brush = "\u{f1fc}"; -pub const paint_roller = "\u{f5aa}"; -pub const palette = "\u{f53f}"; -pub const pallet = "\u{f482}"; -pub const paper_plane = "\u{f1d8}"; -pub const paperclip = "\u{f0c6}"; -pub const parachute_box = "\u{f4cd}"; -pub const paragraph = "\u{f1dd}"; -pub const parking = "\u{f540}"; -pub const passport = "\u{f5ab}"; -pub const pastafarianism = "\u{f67b}"; -pub const paste = "\u{f0ea}"; -pub const pause = "\u{f04c}"; -pub const pause_circle = "\u{f28b}"; -pub const paw = "\u{f1b0}"; -pub const peace = "\u{f67c}"; -pub const pen = "\u{f304}"; -pub const pen_alt = "\u{f305}"; -pub const pen_fancy = "\u{f5ac}"; -pub const pen_nib = "\u{f5ad}"; -pub const pen_square = "\u{f14b}"; -pub const pencil_alt = "\u{f303}"; -pub const pencil_ruler = "\u{f5ae}"; -pub const people_arrows = "\u{f968}"; -pub const people_carry = "\u{f4ce}"; -pub const pepper_hot = "\u{f816}"; -pub const percent = "\u{f295}"; -pub const percentage = "\u{f541}"; -pub const person_booth = "\u{f756}"; -pub const phone = "\u{f095}"; -pub const phone_alt = "\u{f879}"; -pub const phone_slash = "\u{f3dd}"; -pub const phone_square = "\u{f098}"; -pub const phone_square_alt = "\u{f87b}"; -pub const phone_volume = "\u{f2a0}"; -pub const photo_video = "\u{f87c}"; -pub const piggy_bank = "\u{f4d3}"; -pub const pills = "\u{f484}"; -pub const pizza_slice = "\u{f818}"; -pub const place_of_worship = "\u{f67f}"; -pub const plane = "\u{f072}"; -pub const plane_arrival = "\u{f5af}"; -pub const plane_departure = "\u{f5b0}"; -pub const plane_slash = "\u{f969}"; -pub const play = "\u{f04b}"; -pub const play_circle = "\u{f144}"; -pub const plug = "\u{f1e6}"; -pub const plus = "\u{f067}"; -pub const plus_circle = "\u{f055}"; -pub const plus_square = "\u{f0fe}"; -pub const podcast = "\u{f2ce}"; -pub const poll = "\u{f681}"; -pub const poll_h = "\u{f682}"; -pub const poo = "\u{f2fe}"; -pub const poo_storm = "\u{f75a}"; -pub const poop = "\u{f619}"; -pub const portrait = "\u{f3e0}"; -pub const pound_sign = "\u{f154}"; -pub const power_off = "\u{f011}"; -pub const pray = "\u{f683}"; -pub const praying_hands = "\u{f684}"; -pub const prescription = "\u{f5b1}"; -pub const prescription_bottle = "\u{f485}"; -pub const prescription_bottle_alt = "\u{f486}"; -pub const print = "\u{f02f}"; -pub const procedures = "\u{f487}"; -pub const project_diagram = "\u{f542}"; -pub const pump_medical = "\u{f96a}"; -pub const pump_soap = "\u{f96b}"; -pub const puzzle_piece = "\u{f12e}"; -pub const qrcode = "\u{f029}"; -pub const question = "\u{f128}"; -pub const question_circle = "\u{f059}"; -pub const quidditch = "\u{f458}"; -pub const quote_left = "\u{f10d}"; -pub const quote_right = "\u{f10e}"; -pub const quran = "\u{f687}"; -pub const radiation = "\u{f7b9}"; -pub const radiation_alt = "\u{f7ba}"; -pub const rainbow = "\u{f75b}"; -pub const random = "\u{f074}"; -pub const receipt = "\u{f543}"; -pub const record_vinyl = "\u{f8d9}"; -pub const recycle = "\u{f1b8}"; -pub const redo = "\u{f01e}"; -pub const redo_alt = "\u{f2f9}"; -pub const registered = "\u{f25d}"; -pub const remove_format = "\u{f87d}"; -pub const reply = "\u{f3e5}"; -pub const reply_all = "\u{f122}"; -pub const republican = "\u{f75e}"; -pub const restroom = "\u{f7bd}"; -pub const retweet = "\u{f079}"; -pub const ribbon = "\u{f4d6}"; -pub const ring = "\u{f70b}"; -pub const road = "\u{f018}"; -pub const robot = "\u{f544}"; -pub const rocket = "\u{f135}"; -pub const route = "\u{f4d7}"; -pub const rss = "\u{f09e}"; -pub const rss_square = "\u{f143}"; -pub const ruble_sign = "\u{f158}"; -pub const ruler = "\u{f545}"; -pub const ruler_combined = "\u{f546}"; -pub const ruler_horizontal = "\u{f547}"; -pub const ruler_vertical = "\u{f548}"; -pub const running = "\u{f70c}"; -pub const rupee_sign = "\u{f156}"; -pub const sad_cry = "\u{f5b3}"; -pub const sad_tear = "\u{f5b4}"; -pub const satellite = "\u{f7bf}"; -pub const satellite_dish = "\u{f7c0}"; -pub const save = "\u{f0c7}"; -pub const school = "\u{f549}"; -pub const screwdriver = "\u{f54a}"; -pub const scroll = "\u{f70e}"; -pub const sd_card = "\u{f7c2}"; -pub const search = "\u{f002}"; -pub const search_dollar = "\u{f688}"; -pub const search_location = "\u{f689}"; -pub const search_minus = "\u{f010}"; -pub const search_plus = "\u{f00e}"; -pub const seedling = "\u{f4d8}"; -pub const server = "\u{f233}"; -pub const shapes = "\u{f61f}"; -pub const share = "\u{f064}"; -pub const share_alt = "\u{f1e0}"; -pub const share_alt_square = "\u{f1e1}"; -pub const share_square = "\u{f14d}"; -pub const shekel_sign = "\u{f20b}"; -pub const shield_alt = "\u{f3ed}"; -pub const shield_virus = "\u{f96c}"; -pub const ship = "\u{f21a}"; -pub const shipping_fast = "\u{f48b}"; -pub const shoe_prints = "\u{f54b}"; -pub const shopping_bag = "\u{f290}"; -pub const shopping_basket = "\u{f291}"; -pub const shopping_cart = "\u{f07a}"; -pub const shower = "\u{f2cc}"; -pub const shuttle_van = "\u{f5b6}"; -pub const sign = "\u{f4d9}"; -pub const sign_in_alt = "\u{f2f6}"; -pub const sign_language = "\u{f2a7}"; -pub const sign_out_alt = "\u{f2f5}"; -pub const signal = "\u{f012}"; -pub const signature = "\u{f5b7}"; -pub const sim_card = "\u{f7c4}"; -pub const sink = "\u{f96d}"; -pub const sitemap = "\u{f0e8}"; -pub const skating = "\u{f7c5}"; -pub const skiing = "\u{f7c9}"; -pub const skiing_nordic = "\u{f7ca}"; -pub const skull = "\u{f54c}"; -pub const skull_crossbones = "\u{f714}"; -pub const slash = "\u{f715}"; -pub const sleigh = "\u{f7cc}"; -pub const sliders_h = "\u{f1de}"; -pub const smile = "\u{f118}"; -pub const smile_beam = "\u{f5b8}"; -pub const smile_wink = "\u{f4da}"; -pub const smog = "\u{f75f}"; -pub const smoking = "\u{f48d}"; -pub const smoking_ban = "\u{f54d}"; -pub const sms = "\u{f7cd}"; -pub const snowboarding = "\u{f7ce}"; -pub const snowflake = "\u{f2dc}"; -pub const snowman = "\u{f7d0}"; -pub const snowplow = "\u{f7d2}"; -pub const soap = "\u{f96e}"; -pub const socks = "\u{f696}"; -pub const solar_panel = "\u{f5ba}"; -pub const sort = "\u{f0dc}"; -pub const sort_alpha_down = "\u{f15d}"; -pub const sort_alpha_down_alt = "\u{f881}"; -pub const sort_alpha_up = "\u{f15e}"; -pub const sort_alpha_up_alt = "\u{f882}"; -pub const sort_amount_down = "\u{f160}"; -pub const sort_amount_down_alt = "\u{f884}"; -pub const sort_amount_up = "\u{f161}"; -pub const sort_amount_up_alt = "\u{f885}"; -pub const sort_down = "\u{f0dd}"; -pub const sort_numeric_down = "\u{f162}"; -pub const sort_numeric_down_alt = "\u{f886}"; -pub const sort_numeric_up = "\u{f163}"; -pub const sort_numeric_up_alt = "\u{f887}"; -pub const sort_up = "\u{f0de}"; -pub const spa = "\u{f5bb}"; -pub const space_shuttle = "\u{f197}"; -pub const spell_check = "\u{f891}"; -pub const spider = "\u{f717}"; -pub const spinner = "\u{f110}"; -pub const splotch = "\u{f5bc}"; -pub const spray_can = "\u{f5bd}"; -pub const square = "\u{f0c8}"; -pub const square_full = "\u{f45c}"; -pub const square_root_alt = "\u{f698}"; -pub const stamp = "\u{f5bf}"; -pub const star = "\u{f005}"; -pub const star_and_crescent = "\u{f699}"; -pub const star_half = "\u{f089}"; -pub const star_half_alt = "\u{f5c0}"; -pub const star_of_david = "\u{f69a}"; -pub const star_of_life = "\u{f621}"; -pub const step_backward = "\u{f048}"; -pub const step_forward = "\u{f051}"; -pub const stethoscope = "\u{f0f1}"; -pub const sticky_note = "\u{f249}"; -pub const stop = "\u{f04d}"; -pub const stop_circle = "\u{f28d}"; -pub const stopwatch = "\u{f2f2}"; -pub const stopwatch_20 = "\u{f96f}"; -pub const store = "\u{f54e}"; -pub const store_alt = "\u{f54f}"; -pub const store_alt_slash = "\u{f970}"; -pub const store_slash = "\u{f971}"; -pub const stream = "\u{f550}"; -pub const street_view = "\u{f21d}"; -pub const strikethrough = "\u{f0cc}"; -pub const stroopwafel = "\u{f551}"; -pub const subscript = "\u{f12c}"; -pub const subway = "\u{f239}"; -pub const suitcase = "\u{f0f2}"; -pub const suitcase_rolling = "\u{f5c1}"; -pub const sun = "\u{f185}"; -pub const superscript = "\u{f12b}"; -pub const surprise = "\u{f5c2}"; -pub const swatchbook = "\u{f5c3}"; -pub const swimmer = "\u{f5c4}"; -pub const swimming_pool = "\u{f5c5}"; -pub const synagogue = "\u{f69b}"; -pub const sync = "\u{f021}"; -pub const sync_alt = "\u{f2f1}"; -pub const syringe = "\u{f48e}"; -pub const table = "\u{f0ce}"; -pub const table_tennis = "\u{f45d}"; -pub const tablet = "\u{f10a}"; -pub const tablet_alt = "\u{f3fa}"; -pub const tablets = "\u{f490}"; -pub const tachometer_alt = "\u{f3fd}"; -pub const tag = "\u{f02b}"; -pub const tags = "\u{f02c}"; -pub const tape = "\u{f4db}"; -pub const tasks = "\u{f0ae}"; -pub const taxi = "\u{f1ba}"; -pub const teeth = "\u{f62e}"; -pub const teeth_open = "\u{f62f}"; -pub const temperature_high = "\u{f769}"; -pub const temperature_low = "\u{f76b}"; -pub const tenge = "\u{f7d7}"; -pub const terminal = "\u{f120}"; -pub const text_height = "\u{f034}"; -pub const text_width = "\u{f035}"; -pub const th = "\u{f00a}"; -pub const th_large = "\u{f009}"; -pub const th_list = "\u{f00b}"; -pub const theater_masks = "\u{f630}"; -pub const thermometer = "\u{f491}"; -pub const thermometer_empty = "\u{f2cb}"; -pub const thermometer_full = "\u{f2c7}"; -pub const thermometer_half = "\u{f2c9}"; -pub const thermometer_quarter = "\u{f2ca}"; -pub const thermometer_three_quarters = "\u{f2c8}"; -pub const thumbs_down = "\u{f165}"; -pub const thumbs_up = "\u{f164}"; -pub const thumbtack = "\u{f08d}"; -pub const ticket_alt = "\u{f3ff}"; -pub const times = "\u{f00d}"; -pub const times_circle = "\u{f057}"; -pub const tint = "\u{f043}"; -pub const tint_slash = "\u{f5c7}"; -pub const tired = "\u{f5c8}"; -pub const toggle_off = "\u{f204}"; -pub const toggle_on = "\u{f205}"; -pub const toilet = "\u{f7d8}"; -pub const toilet_paper = "\u{f71e}"; -pub const toilet_paper_slash = "\u{f972}"; -pub const toolbox = "\u{f552}"; -pub const tools = "\u{f7d9}"; -pub const tooth = "\u{f5c9}"; -pub const torah = "\u{f6a0}"; -pub const torii_gate = "\u{f6a1}"; -pub const tractor = "\u{f722}"; -pub const trademark = "\u{f25c}"; -pub const traffic_light = "\u{f637}"; -pub const trailer = "\u{f941}"; -pub const train = "\u{f238}"; -pub const tram = "\u{f7da}"; -pub const transgender = "\u{f224}"; -pub const transgender_alt = "\u{f225}"; -pub const trash = "\u{f1f8}"; -pub const trash_alt = "\u{f2ed}"; -pub const trash_restore = "\u{f829}"; -pub const trash_restore_alt = "\u{f82a}"; -pub const tree = "\u{f1bb}"; -pub const trophy = "\u{f091}"; -pub const truck = "\u{f0d1}"; -pub const truck_loading = "\u{f4de}"; -pub const truck_monster = "\u{f63b}"; -pub const truck_moving = "\u{f4df}"; -pub const truck_pickup = "\u{f63c}"; -pub const tshirt = "\u{f553}"; -pub const tty = "\u{f1e4}"; -pub const tv = "\u{f26c}"; -pub const umbrella = "\u{f0e9}"; -pub const umbrella_beach = "\u{f5ca}"; -pub const underline = "\u{f0cd}"; -pub const undo = "\u{f0e2}"; -pub const undo_alt = "\u{f2ea}"; -pub const universal_access = "\u{f29a}"; -pub const university = "\u{f19c}"; -pub const unlink = "\u{f127}"; -pub const unlock = "\u{f09c}"; -pub const unlock_alt = "\u{f13e}"; -pub const upload = "\u{f093}"; -pub const user = "\u{f007}"; -pub const user_alt = "\u{f406}"; -pub const user_alt_slash = "\u{f4fa}"; -pub const user_astronaut = "\u{f4fb}"; -pub const user_check = "\u{f4fc}"; -pub const user_circle = "\u{f2bd}"; -pub const user_clock = "\u{f4fd}"; -pub const user_cog = "\u{f4fe}"; -pub const user_edit = "\u{f4ff}"; -pub const user_friends = "\u{f500}"; -pub const user_graduate = "\u{f501}"; -pub const user_injured = "\u{f728}"; -pub const user_lock = "\u{f502}"; -pub const user_md = "\u{f0f0}"; -pub const user_minus = "\u{f503}"; -pub const user_ninja = "\u{f504}"; -pub const user_nurse = "\u{f82f}"; -pub const user_plus = "\u{f234}"; -pub const user_secret = "\u{f21b}"; -pub const user_shield = "\u{f505}"; -pub const user_slash = "\u{f506}"; -pub const user_tag = "\u{f507}"; -pub const user_tie = "\u{f508}"; -pub const user_times = "\u{f235}"; -pub const users = "\u{f0c0}"; -pub const users_cog = "\u{f509}"; -pub const users_slash = "\u{f973}"; -pub const utensil_spoon = "\u{f2e5}"; -pub const utensils = "\u{f2e7}"; -pub const vector_square = "\u{f5cb}"; -pub const venus = "\u{f221}"; -pub const venus_double = "\u{f226}"; -pub const venus_mars = "\u{f228}"; -pub const vial = "\u{f492}"; -pub const vials = "\u{f493}"; -pub const video = "\u{f03d}"; -pub const video_slash = "\u{f4e2}"; -pub const vihara = "\u{f6a7}"; -pub const virus = "\u{f974}"; -pub const virus_slash = "\u{f975}"; -pub const viruses = "\u{f976}"; -pub const voicemail = "\u{f897}"; -pub const volleyball_ball = "\u{f45f}"; -pub const volume_down = "\u{f027}"; -pub const volume_mute = "\u{f6a9}"; -pub const volume_off = "\u{f026}"; -pub const volume_up = "\u{f028}"; -pub const vote_yea = "\u{f772}"; -pub const vr_cardboard = "\u{f729}"; -pub const walking = "\u{f554}"; -pub const wallet = "\u{f555}"; -pub const warehouse = "\u{f494}"; -pub const water = "\u{f773}"; -pub const wave_square = "\u{f83e}"; -pub const weight = "\u{f496}"; -pub const weight_hanging = "\u{f5cd}"; -pub const wheelchair = "\u{f193}"; -pub const wifi = "\u{f1eb}"; -pub const wind = "\u{f72e}"; -pub const window_close = "\u{f410}"; -pub const window_maximize = "\u{f2d0}"; -pub const window_minimize = "\u{f2d1}"; -pub const window_restore = "\u{f2d2}"; -pub const wine_bottle = "\u{f72f}"; -pub const wine_glass = "\u{f4e3}"; -pub const wine_glass_alt = "\u{f5ce}"; -pub const won_sign = "\u{f159}"; -pub const wrench = "\u{f0ad}"; -pub const x_ray = "\u{f497}"; -pub const yen_sign = "\u{f157}"; -pub const yin_yang = "\u{f6ad}"; diff --git a/src/tools/timer.zig b/src/tools/timer.zig deleted file mode 100644 index f1891125..00000000 --- a/src/tools/timer.zig +++ /dev/null @@ -1,23 +0,0 @@ -// A simple timer utility for benchmarking. -const std = @import("std"); - -const Self = @This(); -start_time: i64 = -1, -done: bool = false, - -pub fn start(self: *Self) void { - self.start_time = std.time.milliTimestamp(); - self.done = false; -} - -pub fn end(self: *Self) i64 { - if (self.start_time == -1 or self.done) { - std.debug.panic("Timer already ended", .{}); - return -1; - } - self.done = true; - - const end_time = std.time.milliTimestamp(); - const elapsed = end_time - self.start_time; - return elapsed; -} diff --git a/src/web_main.zig b/src/web_main.zig index 63524544..daafcbbf 100644 --- a/src/web_main.zig +++ b/src/web_main.zig @@ -23,7 +23,6 @@ const fizzy = @import("fizzy.zig"); comptime { // Pure constants / re-exports _ = fizzy.version; - _ = fizzy.fa.adjust; _ = fizzy.atlas; // Algorithms — pure Zig + dvui @@ -58,7 +57,7 @@ comptime { // Custom dvui wrapper + widgets — types compile even though the widget files // contain dead `@import("backend")` SDL3 imports at file scope. - _ = fizzy.dvui.FileWidget; + _ = @import("plugins/pixelart/widgets/FileWidget.zig"); _ = fizzy.dvui.CanvasWidget; // The big ones: Editor + App. Type-level reference only — passes because Zig From 1e41380c37a3d7f80fe50277311ee93a8919999a Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 12:45:09 -0500 Subject: [PATCH 16/49] Phase 4 stage B --- HANDOFF.md | 160 +++++++++++++++++++ src/App.zig | 11 ++ src/editor/Editor.zig | 123 +++++--------- src/editor/Keybinds.zig | 28 ++-- src/fizzy.zig | 5 + src/plugins/pixelart/PixelArt.zig | 78 +++++++++ src/plugins/pixelart/Tools.zig | 10 +- src/plugins/pixelart/Transform.zig | 2 +- src/plugins/pixelart/dialogs/Export.zig | 4 +- src/plugins/pixelart/explorer/project.zig | 12 +- src/plugins/pixelart/explorer/sprites.zig | 4 +- src/plugins/pixelart/explorer/tools.zig | 36 ++--- src/plugins/pixelart/internal/File.zig | 24 +-- src/plugins/pixelart/internal/Layer.zig | 4 +- src/plugins/pixelart/panel/sprites.zig | 2 +- src/plugins/pixelart/plugin.zig | 4 + src/plugins/pixelart/widgets/FileWidget.zig | 124 +++++++------- src/plugins/pixelart/widgets/ImageWidget.zig | 8 +- src/plugins/workbench/files.zig | 2 +- 19 files changed, 430 insertions(+), 211 deletions(-) create mode 100644 HANDOFF.md create mode 100644 src/plugins/pixelart/PixelArt.zig diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..6cf1799a --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,160 @@ +# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, after Stage B) + +## TL;DR + +We are turning the monolithic editor into a **core shell + plugins** layout. Phase 4 +makes `core` a real, separately-wired Zig module with no dependency on the `fizzy` +app hub, then (Stages B–E) lifts the pixel-art editor fully behind the plugin SDK so +it can become its own compile-time module. + +**Done:** Stage A1, A2, A3, B. **Next:** Stage C (then D, E). + +## What Stage B did + +Lifted the pixel-art editor state off the shell `Editor` into a plugin-owned +`PixelArt` struct (`src/plugins/pixelart/PixelArt.zig`), reached via a new +`fizzy.pixelart: *PixelArt` global (mirrors the existing `fizzy.packer`). + +- **Fields moved:** `tools`, `colors`, `project`, `sprite_clipboard`, `pack_jobs` + (plus the `SpriteClipboard` type). ~190 `editor.` / `fizzy.editor.` + call sites repointed to `fizzy.pixelart.` across `Editor.zig`, `Keybinds.zig`, + `workbench/files.zig`, and the pixel-art tree. +- **`atlas` deliberately stayed on the shell** — it's the shared UI icon spritesheet + (cursor/pencil/logo/selection icons) the shell uses for its own logo + (`workbench/files.zig`, `Workspace.zig`), not pixel-art-specific. Moving it would + invert the dependency. (Its type is still `Internal.Atlas`; relocating that type to + core is a later structural question, not Stage B.) +- **Lifecycle:** `fizzy.pixelart` is allocated + `PixelArt.init`'d in `App.AppInit` + *before* `editor.postInit()` so the pixel-art `plugin.register` adopts it as + `plugin.state`. `PixelArt.init` now owns the tools init + the two `fizzy.hex` palette + loads (moved out of `Editor.init`). `PixelArt.deinit` (pack-job cancel, palette free, + project save, tools free) runs from `App.AppDeinit` right after `editor.deinit()`; + the old interleaved pixel-art teardown blocks were removed from `Editor.deinit`. +- Three Editor helpers (`processHoldOpenRadialMenu`, `isPackingActive`, + `runWasmPackWorkers`) now ignore their `editor` param (`_: *Editor`) since they only + reach `fizzy.pixelart`. The pack methods (`startPackProject`/`processPackJob`/…) and + the copy-paste / radial-menu draw code still live on `Editor` — they relocate later. +- Type aliases on `Editor` (`pub const Tools/Colors/Project/Transform`) were left in + place; they're used as type paths (`Editor.Tools.Tool`) and move in Stage D. + +Verified green: `zig build`, `zig build check-web`, `zig build test`. (No live GUI +run — pure refactor.) + +All three build configs are green right now: + +``` +zig build # native exe +zig build check-web # wasm +zig build test # unit/integration tests +``` + +Run all three after every stage. Note: `zig build` for this repo currently needs to +run outside the sandbox (network/file access), so expect to pass elevated permissions. + +--- + +## What `core` is now (Stage A3 result) + +`src/core/` is a standalone module (`src/core/core.zig` is its root). It holds shared +infrastructure and **never imports `src/fizzy.zig`**: + +``` +src/core/ + core.zig # module root: gpa + trackpad hook + re-exports + dvui.zig # generic dvui hub: dialog framework, helpers, generic widgets + fs.zig paths.zig platform.zig Fling.zig + gfx/ image.zig perf.zig water_surface.zig + math/ math.zig color.zig direction.zig easing.zig layout_anchor.zig + widgets/ CanvasWidget PanedWidget ReorderWidget FloatingWindowWidget + TreeWidget TreeSelection + generated/ atlas.zig # written by the build's process-assets step +``` + +### Decoupling mechanisms (important invariants) + +- **Allocator injection.** `core.gpa` is a `std.mem.Allocator` set once at startup in + `App.init` (`fizzy.core.gpa = allocator;`). Core code (e.g. `gfx/image.zig`) allocates + through `core.gpa` instead of reaching into `fizzy.app.allocator`. +- **Trackpad hook.** `core.takeTrackpadPinchRatio` is a `*const fn () f32` set in + `App.init` to `fizzy.backend.takeTrackpadPinchRatio`. `CanvasWidget` calls the hook so + it doesn't depend on the heavy native backend. Defaults to a `1.0` no-op for headless/test. +- **Dialog chrome state moved into core.** `core.dvui.modal_dim_titlebar: bool` and + `core.dvui.dialog_close_rect_override: ?dvui.Rect.Physical` replaced the old + `Editor.dim_titlebar` field and `workbench/files.zig: new_file_close_rect` var. The + shell reads `fizzy.dvui.modal_dim_titlebar` in `Editor.setTitlebarColor`. +- **`fizzy.zig` re-exports core** so existing `fizzy.` call sites keep working: + `fizzy.image/fs/perf/water_surface/math/platform/paths/dvui/Fling/atlas` all alias + `core.*`, plus `pub const core = @import("core");`. +- **Widget split.** Generic widgets live in `core/widgets/` and are exposed as + `core.dvui.CanvasWidget` etc. The **pixel-art** `FileWidget` and `ImageWidget` stayed + in `src/plugins/pixelart/widgets/` (ImageWidget is still pixel-art-coupled). Consumers + import them locally, not via the hub. `src/editor/widgets/Widgets.zig` was deleted. + +### Build wiring + +`core` is created three times (one per target/backend variant) in `build.zig`: +- native exe: `core_module` (dvui_sdl3) — search `addImport("core"` +- web exe: `core_module_web` (dvui_web) +- test: `core_module_test` (dvui_testing) + +Each gets `dvui`, `known-folders`, and (lazy) `icons`. The generated atlas now writes to +`src/core/generated/`, and the inline test modules point at `src/core/math/*`. + +### Gotchas discovered (don't repeat these) + +- **Build-script / module file-ownership trap.** `build.zig` imports + `src/tools/process_assets.zig`, which imports `src/plugins/pixelart/Atlas.zig` to + generate the atlas index *at build time*. A file may belong to only one module within a + single compilation. Routing `Atlas.zig`'s file read through `fizzy.fs`/`core.fs` (a) + dragged the whole `fizzy`+`core` graph into the build-runner compilation (no `core` + module there) and (b) caused "file exists in modules 'core' and 'root'". **Fix applied:** + `Atlas.zig` now imports nothing but `std` and inlines its file read. Keep build-time + tools (`process_assets.zig` and anything it imports) free of `fizzy`/`core` module imports. +- **macOS case-insensitive FS.** `sprite.zig` vs `Sprite.zig` collide. The atlas-render + library is named `sprite_render.zig` for this reason. +- **Lazy top-level imports.** An unused `const fizzy = @import(...)` is fine (never + analyzed). Problems only appear when build-*reachable* code forces analysis. + +--- + +## Remaining stages + +The plan tasks are tracked as todos `b`, `c`, `d`, `e`. The pixel-art plugin still has a +large coupling surface to the shell: ~250 `fizzy.editor.` / `fizzy.backend.` / +`fizzy.platform.` references across `src/plugins/pixelart/**` (biggest offenders: +`widgets/FileWidget.zig` ~80, `dialogs/Export.zig`, `internal/File.zig`, +`explorer/tools.zig`). Stages B–D systematically remove these. + +### Stage B — lift pixel-art editor state off the shell `Editor` +Move the pixel-art-specific fields (tools, colors, atlas, project, buffers, transform) +off `src/editor/Editor.zig` (~83 refs) into a `PixelArt` plugin-state struct owned by the +plugin. Update `Editor.zig`, `Keybinds` (~15 refs), and the `Menu`, plus the pixel-art +references that read those fields. Build green (all 3). + +### Stage C — expand the SDK Host + a `workbench` service vtable +Grow `src/sdk/sdk.zig` Host surface to cover the ~110-ref shell surface the plugin still +needs: arena access, settings, folder access, doc/tab access, command registration. Then +replace remaining pixel-art `fizzy.editor` / `fizzy.backend` / `fizzy.platform` calls with +SDK calls. Build green. + +### Stage D — make `pixelart` its own module +Add a `src/plugins/pixelart/pixelart.zig` module root; repoint all pixel-art imports from +`fizzy.zig` to `core` / `sdk` / `dvui` / local files; wire `b.addModule("pixelart", ...)` +in `build.zig` (3 configs, mirroring how `core` is wired); have `App` call +`pixelart.register(host)`. Build native + test + web. + +### Stage E — strip pixel-art names from shell hubs +Remove pixel-art names from `fizzy.zig` / Dialogs / `Editor` / Explorer / Panel; route all +contributions through the SDK only. Final verification across the 3 configs. + +--- + +## State of the tree + +Uncommitted. Stage A3 touched: `build.zig`, `src/App.zig`, `src/fizzy.zig`, +`src/web_main.zig`, `src/editor/Editor.zig`, the moved `src/core/**` files, and the +pixel-art/workbench consumers (`Atlas.zig`, `CanvasData.zig`, `PackJob.zig`, +`FileLoadJob.zig`, `files.zig`, `plugin.zig`, `dialogs/GridLayout.zig`, +`widgets/{CanvasBridge,FileWidget,ImageWidget}.zig`). Deleted: `editor/widgets/Widgets.zig`, +`tools/timer.zig`, `core/gfx/gfx.zig` (empty), `core/font_awesome.zig` (unused — `fa` +re-exports removed from `core.zig`/`fizzy.zig` and the web probe). Nothing has been committed. diff --git a/src/App.zig b/src/App.zig index b7275477..7ca4e3d5 100644 --- a/src/App.zig +++ b/src/App.zig @@ -164,6 +164,13 @@ pub fn AppInit(win: *dvui.Window) !void { fizzy.editor = try allocator.create(Editor); fizzy.editor.* = Editor.init(fizzy.app) catch unreachable; + + // Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created + // before `postInit` so the pixel-art plugin's `register` can adopt it as its + // `state`. Owned here for the app's lifetime; torn down in `AppDeinit`. + fizzy.pixelart = try allocator.create(fizzy.PixelArt); + fizzy.pixelart.* = fizzy.PixelArt.init(allocator) catch unreachable; + // Second-stage init that needs the editor at its final heap address (e.g. // registering the workbench-api service whose `ctx` is this pointer). fizzy.editor.postInit() catch unreachable; @@ -220,6 +227,10 @@ pub fn AppDeinit() void { // Persist the current windowed frame while the window still exists. No-op off macOS. fizzy.backend.saveWindowGeometry(fizzy.app.window); fizzy.editor.deinit() catch unreachable; + // Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs). + // After the editor so any editor teardown that still reads pixel-art state runs first. + fizzy.pixelart.deinit(fizzy.app.allocator); + fizzy.app.allocator.destroy(fizzy.pixelart); // Tear down the singleton listener after the editor so any callback // currently in flight finishes before we free state it touches. singleton.deinit(); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 94710481..d3ad7df0 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -77,7 +77,6 @@ infobar: Infobar, /// The root folder that will be searched for files and a .fizproject file folder: ?[]const u8 = null, -project: ?Project = null, /// From `.fizignore` (preferred) or `.gitignore` at the project root; used by the Files explorer. ignore: IgnoreRules = .{}, @@ -91,12 +90,6 @@ open_files: std.AutoArrayHashMapUnmanaged(u64, fizzy.Internal.File) = .empty, /// `path` allocation; the StringHashMap stores key slices that point into job memory. loading_jobs: std.StringHashMapUnmanaged(*FileLoadJob) = .empty, -/// Background project-pack jobs. Each `startPackProject` cancels any predecessors and pushes a -/// new job; only the newest job's result is installed. Cancelled jobs are still kept here -/// until their worker observes the flag and publishes `done`, at which point -/// `processPackJob` reaps them. This way rapid Pack-Project clicks (or future per-save -/// repacks) coalesce: only the most recent request produces a visible atlas update. -pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty, /// True iff a loading job should set its target file as the active file once it lands. /// `setActiveFile`-on-completion respects the most recent open request — multiple in-flight /// loads only auto-focus the most recently requested one. @@ -111,14 +104,9 @@ tab_drag_from_tree_path: ?[]u8 = null, /// `drawFiles` data id for `removed_path`; clear after drop on workspace canvas. file_tree_data_id: ?dvui.Id = null, -tools: Tools, -colors: Colors = .{}, - grouping_id_counter: u64 = 0, file_id_counter: u64 = 0, -sprite_clipboard: ?SpriteClipboard = null, - window_opacity: f32 = 1.0, /// Animated window-background opacity multiplier. Eases toward the windowed @@ -175,11 +163,6 @@ settings_save_deadline_ns: i128 = 0, /// to open the hold-to-context menu on touch-only hardware. last_touch_press_ns: ?i128 = null, -pub const SpriteClipboard = struct { - source: dvui.ImageSource, - offset: dvui.Point, -}; - const embedded_fonts: []const dvui.Font.Source = &.{ .{ .family = dvui.Font.array("CozetteVector"), @@ -270,7 +253,6 @@ pub fn init( .data = try .loadFromBytes(app.allocator, assets.files.@"fizzy.atlas"), .source = try fizzy.image.fromImageFileBytes("fizzy.png", assets.files.@"fizzy.png", .ptr), }, - .tools = try .init(app.allocator), .themes = .empty, .host = .init(app.allocator), .workbench = .init(app.allocator), @@ -450,8 +432,8 @@ pub fn init( return err; }; - editor.colors.file_tree_palette = fizzy.Internal.Palette.loadFromBytes(app.allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; - editor.colors.palette = fizzy.Internal.Palette.loadFromBytes(app.allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; + // Pixel-art tools/colors/palettes now init in `PixelArt.init` (App owns the + // `fizzy.pixelart` instance, created just after this `Editor.init` returns). try Keybinds.register(); @@ -1236,7 +1218,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { processHoldOpenRadialMenu(editor); - if (editor.tools.radial_menu.visible) { + if (fizzy.pixelart.tools.radial_menu.visible) { editor.drawRadialMenu() catch { dvui.log.err("Failed to draw radial menu", .{}); }; @@ -1436,8 +1418,8 @@ pub fn setWindowStyle(_: *Editor) void { /// Dismiss rules for the hold-opened radial menu (empty workspace area): stay open after /// the opening finger lifts; close on tool button click or a non-drag click outside. -fn processHoldOpenRadialMenu(editor: *Editor) void { - const rm = &editor.tools.radial_menu; +fn processHoldOpenRadialMenu(_: *Editor) void { + const rm = &fizzy.pixelart.tools.radial_menu; if (!rm.visible or !rm.opened_by_press) { rm.outside_click_press_p = null; return; @@ -1498,7 +1480,7 @@ pub fn drawRadialMenu(editor: *Editor) !void { // `center` is set when the menu opens (Space down or hold on empty workspace) and stays // fixed until close so tool buttons remain hoverable/clickable. - const center = fw.data().rectScale().pointFromPhysical(editor.tools.radial_menu.center); + const center = fw.data().rectScale().pointFromPhysical(fizzy.pixelart.tools.radial_menu.center); const tool_count: usize = std.meta.fields(Editor.Tools.Tool).len; @@ -1549,7 +1531,7 @@ pub fn drawRadialMenu(editor: *Editor) !void { } var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(i); } @@ -1584,8 +1566,8 @@ pub fn drawRadialMenu(editor: *Editor) !void { .rect = rect, .id_extra = i, .corner_radius = dvui.Rect.all(1000.0), - .color_fill = if (tool == editor.tools.current) dvui.themeGet().color(.content, .fill) else .transparent, - .box_shadow = if (tool == editor.tools.current) .{ + .color_fill = if (tool == fizzy.pixelart.tools.current) dvui.themeGet().color(.content, .fill) else .transparent, + .box_shadow = if (tool == fizzy.pixelart.tools.current) .{ .color = .black, .offset = .{ .x = -2.5, .y = 2.5 }, .fade = 4.0, @@ -1597,10 +1579,10 @@ pub fn drawRadialMenu(editor: *Editor) !void { }); { - editor.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {}; + fizzy.pixelart.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {}; } - const selection_sprite = switch (editor.tools.selection_mode) { + const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) { .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], @@ -1646,11 +1628,11 @@ pub fn drawRadialMenu(editor: *Editor) !void { angle += step; if (button.hovered()) { - editor.tools.set(tool); + fizzy.pixelart.tools.set(tool); } if (button.clicked()) { - editor.tools.set(tool); - editor.tools.radial_menu.close(); + fizzy.pixelart.tools.set(tool); + fizzy.pixelart.tools.radial_menu.close(); } button.deinit(); @@ -1686,8 +1668,8 @@ pub fn drawRadialMenu(editor: *Editor) !void { .rect = rect, })) { file.editor.playing = !file.editor.playing; - if (editor.tools.radial_menu.opened_by_press) { - editor.tools.radial_menu.close(); + if (fizzy.pixelart.tools.radial_menu.opened_by_press) { + fizzy.pixelart.tools.radial_menu.close(); } } } @@ -1974,7 +1956,7 @@ pub fn close(app: *App, editor: *Editor) void { pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { if (editor.folder) |folder| { editor.ignore.deinit(fizzy.app.allocator); - if (editor.project) |*project| { + if (fizzy.pixelart.project) |*project| { project.save() catch { dvui.log.err("Failed to save project", .{}); }; @@ -1985,7 +1967,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); editor.host.setActiveSidebarView(@import("../plugins/workbench/plugin.zig").view_files); - editor.project = Project.load(fizzy.app.allocator) catch null; + fizzy.pixelart.project = Project.load(fizzy.app.allocator) catch null; editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); } @@ -2225,7 +2207,7 @@ pub fn startPackProject(editor: *Editor) !void { // predecessor publishes `done` between append and cancel: `processPackJob` walks the list // newest-first and would otherwise see an old non-cancelled ready job and install its // (stale) atlas. Cancelled predecessors are skipped during install selection. - for (editor.pack_jobs.items) |old| { + for (fizzy.pixelart.pack_jobs.items) |old| { old.cancelled.store(true, .monotonic); } @@ -2233,8 +2215,8 @@ pub fn startPackProject(editor: *Editor) !void { owned_inputs = null; errdefer job.destroy(); - try editor.pack_jobs.append(fizzy.app.allocator, job); - errdefer _ = editor.pack_jobs.pop(); + try fizzy.pixelart.pack_jobs.append(fizzy.app.allocator, job); + errdefer _ = fizzy.pixelart.pack_jobs.pop(); if (comptime builtin.target.cpu.arch == .wasm32) { // Worker runs at end of `tick` (after the explorer draws) so the Pack @@ -2248,8 +2230,8 @@ pub fn startPackProject(editor: *Editor) !void { /// True while a pack is queued, running, or finished but not yet installed into /// `fizzy.packer.atlas`. Drives the explorer Pack button spinner. -pub fn isPackingActive(editor: *const Editor) bool { - for (editor.pack_jobs.items) |job| { +pub fn isPackingActive(_: *const Editor) bool { + for (fizzy.pixelart.pack_jobs.items) |job| { if (job.cancelled.load(.monotonic)) continue; if (!job.done.load(.acquire)) return true; if (!job.result_consumed) return true; @@ -2258,8 +2240,8 @@ pub fn isPackingActive(editor: *const Editor) bool { } /// Run queued wasm pack workers after UI has drawn so `isPackingActive` can show feedback. -fn runWasmPackWorkers(editor: *Editor) void { - for (editor.pack_jobs.items) |job| { +fn runWasmPackWorkers(_: *Editor) void { + for (fizzy.pixelart.pack_jobs.items) |job| { if (job.cancelled.load(.monotonic)) continue; if (job.done.load(.acquire)) continue; PackJob.workerMain(job); @@ -2342,17 +2324,17 @@ fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void { /// rest. Older or cancelled jobs' results — even successful ones — are freed without affecting /// `fizzy.packer.atlas` so coalesced re-triggers can't briefly flicker stale atlases. pub fn processPackJob(editor: *Editor) void { - if (editor.pack_jobs.items.len == 0) return; + if (fizzy.pixelart.pack_jobs.items.len == 0) return; // Identify the newest (last appended) job that finished with a `.ready` result and was // not cancelled. Only its result is installed; older successful results are stale and // get discarded along with cancelled / failed ones. var install_index: ?usize = null; { - var i = editor.pack_jobs.items.len; + var i = fizzy.pixelart.pack_jobs.items.len; while (i > 0) { i -= 1; - const job = editor.pack_jobs.items[i]; + const job = fizzy.pixelart.pack_jobs.items[i]; if (!job.done.load(.acquire)) continue; if (job.cancelled.load(.monotonic)) continue; if (job.currentPhase() == .ready and job.result_atlas != null) { @@ -2363,7 +2345,7 @@ pub fn processPackJob(editor: *Editor) void { } if (install_index) |idx| { - const job = editor.pack_jobs.items[idx]; + const job = fizzy.pixelart.pack_jobs.items[idx]; const new_atlas = job.result_atlas.?; // Free the previously-installed atlas's allocations so the new one can take its // place — matches the synchronous `packAndClear` cleanup ordering. @@ -2389,10 +2371,10 @@ pub fn processPackJob(editor: *Editor) void { } else blk: { // Newest finished job had no atlas (empty inputs / no packable frames). Tell the user // so the Pack button doesn't look like it silently did nothing. - var i = editor.pack_jobs.items.len; + var i = fizzy.pixelart.pack_jobs.items.len; while (i > 0) { i -= 1; - const job = editor.pack_jobs.items[i]; + const job = fizzy.pixelart.pack_jobs.items[i]; if (!job.done.load(.acquire)) continue; if (job.cancelled.load(.monotonic)) continue; if (job.currentPhase() == .ready and job.result_atlas == null) { @@ -2405,9 +2387,9 @@ pub fn processPackJob(editor: *Editor) void { // Reap everything that has published `done`. Successful-but-superseded jobs leave their // `result_atlas` un-consumed; `destroy()` frees those allocations for us. var write: usize = 0; - for (editor.pack_jobs.items) |job| { + for (fizzy.pixelart.pack_jobs.items) |job| { if (!job.done.load(.acquire)) { - editor.pack_jobs.items[write] = job; + fizzy.pixelart.pack_jobs.items[write] = job; write += 1; continue; } @@ -2422,7 +2404,7 @@ pub fn processPackJob(editor: *Editor) void { } job.destroy(); } - editor.pack_jobs.shrinkRetainingCapacity(write); + fizzy.pixelart.pack_jobs.shrinkRetainingCapacity(write); } /// Returns the active workspace's canvas content rect (physical pixels) captured from the @@ -2768,15 +2750,15 @@ pub fn copy(editor: *Editor) !void { if (editor.activeFile()) |file| { if (file.editor.transform != null) return; - if (editor.sprite_clipboard) |*clipboard| { + if (fizzy.pixelart.sprite_clipboard) |*clipboard| { fizzy.app.allocator.free(fizzy.image.bytes(clipboard.source)); - editor.sprite_clipboard = null; + fizzy.pixelart.sprite_clipboard = null; } file.editor.transform_layer.clear(); var selected_layer = file.layers.get(file.selected_layer_index); - switch (editor.tools.current) { + switch (fizzy.pixelart.tools.current) { .selection => { // We are in the selection tool, so we should assume that the user has painted a selection // into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing @@ -2841,7 +2823,7 @@ pub fn copy(editor: *Editor) !void { if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { const sprite_tl = file.spritePoint(reduced_data_rect.topLeft()); - editor.sprite_clipboard = .{ + fizzy.pixelart.sprite_clipboard = .{ .source = fizzy.image.fromPixelsPMA( @ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)), @intFromFloat(reduced_data_rect.w), @@ -2864,7 +2846,7 @@ pub fn copy(editor: *Editor) !void { } pub fn paste(editor: *Editor) !void { - if (editor.sprite_clipboard) |*clipboard| { + if (fizzy.pixelart.sprite_clipboard) |*clipboard| { if (editor.activeFile()) |file| { const active_layer = file.layers.get(file.selected_layer_index); @@ -2992,7 +2974,7 @@ pub fn transform(editor: *Editor) !void { var selected_layer = file.layers.get(file.selected_layer_index); - switch (editor.tools.current) { + switch (fizzy.pixelart.tools.current) { .selection => { file.editor.transform_layer.clear(); // We are in the selection tool, so we should assume that the user has painted a selection @@ -3425,13 +3407,6 @@ pub fn deinit(editor: *Editor) !void { editor.loading_jobs.deinit(fizzy.app.allocator); } - for (editor.pack_jobs.items) |job| { - // Detached workers still reference each job. Signal cancellation and leak the structs - // on hard quit — better than a use-after-free if a worker hasn't yet observed it. - job.cancelled.store(true, .monotonic); - } - editor.pack_jobs.deinit(fizzy.app.allocator); - if (editor.tab_drag_from_tree_path) |p| { fizzy.app.allocator.free(p); editor.tab_drag_from_tree_path = null; @@ -3446,9 +3421,6 @@ pub fn deinit(editor: *Editor) !void { editor.quit_saves_in_flight.deinit(fizzy.app.allocator); editor.pending_close_after_save.deinit(fizzy.app.allocator); - if (editor.colors.palette) |*palette| palette.deinit(); - if (editor.colors.file_tree_palette) |*palette| palette.deinit(); - // Recents persist via Io.Dir.cwd writes — no FS on wasm; skip persist. if (comptime builtin.target.cpu.arch != .wasm32) { editor.recents.save(fizzy.app.allocator, try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "recents.json" })) catch { @@ -3464,18 +3436,6 @@ pub fn deinit(editor: *Editor) !void { } editor.settings.deinit(fizzy.app.allocator); - if (editor.project) |*project| { - // Wasm: skip project.save() — it walks std.Io.Dir.cwd() which pulls in - // posix.AT (unavailable on freestanding). Browser tabs have no - // persistent on-disk project anyway. - if (comptime builtin.target.cpu.arch != .wasm32) { - project.save() catch { - dvui.log.err("Failed to save project file", .{}); - }; - } - project.deinit(fizzy.app.allocator); - } - editor.explorer.deinit(); for (editor.workspaces.values()) |*workspace| workspace.deinit(); @@ -3484,7 +3444,8 @@ pub fn deinit(editor: *Editor) !void { editor.host.deinit(); editor.workbench.deinit(); - editor.tools.deinit(fizzy.app.allocator); + // Pixel-art state (tools/colors/project/pack jobs) is torn down by + // `PixelArt.deinit` in `App.AppDeinit`, after this returns. editor.ignore.deinit(fizzy.app.allocator); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 0852fd4c..b6ae5cca 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -72,7 +72,7 @@ pub fn tick() !void { } if (ke.matchBind("quick_tools")) { - const rm = &fizzy.editor.tools.radial_menu; + const rm = &fizzy.pixelart.tools.radial_menu; switch (ke.action) { .down => { const mp = dvui.currentWindow().mouse_pt; @@ -91,11 +91,11 @@ pub fn tick() !void { } if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.editor.tools.current != .selection or fizzy.editor.tools.selection_mode == .pixel) { - if (fizzy.editor.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1) - fizzy.editor.tools.stroke_size += 1; + if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) { + if (fizzy.pixelart.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1) + fizzy.pixelart.tools.stroke_size += 1; - fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size); + fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size); } } @@ -127,11 +127,11 @@ pub fn tick() !void { } if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.editor.tools.current != .selection or fizzy.editor.tools.selection_mode == .pixel) { - if (fizzy.editor.tools.stroke_size > 1) - fizzy.editor.tools.stroke_size -= 1; + if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) { + if (fizzy.pixelart.tools.stroke_size > 1) + fizzy.pixelart.tools.stroke_size -= 1; - fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size); + fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size); } } @@ -212,19 +212,19 @@ pub fn tick() !void { } if (ke.matchBind("pencil") and ke.action == .down) { - fizzy.editor.tools.set(.pencil); + fizzy.pixelart.tools.set(.pencil); } if (ke.matchBind("eraser") and ke.action == .down) { - fizzy.editor.tools.set(.eraser); + fizzy.pixelart.tools.set(.eraser); } if (ke.matchBind("bucket") and ke.action == .down) { - fizzy.editor.tools.set(.bucket); + fizzy.pixelart.tools.set(.bucket); } if (ke.matchBind("pointer") and ke.action == .down) { - fizzy.editor.tools.set(.pointer); + fizzy.pixelart.tools.set(.pointer); } if (ke.matchBind("selection") and ke.action == .down) { - fizzy.editor.tools.set(.selection); + fizzy.pixelart.tools.set(.selection); } }, else => {}, diff --git a/src/fizzy.zig b/src/fizzy.zig index 3feeeeb6..dfb406e1 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -37,10 +37,15 @@ pub const Packer = @import("plugins/pixelart/Packer.zig"); //pub const Popups = @import("editor/popups/Popups.zig"); pub const Sidebar = @import("editor/Sidebar.zig"); +/// Pixel-art plugin state (Phase 4 Stage B): the tools/colors/project/clipboard/ +/// pack-job fields formerly hung off the shell `Editor`. +pub const PixelArt = @import("plugins/pixelart/PixelArt.zig"); + // Global pointers pub var app: *App = undefined; pub var editor: *Editor = undefined; pub var packer: *Packer = undefined; +pub var pixelart: *PixelArt = undefined; /// Internal types /// These types contain additional data to support the editor diff --git a/src/plugins/pixelart/PixelArt.zig b/src/plugins/pixelart/PixelArt.zig new file mode 100644 index 00000000..6f411f11 --- /dev/null +++ b/src/plugins/pixelart/PixelArt.zig @@ -0,0 +1,78 @@ +//! Pixel-art plugin state, lifted off the shell `Editor` (Phase 4 Stage B). +//! +//! Owns the pixel-art-specific editor state that used to live as top-level fields +//! on `src/editor/Editor.zig`: the active tools, color/palette state, the open +//! project's pack config, the sprite clipboard, and the background pack-job queue. +//! +//! Accessed during Stages B–C through the `fizzy.pixelart` global (mirroring the +//! existing `fizzy.packer`). Stage D repoints plugin code at the SDK instead, at +//! which point this struct becomes the plugin's `state` proper rather than a +//! shell-reachable global. +const std = @import("std"); +const builtin = @import("builtin"); +const fizzy = @import("../../fizzy.zig"); +const dvui = @import("dvui"); +const assets = @import("assets"); + +const Colors = @import("Colors.zig"); +const Project = @import("Project.zig"); +const Tools = @import("Tools.zig"); +const PackJob = @import("PackJob.zig"); + +const PixelArt = @This(); + +/// A floating sprite cut/copied from the canvas, pasted relative to `offset`. +pub const SpriteClipboard = struct { + source: dvui.ImageSource, + offset: dvui.Point, +}; + +tools: Tools, +colors: Colors = .{}, + +/// The open project's `.fizproject` pack config, or null when no project folder is open. +project: ?Project = null, + +sprite_clipboard: ?SpriteClipboard = null, + +/// Background project-pack jobs. Each `Editor.startPackProject` cancels any predecessors and +/// pushes a new job; only the newest job's result is installed. Cancelled jobs are still kept +/// here until their worker observes the flag and publishes `done`, at which point +/// `Editor.processPackJob` reaps them. This way rapid Pack-Project clicks coalesce: only the +/// most recent request produces a visible atlas update. +pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty, + +pub fn init(allocator: std.mem.Allocator) !PixelArt { + var pa: PixelArt = .{ + .tools = try .init(allocator), + }; + pa.colors.file_tree_palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; + pa.colors.palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; + return pa; +} + +pub fn deinit(pa: *PixelArt, allocator: std.mem.Allocator) void { + for (pa.pack_jobs.items) |job| { + // Detached workers still reference each job. Signal cancellation and leak the structs + // on hard quit — better than a use-after-free if a worker hasn't yet observed it. + job.cancelled.store(true, .monotonic); + } + pa.pack_jobs.deinit(allocator); + + if (pa.colors.palette) |*palette| palette.deinit(); + if (pa.colors.file_tree_palette) |*palette| palette.deinit(); + + if (pa.project) |*project| { + // Wasm: skip project.save() — it walks std.Io.Dir.cwd() which pulls in + // posix.AT (unavailable on freestanding). Browser tabs have no + // persistent on-disk project anyway. + if (comptime builtin.target.cpu.arch != .wasm32) { + project.save() catch { + dvui.log.err("Failed to save project file", .{}); + }; + } + project.deinit(allocator); + } + + pa.tools.deinit(allocator); +} diff --git a/src/plugins/pixelart/Tools.zig b/src/plugins/pixelart/Tools.zig index 8efce232..9f00c6a6 100644 --- a/src/plugins/pixelart/Tools.zig +++ b/src/plugins/pixelart/Tools.zig @@ -194,8 +194,8 @@ pub fn getIndex(_: *Tools, point: dvui.Point) ?usize { /// Only used for handling getting the pixels surrounding the origin /// for stroke sizes larger than 1 pub fn getIndexShapeOffset(self: *Tools, origin: dvui.Point, current_index: usize) ?usize { - const shape = fizzy.editor.tools.stroke_shape; - const s: i32 = @intCast(fizzy.editor.tools.stroke_size); + const shape = fizzy.pixelart.tools.stroke_shape; + const s: i32 = @intCast(fizzy.pixelart.tools.stroke_size); if (s == 1) { if (current_index != 0) @@ -337,7 +337,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 const atlas_size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 }; var mode_color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { mode_color = palette.getDVUIColor(4); } @@ -367,7 +367,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 2 => "COLOR", else => unreachable, }; - const selected = fizzy.editor.tools.selection_mode == mode; + const selected = fizzy.pixelart.tools.selection_mode == mode; var mode_col = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .none, @@ -438,7 +438,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 }; if (mode_button.clicked()) { - fizzy.editor.tools.selection_mode = mode; + fizzy.pixelart.tools.selection_mode = mode; } } } diff --git a/src/plugins/pixelart/Transform.zig b/src/plugins/pixelart/Transform.zig index 5fe1550f..a4f44975 100644 --- a/src/plugins/pixelart/Transform.zig +++ b/src/plugins/pixelart/Transform.zig @@ -70,7 +70,7 @@ pub fn accept(self: *Transform) void { // Paste / transform accept writes new pixels but does not go through `processSelection`; the // overlay uses `selection_layer.mask ∩ active_layer.mask`. Keep the mask aligned with the // committed transform so copied/pasted (and moved) pixels show the selection outline. - if (fizzy.editor.tools.current == .selection) { + if (fizzy.pixelart.tools.current == .selection) { file.editor.selection_layer.clearMask(); for (pix, 0..) |temp_pixel, pixel_index| { if (temp_pixel.a != 0) { diff --git a/src/plugins/pixelart/dialogs/Export.zig b/src/plugins/pixelart/dialogs/Export.zig index 669b4079..bce96316 100644 --- a/src/plugins/pixelart/dialogs/Export.zig +++ b/src/plugins/pixelart/dialogs/Export.zig @@ -49,7 +49,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { // Export stays non-modal so the user can click the canvas to adjust selections. Switch to // the pointer tool on open so marquee/sprite picks work; drawing tools stay off until close. if (dvui.firstFrame(id)) { - fizzy.editor.tools.set(.pointer); + fizzy.pixelart.tools.set(.pointer); } var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); @@ -498,7 +498,7 @@ fn exportCheckerboardVertexColor( } fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color { - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { var animation_index: ?usize = null; if (file.selected_animation_index) |selected_animation_index| { diff --git a/src/plugins/pixelart/explorer/project.zig b/src/plugins/pixelart/explorer/project.zig index e6affcba..0620a7b8 100644 --- a/src/plugins/pixelart/explorer/project.zig +++ b/src/plugins/pixelart/explorer/project.zig @@ -15,7 +15,7 @@ pub fn draw() !void { } if (fizzy.editor.folder) |folder| { - if (fizzy.editor.project) |_| { + if (fizzy.pixelart.project) |_| { const tl = dvui.textLayout(@src(), .{}, .{ .expand = .none, .margin = dvui.Rect.all(0), @@ -44,7 +44,7 @@ pub fn draw() !void { tl.deinit(); if (dvui.button(@src(), "Create Project", .{}, .{ .expand = .horizontal })) { - fizzy.editor.project = .{}; + fizzy.pixelart.project = .{}; } return; } @@ -67,7 +67,7 @@ pub fn draw() !void { dvui.log.err("Failed to draw path text entry", .{}); }; - if (fizzy.editor.project) |project| { + if (fizzy.pixelart.project) |project| { if (fizzy.packer.atlas) |atlas| { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } }); if (dvui.button(@src(), "Export Project", .{ .draw_focus = false }, .{ @@ -258,7 +258,7 @@ const PathType = enum { }; fn pathTextEntry(path_type: PathType) !void { - if (fizzy.editor.project) |*project| { + if (fizzy.pixelart.project) |*project| { const output_path = switch (path_type) { .atlas => &project.packed_atlas_output, .image => &project.packed_image_output, @@ -455,7 +455,7 @@ fn packProjectButton(packing: bool) bool { } pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void { - if (fizzy.editor.project) |*project| { + if (fizzy.pixelart.project) |*project| { const output_path = &project.packed_atlas_output; if (paths) |paths_| { @@ -467,7 +467,7 @@ pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void { } pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void { - if (fizzy.editor.project) |*project| { + if (fizzy.pixelart.project) |*project| { const output_path = &project.packed_image_output; if (paths) |paths_| { diff --git a/src/plugins/pixelart/explorer/sprites.zig b/src/plugins/pixelart/explorer/sprites.zig index eaa90f5d..9b20679d 100644 --- a/src/plugins/pixelart/explorer/sprites.zig +++ b/src/plugins/pixelart/explorer/sprites.zig @@ -829,7 +829,7 @@ pub fn drawAnimations(self: *Sprites) !void { const selected = if (self.edit_anim_id) |id| id == anim_id else (is_primary_row or in_multi); var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(anim_id)); } @@ -1600,7 +1600,7 @@ pub fn drawFrames(self: *Sprites) !void { for (animation.frames, 0..) |*frame, frame_index| { var anim_color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { anim_color = palette.getDVUIColor(@intCast(animation.id)); } diff --git a/src/plugins/pixelart/explorer/tools.zig b/src/plugins/pixelart/explorer/tools.zig index a6ed38d3..a60d59ef 100644 --- a/src/plugins/pixelart/explorer/tools.zig +++ b/src/plugins/pixelart/explorer/tools.zig @@ -163,14 +163,14 @@ pub fn drawTools() !void { const tool: fizzy.Editor.Tools.Tool = @enumFromInt(i); const id_extra = i; - const selected = fizzy.editor.tools.current == tool; + const selected = fizzy.pixelart.tools.current == tool; var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(i); } - const selection_sprite = switch (fizzy.editor.tools.selection_mode) { + const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) { .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], @@ -204,7 +204,7 @@ pub fn drawTools() !void { }); defer button.deinit(); - fizzy.editor.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {}; + fizzy.pixelart.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {}; if (button.hovered()) { button.data().options.color_border = color; @@ -240,7 +240,7 @@ pub fn drawTools() !void { }; if (button.clicked()) { - fizzy.editor.tools.set(tool); + fizzy.pixelart.tools.set(tool); } } } @@ -539,7 +539,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { const font = if (visible) dvui.Font.theme(.body) else dvui.Font.theme(.body).withStyle(.italic); var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(layer_id)); } @@ -945,8 +945,8 @@ pub fn drawColors() !void { }); defer hbox.deinit(); - const primary: dvui.Color = .{ .r = fizzy.editor.colors.primary[0], .g = fizzy.editor.colors.primary[1], .b = fizzy.editor.colors.primary[2], .a = fizzy.editor.colors.primary[3] }; - const secondary: dvui.Color = .{ .r = fizzy.editor.colors.secondary[0], .g = fizzy.editor.colors.secondary[1], .b = fizzy.editor.colors.secondary[2], .a = fizzy.editor.colors.secondary[3] }; + const primary: dvui.Color = .{ .r = fizzy.pixelart.colors.primary[0], .g = fizzy.pixelart.colors.primary[1], .b = fizzy.pixelart.colors.primary[2], .a = fizzy.pixelart.colors.primary[3] }; + const secondary: dvui.Color = .{ .r = fizzy.pixelart.colors.secondary[0], .g = fizzy.pixelart.colors.secondary[1], .b = fizzy.pixelart.colors.secondary[2], .a = fizzy.pixelart.colors.secondary[3] }; const button_opts: dvui.Options = .{ .expand = .both, @@ -978,7 +978,7 @@ pub fn drawColors() !void { primary_button.init(@src(), .{}, button_opts); defer primary_button.deinit(); - try drawColorPicker(primary_button.data().rectScale().r, &fizzy.editor.colors.primary, 0); + try drawColorPicker(primary_button.data().rectScale().r, &fizzy.pixelart.colors.primary, 0); primary_button.processEvents(); primary_button.drawBackground(); @@ -991,7 +991,7 @@ pub fn drawColors() !void { secondary_button.init(@src(), .{}, button_opts.override(secondary_overrider)); defer secondary_button.deinit(); - try drawColorPicker(secondary_button.data().rectScale().r, &fizzy.editor.colors.secondary, 1); + try drawColorPicker(secondary_button.data().rectScale().r, &fizzy.pixelart.colors.secondary, 1); secondary_button.processEvents(); secondary_button.drawBackground(); @@ -1000,7 +1000,7 @@ pub fn drawColors() !void { } if (clicked) { - std.mem.swap([4]u8, &fizzy.editor.colors.primary, &fizzy.editor.colors.secondary); + std.mem.swap([4]u8, &fizzy.pixelart.colors.primary, &fizzy.pixelart.colors.secondary); } } @@ -1103,7 +1103,7 @@ pub fn drawPalettes() !void { .gravity_x = 1.0, }); - if (fizzy.editor.colors.palette) |*palette| { + if (fizzy.pixelart.colors.palette) |*palette| { dvui.label(@src(), "{s}", .{palette.name}, .{ .margin = .all(0), .padding = .all(0) }); } else { dvui.label(@src(), "Palette Search", .{}, .{ .margin = .all(0), .padding = .all(0) }); @@ -1133,7 +1133,7 @@ pub fn drawPalettes() !void { const ext = std.fs.path.extension(entry.name); if (std.mem.eql(u8, ext, ".hex")) { if (dropdown.addChoiceLabel(entry.name)) { - fizzy.editor.colors.palette = fizzy.Internal.Palette.loadFromBytes(fizzy.app.allocator, entry.name, data) catch |err| { + fizzy.pixelart.colors.palette = fizzy.Internal.Palette.loadFromBytes(fizzy.app.allocator, entry.name, data) catch |err| { dvui.log.err("Failed to load palette: {s}", .{@errorName(err)}); return error.FailedToLoadPalette; }; @@ -1157,7 +1157,7 @@ pub fn drawPalettes() !void { } { - if (fizzy.editor.colors.palette) |*palette| { + if (fizzy.pixelart.colors.palette) |*palette| { var flex_box = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .expand = .horizontal, .max_size_content = .{ @@ -1244,9 +1244,9 @@ pub fn drawPalettes() !void { switch (evt) { .mouse => |mouse_evt| { if (mouse_evt.button.pointer() or mouse_evt.button.touch()) { - @memcpy(&fizzy.editor.colors.primary, &color); + @memcpy(&fizzy.pixelart.colors.primary, &color); } else if (mouse_evt.button == .right) { - @memcpy(&fizzy.editor.colors.secondary, &color); + @memcpy(&fizzy.pixelart.colors.secondary, &color); } }, else => {}, @@ -1279,10 +1279,10 @@ fn searchPalettes(dropdown: *dvui.DropdownWidget) !void { if (dropdown.addChoiceLabel(label)) { const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ fizzy.editor.palette_folder, entry.name }); - if (fizzy.editor.colors.palette) |*palette| + if (fizzy.pixelart.colors.palette) |*palette| palette.deinit(); - fizzy.editor.colors.palette = fizzy.Internal.Palette.loadFromFile(fizzy.app.allocator, abs_path) catch |err| { + fizzy.pixelart.colors.palette = fizzy.Internal.Palette.loadFromFile(fizzy.app.allocator, abs_path) catch |err| { dvui.log.err("Failed to load palette: {s}", .{@errorName(err)}); return error.FailedToLoadPalette; }; diff --git a/src/plugins/pixelart/internal/File.zig b/src/plugins/pixelart/internal/File.zig index 5443e17d..291cd342 100644 --- a/src/plugins/pixelart/internal/File.zig +++ b/src/plugins/pixelart/internal/File.zig @@ -1678,9 +1678,9 @@ pub fn selectPoint(file: *File, point: dvui.Point, select_options: SelectOptions } } } else { - var iter = fizzy.editor.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); + var iter = fizzy.pixelart.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.editor.tools.offset_table[i]; + const offset = fizzy.pixelart.tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (select_options.constrain_to_tile) { @@ -1727,11 +1727,11 @@ pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_op const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size); const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) }; - var mask = fizzy.editor.tools.stroke; + var mask = fizzy.pixelart.tools.stroke; if (select_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) { for (0..(stroke_size * stroke_size)) |index| { - if (fizzy.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { + if (fizzy.pixelart.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { mask.unset(i); } } @@ -1742,11 +1742,11 @@ pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_op if (select_options.stroke_size < fizzy.Editor.Tools.min_full_stroke_size) { selectPoint(file, point, select_options); } else { - var stroke = if (point_i == 0) fizzy.editor.tools.stroke else mask; + var stroke = if (point_i == 0) fizzy.pixelart.tools.stroke else mask; var iter = stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.editor.tools.offset_table[i]; + const offset = fizzy.pixelart.tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (select_options.constrain_to_tile) { @@ -2339,9 +2339,9 @@ pub fn drawPoint(file: *File, point: dvui.Point, layer: DrawLayer, draw_options: } } } else { - var iter = fizzy.editor.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); + var iter = fizzy.pixelart.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.editor.tools.offset_table[i]; + const offset = fizzy.pixelart.tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (clip_rect) |cr| { @@ -2430,11 +2430,11 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size); const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) }; - var mask = fizzy.editor.tools.stroke; + var mask = fizzy.pixelart.tools.stroke; if (draw_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) { for (0..(stroke_size * stroke_size)) |index| { - if (fizzy.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { + if (fizzy.pixelart.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { mask.unset(i); } } @@ -2457,11 +2457,11 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw .clip_rect = draw_options.clip_rect, }); } else { - var stroke = if (point_i == 0) fizzy.editor.tools.stroke else mask; + var stroke = if (point_i == 0) fizzy.pixelart.tools.stroke else mask; var iter = stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.editor.tools.offset_table[i]; + const offset = fizzy.pixelart.tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (clip_rect) |cr| { diff --git a/src/plugins/pixelart/internal/Layer.zig b/src/plugins/pixelart/internal/Layer.zig index b8562ff5..a4aedfc1 100644 --- a/src/plugins/pixelart/internal/Layer.zig +++ b/src/plugins/pixelart/internal/Layer.zig @@ -266,8 +266,8 @@ pub fn invalidate(self: *Layer) void { /// Only used for handling getting the pixels surrounding the origin /// for stroke sizes larger than 1 pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usize) ?ShapeOffsetResult { - const shape = fizzy.editor.tools.stroke_shape; - const s: i32 = @intCast(fizzy.editor.tools.stroke_size); + const shape = fizzy.pixelart.tools.stroke_shape; + const s: i32 = @intCast(fizzy.pixelart.tools.stroke_size); if (s == 1) { if (current_index != 0) diff --git a/src/plugins/pixelart/panel/sprites.zig b/src/plugins/pixelart/panel/sprites.zig index 9aaa51e3..d844d03d 100644 --- a/src/plugins/pixelart/panel/sprites.zig +++ b/src/plugins/pixelart/panel/sprites.zig @@ -808,7 +808,7 @@ fn sideCardsFlown(playing: bool) bool { /// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee). fn drawingToolActive() bool { - return switch (fizzy.editor.tools.current) { + return switch (fizzy.pixelart.tools.current) { .pointer, .selection => false, .pencil, .eraser, .bucket => true, }; diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig index 9f321f9e..5fdcd2ef 100644 --- a/src/plugins/pixelart/plugin.zig +++ b/src/plugins/pixelart/plugin.zig @@ -247,6 +247,10 @@ fn redo(_: *anyopaque, doc: DocHandle) anyerror!void { } pub fn register(host: *sdk.Host) !void { + // Adopt the app-owned pixel-art state as this plugin's `state`. Stage B keeps + // it reachable through the `fizzy.pixelart` global too; Stage D drops the global + // and routes plugin access through `state` + the SDK. + plugin.state = fizzy.pixelart; try host.registerPlugin(&plugin); try host.registerSidebarView(.{ .id = view_tools, diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig index 8c9285c6..3fb35d68 100644 --- a/src/plugins/pixelart/widgets/FileWidget.zig +++ b/src/plugins/pixelart/widgets/FileWidget.zig @@ -33,7 +33,7 @@ fn onEmptyTap(_: ?*anyopaque) void { /// Off-artboard hold past the hold-menu duration → open the radial tool menu at the press /// point. The canvas releases its own capture afterward so the menu buttons can be hovered. fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void { - const rm = &fizzy.editor.tools.radial_menu; + const rm = &fizzy.pixelart.tools.radial_menu; rm.mouse_position = press_p; rm.center = press_p; rm.visible = true; @@ -45,7 +45,7 @@ fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void { /// A modified (ctrl/cmd or shift) off-artboard press is the sprite-selection marquee's /// while the pointer tool is active — yield it instead of starting a viewport pan. fn yieldModifiedEmptyPress(_: ?*anyopaque) bool { - return fizzy.editor.tools.current == .pointer; + return fizzy.pixelart.tools.current == .pointer; } init_options: InitOptions, @@ -307,23 +307,23 @@ pub fn sampleColorAtPoint( if (off_canvas) { // Sampling the empty margin outside the artboard isn't an erase — drop back // to the pointer tool so the click reads as "leave drawing mode". - if (fizzy.editor.tools.current != .pointer) { - fizzy.editor.tools.set(.pointer); + if (fizzy.pixelart.tools.current != .pointer) { + fizzy.pixelart.tools.set(.pointer); } } else if (color[3] == 0) { - if (fizzy.editor.tools.current != .eraser) { - fizzy.editor.tools.set(.eraser); + if (fizzy.pixelart.tools.current != .eraser) { + fizzy.pixelart.tools.set(.eraser); } } else { - fizzy.editor.colors.primary = color; - if (switch (fizzy.editor.tools.current) { + fizzy.pixelart.colors.primary = color; + if (switch (fizzy.pixelart.tools.current) { .pencil, .bucket => false, else => true, }) - fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool); + fizzy.pixelart.tools.set(fizzy.pixelart.tools.previous_drawing_tool); } } else if (apply_primary and color[3] > 0) { - fizzy.editor.colors.primary = color; + fizzy.pixelart.colors.primary = color; } } @@ -349,7 +349,7 @@ pub fn processAnimationSelection(self: *FileWidget) void { switch (e.evt) { .mouse => |me| { - if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (fizzy.editor.tools.current != .pointer and self.sample_data_point == null)) { + if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (fizzy.pixelart.tools.current != .pointer and self.sample_data_point == null)) { if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| { var found: bool = false; for (file.animations.items(.frames), 0..) |frames, anim_index| { @@ -378,7 +378,7 @@ pub fn processAnimationSelection(self: *FileWidget) void { } pub fn processCellReorder(self: *FileWidget) void { - if (fizzy.editor.tools.current != .pointer) return; + if (fizzy.pixelart.tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; if (self.drag_data_point != null) return; @@ -529,7 +529,7 @@ pub fn processCellReorder(self: *FileWidget) void { /// /// Supports add/remove, drag selection, etc. pub fn processSpriteSelection(self: *FileWidget) void { - if (fizzy.editor.tools.current != .pointer) return; + if (fizzy.pixelart.tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -706,7 +706,7 @@ fn bubblePanSharedForGrid(self: *FileWidget) ?BubblePanShared { const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id; const cw = dvui.currentWindow(); - const tool_not_pointer = fizzy.editor.tools.current != .pointer; + const tool_not_pointer = fizzy.pixelart.tools.current != .pointer; const mod_shift = cw.modifiers.matchBind("shift"); const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd"); const sample_active = self.sample_data_point != null; @@ -878,10 +878,10 @@ pub fn drawSpriteBubbles(self: *FileWidget) void { const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id; const cw = dvui.currentWindow(); const drag_sprite_selection = dvui.dragName("sprite_selection_drag"); - const tool_not_pointer = fizzy.editor.tools.current != .pointer; + const tool_not_pointer = fizzy.pixelart.tools.current != .pointer; const mod_shift = cw.modifiers.matchBind("shift"); const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd"); - const radial_visible = fizzy.editor.tools.radial_menu.visible; + const radial_visible = fizzy.pixelart.tools.radial_menu.visible; const sample_active = self.sample_data_point != null; const canvas_gesturing = self.init_options.file.editor.canvas.trackpadPinching() or self.init_options.file.editor.canvas.gestureActive(); @@ -1134,7 +1134,7 @@ fn drawSpriteBubbleForRow( if (animation_index) |ai| { const id = file.animations.get(ai).id; - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(id)); } if (file.selected_animation_index == ai) { @@ -1440,7 +1440,7 @@ pub fn drawSpriteBubble( var add_rem_message: ?[]const u8 = null; var border_color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { if (self.init_options.file.selected_animation_index) |index| { border_color = palette.getDVUIColor(@intCast(self.init_options.file.animations.get(index).id)); add_rem_message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{self.init_options.file.animations.get(index).name}) catch { @@ -1781,7 +1781,7 @@ pub fn drawSpriteBubble( /// Draw the highlight colored selection box for each selected sprite. pub fn drawSpriteSelection(self: *FileWidget) void { - if (fizzy.editor.tools.current != .pointer) return; + if (fizzy.pixelart.tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -1911,8 +1911,8 @@ fn strokePolylineDashedPhysical( } fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void { - if (fizzy.editor.tools.current != .selection) return; - if (fizzy.editor.tools.selection_mode != .box) return; + if (fizzy.pixelart.tools.current != .selection) return; + if (fizzy.pixelart.tools.selection_mode != .box) return; const start = self.drag_data_point orelse return; if (dvui.dragging(dvui.currentWindow().mouse_pt, "stroke_drag") == null) return; @@ -2001,7 +2001,7 @@ fn applySelectionBoxPreview( /// This selection is pixel-based, and includes shift/ctrl/cmd modifiers to support add/remove. /// The selection uses the same logic as the stroke tool to brush the selection over existing pixels. pub fn processSelection(self: *FileWidget) void { - if (switch (fizzy.editor.tools.current) { + if (switch (fizzy.pixelart.tools.current) { .selection, => false, else => true, @@ -2024,7 +2024,7 @@ pub fn processSelection(self: *FileWidget) void { // Pixel mode: draw the committed selection before handling events (brush preview layers on top). // Box mode: skip — the mask is updated on mouse release in the same frame as this paint; drawing // here would use stale data until the next frame. Box repaints from the current mask after events. - if (fizzy.editor.tools.selection_mode == .pixel or fizzy.editor.tools.selection_mode == .color) { + if (fizzy.pixelart.tools.selection_mode == .pixel or fizzy.pixelart.tools.selection_mode == .color) { @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); file.editor.temporary_layer.clearMask(); @@ -2044,21 +2044,21 @@ pub fn processSelection(self: *FileWidget) void { switch (e.evt) { .key => |ke| { var update: bool = false; - if (fizzy.editor.tools.selection_mode == .pixel) { + if (fizzy.pixelart.tools.selection_mode == .pixel) { if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.editor.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1) - fizzy.editor.tools.stroke_size += 1; + if (fizzy.pixelart.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1) + fizzy.pixelart.tools.stroke_size += 1; - fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size); + fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size); e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); update = true; } if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.editor.tools.stroke_size > 1) - fizzy.editor.tools.stroke_size -= 1; + if (fizzy.pixelart.tools.stroke_size > 1) + fizzy.pixelart.tools.stroke_size -= 1; - fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size); + fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size); e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); update = true; } @@ -2081,7 +2081,7 @@ pub fn processSelection(self: *FileWidget) void { .temporary, .{ .mask_only = true, - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = fizzy.pixelart.tools.stroke_size, }, ); @@ -2099,8 +2099,8 @@ pub fn processSelection(self: *FileWidget) void { const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p); if (me.action == .position) { - const box_mode = fizzy.editor.tools.selection_mode == .box; - const color_mode = fizzy.editor.tools.selection_mode == .color; + const box_mode = fizzy.pixelart.tools.selection_mode == .box; + const color_mode = fizzy.pixelart.tools.selection_mode == .color; const is_drag = dvui.dragging(me.p, "stroke_drag") != null; const box_drag = box_mode and is_drag and self.drag_data_point != null; @@ -2151,7 +2151,7 @@ pub fn processSelection(self: *FileWidget) void { .temporary, .{ .mask_only = true, - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = fizzy.pixelart.tools.stroke_size, }, ); @@ -2182,7 +2182,7 @@ pub fn processSelection(self: *FileWidget) void { if (!widget_active) continue; e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - if (fizzy.editor.tools.selection_mode == .color) { + if (fizzy.pixelart.tools.selection_mode == .color) { // Only clear the mask if we don't have ctrl/cmd pressed if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) file.editor.selection_layer.clearMask(); @@ -2200,14 +2200,14 @@ pub fn processSelection(self: *FileWidget) void { if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) file.editor.selection_layer.clearMask(); - if (fizzy.editor.tools.selection_mode == .box) { + if (fizzy.pixelart.tools.selection_mode == .box) { self.drag_data_point = current_point; } else { file.selectPoint( current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = fizzy.pixelart.tools.stroke_size, }, ); @@ -2220,23 +2220,23 @@ pub fn processSelection(self: *FileWidget) void { dvui.captureMouse(null, e.num); dvui.dragEnd(); - if (fizzy.editor.tools.selection_mode == .box) { + if (fizzy.pixelart.tools.selection_mode == .box) { if (self.drag_data_point) |start| { file.selectRectBetweenPoints( start, current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = fizzy.pixelart.tools.stroke_size, }, ); } - } else if (fizzy.editor.tools.selection_mode != .color) { + } else if (fizzy.pixelart.tools.selection_mode != .color) { file.selectPoint( current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = fizzy.pixelart.tools.stroke_size, }, ); } @@ -2268,14 +2268,14 @@ pub fn processSelection(self: *FileWidget) void { }); } - if (fizzy.editor.tools.selection_mode == .pixel) { + if (fizzy.pixelart.tools.selection_mode == .pixel) { if (self.drag_data_point) |previous_point| { file.selectLine( previous_point, current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = fizzy.pixelart.tools.stroke_size, }, ); } @@ -2290,7 +2290,7 @@ pub fn processSelection(self: *FileWidget) void { } } - if (fizzy.editor.tools.selection_mode == .box) { + if (fizzy.pixelart.tools.selection_mode == .box) { const mouse_pt = dvui.currentWindow().mouse_pt; const is_drag = dvui.dragging(mouse_pt, "stroke_drag") != null; if (!(is_drag and self.drag_data_point != null)) { @@ -2388,7 +2388,7 @@ fn processStrokeDragSegment( { if (self.sample_data_point == null or color[3] == 0) { clearTempPreview(&file.editor); - const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; file.drawPoint( current_point, .temporary, @@ -2409,12 +2409,12 @@ fn processStrokeDragSegment( /// Supports using shift to draw a line between two points, and increasing/decreasing stroke size pub fn processStroke(self: *FileWidget) void { const file = self.init_options.file; - const stroke_size = fizzy.editor.tools.stroke_size; + const stroke_size = fizzy.pixelart.tools.stroke_size; const widget_active = self.active(); if (self.cell_reorder_point != null) return; - if (switch (fizzy.editor.tools.current) { + if (switch (fizzy.pixelart.tools.current) { .pencil, .eraser, => false, @@ -2423,8 +2423,8 @@ pub fn processStroke(self: *FileWidget) void { if (self.sample_key_down or self.right_mouse_down) return; - const color: [4]u8 = switch (fizzy.editor.tools.current) { - .pencil => fizzy.editor.colors.primary, + const color: [4]u8 = switch (fizzy.pixelart.tools.current) { + .pencil => fizzy.pixelart.colors.primary, .eraser => [_]u8{ 0, 0, 0, 0 }, else => unreachable, }; @@ -2569,7 +2569,7 @@ pub fn processStroke(self: *FileWidget) void { self.sample_data_point == null) { clearTempPreview(&file.editor); - const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; file.drawPoint( current_point, .temporary, @@ -2595,10 +2595,10 @@ pub fn processStroke(self: *FileWidget) void { /// Supports using ctrl/cmd to replace all existing pixels of the same color with the new color, /// or without modifiers to flood fill the layer with the new color. pub fn processFill(self: *FileWidget) void { - if (fizzy.editor.tools.current != .bucket) return; + if (fizzy.pixelart.tools.current != .bucket) return; if (self.sample_key_down) return; const file = self.init_options.file; - const color = fizzy.editor.colors.primary; + const color = fizzy.pixelart.colors.primary; const widget_active = self.active(); // Skip the cursor-follow temp preview on touch: the finger occludes the pixel and @@ -2608,7 +2608,7 @@ pub fn processFill(self: *FileWidget) void { self.sample_data_point == null) { clearTempPreview(&file.editor); - const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; const fill_preview_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); file.drawPoint( fill_preview_pt, @@ -3864,7 +3864,7 @@ fn checkerboardVertexColor( /// Animation color for transparency tint; matches bubble arc palette lookup order (selected animation first, else first containing animation). fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color { - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (fizzy.pixelart.colors.file_tree_palette) |*palette| { var animation_index: ?usize = null; if (file.selected_animation_index) |selected_animation_index| { @@ -4073,8 +4073,8 @@ pub fn active(self: *FileWidget) bool { pub fn drawCursor(self: *FileWidget) void { if (fizzy.dvui.canvasPointerInputSuppressed()) return; - if (fizzy.editor.tools.current == .pointer and self.sample_data_point == null) return; - if (fizzy.editor.tools.radial_menu.visible) return; + if (fizzy.pixelart.tools.current == .pointer and self.sample_data_point == null) return; + if (fizzy.pixelart.tools.radial_menu.visible) return; if (self.init_options.file.editor.transform != null) return; if (self.init_options.file.editor.canvas.gestureActive()) return; if (self.init_options.file.editor.canvas.trackpadPinching()) return; @@ -4113,13 +4113,13 @@ pub fn drawCursor(self: *FileWidget) void { const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point); - const selection_sprite = switch (fizzy.editor.tools.selection_mode) { + const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) { .box => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], .pixel => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], .color => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], }; - if (switch (fizzy.editor.tools.current) { + if (switch (fizzy.pixelart.tools.current) { .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default], .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default], .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default], @@ -4619,7 +4619,7 @@ pub fn drawLayers(self: *FileWidget) void { } // Draw the selection box for the selected sprites - if (fizzy.editor.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) { + if (fizzy.pixelart.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) { var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { const sprite_rect = file.spriteRect(i); @@ -5483,7 +5483,7 @@ fn autoPanForResize(self: *FileWidget, mouse_pt: dvui.Point.Physical) void { } pub fn processResize(self: *FileWidget) void { - if (fizzy.editor.tools.current != .pointer) return; + if (fizzy.pixelart.tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -5818,7 +5818,7 @@ pub fn processEvents(self: *FileWidget) void { self.updateActiveLayerMask(); } - if (fizzy.editor.tools.current == .selection) { + if (fizzy.pixelart.tools.current == .selection) { if (dvui.timerDoneOrNone(self.init_options.file.editor.canvas.scroll_container.data().id)) { self.init_options.file.editor.checkerboard.toggleAll(); @@ -5828,7 +5828,7 @@ pub fn processEvents(self: *FileWidget) void { if (self.init_options.file.editor.transform == null) { const tool_t0 = fizzy.perf.toolProcessBegin(); - switch (fizzy.editor.tools.current) { + switch (fizzy.pixelart.tools.current) { .bucket => self.processFill(), .pencil, .eraser => self.processStroke(), .selection => self.processSelection(), diff --git a/src/plugins/pixelart/widgets/ImageWidget.zig b/src/plugins/pixelart/widgets/ImageWidget.zig index a07d4dab..3ce4de64 100644 --- a/src/plugins/pixelart/widgets/ImageWidget.zig +++ b/src/plugins/pixelart/widgets/ImageWidget.zig @@ -151,15 +151,15 @@ fn sample(self: *ImageWidget, point: dvui.Point, screen_p: dvui.Point.Physical) } } - fizzy.editor.colors.primary = color; + fizzy.pixelart.colors.primary = color; self.sample_data_point = point; if (color[3] == 0) { - if (fizzy.editor.tools.current != .eraser) { - fizzy.editor.tools.set(.eraser); + if (fizzy.pixelart.tools.current != .eraser) { + fizzy.pixelart.tools.set(.eraser); } } else { - fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool); + fizzy.pixelart.tools.set(fizzy.pixelart.tools.previous_drawing_tool); } } diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/files.zig index 34b61388..01c87a5f 100644 --- a/src/plugins/workbench/files.zig +++ b/src/plugins/workbench/files.zig @@ -497,7 +497,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg try visible_file_rows_order.append(fizzy.app.allocator, .{ .id = inner_id_extra.*, .path = abs_path }); var color = dvui.themeGet().color(.control, .fill); - if (fizzy.editor.colors.palette) |*palette| { + if (fizzy.pixelart.colors.palette) |*palette| { color = palette.getDVUIColor(color_id.*); } From 58368af733ce2fa43fdcd21dc8fadd1843e6cf4f Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 13:04:59 -0500 Subject: [PATCH 17/49] Phase 4 stage C --- HANDOFF.md | 211 +++++++++++++++-- src/App.zig | 2 +- src/editor/Editor.zig | 88 +++++++- src/editor/Settings.zig | 161 +++++++------ src/editor/explorer/settings.zig | 118 ---------- src/plugins/pixelart/CanvasData.zig | 16 +- src/plugins/pixelart/PixelArt.zig | 12 +- src/plugins/pixelart/Settings.zig | 213 ++++++++++++++++++ src/plugins/pixelart/dialogs/Export.zig | 2 +- src/plugins/pixelart/dialogs/GridLayout.zig | 4 +- src/plugins/pixelart/internal/Atlas.zig | 4 +- src/plugins/pixelart/internal/File.zig | 4 +- src/plugins/pixelart/panel/sprites.zig | 8 +- src/plugins/pixelart/plugin.zig | 15 +- src/plugins/pixelart/widgets/CanvasBridge.zig | 2 +- src/plugins/pixelart/widgets/FileWidget.zig | 8 +- src/sdk/Host.zig | 84 +++++++ src/sdk/ShellApi.zig | 48 ++++ src/sdk/regions.zig | 13 ++ src/sdk/sdk.zig | 7 +- 20 files changed, 786 insertions(+), 234 deletions(-) create mode 100644 src/plugins/pixelart/Settings.zig create mode 100644 src/sdk/ShellApi.zig diff --git a/HANDOFF.md b/HANDOFF.md index 6cf1799a..6380df46 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,4 +1,4 @@ -# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, after Stage B) +# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, mid Stage C) ## TL;DR @@ -7,7 +7,13 @@ makes `core` a real, separately-wired Zig module with no dependency on the `fizz app hub, then (Stages B–E) lifts the pixel-art editor fully behind the plugin SDK so it can become its own compile-time module. -**Done:** Stage A1, A2, A3, B. **Next:** Stage C (then D, E). +**Done:** Stage A1, A2, A3, B, and **Stage C part 1 (per-plugin settings)**. +**Next:** Stage C remainder (doc/tab/host/arena/folder decoupling) + the sprite/atlas → +`core` extraction. Then D, E. + +> **Read this first if you're a fresh agent:** the immediately actionable work is in +> "Stage C — remaining work" and "Next big rock: sprite/atlas → core" near the bottom. +> Several items there are now low-effort because the SDK surface they need already exists. ## What Stage B did @@ -40,6 +46,87 @@ Lifted the pixel-art editor state off the shell `Editor` into a plugin-owned Verified green: `zig build`, `zig build check-web`, `zig build test`. (No live GUI run — pure refactor.) +## What Stage C part 1 did — per-plugin settings (VSCode-style) + +Goal (set by the user): pixel-art-specific settings should **belong to the pixel-art +plugin**, and the Settings tab should be a registry that each plugin contributes its own +section to, grouped by plugin. The shell stores plugin settings opaquely but never +interprets them. + +### New SDK surface (all in `src/sdk/`) + +- **`SettingsSection`** (`regions.zig`, exported from `sdk.zig`): `{ id, owner, title, draw }`. + The Settings sidebar view renders each registered section under its `title` heading. +- **`Host` additions** (`Host.zig`): + - `settings_sections` registry + `registerSettingsSection`. + - `plugin_settings: PluginSettings` (= `StringArrayHashMapUnmanaged([]const u8)`): the + opaque per-plugin blob store (id → serialized JSON). `loadPluginSettings(id)` / + `storePluginSettings(id, json)` (the latter dupes + marks shell settings dirty). Host + owns + frees the key/value strings in `deinit`. + - `shell_api: ?ShellApi` + `installShell(api)` + thin forwarders: `arena()`, `folder()`, + `paletteFolder()`, `markSettingsDirty()`, `contentOpacity()`. +- **`ShellApi`** (`ShellApi.zig`): vtable + ctx the shell installs so plugins reach shared + shell state without importing `Editor`. The shell's vtable impl lives in `Editor.zig` + (`shell_api_vtable` + `shellArena`/`shellFolder`/… ; ctx is `*Editor`), installed in + `Editor.postInit`. + +### Storage / persistence (`src/editor/Settings.zig`) + +- On-disk format gained a `"plugins"` object: `{ , "plugins": { id: } }`. +- `Settings.serialize(settings, plugin_store, alloc)` serializes the struct, drops the + trailing `}`, and **textually splices** `,"plugins":{…}}` with each plugin's already- + serialized blob inline. (Robust — avoids `std.json.Value` lifetime hazards. Round-trip + validated with a standalone test: valid JSON, shell parses back via `ignore_unknown_fields`, + blobs re-extract cleanly.) +- `Settings.save(...)` and the autosave **dedup snapshot** (`settings_last_saved_json`) and + the three Editor save sites all now go through `serialize` so plugin-only changes still + trigger a write. (Watch: `Settings.save` is called from `saveSettingsGuarded`, + `saveSettingsRaw`, and the init snapshot — all four-arg now.) +- `Settings.loadPluginStore(alloc, path, store)` re-parses settings.json as a `Value`, + extracts the `"plugins"` object into the store. Called from `Editor.init` right after + `Settings.load`, before `PixelArt.init` runs (so the plugin can read its blob). +- **One-time migration:** a legacy *flat* settings.json (no `"plugins"`) seeds the + `"pixelart"` blob from the **whole root** — pixel art ignores unknown keys, so its moved + fields (`show_rulers`, `input_scheme`, …) survive; the next save rewrites the blob clean. + (Self-healing, no data loss. The blob is temporarily bloated with shell keys until then.) + +### Pixel-art side + +- New **`src/plugins/pixelart/Settings.zig`** (`PixelArt.Settings`, `pub`): owns the moved + fields + `InputScheme`/`ResolvedPanZoomScheme`/`TransparencyEffect` enums + + `resolvedPanZoomScheme`. `load(host)` parses its blob (defaults if absent/garbage; no + heap fields so returning by value after `parsed.deinit()` is safe). `save(host)` + serializes + `host.storePluginSettings`. `draw(_)` renders the section (Canvas group: + transparency effect, show rulers, cover-flow cards; Controls group: control scheme). +- `PixelArt` struct gained `host: *sdk.Host` and `settings: Settings`, both set in + `PixelArt.init(allocator, host)` (App now passes `&fizzy.editor.host`). +- `plugin.register` registers the `"pixelart"` settings section ("Pixel Art"). + +### Fields moved off shell `Settings` → `PixelArt.Settings` + +`input_scheme`, `show_rulers`, `scrolling_cards`, `ruler_padding`, `zoom_sensitivity`, +`zoom_steps`, `max_file_size`, `checker_color_even/odd`, `transparency_effect` (+ the +three enums + `resolvedPanZoomScheme`). All ~27 pixel-art read sites repointed to +`fizzy.pixelart.settings.`; type refs (`fizzy.Editor.Settings.TransparencyEffect`, +`…resolvedPanZoomScheme`) → `fizzy.PixelArt.Settings.…`. + +**`content_opacity` deliberately stays on the shell** — it's also read by `workbench/ +Workspace.zig` and `panel/Panel.zig`, so it's genuinely shell-level. Pixel art's 3 reads +go through `fizzy.pixelart.host.contentOpacity()` (the ShellApi). The pixel-art settings +*UI controls* were removed from `editor/explorer/settings.zig` (the shell "Editor" section +now only has theme/fonts/window+content opacity/hold-timing/debugging). + +### Settings UI + +`Editor.drawSettingsPane` now iterates `host.settings_sections` and renders each under a +heading label (registration order = display order; shell "Editor" registered first in +`postInit`, before plugins). The shell section draw = `Explorer.settings.draw` (trimmed); +the pixel-art section draw = `PixelArt.Settings.draw`. + +Verified green: `zig build`, `zig build check-web`, `zig build test`. Persistence splice +round-trip checked with a throwaway `zig run` harness (valid JSON + clean extraction). No +live GUI run. + All three build configs are green right now: ``` @@ -131,11 +218,10 @@ off `src/editor/Editor.zig` (~83 refs) into a `PixelArt` plugin-state struct own plugin. Update `Editor.zig`, `Keybinds` (~15 refs), and the `Menu`, plus the pixel-art references that read those fields. Build green (all 3). -### Stage C — expand the SDK Host + a `workbench` service vtable -Grow `src/sdk/sdk.zig` Host surface to cover the ~110-ref shell surface the plugin still -needs: arena access, settings, folder access, doc/tab access, command registration. Then -replace remaining pixel-art `fizzy.editor` / `fizzy.backend` / `fizzy.platform` calls with -SDK calls. Build green. +### Stage C — expand the SDK Host (settings done; rest below) +Grow the SDK Host surface so the plugin reaches shell state via the SDK, not +`fizzy.editor`. **Part 1 (per-plugin settings) is done** — see "What Stage C part 1 did" +above. The remaining coupling and a recommended order are in "Stage C — remaining work". ### Stage D — make `pixelart` its own module Add a `src/plugins/pixelart/pixelart.zig` module root; repoint all pixel-art imports from @@ -149,12 +235,109 @@ contributions through the SDK only. Final verification across the 3 configs. --- +## Stage C — remaining work (start here) + +Settings is fully decoupled (`grep -r 'fizzy.editor.settings' src/plugins/pixelart` → 0). +Here is the **current** `fizzy.editor.*` / `fizzy.backend.*` / `fizzy.platform.*` surface +still in `src/plugins/pixelart/**` (run the greps to refresh): + +``` +33 fizzy.editor.activeFile 11 fizzy.editor.open_files 6 fizzy.editor.newFileID +31 fizzy.editor.atlas 11 fizzy.editor.host 6 fizzy.editor.folder +17 fizzy.editor.explorer 10 fizzy.editor.arena 2 fizzy.editor.palette_folder ++ doc/save-flow tail: setActiveFile, getFile, getFileFromPath, newFile, open_file_index, + requestCompositeWarmup, startPackProject, isPackingActive, requestSaveAs, + requestWebSaveDialog, requestGridLayoutDialog, cancelPendingSaveDialog, abortSaveAllQuit, + copy/paste/accept/cancel, save, transform, buffers, panel, allocNextUntitledPath, + pending_*/quit_* (all 1–3 refs each) +backend: showSaveFileDialog ×5, DialogFileFilter ×4, isMaximized ×3 ; platform: isMacOS ×3 +``` + +**Recommended order (easy → hard):** + +1. **`host` (11) — trivial now.** `PixelArt` already holds `host: *sdk.Host` (set in + `init`). Repoint `fizzy.editor.host.setActiveSidebarView/isActiveSidebarView` → + `fizzy.pixelart.host.…`. Pure mechanical, no SDK change. +2. **`arena` (10), `folder` (6), `palette_folder` (2) — done-for-you.** The ShellApi + forwarders already exist: `fizzy.pixelart.host.arena()` / `.folder()` / + `.paletteFolder()`. Repoint `fizzy.editor.arena.allocator()` → `fizzy.pixelart.host.arena()`, + etc. (mind that `arena` callers use `.allocator()`; the forwarder already returns the + `Allocator`). +3. **`backend.isMaximized` (3), `platform.isMacOS` (3).** Add `isMaximized()` to ShellApi + (shell calls `fizzy.backend.isMaximized(dvui.currentWindow())`). `isMacOS` is just + `core.platform.isMacOS()` — pixel art can call `fizzy.platform.isMacOS()` until Stage D + repoints it to `core` directly; low priority. +4. **`explorer` (17).** These read pixel-art state that *lives on the shell `Explorer`* + (`explorer.tools`, `.sprites`, `.pinned_palettes`, `.layers_ratio`, `.rect`, + `.scroll_info`). `tools`/`sprites` are pixel-art pane modules; `pinned_palettes`/ + `layers_ratio` are pixel-art UI state. These should **move onto `PixelArt`** (like the + settings did), not get an SDK accessor. `rect`/`scroll_info` are shell explorer layout — + expose via ShellApi or pass into the draw. +5. **Native save dialogs (`backend.showSaveFileDialog` ×5, `DialogFileFilter` ×4).** Add a + small SDK surface for "ask the host to run a native save dialog" (native-only; web has + its own path). The save-flow tail (`requestSaveAs`, `pending_*`, `quit_*`, `accept`, + `cancel`, `abortSaveAllQuit`, …) is the shell's save/quit orchestration the pixel-art + dialogs poke — needs a deliberate "document save service" vtable, the hardest part. +6. **Docs/tabs (`activeFile` ×33, `open_files` ×11, `setActiveFile`, `getFile*`, `newFile*`, + `open_file_index`, `buffers`, `transform`, `copy/paste`, `requestCompositeWarmup`, + `startPackProject`, `isPackingActive`).** This is the **deep coupling**: the shell's + `open_files` is literally `AutoArrayHashMapUnmanaged(u64, Internal.File)` — a map of + *pixel-art* `Internal.File` values. The shell currently owns and iterates pixel-art docs + directly. Fully decoupling means the shell stores **opaque documents (`DocHandle`)** and + the pixel-art plugin owns the `Internal.File` storage. That is a large structural change + (touches the workspace/tab/save systems) — likely its own stage. Until then, pixel-art + can reach the active doc through a `host.activeDoc() ?DocHandle` + cast, but the storage + inversion is the real work. + +`atlas` (31) is handled by the sprite/atlas → core extraction below, not by an SDK accessor. + +## Next big rock: sprite / atlas → `core` + +This resolves the `editor.atlas` (Stage B) and `fizzy.editor.atlas` (×31) coupling and is +the prerequisite for the shell not depending on the pixel-art plugin for its own UI icons. + +**Findings (verified in code):** + +- The shell (`workbench`) only calls `fizzy.sprite_render.sprite(...)` in two places — + `workbench/files.zig:~774` and `workbench/Workspace.zig:~300` — both drawing a **static + atlas sprite** (the logo / UI icons), passing `file = null`. It never uses the heavy path. +- But `src/plugins/pixelart/sprite_render.zig` lives in the plugin and is tangled: the same + `sprite()` also does layer compositing, file previews, reflections, and `water_surface` + (all need a full pixel-art `Internal.File`). So today the shell reaches *backwards* into + the plugin just to draw an icon. `editor.atlas` is typed `Internal.Atlas` (pixel art's). + +**Plan:** split by responsibility with `core` as the shared floor. + +- → **`core`:** a generic atlas data type + a "draw sprite N (sub-rect of a texture)" + primitive (the slice the shell's logo/icons need; essentially `dvui.renderImage` + sprite + rect math). The shell's `editor.atlas` becomes a `core` atlas type drawn via the `core` + helper, depending on `core` not the plugin. +- → **stays in pixel-art plugin:** `renderSprite` / `render.renderLayers` / composites / + reflections / `water_surface` — all the editing rendering on top of the primitive. + +End-state dependency graph: **shell → core**, **plugin → core**, neither depends on the +other. (User has signed off on this direction; sequenced *after* settings.) + +--- + ## State of the tree -Uncommitted. Stage A3 touched: `build.zig`, `src/App.zig`, `src/fizzy.zig`, -`src/web_main.zig`, `src/editor/Editor.zig`, the moved `src/core/**` files, and the -pixel-art/workbench consumers (`Atlas.zig`, `CanvasData.zig`, `PackJob.zig`, -`FileLoadJob.zig`, `files.zig`, `plugin.zig`, `dialogs/GridLayout.zig`, -`widgets/{CanvasBridge,FileWidget,ImageWidget}.zig`). Deleted: `editor/widgets/Widgets.zig`, -`tools/timer.zig`, `core/gfx/gfx.zig` (empty), `core/font_awesome.zig` (unused — `fa` -re-exports removed from `core.zig`/`fizzy.zig` and the web probe). Nothing has been committed. +**Uncommitted** (nothing in this whole Phase-4 effort has been committed — commit on +request). Beyond the Stage A3 changes, the working tree now also has: + +- **Stage B:** new `src/plugins/pixelart/PixelArt.zig`; `fizzy.pixelart` global in + `fizzy.zig`; init/deinit wiring in `App.zig`; field removals + ~190 repoints in + `Editor.zig`, `Keybinds.zig`, `workbench/files.zig`, and the pixel-art tree. +- **Stage C part 1 (settings):** new `src/sdk/ShellApi.zig`, + `src/plugins/pixelart/Settings.zig`; `SettingsSection` in `sdk/regions.zig` + `sdk.zig`; + Host store/forwarders/section-registry in `sdk/Host.zig`; persistence rework in + `editor/Settings.zig`; ShellApi impl + section iteration in `editor/Editor.zig`; trimmed + `editor/explorer/settings.zig`; settings repoints across the pixel-art tree; + `App.zig` passes the host to `PixelArt.init`. + +Sanity greps for the next agent: +- `grep -rn 'fizzy.editor.settings' src/plugins/pixelart` → **0** (settings decoupled). +- `grep -rhoE 'fizzy\.editor\.[a-zA-Z_]+' src/plugins/pixelart | sort | uniq -c | sort -rn` + → the remaining Stage C surface (see "Stage C — remaining work"). + +All three configs green: `zig build`, `zig build check-web`, `zig build test`. diff --git a/src/App.zig b/src/App.zig index 7ca4e3d5..40f13a89 100644 --- a/src/App.zig +++ b/src/App.zig @@ -169,7 +169,7 @@ pub fn AppInit(win: *dvui.Window) !void { // before `postInit` so the pixel-art plugin's `register` can adopt it as its // `state`. Owned here for the app's lifetime; torn down in `AppDeinit`. fizzy.pixelart = try allocator.create(fizzy.PixelArt); - fizzy.pixelart.* = fizzy.PixelArt.init(allocator) catch unreachable; + fizzy.pixelart.* = fizzy.PixelArt.init(allocator, &fizzy.editor.host) catch unreachable; // Second-stage init that needs the editor at its final heap address (e.g. // registering the workbench-api service whose `ctx` is this pointer). diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index d3ad7df0..a77b4de4 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -260,7 +260,14 @@ pub fn init( try editor.workbench.registerBuiltins(); - editor.settings = try Settings.load(app.allocator, try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" })); + { + const settings_path = try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" }); + editor.settings = try Settings.load(app.allocator, settings_path); + // Load the opaque per-plugin settings blobs into the Host so plugins (created + // right after this `Editor.init` returns) can read their own settings. Runs a + // one-time migration of legacy flat settings; see `Settings.loadPluginStore`. + Settings.loadPluginStore(app.allocator, settings_path, &editor.host.plugin_settings); + } // Start the long-lived save-queue worker. All .fiz async saves get // serialized through this single thread (see `File.SaveQueue`); concurrent @@ -437,8 +444,8 @@ pub fn init( try Keybinds.register(); - // Collect the initial settings json - editor.settings_last_saved_json = try std.json.Stringify.valueAlloc(fizzy.app.allocator, &editor.settings, .{}); + // Collect the initial settings json (shell fields + per-plugin blobs) for autosave dedup. + editor.settings_last_saved_json = try Settings.serialize(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator); return editor; } @@ -453,6 +460,20 @@ pub fn init( pub const view_settings = "shell.settings"; pub fn postInit(editor: *Editor) !void { + // Install the shell's read/utility surface so plugins reach shared shell state + // (per-frame arena, project folder, content opacity, settings dirty-mark) through + // the Host instead of importing the concrete Editor. + editor.host.installShell(.{ .ctx = editor, .vtable = &shell_api_vtable }); + + // The shell's own settings section, registered first so "Editor" leads the list; + // plugins append theirs in their `register` (the Settings view renders each grouped + // by owner, VSCode-style). + try editor.host.registerSettingsSection(.{ + .id = "shell.settings.editor", + .title = "Editor", + .draw = drawShellSettingsSection, + }); + // Register plugin contributions (sidebar/bottom/center/menus). These are the // near-empty shell's content: it iterates the Host registries rather than // hardcoding panes. Web-safe — the draw fns reach the same inline code the @@ -495,10 +516,63 @@ pub fn postInit(editor: *Editor) !void { } } +/// The Settings sidebar view: render every registered settings section under its title +/// heading, grouped by owner (VSCode-style). The shell registers its own "Editor" +/// section; plugins add theirs. fn drawSettingsPane(_: ?*anyopaque) anyerror!void { + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer vbox.deinit(); + + for (fizzy.editor.host.settings_sections.items, 0..) |*section, i| { + var sbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .id_extra = i }); + defer sbox.deinit(); + + dvui.labelNoFmt(@src(), section.title, .{}, .{ + .font = dvui.Font.theme(.heading), + .margin = .{ .x = 2, .y = 6, .w = 2, .h = 2 }, + }); + try section.draw(section.ctx); + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 12 } }); + } +} + +/// Shell-owned settings controls (theme, fonts, window/content opacity, input timing, +/// debugging). Pixel-art-specific controls live in the pixel-art plugin's own section. +fn drawShellSettingsSection(_: ?*anyopaque) anyerror!void { try Explorer.settings.draw(); } +// ---- ShellApi: the shell-provided read/utility surface for plugins ---------- +// Installed on the Host in `postInit`; `ctx` is this `*Editor`. + +const shell_api_vtable: sdk.ShellApi.VTable = .{ + .arena = shellArena, + .folder = shellFolder, + .paletteFolder = shellPaletteFolder, + .markSettingsDirty = shellMarkSettingsDirty, + .contentOpacity = shellContentOpacity, +}; + +fn shellCtx(ctx: *anyopaque) *Editor { + return @ptrCast(@alignCast(ctx)); +} +fn shellArena(ctx: *anyopaque) std.mem.Allocator { + return shellCtx(ctx).arena.allocator(); +} +fn shellFolder(ctx: *anyopaque) ?[]const u8 { + return shellCtx(ctx).folder; +} +fn shellPaletteFolder(ctx: *anyopaque) ?[]const u8 { + return shellCtx(ctx).palette_folder; +} +fn shellMarkSettingsDirty(ctx: *anyopaque) void { + shellCtx(ctx).markSettingsDirty(); +} +fn shellContentOpacity(ctx: *anyopaque) f32 { + return shellCtx(ctx).settings.content_opacity; +} + /// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void { const themes_dir = try std.fs.path.join(gpa, &.{ editor.config_folder, "Themes" }); @@ -643,7 +717,7 @@ fn saveSettingsGuarded(editor: *Editor) !void { if (editor.activelyDrawing()) return; - const serialized = try std.json.Stringify.valueAlloc(fizzy.app.allocator, &editor.settings, .{}); + const serialized = try Settings.serialize(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator); defer fizzy.app.allocator.free(serialized); if (editor.settings_last_saved_json) |old| { @@ -656,7 +730,7 @@ fn saveSettingsGuarded(editor: *Editor) !void { const settings_path = try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "settings.json" }); defer fizzy.app.allocator.free(settings_path); - try Settings.save(&editor.settings, fizzy.app.allocator, settings_path); + try Settings.save(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator, settings_path); if (editor.settings_last_saved_json) |blob| { fizzy.app.allocator.free(blob); @@ -668,7 +742,7 @@ fn saveSettingsGuarded(editor: *Editor) !void { /// Flush to disk regardless of idle/drawing deferral — used during shutdown only. fn saveSettingsRaw(editor: *Editor) !void { - const serialized = try std.json.Stringify.valueAlloc(fizzy.app.allocator, &editor.settings, .{}); + const serialized = try Settings.serialize(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator); defer fizzy.app.allocator.free(serialized); const need_disk = blk: { @@ -682,7 +756,7 @@ fn saveSettingsRaw(editor: *Editor) !void { defer fizzy.app.allocator.free(settings_path); if (need_disk) - try Settings.save(&editor.settings, fizzy.app.allocator, settings_path); + try Settings.save(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator, settings_path); if (need_disk) { if (editor.settings_last_saved_json) |blob| { diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig index 83a012df..518de372 100644 --- a/src/editor/Settings.zig +++ b/src/editor/Settings.zig @@ -12,26 +12,9 @@ pub const autosave_timeout_ns: i128 = 500 * 1_000_000; pub var parsed: ?std.json.Parsed(Settings) = null; -pub const InputScheme = enum { auto, mouse, trackpad }; - -/// Resolved zoom/pan control style after applying `auto` (`dvui.getMouseTypeHint`). -pub const ResolvedPanZoomScheme = enum { - mouse, - trackpad, -}; pub const FlipbookView = enum { sequential, grid }; pub const Compatibility = enum { none, ldtk }; -/// How sprite-cell transparency (checkerboard) is tinted behind the canvas. -pub const TransparencyEffect = enum { - /// Uniform default tone only (no hue gradient). - none, - /// Mouse-smoothed corner gradient (current default). - rainbow, - /// Per-cell tone shifted toward the animation’s palette color (when the sprite belongs to an animation). - animation, -}; - /// The ratio of the explorer to the artboard. explorer_ratio: f32 = 0.35, @@ -42,38 +25,15 @@ min_window_size: [2]f32 = .{ 640, 480 }, initial_window_size: [2]f32 = .{ 1280, 720 }, -/// Zoom/pan control scheme (`auto` picks mouse vs trackpad gestures from `dvui.getMouseTypeHint` after scroll events). -input_scheme: InputScheme = .auto, - /// Touch or long-press duration (ms) before a context menu opens instead of a normal click. hold_menu_duration_ms: u32 = 500, -/// Whether or not to show rulers on each canvas. -show_rulers: bool = true, - -/// Sprites panel: when true, show side cards in the cover-flow strip; when false, -/// fly them away for single-card focus (snap scroll) -scrolling_cards: bool = true, - /// When true, print frame/draw perf stats to the console (Debug / ReleaseSafe only for tick stats). perf_logging: bool = false, /// Pretend an app update is available (badge + launch toast). Restart after toggling. debug_simulate_update_available: bool = false, -/// Padding to include in the size of the ruler outside of the font height. -ruler_padding: f32 = 4.0, - -/// Setting to control overall zoom sensitivity -/// 0 - 1 -zoom_sensitivity: f32 = 1.0, - -/// Predetermined zoom steps, each is pixel perfect. -zoom_steps: [23]f32 = [_]f32{ 0.125, 0.167, 0.2, 0.25, 0.333, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 18, 28, 38, 50, 70, 90, 128, 256, 512 }, - -/// Maximum file size -max_file_size: [2]i32 = .{ 4096, 4096 }, - /// Maximum number of recents before removing oldest max_recents: usize = 10, @@ -86,39 +46,19 @@ font_title_size: f32 = 9, font_heading_size: f32 = 8, font_mono_size: f32 = 10, -/// Color for the even squares of the checkerboard pattern -checker_color_even: [4]u8 = .{ 255, 255, 255, 255 }, -/// Color for the odd squares of the checkerboard pattern -checker_color_odd: [4]u8 = .{ 175, 175, 175, 255 }, - /// Opacity of the background window /// CURRENTLY ONLY SUPPORTED ON MACOS and Windows window_opacity_dark: f32 = 0.7, window_opacity_light: f32 = 0.3, -content_opacity: f32 = 0.7, -/// Checkerboard / transparency tint behind sprites (grid cells). -transparency_effect: TransparencyEffect = .none, +/// Opacity of the content area (also drives plugin panes that match the shell chrome). +content_opacity: f32 = 0.7, titlebar_height: f32 = 26.0, // This is the height of the titlebar in pixels /// Empty strip below the top window edge (non-macOS), above the main title row (in-window menu, etc.). titlebar_top_buffer: f32 = 10.0, -pub fn resolvedPanZoomScheme(settings: *const Settings) ResolvedPanZoomScheme { - return switch (settings.input_scheme) { - .auto => switch (dvui.mouseType()) { - // Use runtime platform detection so macOS web users get the trackpad - // default. `builtin.os.tag == .macos` is false on wasm32-freestanding. - .unknown => if (fizzy.platform.isMacOS()) .trackpad else .mouse, - .mouse => .mouse, - .trackpad => .trackpad, - }, - .mouse => .mouse, - .trackpad => .trackpad, - }; -} - fn default(allocator: std.mem.Allocator) !Settings { return .{ .theme = try allocator.dupe(u8, default_theme), @@ -133,6 +73,7 @@ pub fn setThemeName(settings: *Settings, allocator: std.mem.Allocator, name: []c } /// Loads settings (`theme` is always heap-owned after successful return — see `setThemeName` / `deinit`). +/// Unknown keys (e.g. the "plugins" object, parsed separately by `loadPluginStore`) are ignored. pub fn load(allocator: std.mem.Allocator, path: []const u8) !Settings { // Wasm: no on-disk config; `fizzy.fs.read` uses `Io.Dir.cwd()` (posix.AT). if (comptime builtin.target.cpu.arch == .wasm32) return default(allocator); @@ -157,13 +98,105 @@ pub fn load(allocator: std.mem.Allocator, path: []const u8) !Settings { return result; } -pub fn save(settings: *Settings, allocator: std.mem.Allocator, path: []const u8) !void { - const str = try std.json.Stringify.valueAlloc(allocator, settings, .{}); +/// Serialize the shell settings plus the opaque per-plugin store into a single +/// settings.json document: `{ , "plugins": { : , … } }`. The +/// plugin blobs are already-serialized JSON objects, spliced in verbatim — the shell +/// never interprets them. +pub fn serialize( + settings: *const Settings, + plugin_settings: *const std.StringArrayHashMapUnmanaged([]const u8), + allocator: std.mem.Allocator, +) ![]u8 { + const fields = try std.json.Stringify.valueAlloc(allocator, settings, .{}); + defer allocator.free(fields); + // `fields` is a `{…}` object with at least one member, so dropping the trailing + // brace and appending `,"plugins":{…}}` always yields valid JSON. + var out: std.ArrayListUnmanaged(u8) = .empty; + errdefer out.deinit(allocator); + try out.appendSlice(allocator, fields[0 .. fields.len - 1]); + try out.appendSlice(allocator, ",\"plugins\":{"); + var first = true; + var it = plugin_settings.iterator(); + while (it.next()) |e| { + if (!first) try out.append(allocator, ','); + first = false; + const key = try std.json.Stringify.valueAlloc(allocator, e.key_ptr.*, .{}); + defer allocator.free(key); + try out.appendSlice(allocator, key); + try out.append(allocator, ':'); + try out.appendSlice(allocator, e.value_ptr.*); + } + try out.appendSlice(allocator, "}}"); + return out.toOwnedSlice(allocator); +} + +pub fn save( + settings: *Settings, + plugin_settings: *const std.StringArrayHashMapUnmanaged([]const u8), + allocator: std.mem.Allocator, + path: []const u8, +) !void { + const str = try serialize(settings, plugin_settings, allocator); defer allocator.free(str); try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = str }); } +/// Populate `store` (id -> owned JSON blob) from the "plugins" object in settings.json. +/// One-time migration: a legacy flat settings.json (no "plugins" object) seeds the +/// pixel-art blob from the whole root so its moved fields (show_rulers, input_scheme, …) +/// survive the format change — pixel art ignores unknown keys, and the next save rewrites +/// the blob cleanly. +pub fn loadPluginStore( + allocator: std.mem.Allocator, + path: []const u8, + store: *std.StringArrayHashMapUnmanaged([]const u8), +) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const data = fizzy.fs.read(allocator, dvui.io, path) catch return; + defer allocator.free(data); + + var parsed_v = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return; + defer parsed_v.deinit(); + + const root = switch (parsed_v.value) { + .object => |o| o, + else => return, + }; + + if (root.get("plugins")) |plugins_val| { + switch (plugins_val) { + .object => |plugins| { + var it = plugins.iterator(); + while (it.next()) |e| { + const blob = std.json.Stringify.valueAlloc(allocator, e.value_ptr.*, .{}) catch continue; + const key = allocator.dupe(u8, e.key_ptr.*) catch { + allocator.free(blob); + continue; + }; + store.put(allocator, key, blob) catch { + allocator.free(key); + allocator.free(blob); + }; + } + return; + }, + else => {}, + } + } + + // Legacy flat settings.json: seed the pixel-art blob from the whole root. + const legacy_blob = std.json.Stringify.valueAlloc(allocator, parsed_v.value, .{}) catch return; + const key = allocator.dupe(u8, "pixelart") catch { + allocator.free(legacy_blob); + return; + }; + store.put(allocator, key, legacy_blob) catch { + allocator.free(key); + allocator.free(legacy_blob); + }; +} + pub fn deinit(settings: *Settings, allocator: std.mem.Allocator) void { allocator.free(settings.theme); defer parsed = null; diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 8b7aba09..5141acf5 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -148,68 +148,6 @@ pub fn draw() !void { dvui.refresh(null, @src(), vbox.data().id); } - { - var dropdown: dvui.DropdownWidget = undefined; - dropdown.init(@src(), .{ .label = "Transparency effect" }, .{ - .expand = .horizontal, - .corner_radius = dvui.Rect.all(1000), - }); - defer dropdown.deinit(); - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .vertical, - .gravity_x = 1.0, - }); - - const label_text = switch (fizzy.editor.settings.transparency_effect) { - .none => "None", - .rainbow => "Rainbow", - .animation => "Animation", - }; - dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); - - dvui.icon( - @src(), - "dropdown_triangle", - dvui.entypo.triangle_down, - .{}, - .{ .gravity_y = 0.5 }, - ); - - hbox.deinit(); - - if (dropdown.dropped()) { - if (dropdown.addChoiceLabel("None")) { - fizzy.editor.settings.transparency_effect = .none; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - if (dropdown.addChoiceLabel("Rainbow")) { - fizzy.editor.settings.transparency_effect = .rainbow; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - if (dropdown.addChoiceLabel("Animation")) { - fizzy.editor.settings.transparency_effect = .animation; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - } - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); - } - - if (dvui.checkbox(@src(), &fizzy.editor.settings.show_rulers, "Show Rulers", .{ - .expand = .none, - })) { - fizzy.editor.markSettingsDirty(); - } - - if (dvui.checkbox(@src(), &fizzy.editor.settings.scrolling_cards, "Show sprite cover-flow cards", .{ - .expand = .none, - })) { - fizzy.editor.markSettingsDirty(); - } } { @@ -218,62 +156,6 @@ pub fn draw() !void { }); defer box.deinit(); - { - var dropdown: dvui.DropdownWidget = undefined; - dropdown.init(@src(), .{ .label = "Control scheme" }, .{ - .expand = .horizontal, - .corner_radius = dvui.Rect.all(1000), - }); - defer dropdown.deinit(); - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .vertical, - .gravity_x = 1.0, - }); - - const label_text: []const u8 = switch (fizzy.editor.settings.input_scheme) { - .auto => switch (dvui.mouseType()) { - // Pre-classification (no scroll events seen yet) — drop the parenthetical - // entirely rather than showing "Auto (unknown)". - .unknown => "Auto", - .mouse, .trackpad => |hint| try std.fmt.allocPrint(dvui.currentWindow().lifo(), "Auto ({s})", .{@tagName(hint)}), - }, - .mouse => "Mouse", - .trackpad => "Trackpad", - }; - dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); - - dvui.icon( - @src(), - "dropdown_triangle", - dvui.entypo.triangle_down, - .{}, - .{ .gravity_y = 0.5 }, - ); - - hbox.deinit(); - - if (dropdown.dropped()) { - if (dropdown.addChoiceLabel("Auto")) { - fizzy.editor.settings.input_scheme = .auto; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - if (dropdown.addChoiceLabel("Mouse")) { - fizzy.editor.settings.input_scheme = .mouse; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - if (dropdown.addChoiceLabel("Trackpad")) { - fizzy.editor.settings.input_scheme = .trackpad; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - } - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); - } - var hold_menu_ms: f32 = @floatFromInt(fizzy.editor.settings.hold_menu_duration_ms); if (dvui.sliderEntry(@src(), "Context menu hold: {d:0.0} ms", .{ .value = &hold_menu_ms, diff --git a/src/plugins/pixelart/CanvasData.zig b/src/plugins/pixelart/CanvasData.zig index 027cddf4..868bb422 100644 --- a/src/plugins/pixelart/CanvasData.zig +++ b/src/plugins/pixelart/CanvasData.zig @@ -99,15 +99,15 @@ pub fn drawRuler(self: *CanvasData, file: *File, orientation: RulerOrientation) const largest_label_size = font.textSize(largest_label); const natural_scale = dvui.currentWindow().natural_scale; const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical); - const base_ruler_size = largest_label_size.w + fizzy.editor.settings.ruler_padding; + const base_ruler_size = largest_label_size.w + fizzy.pixelart.settings.ruler_padding; const ruler_thickness: f32 = switch (orientation) { .horizontal => blk: { - self.horizontal_ruler_height = font.textSize("M").h + fizzy.editor.settings.ruler_padding; + self.horizontal_ruler_height = font.textSize("M").h + fizzy.pixelart.settings.ruler_padding; break :blk self.horizontal_ruler_height; }, .vertical => blk: { - self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.editor.settings.ruler_padding); + self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.pixelart.settings.ruler_padding); break :blk self.vertical_ruler_width; }, }; @@ -591,7 +591,7 @@ pub fn drawRulerLabel(_: *CanvasData, options: TextLabelOptions) void { else font.textSize(label).scale(natural, dvui.Size.Physical); - const padding = fizzy.editor.settings.ruler_padding * natural; + const padding = fizzy.pixelart.settings.ruler_padding * natural; var label_rect = rect; @@ -864,8 +864,8 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { // shrinks, and once it's narrower than the pill we bail and draw nothing this frame — // so closing splits cleanly hides the menu. const wb = container.rectScale().r.toNatural(); - const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; - const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; + const ruler_top: f32 = if (fizzy.pixelart.settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (fizzy.pixelart.settings.show_rulers) self.vertical_ruler_width else 0; const canvas_nat = dvui.Rect{ .x = wb.x + ruler_left, .y = wb.y + ruler_top, @@ -1102,8 +1102,8 @@ pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void { // Anchor against the same canvas-scroll-area rect the pill uses. const wb = container.rectScale().r.toNatural(); - const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; - const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; + const ruler_top: f32 = if (fizzy.pixelart.settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (fizzy.pixelart.settings.show_rulers) self.vertical_ruler_width else 0; const canvas_nat = dvui.Rect{ .x = wb.x + ruler_left, .y = wb.y + ruler_top, diff --git a/src/plugins/pixelart/PixelArt.zig b/src/plugins/pixelart/PixelArt.zig index 6f411f11..d106037d 100644 --- a/src/plugins/pixelart/PixelArt.zig +++ b/src/plugins/pixelart/PixelArt.zig @@ -14,10 +14,12 @@ const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const assets = @import("assets"); +const sdk = fizzy.sdk; const Colors = @import("Colors.zig"); const Project = @import("Project.zig"); const Tools = @import("Tools.zig"); const PackJob = @import("PackJob.zig"); +pub const Settings = @import("Settings.zig"); const PixelArt = @This(); @@ -27,6 +29,12 @@ pub const SpriteClipboard = struct { offset: dvui.Point, }; +/// The shell host (service locator + per-plugin settings store). Set in `init`. +host: *sdk.Host, + +/// Pixel-art editing preferences, loaded from the host's per-plugin settings store. +settings: Settings = .{}, + tools: Tools, colors: Colors = .{}, @@ -42,8 +50,10 @@ sprite_clipboard: ?SpriteClipboard = null, /// most recent request produces a visible atlas update. pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty, -pub fn init(allocator: std.mem.Allocator) !PixelArt { +pub fn init(allocator: std.mem.Allocator, host: *sdk.Host) !PixelArt { var pa: PixelArt = .{ + .host = host, + .settings = Settings.load(host), .tools = try .init(allocator), }; pa.colors.file_tree_palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; diff --git a/src/plugins/pixelart/Settings.zig b/src/plugins/pixelart/Settings.zig new file mode 100644 index 00000000..260b4eb7 --- /dev/null +++ b/src/plugins/pixelart/Settings.zig @@ -0,0 +1,213 @@ +//! Pixel-art plugin settings: the canvas / sprite-editing preferences formerly stored +//! as top-level fields on the shell `Settings`. Persisted via the shell's per-plugin +//! settings store (the `Host`), keyed by the plugin id, as an opaque JSON blob the shell +//! never interprets. +const std = @import("std"); +const builtin = @import("builtin"); +const fizzy = @import("../../fizzy.zig"); +const dvui = @import("dvui"); +const sdk = fizzy.sdk; + +const PixelArtSettings = @This(); + +/// Per-plugin settings store key (matches `plugin.id`). +pub const plugin_id = "pixelart"; + +pub const InputScheme = enum { auto, mouse, trackpad }; + +/// Resolved zoom/pan control style after applying `auto` (`dvui.mouseType`). +pub const ResolvedPanZoomScheme = enum { mouse, trackpad }; + +/// How sprite-cell transparency (checkerboard) is tinted behind the canvas. +pub const TransparencyEffect = enum { + /// Uniform default tone only (no hue gradient). + none, + /// Mouse-smoothed corner gradient. + rainbow, + /// Per-cell tone shifted toward the animation's palette color. + animation, +}; + +/// Zoom/pan control scheme (`auto` picks mouse vs trackpad from `dvui.mouseType()` after scroll events). +input_scheme: InputScheme = .auto, + +/// Whether or not to show rulers on each canvas. +show_rulers: bool = true, + +/// Sprites panel: when true, show side cards in the cover-flow strip; when false, +/// fly them away for single-card focus (snap scroll). +scrolling_cards: bool = true, + +/// Padding to include in the size of the ruler outside of the font height. +ruler_padding: f32 = 4.0, + +/// Overall zoom sensitivity (0 - 1). +zoom_sensitivity: f32 = 1.0, + +/// Predetermined zoom steps, each pixel perfect. +zoom_steps: [23]f32 = [_]f32{ 0.125, 0.167, 0.2, 0.25, 0.333, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 18, 28, 38, 50, 70, 90, 128, 256, 512 }, + +/// Maximum file size. +max_file_size: [2]i32 = .{ 4096, 4096 }, + +/// Color for the even squares of the checkerboard pattern. +checker_color_even: [4]u8 = .{ 255, 255, 255, 255 }, +/// Color for the odd squares of the checkerboard pattern. +checker_color_odd: [4]u8 = .{ 175, 175, 175, 255 }, + +/// Checkerboard / transparency tint behind sprites (grid cells). +transparency_effect: TransparencyEffect = .none, + +pub fn resolvedPanZoomScheme(settings: *const PixelArtSettings) ResolvedPanZoomScheme { + return switch (settings.input_scheme) { + .auto => switch (dvui.mouseType()) { + // Runtime platform detection so macOS web users get the trackpad default + // (`builtin.os.tag == .macos` is false on wasm32-freestanding). + .unknown => if (fizzy.platform.isMacOS()) .trackpad else .mouse, + .mouse => .mouse, + .trackpad => .trackpad, + }, + .mouse => .mouse, + .trackpad => .trackpad, + }; +} + +/// Load from the host's per-plugin store, or defaults if absent/unparsable. Unknown keys +/// are ignored, so the one-time legacy-migration blob (which still carries shell fields) +/// parses fine — only the pixel-art fields are picked up. +pub fn load(host: *sdk.Host) PixelArtSettings { + const blob = host.loadPluginSettings(plugin_id) orelse return .{}; + const parsed = std.json.parseFromSlice(PixelArtSettings, host.allocator, blob, .{ + .ignore_unknown_fields = true, + }) catch return .{}; + defer parsed.deinit(); + // PixelArtSettings has no heap-owned fields (all values/arrays/enums), so the parsed + // value is safe to return after freeing the parse arena. + return parsed.value; +} + +/// Serialize and persist to the host store (marks shell settings dirty for autosave). +pub fn save(settings: *const PixelArtSettings, host: *sdk.Host) void { + const json = std.json.Stringify.valueAlloc(host.allocator, settings, .{}) catch return; + defer host.allocator.free(json); + host.storePluginSettings(plugin_id, json) catch {}; +} + +/// The plugin's Settings section body (registered as a `SettingsSection`). Renders the +/// canvas / control prefs and persists on change. +pub fn draw(_: ?*anyopaque) !void { + const pa = fizzy.pixelart; + + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer vbox.deinit(); + + { + var box = dvui.groupBox(@src(), "Canvas", .{ .expand = .horizontal }); + defer box.deinit(); + + { + var dropdown: dvui.DropdownWidget = undefined; + dropdown.init(@src(), .{ .label = "Transparency effect" }, .{ + .expand = .horizontal, + .corner_radius = dvui.Rect.all(1000), + }); + defer dropdown.deinit(); + + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .vertical, + .gravity_x = 1.0, + }); + + const label_text = switch (pa.settings.transparency_effect) { + .none => "None", + .rainbow => "Rainbow", + .animation => "Animation", + }; + dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); + + dvui.icon(@src(), "dropdown_triangle", dvui.entypo.triangle_down, .{}, .{ .gravity_y = 0.5 }); + + hbox.deinit(); + + if (dropdown.dropped()) { + if (dropdown.addChoiceLabel("None")) { + pa.settings.transparency_effect = .none; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + if (dropdown.addChoiceLabel("Rainbow")) { + pa.settings.transparency_effect = .rainbow; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + if (dropdown.addChoiceLabel("Animation")) { + pa.settings.transparency_effect = .animation; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + } + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); + } + + if (dvui.checkbox(@src(), &pa.settings.show_rulers, "Show Rulers", .{ .expand = .none })) { + pa.settings.save(pa.host); + } + + if (dvui.checkbox(@src(), &pa.settings.scrolling_cards, "Show sprite cover-flow cards", .{ .expand = .none })) { + pa.settings.save(pa.host); + } + } + + { + var box = dvui.groupBox(@src(), "Controls", .{ .expand = .horizontal }); + defer box.deinit(); + + var dropdown: dvui.DropdownWidget = undefined; + dropdown.init(@src(), .{ .label = "Control scheme" }, .{ + .expand = .horizontal, + .corner_radius = dvui.Rect.all(1000), + }); + defer dropdown.deinit(); + + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .vertical, + .gravity_x = 1.0, + }); + + const label_text: []const u8 = switch (pa.settings.input_scheme) { + .auto => switch (dvui.mouseType()) { + // Pre-classification (no scroll events seen yet) — drop the parenthetical. + .unknown => "Auto", + .mouse, .trackpad => |hint| try std.fmt.allocPrint(dvui.currentWindow().lifo(), "Auto ({s})", .{@tagName(hint)}), + }, + .mouse => "Mouse", + .trackpad => "Trackpad", + }; + dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); + + dvui.icon(@src(), "dropdown_triangle", dvui.entypo.triangle_down, .{}, .{ .gravity_y = 0.5 }); + + hbox.deinit(); + + if (dropdown.dropped()) { + if (dropdown.addChoiceLabel("Auto")) { + pa.settings.input_scheme = .auto; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + if (dropdown.addChoiceLabel("Mouse")) { + pa.settings.input_scheme = .mouse; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + if (dropdown.addChoiceLabel("Trackpad")) { + pa.settings.input_scheme = .trackpad; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + } + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); + } +} diff --git a/src/plugins/pixelart/dialogs/Export.zig b/src/plugins/pixelart/dialogs/Export.zig index bce96316..d05e66ab 100644 --- a/src/plugins/pixelart/dialogs/Export.zig +++ b/src/plugins/pixelart/dialogs/Export.zig @@ -536,7 +536,7 @@ fn exportCheckerboardCellCornerColor( u: f32, v: f32, ) dvui.Color { - switch (fizzy.editor.settings.transparency_effect) { + switch (fizzy.pixelart.settings.transparency_effect) { .none => return pal.tone, .rainbow => return exportCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u, v, 0.5, 0.5, pal.tone), .animation => { diff --git a/src/plugins/pixelart/dialogs/GridLayout.zig b/src/plugins/pixelart/dialogs/GridLayout.zig index 8dd50c7d..c56eac5c 100644 --- a/src/plugins/pixelart/dialogs/GridLayout.zig +++ b/src/plugins/pixelart/dialogs/GridLayout.zig @@ -128,7 +128,7 @@ fn workspaceCanvasChromeColor() dvui.Color { switch (builtin.os.tag) { .macos, .windows => { content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) - content_color.opacity(fizzy.editor.settings.content_opacity) + content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; }, @@ -222,7 +222,7 @@ fn drawCheckerboardPreviewTiled( if (cell_w <= 0 or cell_h <= 0 or cols == 0 or rows == 0) return; const pal = previewCheckerboardPalette(); - const te = fizzy.editor.settings.transparency_effect; + const te = fizzy.pixelart.settings.transparency_effect; const cols_f = @max(@as(f32, @floatFromInt(cols)), 1.0); const rows_f = @max(@as(f32, @floatFromInt(rows)), 1.0); const nw = cell_w * cols_f; diff --git a/src/plugins/pixelart/internal/Atlas.zig b/src/plugins/pixelart/internal/Atlas.zig index d262c0ef..15eb5347 100644 --- a/src/plugins/pixelart/internal/Atlas.zig +++ b/src/plugins/pixelart/internal/Atlas.zig @@ -25,8 +25,8 @@ pub fn initCheckerboardTile(atlas: *Atlas) void { atlas.checkerboard_tile = fizzy.image.checkerboardTile( alpha_checkerboard_count, alpha_checkerboard_count, - fizzy.editor.settings.checker_color_even, - fizzy.editor.settings.checker_color_odd, + fizzy.pixelart.settings.checker_color_even, + fizzy.pixelart.settings.checker_color_odd, ); } diff --git a/src/plugins/pixelart/internal/File.zig b/src/plugins/pixelart/internal/File.zig index 291cd342..67d5914c 100644 --- a/src/plugins/pixelart/internal/File.zig +++ b/src/plugins/pixelart/internal/File.zig @@ -263,8 +263,8 @@ pub fn checkerboardTileTexture(file: *File) ?dvui.Texture { file.editor.checkerboard_tile = fizzy.image.checkerboardTile( want.w, want.h, - fizzy.editor.settings.checker_color_even, - fizzy.editor.settings.checker_color_odd, + fizzy.pixelart.settings.checker_color_even, + fizzy.pixelart.settings.checker_color_odd, ); return file.editor.checkerboard_tile; } diff --git a/src/plugins/pixelart/panel/sprites.zig b/src/plugins/pixelart/panel/sprites.zig index d844d03d..76c03c15 100644 --- a/src/plugins/pixelart/panel/sprites.zig +++ b/src/plugins/pixelart/panel/sprites.zig @@ -275,7 +275,7 @@ pub fn draw(self: *Sprites) !void { // ---- Animated fit-scale: aim the front sprite at a fraction of the // pane so several neighbours are visible at once. ---- const scale = blk: { - const steps = fizzy.editor.settings.zoom_steps; + const steps = fizzy.pixelart.settings.zoom_steps; const sprite_width = src_rect.w; const sprite_height = src_rect.h; const target_width = parent.w * 0.34; @@ -803,7 +803,7 @@ pub fn draw(self: *Sprites) !void { /// Side cards lift away during playback, while a drawing tool is active, or when /// `settings.scrolling_cards` is off (focus mode; toggled in settings or the sprites pane). fn sideCardsFlown(playing: bool) bool { - return playing or drawingToolActive() or !fizzy.editor.settings.scrolling_cards; + return playing or drawingToolActive() or !fizzy.pixelart.settings.scrolling_cards; } /// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee). @@ -1237,8 +1237,8 @@ pub fn drawAnimationControlsDialog(_: *Sprites) void { !fly_forced, flown, ) and !fly_forced) { - fizzy.editor.settings.scrolling_cards = !fizzy.editor.settings.scrolling_cards; - fizzy.editor.markSettingsDirty(); + fizzy.pixelart.settings.scrolling_cards = !fizzy.pixelart.settings.scrolling_cards; + fizzy.pixelart.settings.save(fizzy.pixelart.host); dvui.refresh(null, @src(), dvui.parentGet().data().id); } } diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig index 5fdcd2ef..b2745892 100644 --- a/src/plugins/pixelart/plugin.zig +++ b/src/plugins/pixelart/plugin.zig @@ -10,6 +10,7 @@ const sdk = fizzy.sdk; const CanvasData = @import("CanvasData.zig"); const FileWidget = @import("widgets/FileWidget.zig"); const ImageWidget = @import("widgets/ImageWidget.zig"); +const PixelArtSettings = @import("Settings.zig"); const DocHandle = sdk.DocHandle; const Internal = fizzy.Internal; @@ -112,7 +113,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { fizzy.perf.canvasPaneDrawn(); - if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) { + if (fizzy.pixelart.settings.show_rulers and !dvui.firstFrame(container.id)) { defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .top, .{}); chrome.drawRuler(file, .horizontal); } @@ -120,7 +121,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); defer canvas_hbox.deinit(); - if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) { + if (fizzy.pixelart.settings.show_rulers and !dvui.firstFrame(container.id)) { defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .left, .{}); chrome.drawRuler(file, .vertical); } @@ -165,10 +166,10 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { switch (builtin.os.tag) { .macos => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; + content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; }, .windows => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; + content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; }, else => {}, } @@ -280,6 +281,12 @@ pub fn register(host: *sdk.Host) !void { .title = "Sprites", .draw = drawSpritesPanel, }); + try host.registerSettingsSection(.{ + .id = "pixelart.settings", + .owner = &plugin, + .title = "Pixel Art", + .draw = PixelArtSettings.draw, + }); } fn drawTools(_: ?*anyopaque) anyerror!void { diff --git a/src/plugins/pixelart/widgets/CanvasBridge.zig b/src/plugins/pixelart/widgets/CanvasBridge.zig index c2f655d8..93d05774 100644 --- a/src/plugins/pixelart/widgets/CanvasBridge.zig +++ b/src/plugins/pixelart/widgets/CanvasBridge.zig @@ -6,7 +6,7 @@ const CanvasWidget = fizzy.dvui.CanvasWidget; /// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum. pub fn scheme() CanvasWidget.PanZoomScheme { - return switch (fizzy.Editor.Settings.resolvedPanZoomScheme(&fizzy.editor.settings)) { + return switch (fizzy.PixelArt.Settings.resolvedPanZoomScheme(&fizzy.pixelart.settings)) { .mouse => .mouse, .trackpad => .trackpad, }; diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig index 3fb35d68..bbb79ccc 100644 --- a/src/plugins/pixelart/widgets/FileWidget.zig +++ b/src/plugins/pixelart/widgets/FileWidget.zig @@ -3896,7 +3896,7 @@ fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) } fn checkerboardCellCornerColor( - effect: fizzy.Editor.Settings.TransparencyEffect, + effect: fizzy.PixelArt.Settings.TransparencyEffect, file: *fizzy.Internal.File, sprite_index: usize, c_tl: dvui.Color, @@ -3941,7 +3941,7 @@ fn checkerboardGridPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr: fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index: usize) dvui.Color { const pal = checkerboardGridPalette(); const tone = pal.tone; - switch (fizzy.editor.settings.transparency_effect) { + switch (fizzy.pixelart.settings.transparency_effect) { .none => return tone, .rainbow => { const mu_mv = dvui.dataGet(null, file.editor.canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 }; @@ -3964,7 +3964,7 @@ fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void { const n = file.spriteCount(); if (n == 0) return; - const te = fizzy.editor.settings.transparency_effect; + const te = fizzy.pixelart.settings.transparency_effect; const pal = checkerboardGridPalette(); const tone = pal.tone; const rs = file.editor.canvas.screen_rect_scale; @@ -4689,7 +4689,7 @@ fn drawCheckerboardReorderFloatingStrip( const c_tr = pal.c_tr; const c_bl = pal.c_bl; const c_br = pal.c_br; - const te = fizzy.editor.settings.transparency_effect; + const te = fizzy.pixelart.settings.transparency_effect; const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0); const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0); diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 00bc7bb3..8d6e78cc 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -8,6 +8,7 @@ const std = @import("std"); const Plugin = @import("Plugin.zig"); const regions = @import("regions.zig"); +const ShellApi = @import("ShellApi.zig"); pub const Host = @This(); @@ -15,6 +16,12 @@ pub const SidebarView = regions.SidebarView; pub const BottomView = regions.BottomView; pub const CenterProvider = regions.CenterProvider; pub const MenuContribution = regions.MenuContribution; +pub const SettingsSection = regions.SettingsSection; + +/// Per-plugin opaque settings blobs: plugin id -> serialized JSON. The Host owns the +/// key + value strings; the shell persists them verbatim under "plugins" in +/// settings.json and never interprets them. +pub const PluginSettings = std.StringArrayHashMapUnmanaged([]const u8); allocator: std.mem.Allocator, @@ -26,6 +33,13 @@ plugins: std.ArrayListUnmanaged(*Plugin) = .empty, /// draw per-branch explorer decorations without a compile-time dependency on it. services: std.StringHashMapUnmanaged(*anyopaque) = .empty, +/// The shell's read/utility surface (arena, folder, shared settings, dirty mark), +/// installed by the shell during startup. Null until installed (headless/test). +shell_api: ?ShellApi = null, + +/// Opaque per-plugin settings store (see `PluginSettings`). +plugin_settings: PluginSettings = .empty, + // ---- shell region registries (Phase 2) ------------------------------------- // The shell iterates these instead of hardcoded enums/switches. Items keep their // registration order, which is the order they appear in the UI. @@ -38,6 +52,8 @@ bottom_views: std.ArrayListUnmanaged(BottomView) = .empty, center_providers: std.ArrayListUnmanaged(CenterProvider) = .empty, /// Menubar contributions (non-macOS in-app menu bar). menus: std.ArrayListUnmanaged(MenuContribution) = .empty, +/// Settings sections (Settings view renders each under its title, grouped by owner). +settings_sections: std.ArrayListUnmanaged(SettingsSection) = .empty, /// Active selection by contribution id (null = use the first registered). active_sidebar_view: ?[]const u8 = null, @@ -55,6 +71,70 @@ pub fn deinit(self: *Host) void { self.bottom_views.deinit(self.allocator); self.center_providers.deinit(self.allocator); self.menus.deinit(self.allocator); + self.settings_sections.deinit(self.allocator); + { + var it = self.plugin_settings.iterator(); + while (it.next()) |e| { + self.allocator.free(e.key_ptr.*); + self.allocator.free(e.value_ptr.*); + } + self.plugin_settings.deinit(self.allocator); + } +} + +// ---- shell services (installed by the shell during startup) ---------------- + +/// Install the shell's read/utility surface. Called once during startup. +pub fn installShell(self: *Host, api: ShellApi) void { + self.shell_api = api; +} + +/// Per-frame arena allocator (reset every frame; do not free). Asserts the shell is installed. +pub fn arena(self: *Host) std.mem.Allocator { + return self.shell_api.?.arena(); +} + +/// Open project root folder, or null when none is open. +pub fn folder(self: *Host) ?[]const u8 { + return if (self.shell_api) |a| a.folder() else null; +} + +/// User palettes folder (config), or null on platforms without one. +pub fn paletteFolder(self: *Host) ?[]const u8 { + return if (self.shell_api) |a| a.paletteFolder() else null; +} + +/// Mark shell settings dirty so the debounced autosave persists them. +pub fn markSettingsDirty(self: *Host) void { + if (self.shell_api) |a| a.markSettingsDirty(); +} + +/// Shell-owned content-area opacity (matches the shell chrome). 1.0 if no shell installed. +pub fn contentOpacity(self: *Host) f32 { + return if (self.shell_api) |a| a.contentOpacity() else 1.0; +} + +// ---- per-plugin settings store --------------------------------------------- + +/// The stored settings blob for `id` (serialized JSON), or null if none. The returned +/// slice is owned by the Host and valid until the next `storePluginSettings` for `id`. +pub fn loadPluginSettings(self: *Host, id: []const u8) ?[]const u8 { + return self.plugin_settings.get(id); +} + +/// Store `json` as `id`'s settings blob (replacing any previous), and mark the shell +/// settings dirty so it persists. The Host copies both `id` and `json`. +pub fn storePluginSettings(self: *Host, id: []const u8, json: []const u8) !void { + const dup = try self.allocator.dupe(u8, json); + errdefer self.allocator.free(dup); + if (self.plugin_settings.getPtr(id)) |slot| { + self.allocator.free(slot.*); + slot.* = dup; + } else { + const key = try self.allocator.dupe(u8, id); + try self.plugin_settings.put(self.allocator, key, dup); + } + self.markSettingsDirty(); } pub fn registerPlugin(self: *Host, plugin: *Plugin) !void { @@ -90,6 +170,10 @@ pub fn registerMenu(self: *Host, menu: MenuContribution) !void { try self.menus.append(self.allocator, menu); } +pub fn registerSettingsSection(self: *Host, section: SettingsSection) !void { + try self.settings_sections.append(self.allocator, section); +} + // ---- active selection ------------------------------------------------------ pub fn setActiveSidebarView(self: *Host, id: []const u8) void { diff --git a/src/sdk/ShellApi.zig b/src/sdk/ShellApi.zig new file mode 100644 index 00000000..7f352758 --- /dev/null +++ b/src/sdk/ShellApi.zig @@ -0,0 +1,48 @@ +//! The shell-provided read/utility surface a plugin reaches through the `Host`. +//! +//! The shell installs one of these on the `Host` during startup (`Host.installShell`); +//! plugins call the convenience forwarders on `Host` (e.g. `host.arena()`), which +//! dispatch through this vtable. It exposes only the genuinely shared shell state a +//! plugin still needs — the per-frame arena, the open project folder, the few shell- +//! owned settings plugins read, and the dirty-mark hook — without leaking the concrete +//! `Editor` type across the SDK boundary. +const std = @import("std"); + +const ShellApi = @This(); + +ctx: *anyopaque, +vtable: *const VTable, + +pub const VTable = struct { + /// The shell's per-frame arena allocator (reset every frame; do not free). + arena: *const fn (ctx: *anyopaque) std.mem.Allocator, + /// The open project root folder, or null when none is open. + folder: *const fn (ctx: *anyopaque) ?[]const u8, + /// The user palettes folder (config), or null on platforms without one (web). + paletteFolder: *const fn (ctx: *anyopaque) ?[]const u8, + /// Mark shell settings dirty so the debounced autosave persists them. + markSettingsDirty: *const fn (ctx: *anyopaque) void, + /// Shell-owned content-area opacity (also drives the shell's own panes); plugins + /// read it to match the shell chrome. + contentOpacity: *const fn (ctx: *anyopaque) f32, +}; + +pub fn arena(self: ShellApi) std.mem.Allocator { + return self.vtable.arena(self.ctx); +} + +pub fn folder(self: ShellApi) ?[]const u8 { + return self.vtable.folder(self.ctx); +} + +pub fn paletteFolder(self: ShellApi) ?[]const u8 { + return self.vtable.paletteFolder(self.ctx); +} + +pub fn markSettingsDirty(self: ShellApi) void { + self.vtable.markSettingsDirty(self.ctx); +} + +pub fn contentOpacity(self: ShellApi) f32 { + return self.vtable.contentOpacity(self.ctx); +} diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig index cfe89cb7..903254bb 100644 --- a/src/sdk/regions.zig +++ b/src/sdk/regions.zig @@ -57,3 +57,16 @@ pub const MenuContribution = struct { ctx: ?*anyopaque = null, draw: *const fn (ctx: ?*anyopaque) anyerror!void, }; + +/// A settings section. The Settings view renders each registered section under its +/// own `title` heading, grouped by plugin (VSCode-style). The shell registers its +/// own "Editor" section; plugins register theirs (e.g. pixel art's canvas/ruler +/// prefs). `draw` fills the section body with that owner's controls. +pub const SettingsSection = struct { + id: []const u8, + owner: ?*Plugin = null, + /// Heading shown above this section's controls. + title: []const u8, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, +}; diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index 8390a064..34c9d413 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -8,9 +8,14 @@ pub const Host = @import("Host.zig"); pub const Plugin = @import("Plugin.zig"); pub const DocHandle = @import("DocHandle.zig"); -/// Shell region contribution types (sidebar / bottom / center / menu). +/// Shell region contribution types (sidebar / bottom / center / menu / settings). pub const regions = @import("regions.zig"); pub const SidebarView = regions.SidebarView; pub const BottomView = regions.BottomView; pub const CenterProvider = regions.CenterProvider; pub const MenuContribution = regions.MenuContribution; +pub const SettingsSection = regions.SettingsSection; + +/// Shell-provided read/utility surface plugins reach through the `Host` +/// (arena, folder, shared settings, dirty-marking). +pub const ShellApi = @import("ShellApi.zig"); From 5913735b6f35d1f998bca6063f5dd8cab051566c Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 13:39:53 -0500 Subject: [PATCH 18/49] Phase 4 stage C ctnd --- HANDOFF.md | 16 ++++----- src/editor/Editor.zig | 17 ++++++++-- src/editor/explorer/Explorer.zig | 6 ---- src/plugins/pixelart/Packer.zig | 2 +- src/plugins/pixelart/PixelArt.zig | 13 ++++++++ src/plugins/pixelart/Project.zig | 10 +++--- src/plugins/pixelart/algorithms/brezenham.zig | 2 +- src/plugins/pixelart/dialogs/GridLayout.zig | 2 +- src/plugins/pixelart/explorer/project.zig | 4 +-- src/plugins/pixelart/explorer/tools.zig | 25 +++++++------- src/plugins/pixelart/internal/Atlas.zig | 6 ++-- src/plugins/pixelart/internal/File.zig | 6 ++-- src/plugins/pixelart/internal/History.zig | 16 ++++----- src/plugins/pixelart/internal/Layer.zig | 2 +- src/plugins/pixelart/plugin.zig | 12 +++---- src/plugins/pixelart/render.zig | 2 +- src/plugins/pixelart/widgets/FileWidget.zig | 8 ++--- src/sdk/{ShellApi.zig => EditorAPI.zig} | 33 +++++++++++++++---- src/sdk/Host.zig | 22 +++++++++++-- src/sdk/sdk.zig | 2 +- 20 files changed, 132 insertions(+), 74 deletions(-) rename src/sdk/{ShellApi.zig => EditorAPI.zig} (58%) diff --git a/HANDOFF.md b/HANDOFF.md index 6380df46..a4a4a5b9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -63,9 +63,9 @@ interprets them. opaque per-plugin blob store (id → serialized JSON). `loadPluginSettings(id)` / `storePluginSettings(id, json)` (the latter dupes + marks shell settings dirty). Host owns + frees the key/value strings in `deinit`. - - `shell_api: ?ShellApi` + `installShell(api)` + thin forwarders: `arena()`, `folder()`, + - `shell_api: ?EditorAPI` + `installShell(api)` + thin forwarders: `arena()`, `folder()`, `paletteFolder()`, `markSettingsDirty()`, `contentOpacity()`. -- **`ShellApi`** (`ShellApi.zig`): vtable + ctx the shell installs so plugins reach shared +- **`EditorAPI`** (`EditorAPI.zig`): vtable + ctx the shell installs so plugins reach shared shell state without importing `Editor`. The shell's vtable impl lives in `Editor.zig` (`shell_api_vtable` + `shellArena`/`shellFolder`/… ; ctx is `*Editor`), installed in `Editor.postInit`. @@ -112,7 +112,7 @@ three enums + `resolvedPanZoomScheme`). All ~27 pixel-art read sites repointed t **`content_opacity` deliberately stays on the shell** — it's also read by `workbench/ Workspace.zig` and `panel/Panel.zig`, so it's genuinely shell-level. Pixel art's 3 reads -go through `fizzy.pixelart.host.contentOpacity()` (the ShellApi). The pixel-art settings +go through `fizzy.pixelart.host.contentOpacity()` (the EditorAPI). The pixel-art settings *UI controls* were removed from `editor/explorer/settings.zig` (the shell "Editor" section now only has theme/fonts/window+content opacity/hold-timing/debugging). @@ -258,12 +258,12 @@ backend: showSaveFileDialog ×5, DialogFileFilter ×4, isMaximized ×3 ; platfor 1. **`host` (11) — trivial now.** `PixelArt` already holds `host: *sdk.Host` (set in `init`). Repoint `fizzy.editor.host.setActiveSidebarView/isActiveSidebarView` → `fizzy.pixelart.host.…`. Pure mechanical, no SDK change. -2. **`arena` (10), `folder` (6), `palette_folder` (2) — done-for-you.** The ShellApi +2. **`arena` (10), `folder` (6), `palette_folder` (2) — done-for-you.** The EditorAPI forwarders already exist: `fizzy.pixelart.host.arena()` / `.folder()` / `.paletteFolder()`. Repoint `fizzy.editor.arena.allocator()` → `fizzy.pixelart.host.arena()`, etc. (mind that `arena` callers use `.allocator()`; the forwarder already returns the `Allocator`). -3. **`backend.isMaximized` (3), `platform.isMacOS` (3).** Add `isMaximized()` to ShellApi +3. **`backend.isMaximized` (3), `platform.isMacOS` (3).** Add `isMaximized()` to EditorAPI (shell calls `fizzy.backend.isMaximized(dvui.currentWindow())`). `isMacOS` is just `core.platform.isMacOS()` — pixel art can call `fizzy.platform.isMacOS()` until Stage D repoints it to `core` directly; low priority. @@ -272,7 +272,7 @@ backend: showSaveFileDialog ×5, DialogFileFilter ×4, isMaximized ×3 ; platfor `.scroll_info`). `tools`/`sprites` are pixel-art pane modules; `pinned_palettes`/ `layers_ratio` are pixel-art UI state. These should **move onto `PixelArt`** (like the settings did), not get an SDK accessor. `rect`/`scroll_info` are shell explorer layout — - expose via ShellApi or pass into the draw. + expose via EditorAPI or pass into the draw. 5. **Native save dialogs (`backend.showSaveFileDialog` ×5, `DialogFileFilter` ×4).** Add a small SDK surface for "ask the host to run a native save dialog" (native-only; web has its own path). The save-flow tail (`requestSaveAs`, `pending_*`, `quit_*`, `accept`, @@ -328,10 +328,10 @@ request). Beyond the Stage A3 changes, the working tree now also has: - **Stage B:** new `src/plugins/pixelart/PixelArt.zig`; `fizzy.pixelart` global in `fizzy.zig`; init/deinit wiring in `App.zig`; field removals + ~190 repoints in `Editor.zig`, `Keybinds.zig`, `workbench/files.zig`, and the pixel-art tree. -- **Stage C part 1 (settings):** new `src/sdk/ShellApi.zig`, +- **Stage C part 1 (settings):** new `src/sdk/EditorAPI.zig`, `src/plugins/pixelart/Settings.zig`; `SettingsSection` in `sdk/regions.zig` + `sdk.zig`; Host store/forwarders/section-registry in `sdk/Host.zig`; persistence rework in - `editor/Settings.zig`; ShellApi impl + section iteration in `editor/Editor.zig`; trimmed + `editor/Settings.zig`; EditorAPI impl + section iteration in `editor/Editor.zig`; trimmed `editor/explorer/settings.zig`; settings repoints across the pixel-art tree; `App.zig` passes the host to `PixelArt.init`. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index a77b4de4..f5d0ffc6 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -543,15 +543,18 @@ fn drawShellSettingsSection(_: ?*anyopaque) anyerror!void { try Explorer.settings.draw(); } -// ---- ShellApi: the shell-provided read/utility surface for plugins ---------- +// ---- EditorAPI: the shell-provided read/utility surface for plugins ---------- // Installed on the Host in `postInit`; `ctx` is this `*Editor`. -const shell_api_vtable: sdk.ShellApi.VTable = .{ +const shell_api_vtable: sdk.EditorAPI.VTable = .{ .arena = shellArena, .folder = shellFolder, .paletteFolder = shellPaletteFolder, .markSettingsDirty = shellMarkSettingsDirty, .contentOpacity = shellContentOpacity, + .isMaximized = shellIsMaximized, + .explorerRect = shellExplorerRect, + .explorerVirtualSize = shellExplorerVirtualSize, }; fn shellCtx(ctx: *anyopaque) *Editor { @@ -572,6 +575,16 @@ fn shellMarkSettingsDirty(ctx: *anyopaque) void { fn shellContentOpacity(ctx: *anyopaque) f32 { return shellCtx(ctx).settings.content_opacity; } +fn shellIsMaximized(ctx: *anyopaque) bool { + _ = ctx; + return fizzy.backend.isMaximized(dvui.currentWindow()); +} +fn shellExplorerRect(ctx: *anyopaque) dvui.Rect { + return shellCtx(ctx).explorer.rect; +} +fn shellExplorerVirtualSize(ctx: *anyopaque) dvui.Size { + return shellCtx(ctx).explorer.scroll_info.virtual_size; +} /// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void { diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 7b85dade..84ce657f 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -14,15 +14,11 @@ const nfd = @import("nfd"); pub const Explorer = @This(); pub const files = @import("../../plugins/workbench/files.zig"); -pub const Tools = @import("../../plugins/pixelart/explorer/tools.zig"); -pub const Sprites = @import("../../plugins/pixelart/explorer/sprites.zig"); // pub const animations = @import("animations.zig"); // pub const keyframe_animations = @import("keyframe_animations.zig"); pub const project = @import("../../plugins/pixelart/explorer/project.zig"); pub const settings = @import("settings.zig"); -sprites: Sprites = .{}, -tools: Tools = .{}, paned: *fizzy.dvui.PanedWidget = undefined, scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto, @@ -30,8 +26,6 @@ scroll_info: dvui.ScrollInfo = .{ rect: dvui.Rect = .{}, rect_screen: dvui.Rect.Physical = .{}, open_branches: std.AutoHashMap(dvui.Id, void) = undefined, -pinned_palettes: bool = false, -layers_ratio: f32 = 0.5, animations_ratio: f32 = 0.5, closed: bool = false, diff --git a/src/plugins/pixelart/Packer.zig b/src/plugins/pixelart/Packer.zig index 00f49532..ff9688ff 100644 --- a/src/plugins/pixelart/Packer.zig +++ b/src/plugins/pixelart/Packer.zig @@ -249,7 +249,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { } pub fn appendProject(packer: *Packer) !void { - if (fizzy.editor.folder) |root_directory| { + if (fizzy.pixelart.host.folder()) |root_directory| { try recurseFiles(packer, root_directory); } } diff --git a/src/plugins/pixelart/PixelArt.zig b/src/plugins/pixelart/PixelArt.zig index d106037d..fec50fa0 100644 --- a/src/plugins/pixelart/PixelArt.zig +++ b/src/plugins/pixelart/PixelArt.zig @@ -19,6 +19,8 @@ const Colors = @import("Colors.zig"); const Project = @import("Project.zig"); const Tools = @import("Tools.zig"); const PackJob = @import("PackJob.zig"); +const ToolsPane = @import("explorer/tools.zig"); +const SpritesPane = @import("explorer/sprites.zig"); pub const Settings = @import("Settings.zig"); const PixelArt = @This(); @@ -38,6 +40,17 @@ settings: Settings = .{}, tools: Tools, colors: Colors = .{}, +/// Explorer sidebar panes (lifted off the shell `Explorer` in Phase 4 Stage C). The "tools" +/// view (layers + palette) and the "sprites" view (animations/frames) are pixel-art-specific +/// UI state; the shell only routes the registered sidebar view's `draw` to them. +tools_pane: ToolsPane = .{}, +sprites_pane: SpritesPane = .{}, + +/// Whether the palette pane is pinned open in the tools sidebar (pixel-art UI state). +pinned_palettes: bool = false, +/// Split ratio between the layers list and the palette in the tools sidebar. +layers_ratio: f32 = 0.5, + /// The open project's `.fizproject` pack config, or null when no project folder is open. project: ?Project = null, diff --git a/src/plugins/pixelart/Project.zig b/src/plugins/pixelart/Project.zig index c3cff907..e9e28ce0 100644 --- a/src/plugins/pixelart/Project.zig +++ b/src/plugins/pixelart/Project.zig @@ -22,8 +22,8 @@ pack_on_save: bool = false, pub fn load(allocator: std.mem.Allocator) !?Project { if (comptime builtin.target.cpu.arch == .wasm32) return null; - if (fizzy.editor.folder) |folder| { - const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" }); + if (fizzy.pixelart.host.folder()) |folder| { + const file = try std.fs.path.join(fizzy.pixelart.host.arena(), &.{ folder, ".fizproject" }); if (fizzy.fs.read(allocator, dvui.io, file) catch null) |r| { read = r; @@ -60,8 +60,8 @@ pub fn load(allocator: std.mem.Allocator) !?Project { pub fn save(project: *Project) !void { if (comptime builtin.target.cpu.arch == .wasm32) return; - if (fizzy.editor.folder) |folder| { - const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" }); + if (fizzy.pixelart.host.folder()) |folder| { + const file = try std.fs.path.join(fizzy.pixelart.host.arena(), &.{ folder, ".fizproject" }); const options = std.json.Stringify.Options{}; const str = try std.json.Stringify.valueAlloc(fizzy.app.allocator, Project{ @@ -90,7 +90,7 @@ pub fn exportAssets(project: *Project) !void { } // if (project.packed_heightmap_output) |packed_heightmap_output| { - // const path = try std.fs.path.joinZ(fizzy.editor.arena.allocator(), &.{ parent_folder, packed_heightmap_output }); + // const path = try std.fs.path.joinZ(fizzy.pixelart.host.arena(), &.{ parent_folder, packed_heightmap_output }); // try fizzy.editor.atlas.save(path, .heightmap); // } } diff --git a/src/plugins/pixelart/algorithms/brezenham.zig b/src/plugins/pixelart/algorithms/brezenham.zig index 46f2061b..4d114cc8 100644 --- a/src/plugins/pixelart/algorithms/brezenham.zig +++ b/src/plugins/pixelart/algorithms/brezenham.zig @@ -4,7 +4,7 @@ const dvui = @import("dvui"); pub fn process(start: dvui.Point, end: dvui.Point) ![]dvui.Point { // Bresenham's line algorithm for integer grid points - var output = std.array_list.Managed(dvui.Point).init(fizzy.editor.arena.allocator()); + var output = std.array_list.Managed(dvui.Point).init(fizzy.pixelart.host.arena()); // Round input points to nearest integer grid const x0: i32 = @intFromFloat(@floor(start.x)); diff --git a/src/plugins/pixelart/dialogs/GridLayout.zig b/src/plugins/pixelart/dialogs/GridLayout.zig index c56eac5c..d047845f 100644 --- a/src/plugins/pixelart/dialogs/GridLayout.zig +++ b/src/plugins/pixelart/dialogs/GridLayout.zig @@ -127,7 +127,7 @@ fn workspaceCanvasChromeColor() dvui.Color { var content_color = dvui.themeGet().color(.window, .fill); switch (builtin.os.tag) { .macos, .windows => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) + content_color = if (!fizzy.pixelart.host.isMaximized()) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; diff --git a/src/plugins/pixelart/explorer/project.zig b/src/plugins/pixelart/explorer/project.zig index 0620a7b8..8988377c 100644 --- a/src/plugins/pixelart/explorer/project.zig +++ b/src/plugins/pixelart/explorer/project.zig @@ -14,7 +14,7 @@ pub fn draw() !void { return; } - if (fizzy.editor.folder) |folder| { + if (fizzy.pixelart.host.folder()) |folder| { if (fizzy.pixelart.project) |_| { const tl = dvui.textLayout(@src(), .{}, .{ .expand = .none, @@ -34,7 +34,7 @@ pub fn draw() !void { } else { var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, - .max_size_content = .{ .w = fizzy.editor.explorer.scroll_info.virtual_size.w, .h = std.math.floatMax(f32) }, + .max_size_content = .{ .w = fizzy.pixelart.host.explorerVirtualSize().w, .h = std.math.floatMax(f32) }, }); defer box.deinit(); diff --git a/src/plugins/pixelart/explorer/tools.zig b/src/plugins/pixelart/explorer/tools.zig index a60d59ef..d3da9a3e 100644 --- a/src/plugins/pixelart/explorer/tools.zig +++ b/src/plugins/pixelart/explorer/tools.zig @@ -81,7 +81,7 @@ pub fn draw(self: *Tools) !void { if (paned.dragging) { max_split_ratio = paned.split_ratio.*; - fizzy.editor.explorer.layers_ratio = paned.split_ratio.*; + fizzy.pixelart.layers_ratio = paned.split_ratio.*; } if (paned.showFirst()) { @@ -97,7 +97,7 @@ pub fn draw(self: *Tools) !void { const autofit = !paned.dragging and !paned.collapsed_state and !paned.animating; // Refit must be done between showFirst and showSecond - if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !fizzy.editor.explorer.pinned_palettes) { + if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !fizzy.pixelart.pinned_palettes) { if (dvui.firstFrame(paned.data().id) and layer_count == 0) paned.split_ratio.* = 0.0; @@ -108,7 +108,7 @@ pub fn draw(self: *Tools) !void { // next frame when min sizes are valid. if (dvui.firstFrame(paned.data().id) and layer_count > 0) { paned.split_ratio.* = 0.01; - //fizzy.editor.explorer.layers_ratio = paned.split_ratio.*; + //fizzy.pixelart.layers_ratio = paned.split_ratio.*; } else { const ratio = paned.getFirstFittedRatio( .{ @@ -129,9 +129,9 @@ pub fn draw(self: *Tools) !void { if (layer_count == 0) paned.split_ratio.* = 0.0 else - paned.split_ratio.* = fizzy.editor.explorer.layers_ratio; + paned.split_ratio.* = fizzy.pixelart.layers_ratio; - fizzy.editor.explorer.layers_ratio = paned.split_ratio.*; + fizzy.pixelart.layers_ratio = paned.split_ratio.*; } } @@ -589,7 +589,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { if (file.editor.isolate_layer) { if (file.peek_layer_index) |peek_layer_index| { min_layer_index = peek_layer_index; - } else if (!fizzy.editor.explorer.tools.layersHovered()) { + } else if (!fizzy.pixelart.tools_pane.layersHovered()) { min_layer_index = file.selected_layer_index; } } @@ -1069,9 +1069,9 @@ pub fn drawPaletteControls() !void { .corner_radius = dvui.Rect.all(1000), }, .rotation = std.math.pi * 0.25, - .style = if (fizzy.editor.explorer.pinned_palettes) .highlight else .control, + .style = if (fizzy.pixelart.pinned_palettes) .highlight else .control, })) { - fizzy.editor.explorer.pinned_palettes = !fizzy.editor.explorer.pinned_palettes; + fizzy.pixelart.pinned_palettes = !fizzy.pixelart.pinned_palettes; } } @@ -1161,8 +1161,8 @@ pub fn drawPalettes() !void { var flex_box = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .expand = .horizontal, .max_size_content = .{ - .w = fizzy.editor.explorer.rect.w - 20 * dvui.currentWindow().natural_scale, - .h = fizzy.editor.explorer.rect.h - 20 * dvui.currentWindow().natural_scale, + .w = fizzy.pixelart.host.explorerRect().w - 20 * dvui.currentWindow().natural_scale, + .h = fizzy.pixelart.host.explorerRect().h - 20 * dvui.currentWindow().natural_scale, }, }); @@ -1267,7 +1267,8 @@ pub fn drawPalettes() !void { } fn searchPalettes(dropdown: *dvui.DropdownWidget) !void { const io = dvui.io; - var dir_opt = std.Io.Dir.cwd().openDir(io, fizzy.editor.palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null; + const palette_folder = fizzy.pixelart.host.paletteFolder() orelse return; + var dir_opt = std.Io.Dir.cwd().openDir(io, palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null; if (dir_opt) |*dir| { defer dir.close(io); var iter = dir.iterate(); @@ -1277,7 +1278,7 @@ fn searchPalettes(dropdown: *dvui.DropdownWidget) !void { if (std.mem.eql(u8, ext, ".hex")) { const label = try std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{entry.name}); if (dropdown.addChoiceLabel(label)) { - const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ fizzy.editor.palette_folder, entry.name }); + const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ palette_folder, entry.name }); if (fizzy.pixelart.colors.palette) |*palette| palette.deinit(); diff --git a/src/plugins/pixelart/internal/Atlas.zig b/src/plugins/pixelart/internal/Atlas.zig index 15eb5347..bf8a25b6 100644 --- a/src/plugins/pixelart/internal/Atlas.zig +++ b/src/plugins/pixelart/internal/Atlas.zig @@ -48,7 +48,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { // below writes through `std.Io.Dir.cwd()` which requires `posix.AT` (not // available on `wasm32-freestanding`). if (comptime @import("builtin").target.cpu.arch == .wasm32) { - const allocator = fizzy.editor.arena.allocator(); + const allocator = fizzy.pixelart.host.arena(); switch (selector) { .source => { const ext = std.fs.path.extension(path); @@ -83,7 +83,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { switch (selector) { .source => { const ext = std.fs.path.extension(path); - const write_path = std.fmt.allocPrintSentinel(fizzy.editor.arena.allocator(), "{s}", .{path}, 0) catch unreachable; + const write_path = std.fmt.allocPrintSentinel(fizzy.pixelart.host.arena(), "{s}", .{path}, 0) catch unreachable; if (std.mem.eql(u8, ext, ".png")) { try fizzy.image.writeToPng(atlas.source, write_path); @@ -101,7 +101,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { } const options: std.json.Stringify.Options = .{}; - const output = try std.json.Stringify.valueAlloc(fizzy.editor.arena.allocator(), atlas.data, options); + const output = try std.json.Stringify.valueAlloc(fizzy.pixelart.host.arena(), atlas.data, options); std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = output }) catch return error.CouldNotWriteAtlasData; }, diff --git a/src/plugins/pixelart/internal/File.zig b/src/plugins/pixelart/internal/File.zig index 67d5914c..d72ef320 100644 --- a/src/plugins/pixelart/internal/File.zig +++ b/src/plugins/pixelart/internal/File.zig @@ -2682,7 +2682,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: .dest_pixels_before = dest_pixels_before, .dest_mask_before = dest_mask_before, } }); - fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); + fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); } pub fn duplicateLayer(self: *File, index: usize) !u64 { @@ -2798,7 +2798,7 @@ pub fn saveTar(self: *File, window: *dvui.Window) !void { var ext = try self.external(fizzy.app.allocator); defer ext.deinit(fizzy.app.allocator); - const output_path = try fizzy.editor.arena.allocator().dupeZ(u8, self.path); + const output_path = try fizzy.pixelart.host.arena().dupeZ(u8, self.path); var handle = try std.fs.cwd().createFile(output_path, .{}); defer handle.close(); @@ -2826,7 +2826,7 @@ pub fn saveTar(self: *File, window: *dvui.Window) !void { else => return error.InvalidImageSource, }; - try wrt.writeFileBytes(try std.fmt.allocPrintZ(fizzy.editor.arena.allocator(), "{s}.layer", .{layer.name}), data, .{}); + try wrt.writeFileBytes(try std.fmt.allocPrintZ(fizzy.pixelart.host.arena(), "{s}.layer", .{layer.name}), data, .{}); } } diff --git a/src/plugins/pixelart/internal/History.zig b/src/plugins/pixelart/internal/History.zig index 240c515f..45025e7c 100644 --- a/src/plugins/pixelart/internal/History.zig +++ b/src/plugins/pixelart/internal/History.zig @@ -388,7 +388,7 @@ fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { file.editor.layer_composite_dirty = true; file.editor.split_composite_dirty = true; file.selected_layer_index = lm.source_index; - fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); + fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); } @@ -430,7 +430,7 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { .up => dest_i, .down => dest_i - 1, }; - fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); + fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); } @@ -619,7 +619,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi //try file.editor.selected_sprites.append(sprite_index); } - fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites); + fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites); }, .layers_order => |*layers_order| { file.editor.layer_composite_dirty = true; @@ -674,7 +674,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi layer_restore_delete.action = .restore; }, } - fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); + fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); }, .layer_name => |*layer_name| { @@ -682,7 +682,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi fizzy.app.allocator.free(file.layers.items(.name)[layer_name.index]); file.layers.items(.name)[layer_name.index] = try fizzy.app.allocator.dupe(u8, layer_name.name); layer_name.name = name; - fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); + fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); }, .layer_settings => |*layer_settings| { const idx = layer_settings.index; @@ -701,7 +701,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (visibility_changed) { file.editor.split_composite_dirty = true; } - fizzy.editor.host.setActiveSidebarView(pixelart.view_tools); + fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); }, .animation_restore_delete => |*animation_restore_delete| { const a = animation_restore_delete.action; @@ -727,14 +727,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi } }, } - fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites); + fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites); }, .animation_name => |*animation_name| { const name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[animation_name.index]); fizzy.app.allocator.free(file.animations.items(.name)[animation_name.index]); file.animations.items(.name)[animation_name.index] = try fizzy.app.allocator.dupe(u8, animation_name.name); animation_name.name = name; - fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites); + fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites); }, .animation_settings => {}, .animation_order => |*animation_order| { diff --git a/src/plugins/pixelart/internal/Layer.zig b/src/plugins/pixelart/internal/Layer.zig index a4aedfc1..21a4fe60 100644 --- a/src/plugins/pixelart/internal/Layer.zig +++ b/src/plugins/pixelart/internal/Layer.zig @@ -412,7 +412,7 @@ pub fn writeSourceToZip( const w = @as(c_int, @intFromFloat(s.w)); const h = @as(c_int, @intFromFloat(s.h)); - var writer = std.Io.Writer.Allocating.init(fizzy.editor.arena.allocator()); + var writer = std.Io.Writer.Allocating.init(fizzy.pixelart.host.arena()); try fizzy.image.ensurePngWriterBuffer(&writer.writer); try dvui.PNGEncoder.writeWithResolution(&writer.writer, fizzy.image.bytes(source), @intCast(w), @intCast(h), resolution); diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig index b2745892..6a1934bb 100644 --- a/src/plugins/pixelart/plugin.zig +++ b/src/plugins/pixelart/plugin.zig @@ -166,10 +166,10 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { switch (builtin.os.tag) { .macos => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; + content_color = if (!fizzy.pixelart.host.isMaximized()) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; }, .windows => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; + content_color = if (!fizzy.pixelart.host.isMaximized()) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; }, else => {}, } @@ -177,7 +177,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32) fizzy.packer.atlas != null else - fizzy.editor.folder != null and fizzy.packer.atlas != null; + fizzy.pixelart.host.folder() != null and fizzy.packer.atlas != null; // Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage). var canvas_vbox = Workspace.workspaceMainCanvasVbox(content_color, show_packed_atlas, ws.grouping); @@ -218,7 +218,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32) "Pack open files to see the preview." - else if (fizzy.editor.folder == null) + else if (fizzy.pixelart.host.folder() == null) "Open a project folder, then pack to see the preview." else "Pack the project to see the preview."; @@ -290,10 +290,10 @@ pub fn register(host: *sdk.Host) !void { } fn drawTools(_: ?*anyopaque) anyerror!void { - try fizzy.editor.explorer.tools.draw(); + try fizzy.pixelart.tools_pane.draw(); } fn drawSprites(_: ?*anyopaque) anyerror!void { - try fizzy.editor.explorer.sprites.draw(); + try fizzy.pixelart.sprites_pane.draw(); } fn drawProject(_: ?*anyopaque) anyerror!void { try fizzy.Editor.Explorer.project.draw(); diff --git a/src/plugins/pixelart/render.zig b/src/plugins/pixelart/render.zig index 14a46745..a3ec3daf 100644 --- a/src/plugins/pixelart/render.zig +++ b/src/plugins/pixelart/render.zig @@ -112,7 +112,7 @@ fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_inde if (init_opts.file.editor.isolate_layer) { if (init_opts.file.peek_layer_index) |peek_layer_index| { min_layer_index = peek_layer_index; - } else if (!fizzy.editor.explorer.tools.layersHovered()) { + } else if (!fizzy.pixelart.tools_pane.layersHovered()) { min_layer_index = init_opts.file.selected_layer_index; } } diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig index bbb79ccc..4f3e1143 100644 --- a/src/plugins/pixelart/widgets/FileWidget.zig +++ b/src/plugins/pixelart/widgets/FileWidget.zig @@ -273,7 +273,7 @@ pub fn sampleColorAtPoint( if (file.editor.isolate_layer) { if (file.peek_layer_index) |peek_layer_index| { min_layer_index = peek_layer_index; - } else if (!fizzy.editor.explorer.tools.layersHovered()) { + } else if (!fizzy.pixelart.tools_pane.layersHovered()) { min_layer_index = file.selected_layer_index; } } @@ -1642,8 +1642,8 @@ pub fn drawSpriteBubble( self.init_options.file.selected_animation_index = anim_index; self.init_options.file.collapseAnimationSelectionToPrimary(); self.init_options.file.editor.animations_scroll_to_index = anim_index; - fizzy.editor.explorer.sprites.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index]; - fizzy.editor.host.setActiveSidebarView(@import("../plugin.zig").view_sprites); + fizzy.pixelart.sprites_pane.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index]; + fizzy.pixelart.host.setActiveSidebarView(@import("../plugin.zig").view_sprites); var anim = self.init_options.file.animations.get(anim_index); if (anim.frames.len == 0) { @@ -4626,7 +4626,7 @@ pub fn drawLayers(self: *FileWidget) void { const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect); // Draw the origins when in the sprites pane - if (fizzy.editor.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) { + if (fizzy.pixelart.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) { const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] }; const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y }; diff --git a/src/sdk/ShellApi.zig b/src/sdk/EditorAPI.zig similarity index 58% rename from src/sdk/ShellApi.zig rename to src/sdk/EditorAPI.zig index 7f352758..f94f2e2a 100644 --- a/src/sdk/ShellApi.zig +++ b/src/sdk/EditorAPI.zig @@ -7,8 +7,9 @@ //! owned settings plugins read, and the dirty-mark hook — without leaking the concrete //! `Editor` type across the SDK boundary. const std = @import("std"); +const dvui = @import("dvui"); -const ShellApi = @This(); +const EditorAPI = @This(); ctx: *anyopaque, vtable: *const VTable, @@ -25,24 +26,44 @@ pub const VTable = struct { /// Shell-owned content-area opacity (also drives the shell's own panes); plugins /// read it to match the shell chrome. contentOpacity: *const fn (ctx: *anyopaque) f32, + /// Whether the OS window is currently maximized (always false on web). + isMaximized: *const fn (ctx: *anyopaque) bool, + /// The explorer pane's content rect (shell layout); plugins drawn inside the explorer + /// read it to size their content. Zero rect when no shell is installed. + explorerRect: *const fn (ctx: *anyopaque) dvui.Rect, + /// The explorer scroll area's virtual content size (shell layout). Zero size when no + /// shell is installed. + explorerVirtualSize: *const fn (ctx: *anyopaque) dvui.Size, }; -pub fn arena(self: ShellApi) std.mem.Allocator { +pub fn arena(self: EditorAPI) std.mem.Allocator { return self.vtable.arena(self.ctx); } -pub fn folder(self: ShellApi) ?[]const u8 { +pub fn folder(self: EditorAPI) ?[]const u8 { return self.vtable.folder(self.ctx); } -pub fn paletteFolder(self: ShellApi) ?[]const u8 { +pub fn paletteFolder(self: EditorAPI) ?[]const u8 { return self.vtable.paletteFolder(self.ctx); } -pub fn markSettingsDirty(self: ShellApi) void { +pub fn markSettingsDirty(self: EditorAPI) void { self.vtable.markSettingsDirty(self.ctx); } -pub fn contentOpacity(self: ShellApi) f32 { +pub fn contentOpacity(self: EditorAPI) f32 { return self.vtable.contentOpacity(self.ctx); } + +pub fn isMaximized(self: EditorAPI) bool { + return self.vtable.isMaximized(self.ctx); +} + +pub fn explorerRect(self: EditorAPI) dvui.Rect { + return self.vtable.explorerRect(self.ctx); +} + +pub fn explorerVirtualSize(self: EditorAPI) dvui.Size { + return self.vtable.explorerVirtualSize(self.ctx); +} diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 8d6e78cc..33bdec8e 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -6,9 +6,10 @@ //! Phase 0: holds the plugin registry + service locator. Nothing is registered //! yet — the existing pixel-art code still uses globals directly. const std = @import("std"); +const dvui = @import("dvui"); const Plugin = @import("Plugin.zig"); const regions = @import("regions.zig"); -const ShellApi = @import("ShellApi.zig"); +const EditorAPI = @import("EditorAPI.zig"); pub const Host = @This(); @@ -35,7 +36,7 @@ services: std.StringHashMapUnmanaged(*anyopaque) = .empty, /// The shell's read/utility surface (arena, folder, shared settings, dirty mark), /// installed by the shell during startup. Null until installed (headless/test). -shell_api: ?ShellApi = null, +shell_api: ?EditorAPI = null, /// Opaque per-plugin settings store (see `PluginSettings`). plugin_settings: PluginSettings = .empty, @@ -85,7 +86,7 @@ pub fn deinit(self: *Host) void { // ---- shell services (installed by the shell during startup) ---------------- /// Install the shell's read/utility surface. Called once during startup. -pub fn installShell(self: *Host, api: ShellApi) void { +pub fn installShell(self: *Host, api: EditorAPI) void { self.shell_api = api; } @@ -114,6 +115,21 @@ pub fn contentOpacity(self: *Host) f32 { return if (self.shell_api) |a| a.contentOpacity() else 1.0; } +/// Whether the OS window is currently maximized. False if no shell installed (headless/web). +pub fn isMaximized(self: *Host) bool { + return if (self.shell_api) |a| a.isMaximized() else false; +} + +/// The explorer pane's content rect (shell layout). Zero rect if no shell installed. +pub fn explorerRect(self: *Host) dvui.Rect { + return if (self.shell_api) |a| a.explorerRect() else .{}; +} + +/// The explorer scroll area's virtual content size (shell layout). Zero size if no shell installed. +pub fn explorerVirtualSize(self: *Host) dvui.Size { + return if (self.shell_api) |a| a.explorerVirtualSize() else .{}; +} + // ---- per-plugin settings store --------------------------------------------- /// The stored settings blob for `id` (serialized JSON), or null if none. The returned diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index 34c9d413..7e5e6992 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -18,4 +18,4 @@ pub const SettingsSection = regions.SettingsSection; /// Shell-provided read/utility surface plugins reach through the `Host` /// (arena, folder, shared settings, dirty-marking). -pub const ShellApi = @import("ShellApi.zig"); +pub const EditorAPI = @import("EditorAPI.zig"); From 001dfec30a11f74a041eb5ae1aa0cca8fb3c8fc3 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 15:09:42 -0500 Subject: [PATCH 19/49] phase 4 stage c ctnd --- src/core/Atlas.zig | 34 ++++++++ src/core/Sprite.zig | 91 +++++++++++++++++++++ src/core/core.zig | 6 ++ src/editor/Editor.zig | 41 ++++++++-- src/fizzy.zig | 4 +- src/plugins/pixelart/Project.zig | 8 +- src/plugins/pixelart/Tools.zig | 10 +-- src/plugins/pixelart/dialogs/Export.zig | 16 ++-- src/plugins/pixelart/explorer/project.zig | 2 +- src/plugins/pixelart/explorer/tools.zig | 18 ++-- src/plugins/pixelart/sprite_render.zig | 11 ++- src/plugins/pixelart/widgets/FileWidget.zig | 16 ++-- src/plugins/workbench/Workspace.zig | 6 +- src/plugins/workbench/files.zig | 12 +-- src/sdk/EditorAPI.zig | 50 +++++++++++ src/sdk/Host.zig | 16 ++++ src/sdk/sdk.zig | 4 + 17 files changed, 284 insertions(+), 61 deletions(-) create mode 100644 src/core/Atlas.zig create mode 100644 src/core/Sprite.zig diff --git a/src/core/Atlas.zig b/src/core/Atlas.zig new file mode 100644 index 00000000..060995f9 --- /dev/null +++ b/src/core/Atlas.zig @@ -0,0 +1,34 @@ +//! A loaded spritesheet: GPU `source` texture + indexed sprite metadata. +//! +//! The shell's `editor.atlas` uses this minimal type for UI icons. The pixel-art +//! plugin's packed output uses the richer `Internal.Atlas` instead. +const std = @import("std"); +const dvui = @import("dvui"); + +const Sprite = @import("Sprite.zig"); + +const Atlas = @This(); + +source: dvui.ImageSource, +sprites: []Sprite, + +const SpritesOnly = struct { + sprites: []Sprite, +}; + +/// Parse a `.atlas` JSON blob and return a duped sprite table. Animations and +/// other fields are ignored (`ignore_unknown_fields`). +pub fn loadSpritesFromBytes(allocator: std.mem.Allocator, bytes: []const u8) ![]Sprite { + const options: std.json.ParseOptions = .{ + .ignore_unknown_fields = true, + .allocate = .alloc_if_needed, + }; + var parsed = try std.json.parseFromSlice(SpritesOnly, allocator, bytes, options); + defer parsed.deinit(); + return try allocator.dupe(Sprite, parsed.value.sprites); +} + +pub fn deinit(self: *Atlas, allocator: std.mem.Allocator) void { + allocator.free(self.sprites); + self.sprites = &.{}; +} diff --git a/src/core/Sprite.zig b/src/core/Sprite.zig new file mode 100644 index 00000000..e71d8c49 --- /dev/null +++ b/src/core/Sprite.zig @@ -0,0 +1,91 @@ +//! A sub-rect within an atlas texture: pixel `source` rect + optional `origin`. +//! +//! Used by the shell for UI icons and by the pixel-art renderer as the sprite-rect +//! type. Distinct from the plugin's build-time `Atlas.zig` (JSON loader with animations). +const std = @import("std"); +const dvui = @import("dvui"); + +const Sprite = @This(); + +origin: [2]f32 = .{ 0.0, 0.0 }, +source: [4]u32, + +/// Draw this sprite from `atlas_source` as a dvui widget (static textured quad). +pub fn draw( + self: Sprite, + src: std.builtin.SourceLocation, + atlas_source: dvui.ImageSource, + scale: f32, + opts: dvui.Options, +) dvui.WidgetData { + const source_size: dvui.Size = dvui.imageSize(atlas_source) catch .{ .w = 0, .h = 0 }; + + const uv = dvui.Rect{ + .x = @as(f32, @floatFromInt(self.source[0])) / source_size.w, + .y = @as(f32, @floatFromInt(self.source[1])) / source_size.h, + .w = @as(f32, @floatFromInt(self.source[2])) / source_size.w, + .h = @as(f32, @floatFromInt(self.source[3])) / source_size.h, + }; + + const options = (dvui.Options{ .name = "sprite" }).override(opts); + + const size: dvui.Size = if (options.min_size_content) |msc| msc else .{ + .w = @as(f32, @floatFromInt(self.source[2])) * scale, + .h = @as(f32, @floatFromInt(self.source[3])) * scale, + }; + + var wd = dvui.WidgetData.init(src, .{}, options.override(.{ .min_size_content = size })); + wd.register(); + + const cr = wd.contentRect(); + const ms = wd.options.min_size_contentGet(); + + var too_big = false; + if (ms.w > cr.w or ms.h > cr.h) too_big = true; + + var e = wd.options.expandGet(); + const g = wd.options.gravityGet(); + var rect = dvui.placeIn(cr, ms, e, g); + + if (too_big and e != .ratio) { + if (ms.w > cr.w and !e.isHorizontal()) { + rect.w = ms.w; + rect.x -= g.x * (ms.w - cr.w); + } + if (ms.h > cr.h and !e.isVertical()) { + rect.h = ms.h; + rect.y -= g.y * (ms.h - cr.h); + } + } + + wd.rect = rect.outset(wd.options.paddingGet()).outset(wd.options.borderGet()).outset(wd.options.marginGet()); + + if (wd.options.rotationGet() == 0.0) { + wd.borderAndBackground(.{}); + } else if (wd.options.borderGet().nonZero()) { + dvui.log.debug("image {x} can't render border while rotated\n", .{wd.id}); + } + + const rs = wd.contentRectScale(); + dvui.renderImage(atlas_source, rs, .{ + .uv = uv, + .fade = 0.0, + }) catch { + dvui.log.err("Failed to render sprite", .{}); + }; + + if (opts.color_border) |border| { + var path: dvui.Path.Builder = .init(dvui.currentWindow().arena()); + defer path.deinit(); + const r = wd.contentRectScale().r; + path.addPoint(r.topLeft()); + path.addPoint(r.topRight()); + path.addPoint(r.bottomRight()); + path.addPoint(r.bottomLeft()); + path.build().stroke(.{ .color = border, .thickness = 1.0, .closed = true }); + } + + wd.minSizeSetAndRefresh(); + wd.minSizeReportToParent(); + return wd; +} diff --git a/src/core/core.zig b/src/core/core.zig index 7eb7b3f3..2fd4cd4b 100644 --- a/src/core/core.zig +++ b/src/core/core.zig @@ -37,3 +37,9 @@ pub const dvui = @import("dvui.zig"); /// Generic momentum/fling helper (pan, scrub, cover-flow). pub const Fling = @import("Fling.zig"); + +/// Generic sprite sub-rect within an atlas texture. +pub const Sprite = @import("Sprite.zig"); + +/// Generic loaded spritesheet (`source` texture + sprite table). +pub const Atlas = @import("Atlas.zig"); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index f5d0ffc6..a90ea23e 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -54,7 +54,7 @@ arena: std.heap.ArenaAllocator, config_folder: []const u8, palette_folder: []const u8, -atlas: fizzy.Internal.Atlas, +atlas: fizzy.core.Atlas, /// Plugin registry + service locator exposed to plugins host: Host, @@ -250,7 +250,7 @@ pub fn init( .arena = .init(std.heap.page_allocator), .last_titlebar_color = dvui.themeGet().color(.control, .fill), .atlas = .{ - .data = try .loadFromBytes(app.allocator, assets.files.@"fizzy.atlas"), + .sprites = try fizzy.core.Atlas.loadSpritesFromBytes(app.allocator, assets.files.@"fizzy.atlas"), .source = try fizzy.image.fromImageFileBytes("fizzy.png", assets.files.@"fizzy.png", .ptr), }, .themes = .empty, @@ -555,6 +555,8 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .isMaximized = shellIsMaximized, .explorerRect = shellExplorerRect, .explorerVirtualSize = shellExplorerVirtualSize, + .showSaveDialog = shellShowSaveDialog, + .uiAtlas = shellUiAtlas, }; fn shellCtx(ctx: *anyopaque) *Editor { @@ -585,6 +587,25 @@ fn shellExplorerRect(ctx: *anyopaque) dvui.Rect { fn shellExplorerVirtualSize(ctx: *anyopaque) dvui.Size { return shellCtx(ctx).explorer.scroll_info.virtual_size; } +fn shellShowSaveDialog( + ctx: *anyopaque, + cb: sdk.EditorAPI.SaveDialogCallback, + filters: []const sdk.EditorAPI.SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + _ = ctx; + // `SaveDialogFilter` shares `DialogFileFilter`'s layout, so the slice forwards as-is. + const native_filters: [*]const fizzy.backend.DialogFileFilter = @ptrCast(filters.ptr); + fizzy.backend.showSaveFileDialog(cb, native_filters[0..filters.len], default_filename, default_folder); +} +fn shellUiAtlas(ctx: *anyopaque) sdk.EditorAPI.UiAtlasView { + const atlas = &shellCtx(ctx).atlas; + return .{ + .source = atlas.source, + .sprites = @as([]const sdk.EditorAPI.UiSprite, @ptrCast(atlas.sprites)), + }; +} /// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void { @@ -1670,16 +1691,16 @@ pub fn drawRadialMenu(editor: *Editor) !void { } const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) { - .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], - .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], - .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], + .box => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.box_selection_default], + .pixel => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pixel_selection_default], + .color => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.color_selection_default], }; const sprite = switch (@as(Editor.Tools.Tool, @enumFromInt(i))) { - .pointer => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.cursor_default], - .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default], - .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default], - .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default], + .pointer => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.cursor_default], + .pencil => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pencil_default], + .eraser => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.eraser_default], + .bucket => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.bucket_default], .selection => selection_sprite, }; const size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 1, .h = 1 }; @@ -3536,6 +3557,8 @@ pub fn deinit(editor: *Editor) !void { editor.ignore.deinit(fizzy.app.allocator); + editor.atlas.deinit(fizzy.app.allocator); + if (editor.folder) |folder| fizzy.app.allocator.free(folder); editor.arena.deinit(); } diff --git a/src/fizzy.zig b/src/fizzy.zig index dfb406e1..561e3b0b 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -22,8 +22,8 @@ pub const fs = core.fs; pub const image = core.image; pub const render = @import("plugins/pixelart/render.zig"); -/// Atlas-consumer sprite rendering library (lives in the pixel-art plugin, -/// consumed by the shell/workbench to draw sprites from a packed atlas). +/// Pixel-art sprite renderer (layer compositing, reflections, cover-flow). Shell UI +/// icons use `fizzy.core.Sprite.draw` from core instead. pub const sprite_render = @import("plugins/pixelart/sprite_render.zig"); pub const perf = core.perf; pub const water_surface = core.water_surface; diff --git a/src/plugins/pixelart/Project.zig b/src/plugins/pixelart/Project.zig index e9e28ce0..7d85d568 100644 --- a/src/plugins/pixelart/Project.zig +++ b/src/plugins/pixelart/Project.zig @@ -81,17 +81,19 @@ pub fn save(project: *Project) !void { /// Project output assets will be exported to a join of parent_folder and the individual output paths for each asset pub fn exportAssets(project: *Project) !void { + const atlas = fizzy.packer.atlas orelse return; + if (project.packed_atlas_output) |packed_atlas_output| { - try fizzy.editor.atlas.save(packed_atlas_output, .data); + try atlas.save(packed_atlas_output, .data); } if (project.packed_image_output) |packed_image_output| { - try fizzy.editor.atlas.save(packed_image_output, .source); + try atlas.save(packed_image_output, .source); } // if (project.packed_heightmap_output) |packed_heightmap_output| { // const path = try std.fs.path.joinZ(fizzy.pixelart.host.arena(), &.{ parent_folder, packed_heightmap_output }); - // try fizzy.editor.atlas.save(path, .heightmap); + // try atlas.save(path, .heightmap); // } } diff --git a/src/plugins/pixelart/Tools.zig b/src/plugins/pixelart/Tools.zig index 9f00c6a6..2dc496b1 100644 --- a/src/plugins/pixelart/Tools.zig +++ b/src/plugins/pixelart/Tools.zig @@ -334,7 +334,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 }); defer mode_row.deinit(); - const atlas_size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 }; + const atlas_size: dvui.Size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; var mode_color = dvui.themeGet().color(.control, .fill_hover); if (fizzy.pixelart.colors.file_tree_palette) |*palette| { @@ -377,9 +377,9 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 defer mode_col.deinit(); const sprite = switch (mode) { - .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], - .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], - .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], + .box => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default], + .pixel => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default], + .color => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default], }; const uv = dvui.Rect{ .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w, @@ -430,7 +430,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 rs.r.w = width; rs.r.h = height; - dvui.renderImage(fizzy.editor.atlas.source, rs, .{ + dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{ .uv = uv, .fade = 0.0, }) catch { diff --git a/src/plugins/pixelart/dialogs/Export.zig b/src/plugins/pixelart/dialogs/Export.zig index d05e66ab..a6a1fd64 100644 --- a/src/plugins/pixelart/dialogs/Export.zig +++ b/src/plugins/pixelart/dialogs/Export.zig @@ -281,9 +281,9 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void break :blk default_filename; }; - fizzy.backend.showSaveFileDialog( + fizzy.pixelart.host.showSaveDialog( saveAnimationCallback, - &[_]fizzy.backend.DialogFileFilter{.{ .name = "GIF", .pattern = "gif" }}, + &[_]fizzy.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }}, default, null, // Passing null here means use the last save folder location ); @@ -304,9 +304,9 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void }; defer fizzy.app.allocator.free(default); - fizzy.backend.showSaveFileDialog( + fizzy.pixelart.host.showSaveDialog( exportCurrentSpriteCallback, - &[_]fizzy.backend.DialogFileFilter{ + &[_]fizzy.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -328,9 +328,9 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void }; defer fizzy.app.allocator.free(default); - fizzy.backend.showSaveFileDialog( + fizzy.pixelart.host.showSaveDialog( exportLayerCallback, - &[_]fizzy.backend.DialogFileFilter{ + &[_]fizzy.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -352,9 +352,9 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void }; defer fizzy.app.allocator.free(default); - fizzy.backend.showSaveFileDialog( + fizzy.pixelart.host.showSaveDialog( exportAllCallback, - &[_]fizzy.backend.DialogFileFilter{ + &[_]fizzy.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, diff --git a/src/plugins/pixelart/explorer/project.zig b/src/plugins/pixelart/explorer/project.zig index 8988377c..63bc7528 100644 --- a/src/plugins/pixelart/explorer/project.zig +++ b/src/plugins/pixelart/explorer/project.zig @@ -315,7 +315,7 @@ fn pathTextEntry(path_type: PathType) !void { break :blk true; }; - fizzy.backend.showSaveFileDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{ + fizzy.pixelart.host.showSaveDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{ if (path_type == .atlas) .{ .name = "Atlas Data", .pattern = "atlas" } else .{ .name = "Atlas Image", .pattern = "png;jpg;jpeg" }, }, "", if (valid_path) output_path.* else null); set_text = true; diff --git a/src/plugins/pixelart/explorer/tools.zig b/src/plugins/pixelart/explorer/tools.zig index d3da9a3e..d26445b1 100644 --- a/src/plugins/pixelart/explorer/tools.zig +++ b/src/plugins/pixelart/explorer/tools.zig @@ -171,16 +171,16 @@ pub fn drawTools() !void { } const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) { - .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], - .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], - .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], + .pixel => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default], + .box => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default], + .color => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default], }; const sprite = switch (tool) { - .pointer => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.cursor_default], - .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default], - .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default], - .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default], + .pointer => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.cursor_default], + .pencil => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pencil_default], + .eraser => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.eraser_default], + .bucket => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.bucket_default], .selection => selection_sprite, }; var button: dvui.ButtonWidget = undefined; @@ -210,7 +210,7 @@ pub fn drawTools() !void { button.data().options.color_border = color; } - const size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 }; + const size: dvui.Size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; const uv = dvui.Rect{ .x = @as(f32, @floatFromInt(sprite.source[0])) / size.w, @@ -232,7 +232,7 @@ pub fn drawTools() !void { rs.r.w = width; rs.r.h = height; - dvui.renderImage(fizzy.editor.atlas.source, rs, .{ + dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{ .uv = uv, .fade = 0.0, }) catch { diff --git a/src/plugins/pixelart/sprite_render.zig b/src/plugins/pixelart/sprite_render.zig index 58d12c47..efd519dd 100644 --- a/src/plugins/pixelart/sprite_render.zig +++ b/src/plugins/pixelart/sprite_render.zig @@ -1,9 +1,8 @@ //! Sprite/atlas rendering library for the pixel-art plugin. //! -//! Consumes packed-atlas output (Atlas/Sprite types) and renders sprites -//! (including the cover-flow water reflection mesh). Lives in the pixel-art -//! plugin but is consumed by the shell/workbench to draw sprites from a -//! packed atlas (cursors, icons, document previews). +//! Heavy rendering on top of `core.Sprite` rects: layer compositing, file previews, +//! reflections, and water-surface meshes. Shell/workbench UI icons use +//! `fizzy.core.Sprite.draw` from core instead of this module. const std = @import("std"); const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); @@ -12,7 +11,7 @@ pub const SpriteInitOptions = struct { source: dvui.ImageSource, file: ?*fizzy.Internal.File = null, alpha_source: ?dvui.ImageSource = null, - sprite: fizzy.Atlas.Sprite, + sprite: fizzy.core.Sprite, scale: f32 = 1.0, depth: f32 = 0.0, // -1.0 is front, 1.0 is back reflection: bool = false, @@ -647,7 +646,7 @@ pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, optio return builder.build(); } -pub fn renderSprite(source: dvui.ImageSource, s: fizzy.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void { +pub fn renderSprite(source: dvui.ImageSource, s: fizzy.core.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void { const atlas_size = dvui.imageSize(source) catch { std.log.err("Failed to get atlas size", .{}); return; diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig index 4f3e1143..217c3209 100644 --- a/src/plugins/pixelart/widgets/FileWidget.zig +++ b/src/plugins/pixelart/widgets/FileWidget.zig @@ -4114,19 +4114,19 @@ pub fn drawCursor(self: *FileWidget) void { const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point); const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) { - .box => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], - .pixel => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], - .color => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], + .box => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default], + .pixel => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default], + .color => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default], }; if (switch (fizzy.pixelart.tools.current) { - .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default], - .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default], - .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default], + .pencil => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pencil_default], + .eraser => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.eraser_default], + .bucket => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.bucket_default], .selection => selection_sprite, else => null, }) |sprite| { - const atlas_size = dvui.imageSize(fizzy.editor.atlas.source) catch { + const atlas_size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch { dvui.log.err("Failed to get atlas size", .{}); return; }; @@ -4164,7 +4164,7 @@ pub fn drawCursor(self: *FileWidget) void { const rs = box.data().rectScale(); - dvui.renderImage(fizzy.editor.atlas.source, rs, .{ + dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{ .uv = uv, }) catch { dvui.log.err("Failed to render cursor image", .{}); diff --git a/src/plugins/workbench/Workspace.zig b/src/plugins/workbench/Workspace.zig index ada28cdf..000429a6 100644 --- a/src/plugins/workbench/Workspace.zig +++ b/src/plugins/workbench/Workspace.zig @@ -297,11 +297,7 @@ fn drawTabs(self: *Workspace) void { } if (is_fizzy_file) { - _ = fizzy.sprite_render.sprite(@src(), .{ - .source = fizzy.editor.atlas.source, - .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default], - .scale = 2.0, - }, .{ + _ = fizzy.core.Sprite.draw(fizzy.editor.atlas.sprites[fizzy.atlas.sprites.logo_default], @src(), fizzy.editor.atlas.source, 2.0, .{ .gravity_y = 0.5, .padding = dvui.Rect.all(4), }); diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/files.zig index 01c87a5f..1e07143a 100644 --- a/src/plugins/workbench/files.zig +++ b/src/plugins/workbench/files.zig @@ -771,11 +771,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color; if (ext == .fizzy) { - _ = fizzy.sprite_render.sprite( - @src(), - .{ .source = fizzy.editor.atlas.source, .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default], .scale = 2.0 }, - .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false }, - ); + _ = fizzy.core.Sprite.draw( + fizzy.editor.atlas.sprites[fizzy.atlas.sprites.logo_default], + @src(), + fizzy.editor.atlas.source, + 2.0, + .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false }, + ); } else { dvui.icon( @src(), diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index f94f2e2a..db08f64d 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -11,6 +11,30 @@ const dvui = @import("dvui"); const EditorAPI = @This(); +/// Sub-rect within the shell UI spritesheet. Layout matches `core.Sprite`. +pub const UiSprite = struct { + origin: [2]f32 = .{ 0.0, 0.0 }, + source: [4]u32, +}; + +/// Read-only view of the shell's UI icon atlas (source texture + sprite table). +pub const UiAtlasView = struct { + source: dvui.ImageSource, + sprites: []const UiSprite, +}; + +/// A name/extension-pattern pair for a native save dialog. Layout matches the backend's +/// `DialogFileFilter` (which mirrors `SDL_DialogFileFilter`), so the shell forwards a slice +/// of these straight to the backend without a copy. `pattern` is a `;`-separated extension +/// list, e.g. `"png;jpg;jpeg"`. +pub const SaveDialogFilter = extern struct { + name: [*:0]const u8, + pattern: [*:0]const u8, +}; + +/// Invoked when a native save dialog resolves: the chosen paths, or null if cancelled. +pub const SaveDialogCallback = *const fn (?[][:0]const u8) void; + ctx: *anyopaque, vtable: *const VTable, @@ -34,6 +58,18 @@ pub const VTable = struct { /// The explorer scroll area's virtual content size (shell layout). Zero size when no /// shell is installed. explorerVirtualSize: *const fn (ctx: *anyopaque) dvui.Size, + /// Run the platform's native "save file" dialog (native: OS dialog; web: download + /// picker). `cb` is invoked when it resolves. No-op when no shell is installed. + showSaveDialog: *const fn ( + ctx: *anyopaque, + cb: SaveDialogCallback, + filters: []const SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, + ) void, + /// Shell-owned UI icon spritesheet (cursors, tool icons, logo). Stable for the + /// editor lifetime; plugins read `.source` / `.sprites` but never mutate it. + uiAtlas: *const fn (ctx: *anyopaque) UiAtlasView, }; pub fn arena(self: EditorAPI) std.mem.Allocator { @@ -67,3 +103,17 @@ pub fn explorerRect(self: EditorAPI) dvui.Rect { pub fn explorerVirtualSize(self: EditorAPI) dvui.Size { return self.vtable.explorerVirtualSize(self.ctx); } + +pub fn showSaveDialog( + self: EditorAPI, + cb: SaveDialogCallback, + filters: []const SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + self.vtable.showSaveDialog(self.ctx, cb, filters, default_filename, default_folder); +} + +pub fn uiAtlas(self: EditorAPI) UiAtlasView { + return self.vtable.uiAtlas(self.ctx); +} diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 33bdec8e..5b0cad6f 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -130,6 +130,22 @@ pub fn explorerVirtualSize(self: *Host) dvui.Size { return if (self.shell_api) |a| a.explorerVirtualSize() else .{}; } +/// Run the platform's native "save file" dialog. No-op if no shell installed (headless/test). +pub fn showSaveDialog( + self: *Host, + cb: EditorAPI.SaveDialogCallback, + filters: []const EditorAPI.SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + if (self.shell_api) |a| a.showSaveDialog(cb, filters, default_filename, default_folder); +} + +/// Shell-owned UI icon spritesheet. Asserts the shell is installed. +pub fn uiAtlas(self: *Host) EditorAPI.UiAtlasView { + return self.shell_api.?.uiAtlas(); +} + // ---- per-plugin settings store --------------------------------------------- /// The stored settings blob for `id` (serialized JSON), or null if none. The returned diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index 7e5e6992..dbd90bb7 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -19,3 +19,7 @@ pub const SettingsSection = regions.SettingsSection; /// Shell-provided read/utility surface plugins reach through the `Host` /// (arena, folder, shared settings, dirty-marking). pub const EditorAPI = @import("EditorAPI.zig"); +pub const SaveDialogFilter = EditorAPI.SaveDialogFilter; +pub const SaveDialogCallback = EditorAPI.SaveDialogCallback; +pub const UiSprite = EditorAPI.UiSprite; +pub const UiAtlasView = EditorAPI.UiAtlasView; From ceab790d219768ba2afc053736cbd4e23d6669c6 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 15:55:40 -0500 Subject: [PATCH 20/49] Phase 4 stage d --- CLA.md | 88 --- CONTRIBUTING.md | 36 -- HANDOFF.md | 556 ++++++++--------- build.zig | 66 +- contributor.md | 60 -- .../process_assets.zig => process_assets.zig | 2 +- src/App.zig | 16 +- src/{ => backend}/auto_update.zig | 0 src/{ => backend}/backend_native.zig | 2 +- src/{ => backend}/backend_web.zig | 4 +- src/{ => backend}/file_assoc.zig | 0 .../msvc_translatec_shim/stdint.h | 0 src/{ => backend}/objc/FizzyMenuTarget.m | 0 src/{ => backend}/objc/FizzyTrackpadGesture.m | 0 .../objc/FizzyVisualEffectView.m | 0 src/{ => backend}/objc/FizzyWindowMonitor.m | 6 +- src/{ => backend}/singleton.zig | 0 src/{ => backend}/singleton_native.zig | 2 +- src/{ => backend}/singleton_web.zig | 0 src/{ => backend}/update_install.zig | 0 src/{ => backend}/update_notify.zig | 2 +- src/{ => backend}/web_io.zig | 0 src/{ => backend}/window_layout.zig | 2 +- src/editor/Editor.zig | 377 ++++++++--- src/editor/Infobar.zig | 2 +- src/editor/Keybinds.zig | 2 +- src/editor/Menu.zig | 3 +- src/editor/WebFileIo.zig | 8 +- src/editor/dialogs/AboutFizzy.zig | 4 +- src/editor/dialogs/AppQuitUnsaved.zig | 7 +- src/editor/dialogs/Dialogs.zig | 9 +- src/editor/dialogs/UnsavedClose.zig | 6 +- src/editor/explorer/Explorer.zig | 6 +- src/editor/panel/Panel.zig | 3 - src/fizzy.zig | 66 +- src/plugins/pixelart/Colors.zig | 10 - src/plugins/pixelart/module.zig | 51 ++ src/plugins/pixelart/pixelart.zig | 54 ++ src/plugins/pixelart/{ => src}/Animation.zig | 0 src/plugins/pixelart/{ => src}/Atlas.zig | 0 src/plugins/pixelart/{ => src}/CanvasData.zig | 56 +- src/plugins/pixelart/src/Colors.zig | 11 + src/plugins/pixelart/src/Docs.zig | 37 ++ src/plugins/pixelart/{ => src}/File.zig | 29 +- src/plugins/pixelart/src/Globals.zig | 15 + .../pixelart/{ => src}/LDTKTileset.zig | 1 - src/plugins/pixelart/{ => src}/Layer.zig | 0 src/plugins/pixelart/{ => src}/PackJob.zig | 63 +- src/plugins/pixelart/{ => src}/Packer.zig | 77 +-- src/plugins/pixelart/{ => src}/Project.zig | 20 +- src/plugins/pixelart/{ => src}/Settings.zig | 15 +- src/plugins/pixelart/{ => src}/Sprite.zig | 0 .../pixelart/{PixelArt.zig => src/State.zig} | 71 ++- src/plugins/pixelart/{ => src}/Tools.zig | 27 +- src/plugins/pixelart/{ => src}/Transform.zig | 47 +- .../{ => src}/algorithms/algorithms.zig | 0 .../{ => src}/algorithms/brezenham.zig | 5 +- .../pixelart/{ => src}/algorithms/reduce.zig | 0 .../deps/msf_gif/fizzy_msf_gif_wasm.c | 0 .../pixelart/{ => src}/deps/msf_gif/msf_gif.c | 0 .../pixelart/{ => src}/deps/msf_gif/msf_gif.h | 0 .../{ => src}/deps/msf_gif/msf_gif.zig | 0 .../{ => src}/deps/msf_gif/wasm_shim/string.h | 0 .../{ => src}/deps/stbi/fizzy_stbi_libc.c | 0 .../{ => src}/deps/stbi/stb_image_resize2.h | 0 .../{ => src}/deps/stbi/stb_rect_pack.h | 0 .../pixelart/{ => src}/deps/stbi/zstbi.c | 0 .../pixelart/{ => src}/deps/stbi/zstbi.zig | 0 .../pixelart/{ => src}/deps/zip/build.zig | 0 .../{ => src}/deps/zip/fizzy_zip_libc.c | 0 .../{ => src}/deps/zip/fizzy_zip_strings.c | 0 .../{ => src}/deps/zip/fizzy_zip_wasm.h | 0 .../pixelart/{ => src}/deps/zip/src/miniz.h | 0 .../pixelart/{ => src}/deps/zip/src/zip.c | 0 .../pixelart/{ => src}/deps/zip/src/zip.h | 0 .../pixelart/{ => src}/deps/zip/zip.zig | 0 .../pixelart/{ => src}/dialogs/Export.zig | 161 ++--- .../dialogs/FlatRasterSaveWarning.zig | 45 +- .../pixelart/{ => src}/dialogs/GridLayout.zig | 121 ++-- .../pixelart/{ => src}/dialogs/NewFile.zig | 33 +- .../pixelart/{ => src}/explorer/project.zig | 67 +- .../pixelart/{ => src}/explorer/sprites.zig | 234 +++---- .../pixelart/{ => src}/explorer/tools.zig | 159 ++--- .../pixelart/{ => src}/internal/Animation.zig | 0 .../pixelart/{ => src}/internal/Atlas.zig | 29 +- .../pixelart/{ => src}/internal/Buffers.zig | 35 +- .../pixelart/{ => src}/internal/File.zig | 588 +++++++++--------- .../pixelart/{ => src}/internal/History.zig | 136 ++-- .../pixelart/{ => src}/internal/Layer.zig | 89 +-- .../pixelart/{ => src}/internal/Palette.zig | 11 +- .../pixelart/{ => src}/internal/Sprite.zig | 0 .../internal/grid_layout_validate.zig | 0 .../{ => src}/internal/layer_order.zig | 0 .../{ => src}/internal/palette_parse.zig | 0 .../pixelart/{ => src}/panel/sprites.zig | 48 +- src/plugins/pixelart/{ => src}/plugin.zig | 74 ++- src/plugins/pixelart/{ => src}/render.zig | 83 +-- .../pixelart/{ => src}/sprite_render.zig | 21 +- .../{ => src}/widgets/CanvasBridge.zig | 11 +- .../pixelart/{ => src}/widgets/FileWidget.zig | 326 +++++----- .../{ => src}/widgets/ImageWidget.zig | 35 +- src/plugins/workbench/module.zig | 11 + .../workbench/{ => src}/FileLoadJob.zig | 2 +- src/plugins/workbench/{ => src}/Workbench.zig | 4 +- src/plugins/workbench/{ => src}/Workspace.zig | 54 +- src/plugins/workbench/{ => src}/files.zig | 5 +- src/plugins/workbench/{ => src}/plugin.zig | 2 +- src/plugins/workbench/workbench.zig | 9 + src/sdk/EditorAPI.zig | 176 ++++++ src/sdk/Host.zig | 118 ++++ src/web_main.zig | 2 +- 111 files changed, 2547 insertions(+), 2066 deletions(-) delete mode 100644 CLA.md delete mode 100644 CONTRIBUTING.md delete mode 100644 contributor.md rename src/tools/process_assets.zig => process_assets.zig (98%) rename src/{ => backend}/auto_update.zig (100%) rename src/{ => backend}/backend_native.zig (99%) rename src/{ => backend}/backend_web.zig (98%) rename src/{ => backend}/file_assoc.zig (100%) rename src/{tools => backend}/msvc_translatec_shim/stdint.h (100%) rename src/{ => backend}/objc/FizzyMenuTarget.m (100%) rename src/{ => backend}/objc/FizzyTrackpadGesture.m (100%) rename src/{ => backend}/objc/FizzyVisualEffectView.m (100%) rename src/{ => backend}/objc/FizzyWindowMonitor.m (99%) rename src/{ => backend}/singleton.zig (100%) rename src/{ => backend}/singleton_native.zig (99%) rename src/{ => backend}/singleton_web.zig (100%) rename src/{ => backend}/update_install.zig (100%) rename src/{ => backend}/update_notify.zig (99%) rename src/{ => backend}/web_io.zig (100%) rename src/{ => backend}/window_layout.zig (99%) delete mode 100644 src/plugins/pixelart/Colors.zig create mode 100644 src/plugins/pixelart/module.zig create mode 100644 src/plugins/pixelart/pixelart.zig rename src/plugins/pixelart/{ => src}/Animation.zig (100%) rename src/plugins/pixelart/{ => src}/Atlas.zig (100%) rename src/plugins/pixelart/{ => src}/CanvasData.zig (96%) create mode 100644 src/plugins/pixelart/src/Colors.zig create mode 100644 src/plugins/pixelart/src/Docs.zig rename src/plugins/pixelart/{ => src}/File.zig (84%) create mode 100644 src/plugins/pixelart/src/Globals.zig rename src/plugins/pixelart/{ => src}/LDTKTileset.zig (87%) rename src/plugins/pixelart/{ => src}/Layer.zig (100%) rename src/plugins/pixelart/{ => src}/PackJob.zig (93%) rename src/plugins/pixelart/{ => src}/Packer.zig (81%) rename src/plugins/pixelart/{ => src}/Project.zig (82%) rename src/plugins/pixelart/{ => src}/Settings.zig (95%) rename src/plugins/pixelart/{ => src}/Sprite.zig (100%) rename src/plugins/pixelart/{PixelArt.zig => src/State.zig} (61%) rename src/plugins/pixelart/{ => src}/Tools.zig (93%) rename src/plugins/pixelart/{ => src}/Transform.zig (85%) rename src/plugins/pixelart/{ => src}/algorithms/algorithms.zig (100%) rename src/plugins/pixelart/{ => src}/algorithms/brezenham.zig (86%) rename src/plugins/pixelart/{ => src}/algorithms/reduce.zig (100%) rename src/plugins/pixelart/{ => src}/deps/msf_gif/fizzy_msf_gif_wasm.c (100%) rename src/plugins/pixelart/{ => src}/deps/msf_gif/msf_gif.c (100%) rename src/plugins/pixelart/{ => src}/deps/msf_gif/msf_gif.h (100%) rename src/plugins/pixelart/{ => src}/deps/msf_gif/msf_gif.zig (100%) rename src/plugins/pixelart/{ => src}/deps/msf_gif/wasm_shim/string.h (100%) rename src/plugins/pixelart/{ => src}/deps/stbi/fizzy_stbi_libc.c (100%) rename src/plugins/pixelart/{ => src}/deps/stbi/stb_image_resize2.h (100%) rename src/plugins/pixelart/{ => src}/deps/stbi/stb_rect_pack.h (100%) rename src/plugins/pixelart/{ => src}/deps/stbi/zstbi.c (100%) rename src/plugins/pixelart/{ => src}/deps/stbi/zstbi.zig (100%) rename src/plugins/pixelart/{ => src}/deps/zip/build.zig (100%) rename src/plugins/pixelart/{ => src}/deps/zip/fizzy_zip_libc.c (100%) rename src/plugins/pixelart/{ => src}/deps/zip/fizzy_zip_strings.c (100%) rename src/plugins/pixelart/{ => src}/deps/zip/fizzy_zip_wasm.h (100%) rename src/plugins/pixelart/{ => src}/deps/zip/src/miniz.h (100%) rename src/plugins/pixelart/{ => src}/deps/zip/src/zip.c (100%) rename src/plugins/pixelart/{ => src}/deps/zip/src/zip.h (100%) rename src/plugins/pixelart/{ => src}/deps/zip/zip.zig (100%) rename src/plugins/pixelart/{ => src}/dialogs/Export.zig (85%) rename src/plugins/pixelart/{ => src}/dialogs/FlatRasterSaveWarning.zig (79%) rename src/plugins/pixelart/{ => src}/dialogs/GridLayout.zig (93%) rename src/plugins/pixelart/{ => src}/dialogs/NewFile.zig (90%) rename src/plugins/pixelart/{ => src}/explorer/project.zig (89%) rename src/plugins/pixelart/{ => src}/explorer/sprites.zig (90%) rename src/plugins/pixelart/{ => src}/explorer/tools.zig (90%) rename src/plugins/pixelart/{ => src}/internal/Animation.zig (100%) rename src/plugins/pixelart/{ => src}/internal/Atlas.zig (76%) rename src/plugins/pixelart/{ => src}/internal/Buffers.zig (76%) rename src/plugins/pixelart/{ => src}/internal/File.zig (86%) rename src/plugins/pixelart/{ => src}/internal/History.zig (87%) rename src/plugins/pixelart/{ => src}/internal/Layer.zig (82%) rename src/plugins/pixelart/{ => src}/internal/Palette.zig (81%) rename src/plugins/pixelart/{ => src}/internal/Sprite.zig (100%) rename src/plugins/pixelart/{ => src}/internal/grid_layout_validate.zig (100%) rename src/plugins/pixelart/{ => src}/internal/layer_order.zig (100%) rename src/plugins/pixelart/{ => src}/internal/palette_parse.zig (100%) rename src/plugins/pixelart/{ => src}/panel/sprites.zig (97%) rename src/plugins/pixelart/{ => src}/plugin.zig (86%) rename src/plugins/pixelart/{ => src}/render.zig (92%) rename src/plugins/pixelart/{ => src}/sprite_render.zig (97%) rename src/plugins/pixelart/{ => src}/widgets/CanvasBridge.zig (66%) rename src/plugins/pixelart/{ => src}/widgets/FileWidget.zig (95%) rename src/plugins/pixelart/{ => src}/widgets/ImageWidget.zig (93%) create mode 100644 src/plugins/workbench/module.zig rename src/plugins/workbench/{ => src}/FileLoadJob.zig (99%) rename src/plugins/workbench/{ => src}/Workbench.zig (98%) rename src/plugins/workbench/{ => src}/Workspace.zig (95%) rename src/plugins/workbench/{ => src}/files.zig (99%) rename src/plugins/workbench/{ => src}/plugin.zig (98%) create mode 100644 src/plugins/workbench/workbench.zig diff --git a/CLA.md b/CLA.md deleted file mode 100644 index 6a8bf745..00000000 --- a/CLA.md +++ /dev/null @@ -1,88 +0,0 @@ -# Fizzy Contributor License Agreement (CLA) - -Thank you for your interest in contributing to fizzy (the "Project"), maintained -by Colton Franklin ("Maintainer"). This Contributor License Agreement ("CLA") -clarifies the intellectual property rights granted with each contribution. - -This CLA is adapted from the "inbound = outbound + relicense" pattern used by -many dual-licensed open-source projects. You retain ownership of your -contributions; this document only grants the Maintainer the rights needed to -distribute and dual-license the Project as a whole. - -**You** ("Contributor") agree to the following terms for any contribution you -submit (via pull request, patch, or any other means) to the Project. The -Maintainer accepts your contribution under these terms. - -## 1. Definitions - -- **"Contribution"** means any source code, documentation, asset, or other - work of authorship that you intentionally submit to the Project. -- **"Submit"** means any form of communication sent to the Maintainer or - Project, including pull requests, issues, patches, and electronic - discussion, but excluding communication explicitly marked "Not a - Contribution." - -## 2. Copyright License Grant - -You hereby grant to the Maintainer, and to recipients of software distributed -by the Maintainer, a perpetual, worldwide, non-exclusive, no-charge, -royalty-free, irrevocable copyright license to reproduce, prepare derivative -works of, publicly display, publicly perform, sublicense, and distribute your -Contribution and such derivative works **under any license terms, including -proprietary and commercial license terms.** This explicitly includes the -right to relicense your Contribution as part of the Project under different -terms (for example, alongside the Project's GNU GPL v3.0 license, under a -separate paid commercial license). - -You retain all right, title, and interest in your Contribution; this is a -license, not an assignment. - -## 3. Patent License Grant - -You hereby grant to the Maintainer and recipients of software distributed by -the Maintainer a perpetual, worldwide, non-exclusive, no-charge, royalty-free, -irrevocable (except as stated in this section) patent license to make, have -made, use, offer to sell, sell, import, and otherwise transfer your -Contribution, where such license applies only to those patent claims -licensable by you that are necessarily infringed by your Contribution alone -or by combination of your Contribution with the Project to which it was -submitted. - -If any entity institutes patent litigation against you or any other entity -(including a cross-claim or counterclaim in a lawsuit) alleging that your -Contribution, or the Project to which you have contributed, constitutes -direct or contributory patent infringement, then any patent licenses granted -to that entity under this CLA for that Contribution or Project shall -terminate as of the date such litigation is filed. - -## 4. Your Representations - -You represent that: - -1. Each of your Contributions is your original creation, or you have the - right to submit it under this CLA. -2. Your Contribution does not violate any third party's intellectual - property rights, contracts, or other obligations (including, if - applicable, any agreement with your employer). -3. If your employer has rights to intellectual property you create, you have - either (a) received permission to make Contributions on behalf of that - employer, (b) had your employer waive such rights for your Contributions, - or (c) had your employer also sign this CLA. - -You agree to notify the Maintainer if any of these representations becomes -inaccurate. - -## 5. No Obligation - -You are not expected to provide support for your Contributions, except to the -extent you desire to provide support. Unless required by applicable law or -agreed to in writing, you provide your Contributions on an "AS IS" basis, -without warranties or conditions of any kind, either express or implied. - -## 6. Acceptance - -You accept this CLA by submitting a pull request after this CLA is in place, -or by explicitly indicating agreement in a manner the Maintainer accepts -(for example, signing via [CLA Assistant](https://cla-assistant.io/) on a -pull request, or replying to an issue with the exact text "I have read the -CLA Document and I hereby sign the CLA"). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 5a48ddaa..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,36 +0,0 @@ -# Contributing to fizzy - -Thanks for your interest in contributing! - -## License & CLA - -fizzy is licensed under the [GNU General Public License v3.0](LICENSE). - -To keep the door open for the project to offer a separate commercial license -in addition to the GPL, **all contributors must sign the -[Contributor License Agreement](CLA.md)** before their pull request can be -merged. - -You retain copyright on your contributions — the CLA only grants the -maintainer (Colton Franklin) the rights needed to relicense the project as a -whole, including under future commercial terms. - -### How to sign - -A [CLA Assistant](https://cla-assistant.io/) bot is wired up to this -repository. The first time you open a pull request, it will post a comment -with a one-click sign-off link. After you sign once, subsequent PRs are -auto-checked against your signature — no further action needed. - -## Pull requests - -- Keep changes focused. One concern per PR. -- Match the style of the surrounding code. Run `zig build` locally before - pushing. -- Reference any related issue in the PR description. - -## Reporting issues - -Use the issue tracker for bug reports and feature requests. For bug reports, -include OS, fizzy version (visible in the title bar / `Help > About`), and -steps to reproduce. diff --git a/HANDOFF.md b/HANDOFF.md index a4a4a5b9..7a780283 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,4 +1,4 @@ -# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, mid Stage C) +# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, Stage D in progress) ## TL;DR @@ -7,127 +7,21 @@ makes `core` a real, separately-wired Zig module with no dependency on the `fizz app hub, then (Stages B–E) lifts the pixel-art editor fully behind the plugin SDK so it can become its own compile-time module. -**Done:** Stage A1, A2, A3, B, and **Stage C part 1 (per-plugin settings)**. -**Next:** Stage C remainder (doc/tab/host/arena/folder decoupling) + the sprite/atlas → -`core` extraction. Then D, E. - -> **Read this first if you're a fresh agent:** the immediately actionable work is in -> "Stage C — remaining work" and "Next big rock: sprite/atlas → core" near the bottom. -> Several items there are now low-effort because the SDK surface they need already exists. - -## What Stage B did - -Lifted the pixel-art editor state off the shell `Editor` into a plugin-owned -`PixelArt` struct (`src/plugins/pixelart/PixelArt.zig`), reached via a new -`fizzy.pixelart: *PixelArt` global (mirrors the existing `fizzy.packer`). - -- **Fields moved:** `tools`, `colors`, `project`, `sprite_clipboard`, `pack_jobs` - (plus the `SpriteClipboard` type). ~190 `editor.` / `fizzy.editor.` - call sites repointed to `fizzy.pixelart.` across `Editor.zig`, `Keybinds.zig`, - `workbench/files.zig`, and the pixel-art tree. -- **`atlas` deliberately stayed on the shell** — it's the shared UI icon spritesheet - (cursor/pencil/logo/selection icons) the shell uses for its own logo - (`workbench/files.zig`, `Workspace.zig`), not pixel-art-specific. Moving it would - invert the dependency. (Its type is still `Internal.Atlas`; relocating that type to - core is a later structural question, not Stage B.) -- **Lifecycle:** `fizzy.pixelart` is allocated + `PixelArt.init`'d in `App.AppInit` - *before* `editor.postInit()` so the pixel-art `plugin.register` adopts it as - `plugin.state`. `PixelArt.init` now owns the tools init + the two `fizzy.hex` palette - loads (moved out of `Editor.init`). `PixelArt.deinit` (pack-job cancel, palette free, - project save, tools free) runs from `App.AppDeinit` right after `editor.deinit()`; - the old interleaved pixel-art teardown blocks were removed from `Editor.deinit`. -- Three Editor helpers (`processHoldOpenRadialMenu`, `isPackingActive`, - `runWasmPackWorkers`) now ignore their `editor` param (`_: *Editor`) since they only - reach `fizzy.pixelart`. The pack methods (`startPackProject`/`processPackJob`/…) and - the copy-paste / radial-menu draw code still live on `Editor` — they relocate later. -- Type aliases on `Editor` (`pub const Tools/Colors/Project/Transform`) were left in - place; they're used as type paths (`Editor.Tools.Tool`) and move in Stage D. - -Verified green: `zig build`, `zig build check-web`, `zig build test`. (No live GUI -run — pure refactor.) - -## What Stage C part 1 did — per-plugin settings (VSCode-style) - -Goal (set by the user): pixel-art-specific settings should **belong to the pixel-art -plugin**, and the Settings tab should be a registry that each plugin contributes its own -section to, grouped by plugin. The shell stores plugin settings opaquely but never -interprets them. - -### New SDK surface (all in `src/sdk/`) - -- **`SettingsSection`** (`regions.zig`, exported from `sdk.zig`): `{ id, owner, title, draw }`. - The Settings sidebar view renders each registered section under its `title` heading. -- **`Host` additions** (`Host.zig`): - - `settings_sections` registry + `registerSettingsSection`. - - `plugin_settings: PluginSettings` (= `StringArrayHashMapUnmanaged([]const u8)`): the - opaque per-plugin blob store (id → serialized JSON). `loadPluginSettings(id)` / - `storePluginSettings(id, json)` (the latter dupes + marks shell settings dirty). Host - owns + frees the key/value strings in `deinit`. - - `shell_api: ?EditorAPI` + `installShell(api)` + thin forwarders: `arena()`, `folder()`, - `paletteFolder()`, `markSettingsDirty()`, `contentOpacity()`. -- **`EditorAPI`** (`EditorAPI.zig`): vtable + ctx the shell installs so plugins reach shared - shell state without importing `Editor`. The shell's vtable impl lives in `Editor.zig` - (`shell_api_vtable` + `shellArena`/`shellFolder`/… ; ctx is `*Editor`), installed in - `Editor.postInit`. - -### Storage / persistence (`src/editor/Settings.zig`) - -- On-disk format gained a `"plugins"` object: `{ , "plugins": { id: } }`. -- `Settings.serialize(settings, plugin_store, alloc)` serializes the struct, drops the - trailing `}`, and **textually splices** `,"plugins":{…}}` with each plugin's already- - serialized blob inline. (Robust — avoids `std.json.Value` lifetime hazards. Round-trip - validated with a standalone test: valid JSON, shell parses back via `ignore_unknown_fields`, - blobs re-extract cleanly.) -- `Settings.save(...)` and the autosave **dedup snapshot** (`settings_last_saved_json`) and - the three Editor save sites all now go through `serialize` so plugin-only changes still - trigger a write. (Watch: `Settings.save` is called from `saveSettingsGuarded`, - `saveSettingsRaw`, and the init snapshot — all four-arg now.) -- `Settings.loadPluginStore(alloc, path, store)` re-parses settings.json as a `Value`, - extracts the `"plugins"` object into the store. Called from `Editor.init` right after - `Settings.load`, before `PixelArt.init` runs (so the plugin can read its blob). -- **One-time migration:** a legacy *flat* settings.json (no `"plugins"`) seeds the - `"pixelart"` blob from the **whole root** — pixel art ignores unknown keys, so its moved - fields (`show_rulers`, `input_scheme`, …) survive; the next save rewrites the blob clean. - (Self-healing, no data loss. The blob is temporarily bloated with shell keys until then.) - -### Pixel-art side - -- New **`src/plugins/pixelart/Settings.zig`** (`PixelArt.Settings`, `pub`): owns the moved - fields + `InputScheme`/`ResolvedPanZoomScheme`/`TransparencyEffect` enums + - `resolvedPanZoomScheme`. `load(host)` parses its blob (defaults if absent/garbage; no - heap fields so returning by value after `parsed.deinit()` is safe). `save(host)` - serializes + `host.storePluginSettings`. `draw(_)` renders the section (Canvas group: - transparency effect, show rulers, cover-flow cards; Controls group: control scheme). -- `PixelArt` struct gained `host: *sdk.Host` and `settings: Settings`, both set in - `PixelArt.init(allocator, host)` (App now passes `&fizzy.editor.host`). -- `plugin.register` registers the `"pixelart"` settings section ("Pixel Art"). - -### Fields moved off shell `Settings` → `PixelArt.Settings` - -`input_scheme`, `show_rulers`, `scrolling_cards`, `ruler_padding`, `zoom_sensitivity`, -`zoom_steps`, `max_file_size`, `checker_color_even/odd`, `transparency_effect` (+ the -three enums + `resolvedPanZoomScheme`). All ~27 pixel-art read sites repointed to -`fizzy.pixelart.settings.`; type refs (`fizzy.Editor.Settings.TransparencyEffect`, -`…resolvedPanZoomScheme`) → `fizzy.PixelArt.Settings.…`. - -**`content_opacity` deliberately stays on the shell** — it's also read by `workbench/ -Workspace.zig` and `panel/Panel.zig`, so it's genuinely shell-level. Pixel art's 3 reads -go through `fizzy.pixelart.host.contentOpacity()` (the EditorAPI). The pixel-art settings -*UI controls* were removed from `editor/explorer/settings.zig` (the shell "Editor" section -now only has theme/fonts/window+content opacity/hold-timing/debugging). - -### Settings UI - -`Editor.drawSettingsPane` now iterates `host.settings_sections` and renders each under a -heading label (registration order = display order; shell "Editor" registered first in -`postInit`, before plugins). The shell section draw = `Explorer.settings.draw` (trimmed); -the pixel-art section draw = `PixelArt.Settings.draw`. - -Verified green: `zig build`, `zig build check-web`, `zig build test`. Persistence splice -round-trip checked with a throwaway `zig run` harness (valid JSON + clean extraction). No -live GUI run. - -All three build configs are green right now: +**Done:** Stage A1, A2, A3, B, and **Stage C (full)** — per-plugin settings, docs/tabs +storage inversion, save/pack/editor-action decoupling, platform detection, explorer pane +lift, sprites bottom-panel lift. + +**In progress:** **Stage D** — module scaffold (`module.zig`, `State.zig`, `pixelart.zig`, +`Globals.zig`), hub consolidation through `fizzy.pixelart_mod`, plugin import migration off +`fizzy.zig`. + +**Next:** wire `b.addModule("pixelart", …)` in `build.zig`, break `plugin.zig` → +`Editor.Workspace` dep. Then Stage E. + +> **Read this first if you're a fresh agent:** start at "Stage D — remaining work" +> below. All three build configs are green right now. + +All three build configs are green: ``` zig build # native exe @@ -135,209 +29,277 @@ zig build check-web # wasm zig build test # unit/integration tests ``` -Run all three after every stage. Note: `zig build` for this repo currently needs to -run outside the sandbox (network/file access), so expect to pass elevated permissions. +Run all three after every stage. `zig build` for this repo currently needs to run outside +the sandbox (network/file access). --- -## What `core` is now (Stage A3 result) +## Plugin directory layout (convention) -`src/core/` is a standalone module (`src/core/core.zig` is its root). It holds shared -infrastructure and **never imports `src/fizzy.zig`**: +Every plugin follows the same shape: ``` -src/core/ - core.zig # module root: gpa + trackpad hook + re-exports - dvui.zig # generic dvui hub: dialog framework, helpers, generic widgets - fs.zig paths.zig platform.zig Fling.zig - gfx/ image.zig perf.zig water_surface.zig - math/ math.zig color.zig direction.zig easing.zig layout_anchor.zig - widgets/ CanvasWidget PanedWidget ReorderWidget FloatingWindowWidget - TreeWidget TreeSelection - generated/ atlas.zig # written by the build's process-assets step +src/plugins// + module.zig # build module root / shell import surface + .zig # intra-plugin hub (sdk, core, Globals, shared types) + src/ # all implementation code +``` + +**pixelart** and **workbench** both use this layout now. + +| File | Role | +|------|------| +| `module.zig` | Compile-time module root; shell reaches it via `fizzy.pixelart_mod` / future `@import("pixelart")` | +| `pixelart.zig` / `workbench.zig` | Hub named after the plugin folder; files in `src/**` import as `../.zig` or `../../.zig` | +| `src/State.zig` | Plugin runtime state (`pixelart` only) | +| `src/Globals.zig` | Runtime injection: `gpa`, `state`, `packer` (`pixelart` only) | +| `src/plugin.zig` | Plugin registration + draw entry points | +| `src/deps/` | Third-party deps (`pixelart` only) | + +Shell still uses `fizzy.pixelart: *State` global during migration; plugin code uses +`Globals.state`. + +### macOS case-insensitive rename protocol + +On APFS (default, case-insensitive), `PixelArt.zig` and `pixelart.zig` are the **same +file**. Never create `pixelart.zig` while `PixelArt.zig` is still in git — it silently +overwrites the state struct. + +**Two-step git rename (Option A):** + +```bash +git mv src/plugins/pixelart/PixelArt.zig src/plugins/pixelart/__legacy_remove__.zig +git rm -f src/plugins/pixelart/__legacy_remove__.zig +# now safe to add src/plugins/pixelart/pixelart.zig and State.zig ``` -### Decoupling mechanisms (important invariants) - -- **Allocator injection.** `core.gpa` is a `std.mem.Allocator` set once at startup in - `App.init` (`fizzy.core.gpa = allocator;`). Core code (e.g. `gfx/image.zig`) allocates - through `core.gpa` instead of reaching into `fizzy.app.allocator`. -- **Trackpad hook.** `core.takeTrackpadPinchRatio` is a `*const fn () f32` set in - `App.init` to `fizzy.backend.takeTrackpadPinchRatio`. `CanvasWidget` calls the hook so - it doesn't depend on the heavy native backend. Defaults to a `1.0` no-op for headless/test. -- **Dialog chrome state moved into core.** `core.dvui.modal_dim_titlebar: bool` and - `core.dvui.dialog_close_rect_override: ?dvui.Rect.Physical` replaced the old - `Editor.dim_titlebar` field and `workbench/files.zig: new_file_close_rect` var. The - shell reads `fizzy.dvui.modal_dim_titlebar` in `Editor.setTitlebarColor`. -- **`fizzy.zig` re-exports core** so existing `fizzy.` call sites keep working: - `fizzy.image/fs/perf/water_surface/math/platform/paths/dvui/Fling/atlas` all alias - `core.*`, plus `pub const core = @import("core");`. -- **Widget split.** Generic widgets live in `core/widgets/` and are exposed as - `core.dvui.CanvasWidget` etc. The **pixel-art** `FileWidget` and `ImageWidget` stayed - in `src/plugins/pixelart/widgets/` (ImageWidget is still pixel-art-coupled). Consumers - import them locally, not via the hub. `src/editor/widgets/Widgets.zig` was deleted. - -### Build wiring - -`core` is created three times (one per target/backend variant) in `build.zig`: -- native exe: `core_module` (dvui_sdl3) — search `addImport("core"` -- web exe: `core_module_web` (dvui_web) -- test: `core_module_test` (dvui_testing) - -Each gets `dvui`, `known-folders`, and (lazy) `icons`. The generated atlas now writes to -`src/core/generated/`, and the inline test modules point at `src/core/math/*`. - -### Gotchas discovered (don't repeat these) - -- **Build-script / module file-ownership trap.** `build.zig` imports - `src/tools/process_assets.zig`, which imports `src/plugins/pixelart/Atlas.zig` to - generate the atlas index *at build time*. A file may belong to only one module within a - single compilation. Routing `Atlas.zig`'s file read through `fizzy.fs`/`core.fs` (a) - dragged the whole `fizzy`+`core` graph into the build-runner compilation (no `core` - module there) and (b) caused "file exists in modules 'core' and 'root'". **Fix applied:** - `Atlas.zig` now imports nothing but `std` and inlines its file read. Keep build-time - tools (`process_assets.zig` and anything it imports) free of `fizzy`/`core` module imports. -- **macOS case-insensitive FS.** `sprite.zig` vs `Sprite.zig` collide. The atlas-render - library is named `sprite_render.zig` for this reason. -- **Lazy top-level imports.** An unused `const fizzy = @import(...)` is fine (never - analyzed). Problems only appear when build-*reachable* code forces analysis. +**Import paths inside `src/`:** + +- `src/foo.zig` → `@import("../pixelart.zig")` +- `src/widgets/bar.zig` → `@import("../../pixelart.zig")` +- View ids (`view_tools`, `view_sprites`) live in `src/plugin.zig` — import as + `@import("../plugin.zig")` from nested dirs, not through the hub. --- -## Remaining stages +## What Stage C did (complete) + +### Part 1 — per-plugin settings (VSCode-style) + +Pixel-art-specific settings belong to the pixel-art plugin; the shell stores them opaquely. + +- **`SettingsSection`** in SDK; `Host` registry + `plugin_settings` blob store. +- **`EditorAPI`** vtable for shell reach-through (`arena`, `folder`, `paletteFolder`, …). +- **`Settings`** owns moved fields; `plugin.register` adds the "Pixel Art" section. +- Shell `Settings.serialize` splices `"plugins": { id: blob }` into settings.json. + +### Part 2 — docs/tabs storage inversion + +The shell no longer owns `Internal.File` values directly. -The plan tasks are tracked as todos `b`, `c`, `d`, `e`. The pixel-art plugin still has a -large coupling surface to the shell: ~250 `fizzy.editor.` / `fizzy.backend.` / -`fizzy.platform.` references across `src/plugins/pixelart/**` (biggest offenders: -`widgets/FileWidget.zig` ~80, `dialogs/Export.zig`, `internal/File.zig`, -`explorer/tools.zig`). Stages B–D systematically remove these. +- **`Docs.zig`**: plugin owns `files: HashMap(u64, Internal.File)`. +- **`Editor.open_files`**: `HashMap(u64, sdk.DocHandle)` — opaque handles with `ptr`/`id`/`owner`. +- **EditorAPI doc surface**: `activeDoc`, `docByIndex`, `docById`, `docIndex`, `openDocCount`, + `setActiveDocIndex`, `allocDocId`. +- Shell helpers: `fileFromDoc`, `docAt`, `fileAt`, `activeDoc`, `insertOpenDoc`, `closeDocumentResources`. +- Plugin repointed: `fizzy.pixelart.docs.activeFile(host)`, `host.docIndex` / `setActiveDocIndex`, + `host.allocDocId()`, `docs.fileById`, etc. +- **`State.docs`**: field + `docs.deinit` in teardown. -### Stage B — lift pixel-art editor state off the shell `Editor` -Move the pixel-art-specific fields (tools, colors, atlas, project, buffers, transform) -off `src/editor/Editor.zig` (~83 refs) into a `PixelArt` plugin-state struct owned by the -plugin. Update `Editor.zig`, `Keybinds` (~15 refs), and the `Menu`, plus the pixel-art -references that read those fields. Build green (all 3). +### Part 3 — save/pack/editor-action decoupling -### Stage C — expand the SDK Host (settings done; rest below) -Grow the SDK Host surface so the plugin reaches shell state via the SDK, not -`fizzy.editor`. **Part 1 (per-plugin settings) is done** — see "What Stage C part 1 did" -above. The remaining coupling and a recommended order are in "Stage C — remaining work". +Pixel-art dialogs and actions reach the shell through `host.*` / `EditorAPI`, not `fizzy.editor.*`. -### Stage D — make `pixelart` its own module -Add a `src/plugins/pixelart/pixelart.zig` module root; repoint all pixel-art imports from -`fizzy.zig` to `core` / `sdk` / `dvui` / local files; wire `b.addModule("pixelart", ...)` -in `build.zig` (3 configs, mirroring how `core` is wired); have `App` call -`pixelart.register(host)`. Build native + test + web. +**EditorAPI additions** (all wired in `Editor.zig` shell vtable + `Host.zig` forwarders): -### Stage E — strip pixel-art names from shell hubs -Remove pixel-art names from `fizzy.zig` / Dialogs / `Editor` / Explorer / Panel; route all -contributions through the SDK only. Final verification across the 3 configs. +`accept`, `cancel`, `copy`, `paste`, `transform`, `save`, `requestCompositeWarmup`, +`requestGridLayoutDialog`, `allocUntitledPath`, `createDocument`, `requestSaveAs`, +`requestWebSave`, `cancelPendingSaveDialog`, `setPendingCloseDocId`, `queueCloseAfterSave`, +`trackQuitSaveInFlight`, `resumeSaveAllQuit`, `abortSaveAllQuit`, `startPackProject`, +`isPackingActive`, `showSaveDialog`, `uiAtlas`, `explorerRect`, `explorerVirtualSize`, +`isMaximized`. + +### Part 4 — explorer pane + bottom-panel lift + +- **`tools_pane`**, **`sprites_pane`**, **`pinned_palettes`**, **`layers_ratio`** moved onto + `State` (were on shell `Explorer`). +- **`sprites_panel`** moved off `editor.panel.sprites` onto `State`; drawn via + `Globals.state.sprites_panel.draw()` from `plugin.zig`. + +### Part 5 — platform detection + +- **EditorAPI**: `isMacOS()`, `appliesNativeWindowOpacity()`. +- Plugin repointed: keybinds, window chrome opacity, `Settings.resolvedPanZoomScheme(settings, host)`. +- **Zero** live `fizzy.platform` / `builtin.os.tag` in `src/plugins/pixelart/**`. + +### Stage C sanity greps + +``` +grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live (4 commented-out lines in Tools.zig, Project.zig) +grep -rn 'fizzy\.platform' src/plugins/pixelart → 0 +grep -rn 'fizzy\.backend\.' src/plugins/pixelart → check; native save dialogs go through host.showSaveDialog +``` --- -## Stage C — remaining work (start here) +## What Stage D has done so far + +### Module root — `src/plugins/pixelart/module.zig` + +Canonical export surface for the plugin tree. **`fizzy.zig`** re-exports through +`fizzy.pixelart_mod = @import("plugins/pixelart/module.zig")` instead of scattering +direct `@import("plugins/pixelart/…")` across the hub. + +Exports: `Globals`, `State`, `Settings`, `Docs`, `Tools`, `Transform`, `Project`, +`Colors`, `Packer`, `PackJob`, `plugin`, `dialogs.*`, `explorer.project`, `render`, +`sprite_render`, `algorithms`, on-disk types, `internal.*`. + +### Intra-plugin hub — `src/plugins/pixelart/pixelart.zig` + +Plugin files import this for `sdk`, `core`, `Globals`, shared types, and `internal.*`. +**Not** the build module root — that is `module.zig`. + +### Plugin state — `src/plugins/pixelart/State.zig` + +Renamed from `PixelArt.zig` / `PixelArt` struct → `State.zig` / `State`. + +### Globals injection — `src/plugins/pixelart/Globals.zig` + +Runtime pointers set once in `App.AppInit`: + +```zig +fizzy.pixelart_mod.Globals.gpa = allocator; +fizzy.pixelart_mod.Globals.state = fizzy.pixelart; +fizzy.pixelart_mod.Globals.packer = fizzy.packer; +``` + +Plugin tree now uses `Globals.allocator()` / `Globals.state` / `Globals.packer` — **zero** +remaining `fizzy.app.allocator` refs in `src/plugins/pixelart/**`. + +### Hub consolidation (partial) + +- **`fizzy.zig`**: `State`, `Packer`, `Internal`, on-disk types, `Tools`, `Transform`, + `PackJob`, `algorithms`, `render`, `sprite_render` all alias `pixelart_mod.*`. + Global `fizzy.pixelart: *State` kept for shell during migration. +- **`Editor.zig`**: removed public aliases `Colors`, `Project`, `Tools`, `Transform`; + uses `fizzy.Tools`, `fizzy.pixelart_mod.Project`, `fizzy.pixelart_mod.plugin.*`. +- **Shell imports rerouted** (via `fizzy.pixelart_mod`): + - `editor/dialogs/Dialogs.zig` → `dialogs.NewFile/Export/GridLayout/FlatRasterSaveWarning` + - `editor/dialogs/UnsavedClose.zig` → `dialogs.FlatRasterSaveWarning` + - `editor/explorer/Explorer.zig` → `explorer.project` +- **`Panel.zig`**: removed dead `Sprites` field/import. +- **Plugin import migration**: `bridge.zig` → `pixelart.zig`; `Globals.pixelart` → + `Globals.state`; subdirectory files use `../pixelart.zig`. + +### SDK module wired in `build.zig` -Settings is fully decoupled (`grep -r 'fizzy.editor.settings' src/plugins/pixelart` → 0). -Here is the **current** `fizzy.editor.*` / `fizzy.backend.*` / `fizzy.platform.*` surface -still in `src/plugins/pixelart/**` (run the greps to refresh): +`wireSdkModule` adds `@import("sdk")` to native, web, and test roots. `fizzy.zig` imports +sdk via `@import("sdk")` (not a duplicate file-path import). + +### Still direct-importing pixel-art files (shell) ``` -33 fizzy.editor.activeFile 11 fizzy.editor.open_files 6 fizzy.editor.newFileID -31 fizzy.editor.atlas 11 fizzy.editor.host 6 fizzy.editor.folder -17 fizzy.editor.explorer 10 fizzy.editor.arena 2 fizzy.editor.palette_folder -+ doc/save-flow tail: setActiveFile, getFile, getFileFromPath, newFile, open_file_index, - requestCompositeWarmup, startPackProject, isPackingActive, requestSaveAs, - requestWebSaveDialog, requestGridLayoutDialog, cancelPendingSaveDialog, abortSaveAllQuit, - copy/paste/accept/cancel, save, transform, buffers, panel, allocNextUntitledPath, - pending_*/quit_* (all 1–3 refs each) -backend: showSaveFileDialog ×5, DialogFileFilter ×4, isMaximized ×3 ; platform: isMacOS ×3 +process_assets.zig (repo root) → Atlas.zig (build-time, std-only — OK, separate compilation) +src/web_main.zig → FileWidget.zig force-import (wasm link — migrate later) ``` -**Recommended order (easy → hard):** - -1. **`host` (11) — trivial now.** `PixelArt` already holds `host: *sdk.Host` (set in - `init`). Repoint `fizzy.editor.host.setActiveSidebarView/isActiveSidebarView` → - `fizzy.pixelart.host.…`. Pure mechanical, no SDK change. -2. **`arena` (10), `folder` (6), `palette_folder` (2) — done-for-you.** The EditorAPI - forwarders already exist: `fizzy.pixelart.host.arena()` / `.folder()` / - `.paletteFolder()`. Repoint `fizzy.editor.arena.allocator()` → `fizzy.pixelart.host.arena()`, - etc. (mind that `arena` callers use `.allocator()`; the forwarder already returns the - `Allocator`). -3. **`backend.isMaximized` (3), `platform.isMacOS` (3).** Add `isMaximized()` to EditorAPI - (shell calls `fizzy.backend.isMaximized(dvui.currentWindow())`). `isMacOS` is just - `core.platform.isMacOS()` — pixel art can call `fizzy.platform.isMacOS()` until Stage D - repoints it to `core` directly; low priority. -4. **`explorer` (17).** These read pixel-art state that *lives on the shell `Explorer`* - (`explorer.tools`, `.sprites`, `.pinned_palettes`, `.layers_ratio`, `.rect`, - `.scroll_info`). `tools`/`sprites` are pixel-art pane modules; `pinned_palettes`/ - `layers_ratio` are pixel-art UI state. These should **move onto `PixelArt`** (like the - settings did), not get an SDK accessor. `rect`/`scroll_info` are shell explorer layout — - expose via EditorAPI or pass into the draw. -5. **Native save dialogs (`backend.showSaveFileDialog` ×5, `DialogFileFilter` ×4).** Add a - small SDK surface for "ask the host to run a native save dialog" (native-only; web has - its own path). The save-flow tail (`requestSaveAs`, `pending_*`, `quit_*`, `accept`, - `cancel`, `abortSaveAllQuit`, …) is the shell's save/quit orchestration the pixel-art - dialogs poke — needs a deliberate "document save service" vtable, the hardest part. -6. **Docs/tabs (`activeFile` ×33, `open_files` ×11, `setActiveFile`, `getFile*`, `newFile*`, - `open_file_index`, `buffers`, `transform`, `copy/paste`, `requestCompositeWarmup`, - `startPackProject`, `isPackingActive`).** This is the **deep coupling**: the shell's - `open_files` is literally `AutoArrayHashMapUnmanaged(u64, Internal.File)` — a map of - *pixel-art* `Internal.File` values. The shell currently owns and iterates pixel-art docs - directly. Fully decoupling means the shell stores **opaque documents (`DocHandle`)** and - the pixel-art plugin owns the `Internal.File` storage. That is a large structural change - (touches the workspace/tab/save systems) — likely its own stage. Until then, pixel-art - can reach the active doc through a `host.activeDoc() ?DocHandle` + cast, but the storage - inversion is the real work. - -`atlas` (31) is handled by the sprite/atlas → core extraction below, not by an SDK accessor. - -## Next big rock: sprite / atlas → `core` - -This resolves the `editor.atlas` (Stage B) and `fizzy.editor.atlas` (×31) coupling and is -the prerequisite for the shell not depending on the pixel-art plugin for its own UI icons. - -**Findings (verified in code):** - -- The shell (`workbench`) only calls `fizzy.sprite_render.sprite(...)` in two places — - `workbench/files.zig:~774` and `workbench/Workspace.zig:~300` — both drawing a **static - atlas sprite** (the logo / UI icons), passing `file = null`. It never uses the heavy path. -- But `src/plugins/pixelart/sprite_render.zig` lives in the plugin and is tangled: the same - `sprite()` also does layer compositing, file previews, reflections, and `water_surface` - (all need a full pixel-art `Internal.File`). So today the shell reaches *backwards* into - the plugin just to draw an icon. `editor.atlas` is typed `Internal.Atlas` (pixel art's). - -**Plan:** split by responsibility with `core` as the shared floor. - -- → **`core`:** a generic atlas data type + a "draw sprite N (sub-rect of a texture)" - primitive (the slice the shell's logo/icons need; essentially `dvui.renderImage` + sprite - rect math). The shell's `editor.atlas` becomes a `core` atlas type drawn via the `core` - helper, depending on `core` not the plugin. -- → **stays in pixel-art plugin:** `renderSprite` / `render.renderLayers` / composites / - reflections / `water_surface` — all the editing rendering on top of the primitive. - -End-state dependency graph: **shell → core**, **plugin → core**, neither depends on the -other. (User has signed off on this direction; sequenced *after* settings.) +--- + +## Stage D — remaining work (start here) + +1. **Wire `b.addModule("pixelart", …)` in `build.zig`** (native, web, test) with deps: + `core`, `sdk`, `dvui`, `assets`, `zip`, `zstbi`, etc. — mirroring how `core` is wired. + Point the module root at `module.zig`. Today the plugin compiles through path imports + in `fizzy.zig`; the build module is scaffold-only. + +2. **Break `plugin.zig` dependency on `fizzy.Editor.Workspace`** (project view drawing + still reaches into shell types). + +3. **Route `web_main.zig` FileWidget import** through `pixelart_mod` or the future build + module. + +4. **Optional cleanup:** shell `Editor.zig` still uses `fizzy.pixelart.*` extensively — + shrink as plugin vtable / EditorAPI surface grows (Stage E). + +Do **not** re-introduce a duplicate `@import("plugins/pixelart/module.zig")` from both +`App.zig` and `fizzy.zig` via a third path; always go through `fizzy.pixelart_mod` in +app code until the build module is fully wired. + +--- + +## Stage E — strip pixel-art names from shell hubs (later) + +- Remove pixel-art type names from `fizzy.zig` hub (consumers import `pixelart` module). +- Remove `editor/dialogs/` pixel-art dialog aliases (plugins register dialogs via SDK). +- Shell `Editor` radial-menu / copy-paste / pack code still touches `fizzy.pixelart.tools` — + route through plugin vtable or EditorAPI. +- Shell still uses `fizzy.Internal.File` directly in several `Editor.zig` helpers — shrink + as doc ownership solidifies. + +--- + +## Next big rock: sprite / atlas → `core` (parallel track) + +Resolves `editor.atlas` coupling and the shell reaching into the plugin for UI icons. + +- Shell only needs a static atlas sprite draw (logo/icons) — `workbench/files.zig`, + `workbench/Workspace.zig`. +- **`core`:** generic atlas type + "draw sprite N" primitive. +- **Plugin:** `renderSprite`, composites, reflections, `water_surface`. +- End-state: **shell → core**, **plugin → core**, neither depends on the other. + +(User signed off; sequenced after settings, can proceed alongside late Stage D.) + +--- + +## What `core` is (Stage A3 — unchanged) + +`src/core/` is a standalone module; never imports `src/fizzy.zig`. See prior handoff +sections for allocator injection, trackpad hook, dialog chrome state, build wiring, and +the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atlas.zig`). + +**macOS case-insensitive FS gotchas:** +- `sprite.zig` vs `Sprite.zig` → use `sprite_render.zig`. +- `pixelart.zig` vs `PixelArt.zig` / `State.zig` → use `module.zig` for the build module + root; use the two-step git rename when introducing `pixelart.zig` hub. + +--- + +## Key paths + +| Path | Role | +|------|------| +| `HANDOFF.md` | This file | +| `src/plugins/pixelart/module.zig` | Pixel-art build module root | +| `src/plugins/pixelart/pixelart.zig` | Pixel-art intra-plugin hub | +| `src/plugins/pixelart/src/` | Pixel-art implementation tree | +| `src/plugins/workbench/module.zig` | Workbench build module root | +| `src/plugins/workbench/workbench.zig` | Workbench intra-plugin hub | +| `src/plugins/workbench/src/` | Workbench implementation tree | +| `src/sdk/EditorAPI.zig`, `Host.zig` | Full shell API surface | +| `src/editor/Editor.zig` | Shell; still uses `fizzy.pixelart.*` and `Internal.File` helpers | +| `src/fizzy.zig` | App hub; mid-migration to `pixelart_mod` re-exports | +| `process_assets.zig` | Build-time asset atlas generator (repo root, beside `build.zig`) | +| `src/backend/` | Platform backend: native/web stubs, singleton, auto-update, objc, MSVC shim | --- ## State of the tree -**Uncommitted** (nothing in this whole Phase-4 effort has been committed — commit on -request). Beyond the Stage A3 changes, the working tree now also has: - -- **Stage B:** new `src/plugins/pixelart/PixelArt.zig`; `fizzy.pixelart` global in - `fizzy.zig`; init/deinit wiring in `App.zig`; field removals + ~190 repoints in - `Editor.zig`, `Keybinds.zig`, `workbench/files.zig`, and the pixel-art tree. -- **Stage C part 1 (settings):** new `src/sdk/EditorAPI.zig`, - `src/plugins/pixelart/Settings.zig`; `SettingsSection` in `sdk/regions.zig` + `sdk.zig`; - Host store/forwarders/section-registry in `sdk/Host.zig`; persistence rework in - `editor/Settings.zig`; EditorAPI impl + section iteration in `editor/Editor.zig`; trimmed - `editor/explorer/settings.zig`; settings repoints across the pixel-art tree; - `App.zig` passes the host to `PixelArt.init`. - -Sanity greps for the next agent: -- `grep -rn 'fizzy.editor.settings' src/plugins/pixelart` → **0** (settings decoupled). -- `grep -rhoE 'fizzy\.editor\.[a-zA-Z_]+' src/plugins/pixelart | sort | uniq -c | sort -rn` - → the remaining Stage C surface (see "Stage C — remaining work"). +**Uncommitted** — nothing in this Phase-4 effort has been committed (commit on request). + +Beyond Stages A–C, the working tree now also has Stage D scaffold changes: +`module.zig`, `pixelart.zig`, `State.zig`, `Globals.zig`, hub re-exports in `fizzy.zig`, +shell import migration, `State.docs` + explorer/bottom-panel fields, `bridge.zig` removed. + +Sanity greps: + +``` +grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live +grep -rn 'fizzy\.platform' src/plugins/pixelart → 0 +grep -rn 'fizzy\.app\.allocator' src/plugins/pixelart → 0 +grep -rn 'bridge\.' src/plugins/pixelart → 0 +grep -rn 'plugins/pixelart/' src --include='*.zig' → process_assets, fizzy module import, web_main +``` All three configs green: `zig build`, `zig build check-web`, `zig build test`. diff --git a/build.zig b/build.zig index b7fa39af..85a57c51 100644 --- a/build.zig +++ b/build.zig @@ -1,13 +1,13 @@ const std = @import("std"); -const zip = @import("src/plugins/pixelart/deps/zip/build.zig"); +const zip = @import("src/plugins/pixelart/src/deps/zip/build.zig"); const dvui = @import("dvui"); const velopack = @import("velopack_zig"); const content_dir = "assets/"; -const ProcessAssetsStep = @import("src/tools/process_assets.zig"); +const ProcessAssetsStep = @import("process_assets.zig"); const update = @import("update.zig"); const GitDependency = update.GitDependency; @@ -358,6 +358,7 @@ pub fn build(b: *std.Build) !void { core_module_web.addImport("icons", dep.module("icons")); } web_exe.root_module.addImport("core", core_module_web); + wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), web_exe.root_module); // Three editor files have `const sdl3 = @import("backend").c;` at file // scope. After refactoring all `sdl3.SDL_DialogFileFilter` references @@ -375,7 +376,7 @@ pub fn build(b: *std.Build) !void { .root_module = b.addModule("zstbi_web", .{ .target = web_target, .optimize = optimize, - .root_source_file = b.path("src/plugins/pixelart/deps/stbi/zstbi.zig"), + .root_source_file = b.path("src/plugins/pixelart/src/deps/stbi/zstbi.zig"), .link_libc = false, .single_threaded = true, }), @@ -385,11 +386,11 @@ pub fn build(b: *std.Build) !void { "-DSTBI_NO_SIMD=1", }; zstbi_web_lib.root_module.addCSourceFile(.{ - .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/zstbi.c"), + .file = std.Build.path(b, "src/plugins/pixelart/src/deps/stbi/zstbi.c"), .flags = &zstbi_web_cflags, }); zstbi_web_lib.root_module.addCSourceFile(.{ - .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c"), + .file = std.Build.path(b, "src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c"), .flags = &zstbi_web_cflags, }); web_exe.root_module.addImport("zstbi", zstbi_web_lib.root_module); @@ -399,14 +400,14 @@ pub fn build(b: *std.Build) !void { .root_module = b.addModule("msf_gif_web", .{ .target = web_target, .optimize = optimize, - .root_source_file = b.path("src/plugins/pixelart/deps/msf_gif/msf_gif.zig"), + .root_source_file = b.path("src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig"), .link_libc = false, .single_threaded = true, }), }); - const msf_gif_wasm_cflags = [_][]const u8{"-Isrc/plugins/pixelart/deps/msf_gif/wasm_shim"}; + const msf_gif_wasm_cflags = [_][]const u8{"-Isrc/plugins/pixelart/src/deps/msf_gif/wasm_shim"}; msf_gif_web_lib.root_module.addCSourceFile(.{ - .file = std.Build.path(b, "src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c"), + .file = std.Build.path(b, "src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c"), .flags = &msf_gif_wasm_cflags, }); web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module); @@ -754,13 +755,13 @@ pub fn build(b: *std.Build) !void { inline for (.{ .{ "fizzy-direction", "src/core/math/direction.zig" }, .{ "fizzy-easing", "src/core/math/easing.zig" }, - .{ "fizzy-layer-order", "src/plugins/pixelart/internal/layer_order.zig" }, - .{ "fizzy-palette-parse", "src/plugins/pixelart/internal/palette_parse.zig" }, + .{ "fizzy-layer-order", "src/plugins/pixelart/src/internal/layer_order.zig" }, + .{ "fizzy-palette-parse", "src/plugins/pixelart/src/internal/palette_parse.zig" }, .{ "fizzy-layout-anchor", "src/core/math/layout_anchor.zig" }, - .{ "fizzy-reduce", "src/plugins/pixelart/algorithms/reduce.zig" }, - .{ "fizzy-grid-validate", "src/plugins/pixelart/internal/grid_layout_validate.zig" }, - .{ "fizzy-animation", "src/plugins/pixelart/Animation.zig" }, - .{ "fizzy-window-layout", "src/window_layout.zig" }, + .{ "fizzy-reduce", "src/plugins/pixelart/src/algorithms/reduce.zig" }, + .{ "fizzy-grid-validate", "src/plugins/pixelart/src/internal/grid_layout_validate.zig" }, + .{ "fizzy-animation", "src/plugins/pixelart/src/Animation.zig" }, + .{ "fizzy-window-layout", "src/backend/window_layout.zig" }, }) |entry| { tests_module.addAnonymousImport(entry[0], .{ .root_source_file = b.path(entry[1]), @@ -846,6 +847,7 @@ pub fn build(b: *std.Build) !void { core_module_test.addImport("icons", dep.module("icons")); } fizzy_test_module.addImport("core", core_module_test); + wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module); if (target.result.os.tag == .macos) { if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| { @@ -980,7 +982,7 @@ fn applyMsvcTranslateCShim(b: *std.Build, roots: []const *std.Build.Step.Compile const rt = tc.target.result; if (rt.os.tag != .windows or rt.abi != .msvc) continue; // `-I` searches before `-isystem`, so this shim wins over MSVC's . - tc.addIncludePath(b.path("src/tools/msvc_translatec_shim")); + tc.addIncludePath(b.path("src/backend/msvc_translatec_shim")); // Pre-define SIZE_MAX so MSVC's stdint.h `#ifndef SIZE_MAX` block — which would // otherwise install a `0xff…ui64` literal — skips itself. Belt-and-suspenders // to the shim: covers the case where another header includes through @@ -1104,22 +1106,22 @@ fn addFizzyExecutableForTarget( .root_module = b.addModule("zstbi", .{ .target = resolved_target, .optimize = optimize, - .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/deps/stbi/zstbi.zig" }, + .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/src/deps/stbi/zstbi.zig" }, }), }); const zstbi_module = zstbi_lib.root_module; - zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/zstbi.c") }); + zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/src/deps/stbi/zstbi.c") }); const msf_gif_lib = b.addLibrary(.{ .name = "msf_gif", .root_module = b.addModule("msf_gif", .{ .target = resolved_target, .optimize = optimize, - .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/deps/msf_gif/msf_gif.zig" }, + .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig" }, }), }); const msf_gif_module = msf_gif_lib.root_module; - msf_gif_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/deps/msf_gif/msf_gif.c") }); + msf_gif_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/src/deps/msf_gif/msf_gif.c") }); const exe = b.addExecutable(.{ .name = "fizzy", @@ -1170,6 +1172,7 @@ fn addFizzyExecutableForTarget( core_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); core_module.addImport("known-folders", known_folders); exe.root_module.addImport("core", core_module); + wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module); const singleton_app_dep = b.dependency("dvui_singleton_app", .{ .target = resolved_target, @@ -1197,10 +1200,10 @@ fn addFizzyExecutableForTarget( })) |dep| { exe.root_module.addImport("objc", dep.module("objc")); } - exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyVisualEffectView.m") }); - exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyMenuTarget.m") }); - exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyTrackpadGesture.m") }); - exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyWindowMonitor.m") }); + exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyVisualEffectView.m") }); + exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyMenuTarget.m") }); + exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyTrackpadGesture.m") }); + exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyWindowMonitor.m") }); } else if (resolved_target.result.os.tag == .windows) { if (b.lazyDependency("zigwin32", .{})) |dep| { exe.root_module.addImport("win32", dep.module("win32")); @@ -1238,6 +1241,23 @@ fn addFizzyExecutableForTarget( }; } +/// Plugin SDK (`src/sdk/sdk.zig`). Depends only on `dvui`. +fn wireSdkModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + dvui_module: *std.Build.Module, + consumer: *std.Build.Module, +) void { + const sdk_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/sdk/sdk.zig"), + }); + sdk_module.addImport("dvui", dvui_module); + consumer.addImport("sdk", sdk_module); +} + inline fn thisDir() []const u8 { return comptime std.fs.path.dirname(@src().file) orelse "."; } diff --git a/contributor.md b/contributor.md deleted file mode 100644 index 3be379a5..00000000 --- a/contributor.md +++ /dev/null @@ -1,60 +0,0 @@ -

- -

-

- -## Contributing - -Hello and thank you so much for considering contributing to Fizzy! - -By suggestion, this document will hopefully serve as a good starting point for understanding Fizzy's internals and where things are. However, if you ever have any questions or would like -to have a conversation about Fizzy, please reach out to me on discord or add an issue. I'm "foxnne" on discord as well. - -### Overview - -Fizzy is built using several game development libraries by others in the Zig community, as well as a C library for handling zipped files. The dependencies are as follows: - - ***mach-core***: Handles windowing and input, and uses the new zig package manager. This library and dependencies will be downloaded to the cache on build. - - ***nfd_zig***: Native file dialogs wrapper, copied into the src/deps folder. - - ***zgui***: Wrapper for Dear Imgui, which is copied into the src/plugins/pixelart/deps/zig-gamedev folder. - - ***zmath***: Math library, primarily using this for vector math and matrices. As above, this is copied into the src/plugins/pixelart/deps/zig-gamedev folder. - - ***zstbi***: Wrapper for stbi provided by zig-gamedev. This handles loading and resizing images. As above, this is copied into the src/plugins/pixelart/deps/zig-gamedev folder. - - ***zip***: Wrapper for the zip library, copied into the src/deps folder. - -Outside of the `src` folder, we have `assets` which contain all assets that we would like to be copied over next to the executable and used by Fizzy at runtime. - -`fizzy.zig` holds all the main loop information and init, update, and deinit functions. Mach-core handles the main entry point and calls these functions for us. Mach-core is multi-threaded in the sense that there are two update loops, one which is run on the main thread, and one that runs in a separate thread. For more information about mach-core please see [the mach-core website](https://machengine.org/core/). - -Please note that we need to handle native file dialogs from the main thread, which is currently how Fizzy handles it. I tried to set this up as a request/response. - -Inside of the `src` folder we have several subfolders. I tried to organize the project based on a few categories as follows: - -Outside of these subfolders, please note that `assets.zig` is generated so don't edit this file. - -- **algorithms**: This folder holds any generalized algorithms for use in pixel art operations. As of writing this, it only currently contains the brezenham algorithm used - by the stroke/pencil tool. This algorithm handles quick mouse movements when drawing and prevents broken lines, as each frame a line is drawn from the previous frame. - -- **deps**: This folder holds the previously outlined dependencies, except for those that are using the new zig package manager. -- **editor**: This folder holds individual files generally with simple *draw()* functions that mimic the layout of the editor itself. I tried to use subfolders and similar to - set the project up in a way that was easy to understand from looking at the editor itself. - - i.e. `editor/artboard/canvas.zig` is the file responsible for the canvas within the main artboard, while `editor/artboard/flipbook/canvas.zig` is the canvas within the flipbook. - - Note that `editor.zig` contains a bit more than just drawing of the editor panels, and contains many of the main *editor* related functions, like loading and opening files, setting the project folder, - saving files, and importing png files. - -- **gfx**: Fizzy is set up similar to a game, with the flipbook and main artboard having a camera. Each file actually has its own Camera, which allows u - to have individual views per file, and not a shared camera between all files. That means you can be working on two files and not have your camera move around as you switch. - - Other things in gfx are general things related to textures, atlases, quads, etc. Some of this is unused currently and can be removed. - -- **input**: Input holds hotkeys and mouse information. - - `Hotkeys.zig` is my attempt at trying to set up configurable hotkeys in the future. - -- **math**: General math functions I've written or picked up over time. -- **shaders**: Currently doesn't get used, but in the future if we support using the GPU for some operations, the wgsl files would live here. -- **storage**: This is where History, and the containers used to store information are. internal and external contain the structs used to describe a fizzy file internally, with additional information for the program to use, or externally, which should be easily exported as JSON. -- **tools**: A few helpful things such as font-awesome mapping, an example of the build step to process assets, and the Packer struct, which is responsible for packing all sprites to an atlas. - - - - - - - diff --git a/src/tools/process_assets.zig b/process_assets.zig similarity index 98% rename from src/tools/process_assets.zig rename to process_assets.zig index d596bfdb..505042f9 100644 --- a/src/tools/process_assets.zig +++ b/process_assets.zig @@ -3,7 +3,7 @@ const path = std.fs.path; const Step = std.Build.Step; const Io = std.Io; -const Atlas = @import("../plugins/pixelart/Atlas.zig"); +const Atlas = @import("src/plugins/pixelart/src/Atlas.zig"); const ProcessAssetsStep = @This(); step: Step, diff --git a/src/App.zig b/src/App.zig index 40f13a89..e93f5bb3 100644 --- a/src/App.zig +++ b/src/App.zig @@ -8,9 +8,9 @@ const assets = @import("assets"); const icon = assets.files.@"icon.png"; const fizzy = @import("fizzy.zig"); -const auto_update = @import("auto_update.zig"); -const update_notify = @import("update_notify.zig"); -const singleton = @import("singleton.zig"); +const auto_update = @import("backend/auto_update.zig"); +const update_notify = @import("backend/update_notify.zig"); +const singleton = @import("backend/singleton.zig"); const paths = fizzy.paths; const App = @This(); @@ -168,8 +168,10 @@ pub fn AppInit(win: *dvui.Window) !void { // Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created // before `postInit` so the pixel-art plugin's `register` can adopt it as its // `state`. Owned here for the app's lifetime; torn down in `AppDeinit`. - fizzy.pixelart = try allocator.create(fizzy.PixelArt); - fizzy.pixelart.* = fizzy.PixelArt.init(allocator, &fizzy.editor.host) catch unreachable; + fizzy.pixelart = try allocator.create(fizzy.State); + fizzy.pixelart_mod.Globals.gpa = allocator; + fizzy.pixelart_mod.Globals.state = fizzy.pixelart; + fizzy.pixelart.* = fizzy.State.init(allocator, &fizzy.editor.host) catch unreachable; // Second-stage init that needs the editor at its final heap address (e.g. // registering the workbench-api service whose `ctx` is this pointer). @@ -181,6 +183,8 @@ pub fn AppInit(win: *dvui.Window) !void { fizzy.packer = try allocator.create(Packer); fizzy.packer.* = Packer.init(allocator) catch unreachable; + fizzy.pixelart_mod.Globals.packer = fizzy.packer; + // Hand the window to the listener thread and queue our own argv so the // first frame opens any files / project folder supplied on the command line. singleton.registerWindow(win, resolved_argv); @@ -226,6 +230,8 @@ pub fn AppInit(win: *dvui.Window) !void { pub fn AppDeinit() void { // Persist the current windowed frame while the window still exists. No-op off macOS. fizzy.backend.saveWindowGeometry(fizzy.app.window); + // Persist `.fizproject` while `editor.host` and `editor.folder` are still live. + fizzy.State.persistProject(fizzy.pixelart); fizzy.editor.deinit() catch unreachable; // Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs). // After the editor so any editor teardown that still reads pixel-art state runs first. diff --git a/src/auto_update.zig b/src/backend/auto_update.zig similarity index 100% rename from src/auto_update.zig rename to src/backend/auto_update.zig diff --git a/src/backend_native.zig b/src/backend/backend_native.zig similarity index 99% rename from src/backend_native.zig rename to src/backend/backend_native.zig index e177739f..e785e85d 100644 --- a/src/backend_native.zig +++ b/src/backend/backend_native.zig @@ -1,5 +1,5 @@ // These are functions specific to the backend, which is currently SDL3 -const fizzy = @import("fizzy.zig"); +const fizzy = @import("../fizzy.zig"); const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); diff --git a/src/backend_web.zig b/src/backend/backend_web.zig similarity index 98% rename from src/backend_web.zig rename to src/backend/backend_web.zig index 0bf57f11..95299263 100644 --- a/src/backend_web.zig +++ b/src/backend/backend_web.zig @@ -8,7 +8,7 @@ const dvui = @import("dvui"); const builtin = @import("builtin"); const WebFileIo = if (builtin.target.cpu.arch == .wasm32) - @import("editor/WebFileIo.zig") + @import("../editor/WebFileIo.zig") else struct {}; @@ -151,7 +151,7 @@ pub fn showOpenFolderDialog( _: ?[]const u8, ) void { if (comptime builtin.target.cpu.arch == .wasm32) { - const Dialogs = @import("editor/dialogs/Dialogs.zig"); + const Dialogs = @import("../editor/dialogs/Dialogs.zig"); Dialogs.WebFolderUnavailable.request(); } } diff --git a/src/file_assoc.zig b/src/backend/file_assoc.zig similarity index 100% rename from src/file_assoc.zig rename to src/backend/file_assoc.zig diff --git a/src/tools/msvc_translatec_shim/stdint.h b/src/backend/msvc_translatec_shim/stdint.h similarity index 100% rename from src/tools/msvc_translatec_shim/stdint.h rename to src/backend/msvc_translatec_shim/stdint.h diff --git a/src/objc/FizzyMenuTarget.m b/src/backend/objc/FizzyMenuTarget.m similarity index 100% rename from src/objc/FizzyMenuTarget.m rename to src/backend/objc/FizzyMenuTarget.m diff --git a/src/objc/FizzyTrackpadGesture.m b/src/backend/objc/FizzyTrackpadGesture.m similarity index 100% rename from src/objc/FizzyTrackpadGesture.m rename to src/backend/objc/FizzyTrackpadGesture.m diff --git a/src/objc/FizzyVisualEffectView.m b/src/backend/objc/FizzyVisualEffectView.m similarity index 100% rename from src/objc/FizzyVisualEffectView.m rename to src/backend/objc/FizzyVisualEffectView.m diff --git a/src/objc/FizzyWindowMonitor.m b/src/backend/objc/FizzyWindowMonitor.m similarity index 99% rename from src/objc/FizzyWindowMonitor.m rename to src/backend/objc/FizzyWindowMonitor.m index 707a841a..ca6e034b 100644 --- a/src/objc/FizzyWindowMonitor.m +++ b/src/backend/objc/FizzyWindowMonitor.m @@ -8,11 +8,11 @@ * Green-button maximize uses a native fullscreen Space (menu bar hidden). * SDL3 ignores resize notifications while a Space transition animates, so a * 60Hz NSTimer pump renders live frames during the morph. The Zig side - * (src/backend_native.zig) pushes live contentView bounds into SDL before each + * (src/backend/backend_native.zig) pushes live contentView bounds into SDL before each * frame so the Metal drawable and layout stay paired. * * The fizzy_macos_window_* callbacks below are exported from - * src/backend_native.zig; everything else is self-contained. */ + * src/backend/backend_native.zig; everything else is self-contained. */ extern void fizzy_macos_window_resize_cb(void); extern void fizzy_macos_window_pump_frame(void); @@ -20,7 +20,7 @@ extern void fizzy_macos_window_request_clear_frames(int frames); extern void fizzy_macos_window_commit_steady_state(void); /* Pure window-frame decisions live in window_layout.zig (unit-tested); see - * backend_native.zig for the C-ABI wrappers. */ + * backend/backend_native.zig for the C-ABI wrappers. */ extern int fizzy_macos_constrain_is_menu_bar_nudge(double rx, double ry, double rw, double rh, double cx, double cy, double cw, double ch, double visible_top); diff --git a/src/singleton.zig b/src/backend/singleton.zig similarity index 100% rename from src/singleton.zig rename to src/backend/singleton.zig diff --git a/src/singleton_native.zig b/src/backend/singleton_native.zig similarity index 99% rename from src/singleton_native.zig rename to src/backend/singleton_native.zig index d52a071e..dd453999 100644 --- a/src/singleton_native.zig +++ b/src/backend/singleton_native.zig @@ -15,7 +15,7 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); const singleton_app = @import("singleton_app"); -const fizzy = @import("fizzy.zig"); +const fizzy = @import("../fizzy.zig"); const log = std.log.scoped(.singleton); diff --git a/src/singleton_web.zig b/src/backend/singleton_web.zig similarity index 100% rename from src/singleton_web.zig rename to src/backend/singleton_web.zig diff --git a/src/update_install.zig b/src/backend/update_install.zig similarity index 100% rename from src/update_install.zig rename to src/backend/update_install.zig diff --git a/src/update_notify.zig b/src/backend/update_notify.zig similarity index 99% rename from src/update_notify.zig rename to src/backend/update_notify.zig index 7644f846..1c8de6a5 100644 --- a/src/update_notify.zig +++ b/src/backend/update_notify.zig @@ -7,7 +7,7 @@ const std = @import("std"); const dvui = @import("dvui"); const auto_update = @import("auto_update.zig"); const update_install = @import("update_install.zig"); -const fizzy = @import("fizzy.zig"); +const fizzy = @import("../fizzy.zig"); const Phase = enum(u8) { pending, diff --git a/src/web_io.zig b/src/backend/web_io.zig similarity index 100% rename from src/web_io.zig rename to src/backend/web_io.zig diff --git a/src/window_layout.zig b/src/backend/window_layout.zig similarity index 99% rename from src/window_layout.zig rename to src/backend/window_layout.zig index 4e8cccfe..af3ec513 100644 --- a/src/window_layout.zig +++ b/src/backend/window_layout.zig @@ -3,7 +3,7 @@ //! height" math is testable without a window. std-only — pulled in by //! `tests/root.zig` and called from `backend_native.zig` (which keeps the //! AppKit/SDL plumbing). Shell/native-windowing infra (not pixel-art), so it lives at -//! `src/window_layout.zig` beside `backend_native.zig` rather than under `internal/`. +//! `src/backend/window_layout.zig` beside `backend_native.zig` rather than under `internal/`. const std = @import("std"); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index a90ea23e..206310bc 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -15,37 +15,35 @@ const plus_jakarta_sans_bold_ttf = assets.files.fonts.@"PlusJakartaSans-Bold.ttf const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); -const update_notify = @import("../update_notify.zig"); +const update_notify = @import("../backend/update_notify.zig"); const App = fizzy.App; const Editor = @This(); -pub const Colors = @import("../plugins/pixelart/Colors.zig"); -pub const Project = @import("../plugins/pixelart/Project.zig"); +const Project = fizzy.pixelart_mod.Project; pub const Recents = @import("Recents.zig"); pub const Settings = @import("Settings.zig"); -pub const Tools = @import("../plugins/pixelart/Tools.zig"); +const Tools = fizzy.Tools; pub const Dialogs = @import("dialogs/Dialogs.zig"); -pub const Transform = @import("../plugins/pixelart/Transform.zig"); pub const Keybinds = @import("Keybinds.zig"); -pub const Workspace = @import("../plugins/workbench/Workspace.zig"); +pub const Workspace = @import("../plugins/workbench/src/Workspace.zig"); pub const Explorer = @import("explorer/Explorer.zig"); pub const IgnoreRules = @import("explorer/IgnoreRules.zig"); pub const Panel = @import("panel/Panel.zig"); pub const Sidebar = @import("Sidebar.zig"); pub const Infobar = @import("Infobar.zig"); pub const Menu = @import("Menu.zig"); -pub const FileLoadJob = @import("../plugins/workbench/FileLoadJob.zig"); -pub const PackJob = @import("../plugins/pixelart/PackJob.zig"); +pub const FileLoadJob = @import("../plugins/workbench/src/FileLoadJob.zig"); +const PackJob = fizzy.PackJob; pub const sdk = fizzy.sdk; pub const Host = sdk.Host; /// Workbench (Phase 1): file-management home — currently the per-branch /// decoration registry for the explorer; grows to own files + tabs/splits. -pub const Workbench = @import("../plugins/workbench/Workbench.zig"); +pub const Workbench = @import("../plugins/workbench/src/Workbench.zig"); /// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels. /// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame @@ -82,7 +80,7 @@ ignore: IgnoreRules = .{}, themes: std.ArrayList(dvui.Theme) = .empty, -open_files: std.AutoArrayHashMapUnmanaged(u64, fizzy.Internal.File) = .empty, +open_files: std.AutoArrayHashMapUnmanaged(u64, sdk.DocHandle) = .empty, /// Background file-load jobs in flight. Keyed by absolute path. Each job's worker thread runs /// `Internal.File.fromPath` off the main thread; the main thread polls via `processLoadingJobs` @@ -439,7 +437,7 @@ pub fn init( return err; }; - // Pixel-art tools/colors/palettes now init in `PixelArt.init` (App owns the + // Pixel-art tools/colors/palettes now init in `State.init` (App owns the // `fizzy.pixelart` instance, created just after this `Editor.init` returns). try Keybinds.register(); @@ -478,8 +476,8 @@ pub fn postInit(editor: *Editor) !void { // near-empty shell's content: it iterates the Host registries rather than // hardcoding panes. Web-safe — the draw fns reach the same inline code the // editor tick already runs on wasm. Order = sidebar order. - try @import("../plugins/workbench/plugin.zig").register(&editor.host); - try @import("../plugins/pixelart/plugin.zig").register(&editor.host); + try @import("../plugins/workbench/src/plugin.zig").register(&editor.host); + try fizzy.pixelart_mod.plugin.register(&editor.host); // Shell built-in: Settings (owner = null; not a plugin). try editor.host.registerSidebarView(.{ @@ -553,10 +551,39 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .markSettingsDirty = shellMarkSettingsDirty, .contentOpacity = shellContentOpacity, .isMaximized = shellIsMaximized, + .isMacOS = shellIsMacOS, + .appliesNativeWindowOpacity = shellAppliesNativeWindowOpacity, .explorerRect = shellExplorerRect, .explorerVirtualSize = shellExplorerVirtualSize, .showSaveDialog = shellShowSaveDialog, .uiAtlas = shellUiAtlas, + .activeDoc = shellActiveDoc, + .docByIndex = shellDocByIndex, + .docById = shellDocById, + .docIndex = shellDocIndex, + .openDocCount = shellOpenDocCount, + .setActiveDocIndex = shellSetActiveDocIndex, + .allocDocId = shellAllocDocId, + .accept = shellAccept, + .cancel = shellCancel, + .copy = shellCopy, + .paste = shellPaste, + .transform = shellTransform, + .save = shellSave, + .requestCompositeWarmup = shellRequestCompositeWarmup, + .requestGridLayoutDialog = shellRequestGridLayoutDialog, + .allocUntitledPath = shellAllocUntitledPath, + .createDocument = shellCreateDocument, + .requestSaveAs = shellRequestSaveAs, + .requestWebSave = shellRequestWebSave, + .cancelPendingSaveDialog = shellCancelPendingSaveDialog, + .setPendingCloseDocId = shellSetPendingCloseDocId, + .queueCloseAfterSave = shellQueueCloseAfterSave, + .trackQuitSaveInFlight = shellTrackQuitSaveInFlight, + .resumeSaveAllQuit = shellResumeSaveAllQuit, + .abortSaveAllQuit = shellAbortSaveAllQuit, + .startPackProject = shellStartPackProject, + .isPackingActive = shellIsPackingActive, }; fn shellCtx(ctx: *anyopaque) *Editor { @@ -581,6 +608,13 @@ fn shellIsMaximized(ctx: *anyopaque) bool { _ = ctx; return fizzy.backend.isMaximized(dvui.currentWindow()); } +fn shellIsMacOS(_: *anyopaque) bool { + return fizzy.platform.isMacOS(); +} +fn shellAppliesNativeWindowOpacity(_: *anyopaque) bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + return builtin.os.tag == .macos or builtin.os.tag == .windows; +} fn shellExplorerRect(ctx: *anyopaque) dvui.Rect { return shellCtx(ctx).explorer.rect; } @@ -606,6 +640,133 @@ fn shellUiAtlas(ctx: *anyopaque) sdk.EditorAPI.UiAtlasView { .sprites = @as([]const sdk.EditorAPI.UiSprite, @ptrCast(atlas.sprites)), }; } +fn shellActiveDoc(ctx: *anyopaque) ?sdk.DocHandle { + return shellCtx(ctx).activeDoc(); +} +fn shellDocByIndex(ctx: *anyopaque, index: usize) ?sdk.DocHandle { + return shellCtx(ctx).docAt(index); +} +fn shellDocById(ctx: *anyopaque, id: u64) ?sdk.DocHandle { + return shellCtx(ctx).docById(id); +} +fn shellDocIndex(ctx: *anyopaque, id: u64) ?usize { + return shellCtx(ctx).open_files.getIndex(id); +} +fn shellOpenDocCount(ctx: *anyopaque) usize { + return shellCtx(ctx).open_files.count(); +} +fn shellSetActiveDocIndex(ctx: *anyopaque, index: usize) void { + shellCtx(ctx).setActiveFile(index); +} +fn shellAllocDocId(ctx: *anyopaque) u64 { + return shellCtx(ctx).newFileID(); +} +fn shellAccept(ctx: *anyopaque) anyerror!void { + return shellCtx(ctx).accept(); +} +fn shellCancel(ctx: *anyopaque) anyerror!void { + return shellCtx(ctx).cancel(); +} +fn shellCopy(ctx: *anyopaque) anyerror!void { + return shellCtx(ctx).copy(); +} +fn shellPaste(ctx: *anyopaque) anyerror!void { + return shellCtx(ctx).paste(); +} +fn shellTransform(ctx: *anyopaque) anyerror!void { + return shellCtx(ctx).transform(); +} +fn shellSave(ctx: *anyopaque) anyerror!void { + return shellCtx(ctx).save(); +} +fn shellRequestCompositeWarmup(ctx: *anyopaque) void { + shellCtx(ctx).requestCompositeWarmup(); +} +fn shellRequestGridLayoutDialog(ctx: *anyopaque) void { + shellCtx(ctx).requestGridLayoutDialog(); +} +fn shellAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 { + return shellCtx(ctx).allocNextUntitledPath(); +} +fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) anyerror!sdk.DocHandle { + const editor = shellCtx(ctx); + const file = try editor.newFile(path, .{ + .columns = grid.columns, + .rows = grid.rows, + .column_width = grid.column_width, + .row_height = grid.row_height, + }); + const owner = fizzy.pixelart_mod.plugin.pluginPtr(); + return .{ .ptr = file, .owner = owner, .id = file.id }; +} +fn shellRequestSaveAs(ctx: *anyopaque) void { + shellCtx(ctx).requestSaveAs(); +} +fn shellRequestWebSave(ctx: *anyopaque, kind: sdk.EditorAPI.WebSaveKind) void { + const native_kind: Dialogs.WebSaveAs.Kind = switch (kind) { + .save => .save, + .save_as => .save_as, + }; + shellCtx(ctx).requestWebSaveDialog(native_kind); +} +fn shellCancelPendingSaveDialog(ctx: *anyopaque) void { + shellCtx(ctx).cancelPendingSaveDialog(); +} +fn shellSetPendingCloseDocId(ctx: *anyopaque, id: u64) void { + shellCtx(ctx).pending_close_file_id = id; +} +fn shellQueueCloseAfterSave(ctx: *anyopaque, id: u64) anyerror!void { + try shellCtx(ctx).pending_close_after_save.put(fizzy.app.allocator, id, {}); +} +fn shellTrackQuitSaveInFlight(ctx: *anyopaque, id: u64) anyerror!void { + try shellCtx(ctx).quit_saves_in_flight.put(fizzy.app.allocator, id, {}); +} +fn shellResumeSaveAllQuit(ctx: *anyopaque) void { + shellCtx(ctx).pending_quit_continue = true; +} +fn shellAbortSaveAllQuit(ctx: *anyopaque) void { + shellCtx(ctx).abortSaveAllQuit(); +} +fn shellStartPackProject(ctx: *anyopaque) anyerror!void { + return shellCtx(ctx).startPackProject(); +} +fn shellIsPackingActive(ctx: *anyopaque) bool { + return shellCtx(ctx).isPackingActive(); +} + +/// Resolve a shell `DocHandle` to the plugin-owned file. Uses `doc.id`, not `doc.ptr`: +/// `docs.files` may reallocate and invalidate pointers stored at insert time. +pub fn fileFromDoc(_: *Editor, doc: sdk.DocHandle) *fizzy.Internal.File { + return fizzy.pixelart.docs.fileById(doc.id).?; +} + +pub fn docAt(editor: *Editor, index: usize) ?sdk.DocHandle { + if (index >= editor.open_files.values().len) return null; + return editor.open_files.values()[index]; +} + +pub fn docById(editor: *Editor, id: u64) ?sdk.DocHandle { + return editor.open_files.get(id); +} + +pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { + if (editor.workspaces.get(editor.open_workspace_grouping)) |workspace| { + return editor.docAt(workspace.open_file_index); + } + return null; +} + +/// Store a loaded/created document in the plugin registry and register its handle. +pub fn insertOpenDoc(editor: *Editor, file: fizzy.Internal.File, owner: *sdk.Plugin) !void { + try fizzy.pixelart.docs.files.put(fizzy.app.allocator, file.id, file); + const ptr = fizzy.pixelart.docs.files.getPtr(file.id).?; + try editor.open_files.put(fizzy.app.allocator, file.id, .{ + // `ptr` is a hint only; consumers must resolve via `fileFromDoc` / `doc.id`. + .ptr = ptr, + .owner = owner, + .id = file.id, + }); +} /// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void { @@ -733,8 +894,8 @@ pub fn markSettingsDirty(editor: *Editor) void { } fn activelyDrawing(editor: *Editor) bool { - for (editor.open_files.values()) |*file| { - if (file.editor.active_drawing) return true; + for (editor.open_files.values()) |doc| { + if (editor.fileFromDoc(doc).editor.active_drawing) return true; } return false; } @@ -828,7 +989,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Drain any "Save and Close" requests whose async save has settled. editor.tickPendingSaveCloses(); var needs_save_status_anim_tick = false; - for (editor.open_files.values()) |*f| { + for (editor.open_files.values()) |doc| { + const f = editor.fileFromDoc(doc); f.tickSaveDoneFlash(); if (f.showsSaveStatusIndicator()) needs_save_status_anim_tick = true; } @@ -853,8 +1015,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { if (!want_quit) continue; var dirty_n: usize = 0; - for (editor.open_files.values()) |f| { - if (f.dirty()) dirty_n += 1; + for (editor.open_files.values()) |doc| { + if (editor.fileFromDoc(doc).dirty()) dirty_n += 1; } if (dirty_n == 0) continue; @@ -910,7 +1072,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { { var any_drawing = false; fizzy.perf.draw_stroke_buf_count = 0; // no active stroke → 0; else first active file's map size - for (editor.open_files.values()) |*file| { + for (editor.open_files.values()) |doc| { + const file = editor.fileFromDoc(doc); if (file.editor.active_drawing) { any_drawing = true; fizzy.perf.draw_stroke_buf_count = file.buffers.stroke.pixels.count(); @@ -1126,7 +1289,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Always reset the peek layer index back, but we need to do this outside of the file widget so // other editor windows can use it - defer for (editor.open_files.values()) |*file| { + defer for (editor.open_files.values()) |doc| { + const file = editor.fileFromDoc(doc); if (file.editor.isolate_layer) { file.peek_layer_index = file.selected_layer_index; } else { @@ -1590,7 +1754,7 @@ pub fn drawRadialMenu(editor: *Editor) !void { // fixed until close so tool buttons remain hoverable/clickable. const center = fw.data().rectScale().pointFromPhysical(fizzy.pixelart.tools.radial_menu.center); - const tool_count: usize = std.meta.fields(Editor.Tools.Tool).len; + const tool_count: usize = std.meta.fields(Tools.Tool).len; const radius: f32 = 50.0; const width: f32 = radius * 2.0; @@ -1667,7 +1831,7 @@ pub fn drawRadialMenu(editor: *Editor) !void { rect.x -= rect.w / 2.0; rect.y -= rect.h / 2.0; - const tool = @as(Editor.Tools.Tool, @enumFromInt(i)); + const tool = @as(Tools.Tool, @enumFromInt(i)); var button: dvui.ButtonWidget = undefined; button.init(@src(), .{}, .{ @@ -1696,7 +1860,7 @@ pub fn drawRadialMenu(editor: *Editor) !void { .color => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.color_selection_default], }; - const sprite = switch (@as(Editor.Tools.Tool, @enumFromInt(i))) { + const sprite = switch (@as(Tools.Tool, @enumFromInt(i))) { .pointer => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.cursor_default], .pencil => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pencil_default], .eraser => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.eraser_default], @@ -1788,10 +1952,12 @@ pub fn drawRadialMenu(editor: *Editor) !void { pub fn rebuildWorkspaces(editor: *Editor) !void { // Create workspaces for each grouping ID - for (editor.open_files.values()) |*file| { + for (editor.open_files.values()) |doc| { + const file = editor.fileFromDoc(doc); if (!editor.workspaces.contains(file.editor.grouping)) { var workspace: fizzy.Editor.Workspace = .init(file.editor.grouping); - for (editor.open_files.values()) |*f| { + for (editor.open_files.values()) |d| { + const f = editor.fileFromDoc(d); if (f.editor.grouping == file.editor.grouping) { workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0; } @@ -1811,7 +1977,8 @@ pub fn rebuildWorkspaces(editor: *Editor) !void { } var contains: bool = false; - for (editor.open_files.values()) |*file| { + for (editor.open_files.values()) |doc| { + const file = editor.fileFromDoc(doc); if (file.editor.grouping == workspace.grouping) { contains = true; break; @@ -1939,8 +2106,7 @@ fn tickPendingSaveCloses(editor: *Editor) void { var i: usize = 0; while (i < editor.pending_close_after_save.count()) { const id = editor.pending_close_after_save.keys()[i]; - const file_ptr = editor.open_files.getPtr(id); - if (file_ptr) |f| { + if (fizzy.pixelart.docs.fileById(id)) |f| { if (f.isSaving()) { i += 1; continue; @@ -1970,7 +2136,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { // Pass 1: kick off any queued saves we haven't started yet. while (editor.quit_save_all_ids.items.len > 0) { const id = editor.quit_save_all_ids.items[0]; - const file_ptr = editor.open_files.getPtr(id) orelse { + const file_ptr = fizzy.pixelart.docs.fileById(id) orelse { _ = editor.quit_save_all_ids.swapRemove(0); continue; }; @@ -2019,8 +2185,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { var i: usize = 0; while (i < editor.quit_saves_in_flight.count()) { const id = editor.quit_saves_in_flight.keys()[i]; - const file_ptr = editor.open_files.getPtr(id); - if (file_ptr) |f| { + if (fizzy.pixelart.docs.fileById(id)) |f| { if (f.isSaving()) { i += 1; continue; @@ -2051,8 +2216,8 @@ pub fn close(app: *App, editor: *Editor) void { return; } var dirty_n: usize = 0; - for (editor.open_files.values()) |f| { - if (f.dirty()) dirty_n += 1; + for (editor.open_files.values()) |doc| { + if (editor.fileFromDoc(doc).dirty()) dirty_n += 1; } if (dirty_n > 0) { Dialogs.AppQuitUnsaved.request(); @@ -2073,15 +2238,15 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { } editor.folder = try fizzy.app.allocator.dupe(u8, path); try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); - editor.host.setActiveSidebarView(@import("../plugins/workbench/plugin.zig").view_files); + editor.host.setActiveSidebarView(@import("../plugins/workbench/src/plugin.zig").view_files); fizzy.pixelart.project = Project.load(fizzy.app.allocator) catch null; editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); } pub fn saving(editor: *Editor) bool { - for (editor.open_files.values()) |file| { - if (file.saving) return true; + for (editor.open_files.values()) |doc| { + if (editor.fileFromDoc(doc).saving) return true; } return false; } @@ -2098,7 +2263,7 @@ pub fn saving(editor: *Editor) bool { pub fn openOrFocusFileAtGrouping(editor: *Editor, path: []const u8, grouping: u64) !?usize { if (editor.getFileFromPath(path)) |file| { const idx = editor.open_files.getIndex(file.id) orelse return error.Unexpected; - editor.open_files.values()[idx].editor.grouping = grouping; + editor.fileAt(idx).?.editor.grouping = grouping; editor.setActiveFile(idx); return idx; } @@ -2121,7 +2286,8 @@ pub fn clearFileTreeTabDragDropState(editor: *Editor) void { pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { // Already open? Just focus it. - for (editor.open_files.values(), 0..) |*file, i| { + for (editor.open_files.values(), 0..) |doc, i| { + const file = editor.fileFromDoc(doc); if (std.mem.eql(u8, file.path, path)) { editor.setActiveFile(i); return false; @@ -2170,7 +2336,8 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { /// Synchronous open from browser file-picker bytes. Caller owns `path` on success (stored in `File.path`). pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !fizzy.Internal.File { - for (editor.open_files.values()) |*file| { + for (editor.open_files.values()) |doc| { + const file = editor.fileFromDoc(doc); if (std.mem.eql(u8, file.path, path)) { if (editor.open_files.getIndex(file.id)) |idx| { editor.setActiveFile(idx); @@ -2224,7 +2391,15 @@ pub fn processLoadingJobs(editor: *Editor) void { var file = result; file.editor.grouping = job.target_grouping; - editor.open_files.put(fizzy.app.allocator, file.id, file) catch { + const owner = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse { + dvui.log.err("No plugin for loaded file: {s}", .{job.path}); + var f = file; + f.deinit(); + job.destroy(); + continue; + }; + + editor.insertOpenDoc(file, owner) catch { dvui.log.err("Failed to insert loaded file into open_files: {s}", .{job.path}); // We still own `file` here — clean it up. var f = file; @@ -2358,7 +2533,8 @@ fn runWasmPackWorkers(_: *Editor) void { } fn appendOpenPackInputs(editor: *Editor, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void { - for (editor.open_files.values()) |*open_file| { + for (editor.open_files.values()) |doc| { + const open_file = editor.fileFromDoc(doc); const snapshot = try PackJob.PackFile.fromOpenFile(fizzy.app.allocator, open_file); try inputs.append(fizzy.app.allocator, .{ .open = snapshot }); } @@ -2400,7 +2576,8 @@ fn findOpenFileForPackPath(editor: *Editor, path: []const u8) ?*fizzy.Internal.F if (editor.getFileFromPath(path)) |file| return file; const basename = std.fs.path.basename(path); - for (editor.open_files.values()) |*file| { + for (editor.open_files.values()) |doc| { + const file = editor.fileFromDoc(doc); if (!std.mem.eql(u8, std.fs.path.basename(file.path), basename)) continue; if (std.mem.eql(u8, file.path, path)) return file; if (editor.folder) |folder| { @@ -2473,7 +2650,7 @@ pub fn processPackJob(editor: *Editor) void { } fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp(); job.result_consumed = true; - editor.host.setActiveSidebarView(@import("../plugins/pixelart/plugin.zig").view_project); + editor.host.setActiveSidebarView(fizzy.pixelart_mod.plugin.view_project); const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null; showPackToast("Project packed", toast_canvas); } else blk: { @@ -2719,17 +2896,18 @@ pub fn newFile(editor: *Editor, path: []const u8, options: fizzy.Internal.File.I return error.FailedToCreateFile; }; - try editor.open_files.put(fizzy.app.allocator, file.id, file); + try editor.insertOpenDoc(file, fizzy.pixelart_mod.plugin.pluginPtr()); editor.setActiveFile(editor.open_files.count() - 1); editor.pending_composite_warmup = true; - return editor.open_files.getPtr(file.id) orelse return error.FailedToCreateFile; + return fizzy.pixelart.docs.fileById(file.id) orelse return error.FailedToCreateFile; } -/// Heap-owned path like `untitled-1`, unique among `open_files` basenames. +/// Heap-owned path like `untitled-1`, unique among open-document basenames. pub fn allocNextUntitledPath(editor: *Editor) ![]u8 { var max_n: u32 = 0; - for (editor.open_files.values()) |f| { + for (editor.open_files.values()) |doc| { + const f = editor.fileFromDoc(doc); const base = std.fs.path.basename(f.path); if (std.mem.startsWith(u8, base, "untitled-")) { const suffix = base["untitled-".len..]; @@ -2786,8 +2964,7 @@ pub fn requestNewFileDialog(_: *Editor) void { } pub fn setActiveFile(editor: *Editor, index: usize) void { - if (index >= editor.open_files.values().len) return; - const file = editor.open_files.values()[index]; + const file = editor.fileAt(index) orelse return; const grouping = file.editor.grouping; if (editor.workspaces.getPtr(grouping)) |workspace| { @@ -2798,30 +2975,21 @@ pub fn setActiveFile(editor: *Editor, index: usize) void { /// Returns the actively focused file, through workspace grouping. pub fn activeFile(editor: *Editor) ?*fizzy.Internal.File { - if (editor.workspaces.get(editor.open_workspace_grouping)) |workspace| { - return editor.getFile(workspace.open_file_index); - } - - return null; + const doc = editor.activeDoc() orelse return null; + return editor.fileFromDoc(doc); } pub fn getFile(editor: *Editor, index: usize) ?*fizzy.Internal.File { - if (editor.open_files.values().len == 0) return null; - if (index >= editor.open_files.values().len) return null; - - return &editor.open_files.values()[index]; + return editor.fileAt(index); } -pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*fizzy.Internal.File { - if (editor.open_files.values().len == 0) return null; - - for (editor.open_files.values()) |*file| { - if (std.mem.eql(u8, file.path, path)) { - return file; - } - } +pub fn fileAt(editor: *Editor, index: usize) ?*fizzy.Internal.File { + const doc = editor.docAt(index) orelse return null; + return editor.fileFromDoc(doc); +} - return null; +pub fn getFileFromPath(_: *Editor, path: []const u8) ?*fizzy.Internal.File { + return fizzy.pixelart.docs.fileFromPath(path); } pub fn forceCloseFile(editor: *Editor, index: usize) !void { @@ -3227,7 +3395,8 @@ pub fn requestWebSaveDialog(editor: *Editor, kind: Dialogs.WebSaveAs.Kind) void /// or flat-raster confirmation are skipped — the user can save those individually. /// Files that are already saving are also skipped (their `saveAsync` no-ops). pub fn saveAll(editor: *Editor) !void { - for (editor.open_files.values()) |*file| { + for (editor.open_files.values()) |doc| { + const file = editor.fileFromDoc(doc); if (!file.dirty()) continue; if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) continue; if (file.shouldConfirmFlatRasterSave()) continue; @@ -3275,7 +3444,7 @@ pub fn cancelPendingSaveDialog(editor: *Editor) void { if (file_id) |id| { _ = editor.pending_close_after_save.swapRemove(id); - if (editor.open_files.getPtr(id)) |f| { + if (fizzy.pixelart.docs.fileById(id)) |f| { f.resetSaveUIState(); } } else if (editor.activeFile()) |f| { @@ -3425,38 +3594,43 @@ pub fn openInFileBrowser(_: *Editor, path: []const u8) !void { } pub fn closeFileID(editor: *Editor, id: u64) !void { - if (editor.open_files.get(id)) |file| { - if (file.dirty()) { - Dialogs.UnsavedClose.request(id); - return; + if (editor.open_files.contains(id)) { + if (fizzy.pixelart.docs.fileById(id)) |file| { + if (file.dirty()) { + Dialogs.UnsavedClose.request(id); + return; + } } try editor.rawCloseFileID(id); } } pub fn closeFile(editor: *Editor, index: usize) !void { - const file = editor.open_files.values()[index]; - try editor.closeFileID(file.id); + const doc = editor.docAt(index) orelse return; + try editor.closeFileID(doc.id); } -/// Tear down a file's resources via its owning plugin, falling back to a direct -/// `deinit` when no plugin claims the extension. The shell still owns removing the -/// entry from `open_files`; this only releases the document's own resources. -fn closeDocumentResources(editor: *Editor, file: *fizzy.Internal.File) void { - if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { - if (plugin.closeDocument(.{ .ptr = file, .owner = plugin, .id = file.id })) return; +/// Tear down a document via its owning plugin, falling back to a direct `deinit`. +/// Removes the entry from the plugin's document registry; the shell still removes +/// the matching `DocHandle` from `open_files`. +fn closeDocumentResources(editor: *Editor, doc: sdk.DocHandle) void { + if (doc.owner.closeDocument(doc)) { + _ = fizzy.pixelart.docs.files.swapRemove(doc.id); + return; } - file.deinit(); + editor.fileFromDoc(doc).deinit(); + _ = fizzy.pixelart.docs.files.swapRemove(doc.id); } pub fn rawCloseFile(editor: *Editor, index: usize) !void { - //editor.open_file_index = 0; - var file = editor.open_files.values()[index]; + const doc = editor.docAt(index) orelse return; + const file = editor.fileFromDoc(doc); if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| { - if (workspace.open_file_index == fizzy.editor.open_files.getIndex(file.id)) { - for (fizzy.editor.open_files.values(), 0..) |f, i| { - if (f.grouping == workspace.grouping and f.id != file.id) { + if (workspace.open_file_index == index) { + for (editor.open_files.values(), 0..) |d, i| { + const f = editor.fileFromDoc(d); + if (f.editor.grouping == workspace.grouping and f.id != file.id) { workspace.open_file_index = i; break; } @@ -3464,27 +3638,28 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void { } } - editor.closeDocumentResources(&file); + editor.closeDocumentResources(doc); editor.open_files.orderedRemoveAt(index); } pub fn rawCloseFileID(editor: *Editor, id: u64) !void { - if (editor.open_files.getPtr(id)) |file| { - - //editor.open_file_index = 0; - if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| { - if (workspace.open_file_index == fizzy.editor.open_files.getIndex(file.id)) { - for (fizzy.editor.open_files.values(), 0..) |f, i| { - if (f.editor.grouping == workspace.grouping and f.id != file.id) { - workspace.open_file_index = i; - break; - } + const doc = editor.open_files.get(id) orelse return; + const file = editor.fileFromDoc(doc); + + if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| { + if (workspace.open_file_index == editor.open_files.getIndex(file.id)) { + for (editor.open_files.values(), 0..) |d, i| { + const f = editor.fileFromDoc(d); + if (f.editor.grouping == workspace.grouping and f.id != file.id) { + workspace.open_file_index = i; + break; } } } - editor.closeDocumentResources(file); - _ = editor.open_files.orderedRemove(id); } + + editor.closeDocumentResources(doc); + _ = editor.open_files.orderedRemove(id); } pub fn closeReference(editor: *Editor, index: usize) !void { @@ -3553,7 +3728,7 @@ pub fn deinit(editor: *Editor) !void { editor.workbench.deinit(); // Pixel-art state (tools/colors/project/pack jobs) is torn down by - // `PixelArt.deinit` in `App.AppDeinit`, after this returns. + // `State.deinit` in `App.AppDeinit`, after this returns. editor.ignore.deinit(fizzy.app.allocator); diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig index 9110c24e..0d0beb1a 100644 --- a/src/editor/Infobar.zig +++ b/src/editor/Infobar.zig @@ -2,7 +2,7 @@ const std = @import("std"); const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const icons = @import("icons"); -const update_notify = @import("../update_notify.zig"); +const update_notify = @import("../backend/update_notify.zig"); const Dialogs = fizzy.Editor.Dialogs; pub const Infobar = @This(); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index b6ae5cca..c5714204 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -92,7 +92,7 @@ pub fn tick() !void { if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) { - if (fizzy.pixelart.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1) + if (fizzy.pixelart.tools.stroke_size < fizzy.Tools.max_brush_size - 1) fizzy.pixelart.tools.stroke_size += 1; fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size); diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 09c51b02..6a83cc57 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -157,7 +157,8 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { // Save All is enabled whenever any open file is dirty with a recognized // extension. Worker queue handles them serially; UI stays responsive. const any_dirty = blk: { - for (fizzy.editor.open_files.values()) |*f| { + for (fizzy.editor.open_files.values()) |doc| { + const f = fizzy.editor.fileFromDoc(doc); if (f.dirty() and fizzy.Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true; } break :blk false; diff --git a/src/editor/WebFileIo.zig b/src/editor/WebFileIo.zig index 2582e00d..8acf190a 100644 --- a/src/editor/WebFileIo.zig +++ b/src/editor/WebFileIo.zig @@ -80,7 +80,13 @@ pub fn pollOpenPicker(editor: *fizzy.Editor) void { const path_owned = fizzy.app.allocator.dupe(u8, wasm_file.name) catch continue; if (editor.openFileFromBytes(path_owned, bytes, open_grouping)) |file| { - editor.open_files.put(fizzy.app.allocator, file.id, file) catch { + const owner = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse { + var f = file; + f.deinit(); + fizzy.app.allocator.free(path_owned); + continue; + }; + editor.insertOpenDoc(file, owner) catch { var f = file; f.deinit(); fizzy.app.allocator.free(path_owned); diff --git a/src/editor/dialogs/AboutFizzy.zig b/src/editor/dialogs/AboutFizzy.zig index eb0b9313..8b15a4a2 100644 --- a/src/editor/dialogs/AboutFizzy.zig +++ b/src/editor/dialogs/AboutFizzy.zig @@ -3,8 +3,8 @@ const builtin = @import("builtin"); const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const build_opts = @import("build_opts"); -const auto_update = @import("../../auto_update.zig"); -const update_notify = @import("../../update_notify.zig"); +const auto_update = @import("../../backend/auto_update.zig"); +const update_notify = @import("../../backend/update_notify.zig"); const assets = @import("assets"); fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool { diff --git a/src/editor/dialogs/AppQuitUnsaved.zig b/src/editor/dialogs/AppQuitUnsaved.zig index 4246abff..b2e05023 100644 --- a/src/editor/dialogs/AppQuitUnsaved.zig +++ b/src/editor/dialogs/AppQuitUnsaved.zig @@ -31,8 +31,8 @@ pub fn request() void { fn dirtyCount() usize { var n: usize = 0; - for (fizzy.editor.open_files.values()) |f| { - if (f.dirty()) n += 1; + for (fizzy.editor.open_files.values()) |doc| { + if (fizzy.editor.fileFromDoc(doc).dirty()) n += 1; } return n; } @@ -112,7 +112,8 @@ fn onSaveAllAndQuit() !void { fizzy.dvui.closeFloatingDialogAnchored(); fizzy.editor.quit_save_all_ids.clearRetainingCapacity(); - for (fizzy.editor.open_files.values()) |f| { + for (fizzy.editor.open_files.values()) |doc| { + const f = fizzy.editor.fileFromDoc(doc); if (f.dirty()) try fizzy.editor.quit_save_all_ids.append(fizzy.app.allocator, f.id); } if (fizzy.editor.quit_save_all_ids.items.len == 0) { diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig index 37f75577..43d7cbac 100644 --- a/src/editor/dialogs/Dialogs.zig +++ b/src/editor/dialogs/Dialogs.zig @@ -1,15 +1,16 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); +const fizzy = @import("../../fizzy.zig"); const Dialogs = @This(); -pub const NewFile = @import("../../plugins/pixelart/dialogs/NewFile.zig"); -pub const Export = @import("../../plugins/pixelart/dialogs/Export.zig"); +pub const NewFile = fizzy.pixelart_mod.dialogs.NewFile; +pub const Export = fizzy.pixelart_mod.dialogs.Export; pub const UnsavedClose = @import("UnsavedClose.zig"); pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig"); -pub const GridLayout = @import("../../plugins/pixelart/dialogs/GridLayout.zig"); -pub const FlatRasterSaveWarning = @import("../../plugins/pixelart/dialogs/FlatRasterSaveWarning.zig"); +pub const GridLayout = fizzy.pixelart_mod.dialogs.GridLayout; +pub const FlatRasterSaveWarning = fizzy.pixelart_mod.dialogs.FlatRasterSaveWarning; pub const AboutFizzy = @import("AboutFizzy.zig"); pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32) @import("WebFolderUnavailable.zig") diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index 35210347..32cb0511 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -1,7 +1,7 @@ const std = @import("std"); const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); -const FlatRasterSaveWarning = @import("../../plugins/pixelart/dialogs/FlatRasterSaveWarning.zig"); +const FlatRasterSaveWarning = fizzy.pixelart_mod.dialogs.FlatRasterSaveWarning; pub fn request(file_id: u64) void { var mutex = fizzy.dvui.dialog(@src(), .{ @@ -21,7 +21,7 @@ pub fn request(file_id: u64) void { } fn fileBasename(file_id: u64) []const u8 { - const file = fizzy.editor.open_files.get(file_id) orelse return "?"; + const file = fizzy.pixelart.docs.fileById(file_id) orelse return "?"; return std.fs.path.basename(file.path); } @@ -111,7 +111,7 @@ fn beginSaveAndClose(file: *fizzy.Internal.File, file_id: u64) !void { } fn onSaveAndClose(file_id: u64) !void { - const file = fizzy.editor.open_files.getPtr(file_id) orelse return; + const file = fizzy.pixelart.docs.fileById(file_id) orelse return; if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) { const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; fizzy.editor.setActiveFile(idx); diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 84ce657f..731678b5 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -13,10 +13,10 @@ const nfd = @import("nfd"); pub const Explorer = @This(); -pub const files = @import("../../plugins/workbench/files.zig"); +pub const files = @import("../../plugins/workbench/src/files.zig"); // pub const animations = @import("animations.zig"); // pub const keyframe_animations = @import("keyframe_animations.zig"); -pub const project = @import("../../plugins/pixelart/explorer/project.zig"); +pub const project = fizzy.pixelart_mod.explorer.project; pub const settings = @import("settings.zig"); paned: *fizzy.dvui.PanedWidget = undefined, @@ -107,7 +107,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { .background = false, }); - if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/plugin.zig").view_files)) { + if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/src/plugin.zig").view_files)) { fizzy.editor.file_tree_data_id = null; if (fizzy.editor.tab_drag_from_tree_path) |p| { fizzy.app.allocator.free(p); diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig index 0e669db4..f63c1c39 100644 --- a/src/editor/panel/Panel.zig +++ b/src/editor/panel/Panel.zig @@ -11,9 +11,6 @@ const Packer = fizzy.Packer; pub const Panel = @This(); -pub const Sprites = @import("../../plugins/pixelart/panel/sprites.zig"); - -sprites: Sprites = .{}, paned: *fizzy.dvui.PanedWidget = undefined, scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto, diff --git a/src/fizzy.zig b/src/fizzy.zig index 561e3b0b..d9c46493 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -17,14 +17,15 @@ pub const version: std.SemanticVersion = .{ pub const atlas = core.atlas; // Other helpers and namespaces -pub const algorithms = @import("plugins/pixelart/algorithms/algorithms.zig"); +pub const pixelart_mod = @import("plugins/pixelart/module.zig"); +pub const algorithms = pixelart_mod.algorithms; +pub const render = pixelart_mod.render; +pub const sprite_render = pixelart_mod.sprite_render; +pub const Tools = pixelart_mod.Tools; +pub const Transform = pixelart_mod.Transform; +pub const PackJob = pixelart_mod.PackJob; pub const fs = core.fs; pub const image = core.image; -pub const render = @import("plugins/pixelart/render.zig"); - -/// Pixel-art sprite renderer (layer compositing, reflections, cover-flow). Shell UI -/// icons use `fizzy.core.Sprite.draw` from core instead. -pub const sprite_render = @import("plugins/pixelart/sprite_render.zig"); pub const perf = core.perf; pub const water_surface = core.water_surface; pub const math = core.math; @@ -33,56 +34,35 @@ pub const App = @import("App.zig"); pub const Editor = @import("editor/Editor.zig"); pub const Explorer = @import("editor/explorer/Explorer.zig"); pub const Fling = core.Fling; -pub const Packer = @import("plugins/pixelart/Packer.zig"); +pub const Packer = pixelart_mod.Packer; //pub const Popups = @import("editor/popups/Popups.zig"); pub const Sidebar = @import("editor/Sidebar.zig"); -/// Pixel-art plugin state (Phase 4 Stage B): the tools/colors/project/clipboard/ -/// pack-job fields formerly hung off the shell `Editor`. -pub const PixelArt = @import("plugins/pixelart/PixelArt.zig"); +/// Pixel-art plugin state (Phase 4 Stage B/D): reached via `fizzy.pixelart` global. +pub const State = pixelart_mod.State; // Global pointers pub var app: *App = undefined; pub var editor: *Editor = undefined; pub var packer: *Packer = undefined; -pub var pixelart: *PixelArt = undefined; - -/// Internal types -/// These types contain additional data to support the editor -/// An example of this is File. fizzy.File matches the file type to read from JSON, -/// while the fizzy.Internal.File contains cameras, timers, file-specific editor fields. -pub const Internal = struct { - pub const Animation = @import("plugins/pixelart/internal/Animation.zig"); - pub const Atlas = @import("plugins/pixelart/internal/Atlas.zig"); - pub const Buffers = @import("plugins/pixelart/internal/Buffers.zig"); - pub const File = @import("plugins/pixelart/internal/File.zig"); - pub const History = @import("plugins/pixelart/internal/History.zig"); - pub const Layer = @import("plugins/pixelart/internal/Layer.zig"); - pub const Palette = @import("plugins/pixelart/internal/Palette.zig"); - pub const Sprite = @import("plugins/pixelart/internal/Sprite.zig"); -}; - -/// Frame-by-frame sprite animation -pub const Animation = @import("plugins/pixelart/Animation.zig"); - -/// Contains lists of sprites and animations -pub const Atlas = @import("plugins/pixelart/Atlas.zig"); - -/// The data that gets written to disk in a .pixi file and read back into this type -pub const File = @import("plugins/pixelart/File.zig"); +pub var pixelart: *State = undefined; -/// Contains information such as the name, visibility and collapse settings of a texture layer -pub const Layer = @import("plugins/pixelart/Layer.zig"); +/// Internal runtime types for open documents (cameras, history, buffers, …). +pub const Internal = pixelart_mod.internal; -/// Source location within the atlas texture and origin location -pub const Sprite = @import("plugins/pixelart/Sprite.zig"); +/// On-disk / JSON pixel-art types. +pub const Animation = pixelart_mod.Animation; +pub const Atlas = pixelart_mod.Atlas; +pub const File = pixelart_mod.File; +pub const Layer = pixelart_mod.Layer; +pub const Sprite = pixelart_mod.Sprite; /// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web /// builds, where `builtin.os.tag` is always `.freestanding`. pub const platform = core.platform; /// Plugin SDK surface -pub const sdk = @import("sdk/sdk.zig"); +pub const sdk = @import("sdk"); /// Custom dvui stuff pub const dvui = core.dvui; @@ -90,11 +70,11 @@ pub const dvui = core.dvui; /// Custom backend stuff. Split per-arch: native uses SDL3 + objc + win32; web is a /// no-op stub layer (no window chrome, no native dialogs, no native menu bar). /// Zig only semantically analyzes the chosen branch, so the wasm build never sees -/// the SDL3 / objc / win32 imports inside `backend_native.zig`. +/// the SDL3 / objc / win32 imports inside `backend/backend_native.zig`. pub const backend = if (@import("builtin").target.cpu.arch == .wasm32) - @import("backend_web.zig") + @import("backend/backend_web.zig") else - @import("backend_native.zig"); + @import("backend/backend_native.zig"); pub const paths = core.paths; diff --git a/src/plugins/pixelart/Colors.zig b/src/plugins/pixelart/Colors.zig deleted file mode 100644 index 5c987ee9..00000000 --- a/src/plugins/pixelart/Colors.zig +++ /dev/null @@ -1,10 +0,0 @@ -const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); - -const Self = @This(); - -primary: [4]u8 = .{ 255, 255, 255, 255 }, -secondary: [4]u8 = .{ 0, 0, 0, 255 }, -height: u8 = 0, -palette: ?fizzy.Internal.Palette = null, -file_tree_palette: ?fizzy.Internal.Palette = null, diff --git a/src/plugins/pixelart/module.zig b/src/plugins/pixelart/module.zig new file mode 100644 index 00000000..55dc09c6 --- /dev/null +++ b/src/plugins/pixelart/module.zig @@ -0,0 +1,51 @@ +//! Pixel-art plugin compile-time module root (Phase 4 Stage D). +//! +//! Wired in `build.zig` as `b.addModule("pixelart", .{ .root_source_file = "module.zig" })`. +//! Shell code imports this as `@import("pixelart")`. Plugin files inside `src/` import +//! `../pixelart.zig` for shared types and `Globals`. +pub const pixelart = @import("pixelart.zig"); +pub const Globals = pixelart.Globals; +pub const State = @import("src/State.zig"); +pub const Settings = @import("src/Settings.zig"); +pub const Docs = @import("src/Docs.zig"); +pub const Tools = @import("src/Tools.zig"); +pub const Transform = @import("src/Transform.zig"); +pub const Project = @import("src/Project.zig"); +pub const Colors = @import("src/Colors.zig"); +pub const Packer = @import("src/Packer.zig"); +pub const PackJob = @import("src/PackJob.zig"); +pub const plugin = @import("src/plugin.zig"); + +pub const dialogs = struct { + pub const NewFile = @import("src/dialogs/NewFile.zig"); + pub const Export = @import("src/dialogs/Export.zig"); + pub const GridLayout = @import("src/dialogs/GridLayout.zig"); + pub const FlatRasterSaveWarning = @import("src/dialogs/FlatRasterSaveWarning.zig"); +}; + +pub const explorer = struct { + pub const project = @import("src/explorer/project.zig"); +}; + +pub const render = @import("src/render.zig"); +pub const sprite_render = @import("src/sprite_render.zig"); +pub const algorithms = @import("src/algorithms/algorithms.zig"); + +/// On-disk / JSON types. +pub const File = @import("src/File.zig"); +pub const Layer = @import("src/Layer.zig"); +pub const Sprite = @import("src/Sprite.zig"); +pub const Atlas = @import("src/Atlas.zig"); +pub const Animation = @import("src/Animation.zig"); + +/// Editor/runtime types (cameras, history, buffers, …). +pub const internal = struct { + pub const Animation = @import("src/internal/Animation.zig"); + pub const Atlas = @import("src/internal/Atlas.zig"); + pub const Buffers = @import("src/internal/Buffers.zig"); + pub const File = @import("src/internal/File.zig"); + pub const History = @import("src/internal/History.zig"); + pub const Layer = @import("src/internal/Layer.zig"); + pub const Palette = @import("src/internal/Palette.zig"); + pub const Sprite = @import("src/internal/Sprite.zig"); +}; diff --git a/src/plugins/pixelart/pixelart.zig b/src/plugins/pixelart/pixelart.zig new file mode 100644 index 00000000..66e29e95 --- /dev/null +++ b/src/plugins/pixelart/pixelart.zig @@ -0,0 +1,54 @@ +//! Intra-plugin import hub for the pixel-art plugin (Phase 4 Stage D). +//! +//! Files inside `src/plugins/pixelart/src/**` import this as `../pixelart.zig` (or +//! `../../pixelart.zig` from nested dirs) instead of `fizzy.zig` for sdk/core/Globals +//! and shared plugin types. The compile-time module root for the build is `module.zig`; +//! shell code reaches the plugin through `@import("pixelart")`. +//! +//! Files that still need shell workbench types (`Editor.Workspace`) keep a local +//! `fizzy` import until that surface moves behind EditorAPI. +const std = @import("std"); + +pub const sdk = @import("sdk"); +pub const core = @import("core"); +pub const dvui = @import("dvui"); +pub const atlas = core.atlas; +pub const math = core.math; +pub const image = core.image; +pub const fs = core.fs; +pub const perf = core.perf; +pub const Fling = core.Fling; +pub const water_surface = core.water_surface; +pub const core_sprite = core.Sprite; +pub const Globals = @import("src/Globals.zig"); + +/// On-disk file format version stamp (kept in sync with `fizzy.version`). +pub const version: std.SemanticVersion = .{ .major = 0, .minor = 2, .patch = 0 }; + +pub const State = @import("src/State.zig"); +pub const Settings = @import("src/Settings.zig"); +pub const Docs = @import("src/Docs.zig"); +pub const Tools = @import("src/Tools.zig"); +pub const Transform = @import("src/Transform.zig"); +pub const Animation = @import("src/Animation.zig"); +pub const Layer = @import("src/Layer.zig"); +pub const Sprite = @import("src/Sprite.zig"); +pub const Atlas = @import("src/Atlas.zig"); +pub const File = @import("src/File.zig"); +pub const render = @import("src/render.zig"); +pub const sprite_render = @import("src/sprite_render.zig"); +pub const algorithms = @import("src/algorithms/algorithms.zig"); + +pub const internal = struct { + pub const File = @import("src/internal/File.zig"); + pub const Layer = @import("src/internal/Layer.zig"); + pub const Palette = @import("src/internal/Palette.zig"); + pub const Atlas = @import("src/internal/Atlas.zig"); + pub const History = @import("src/internal/History.zig"); + pub const Buffers = @import("src/internal/Buffers.zig"); + pub const Animation = @import("src/internal/Animation.zig"); + pub const Sprite = @import("src/internal/Sprite.zig"); +}; + +/// Layer rename buffer size (was `Editor.Constants.max_name_len`). +pub const max_name_len = 256; diff --git a/src/plugins/pixelart/Animation.zig b/src/plugins/pixelart/src/Animation.zig similarity index 100% rename from src/plugins/pixelart/Animation.zig rename to src/plugins/pixelart/src/Animation.zig diff --git a/src/plugins/pixelart/Atlas.zig b/src/plugins/pixelart/src/Atlas.zig similarity index 100% rename from src/plugins/pixelart/Atlas.zig rename to src/plugins/pixelart/src/Atlas.zig diff --git a/src/plugins/pixelart/CanvasData.zig b/src/plugins/pixelart/src/CanvasData.zig similarity index 96% rename from src/plugins/pixelart/CanvasData.zig rename to src/plugins/pixelart/src/CanvasData.zig index 868bb422..3cb74427 100644 --- a/src/plugins/pixelart/CanvasData.zig +++ b/src/plugins/pixelart/src/CanvasData.zig @@ -11,12 +11,14 @@ //! toasts) intentionally stays on `Workspace`. const std = @import("std"); const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const icons = @import("icons"); const FileWidget = @import("widgets/FileWidget.zig"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; const Workspace = fizzy.Editor.Workspace; -const File = fizzy.Internal.File; +const File = pixelart.internal.File; const CanvasData = @This(); @@ -48,8 +50,8 @@ edit_pill_expanded: bool = false, pub fn init(grouping: u64) CanvasData { return .{ - .columns_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "column_drag_{d}", .{grouping}) catch "column_drag", - .rows_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "row_drag_{d}", .{grouping}) catch "row_drag", + .columns_drag_name = std.fmt.allocPrint(Globals.allocator(), "column_drag_{d}", .{grouping}) catch "column_drag", + .rows_drag_name = std.fmt.allocPrint(Globals.allocator(), "row_drag_{d}", .{grouping}) catch "row_drag", }; } @@ -62,7 +64,7 @@ pub fn deinit(_: *CanvasData) void {} /// first use. Called from the plugin's `drawDocument` each frame a document pane renders. pub fn ensure(ws: *Workspace) *CanvasData { if (ws.plugin_view_state) |p| return @ptrCast(@alignCast(p)); - const self = fizzy.app.allocator.create(CanvasData) catch @panic("OOM allocating CanvasData"); + const self = Globals.allocator().create(CanvasData) catch @panic("OOM allocating CanvasData"); self.* = CanvasData.init(ws.grouping); ws.plugin_view_state = self; ws.plugin_view_destroy = destroyOpaque; @@ -81,7 +83,7 @@ pub fn fromWorkspace(ws: *Workspace) ?*CanvasData { fn destroyOpaque(state: *anyopaque) void { const self: *CanvasData = @ptrCast(@alignCast(state)); self.deinit(); - fizzy.app.allocator.destroy(self); + Globals.allocator().destroy(self); } pub const RulerOrientation = enum { @@ -99,15 +101,15 @@ pub fn drawRuler(self: *CanvasData, file: *File, orientation: RulerOrientation) const largest_label_size = font.textSize(largest_label); const natural_scale = dvui.currentWindow().natural_scale; const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical); - const base_ruler_size = largest_label_size.w + fizzy.pixelart.settings.ruler_padding; + const base_ruler_size = largest_label_size.w + Globals.state.settings.ruler_padding; const ruler_thickness: f32 = switch (orientation) { .horizontal => blk: { - self.horizontal_ruler_height = font.textSize("M").h + fizzy.pixelart.settings.ruler_padding; + self.horizontal_ruler_height = font.textSize("M").h + Globals.state.settings.ruler_padding; break :blk self.horizontal_ruler_height; }, .vertical => blk: { - self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.pixelart.settings.ruler_padding); + self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + Globals.state.settings.ruler_padding); break :blk self.vertical_ruler_width; }, }; @@ -228,7 +230,7 @@ fn drawRulerContent( .vertical => self.rows_drag_name, }; - var reorder = fizzy.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ + var reorder = pixelart.core.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ .expand = .both, .margin = dvui.Rect.all(0), .padding = dvui.Rect.all(0), @@ -278,7 +280,7 @@ fn drawRulerContent( .horizontal => .{ .w = @as(f32, @floatFromInt(file.column_width)), .h = 1.0 }, .vertical => .{ .w = 1.0, .h = @as(f32, @floatFromInt(file.row_height)) }, }; - const reorder_mode: fizzy.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) { + const reorder_mode: pixelart.core.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) { .horizontal => .any_y, .vertical => .any_x, }; @@ -316,7 +318,7 @@ fn drawRulerContent( var button_color = if (reorder.drag_point != null) dvui.themeGet().color(.control, .fill).opacity(0.85) else dvui.themeGet().color(.window, .fill); - if (fizzy.dvui.hovered(reorderable.data())) { + if (pixelart.core.dvui.hovered(reorderable.data())) { button_color = dvui.themeGet().color(.control, .fill_hover); dvui.cursorSet(.hand); } @@ -591,7 +593,7 @@ pub fn drawRulerLabel(_: *CanvasData, options: TextLabelOptions) void { else font.textSize(label).scale(natural, dvui.Size.Physical); - const padding = fizzy.pixelart.settings.ruler_padding * natural; + const padding = Globals.state.settings.ruler_padding * natural; var label_rect = rect; @@ -787,12 +789,12 @@ pub fn drawTransformDialog(_: *CanvasData, file: *File, container: *dvui.WidgetD }); defer box.deinit(); if (dvui.buttonIcon(@src(), "transform_cancel", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .err, .expand = .horizontal })) { - fizzy.editor.cancel() catch { + Globals.state.host.cancel() catch { dvui.log.err("Failed to cancel transform", .{}); }; } if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) { - fizzy.editor.accept() catch { + Globals.state.host.accept() catch { dvui.log.err("Failed to accept transform", .{}); }; } @@ -806,7 +808,7 @@ pub fn drawTransformDialog(_: *CanvasData, file: *File, container: *dvui.WidgetD /// single hamburger circle; tapping toggles the row of action buttons in/out with a /// width animation. pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { - const file = fizzy.editor.activeFile() orelse return; + const file = Globals.state.docs.activeFile(Globals.state.host) orelse return; const button_size: f32 = 36; const button_gap: f32 = 6; @@ -864,8 +866,8 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { // shrinks, and once it's narrower than the pill we bail and draw nothing this frame — // so closing splits cleanly hides the menu. const wb = container.rectScale().r.toNatural(); - const ruler_top: f32 = if (fizzy.pixelart.settings.show_rulers) self.horizontal_ruler_height else 0; - const ruler_left: f32 = if (fizzy.pixelart.settings.show_rulers) self.vertical_ruler_width else 0; + const ruler_top: f32 = if (Globals.state.settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (Globals.state.settings.show_rulers) self.vertical_ruler_width else 0; const canvas_nat = dvui.Rect{ .x = wb.x + ruler_left, .y = wb.y + ruler_top, @@ -1041,12 +1043,12 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { const fully_expanded = anim_value >= 0.999; if (btn.clicked() and enabled and fully_expanded) { switch (entry.action) { - .save => fizzy.editor.save() catch { + .save => Globals.state.host.save() catch { dvui.log.err("Failed to save", .{}); }, .exportd => { // Open the Export dialog (same configuration the `export` keybind uses). - var mutex = fizzy.dvui.dialog(@src(), .{ + var mutex = pixelart.core.dvui.dialog(@src(), .{ .displayFn = fizzy.Editor.Dialogs.Export.dialog, .callafterFn = fizzy.Editor.Dialogs.Export.callAfter, .title = "Export...", @@ -1065,16 +1067,16 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { .redo => file.history.undoRedo(file, .redo) catch { dvui.log.err("Failed to redo", .{}); }, - .copy => fizzy.editor.copy() catch { + .copy => Globals.state.host.copy() catch { dvui.log.err("Failed to copy", .{}); }, - .paste => fizzy.editor.paste() catch { + .paste => Globals.state.host.paste() catch { dvui.log.err("Failed to paste", .{}); }, - .transform => fizzy.editor.transform() catch { + .transform => Globals.state.host.transform() catch { dvui.log.err("Failed to start transform", .{}); }, - .grid_layout => fizzy.editor.requestGridLayoutDialog(), + .grid_layout => Globals.state.host.requestGridLayoutDialog(), } } } @@ -1088,7 +1090,7 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { /// the existing color-dropper magnifier at the touch location. On release we read the /// color underneath the sample point and apply it to the primary color slot. pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void { - const file = fizzy.editor.activeFile() orelse return; + const file = Globals.state.docs.activeFile(Globals.state.host) orelse return; const pill_button_size: f32 = 36; const pill_padding: f32 = 6; @@ -1102,8 +1104,8 @@ pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void { // Anchor against the same canvas-scroll-area rect the pill uses. const wb = container.rectScale().r.toNatural(); - const ruler_top: f32 = if (fizzy.pixelart.settings.show_rulers) self.horizontal_ruler_height else 0; - const ruler_left: f32 = if (fizzy.pixelart.settings.show_rulers) self.vertical_ruler_width else 0; + const ruler_top: f32 = if (Globals.state.settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (Globals.state.settings.show_rulers) self.vertical_ruler_width else 0; const canvas_nat = dvui.Rect{ .x = wb.x + ruler_left, .y = wb.y + ruler_top, diff --git a/src/plugins/pixelart/src/Colors.zig b/src/plugins/pixelart/src/Colors.zig new file mode 100644 index 00000000..6fc49554 --- /dev/null +++ b/src/plugins/pixelart/src/Colors.zig @@ -0,0 +1,11 @@ +const std = @import("std"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; + +const Self = @This(); + +primary: [4]u8 = .{ 255, 255, 255, 255 }, +secondary: [4]u8 = .{ 0, 0, 0, 255 }, +height: u8 = 0, +palette: ?pixelart.internal.Palette = null, +file_tree_palette: ?pixelart.internal.Palette = null, diff --git a/src/plugins/pixelart/src/Docs.zig b/src/plugins/pixelart/src/Docs.zig new file mode 100644 index 00000000..7ce735de --- /dev/null +++ b/src/plugins/pixelart/src/Docs.zig @@ -0,0 +1,37 @@ +//! Open-document registry for the pixel-art plugin (Phase 4 docs/tabs inversion). +//! +//! The shell stores opaque `DocHandle`s in `Editor.open_files`; this map owns the +//! concrete `Internal.File` values their `ptr` fields point at. +const std = @import("std"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const sdk = pixelart.sdk; +const Internal = pixelart.internal; + +const Docs = @This(); + +files: std.AutoArrayHashMapUnmanaged(u64, Internal.File) = .{}, + +pub fn fileFrom(self: *Docs, doc: sdk.DocHandle) *Internal.File { + return self.files.getPtr(doc.id).?; +} + +pub fn activeFile(self: *Docs, host: *sdk.Host) ?*Internal.File { + const doc = host.activeDoc() orelse return null; + return self.fileFrom(doc); +} + +pub fn fileById(self: *Docs, id: u64) ?*Internal.File { + return self.files.getPtr(id); +} + +pub fn fileFromPath(self: *Docs, path: []const u8) ?*Internal.File { + for (self.files.values()) |*file| { + if (std.mem.eql(u8, file.path, path)) return file; + } + return null; +} + +pub fn deinit(self: *Docs, allocator: std.mem.Allocator) void { + self.files.deinit(allocator); +} diff --git a/src/plugins/pixelart/File.zig b/src/plugins/pixelart/src/File.zig similarity index 84% rename from src/plugins/pixelart/File.zig rename to src/plugins/pixelart/src/File.zig index 6f0f786e..df2157cf 100644 --- a/src/plugins/pixelart/File.zig +++ b/src/plugins/pixelart/src/File.zig @@ -1,5 +1,8 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); + +const Layer = @import("Layer.zig"); +const Sprite = @import("Sprite.zig"); +const Animation = @import("Animation.zig"); const File = @This(); @@ -13,11 +16,11 @@ column_width: u32, row_height: u32, // Layer data -layers: []fizzy.Layer, +layers: []Layer, // Origins of sprites -sprites: []fizzy.Sprite, +sprites: []Sprite, // Lists of sprite indexes and timings -animations: []fizzy.Animation, +animations: []Animation, pub fn deinit(self: *File, allocator: std.mem.Allocator) void { for (self.layers) |*layer| { @@ -39,9 +42,9 @@ pub const FileV3 = struct { rows: u32, column_width: u32, row_height: u32, - layers: []fizzy.Layer, - sprites: []fizzy.Sprite, - animations: []fizzy.Animation.AnimationV2, + layers: []Layer, + sprites: []Sprite, + animations: []Animation.AnimationV2, pub fn deinit(self: *File, allocator: std.mem.Allocator) void { for (self.layers) |*layer| { @@ -63,9 +66,9 @@ pub const FileV2 = struct { height: u32, tile_width: u32, tile_height: u32, - layers: []fizzy.Layer, - sprites: []fizzy.Sprite, - animations: []fizzy.Animation.AnimationV2, + layers: []Layer, + sprites: []Sprite, + animations: []Animation.AnimationV2, pub fn deinit(self: *File, allocator: std.mem.Allocator) void { for (self.layers) |*layer| { @@ -87,9 +90,9 @@ pub const FileV1 = struct { height: u32, tile_width: u32, tile_height: u32, - layers: []fizzy.Layer, - sprites: []fizzy.Sprite, - animations: []fizzy.Animation.AnimationV1, + layers: []Layer, + sprites: []Sprite, + animations: []Animation.AnimationV1, pub fn deinit(self: *File, allocator: std.mem.Allocator) void { for (self.layers) |*layer| { diff --git a/src/plugins/pixelart/src/Globals.zig b/src/plugins/pixelart/src/Globals.zig new file mode 100644 index 00000000..16ce8f0d --- /dev/null +++ b/src/plugins/pixelart/src/Globals.zig @@ -0,0 +1,15 @@ +//! Runtime injection points for the pixel-art plugin (Phase 4 Stage D). +//! +//! The shell sets these once during `App` startup so plugin code can reach the +//! app allocator and singletons without importing `fizzy.zig`. +const std = @import("std"); +const State = @import("State.zig"); +const Packer = @import("Packer.zig"); + +pub var gpa: std.mem.Allocator = undefined; +pub var state: *State = undefined; +pub var packer: *Packer = undefined; + +pub fn allocator() std.mem.Allocator { + return gpa; +} diff --git a/src/plugins/pixelart/LDTKTileset.zig b/src/plugins/pixelart/src/LDTKTileset.zig similarity index 87% rename from src/plugins/pixelart/LDTKTileset.zig rename to src/plugins/pixelart/src/LDTKTileset.zig index 09303032..216a59d6 100644 --- a/src/plugins/pixelart/LDTKTileset.zig +++ b/src/plugins/pixelart/src/LDTKTileset.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); const core = @import("mach").core; pub const LDTKCompatibility = struct { diff --git a/src/plugins/pixelart/Layer.zig b/src/plugins/pixelart/src/Layer.zig similarity index 100% rename from src/plugins/pixelart/Layer.zig rename to src/plugins/pixelart/src/Layer.zig diff --git a/src/plugins/pixelart/PackJob.zig b/src/plugins/pixelart/src/PackJob.zig similarity index 93% rename from src/plugins/pixelart/PackJob.zig rename to src/plugins/pixelart/src/PackJob.zig index 2d3882a6..e3583213 100644 --- a/src/plugins/pixelart/PackJob.zig +++ b/src/plugins/pixelart/src/PackJob.zig @@ -7,7 +7,7 @@ //! worker only ever touches its own `PackFile` values plus the app allocator. //! //! The worker produces a finished `Internal.Atlas` (RGBA pixels + sprite/animation data). The -//! main thread swaps it into `fizzy.packer.atlas` via `Editor.processPackJob` once `done` is +//! main thread swaps it into `Globals.packer.atlas` via `Editor.processPackJob` once `done` is //! published. //! //! Ownership / threading model: @@ -17,11 +17,12 @@ //! - `phase` / `cancelled` are atomic; either side may read or write them. const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const zstbi = @import("zstbi"); -const perf = fizzy.perf; +const perf = pixelart.perf; const reduce_alg = @import("algorithms/reduce.zig"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; const PackJob = @This(); @@ -60,7 +61,7 @@ pub const PackSprite = struct { pub const PackAnimation = struct { name: []u8, - frames: []fizzy.Animation.Frame, + frames: []pixelart.Animation.Frame, fn deinit(self: *PackAnimation, allocator: std.mem.Allocator) void { allocator.free(self.name); @@ -81,7 +82,7 @@ pub const PackFile = struct { /// Deep-copy the pack-relevant fields of an in-memory file. Caller must run on the main /// thread (reads the file's pixel buffers, which the editor may otherwise mutate). - pub fn fromOpenFile(allocator: std.mem.Allocator, file: *const fizzy.Internal.File) !PackFile { + pub fn fromOpenFile(allocator: std.mem.Allocator, file: *const pixelart.internal.File) !PackFile { const src_layers = file.layers.slice(); var layers = try allocator.alloc(PackLayer, src_layers.len); @@ -97,7 +98,7 @@ pub const PackFile = struct { const sz = dvui.imageSize(layer.source) catch dvui.Size{ .w = 0, .h = 0 }; const layer_w: u32 = @intFromFloat(sz.w); const layer_h: u32 = @intFromFloat(sz.h); - const src_pixels = fizzy.image.pixels(layer.source); + const src_pixels = pixelart.image.pixels(layer.source); const name_copy = try allocator.dupe(u8, layer.name); errdefer allocator.free(name_copy); @@ -135,7 +136,7 @@ pub const PackFile = struct { const anim = src_anims.get(a); const name_copy = try allocator.dupe(u8, anim.name); errdefer allocator.free(name_copy); - const frames_copy = try allocator.dupe(fizzy.Animation.Frame, anim.frames); + const frames_copy = try allocator.dupe(pixelart.Animation.Frame, anim.frames); anims[a] = .{ .name = name_copy, .frames = frames_copy }; anims_initialized = a + 1; } @@ -155,7 +156,7 @@ pub const PackFile = struct { /// Build a snapshot by loading the file from disk. Safe to call from any thread. pub fn fromPath(allocator: std.mem.Allocator, path: []const u8) !?PackFile { - const maybe_file = try fizzy.Internal.File.fromPath(path); + const maybe_file = try pixelart.internal.File.fromPath(path); var file = maybe_file orelse return null; defer file.deinit(); return try PackFile.fromOpenFile(allocator, &file); @@ -213,7 +214,7 @@ done: std.atomic.Value(bool) = .init(false), /// Worker output. Read only after `done.load(.acquire)`. The main thread takes ownership of /// the inner allocations when it consumes the job; subsequent `destroy()` will leave the /// fields alone. -result_atlas: ?fizzy.Internal.Atlas = null, +result_atlas: ?pixelart.internal.Atlas = null, /// Set to `true` once the main thread has consumed `result_atlas` (so `destroy()` knows not /// to free the moved-out atlas allocations). @@ -238,11 +239,11 @@ pub fn destroy(job: *PackJob) void { a.free(job.inputs); // Free any unconsumed result. `result_consumed` is set by the main thread when it moves - // the atlas into `fizzy.packer.atlas`; in that case the new owner is responsible for the + // the atlas into `Globals.packer.atlas`; in that case the new owner is responsible for the // allocations and we must not double-free. if (job.result_atlas != null and !job.result_consumed) { const atlas = job.result_atlas.?; - a.free(fizzy.image.bytes(atlas.source)); + a.free(pixelart.image.bytes(atlas.source)); for (atlas.data.animations) |*anim| a.free(anim.name); a.free(atlas.data.animations); a.free(atlas.data.sprites); @@ -294,10 +295,10 @@ pub fn workerMain(job: *PackJob) void { dvui.refresh(job.window, @src(), null); } - // Worker-local scratch. The final atlas allocations are made through `fizzy.app.allocator` + // Worker-local scratch. The final atlas allocations are made through `Globals.allocator()` // so they outlive the job; everything else (sprite refs, frames, animations, any // `.path`-loaded `PackFile`s, collapse carry-overs) lives in `ws` and is freed below. - const work = WorkerState.init(fizzy.app.allocator) catch |e| { + const work = WorkerState.init(Globals.allocator()) catch |e| { job.err = e; job.phase.store(@intFromEnum(Phase.failed), .release); return; @@ -341,7 +342,7 @@ pub fn workerMain(job: *PackJob) void { return; } job.phase.store(@intFromEnum(Phase.loading), .release); - const maybe_pf = PackFile.fromPath(fizzy.app.allocator, path) catch |e| { + const maybe_pf = PackFile.fromPath(Globals.allocator(), path) catch |e| { job.err = e; job.phase.store(@intFromEnum(Phase.failed), .release); return; @@ -403,10 +404,10 @@ pub fn workerMain(job: *PackJob) void { if (job.cancelled.load(.monotonic)) { // Free the atlas we just built since the consumer won't take it. - fizzy.app.allocator.free(fizzy.image.bytes(atlas.source)); - for (atlas.data.animations) |*anim| fizzy.app.allocator.free(anim.name); - fizzy.app.allocator.free(atlas.data.animations); - fizzy.app.allocator.free(atlas.data.sprites); + Globals.allocator().free(pixelart.image.bytes(atlas.source)); + for (atlas.data.animations) |*anim| Globals.allocator().free(anim.name); + Globals.allocator().free(atlas.data.animations); + Globals.allocator().free(atlas.data.sprites); job.phase.store(@intFromEnum(Phase.cancelled), .release); return; } @@ -440,7 +441,7 @@ const WorkerSprite = struct { const WorkerAnimation = struct { name: []u8, - frames: []fizzy.Animation.Frame, + frames: []pixelart.Animation.Frame, fn deinit(self: *WorkerAnimation, allocator: std.mem.Allocator) void { allocator.free(self.name); @@ -590,7 +591,7 @@ const WorkerState = struct { if (anim.frames.len == 0) continue; if (anim.frames[0].sprite_index != sprite_index) continue; - const frames = try self.allocator.alloc(fizzy.Animation.Frame, anim.frames.len); + const frames = try self.allocator.alloc(pixelart.Animation.Frame, anim.frames.len); for (frames, anim.frames, 0..) |*current_frame, src_frame, i| { current_frame.* = .{ .sprite_index = new_sprite_index + i, @@ -668,10 +669,10 @@ const WorkerState = struct { /// and panics off the main thread. Build the atlas as a plain pixel buffer + raw /// `pixelsPMA` ImageSource directly; first use of the source on the main thread will pick /// up a fresh texture-cache key because `.invalidation = .ptr` keys on the pixel pointer. - fn buildAtlas(self: *WorkerState, tex_size: [2]u16) !fizzy.Internal.Atlas { + fn buildAtlas(self: *WorkerState, tex_size: [2]u16) !pixelart.internal.Atlas { const num_pixels: usize = @as(usize, tex_size[0]) * @as(usize, tex_size[1]); - const pixels = try fizzy.app.allocator.alloc([4]u8, num_pixels); - errdefer fizzy.app.allocator.free(pixels); + const pixels = try Globals.allocator().alloc([4]u8, num_pixels); + errdefer Globals.allocator().free(pixels); @memset(pixels, .{ 0, 0, 0, 0 }); const tex_w: usize = tex_size[0]; @@ -698,23 +699,23 @@ const WorkerState = struct { } } - const sprites_out = try fizzy.app.allocator.alloc(fizzy.Atlas.Sprite, self.sprites.items.len); - errdefer fizzy.app.allocator.free(sprites_out); + const sprites_out = try Globals.allocator().alloc(pixelart.Atlas.Sprite, self.sprites.items.len); + errdefer Globals.allocator().free(sprites_out); for (sprites_out, self.sprites.items, self.frames.items) |*dst, src, src_rect| { dst.source = .{ src_rect.x, src_rect.y, src_rect.w, src_rect.h }; dst.origin = src.origin; } - const animations_out = try fizzy.app.allocator.alloc(fizzy.Animation, self.animations.items.len); + const animations_out = try Globals.allocator().alloc(pixelart.Animation, self.animations.items.len); var anims_initialized: usize = 0; errdefer { - for (animations_out[0..anims_initialized]) |*anim| fizzy.app.allocator.free(anim.name); - fizzy.app.allocator.free(animations_out); + for (animations_out[0..anims_initialized]) |*anim| Globals.allocator().free(anim.name); + Globals.allocator().free(animations_out); } for (animations_out, self.animations.items) |*dst, src| { - dst.name = try fizzy.app.allocator.dupe(u8, src.name); - errdefer fizzy.app.allocator.free(dst.name); - dst.frames = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, src.frames); + dst.name = try Globals.allocator().dupe(u8, src.name); + errdefer Globals.allocator().free(dst.name); + dst.frames = try Globals.allocator().dupe(pixelart.Animation.Frame, src.frames); anims_initialized += 1; } diff --git a/src/plugins/pixelart/Packer.zig b/src/plugins/pixelart/src/Packer.zig similarity index 81% rename from src/plugins/pixelart/Packer.zig rename to src/plugins/pixelart/src/Packer.zig index ff9688ff..7af26053 100644 --- a/src/plugins/pixelart/Packer.zig +++ b/src/plugins/pixelart/src/Packer.zig @@ -1,8 +1,9 @@ const std = @import("std"); const zstbi = @import("zstbi"); const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; -const fizzy = @import("../../fizzy.zig"); pub const LDTKTileset = @import("LDTKTileset.zig"); @@ -31,16 +32,16 @@ pub const Sprite = struct { frames: std.array_list.Managed(zstbi.Rect), sprites: std.array_list.Managed(Sprite), -animations: std.array_list.Managed(fizzy.Animation), +animations: std.array_list.Managed(pixelart.Animation), id_counter: u32 = 0, placeholder: Image, contains_height: bool = false, -open_files: std.array_list.Managed(fizzy.Internal.File), +open_files: std.array_list.Managed(pixelart.internal.File), target: PackTarget = .project, //camera: fizzy.gfx.Camera = .{}, -atlas: ?fizzy.Internal.Atlas = null, +atlas: ?pixelart.internal.Atlas = null, -/// Monotonic time (`fizzy.perf.nanoTimestamp`) when the current in-memory atlas was last installed. +/// Monotonic time (`pixelart.perf.nanoTimestamp`) when the current in-memory atlas was last installed. last_packed_at_ns: ?i128 = null, ldtk: bool = false, @@ -61,8 +62,8 @@ pub fn init(allocator: std.mem.Allocator) !Packer { return .{ .sprites = std.array_list.Managed(Sprite).init(allocator), .frames = std.array_list.Managed(zstbi.Rect).init(allocator), - .animations = std.array_list.Managed(fizzy.Animation).init(allocator), - .open_files = std.array_list.Managed(fizzy.Internal.File).init(allocator), + .animations = std.array_list.Managed(pixelart.Animation).init(allocator), + .open_files = std.array_list.Managed(pixelart.internal.File).init(allocator), .placeholder = .{ .width = 2, .height = 2, .pixels = pixels }, .ldtk_tilesets = std.array_list.Managed(LDTKTileset).init(allocator), }; @@ -75,7 +76,7 @@ pub fn newId(self: *Packer) u32 { } pub fn deinit(self: *Packer) void { - fizzy.app.allocator.free(self.placeholder.pixels); + Globals.allocator().free(self.placeholder.pixels); self.clearAndFree(); self.sprites.deinit(); self.frames.deinit(); @@ -85,17 +86,17 @@ pub fn deinit(self: *Packer) void { pub fn clearAndFree(self: *Packer) void { for (self.sprites.items) |*sprite| { - sprite.deinit(fizzy.app.allocator); + sprite.deinit(Globals.allocator()); } for (self.animations.items) |*animation| { - fizzy.app.allocator.free(animation.name); + Globals.allocator().free(animation.name); } for (self.ldtk_tilesets.items) |*tileset| { for (tileset.layer_paths) |path| { - fizzy.app.allocator.free(path); + Globals.allocator().free(path); } - fizzy.app.allocator.free(tileset.sprites); - fizzy.app.allocator.free(tileset.layer_paths); + Globals.allocator().free(tileset.sprites); + Globals.allocator().free(tileset.layer_paths); } self.frames.clearAndFree(); self.sprites.clearAndFree(); @@ -109,9 +110,9 @@ pub fn clearAndFree(self: *Packer) void { self.open_files.clearAndFree(); } -pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { +pub fn append(self: *Packer, file: *pixelart.internal.File) !void { std.log.info("Appending file with sprites: {d}", .{file.sprites.slice().len}); - var layer_opt: ?fizzy.Internal.Layer = null; + var layer_opt: ?pixelart.Layer = null; var index: usize = 0; while (index < file.layers.slice().len) : (index += 1) { var layer = file.layers.get(index); @@ -121,7 +122,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { // If this layer is collapsed, we need to record its texture to survive the next loop if ((layer.collapse and !last_item) or ((index != 0 and file.layers.slice().get(index - 1).collapse))) { - const current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else try fizzy.Internal.Layer.init( + const current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else try pixelart.Layer.init( 0, "", file.width(), @@ -176,7 +177,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { var image: Image = .{ .width = reduced_src_width, .height = reduced_src_height, - .pixels = try fizzy.app.allocator.alloc([4]u8, reduced_src_width * reduced_src_height), + .pixels = try Globals.allocator().alloc([4]u8, reduced_src_width * reduced_src_height), }; @memset(image.pixels, .{ 0, 0, 0, 0 }); @@ -204,13 +205,13 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { for (0..file.animations.len) |animation_index| { const animation = file.animations.get(animation_index); if (animation.frames[0].sprite_index == sprite_index) { - const frames = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, animation.frames.len); + const frames = try Globals.allocator().alloc(pixelart.Animation.Frame, animation.frames.len); for (frames, animation.frames, 0..) |*current_frame, file_anim_frame, i| { current_frame.sprite_index = new_sprite_index + i; current_frame.ms = file_anim_frame.ms; } try self.animations.append(.{ - .name = try std.fmt.allocPrint(fizzy.app.allocator, "{s}_{s}", .{ animation.name, layer.name }), + .name = try std.fmt.allocPrint(Globals.allocator(), "{s}_{s}", .{ animation.name, layer.name }), .frames = frames, }); } @@ -249,7 +250,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { } pub fn appendProject(packer: *Packer) !void { - if (fizzy.pixelart.host.folder()) |root_directory| { + if (Globals.state.host.folder()) |root_directory| { try recurseFiles(packer, root_directory); } } @@ -265,22 +266,22 @@ pub fn recurseFiles(packer: *Packer, root_directory: []const u8) !void { while (try iter.next(io)) |entry| { if (entry.kind == .file) { const ext = std.fs.path.extension(entry.name); - if (fizzy.Internal.File.isFizzyExtension(ext)) { - const abs_path = try std.fs.path.joinZ(fizzy.app.allocator, &.{ directory, entry.name }); - defer fizzy.app.allocator.free(abs_path); + if (pixelart.internal.File.isFizzyExtension(ext)) { + const abs_path = try std.fs.path.joinZ(Globals.allocator(), &.{ directory, entry.name }); + defer Globals.allocator().free(abs_path); - if (fizzy.editor.getFileFromPath(abs_path)) |file| { + if (Globals.state.docs.fileFromPath(abs_path)) |file| { try p.append(file); } else { - if (try fizzy.Internal.File.fromPath(abs_path)) |file| { + if (try pixelart.internal.File.fromPath(abs_path)) |file| { try p.open_files.append(file); try p.append(&p.open_files.items[p.open_files.items.len - 1]); } } } } else if (entry.kind == .directory) { - const abs_path = try std.fs.path.joinZ(fizzy.app.allocator, &[_][]const u8{ directory, entry.name }); - defer fizzy.app.allocator.free(abs_path); + const abs_path = try std.fs.path.joinZ(Globals.allocator(), &[_][]const u8{ directory, entry.name }); + defer Globals.allocator().free(abs_path); try search(p, abs_path); } } @@ -295,7 +296,7 @@ pub fn recurseFiles(packer: *Packer, root_directory: []const u8) !void { pub fn packAndClear(packer: *Packer) !void { if (try packer.packRects()) |size| { //var atlas_texture = try fizzy.gfx.Texture.createEmpty(size[0], size[1], .{}); - var atlas_layer = try fizzy.Internal.Layer.init( + var atlas_layer = try pixelart.Layer.init( 0, "", size[0], @@ -318,9 +319,9 @@ pub fn packAndClear(packer: *Packer) !void { } atlas_layer.invalidate(); - const atlas: fizzy.Atlas = .{ - .sprites = try fizzy.app.allocator.alloc(fizzy.Atlas.Sprite, packer.sprites.items.len), - .animations = try fizzy.app.allocator.alloc(fizzy.Animation, packer.animations.items.len), + const atlas: pixelart.Atlas = .{ + .sprites = try Globals.allocator().alloc(pixelart.Atlas.Sprite, packer.sprites.items.len), + .animations = try Globals.allocator().alloc(pixelart.Animation, packer.animations.items.len), }; for (atlas.sprites, packer.sprites.items, packer.frames.items) |*dst, src, src_rect| { @@ -329,8 +330,8 @@ pub fn packAndClear(packer: *Packer) !void { } for (atlas.animations, packer.animations.items) |*dst, src| { - dst.name = try fizzy.app.allocator.dupe(u8, src.name); - dst.frames = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, src.frames); + dst.name = try Globals.allocator().dupe(u8, src.name); + dst.frames = try Globals.allocator().dupe(pixelart.Animation.Frame, src.frames); //dst.length = src.length; // dst.start = src.start; } @@ -338,12 +339,12 @@ pub fn packAndClear(packer: *Packer) !void { if (packer.atlas) |*current_atlas| { current_atlas.deinitCheckerboardTile(); for (current_atlas.data.animations) |*animation| { - fizzy.app.allocator.free(animation.name); + Globals.allocator().free(animation.name); } - fizzy.app.allocator.free(current_atlas.data.sprites); - fizzy.app.allocator.free(current_atlas.data.animations); + Globals.allocator().free(current_atlas.data.sprites); + Globals.allocator().free(current_atlas.data.animations); - fizzy.app.allocator.free(fizzy.image.bytes(current_atlas.source)); + Globals.allocator().free(pixelart.image.bytes(current_atlas.source)); current_atlas.data = atlas; current_atlas.source = atlas_layer.source; @@ -356,7 +357,7 @@ pub fn packAndClear(packer: *Packer) !void { packer.atlas.?.initCheckerboardTile(); } - packer.last_packed_at_ns = fizzy.perf.nanoTimestamp(); + packer.last_packed_at_ns = pixelart.perf.nanoTimestamp(); packer.clearAndFree(); } } diff --git a/src/plugins/pixelart/Project.zig b/src/plugins/pixelart/src/Project.zig similarity index 82% rename from src/plugins/pixelart/Project.zig rename to src/plugins/pixelart/src/Project.zig index 7d85d568..767dc0eb 100644 --- a/src/plugins/pixelart/Project.zig +++ b/src/plugins/pixelart/src/Project.zig @@ -1,7 +1,8 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; const Project = @This(); @@ -22,10 +23,10 @@ pack_on_save: bool = false, pub fn load(allocator: std.mem.Allocator) !?Project { if (comptime builtin.target.cpu.arch == .wasm32) return null; - if (fizzy.pixelart.host.folder()) |folder| { - const file = try std.fs.path.join(fizzy.pixelart.host.arena(), &.{ folder, ".fizproject" }); + if (Globals.state.host.folder()) |folder| { + const file = try std.fs.path.join(Globals.state.host.arena(), &.{ folder, ".fizproject" }); - if (fizzy.fs.read(allocator, dvui.io, file) catch null) |r| { + if (pixelart.fs.read(allocator, dvui.io, file) catch null) |r| { read = r; const options = std.json.ParseOptions{ .duplicate_field_behavior = .use_first, .ignore_unknown_fields = true }; @@ -60,11 +61,12 @@ pub fn load(allocator: std.mem.Allocator) !?Project { pub fn save(project: *Project) !void { if (comptime builtin.target.cpu.arch == .wasm32) return; - if (fizzy.pixelart.host.folder()) |folder| { - const file = try std.fs.path.join(fizzy.pixelart.host.arena(), &.{ folder, ".fizproject" }); + if (Globals.state.host.folder()) |folder| { + const file = try std.fs.path.join(Globals.allocator(), &.{ folder, ".fizproject" }); + defer Globals.allocator().free(file); const options = std.json.Stringify.Options{}; - const str = try std.json.Stringify.valueAlloc(fizzy.app.allocator, Project{ + const str = try std.json.Stringify.valueAlloc(Globals.allocator(), Project{ .packed_atlas_output = project.packed_atlas_output, .packed_image_output = project.packed_image_output, //.packed_heightmap_output = project.packed_heightmap_output, @@ -81,7 +83,7 @@ pub fn save(project: *Project) !void { /// Project output assets will be exported to a join of parent_folder and the individual output paths for each asset pub fn exportAssets(project: *Project) !void { - const atlas = fizzy.packer.atlas orelse return; + const atlas = Globals.packer.atlas orelse return; if (project.packed_atlas_output) |packed_atlas_output| { try atlas.save(packed_atlas_output, .data); @@ -92,7 +94,7 @@ pub fn exportAssets(project: *Project) !void { } // if (project.packed_heightmap_output) |packed_heightmap_output| { - // const path = try std.fs.path.joinZ(fizzy.pixelart.host.arena(), &.{ parent_folder, packed_heightmap_output }); + // const path = try std.fs.path.joinZ(Globals.state.host.arena(), &.{ parent_folder, packed_heightmap_output }); // try atlas.save(path, .heightmap); // } } diff --git a/src/plugins/pixelart/Settings.zig b/src/plugins/pixelart/src/Settings.zig similarity index 95% rename from src/plugins/pixelart/Settings.zig rename to src/plugins/pixelart/src/Settings.zig index 260b4eb7..59f90919 100644 --- a/src/plugins/pixelart/Settings.zig +++ b/src/plugins/pixelart/src/Settings.zig @@ -3,10 +3,10 @@ //! settings store (the `Host`), keyed by the plugin id, as an opaque JSON blob the shell //! never interprets. const std = @import("std"); -const builtin = @import("builtin"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); -const sdk = fizzy.sdk; +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const sdk = pixelart.sdk; const PixelArtSettings = @This(); @@ -58,12 +58,11 @@ checker_color_odd: [4]u8 = .{ 175, 175, 175, 255 }, /// Checkerboard / transparency tint behind sprites (grid cells). transparency_effect: TransparencyEffect = .none, -pub fn resolvedPanZoomScheme(settings: *const PixelArtSettings) ResolvedPanZoomScheme { +pub fn resolvedPanZoomScheme(settings: *const PixelArtSettings, host: *sdk.Host) ResolvedPanZoomScheme { return switch (settings.input_scheme) { .auto => switch (dvui.mouseType()) { - // Runtime platform detection so macOS web users get the trackpad default - // (`builtin.os.tag == .macos` is false on wasm32-freestanding). - .unknown => if (fizzy.platform.isMacOS()) .trackpad else .mouse, + // Runtime platform detection so macOS web users get the trackpad default. + .unknown => if (host.isMacOS()) .trackpad else .mouse, .mouse => .mouse, .trackpad => .trackpad, }, @@ -96,7 +95,7 @@ pub fn save(settings: *const PixelArtSettings, host: *sdk.Host) void { /// The plugin's Settings section body (registered as a `SettingsSection`). Renders the /// canvas / control prefs and persists on change. pub fn draw(_: ?*anyopaque) !void { - const pa = fizzy.pixelart; + const pa = Globals.state; var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); defer vbox.deinit(); diff --git a/src/plugins/pixelart/Sprite.zig b/src/plugins/pixelart/src/Sprite.zig similarity index 100% rename from src/plugins/pixelart/Sprite.zig rename to src/plugins/pixelart/src/Sprite.zig diff --git a/src/plugins/pixelart/PixelArt.zig b/src/plugins/pixelart/src/State.zig similarity index 61% rename from src/plugins/pixelart/PixelArt.zig rename to src/plugins/pixelart/src/State.zig index fec50fa0..e89361c0 100644 --- a/src/plugins/pixelart/PixelArt.zig +++ b/src/plugins/pixelart/src/State.zig @@ -1,29 +1,28 @@ -//! Pixel-art plugin state, lifted off the shell `Editor` (Phase 4 Stage B). +//! Pixel-art plugin runtime state (Phase 4 Stage B/D). //! //! Owns the pixel-art-specific editor state that used to live as top-level fields //! on `src/editor/Editor.zig`: the active tools, color/palette state, the open //! project's pack config, the sprite clipboard, and the background pack-job queue. //! -//! Accessed during Stages B–C through the `fizzy.pixelart` global (mirroring the -//! existing `fizzy.packer`). Stage D repoints plugin code at the SDK instead, at -//! which point this struct becomes the plugin's `state` proper rather than a -//! shell-reachable global. +//! Each plugin has a `State.zig` holding its live state. The shell still reaches +//! this through `fizzy.pixelart` during migration; plugin code uses `Globals.state`. const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const assets = @import("assets"); - -const sdk = fizzy.sdk; +const sdk = @import("sdk"); const Colors = @import("Colors.zig"); const Project = @import("Project.zig"); const Tools = @import("Tools.zig"); const PackJob = @import("PackJob.zig"); const ToolsPane = @import("explorer/tools.zig"); const SpritesPane = @import("explorer/sprites.zig"); +const SpritesPanel = @import("panel/sprites.zig"); +const Palette = @import("internal/Palette.zig"); pub const Settings = @import("Settings.zig"); +pub const Docs = @import("Docs.zig"); -const PixelArt = @This(); +const State = @This(); /// A floating sprite cut/copied from the canvas, pasted relative to `offset`. pub const SpriteClipboard = struct { @@ -34,6 +33,9 @@ pub const SpriteClipboard = struct { /// The shell host (service locator + per-plugin settings store). Set in `init`. host: *sdk.Host, +/// Open pixel-art documents (shell `open_files` holds matching `DocHandle`s). +docs: Docs = .{}, + /// Pixel-art editing preferences, loaded from the host's per-plugin settings store. settings: Settings = .{}, @@ -46,6 +48,9 @@ colors: Colors = .{}, tools_pane: ToolsPane = .{}, sprites_pane: SpritesPane = .{}, +/// Sprites cover-flow bottom panel (scroll/fly state; was `editor.panel.sprites`). +sprites_panel: SpritesPanel = .{}, + /// Whether the palette pane is pinned open in the tools sidebar (pixel-art UI state). pinned_palettes: bool = false, /// Split ratio between the layers list and the palette in the tools sidebar. @@ -63,39 +68,43 @@ sprite_clipboard: ?SpriteClipboard = null, /// most recent request produces a visible atlas update. pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty, -pub fn init(allocator: std.mem.Allocator, host: *sdk.Host) !PixelArt { - var pa: PixelArt = .{ +pub fn init(allocator: std.mem.Allocator, host: *sdk.Host) !State { + var st: State = .{ .host = host, .settings = Settings.load(host), .tools = try .init(allocator), }; - pa.colors.file_tree_palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; - pa.colors.palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; - return pa; + st.colors.file_tree_palette = Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; + st.colors.palette = Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; + return st; +} + +/// Write `.fizproject` while the shell `host` and project folder are still live. +/// Called from `AppDeinit` before `editor.deinit`. +pub fn persistProject(st: *State) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + if (st.project) |*project| { + project.save() catch { + dvui.log.err("Failed to save project file", .{}); + }; + } } -pub fn deinit(pa: *PixelArt, allocator: std.mem.Allocator) void { - for (pa.pack_jobs.items) |job| { +pub fn deinit(st: *State, allocator: std.mem.Allocator) void { + for (st.pack_jobs.items) |job| { // Detached workers still reference each job. Signal cancellation and leak the structs // on hard quit — better than a use-after-free if a worker hasn't yet observed it. job.cancelled.store(true, .monotonic); } - pa.pack_jobs.deinit(allocator); - - if (pa.colors.palette) |*palette| palette.deinit(); - if (pa.colors.file_tree_palette) |*palette| palette.deinit(); - - if (pa.project) |*project| { - // Wasm: skip project.save() — it walks std.Io.Dir.cwd() which pulls in - // posix.AT (unavailable on freestanding). Browser tabs have no - // persistent on-disk project anyway. - if (comptime builtin.target.cpu.arch != .wasm32) { - project.save() catch { - dvui.log.err("Failed to save project file", .{}); - }; - } + st.pack_jobs.deinit(allocator); + + if (st.colors.palette) |*palette| palette.deinit(); + if (st.colors.file_tree_palette) |*palette| palette.deinit(); + + if (st.project) |*project| { project.deinit(allocator); } - pa.tools.deinit(allocator); + st.tools.deinit(allocator); + st.docs.deinit(allocator); } diff --git a/src/plugins/pixelart/Tools.zig b/src/plugins/pixelart/src/Tools.zig similarity index 93% rename from src/plugins/pixelart/Tools.zig rename to src/plugins/pixelart/src/Tools.zig index 2dc496b1..9f8eb276 100644 --- a/src/plugins/pixelart/Tools.zig +++ b/src/plugins/pixelart/src/Tools.zig @@ -1,6 +1,7 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; const Tools = @This(); @@ -162,7 +163,7 @@ pub fn set(self: *Tools, tool: Tool) void { self.current = tool; self.setStrokeSize(self.strokeSizeFor(tool)); if (tool == .pencil or tool == .eraser) { - fizzy.editor.requestCompositeWarmup(); + Globals.state.host.requestCompositeWarmup(); } } } @@ -194,8 +195,8 @@ pub fn getIndex(_: *Tools, point: dvui.Point) ?usize { /// Only used for handling getting the pixels surrounding the origin /// for stroke sizes larger than 1 pub fn getIndexShapeOffset(self: *Tools, origin: dvui.Point, current_index: usize) ?usize { - const shape = fizzy.pixelart.tools.stroke_shape; - const s: i32 = @intCast(fizzy.pixelart.tools.stroke_size); + const shape = self.stroke_shape; + const s: i32 = @intCast(self.stroke_size); if (s == 1) { if (current_index != 0) @@ -298,7 +299,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 })); defer vbox2.deinit(); - fizzy.dvui.labelWithKeybind( + pixelart.core.dvui.labelWithKeybind( tool_name, switch (tool) { .pointer => dvui.currentWindow().keybinds.get("pointer") orelse .{}, @@ -334,10 +335,10 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 }); defer mode_row.deinit(); - const atlas_size: dvui.Size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; + const atlas_size: dvui.Size = dvui.imageSize(Globals.state.host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; var mode_color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { + if (Globals.state.colors.file_tree_palette) |*palette| { mode_color = palette.getDVUIColor(4); } @@ -367,7 +368,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 2 => "COLOR", else => unreachable, }; - const selected = fizzy.pixelart.tools.selection_mode == mode; + const selected = Globals.state.tools.selection_mode == mode; var mode_col = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .none, @@ -377,9 +378,9 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 defer mode_col.deinit(); const sprite = switch (mode) { - .box => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default], - .pixel => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default], - .color => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default], + .box => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_default], + .pixel => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_default], + .color => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_default], }; const uv = dvui.Rect{ .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w, @@ -430,7 +431,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 rs.r.w = width; rs.r.h = height; - dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{ + dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{ .uv = uv, .fade = 0.0, }) catch { @@ -438,7 +439,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 }; if (mode_button.clicked()) { - fizzy.pixelart.tools.selection_mode = mode; + Globals.state.tools.selection_mode = mode; } } } diff --git a/src/plugins/pixelart/Transform.zig b/src/plugins/pixelart/src/Transform.zig similarity index 85% rename from src/plugins/pixelart/Transform.zig rename to src/plugins/pixelart/src/Transform.zig index a4f44975..edbddffb 100644 --- a/src/plugins/pixelart/Transform.zig +++ b/src/plugins/pixelart/src/Transform.zig @@ -1,6 +1,7 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; pub const Transform = @This(); @@ -34,24 +35,24 @@ pub fn point(self: *Transform, transform_point: TransformPoint) *dvui.Point { /// Note: `textureReadTarget` reads the full render target; the dominant cost is often GPU→CPU /// bandwidth rather than the merge loops below. pub fn accept(self: *Transform) void { - if (fizzy.editor.open_files.getPtr(self.file_id)) |file| { + if (Globals.state.docs.fileById(self.file_id)) |file| { var layer = file.getLayer(self.layer_id) orelse return; - const t_all: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_all: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; const layer_px: u64 = @as(u64, file.width()) * @as(u64, file.height()); const pix = dvui.textureReadTarget(dvui.currentWindow().arena(), self.target_texture) catch { dvui.log.err("Failed to read target texture", .{}); return; }; - const t_after_gpu: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_after_gpu: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; file.buffers.stroke.clearAndReserveCapacity(@intCast(layer_px)) catch { dvui.log.err("Failed to reserve stroke map for transform accept", .{}); return; }; - const t_loop: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_loop: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; // Two passes: undo keys use the pre-write layer; writes are independent per index, so order // matches the original interleaved loop without mutating layer between undo decisions. for (pix, file.editor.transform_layer.pixels(), layer.pixels(), 0..) |temp_pixel, transform_pixel, layer_pixel, pixel_index| { @@ -70,7 +71,7 @@ pub fn accept(self: *Transform) void { // Paste / transform accept writes new pixels but does not go through `processSelection`; the // overlay uses `selection_layer.mask ∩ active_layer.mask`. Keep the mask aligned with the // committed transform so copied/pasted (and moved) pixels show the selection outline. - if (fizzy.pixelart.tools.current == .selection) { + if (Globals.state.tools.current == .selection) { file.editor.selection_layer.clearMask(); for (pix, 0..) |temp_pixel, pixel_index| { if (temp_pixel.a != 0) { @@ -79,28 +80,28 @@ pub fn accept(self: *Transform) void { } } - const t_after_loop: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_after_loop: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; - const t_to_change: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_to_change: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; const change = file.buffers.stroke.toChange(self.layer_id) catch null; - const t_after_to_change: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_after_to_change: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; - const t_hist: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_hist: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; if (change) |c| { file.history.append(c) catch { dvui.log.err("Failed to append stroke change to history", .{}); }; } - const t_end: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; - - if (fizzy.perf.record) { - fizzy.perf.transform_accept_last_total_ns = @intCast(t_end - t_all); - fizzy.perf.transform_accept_last_gpu_read_ns = @intCast(t_after_gpu - t_all); - fizzy.perf.transform_accept_last_merge_loop_ns = @intCast(t_after_loop - t_loop); - fizzy.perf.transform_accept_last_to_change_ns = @intCast(t_after_to_change - t_to_change); - fizzy.perf.transform_accept_last_history_append_ns = @intCast(t_end - t_hist); - fizzy.perf.transform_accept_last_layer_pixels = layer_px; - fizzy.perf.logTransformAcceptIf(); + const t_end: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; + + if (pixelart.perf.record) { + pixelart.perf.transform_accept_last_total_ns = @intCast(t_end - t_all); + pixelart.perf.transform_accept_last_gpu_read_ns = @intCast(t_after_gpu - t_all); + pixelart.perf.transform_accept_last_merge_loop_ns = @intCast(t_after_loop - t_loop); + pixelart.perf.transform_accept_last_to_change_ns = @intCast(t_after_to_change - t_to_change); + pixelart.perf.transform_accept_last_history_append_ns = @intCast(t_end - t_hist); + pixelart.perf.transform_accept_last_layer_pixels = layer_px; + pixelart.perf.logTransformAcceptIf(); } layer.invalidate(); @@ -109,14 +110,14 @@ pub fn accept(self: *Transform) void { file.editor.transform_layer.clearMask(); file.editor.transform_layer.invalidate(); file.editor.transform = null; - fizzy.app.allocator.free(fizzy.image.bytes(self.source)); + Globals.allocator().free(pixelart.image.bytes(self.source)); self.* = undefined; } } /// Cancels the transform and restores the layer to its original state pub fn cancel(self: *Transform) void { - if (fizzy.editor.open_files.getPtr(self.file_id)) |file| { + if (Globals.state.docs.fileById(self.file_id)) |file| { var layer = file.getLayer(self.layer_id) orelse return; var iterator = file.editor.transform_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); while (iterator.next()) |pixel_index| { @@ -129,7 +130,7 @@ pub fn cancel(self: *Transform) void { file.editor.transform_layer.clearMask(); file.editor.transform_layer.invalidate(); file.editor.transform = null; - fizzy.app.allocator.free(fizzy.image.bytes(self.source)); + Globals.allocator().free(pixelart.image.bytes(self.source)); self.* = undefined; } } diff --git a/src/plugins/pixelart/algorithms/algorithms.zig b/src/plugins/pixelart/src/algorithms/algorithms.zig similarity index 100% rename from src/plugins/pixelart/algorithms/algorithms.zig rename to src/plugins/pixelart/src/algorithms/algorithms.zig diff --git a/src/plugins/pixelart/algorithms/brezenham.zig b/src/plugins/pixelart/src/algorithms/brezenham.zig similarity index 86% rename from src/plugins/pixelart/algorithms/brezenham.zig rename to src/plugins/pixelart/src/algorithms/brezenham.zig index 4d114cc8..2e7f40b1 100644 --- a/src/plugins/pixelart/algorithms/brezenham.zig +++ b/src/plugins/pixelart/src/algorithms/brezenham.zig @@ -1,10 +1,11 @@ const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; pub fn process(start: dvui.Point, end: dvui.Point) ![]dvui.Point { // Bresenham's line algorithm for integer grid points - var output = std.array_list.Managed(dvui.Point).init(fizzy.pixelart.host.arena()); + var output = std.array_list.Managed(dvui.Point).init(Globals.state.host.arena()); // Round input points to nearest integer grid const x0: i32 = @intFromFloat(@floor(start.x)); diff --git a/src/plugins/pixelart/algorithms/reduce.zig b/src/plugins/pixelart/src/algorithms/reduce.zig similarity index 100% rename from src/plugins/pixelart/algorithms/reduce.zig rename to src/plugins/pixelart/src/algorithms/reduce.zig diff --git a/src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c b/src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c similarity index 100% rename from src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c rename to src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c diff --git a/src/plugins/pixelart/deps/msf_gif/msf_gif.c b/src/plugins/pixelart/src/deps/msf_gif/msf_gif.c similarity index 100% rename from src/plugins/pixelart/deps/msf_gif/msf_gif.c rename to src/plugins/pixelart/src/deps/msf_gif/msf_gif.c diff --git a/src/plugins/pixelart/deps/msf_gif/msf_gif.h b/src/plugins/pixelart/src/deps/msf_gif/msf_gif.h similarity index 100% rename from src/plugins/pixelart/deps/msf_gif/msf_gif.h rename to src/plugins/pixelart/src/deps/msf_gif/msf_gif.h diff --git a/src/plugins/pixelart/deps/msf_gif/msf_gif.zig b/src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig similarity index 100% rename from src/plugins/pixelart/deps/msf_gif/msf_gif.zig rename to src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig diff --git a/src/plugins/pixelart/deps/msf_gif/wasm_shim/string.h b/src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h similarity index 100% rename from src/plugins/pixelart/deps/msf_gif/wasm_shim/string.h rename to src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h diff --git a/src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c b/src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c similarity index 100% rename from src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c rename to src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c diff --git a/src/plugins/pixelart/deps/stbi/stb_image_resize2.h b/src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h similarity index 100% rename from src/plugins/pixelart/deps/stbi/stb_image_resize2.h rename to src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h diff --git a/src/plugins/pixelart/deps/stbi/stb_rect_pack.h b/src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h similarity index 100% rename from src/plugins/pixelart/deps/stbi/stb_rect_pack.h rename to src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h diff --git a/src/plugins/pixelart/deps/stbi/zstbi.c b/src/plugins/pixelart/src/deps/stbi/zstbi.c similarity index 100% rename from src/plugins/pixelart/deps/stbi/zstbi.c rename to src/plugins/pixelart/src/deps/stbi/zstbi.c diff --git a/src/plugins/pixelart/deps/stbi/zstbi.zig b/src/plugins/pixelart/src/deps/stbi/zstbi.zig similarity index 100% rename from src/plugins/pixelart/deps/stbi/zstbi.zig rename to src/plugins/pixelart/src/deps/stbi/zstbi.zig diff --git a/src/plugins/pixelart/deps/zip/build.zig b/src/plugins/pixelart/src/deps/zip/build.zig similarity index 100% rename from src/plugins/pixelart/deps/zip/build.zig rename to src/plugins/pixelart/src/deps/zip/build.zig diff --git a/src/plugins/pixelart/deps/zip/fizzy_zip_libc.c b/src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c similarity index 100% rename from src/plugins/pixelart/deps/zip/fizzy_zip_libc.c rename to src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c diff --git a/src/plugins/pixelart/deps/zip/fizzy_zip_strings.c b/src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c similarity index 100% rename from src/plugins/pixelart/deps/zip/fizzy_zip_strings.c rename to src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c diff --git a/src/plugins/pixelart/deps/zip/fizzy_zip_wasm.h b/src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h similarity index 100% rename from src/plugins/pixelart/deps/zip/fizzy_zip_wasm.h rename to src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h diff --git a/src/plugins/pixelart/deps/zip/src/miniz.h b/src/plugins/pixelart/src/deps/zip/src/miniz.h similarity index 100% rename from src/plugins/pixelart/deps/zip/src/miniz.h rename to src/plugins/pixelart/src/deps/zip/src/miniz.h diff --git a/src/plugins/pixelart/deps/zip/src/zip.c b/src/plugins/pixelart/src/deps/zip/src/zip.c similarity index 100% rename from src/plugins/pixelart/deps/zip/src/zip.c rename to src/plugins/pixelart/src/deps/zip/src/zip.c diff --git a/src/plugins/pixelart/deps/zip/src/zip.h b/src/plugins/pixelart/src/deps/zip/src/zip.h similarity index 100% rename from src/plugins/pixelart/deps/zip/src/zip.h rename to src/plugins/pixelart/src/deps/zip/src/zip.h diff --git a/src/plugins/pixelart/deps/zip/zip.zig b/src/plugins/pixelart/src/deps/zip/zip.zig similarity index 100% rename from src/plugins/pixelart/deps/zip/zip.zig rename to src/plugins/pixelart/src/deps/zip/zip.zig diff --git a/src/plugins/pixelart/dialogs/Export.zig b/src/plugins/pixelart/src/dialogs/Export.zig similarity index 85% rename from src/plugins/pixelart/dialogs/Export.zig rename to src/plugins/pixelart/src/dialogs/Export.zig index a6a1fd64..4024005f 100644 --- a/src/plugins/pixelart/dialogs/Export.zig +++ b/src/plugins/pixelart/src/dialogs/Export.zig @@ -1,16 +1,17 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const zigimg = @import("zigimg"); const msf_gif = @import("msf_gif"); const zstbi = @import("zstbi"); -const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../../../editor/WebFileIo.zig") else struct {}; +const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../../../../editor/WebFileIo.zig") else struct {}; const ExportImageFormat = enum { png, jpg }; -const Dialogs = @import("../../../editor/dialogs/Dialogs.zig"); +const Dialogs = @import("../../../../editor/dialogs/Dialogs.zig"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; pub var mode: enum(usize) { single, @@ -39,7 +40,7 @@ pub const min_scale: u32 = 1; pub var anim_frame_index: usize = 0; /// Animation to export/preview: uses the animation selected in the editor. -fn exportAnimationIndex(file: *fizzy.Internal.File) ?usize { +fn exportAnimationIndex(file: *pixelart.internal.File) ?usize { const idx = file.selected_animation_index orelse return null; if (idx >= file.animations.len) return null; return idx; @@ -49,7 +50,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { // Export stays non-modal so the user can click the canvas to adjust selections. Switch to // the pointer tool on open so marquee/sprite picks work; drawing tools stay off until close. if (dvui.firstFrame(id)) { - fizzy.pixelart.tools.set(.pointer); + Globals.state.tools.set(.pointer); } var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); @@ -145,7 +146,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { .all => try allDialog(id), }; - return mode_valid and (fizzy.editor.activeFile() != null); + return mode_valid and (Globals.state.docs.activeFile(Globals.state.host) != null); } pub fn singleDialog(_: dvui.Id) anyerror!bool { @@ -153,14 +154,14 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool { var max_scale: f32 = 16.0; var valid: bool = false; - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { if (file.editor.selected_sprites.findFirstSet() != null) { max_scale = @min(@divTrunc(max_gif_size[0], @as(f32, @floatFromInt(file.column_width))), @divTrunc(max_gif_size[1], @as(f32, @floatFromInt(file.row_height)))); valid = true; } } - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { if (file.editor.selected_sprites.findFirstSet()) |sprite_index| { renderExportPreviewSprite(file, sprite_index); } @@ -168,7 +169,7 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool { exportScaleSlider(max_scale); - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { if (file.editor.selected_sprites.findFirstSet() != null) { const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale); const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale); @@ -184,7 +185,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { var max_scale: f32 = 16.0; var preview_sprite: ?usize = null; - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { max_scale = @min( @divTrunc(max_gif_size[0], @as(f32, @floatFromInt(file.column_width))), @divTrunc(max_gif_size[1], @as(f32, @floatFromInt(file.row_height))), @@ -222,7 +223,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { } } - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { if (preview_sprite) |sprite_index| { renderExportPreviewSprite(file, sprite_index); } @@ -231,7 +232,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { exportScaleSlider(max_scale); if (preview_sprite) |_| { - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale); const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale); exportDimensionsLabelForExport(column_width, row_height); @@ -242,20 +243,20 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { } pub fn layerDialog(_: dvui.Id) anyerror!bool { - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { renderExportPreview(file, .layer); } - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { exportDimensionsLabelForExport(file.width(), file.height()); } return true; } pub fn allDialog(_: dvui.Id) anyerror!bool { - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { renderExportPreview(file, .composite); } - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { exportDimensionsLabelForExport(file.width(), file.height()); } return true; @@ -267,11 +268,11 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void switch (mode) { .animation => { const default = blk: { - const file = fizzy.editor.activeFile() orelse { + const file = Globals.state.docs.activeFile(Globals.state.host) orelse { break :blk "animation.gif"; }; - const default_filename: [:0]const u8 = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.gif", .{ + const default_filename: [:0]const u8 = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.gif", .{ if (exportAnimationIndex(file)) |animation_index| file.animations.items(.name)[animation_index] else "animation", }, 0) catch { dvui.log.err("Failed to allocate filename", .{}); @@ -281,32 +282,32 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void break :blk default_filename; }; - fizzy.pixelart.host.showSaveDialog( + Globals.state.host.showSaveDialog( saveAnimationCallback, - &[_]fizzy.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }}, + &[_]pixelart.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }}, default, null, // Passing null here means use the last save folder location ); }, .single => { - const file = fizzy.editor.activeFile() orelse return; + const file = Globals.state.docs.activeFile(Globals.state.host) orelse return; const sprite_index = file.editor.selected_sprites.findFirstSet() orelse return; - const base = file.spriteExportName(fizzy.app.allocator, sprite_index) catch { + const base = file.spriteExportName(Globals.allocator(), sprite_index) catch { dvui.log.err("Failed to allocate default export name", .{}); return; }; - defer fizzy.app.allocator.free(base); + defer Globals.allocator().free(base); - const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch { + const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch { dvui.log.err("Failed to allocate filename", .{}); return; }; - defer fizzy.app.allocator.free(default); + defer Globals.allocator().free(default); - fizzy.pixelart.host.showSaveDialog( + Globals.state.host.showSaveDialog( exportCurrentSpriteCallback, - &[_]fizzy.sdk.SaveDialogFilter{ + &[_]pixelart.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -315,22 +316,22 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void ); }, .layer => { - const file = fizzy.editor.activeFile() orelse return; - const base = file.layerExportBaseName(fizzy.app.allocator) catch { + const file = Globals.state.docs.activeFile(Globals.state.host) orelse return; + const base = file.layerExportBaseName(Globals.allocator()) catch { dvui.log.err("Failed to allocate default export name", .{}); return; }; - defer fizzy.app.allocator.free(base); + defer Globals.allocator().free(base); - const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch { + const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch { dvui.log.err("Failed to allocate filename", .{}); return; }; - defer fizzy.app.allocator.free(default); + defer Globals.allocator().free(default); - fizzy.pixelart.host.showSaveDialog( + Globals.state.host.showSaveDialog( exportLayerCallback, - &[_]fizzy.sdk.SaveDialogFilter{ + &[_]pixelart.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -339,22 +340,22 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void ); }, .all => { - const file = fizzy.editor.activeFile() orelse return; - const base = file.allExportBaseName(fizzy.app.allocator) catch { + const file = Globals.state.docs.activeFile(Globals.state.host) orelse return; + const base = file.allExportBaseName(Globals.allocator()) catch { dvui.log.err("Failed to allocate default export name", .{}); return; }; - defer fizzy.app.allocator.free(base); + defer Globals.allocator().free(base); - const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch { + const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch { dvui.log.err("Failed to allocate filename", .{}); return; }; - defer fizzy.app.allocator.free(default); + defer Globals.allocator().free(default); - fizzy.pixelart.host.showSaveDialog( + Globals.state.host.showSaveDialog( exportAllCallback, - &[_]fizzy.sdk.SaveDialogFilter{ + &[_]pixelart.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -372,7 +373,7 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void /// One call site for the export preview scroll+tile so widget ids (and first-frame layout) stay /// stable when switching between Single and Animation. Otherwise `renderLayers` early-outs for /// one frame with `content_rs.s == 0` on a fresh scroll id. -fn renderExportPreviewSprite(file: *fizzy.Internal.File, sprite_index: usize) void { +fn renderExportPreviewSprite(file: *pixelart.internal.File, sprite_index: usize) void { const sprite_rect = file.spriteRect(sprite_index); const max_size_content: dvui.Size = .{ .w = (dvui.currentWindow().rect_pixels.w / dvui.currentWindow().natural_scale) / 2, @@ -413,7 +414,7 @@ fn renderExportPreviewSprite(file: *fizzy.Internal.File, sprite_index: usize) vo const local_natural = dvui.Rect{ .x = 0, .y = 0, .w = sprite_rect.w * scale, .h = sprite_rect.h * scale }; drawCheckerboardCell(file, sprite_index, local_natural, box.data().rectScale()); - fizzy.render.renderLayers(.{ + pixelart.render.renderLayers(.{ .file = file, .rs = box.data().rectScale(), .uv = uv, @@ -497,8 +498,8 @@ fn exportCheckerboardVertexColor( return tone.lerp(c_corner, t); } -fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color { - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { +fn exportSpriteAnimationPaletteColor(file: *pixelart.internal.File, sprite_index: usize) ?dvui.Color { + if (Globals.state.colors.file_tree_palette) |*palette| { var animation_index: ?usize = null; if (file.selected_animation_index) |selected_animation_index| { @@ -530,13 +531,13 @@ fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: u } fn exportCheckerboardCellCornerColor( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, sprite_index: usize, pal: CheckerboardPalette, u: f32, v: f32, ) dvui.Color { - switch (fizzy.pixelart.settings.transparency_effect) { + switch (Globals.state.settings.transparency_effect) { .none => return pal.tone, .rainbow => return exportCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u, v, 0.5, 0.5, pal.tone), .animation => { @@ -558,7 +559,7 @@ fn exportCheckerboardCellCornerColor( fn appendCheckerboardCellQuad( builder: *dvui.Triangles.Builder, quad_idx: *usize, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, sprite_index: usize, pal: CheckerboardPalette, geometry_natural: dvui.Rect, @@ -597,7 +598,7 @@ fn appendCheckerboardCellQuad( } fn drawCheckerboardCell( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, sprite_index: usize, geometry_natural: dvui.Rect, rs_box: dvui.RectScale, @@ -619,7 +620,7 @@ fn drawCheckerboardCell( }; } -fn drawCheckerboardFileGrid(file: *fizzy.Internal.File, rs_box: dvui.RectScale) void { +fn drawCheckerboardFileGrid(file: *pixelart.internal.File, rs_box: dvui.RectScale) void { const n = file.spriteCount(); if (n == 0) return; @@ -645,13 +646,13 @@ fn drawCheckerboardFileGrid(file: *fizzy.Internal.File, rs_box: dvui.RectScale) /// Full-canvas preview at 1:1 logical pixels: checkerboard + either the selected layer only or the /// flattened composite (all visible layers). One scroll + box `call site for stable widget ids. -fn renderExportPreview(file: *fizzy.Internal.File, kind: ExportFullPreviewKind) void { +fn renderExportPreview(file: *pixelart.internal.File, kind: ExportFullPreviewKind) void { const w = file.width(); const h = file.height(); if (w == 0 or h == 0) return; if (kind == .composite) { - fizzy.render.syncLayerComposite(file) catch { + pixelart.render.syncLayerComposite(file) catch { dvui.log.err("Export preview: failed to build layer composite", .{}); return; }; @@ -689,13 +690,13 @@ fn renderExportPreview(file: *fizzy.Internal.File, kind: ExportFullPreviewKind) const full_uv = dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 }; const rs = box.data().rectScale(); - var path_tris: dvui.Path.Builder = .init(fizzy.app.allocator); + var path_tris: dvui.Path.Builder = .init(Globals.allocator()); defer path_tris.deinit(); path_tris.addRect(rs.r, .all(0)); - var tris = path_tris.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0.0 }) catch { + var tris = path_tris.build().fillConvexTriangles(Globals.allocator(), .{ .color = .white, .fade = 0.0 }) catch { return; }; - defer tris.deinit(fizzy.app.allocator); + defer tris.deinit(Globals.allocator()); tris.uvFromRectuv(rs.r, full_uv); switch (kind) { @@ -724,20 +725,20 @@ fn renderExportPreview(file: *fizzy.Internal.File, kind: ExportFullPreviewKind) fn writeImageToPath(source: dvui.ImageSource, path: []const u8, format: ExportImageFormat) !void { if (comptime builtin.target.cpu.arch == .wasm32) { - var out = std.Io.Writer.Allocating.init(fizzy.app.allocator); + var out = std.Io.Writer.Allocating.init(Globals.allocator()); errdefer out.deinit(); switch (format) { - .png => try fizzy.image.writePngToWriter(source, &out.writer, 0), - .jpg => try fizzy.image.writeJpgPpiToWriter(source, &out.writer, 0), + .png => try pixelart.image.writePngToWriter(source, &out.writer, 0), + .jpg => try pixelart.image.writeJpgPpiToWriter(source, &out.writer, 0), } const bytes = try out.toOwnedSlice(); - defer fizzy.app.allocator.free(bytes); + defer Globals.allocator().free(bytes); try WebFileIo.downloadBytes(path, bytes); return; } switch (format) { - .png => try fizzy.image.writeToPngResolution(source, path, 0), - .jpg => try fizzy.image.writeToJpgPpi(source, path, 0), + .png => try pixelart.image.writeToPngResolution(source, path, 0), + .jpg => try pixelart.image.writeToJpgPpi(source, path, 0), } } @@ -751,7 +752,7 @@ fn writeGifBytes(path: []const u8, data: []const u8) !void { /// Flatten visible layers for one sprite tile. Layer index `0` is the front (drawn last on canvas); /// higher indices sit behind. `blitData` composites its **first** buffer (upper) over the **second** (lower). -fn compositedSpritePixels(allocator: std.mem.Allocator, file: *fizzy.Internal.File, sprite_index: usize) ![][4]u8 { +fn compositedSpritePixels(allocator: std.mem.Allocator, file: *pixelart.internal.File, sprite_index: usize) ![][4]u8 { const sprite_rect = file.spriteRect(sprite_index); const w: usize = @intFromFloat(sprite_rect.w); const h: usize = @intFromFloat(sprite_rect.h); @@ -772,7 +773,7 @@ fn compositedSpritePixels(allocator: std.mem.Allocator, file: *fizzy.Internal.Fi const layer_pixels = lower.pixelsFromRect(allocator, sprite_rect) orelse continue; defer allocator.free(layer_pixels); - fizzy.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true); + pixelart.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true); } return pixels; @@ -832,7 +833,7 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = fizzy.editor.activeFile() orelse { + const file = Globals.state.docs.activeFile(Globals.state.host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -848,14 +849,14 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void { export_height = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale); } - const pixels = try compositedSpritePixels(fizzy.app.allocator, file, sprite_index); - defer fizzy.app.allocator.free(pixels); + const pixels = try compositedSpritePixels(Globals.allocator(), file, sprite_index); + defer Globals.allocator().free(pixels); if (scale != 1.0) { - const resized = fizzy.app.allocator.alloc([4]u8, export_width * export_height) catch { + const resized = Globals.allocator().alloc([4]u8, export_width * export_height) catch { return error.OutOfMemory; }; - defer fizzy.app.allocator.free(resized); + defer Globals.allocator().free(resized); if (zstbi.resize( pixels, file.column_width, @@ -894,7 +895,7 @@ pub fn exportLayerToPath(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = fizzy.editor.activeFile() orelse { + const file = Globals.state.docs.activeFile(Globals.state.host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -914,7 +915,7 @@ pub fn exportAllToPath(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = fizzy.editor.activeFile() orelse { + const file = Globals.state.docs.activeFile(Globals.state.host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -923,18 +924,18 @@ pub fn exportAllToPath(path: []const u8) anyerror!void { const h = file.height(); if (w == 0 or h == 0) return error.InvalidImageSize; - try fizzy.render.syncLayerComposite(file); + try pixelart.render.syncLayerComposite(file); const target = file.editor.layer_composite_target orelse { return error.NoLayerComposite; }; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } - var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr); + var tmp_layer: pixelart.internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr); defer tmp_layer.deinit(); const format: ExportImageFormat = if (is_png) .png else .jpg; @@ -950,7 +951,7 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = fizzy.editor.activeFile() orelse { + const file = Globals.state.docs.activeFile(Globals.state.host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -962,7 +963,7 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { const animation_index = exportAnimationIndex(file) orelse return error.NoSelectedAnimation; { - const anim: fizzy.Internal.Animation = file.animations.get(animation_index); + const anim: pixelart.internal.Animation = file.animations.get(animation_index); var export_width = file.column_width; var export_height = file.row_height; @@ -981,11 +982,11 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { msf_gif.msf_gif_alpha_threshold = 240; for (anim.frames) |frame| { - const pixels = compositedSpritePixels(fizzy.app.allocator, file, frame.sprite_index) catch |err| { + const pixels = compositedSpritePixels(Globals.allocator(), file, frame.sprite_index) catch |err| { if (err == error.NoPixels) continue; return err; }; - defer fizzy.app.allocator.free(pixels); + defer Globals.allocator().free(pixels); { // msf_gif will error if there are only transparent pixels const valid = blk: { @@ -1005,11 +1006,11 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { } if (scale != 1.0) { - const resized_pixels = fizzy.app.allocator.alloc([4]u8, export_width * export_height) catch { + const resized_pixels = Globals.allocator().alloc([4]u8, export_width * export_height) catch { dvui.log.err("Failed to allocate resized pixels", .{}); continue; }; - defer fizzy.app.allocator.free(resized_pixels); + defer Globals.allocator().free(resized_pixels); _ = zstbi.resize( pixels, diff --git a/src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig similarity index 79% rename from src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig rename to src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig index fd38b7b9..d301db5b 100644 --- a/src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig +++ b/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig @@ -1,6 +1,7 @@ const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; /// When `pending_mode == .save_and_close`, resume `Editor.advanceSaveAllQuit` after flat save. pub var pending_from_save_all_quit: bool = false; @@ -17,7 +18,7 @@ pub fn request(file_id: u64, mode: Mode) void { if (mode == .editor_save) { pending_from_save_all_quit = false; } - var mutex = fizzy.dvui.dialog(@src(), .{ + var mutex = pixelart.core.dvui.dialog(@src(), .{ .displayFn = dialog, .callafterFn = callAfter, .title = "Save as .fiz or current extension?", @@ -33,8 +34,8 @@ pub fn request(file_id: u64, mode: Mode) void { mutex.mutex.unlock(dvui.io); } -fn fileRef(file_id: u64) ?*fizzy.Internal.File { - return fizzy.editor.open_files.getPtr(file_id); +fn fileRef(file_id: u64) ?*pixelart.internal.File { + return Globals.state.docs.fileById(file_id); } fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool { @@ -113,24 +114,24 @@ pub fn dialog(id: dvui.Id) anyerror!bool { } fn onChooseFizzy(file_id: u64) !void { - const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; - fizzy.editor.setActiveFile(idx); + const idx = Globals.state.host.docIndex(file_id) orelse return; + Globals.state.host.setActiveDocIndex(idx); if (pending_mode == .save_and_close) { - fizzy.editor.pending_close_file_id = file_id; + Globals.state.host.setPendingCloseDocId(file_id); } - fizzy.dvui.closeFloatingDialogAnchored(); - fizzy.editor.requestSaveAs(); + pixelart.core.dvui.closeFloatingDialogAnchored(); + Globals.state.host.requestSaveAs(); } fn onChooseFlatRaster(file_id: u64) !void { const f = fileRef(file_id) orelse return; switch (pending_mode) { .editor_save => { - fizzy.dvui.closeFloatingDialogAnchored(); + pixelart.core.dvui.closeFloatingDialogAnchored(); if (comptime @import("builtin").target.cpu.arch == .wasm32) { - const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; - fizzy.editor.setActiveFile(idx); - fizzy.editor.requestWebSaveDialog(.save); + const idx = Globals.state.host.docIndex(file_id) orelse return; + Globals.state.host.setActiveDocIndex(idx); + Globals.state.host.requestWebSave(.save); } else { try f.saveAsync(); } @@ -143,32 +144,32 @@ fn onChooseFlatRaster(file_id: u64) !void { // otherwise this is a single-doc save-and-close. f.saveAsync() catch |err| { dvui.log.err("Save failed: {s}", .{@errorName(err)}); - if (pending_from_save_all_quit) fizzy.editor.abortSaveAllQuit(); + if (pending_from_save_all_quit) Globals.state.host.abortSaveAllQuit(); return; }; if (pending_from_save_all_quit) { - fizzy.editor.quit_saves_in_flight.put(fizzy.app.allocator, file_id, {}) catch |err| { + Globals.state.host.trackQuitSaveInFlight(file_id) catch |err| { dvui.log.err("Save all quit track: {s}", .{@errorName(err)}); - fizzy.editor.abortSaveAllQuit(); + Globals.state.host.abortSaveAllQuit(); return; }; - fizzy.editor.pending_quit_continue = true; + Globals.state.host.resumeSaveAllQuit(); } else { - try fizzy.editor.pending_close_after_save.put(fizzy.app.allocator, file_id, {}); + try Globals.state.host.queueCloseAfterSave(file_id); } - fizzy.dvui.closeFloatingDialogAnchored(); + pixelart.core.dvui.closeFloatingDialogAnchored(); }, } } fn onCancel() void { - fizzy.editor.cancelPendingSaveDialog(); - fizzy.dvui.closeFloatingDialogAnchored(); + Globals.state.host.cancelPendingSaveDialog(); + pixelart.core.dvui.closeFloatingDialogAnchored(); } pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) !void { switch (response) { - .cancel => fizzy.editor.cancelPendingSaveDialog(), + .cancel => Globals.state.host.cancelPendingSaveDialog(), else => {}, } } diff --git a/src/plugins/pixelart/dialogs/GridLayout.zig b/src/plugins/pixelart/src/dialogs/GridLayout.zig similarity index 93% rename from src/plugins/pixelart/dialogs/GridLayout.zig rename to src/plugins/pixelart/src/dialogs/GridLayout.zig index d047845f..9f021824 100644 --- a/src/plugins/pixelart/dialogs/GridLayout.zig +++ b/src/plugins/pixelart/src/dialogs/GridLayout.zig @@ -6,15 +6,15 @@ //! preview on the right that expands with the window. The preview uses `CanvasWidget` so //! panning / zooming honour `Settings.resolvedPanZoomScheme` (`auto` follows DVUI scroll heuristics). -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const std = @import("std"); const NewFile = @import("NewFile.zig"); -const CanvasWidget = fizzy.dvui.CanvasWidget; +const CanvasWidget = pixelart.core.dvui.CanvasWidget; const CanvasBridge = @import("../widgets/CanvasBridge.zig"); -const FloatingWindowWidget = fizzy.dvui.FloatingWindowWidget; -const builtin = @import("builtin"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; +const FloatingWindowWidget = pixelart.core.dvui.FloatingWindowWidget; /// Editable grid fields for one mode (Slice vs Resize each keep their own backing). pub const GridFormState = struct { @@ -77,7 +77,7 @@ var preview_prev_slice_full_layer: bool = false; /// a trackpad). Small epsilon tracks real layout drift; fit only runs when dimensions actually move. const preview_layout_min_delta: f32 = 0.01; -const anchors: [9]fizzy.math.layout_anchor.LayoutAnchor = .{ +const anchors: [9]pixelart.math.layout_anchor.LayoutAnchor = .{ .nw, .n, .ne, .w, .c, .e, .sw, .s, .se, @@ -86,7 +86,7 @@ const anchors: [9]fizzy.math.layout_anchor.LayoutAnchor = .{ const anchor_labels = [_][]const u8{ "NW", "N", "NE", "W", "C", "E", "SW", "S", "SE" }; /// Seed both mode forms with the active file's current grid so the dialog opens "no-op" by default. -pub fn presetFromFile(file: *fizzy.Internal.File) void { +pub fn presetFromFile(file: *pixelart.internal.File) void { resize_form = .{ .column_width = file.column_width, .row_height = file.row_height, @@ -125,14 +125,11 @@ pub fn presetFromFile(file: *fizzy.Internal.File) void { /// Same as `Workspace.drawCanvas` / `workspaceMainCanvasVbox` behind the file widget. fn workspaceCanvasChromeColor() dvui.Color { var content_color = dvui.themeGet().color(.window, .fill); - switch (builtin.os.tag) { - .macos, .windows => { - content_color = if (!fizzy.pixelart.host.isMaximized()) - content_color.opacity(fizzy.pixelart.host.contentOpacity()) - else - content_color; - }, - else => {}, + if (Globals.state.host.appliesNativeWindowOpacity()) { + content_color = if (!Globals.state.host.isMaximized()) + content_color.opacity(Globals.state.host.contentOpacity()) + else + content_color; } return content_color; } @@ -211,7 +208,7 @@ fn font() dvui.Font { /// Checkerboard behind the preview: one quad per grid cell with UV 0..1 (same as /// `FileWidget.drawCheckerboardCellsBatched`). Per-cell so vertex colors can vary. fn drawCheckerboardPreviewTiled( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, cv: *CanvasWidget, rs_box: dvui.RectScale, cols: u32, @@ -222,7 +219,7 @@ fn drawCheckerboardPreviewTiled( if (cell_w <= 0 or cell_h <= 0 or cols == 0 or rows == 0) return; const pal = previewCheckerboardPalette(); - const te = fizzy.pixelart.settings.transparency_effect; + const te = Globals.state.settings.transparency_effect; const cols_f = @max(@as(f32, @floatFromInt(cols)), 1.0); const rows_f = @max(@as(f32, @floatFromInt(rows)), 1.0); const nw = cell_w * cols_f; @@ -394,7 +391,7 @@ fn appendTexturedRectQuad( /// Samples the layer composite texture per **old grid cell**, mapping each sprite through `cellAnchoredBlit` /// so the preview matches the result of `applyGridLayout` independently in every tile. fn drawCompositePreviewPerCells( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, rs_box: dvui.RectScale, old_cols: u32, old_rows: u32, @@ -404,9 +401,9 @@ fn drawCompositePreviewPerCells( new_rows: u32, new_cw_: u32, new_rh_: u32, - anchor_vis: fizzy.math.layout_anchor.LayoutAnchor, + anchor_vis: pixelart.math.layout_anchor.LayoutAnchor, ) void { - fizzy.render.syncLayerComposite(file) catch { + pixelart.render.syncLayerComposite(file) catch { dvui.log.err("Grid layout preview: composite failed", .{}); return; }; @@ -426,7 +423,7 @@ fn drawCompositePreviewPerCells( defer builder.deinit(arena); const tint = dvui.Color.PMA.fromColor(dvui.Color.white.opacity(dvui.currentWindow().alpha)); - const blk = fizzy.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw_, new_rh_, anchor_vis); + const blk = pixelart.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw_, new_rh_, anchor_vis); if (blk.sw == 0 or blk.sh == 0) return; var nrow: u32 = 0; @@ -459,9 +456,9 @@ fn drawCompositePreviewPerCells( } /// One quad for the full layer composite (slice preview — no per-cell remapping). -fn drawCompositePreviewFullLayer(file: *fizzy.Internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void { +fn drawCompositePreviewFullLayer(file: *pixelart.internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void { if (nw <= 0 or nh <= 0) return; - fizzy.render.syncLayerComposite(file) catch { + pixelart.render.syncLayerComposite(file) catch { dvui.log.err("Grid layout preview: composite failed", .{}); return; }; @@ -485,7 +482,7 @@ fn drawCompositePreviewFullLayer(file: *fizzy.Internal.File, rs_box: dvui.RectSc /// When entering Slice, keep the current form values if they already tile the layer exactly; /// otherwise snap from the file's authoritative grid (never force 1×1 unless metadata disagrees /// with pixel dimensions). -fn harmonizeSliceStateWithLayer(file: *fizzy.Internal.File) void { +fn harmonizeSliceStateWithLayer(file: *pixelart.internal.File) void { const canvas = file.canvasPixelSize(); const tw = canvas.w; const th = canvas.h; @@ -515,14 +512,14 @@ fn harmonizeSliceStateWithLayer(file: *fizzy.Internal.File) void { fn renderPreview( mutex_id: dvui.Id, dlg_id: dvui.Id, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, nw: u32, nh: u32, new_cw_: u32, new_rh_: u32, new_cols: u32, new_rows: u32, - anchor_vis: fizzy.math.layout_anchor.LayoutAnchor, + anchor_vis: pixelart.math.layout_anchor.LayoutAnchor, slice_full_layer: bool, host_rect: dvui.Rect, ) void { @@ -830,7 +827,7 @@ fn gridLayoutDrawModePill(dlg_id: dvui.Id) void { if (button.clicked()) { const new_mode: Mode = @enumFromInt(i); if (new_mode == .slice and mode != .slice) { - if (file_id_for_dialog) |fid| if (fizzy.editor.open_files.getPtr(fid)) |tf| + if (file_id_for_dialog) |fid| if (Globals.state.docs.fileById(fid)) |tf| harmonizeSliceStateWithLayer(tf); } mode = new_mode; @@ -846,8 +843,8 @@ pub fn dialog(id: dvui.Id) anyerror!bool { const form_font = font(); const file_id_for_dialog = dvui.dataGet(null, id, "_grid_layout_file_id", u64); - const target_file: ?*fizzy.Internal.File = if (file_id_for_dialog) |fid| - fizzy.editor.open_files.getPtr(fid) + const target_file: ?*pixelart.internal.File = if (file_id_for_dialog) |fid| + Globals.state.docs.fileById(fid) else null; @@ -878,10 +875,10 @@ pub fn dialog(id: dvui.Id) anyerror!bool { defer { if (dialog_middle_scroll.offset(.vertical) > 0.0) - fizzy.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .top, .{}); + pixelart.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .top, .{}); if (dialog_middle_scroll.virtual_size.h > dialog_middle_scroll.viewport.h) - fizzy.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .bottom, .{}); + pixelart.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .bottom, .{}); } // Form (intrinsic width, full height) + preview (expands horizontally with the window). @@ -941,18 +938,18 @@ pub fn dialog(id: dvui.Id) anyerror!bool { const v_scroll = left_scroll.offset(.vertical); const h_scroll = left_scroll.offset(.horizontal); if (v_scroll > 0.0) { - fizzy.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{}); + pixelart.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{}); } if (left_scroll.virtual_size.h > left_scroll.viewport.h) { - fizzy.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{}); + pixelart.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{}); } pane_left.deinit(); if (left_scroll.virtual_size.w > left_scroll.viewport.w) { - fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{}); + pixelart.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{}); } if (h_scroll > 0.0) { - fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{}); + pixelart.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{}); } shell_left.deinit(); } @@ -988,7 +985,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { slice_form.rows, anchors[@min(anchor_ix, anchors.len - 1)], }; - break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(fizzy.math.layout_anchor.LayoutAnchor, .nw) }; + break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(pixelart.math.layout_anchor.LayoutAnchor, .nw) }; } break :blk switch (mode) { .slice => .{ @@ -1019,15 +1016,15 @@ pub fn dialog(id: dvui.Id) anyerror!bool { defer { const rs_scroll = preview_host.data().rectScale(); - fizzy.dvui.drawEdgeShadow(rs_scroll, .top, .{}); - fizzy.dvui.drawEdgeShadow(rs_scroll, .bottom, .{}); - fizzy.dvui.drawEdgeShadow(rs_scroll, .left, .{}); - fizzy.dvui.drawEdgeShadow(rs_scroll, .right, .{}); + pixelart.core.dvui.drawEdgeShadow(rs_scroll, .top, .{}); + pixelart.core.dvui.drawEdgeShadow(rs_scroll, .bottom, .{}); + pixelart.core.dvui.drawEdgeShadow(rs_scroll, .left, .{}); + pixelart.core.dvui.drawEdgeShadow(rs_scroll, .right, .{}); } if (target_file) |tf| { const host_rect = preview_host.data().contentRect(); - const dims_ok = fizzy.Internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows); + const dims_ok = pixelart.internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows); if (dims_ok) { renderPreview( id, @@ -1084,7 +1081,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { /// Resize-mode form: cell width (x), cell height (y), columns (x), rows (y); 9-way anchor; current vs after readout. fn drawResizeForm( unique_id: dvui.Id, - target_file: ?*fizzy.Internal.File, + target_file: ?*pixelart.internal.File, form_font: dvui.Font, ) bool { var valid: bool = true; @@ -1110,7 +1107,7 @@ fn drawResizeForm( .color_text = dvui.themeGet().color(.control, .text), }); - if (!fizzy.Internal.File.validateGridLayoutProposedDims( + if (!pixelart.internal.File.validateGridLayoutProposedDims( resize_form.column_width, resize_form.row_height, resize_form.columns, @@ -1303,7 +1300,7 @@ fn drawResizeForm( /// multiply back to the locked total. fn drawSliceForm( unique_id: dvui.Id, - target_file: ?*fizzy.Internal.File, + target_file: ?*pixelart.internal.File, form_font: dvui.Font, ) bool { var valid: bool = true; @@ -1466,7 +1463,7 @@ fn drawSliceForm( return valid; } -/// Custom window shell for the grid-layout dialog: matches `fizzy.dvui.dialogWindow` (open +/// Custom window shell for the grid-layout dialog: matches `pixelart.core.dvui.dialogWindow` (open /// `autoSize()` animation, nudge + center on modal rect). `min_size_content` is half the main /// window so the first layout pass does not collapse the shell; DVUI then grows to fit content /// (see `FloatingWindowWidget` `Size.max(min_size, min_sizeGet)`). Do not use `max_size_content` @@ -1479,7 +1476,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void { }; if (modal) { - fizzy.dvui.modal_dim_titlebar = true; + pixelart.core.dvui.modal_dim_titlebar = true; } const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse { @@ -1492,8 +1489,8 @@ pub fn windowFn(id: dvui.Id) anyerror!void { }; const cancel_label = dvui.dataGetSlice(null, id, "_cancel_label", []u8); const default = dvui.dataGet(null, id, "_default", dvui.enums.DialogResponse); - const callafter = dvui.dataGet(null, id, "_callafter", fizzy.dvui.CallAfterFn); - const displayFn = dvui.dataGet(null, id, "_displayFn", fizzy.dvui.DisplayFn); + const callafter = dvui.dataGet(null, id, "_callafter", pixelart.core.dvui.CallAfterFn); + const displayFn = dvui.dataGet(null, id, "_displayFn", pixelart.core.dvui.DisplayFn); // Default shell: wide enough for form + preview; DVUI autoSize grows to content if larger. const wr = dvui.windowRect(); @@ -1501,7 +1498,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void { const init_h = @round(wr.h * 0.52); const center_on = dvui.currentWindow().subwindows.current_rect; - var win = fizzy.dvui.floatingWindow(@src(), .{ + var win = pixelart.core.dvui.floatingWindow(@src(), .{ .modal = modal, .center_on = center_on, .window_avoid = .nudge, @@ -1533,12 +1530,12 @@ pub fn windowFn(id: dvui.Id) anyerror!void { if (dvui.animationGet(win.data().id, "_close_x")) |a| { if (a.done()) { - fizzy.dvui.dialog_close_rect_override = null; + pixelart.core.dvui.dialog_close_rect_override = null; dvui.dialogRemove(id); } - } else if (fizzy.dvui.dialog_close_rect_override) |close_rect| { + } else if (pixelart.core.dvui.dialog_close_rect_override) |close_rect| { dvui.dataSet(null, win.data().id, "_close_rect", close_rect); - fizzy.dvui.dialog_close_rect_override = null; + pixelart.core.dvui.dialog_close_rect_override = null; } else { // Call `autoSize` only while opening. Doing it every frame leaves `auto_size` true and the // window keeps animating/snapping to content min size — user resize appears "locked". @@ -1563,16 +1560,16 @@ pub fn windowFn(id: dvui.Id) anyerror!void { var shell = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); defer shell.deinit(); - const header_kind: fizzy.dvui.DialogHeaderKind = switch (dvui.dataGet(null, id, "_header_kind", u8) orelse 0) { - @intFromEnum(fizzy.dvui.DialogHeaderKind.none) => .none, - @intFromEnum(fizzy.dvui.DialogHeaderKind.info) => .info, - @intFromEnum(fizzy.dvui.DialogHeaderKind.warning) => .warning, - @intFromEnum(fizzy.dvui.DialogHeaderKind.err) => .err, + const header_kind: pixelart.core.dvui.DialogHeaderKind = switch (dvui.dataGet(null, id, "_header_kind", u8) orelse 0) { + @intFromEnum(pixelart.core.dvui.DialogHeaderKind.none) => .none, + @intFromEnum(pixelart.core.dvui.DialogHeaderKind.info) => .info, + @intFromEnum(pixelart.core.dvui.DialogHeaderKind.warning) => .warning, + @intFromEnum(pixelart.core.dvui.DialogHeaderKind.err) => .err, else => .none, }; var header_openflag = true; - win.dragAreaSet(fizzy.dvui.windowHeader(title, "", &header_openflag, header_kind)); + win.dragAreaSet(pixelart.core.dvui.windowHeader(title, "", &header_openflag, header_kind)); if (!header_openflag) { if (callafter) |ca| { ca(id, .cancel) catch { @@ -1605,7 +1602,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void { } } - { // Footer — match `fizzy.dvui.dialogWindow` (horizontal strip, gravity_x centered). + { // Footer — match `pixelart.core.dvui.dialogWindow` (horizontal strip, gravity_x centered). var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_x = 0.5, .padding = .{ .y = 6, .h = 8 }, @@ -1695,12 +1692,12 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void switch (response) { .ok => { const file_id = dvui.dataGet(null, id, "_grid_layout_file_id", u64) orelse return; - const file = fizzy.editor.open_files.getPtr(file_id) orelse return; + const file = Globals.state.docs.fileById(file_id) orelse return; switch (mode) { .slice => { const s = slice_form; - if (!fizzy.Internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows)) + if (!pixelart.internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows)) return; file.applyGridSliceOnly(.{ .column_width = s.column_width, @@ -1714,7 +1711,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void }, .resize => { const r = resize_form; - if (!fizzy.Internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows)) + if (!pixelart.internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows)) return; file.applyGridLayout(.{ .column_width = r.column_width, @@ -1730,7 +1727,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void } dvui.refresh(null, @src(), dvui.currentWindow().data().id); - fizzy.editor.requestCompositeWarmup(); + Globals.state.host.requestCompositeWarmup(); }, .cancel => {}, else => {}, diff --git a/src/plugins/pixelart/dialogs/NewFile.zig b/src/plugins/pixelart/src/dialogs/NewFile.zig similarity index 90% rename from src/plugins/pixelart/dialogs/NewFile.zig rename to src/plugins/pixelart/src/dialogs/NewFile.zig index f6a591c9..9554493c 100644 --- a/src/plugins/pixelart/dialogs/NewFile.zig +++ b/src/plugins/pixelart/src/dialogs/NewFile.zig @@ -1,8 +1,10 @@ const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); -const Dialogs = @import("../../../editor/dialogs/Dialogs.zig"); +const Dialogs = @import("../../../../editor/dialogs/Dialogs.zig"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; +const fizzy = @import("../../../../fizzy.zig"); pub var mode: enum(usize) { single, @@ -188,18 +190,16 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void switch (response) { .ok => { if (parent_path) |parent| { - const new_path = try std.fs.path.join(fizzy.app.allocator, &.{ parent, "untitled.fiz" }); - defer fizzy.app.allocator.free(new_path); + const new_path = try std.fs.path.join(Globals.allocator(), &.{ parent, "untitled.fiz" }); + defer Globals.allocator().free(new_path); - const file = fizzy.editor.newFile(new_path, .{ + const doc = try Globals.state.host.createDocument(new_path, .{ .column_width = column_width, .row_height = row_height, .columns = if (mode == .single) 1 else columns, .rows = if (mode == .single) 1 else rows, - }) catch { - dvui.log.err("Failed to create file in folder: {s}", .{parent}); - return error.FailedToCreateFile; - }; + }); + const file = Globals.state.docs.fileFrom(doc); // Save synchronously so the tree's directory scan sees the new file on the next draw // (saveAsync would finish later and the fly-to / rename row would never match). @@ -209,22 +209,19 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void }; if (fizzy.Editor.Explorer.files.new_file_path) |old| { - fizzy.app.allocator.free(old); + Globals.allocator().free(old); } - fizzy.Editor.Explorer.files.new_file_path = try fizzy.app.allocator.dupe(u8, file.path); + fizzy.Editor.Explorer.files.new_file_path = try Globals.allocator().dupe(u8, file.path); dvui.refresh(null, @src(), dvui.currentWindow().data().id); } else { - const new_path = try fizzy.editor.allocNextUntitledPath(); - defer fizzy.app.allocator.free(new_path); - _ = fizzy.editor.newFile(new_path, .{ + const new_path = try Globals.state.host.allocUntitledPath(); + defer Globals.allocator().free(new_path); + _ = try Globals.state.host.createDocument(new_path, .{ .column_width = column_width, .row_height = row_height, .columns = if (mode == .single) 1 else columns, .rows = if (mode == .single) 1 else rows, - }) catch { - dvui.log.err("Failed to create new untitled file", .{}); - return error.FailedToCreateFile; - }; + }); } }, .cancel => {}, diff --git a/src/plugins/pixelart/explorer/project.zig b/src/plugins/pixelart/src/explorer/project.zig similarity index 89% rename from src/plugins/pixelart/explorer/project.zig rename to src/plugins/pixelart/src/explorer/project.zig index 63bc7528..59ce990a 100644 --- a/src/plugins/pixelart/explorer/project.zig +++ b/src/plugins/pixelart/src/explorer/project.zig @@ -2,8 +2,9 @@ const std = @import("std"); const builtin = @import("builtin"); const icons = @import("icons"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; pub fn draw() !void { // On web there's no project folder concept. Render a simplified pane that @@ -14,8 +15,8 @@ pub fn draw() !void { return; } - if (fizzy.pixelart.host.folder()) |folder| { - if (fizzy.pixelart.project) |_| { + if (Globals.state.host.folder()) |folder| { + if (Globals.state.project) |_| { const tl = dvui.textLayout(@src(), .{}, .{ .expand = .none, .margin = dvui.Rect.all(0), @@ -34,7 +35,7 @@ pub fn draw() !void { } else { var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, - .max_size_content = .{ .w = fizzy.pixelart.host.explorerVirtualSize().w, .h = std.math.floatMax(f32) }, + .max_size_content = .{ .w = Globals.state.host.explorerVirtualSize().w, .h = std.math.floatMax(f32) }, }); defer box.deinit(); @@ -44,19 +45,19 @@ pub fn draw() !void { tl.deinit(); if (dvui.button(@src(), "Create Project", .{}, .{ .expand = .horizontal })) { - fizzy.pixelart.project = .{}; + Globals.state.project = .{}; } return; } - const packing = fizzy.editor.isPackingActive(); + const packing = Globals.state.host.isPackingActive(); if (packProjectButton(packing)) { - fizzy.editor.startPackProject() catch |err| { + Globals.state.host.startPackProject() catch |err| { dvui.log.err("Failed to start project pack: {any}", .{err}); }; } - if (fizzy.packer.atlas != null) { + if (Globals.packer.atlas != null) { drawPackedAtlasStats(); } @@ -67,8 +68,8 @@ pub fn draw() !void { dvui.log.err("Failed to draw path text entry", .{}); }; - if (fizzy.pixelart.project) |project| { - if (fizzy.packer.atlas) |atlas| { + if (Globals.state.project) |project| { + if (Globals.packer.atlas) |atlas| { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } }); if (dvui.button(@src(), "Export Project", .{ .draw_focus = false }, .{ .expand = .horizontal, @@ -129,13 +130,13 @@ pub fn draw() !void { // break :blk true; // }; - // if (dvui.dialogNativeFileSave(fizzy.app.allocator, .{ + // if (dvui.dialogNativeFileSave(Globals.allocator(), .{ // .title = "Select Atlas Data Output", // .filters = &.{".atlas"}, // .filter_description = "Atlas file", // .path = if (valid_path) project.packed_atlas_output else null, // }) catch null) |path| { - // project.packed_atlas_output = fizzy.app.allocator.dupe(u8, path[0..]) catch null; + // project.packed_atlas_output = Globals.allocator().dupe(u8, path[0..]) catch null; // set_text = true; // } else { // dvui.log.err("Project failed to copy new path", .{}); @@ -162,7 +163,7 @@ pub fn draw() !void { // if (te.text_changed) { // const t = te.getText(); // if (t.len > 0) { - // project.packed_atlas_output = fizzy.app.allocator.dupe(u8, t) catch null; + // project.packed_atlas_output = Globals.allocator().dupe(u8, t) catch null; // } else { // project.packed_atlas_output = null; // } @@ -210,13 +211,13 @@ pub fn draw() !void { // break :blk true; // }; - // if (dvui.dialogNativeFileSave(fizzy.app.allocator, .{ + // if (dvui.dialogNativeFileSave(Globals.allocator(), .{ // .title = "Select Atlas Image Output", // .filters = &.{".png"}, // .filter_description = "Image file", // .path = if (valid_path) project.packed_image_output else null, // }) catch null) |path| { - // project.packed_image_output = fizzy.app.allocator.dupe(u8, path[0..]) catch null; + // project.packed_image_output = Globals.allocator().dupe(u8, path[0..]) catch null; // set_text = true; // } else { // dvui.log.err("Project failed to copy new path", .{}); @@ -243,7 +244,7 @@ pub fn draw() !void { // if (te.text_changed) { // const t = te.getText(); // if (t.len > 0) { - // project.packed_image_output = fizzy.app.allocator.dupe(u8, t) catch null; + // project.packed_image_output = Globals.allocator().dupe(u8, t) catch null; // } else { // project.packed_image_output = null; // } @@ -258,7 +259,7 @@ const PathType = enum { }; fn pathTextEntry(path_type: PathType) !void { - if (fizzy.pixelart.project) |*project| { + if (Globals.state.project) |*project| { const output_path = switch (path_type) { .atlas => &project.packed_atlas_output, .image => &project.packed_image_output, @@ -315,7 +316,7 @@ fn pathTextEntry(path_type: PathType) !void { break :blk true; }; - fizzy.pixelart.host.showSaveDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{ + Globals.state.host.showSaveDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{ if (path_type == .atlas) .{ .name = "Atlas Data", .pattern = "atlas" } else .{ .name = "Atlas Image", .pattern = "png;jpg;jpeg" }, }, "", if (valid_path) output_path.* else null); set_text = true; @@ -342,7 +343,7 @@ fn pathTextEntry(path_type: PathType) !void { if (te.text_changed) { const t = te.getText(); if (t.len > 0) { - output_path.* = fizzy.app.allocator.dupe(u8, t) catch null; + output_path.* = Globals.allocator().dupe(u8, t) catch null; } else { output_path.* = null; } @@ -351,8 +352,8 @@ fn pathTextEntry(path_type: PathType) !void { } fn drawPackedAtlasStats() void { - const atlas = &fizzy.packer.atlas.?; - const image_size = fizzy.image.size(atlas.source); + const atlas = &Globals.packer.atlas.?; + const image_size = pixelart.image.size(atlas.source); const atlas_w: u32 = @intFromFloat(image_size.w); const atlas_h: u32 = @intFromFloat(image_size.h); @@ -371,7 +372,7 @@ fn drawPackedAtlasStats() void { const label_opts: dvui.Options = .{ .font = body, .color_text = label_color }; const value_opts: dvui.Options = .{ .font = body, .color_text = value_color }; - if (fizzy.packer.last_packed_at_ns) |packed_at_ns| { + if (Globals.packer.last_packed_at_ns) |packed_at_ns| { var when_buf: [64]u8 = undefined; const when = formatLastPacked(&when_buf, packed_at_ns); tl.addText("Last packed: ", label_opts); @@ -396,7 +397,7 @@ fn drawPackedAtlasStats() void { } fn formatLastPacked(buf: []u8, packed_at_ns: i128) []const u8 { - const elapsed_s = @divTrunc(fizzy.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s); + const elapsed_s = @divTrunc(pixelart.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s); if (elapsed_s < 10) { return std.fmt.bufPrint(buf, "just now", .{}) catch "recently"; } @@ -442,7 +443,7 @@ fn packProjectButton(packing: bool) bool { // Spinner overlays at the right edge — same content rect as the label, but anchored to // `gravity_x = 1.0`. Sized to roughly match the cap height so it doesn't fight the label. if (packing) { - fizzy.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{ + pixelart.core.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{ .min_size_content = .{ .w = 16, .h = 16 }, .gravity_x = 1.0, .gravity_y = 0.5, @@ -455,24 +456,24 @@ fn packProjectButton(packing: bool) bool { } pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void { - if (fizzy.pixelart.project) |*project| { + if (Globals.state.project) |*project| { const output_path = &project.packed_atlas_output; if (paths) |paths_| { for (paths_) |path| { - output_path.* = fizzy.app.allocator.dupe(u8, path) catch null; + output_path.* = Globals.allocator().dupe(u8, path) catch null; } } } } pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void { - if (fizzy.pixelart.project) |*project| { + if (Globals.state.project) |*project| { const output_path = &project.packed_image_output; if (paths) |paths_| { for (paths_) |path| { - output_path.* = fizzy.app.allocator.dupe(u8, path) catch null; + output_path.* = Globals.allocator().dupe(u8, path) catch null; } } } @@ -482,7 +483,7 @@ pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void { /// the Pack button (operates on currently-open files) and Download buttons for /// the resulting atlas/image data. fn drawWeb() !void { - if (fizzy.editor.open_files.count() == 0) { + if (Globals.state.host.openDocCount() == 0) { dvui.labelNoFmt( @src(), "Open one or more files to pack.", @@ -500,19 +501,19 @@ fn drawWeb() !void { .style = .highlight, }; - const packing = fizzy.editor.isPackingActive(); + const packing = Globals.state.host.isPackingActive(); if (packProjectButton(packing)) { - fizzy.editor.startPackProject() catch |err| { + Globals.state.host.startPackProject() catch |err| { dvui.log.err("Failed to pack open files: {any}", .{err}); }; } - if (fizzy.packer.atlas != null) { + if (Globals.packer.atlas != null) { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } }); drawPackedAtlasStats(); } - if (fizzy.packer.atlas) |atlas| { + if (Globals.packer.atlas) |atlas| { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } }); if (dvui.button(@src(), "Download Atlas JSON", .{ .draw_focus = false }, btn_opts)) { atlas.save("atlas.atlas", .data) catch { diff --git a/src/plugins/pixelart/explorer/sprites.zig b/src/plugins/pixelart/src/explorer/sprites.zig similarity index 90% rename from src/plugins/pixelart/explorer/sprites.zig rename to src/plugins/pixelart/src/explorer/sprites.zig index 9b20679d..1e55629d 100644 --- a/src/plugins/pixelart/explorer/sprites.zig +++ b/src/plugins/pixelart/src/explorer/sprites.zig @@ -1,8 +1,10 @@ const std = @import("std"); const dvui = @import("dvui"); const icons = @import("icons"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; +const fizzy = @import("../../../../fizzy.zig"); -const fizzy = @import("../../../fizzy.zig"); const Editor = fizzy.Editor; const Sprites = @This(); @@ -91,7 +93,7 @@ pub fn init() Sprites { return .{}; } -fn selectionUiKey(file: *fizzy.Internal.File) u64 { +fn selectionUiKey(file: *pixelart.internal.File) u64 { const c = file.editor.selected_sprites.count(); if (c == 0) return 0; const first = file.editor.selected_sprites.findFirstSet() orelse return 0; @@ -101,7 +103,7 @@ fn selectionUiKey(file: *fizzy.Internal.File) u64 { return (@as(u64, c) << 48) ^ (@as(u64, first) << 24) ^ @as(u64, last); } -fn selectionOriginsDifferFrom(file: *fizzy.Internal.File, indices: []const usize, old_vals: []const [2]f32) bool { +fn selectionOriginsDifferFrom(file: *pixelart.internal.File, indices: []const usize, old_vals: []const [2]f32) bool { for (indices, old_vals) |si, ov| { const cur = file.sprites.get(si).origin; if (cur[0] != ov[0] or cur[1] != ov[1]) return true; @@ -113,36 +115,36 @@ fn freeOriginAxisDragSnapshot(self: *Sprites, axis: enum { x, y }) void { switch (axis) { .x => { if (self.origin_x_drag_indices) |s| { - fizzy.app.allocator.free(s); + Globals.allocator().free(s); self.origin_x_drag_indices = null; } if (self.origin_x_drag_old_vals) |v| { - fizzy.app.allocator.free(v); + Globals.allocator().free(v); self.origin_x_drag_old_vals = null; } }, .y => { if (self.origin_y_drag_indices) |s| { - fizzy.app.allocator.free(s); + Globals.allocator().free(s); self.origin_y_drag_indices = null; } if (self.origin_y_drag_old_vals) |v| { - fizzy.app.allocator.free(v); + Globals.allocator().free(v); self.origin_y_drag_old_vals = null; } }, } } -fn beginOriginAxisDragSnapshot(self: *Sprites, file: *fizzy.Internal.File, axis: enum { x, y }) !void { +fn beginOriginAxisDragSnapshot(self: *Sprites, file: *pixelart.internal.File, axis: enum { x, y }) !void { switch (axis) { .x => if (self.origin_x_drag_indices != null) return, .y => if (self.origin_y_drag_indices != null) return, } const count = file.editor.selected_sprites.count(); - const indices = try fizzy.app.allocator.alloc(usize, count); - errdefer fizzy.app.allocator.free(indices); - const old_vals = try fizzy.app.allocator.alloc([2]f32, count); + const indices = try Globals.allocator().alloc(usize, count); + errdefer Globals.allocator().free(indices); + const old_vals = try Globals.allocator().alloc([2]f32, count); var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); var i: usize = 0; while (iter.next()) |si| : (i += 1) { @@ -161,15 +163,15 @@ fn beginOriginAxisDragSnapshot(self: *Sprites, file: *fizzy.Internal.File, axis: } } -fn appendOriginsHistory(file: *fizzy.Internal.File, indices: []usize, old_vals: [][2]f32) !void { +fn appendOriginsHistory(file: *pixelart.internal.File, indices: []usize, old_vals: [][2]f32) !void { file.history.append(.{ .origins = .{ .indices = indices, .values = old_vals } }) catch |err| { - fizzy.app.allocator.free(indices); - fizzy.app.allocator.free(old_vals); + Globals.allocator().free(indices); + Globals.allocator().free(old_vals); return err; }; } -fn applySpriteOriginAxisNoHistory(file: *fizzy.Internal.File, axis: enum { x, y }, new_val: f32) void { +fn applySpriteOriginAxisNoHistory(file: *pixelart.internal.File, axis: enum { x, y }, new_val: f32) void { const cw = @as(f32, @floatFromInt(file.column_width)); const rh = @as(f32, @floatFromInt(file.row_height)); const max_v: f32 = switch (axis) { @@ -186,7 +188,7 @@ fn applySpriteOriginAxisNoHistory(file: *fizzy.Internal.File, axis: enum { x, y } } -fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_val: f32) !void { +fn commitSpriteOriginAxis(file: *pixelart.internal.File, axis: enum { x, y }, new_val: f32) !void { const cw = @as(f32, @floatFromInt(file.column_width)); const rh = @as(f32, @floatFromInt(file.row_height)); const max_v: f32 = switch (axis) { @@ -198,10 +200,10 @@ fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_v const count = file.editor.selected_sprites.count(); if (count == 0) return; - const indices = try fizzy.app.allocator.alloc(usize, count); - errdefer fizzy.app.allocator.free(indices); - const old_vals = try fizzy.app.allocator.alloc([2]f32, count); - errdefer fizzy.app.allocator.free(old_vals); + const indices = try Globals.allocator().alloc(usize, count); + errdefer Globals.allocator().free(indices); + const old_vals = try Globals.allocator().alloc([2]f32, count); + errdefer Globals.allocator().free(old_vals); var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); var i: usize = 0; @@ -221,14 +223,14 @@ fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_v for (indices, 0..) |si, j| { file.sprites.items(.origin)[si] = old_vals[j]; } - fizzy.app.allocator.free(indices); - fizzy.app.allocator.free(old_vals); + Globals.allocator().free(indices); + Globals.allocator().free(old_vals); return err; }; } pub fn draw(self: *Sprites) !void { - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { const parent_height = dvui.parentGet().data().rect.h - 2.0 * dvui.currentWindow().natural_scale; const parent_data = dvui.parentGet().data(); @@ -289,7 +291,7 @@ pub fn draw(self: *Sprites) !void { } pub fn drawOriginControls(self: *Sprites) !void { - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { if (file.editor.selected_sprites.count() == 0) return; const key = selectionUiKey(file); @@ -418,8 +420,8 @@ pub fn drawOriginControls(self: *Sprites) !void { if (selectionOriginsDifferFrom(file, indices, old_vals)) { try appendOriginsHistory(file, indices, old_vals); } else { - fizzy.app.allocator.free(indices); - fizzy.app.allocator.free(old_vals); + Globals.allocator().free(indices); + Globals.allocator().free(old_vals); } } } @@ -489,8 +491,8 @@ pub fn drawOriginControls(self: *Sprites) !void { if (selectionOriginsDifferFrom(file, indices, old_vals)) { try appendOriginsHistory(file, indices, old_vals); } else { - fizzy.app.allocator.free(indices); - fizzy.app.allocator.free(old_vals); + Globals.allocator().free(indices); + Globals.allocator().free(old_vals); } } } @@ -507,7 +509,7 @@ pub fn drawAnimationControls(self: *Sprites) !void { const icon_color = dvui.themeGet().color(.control, .text); - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { { var add_animation_button: dvui.ButtonWidget = undefined; add_animation_button.init(@src(), .{}, .{ @@ -698,7 +700,7 @@ pub fn drawAnimations(self: *Sprites) !void { controls_box.deinit(); - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { // Make sure to update the prev anim count! defer self.prev_anim_count = file.animations.len; @@ -732,17 +734,17 @@ pub fn drawAnimations(self: *Sprites) !void { defer { if (file.editor.animations_scroll_info.viewport.w < file.editor.animations_scroll_info.virtual_size.w) { if (file.editor.animations_scroll_info.offset(.horizontal) < file.editor.animations_scroll_info.scrollMax(.horizontal)) { - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{}); + pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{}); } if (file.editor.animations_scroll_info.offset(.horizontal) > 0.0) { - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{}); + pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{}); } } } const vertical_scroll = file.editor.animations_scroll_info.offset(.vertical); - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ + var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ .expand = .horizontal, .background = false, }); @@ -769,8 +771,8 @@ pub fn drawAnimations(self: *Sprites) !void { } } - var moved = try fizzy.app.allocator.alloc(fizzy.Internal.Animation, sources.len); - defer fizzy.app.allocator.free(moved); + var moved = try Globals.allocator().alloc(pixelart.internal.Animation, sources.len); + defer Globals.allocator().free(moved); for (sources, 0..) |s, i| { moved[i] = file.animations.get(s); } @@ -781,11 +783,11 @@ pub fn drawAnimations(self: *Sprites) !void { file.animations.orderedRemove(sources[ri]); } - const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); + const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); const target = @min(target_raw, file.animations.len); for (moved, 0..) |anim, i| { - file.animations.insert(fizzy.app.allocator, target + i, anim) catch { + file.animations.insert(Globals.allocator(), target + i, anim) catch { dvui.log.err("Failed to insert animation", .{}); }; } @@ -796,7 +798,7 @@ pub fn drawAnimations(self: *Sprites) !void { file.editor.selected_animation_indices.clearRetainingCapacity(); for (0..moved.len) |i| { - file.editor.selected_animation_indices.append(fizzy.app.allocator, target + i) catch { + file.editor.selected_animation_indices.append(Globals.allocator(), target + i) catch { dvui.log.err("Failed to update animation selection", .{}); }; } @@ -829,7 +831,7 @@ pub fn drawAnimations(self: *Sprites) !void { const selected = if (self.edit_anim_id) |id| id == anim_id else (is_primary_row or in_multi); var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { + if (Globals.state.colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(anim_id)); } @@ -994,13 +996,13 @@ pub fn drawAnimations(self: *Sprites) !void { file.history.append(.{ .animation_name = .{ .index = anim_index, - .name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[anim_index]), + .name = try Globals.allocator().dupe(u8, file.animations.items(.name)[anim_index]), }, }) catch { dvui.log.err("Failed to append history", .{}); }; - fizzy.app.allocator.free(file.animations.items(.name)[anim_index]); - file.animations.items(.name)[anim_index] = try fizzy.app.allocator.dupe(u8, te.getText()); + Globals.allocator().free(file.animations.items(.name)[anim_index]); + file.animations.items(.name)[anim_index] = try Globals.allocator().dupe(u8, te.getText()); } if (te.enter_pressed) { file.selected_animation_index = anim_index; @@ -1050,10 +1052,10 @@ pub fn drawAnimations(self: *Sprites) !void { const anim_si = file.editor.animations_scroll_info; const anim_v_max = anim_si.scrollMax(.vertical); if (vertical_scroll > scroll_list_shadow_deadzone_ns) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); + pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); if (anim_v_max > scroll_list_shadow_deadzone_ns and vertical_scroll < anim_v_max - scroll_list_shadow_deadzone_ns) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); + pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); } } @@ -1063,7 +1065,7 @@ pub fn drawFrameControls(_: *Sprites) !void { }); defer box.deinit(); - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { const index = if (file.selected_animation_index) |i| i else 0; var animation = file.animations.get(index); @@ -1109,8 +1111,8 @@ pub fn drawFrameControls(_: *Sprites) !void { dvui.alphaSet(alpha); if (sort_anim_asc_button.clicked()) { - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); - std.mem.sort(fizzy.Animation.Frame, animation.frames, {}, FrameSort.asc); + const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); + std.mem.sort(pixelart.internal.Animation.Frame, animation.frames, {}, FrameSort.asc); if (!animation.eqlFrames(prev_order)) { file.history.append(.{ @@ -1124,7 +1126,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + Globals.allocator().free(prev_order); } } } @@ -1169,8 +1171,8 @@ pub fn drawFrameControls(_: *Sprites) !void { dvui.alphaSet(alpha); if (sort_anim_desc_button.clicked()) { - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); - std.mem.sort(fizzy.Animation.Frame, animation.frames, {}, FrameSort.desc); + const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); + std.mem.sort(pixelart.internal.Animation.Frame, animation.frames, {}, FrameSort.desc); if (!animation.eqlFrames(prev_order)) { file.history.append(.{ @@ -1184,7 +1186,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + Globals.allocator().free(prev_order); } } } @@ -1232,7 +1234,7 @@ pub fn drawFrameControls(_: *Sprites) !void { if (add_sprite_button.clicked()) { if (file.editor.selected_sprites.count() > 0) { var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - var frames = std.array_list.Managed(fizzy.Animation.Frame).init(dvui.currentWindow().arena()); + var frames = std.array_list.Managed(pixelart.internal.Animation.Frame).init(dvui.currentWindow().arena()); while (iter.next()) |sprite_index| { frames.append(.{ .sprite_index = sprite_index, @@ -1243,9 +1245,9 @@ pub fn drawFrameControls(_: *Sprites) !void { }; } - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); + const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); - animation.appendFrames(fizzy.app.allocator, frames.items) catch { + animation.appendFrames(Globals.allocator(), frames.items) catch { dvui.log.err("Failed to append frames", .{}); }; @@ -1261,7 +1263,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + Globals.allocator().free(prev_order); } } } @@ -1320,12 +1322,12 @@ pub fn drawFrameControls(_: *Sprites) !void { if (duplicate_animation_button.clicked()) { var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); + const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); while (iter.next()) |sprite_index| { for (animation.frames) |frame| { if (frame.sprite_index == sprite_index) { - try animation.appendFrame(fizzy.app.allocator, .{ + try animation.appendFrame(Globals.allocator(), .{ .sprite_index = frame.sprite_index, .ms = frame.ms, }); @@ -1346,7 +1348,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.selected_animation_frame_index = 0; file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + Globals.allocator().free(prev_order); } } } @@ -1392,13 +1394,13 @@ pub fn drawFrameControls(_: *Sprites) !void { if (delete_animation_button.clicked()) { var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); + const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); while (iter.next()) |sprite_index| { var i: usize = animation.frames.len; while (i > 0) : (i -= 1) { if (animation.frames[i - 1].sprite_index == sprite_index) { - animation.removeFrame(fizzy.app.allocator, i - 1); + animation.removeFrame(Globals.allocator(), i - 1); break; } } @@ -1416,7 +1418,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.selected_animation_frame_index = 0; file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + Globals.allocator().free(prev_order); } } } @@ -1424,7 +1426,7 @@ pub fn drawFrameControls(_: *Sprites) !void { } pub fn drawFrames(self: *Sprites) !void { - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { var anim = dvui.animate(@src(), .{ .kind = .horizontal, .duration = 450_000, .easing = dvui.easing.outBack }, .{}); defer anim.deinit(); @@ -1482,7 +1484,7 @@ pub fn drawFrames(self: *Sprites) !void { defer self.prev_sprite_count = animation.frames.len; defer self.prev_anim_id = animation.id; - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ + var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ .expand = .horizontal, .background = false, }); @@ -1495,7 +1497,7 @@ pub fn drawFrames(self: *Sprites) !void { if (removed_frame_indices_len > 0) { const sources = removed_frame_indices_buf[0..removed_frame_indices_len]; - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); + const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); defer file.animations.set(animation_index, animation); const primary_before = file.selected_animation_frame_index; @@ -1509,14 +1511,14 @@ pub fn drawFrames(self: *Sprites) !void { } } - var moved = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, sources.len); - defer fizzy.app.allocator.free(moved); + var moved = try Globals.allocator().alloc(pixelart.internal.Animation.Frame, sources.len); + defer Globals.allocator().free(moved); for (sources, 0..) |s, i| { moved[i] = animation.frames[s]; } - var remaining = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, animation.frames.len - sources.len); - defer fizzy.app.allocator.free(remaining); + var remaining = try Globals.allocator().alloc(pixelart.internal.Animation.Frame, animation.frames.len - sources.len); + defer Globals.allocator().free(remaining); { var ri: usize = 0; var wi: usize = 0; @@ -1535,7 +1537,7 @@ pub fn drawFrames(self: *Sprites) !void { } } - const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); + const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); const target = @min(target_raw, remaining.len); var wi: usize = 0; @@ -1558,7 +1560,7 @@ pub fn drawFrames(self: *Sprites) !void { file.editor.selected_frame_indices.clearRetainingCapacity(); for (0..moved.len) |i| { - file.editor.selected_frame_indices.append(fizzy.app.allocator, target + i) catch { + file.editor.selected_frame_indices.append(Globals.allocator(), target + i) catch { dvui.log.err("Failed to update frame selection", .{}); }; } @@ -1576,7 +1578,7 @@ pub fn drawFrames(self: *Sprites) !void { dvui.log.err("Failed to append history", .{}); }; } else { - fizzy.app.allocator.free(prev_order); + Globals.allocator().free(prev_order); } self.sprite_insert_before_index = null; @@ -1600,7 +1602,7 @@ pub fn drawFrames(self: *Sprites) !void { for (animation.frames, 0..) |*frame, frame_index| { var anim_color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { + if (Globals.state.colors.file_tree_palette) |*palette| { anim_color = palette.getDVUIColor(@intCast(animation.id)); } @@ -1782,10 +1784,10 @@ pub fn drawFrames(self: *Sprites) !void { const frames_si = file.editor.sprites_scroll_info; const frames_v_max = frames_si.scrollMax(.vertical); if (vertical_scroll > scroll_list_shadow_deadzone_ns) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); + pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); if (frames_v_max > scroll_list_shadow_deadzone_ns and vertical_scroll < frames_v_max - scroll_list_shadow_deadzone_ns) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); + pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); } } @@ -1799,21 +1801,21 @@ const FrameRowHit = struct { hbox_tl: dvui.Point.Physical, }; -fn frameGestureMatches(file: *const fizzy.Internal.File, anim_id: u64) bool { +fn frameGestureMatches(file: *const pixelart.internal.File, anim_id: u64) bool { return frame_row_gesture != null and frame_row_gesture.?.file_id == file.id and frame_row_gesture.?.anim_id == anim_id; } -fn frameTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void { +fn frameTreeClearGestureKeysOnly(_: *const pixelart.internal.File) void { frame_row_gesture = null; } -fn frameTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void { +fn frameTreeResetRowPointerGesture(_: *const pixelart.internal.File) void { dvui.dragEnd(); frame_row_gesture = null; } /// After `selected_frame_indices` changes, make tile selection match exactly those frames' sprites. -fn syncSpritesFromCurrentFrameSelection(file: *fizzy.Internal.File, anim_index: usize) void { +fn syncSpritesFromCurrentFrameSelection(file: *pixelart.internal.File, anim_index: usize) void { const frames = file.animations.get(anim_index).frames; file.clearSelectedSprites(); for (file.editor.selected_frame_indices.items) |fi| { @@ -1825,7 +1827,7 @@ fn syncSpritesFromCurrentFrameSelection(file: *fizzy.Internal.File, anim_index: /// Frame selection is scoped to one animation at a time. `selected_frame_indices` always mirrors /// `selected_sprites` for this animation's frames (so canvas changes can't leave stale tree state). -fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id: u64) void { +fn ensureFrameSelection(file: *pixelart.internal.File, anim_index: usize, anim_id: u64) void { const frames = file.animations.get(anim_index).frames; if (file.editor.selected_frame_indices_for_animation_id != anim_id) { @@ -1848,7 +1850,7 @@ fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id: file.editor.selected_frame_indices.clearRetainingCapacity(); for (frames, 0..) |f, i| { if (f.sprite_index < file.editor.selected_sprites.capacity() and file.editor.selected_sprites.isSet(f.sprite_index)) { - file.editor.selected_frame_indices.append(fizzy.app.allocator, i) catch return; + file.editor.selected_frame_indices.append(Globals.allocator(), i) catch return; } } std.sort.pdq(usize, file.editor.selected_frame_indices.items, {}, std.sort.asc(usize)); @@ -1879,11 +1881,11 @@ fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id: } fn applyFrameClick( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, anim_index: usize, anim_id: u64, clicked: usize, - mode: fizzy.dvui.TreeSelection.ClickMode, + mode: pixelart.core.dvui.TreeSelection.ClickMode, ) !bool { ensureFrameSelection(file, anim_index, anim_id); @@ -1904,7 +1906,7 @@ fn applyFrameClick( } var out: std.ArrayList(usize) = .empty; - defer out.deinit(fizzy.app.allocator); + defer out.deinit(Globals.allocator()); // When anchor is null, shift-extend uses `primary_opt` as the range endpoint. During playback // that index is the animated playhead, not the editor's last stable focus — use a selection @@ -1916,8 +1918,8 @@ fn applyFrameClick( break :blk file.editor.selected_frame_indices.items[0]; } else file.selected_animation_frame_index; - const res = try fizzy.dvui.TreeSelection.applyClickUsize( - fizzy.app.allocator, + const res = try pixelart.core.dvui.TreeSelection.applyClickUsize( + Globals.allocator(), prev_multi, primary_for_tree, file.editor.frame_selection_anchor, @@ -1928,7 +1930,7 @@ fn applyFrameClick( ); file.editor.selected_frame_indices.clearRetainingCapacity(); - try file.editor.selected_frame_indices.appendSlice(fizzy.app.allocator, out.items); + try file.editor.selected_frame_indices.appendSlice(Globals.allocator(), out.items); file.editor.selected_frame_indices_for_animation_id = anim_id; file.editor.frame_selection_anchor = res.anchor; if (res.primary) |p| file.selected_animation_frame_index = p; @@ -1936,16 +1938,16 @@ fn applyFrameClick( return false; } -fn narrowFrameSelectionTo(file: *fizzy.Internal.File, anim_index: usize, anim_id: u64, clicked: usize) void { +fn narrowFrameSelectionTo(file: *pixelart.internal.File, anim_index: usize, anim_id: u64, clicked: usize) void { file.editor.selected_frame_indices.clearRetainingCapacity(); - file.editor.selected_frame_indices.append(fizzy.app.allocator, clicked) catch return; + file.editor.selected_frame_indices.append(Globals.allocator(), clicked) catch return; file.editor.selected_frame_indices_for_animation_id = anim_id; file.editor.frame_selection_anchor = clicked; file.selected_animation_frame_index = clicked; syncSpritesFromCurrentFrameSelection(file, anim_index); } -fn buildFrameMultiDragIds(file: *const fizzy.Internal.File, animation_index: usize, hits: []const FrameRowHit, out: []usize) []usize { +fn buildFrameMultiDragIds(file: *const pixelart.internal.File, animation_index: usize, hits: []const FrameRowHit, out: []usize) []usize { const frames = file.animations.get(animation_index).frames; var len: usize = 0; const playhead = file.selected_animation_frame_index; @@ -1982,8 +1984,8 @@ fn buildFrameMultiDragIds(file: *const fizzy.Internal.File, animation_index: usi } fn processFrameTreePointerEvents( - tree: *fizzy.dvui.TreeWidget, - file: *fizzy.Internal.File, + tree: *pixelart.core.dvui.TreeWidget, + file: *pixelart.internal.File, anim_id: u64, animation_index: usize, hits: []const FrameRowHit, @@ -2013,7 +2015,7 @@ fn processFrameTreePointerEvents( frameTreeClearGestureKeysOnly(file); dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod); + const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod); const narrow_on_release = applyFrameClick(file, animation_index, anim_id, h.frame_index, mode) catch blk: { dvui.log.err("Failed to apply frame click", .{}); break :blk false; @@ -2143,15 +2145,15 @@ const AnimationRowHit = struct { hbox_tl: dvui.Point.Physical, }; -fn animationGestureMatches(file: *const fizzy.Internal.File) bool { +fn animationGestureMatches(file: *const pixelart.internal.File) bool { return animation_row_gesture != null and animation_row_gesture.?.file_id == file.id; } -fn animationTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void { +fn animationTreeClearGestureKeysOnly(_: *const pixelart.internal.File) void { animation_row_gesture = null; } -fn animationTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void { +fn animationTreeResetRowPointerGesture(_: *const pixelart.internal.File) void { dvui.dragEnd(); animation_row_gesture = null; } @@ -2174,7 +2176,7 @@ fn animationPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Re return true; } -fn animationTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn animationTreePointerInTreeSurface(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { if (floating_win != dvui.subwindowCurrentId()) return false; const tr = tree.data().borderRectScale().r; if (!tr.contains(p)) return false; @@ -2182,12 +2184,12 @@ fn animationTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point return true; } -fn animationTreePointerInTreeBorder(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn animationTreePointerInTreeBorder(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { if (floating_win != dvui.subwindowCurrentId()) return false; return tree.data().borderRectScale().r.contains(p); } -fn animationTreeMotionAllowsReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event) bool { +fn animationTreeMotionAllowsReorder(tree: *pixelart.core.dvui.TreeWidget, e: *dvui.Event) bool { if (e.target_widgetId) |fwid| { if (fwid == tree.data().id) return true; } @@ -2199,7 +2201,7 @@ fn animationTreeMotionAllowsReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event return in_surface or in_border; } -fn syncAnimationSelectionFrames(file: *fizzy.Internal.File, anim_index: usize) void { +fn syncAnimationSelectionFrames(file: *pixelart.internal.File, anim_index: usize) void { const anim = file.animations.get(anim_index); if (anim.frames.len > 0) { if (file.selected_animation_frame_index >= anim.frames.len) { @@ -2210,7 +2212,7 @@ fn syncAnimationSelectionFrames(file: *fizzy.Internal.File, anim_index: usize) v } } -fn animationIndexInMulti(file: *const fizzy.Internal.File, anim_index: usize) bool { +fn animationIndexInMulti(file: *const pixelart.internal.File, anim_index: usize) bool { for (file.editor.selected_animation_indices.items) |i| { if (i == anim_index) return true; } @@ -2220,7 +2222,7 @@ fn animationIndexInMulti(file: *const fizzy.Internal.File, anim_index: usize) bo /// Keep `selected_animation_indices` consistent with the authoritative single-selection and the /// current animation count. The set may be empty (no animations yet), but if `selected_animation_index` /// is set we guarantee it appears in the set. -fn ensureAnimationSelection(file: *fizzy.Internal.File) void { +fn ensureAnimationSelection(file: *pixelart.internal.File) void { const count = file.animations.len; if (count == 0) { file.editor.selected_animation_indices.clearRetainingCapacity(); @@ -2251,7 +2253,7 @@ fn ensureAnimationSelection(file: *fizzy.Internal.File) void { } } if (!found) { - file.editor.selected_animation_indices.append(fizzy.app.allocator, p) catch return; + file.editor.selected_animation_indices.append(Globals.allocator(), p) catch return; std.sort.pdq(usize, file.editor.selected_animation_indices.items, {}, std.sort.asc(usize)); } } @@ -2263,7 +2265,7 @@ fn ensureAnimationSelection(file: *fizzy.Internal.File) void { /// Apply a modifier-aware click to the animation selection. Returns whether the click should defer /// narrowing until release (Finder-style): plain click on an already-multi-selected row. -fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.dvui.TreeSelection.ClickMode) !bool { +fn applyAnimationClick(file: *pixelart.internal.File, clicked: usize, mode: pixelart.core.dvui.TreeSelection.ClickMode) !bool { const prev_multi = file.editor.selected_animation_indices.items; const was_in_multi = animationIndexInMulti(file, clicked); const was_multi = prev_multi.len > 1; @@ -2271,20 +2273,20 @@ fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.d const defer_narrow = (mode == .replace and was_multi and was_in_multi); var out: std.ArrayList(usize) = .empty; - defer out.deinit(fizzy.app.allocator); + defer out.deinit(Globals.allocator()); if (defer_narrow) { - try out.appendSlice(fizzy.app.allocator, prev_multi); + try out.appendSlice(Globals.allocator(), prev_multi); std.sort.pdq(usize, out.items, {}, std.sort.asc(usize)); file.editor.selected_animation_indices.clearRetainingCapacity(); - try file.editor.selected_animation_indices.appendSlice(fizzy.app.allocator, out.items); + try file.editor.selected_animation_indices.appendSlice(Globals.allocator(), out.items); file.selected_animation_index = clicked; syncAnimationSelectionFrames(file, clicked); return true; } - const res = try fizzy.dvui.TreeSelection.applyClickUsize( - fizzy.app.allocator, + const res = try pixelart.core.dvui.TreeSelection.applyClickUsize( + Globals.allocator(), prev_multi, file.selected_animation_index, file.editor.animation_selection_anchor, @@ -2295,16 +2297,16 @@ fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.d ); file.editor.selected_animation_indices.clearRetainingCapacity(); - try file.editor.selected_animation_indices.appendSlice(fizzy.app.allocator, out.items); + try file.editor.selected_animation_indices.appendSlice(Globals.allocator(), out.items); file.editor.animation_selection_anchor = res.anchor; file.selected_animation_index = res.primary; if (res.primary) |p| syncAnimationSelectionFrames(file, p); return false; } -fn narrowAnimationSelectionTo(file: *fizzy.Internal.File, clicked: usize) void { +fn narrowAnimationSelectionTo(file: *pixelart.internal.File, clicked: usize) void { file.editor.selected_animation_indices.clearRetainingCapacity(); - file.editor.selected_animation_indices.append(fizzy.app.allocator, clicked) catch return; + file.editor.selected_animation_indices.append(Globals.allocator(), clicked) catch return; file.editor.animation_selection_anchor = clicked; file.selected_animation_index = clicked; syncAnimationSelectionFrames(file, clicked); @@ -2312,7 +2314,7 @@ fn narrowAnimationSelectionTo(file: *fizzy.Internal.File, clicked: usize) void { /// Populate `out` with the branch-ids of every selected animation row (primary first), for /// `TreeWidget.dragStartMulti`. Returns a slice into `out` with just the written entries. -fn buildAnimationMultiDragIds(file: *const fizzy.Internal.File, hits: []const AnimationRowHit, out: []usize) []usize { +fn buildAnimationMultiDragIds(file: *const pixelart.internal.File, hits: []const AnimationRowHit, out: []usize) []usize { var len: usize = 0; const primary = file.selected_animation_index; if (primary) |p| { @@ -2341,7 +2343,7 @@ fn buildAnimationMultiDragIds(file: *const fizzy.Internal.File, hits: []const An return out[0..len]; } -fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget, file: *fizzy.Internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void { +fn processAnimationTreePointerEvents(_: *Sprites, tree: *pixelart.core.dvui.TreeWidget, file: *pixelart.internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void { if (!tree.init_options.enable_reordering) return; for (dvui.events()) |*e| { @@ -2367,7 +2369,7 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget, animationTreeClearGestureKeysOnly(file); dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod); + const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod); const narrow_on_release = applyAnimationClick(file, h.anim_index, mode) catch blk: { dvui.log.err("Failed to apply animation click", .{}); break :blk false; @@ -2489,11 +2491,11 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget, } const FrameSort = struct { - pub fn asc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool { + pub fn asc(_: void, a: pixelart.internal.Animation.Frame, b: pixelart.internal.Animation.Frame) bool { return a.sprite_index < b.sprite_index; } - pub fn desc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool { + pub fn desc(_: void, a: pixelart.internal.Animation.Frame, b: pixelart.internal.Animation.Frame) bool { return a.sprite_index > b.sprite_index; } }; diff --git a/src/plugins/pixelart/explorer/tools.zig b/src/plugins/pixelart/src/explorer/tools.zig similarity index 90% rename from src/plugins/pixelart/explorer/tools.zig rename to src/plugins/pixelart/src/explorer/tools.zig index d26445b1..904b1a32 100644 --- a/src/plugins/pixelart/explorer/tools.zig +++ b/src/plugins/pixelart/src/explorer/tools.zig @@ -1,9 +1,10 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const icons = @import("icons"); const assets = @import("assets"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; const Tools = @This(); @@ -68,10 +69,10 @@ pub fn draw(self: *Tools) !void { drawLayerControls() catch {}; // Collect layers length to trigger a refit of the panel - const layer_count: usize = if (fizzy.editor.activeFile()) |file| file.layers.len else 0; + const layer_count: usize = if (Globals.state.docs.activeFile(Globals.state.host)) |file| file.layers.len else 0; defer prev_layer_count = layer_count; - var paned = fizzy.dvui.paned(@src(), .{ + var paned = pixelart.core.dvui.paned(@src(), .{ .direction = .vertical, .collapsed_size = 0, .handle_size = 10, @@ -81,7 +82,7 @@ pub fn draw(self: *Tools) !void { if (paned.dragging) { max_split_ratio = paned.split_ratio.*; - fizzy.pixelart.layers_ratio = paned.split_ratio.*; + Globals.state.layers_ratio = paned.split_ratio.*; } if (paned.showFirst()) { @@ -97,7 +98,7 @@ pub fn draw(self: *Tools) !void { const autofit = !paned.dragging and !paned.collapsed_state and !paned.animating; // Refit must be done between showFirst and showSecond - if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !fizzy.pixelart.pinned_palettes) { + if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !Globals.state.pinned_palettes) { if (dvui.firstFrame(paned.data().id) and layer_count == 0) paned.split_ratio.* = 0.0; @@ -108,7 +109,7 @@ pub fn draw(self: *Tools) !void { // next frame when min sizes are valid. if (dvui.firstFrame(paned.data().id) and layer_count > 0) { paned.split_ratio.* = 0.01; - //fizzy.pixelart.layers_ratio = paned.split_ratio.*; + //Globals.state.layers_ratio = paned.split_ratio.*; } else { const ratio = paned.getFirstFittedRatio( .{ @@ -129,9 +130,9 @@ pub fn draw(self: *Tools) !void { if (layer_count == 0) paned.split_ratio.* = 0.0 else - paned.split_ratio.* = fizzy.pixelart.layers_ratio; + paned.split_ratio.* = Globals.state.layers_ratio; - fizzy.pixelart.layers_ratio = paned.split_ratio.*; + Globals.state.layers_ratio = paned.split_ratio.*; } } @@ -159,28 +160,28 @@ pub fn drawTools() !void { .padding = .{ .h = 10.0, .w = 4.0, .x = 4.0, .y = 4.0 }, }); defer toolbox.deinit(); - for (0..std.meta.fields(fizzy.Editor.Tools.Tool).len) |i| { - const tool: fizzy.Editor.Tools.Tool = @enumFromInt(i); + for (0..std.meta.fields(pixelart.Tools.Tool).len) |i| { + const tool: pixelart.Tools.Tool = @enumFromInt(i); const id_extra = i; - const selected = fizzy.pixelart.tools.current == tool; + const selected = Globals.state.tools.current == tool; var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { + if (Globals.state.colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(i); } - const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) { - .pixel => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default], - .box => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default], - .color => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default], + const selection_sprite = switch (Globals.state.tools.selection_mode) { + .pixel => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_default], + .box => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_default], + .color => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_default], }; const sprite = switch (tool) { - .pointer => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.cursor_default], - .pencil => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pencil_default], - .eraser => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.eraser_default], - .bucket => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.bucket_default], + .pointer => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.cursor_default], + .pencil => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pencil_default], + .eraser => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.eraser_default], + .bucket => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.bucket_default], .selection => selection_sprite, }; var button: dvui.ButtonWidget = undefined; @@ -204,13 +205,13 @@ pub fn drawTools() !void { }); defer button.deinit(); - fizzy.pixelart.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {}; + Globals.state.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {}; if (button.hovered()) { button.data().options.color_border = color; } - const size: dvui.Size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; + const size: dvui.Size = dvui.imageSize(Globals.state.host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; const uv = dvui.Rect{ .x = @as(f32, @floatFromInt(sprite.source[0])) / size.w, @@ -232,7 +233,7 @@ pub fn drawTools() !void { rs.r.w = width; rs.r.h = height; - dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{ + dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{ .uv = uv, .fade = 0.0, }) catch { @@ -240,7 +241,7 @@ pub fn drawTools() !void { }; if (button.clicked()) { - fizzy.pixelart.tools.set(tool); + Globals.state.tools.set(tool); } } } @@ -253,7 +254,7 @@ pub fn drawLayerControls() !void { defer box.deinit(); dvui.labelNoFmt(@src(), "LAYERS", .{}, .{ .font = dvui.Font.theme(.heading), .gravity_y = 0.5 }); - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .background = false, @@ -402,7 +403,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { }); defer vbox.deinit(); - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { layer_rename_hit_te_id = null; layer_rename_hit_rect = null; file.editor.layer_drag_preview_removed = null; @@ -424,7 +425,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { const vertical_scroll = file.editor.layers_scroll_info.offset(.vertical); - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ + var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ .expand = .horizontal, .background = false, }); @@ -438,7 +439,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { if (removed_layer_indices_len > 0) { const sources = removed_layer_indices_buf[0..removed_layer_indices_len]; - const prev_order = try fizzy.app.allocator.alloc(u64, file.layers.len); + const prev_order = try Globals.allocator().alloc(u64, file.layers.len); for (file.layers.items(.id), 0..) |id, i| { prev_order[i] = id; } @@ -455,8 +456,8 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { } // Snapshot moved layers before any removal so indices stay valid. - var moved = try fizzy.app.allocator.alloc(fizzy.Internal.Layer, sources.len); - defer fizzy.app.allocator.free(moved); + var moved = try Globals.allocator().alloc(pixelart.internal.Layer, sources.len); + defer Globals.allocator().free(moved); for (sources, 0..) |s, i| { moved[i] = file.layers.get(s); } @@ -468,11 +469,11 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { file.layers.orderedRemove(sources[ri]); } - const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); + const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); const target = @min(target_raw, file.layers.len); for (moved, 0..) |layer, i| { - file.layers.insert(fizzy.app.allocator, target + i, layer) catch { + file.layers.insert(Globals.allocator(), target + i, layer) catch { dvui.log.err("Failed to insert layer", .{}); }; } @@ -487,7 +488,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { // After a group move the moved rows become contiguous; resync multi-selection to reflect that. file.editor.selected_layer_indices.clearRetainingCapacity(); for (0..moved.len) |i| { - file.editor.selected_layer_indices.append(fizzy.app.allocator, target + i) catch { + file.editor.selected_layer_indices.append(Globals.allocator(), target + i) catch { dvui.log.err("Failed to update layer selection", .{}); }; } @@ -505,7 +506,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { dvui.log.err("Failed to append history", .{}); }; } else { - fizzy.app.allocator.free(prev_order); + Globals.allocator().free(prev_order); } insert_before_index = null; @@ -539,7 +540,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { const font = if (visible) dvui.Font.theme(.body) else dvui.Font.theme(.body).withStyle(.italic); var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { + if (Globals.state.colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(layer_id)); } @@ -589,7 +590,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { if (file.editor.isolate_layer) { if (file.peek_layer_index) |peek_layer_index| { min_layer_index = peek_layer_index; - } else if (!fizzy.pixelart.tools_pane.layersHovered()) { + } else if (!Globals.state.tools_pane.layersHovered()) { min_layer_index = file.selected_layer_index; } } @@ -719,13 +720,13 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { file.history.append(.{ .layer_name = .{ .index = layer_index, - .name = try fizzy.app.allocator.dupe(u8, file.layers.items(.name)[layer_index]), + .name = try Globals.allocator().dupe(u8, file.layers.items(.name)[layer_index]), }, }) catch { dvui.log.err("Failed to append history", .{}); }; - fizzy.app.allocator.free(file.layers.items(.name)[layer_index]); - file.layers.items(.name)[layer_index] = try fizzy.app.allocator.dupe(u8, te.getText()); + Globals.allocator().free(file.layers.items(.name)[layer_index]); + file.layers.items(.name)[layer_index] = try Globals.allocator().dupe(u8, te.getText()); } if (te.enter_pressed) { file.selected_layer_index = layer_index; @@ -917,13 +918,13 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { // Only draw shadow if the scroll bar has been scrolled some if (vertical_scroll > 0.0) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); + pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); if (file.editor.layers_scroll_info.virtual_size.h > file.editor.layers_scroll_info.viewport.h + 1 and vertical_scroll < file.editor.layers_scroll_info.scrollMax(.vertical)) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); + pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); } - if (fizzy.dvui.hovered(vbox.data())) { + if (pixelart.core.dvui.hovered(vbox.data())) { const mp = dvui.currentWindow().mouse_pt; if (tools.layers_scroll_viewport_rect) |vr| { if (!vr.contains(mp)) return null; @@ -945,8 +946,8 @@ pub fn drawColors() !void { }); defer hbox.deinit(); - const primary: dvui.Color = .{ .r = fizzy.pixelart.colors.primary[0], .g = fizzy.pixelart.colors.primary[1], .b = fizzy.pixelart.colors.primary[2], .a = fizzy.pixelart.colors.primary[3] }; - const secondary: dvui.Color = .{ .r = fizzy.pixelart.colors.secondary[0], .g = fizzy.pixelart.colors.secondary[1], .b = fizzy.pixelart.colors.secondary[2], .a = fizzy.pixelart.colors.secondary[3] }; + const primary: dvui.Color = .{ .r = Globals.state.colors.primary[0], .g = Globals.state.colors.primary[1], .b = Globals.state.colors.primary[2], .a = Globals.state.colors.primary[3] }; + const secondary: dvui.Color = .{ .r = Globals.state.colors.secondary[0], .g = Globals.state.colors.secondary[1], .b = Globals.state.colors.secondary[2], .a = Globals.state.colors.secondary[3] }; const button_opts: dvui.Options = .{ .expand = .both, @@ -978,7 +979,7 @@ pub fn drawColors() !void { primary_button.init(@src(), .{}, button_opts); defer primary_button.deinit(); - try drawColorPicker(primary_button.data().rectScale().r, &fizzy.pixelart.colors.primary, 0); + try drawColorPicker(primary_button.data().rectScale().r, &Globals.state.colors.primary, 0); primary_button.processEvents(); primary_button.drawBackground(); @@ -991,7 +992,7 @@ pub fn drawColors() !void { secondary_button.init(@src(), .{}, button_opts.override(secondary_overrider)); defer secondary_button.deinit(); - try drawColorPicker(secondary_button.data().rectScale().r, &fizzy.pixelart.colors.secondary, 1); + try drawColorPicker(secondary_button.data().rectScale().r, &Globals.state.colors.secondary, 1); secondary_button.processEvents(); secondary_button.drawBackground(); @@ -1000,7 +1001,7 @@ pub fn drawColors() !void { } if (clicked) { - std.mem.swap([4]u8, &fizzy.pixelart.colors.primary, &fizzy.pixelart.colors.secondary); + std.mem.swap([4]u8, &Globals.state.colors.primary, &Globals.state.colors.secondary); } } @@ -1069,9 +1070,9 @@ pub fn drawPaletteControls() !void { .corner_radius = dvui.Rect.all(1000), }, .rotation = std.math.pi * 0.25, - .style = if (fizzy.pixelart.pinned_palettes) .highlight else .control, + .style = if (Globals.state.pinned_palettes) .highlight else .control, })) { - fizzy.pixelart.pinned_palettes = !fizzy.pixelart.pinned_palettes; + Globals.state.pinned_palettes = !Globals.state.pinned_palettes; } } @@ -1103,7 +1104,7 @@ pub fn drawPalettes() !void { .gravity_x = 1.0, }); - if (fizzy.pixelart.colors.palette) |*palette| { + if (Globals.state.colors.palette) |*palette| { dvui.label(@src(), "{s}", .{palette.name}, .{ .margin = .all(0), .padding = .all(0) }); } else { dvui.label(@src(), "Palette Search", .{}, .{ .margin = .all(0), .padding = .all(0) }); @@ -1133,7 +1134,7 @@ pub fn drawPalettes() !void { const ext = std.fs.path.extension(entry.name); if (std.mem.eql(u8, ext, ".hex")) { if (dropdown.addChoiceLabel(entry.name)) { - fizzy.pixelart.colors.palette = fizzy.Internal.Palette.loadFromBytes(fizzy.app.allocator, entry.name, data) catch |err| { + Globals.state.colors.palette = pixelart.internal.Palette.loadFromBytes(Globals.allocator(), entry.name, data) catch |err| { dvui.log.err("Failed to load palette: {s}", .{@errorName(err)}); return error.FailedToLoadPalette; }; @@ -1157,12 +1158,12 @@ pub fn drawPalettes() !void { } { - if (fizzy.pixelart.colors.palette) |*palette| { + if (Globals.state.colors.palette) |*palette| { var flex_box = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .expand = .horizontal, .max_size_content = .{ - .w = fizzy.pixelart.host.explorerRect().w - 20 * dvui.currentWindow().natural_scale, - .h = fizzy.pixelart.host.explorerRect().h - 20 * dvui.currentWindow().natural_scale, + .w = Globals.state.host.explorerRect().w - 20 * dvui.currentWindow().natural_scale, + .h = Globals.state.host.explorerRect().h - 20 * dvui.currentWindow().natural_scale, }, }); @@ -1244,9 +1245,9 @@ pub fn drawPalettes() !void { switch (evt) { .mouse => |mouse_evt| { if (mouse_evt.button.pointer() or mouse_evt.button.touch()) { - @memcpy(&fizzy.pixelart.colors.primary, &color); + @memcpy(&Globals.state.colors.primary, &color); } else if (mouse_evt.button == .right) { - @memcpy(&fizzy.pixelart.colors.secondary, &color); + @memcpy(&Globals.state.colors.secondary, &color); } }, else => {}, @@ -1267,7 +1268,7 @@ pub fn drawPalettes() !void { } fn searchPalettes(dropdown: *dvui.DropdownWidget) !void { const io = dvui.io; - const palette_folder = fizzy.pixelart.host.paletteFolder() orelse return; + const palette_folder = Globals.state.host.paletteFolder() orelse return; var dir_opt = std.Io.Dir.cwd().openDir(io, palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null; if (dir_opt) |*dir| { defer dir.close(io); @@ -1280,10 +1281,10 @@ fn searchPalettes(dropdown: *dvui.DropdownWidget) !void { if (dropdown.addChoiceLabel(label)) { const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ palette_folder, entry.name }); - if (fizzy.pixelart.colors.palette) |*palette| + if (Globals.state.colors.palette) |*palette| palette.deinit(); - fizzy.pixelart.colors.palette = fizzy.Internal.Palette.loadFromFile(fizzy.app.allocator, abs_path) catch |err| { + Globals.state.colors.palette = pixelart.internal.Palette.loadFromFile(Globals.allocator(), abs_path) catch |err| { dvui.log.err("Failed to load palette: {s}", .{@errorName(err)}); return error.FailedToLoadPalette; }; @@ -1317,12 +1318,12 @@ fn pointerReleaseInRectWithoutSelectionModifier(r: dvui.Rect.Physical) bool { return false; } -fn layerGestureMatches(file: *const fizzy.Internal.File) bool { +fn layerGestureMatches(file: *const pixelart.internal.File) bool { return layer_row_gesture != null and layer_row_gesture.?.file_id == file.id; } /// True if `layer_index` is present in the multi-selection set (the primary index is always implicitly selected). -fn layerIndexInMulti(file: *const fizzy.Internal.File, layer_index: usize) bool { +fn layerIndexInMulti(file: *const pixelart.internal.File, layer_index: usize) bool { for (file.editor.selected_layer_indices.items) |i| { if (i == layer_index) return true; } @@ -1331,7 +1332,7 @@ fn layerIndexInMulti(file: *const fizzy.Internal.File, layer_index: usize) bool /// Sync the multi-selection list with `file.selected_layer_index` and the current layer count. /// The primary must always be present; stale / out-of-range entries from deletions are dropped. -fn ensureLayerSelection(file: *fizzy.Internal.File) void { +fn ensureLayerSelection(file: *pixelart.internal.File) void { var sel = &file.editor.selected_layer_indices; // Drop out-of-range entries. @@ -1358,7 +1359,7 @@ fn ensureLayerSelection(file: *fizzy.Internal.File) void { } } if (!has_primary and file.layers.len > 0) { - sel.append(fizzy.app.allocator, file.selected_layer_index) catch return; + sel.append(Globals.allocator(), file.selected_layer_index) catch return; std.sort.pdq(usize, sel.items, {}, std.sort.asc(usize)); } } @@ -1373,9 +1374,9 @@ const LayerClickApplied = struct { }; fn applyLayerClick( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, clicked: usize, - mode: fizzy.dvui.TreeSelection.ClickMode, + mode: pixelart.core.dvui.TreeSelection.ClickMode, ) LayerClickApplied { const count_before = file.editor.selected_layer_indices.items.len; @@ -1386,10 +1387,10 @@ fn applyLayerClick( } var tmp: std.ArrayList(usize) = .empty; - defer tmp.deinit(fizzy.app.allocator); + defer tmp.deinit(Globals.allocator()); - const res = fizzy.dvui.TreeSelection.applyClickUsize( - fizzy.app.allocator, + const res = pixelart.core.dvui.TreeSelection.applyClickUsize( + Globals.allocator(), file.editor.selected_layer_indices.items, file.selected_layer_index, file.editor.layer_selection_anchor, @@ -1400,7 +1401,7 @@ fn applyLayerClick( ) catch return .{ .primary = file.selected_layer_index, .narrow_on_release = false }; file.editor.selected_layer_indices.clearRetainingCapacity(); - file.editor.selected_layer_indices.appendSlice(fizzy.app.allocator, tmp.items) catch {}; + file.editor.selected_layer_indices.appendSlice(Globals.allocator(), tmp.items) catch {}; const new_primary = res.primary orelse clicked; file.selected_layer_index = new_primary; @@ -1411,9 +1412,9 @@ fn applyLayerClick( /// Narrow the multi-selection to just `clicked` — used when the user performed a plain press on an /// already-multi-selected row and released without dragging. Mirrors Finder-style behavior. -fn narrowLayerSelectionTo(file: *fizzy.Internal.File, clicked: usize) void { +fn narrowLayerSelectionTo(file: *pixelart.internal.File, clicked: usize) void { file.editor.selected_layer_indices.clearRetainingCapacity(); - file.editor.selected_layer_indices.append(fizzy.app.allocator, clicked) catch {}; + file.editor.selected_layer_indices.append(Globals.allocator(), clicked) catch {}; file.selected_layer_index = clicked; file.editor.layer_selection_anchor = clicked; } @@ -1423,7 +1424,7 @@ fn narrowLayerSelectionTo(file: *fizzy.Internal.File, clicked: usize) void { /// in the row-hits buffer are included (out-of-viewport selections are allowed because hits are /// populated for every drawn row, not just hovered ones). fn buildLayerMultiDragIds( - file: *const fizzy.Internal.File, + file: *const pixelart.internal.File, hits: []const LayerRowHit, out: []usize, ) usize { @@ -1443,12 +1444,12 @@ fn buildLayerMultiDragIds( } /// Clear in-flight gesture only (no `dragEnd`). Used before arming a new row press. -fn layerTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void { +fn layerTreeClearGestureKeysOnly(_: *const pixelart.internal.File) void { layer_row_gesture = null; } /// Clear gesture and global `Dragging` (stale prestart/drag from other widgets). -fn layerTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void { +fn layerTreeResetRowPointerGesture(_: *const pixelart.internal.File) void { dvui.dragEnd(); layer_row_gesture = null; } @@ -1475,7 +1476,7 @@ fn layerPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Rect.P return true; } -fn layerTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn layerTreePointerInTreeSurface(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { if (floating_win != dvui.subwindowCurrentId()) return false; const tr = tree.data().borderRectScale().r; if (!tr.contains(p)) return false; @@ -1483,14 +1484,14 @@ fn layerTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Phy return true; } -fn layerTreePointerInTreeBorder(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn layerTreePointerInTreeBorder(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { if (floating_win != dvui.subwindowCurrentId()) return false; return tree.data().borderRectScale().r.contains(p); } /// While another widget holds capture, `target_widgetId` may not be the tree. Allow starting a reorder drag /// when the pointer is over the tree border (scroll clip can disagree with visible row geometry). -fn layerTreeMotionAllowsLayerReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event) bool { +fn layerTreeMotionAllowsLayerReorder(tree: *pixelart.core.dvui.TreeWidget, e: *dvui.Event) bool { if (e.target_widgetId) |fwid| { if (fwid == tree.data().id) return true; } @@ -1504,7 +1505,7 @@ fn layerTreeMotionAllowsLayerReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Even /// One pass over `events()` in frame order: press → motion → release. /// Runs after layer rows (and rename `textEntry`) are built so geometry and `e.handled` reflect z-order. -fn processLayerTreePointerEvents(tree: *fizzy.dvui.TreeWidget, file: *fizzy.Internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void { +fn processLayerTreePointerEvents(tree: *pixelart.core.dvui.TreeWidget, file: *pixelart.internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void { if (!tree.init_options.enable_reordering) return; for (dvui.events()) |*e| { @@ -1530,7 +1531,7 @@ fn processLayerTreePointerEvents(tree: *fizzy.dvui.TreeWidget, file: *fizzy.Inte layerTreeClearGestureKeysOnly(file); dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod); + const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod); const applied = applyLayerClick(file, h.layer_index, mode); layer_row_gesture = .{ diff --git a/src/plugins/pixelart/internal/Animation.zig b/src/plugins/pixelart/src/internal/Animation.zig similarity index 100% rename from src/plugins/pixelart/internal/Animation.zig rename to src/plugins/pixelart/src/internal/Animation.zig diff --git a/src/plugins/pixelart/internal/Atlas.zig b/src/plugins/pixelart/src/internal/Atlas.zig similarity index 76% rename from src/plugins/pixelart/internal/Atlas.zig rename to src/plugins/pixelart/src/internal/Atlas.zig index bf8a25b6..03fb0c88 100644 --- a/src/plugins/pixelart/internal/Atlas.zig +++ b/src/plugins/pixelart/src/internal/Atlas.zig @@ -1,15 +1,16 @@ const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const Atlas = @This(); const ExternalAtlas = @import("../Atlas.zig"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; const alpha_checkerboard_count: u32 = 8; /// The packed atlas texture source: dvui.ImageSource, -canvas: fizzy.dvui.CanvasWidget = .{}, +canvas: pixelart.core.dvui.CanvasWidget = .{}, /// Checkerboard tile for the project-tab atlas preview (not tied to open files). checkerboard_tile: ?dvui.Texture = null, @@ -22,11 +23,11 @@ data: ExternalAtlas, pub fn initCheckerboardTile(atlas: *Atlas) void { deinitCheckerboardTile(atlas); - atlas.checkerboard_tile = fizzy.image.checkerboardTile( + atlas.checkerboard_tile = pixelart.image.checkerboardTile( alpha_checkerboard_count, alpha_checkerboard_count, - fizzy.pixelart.settings.checker_color_even, - fizzy.pixelart.settings.checker_color_odd, + Globals.state.settings.checker_color_even, + Globals.state.settings.checker_color_odd, ); } @@ -48,23 +49,23 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { // below writes through `std.Io.Dir.cwd()` which requires `posix.AT` (not // available on `wasm32-freestanding`). if (comptime @import("builtin").target.cpu.arch == .wasm32) { - const allocator = fizzy.pixelart.host.arena(); + const allocator = Globals.state.host.arena(); switch (selector) { .source => { const ext = std.fs.path.extension(path); var out = std.Io.Writer.Allocating.init(allocator); errdefer out.deinit(); if (std.mem.eql(u8, ext, ".png")) { - try fizzy.image.writePngToWriter(atlas.source, &out.writer, 72); + try pixelart.image.writePngToWriter(atlas.source, &out.writer, 72); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - try fizzy.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72); + try pixelart.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72); } else { std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{}); return error.InvalidExtension; } const bytes = try out.toOwnedSlice(); defer allocator.free(bytes); - try @import("../../../editor/WebFileIo.zig").downloadBytes(path, bytes); + try @import("../../../../editor/WebFileIo.zig").downloadBytes(path, bytes); }, .data => { if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) { @@ -74,7 +75,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { const options: std.json.Stringify.Options = .{}; const output = try std.json.Stringify.valueAlloc(allocator, atlas.data, options); defer allocator.free(output); - try @import("../../../editor/WebFileIo.zig").downloadBytes(path, output); + try @import("../../../../editor/WebFileIo.zig").downloadBytes(path, output); }, } return; @@ -83,12 +84,12 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { switch (selector) { .source => { const ext = std.fs.path.extension(path); - const write_path = std.fmt.allocPrintSentinel(fizzy.pixelart.host.arena(), "{s}", .{path}, 0) catch unreachable; + const write_path = std.fmt.allocPrintSentinel(Globals.state.host.arena(), "{s}", .{path}, 0) catch unreachable; if (std.mem.eql(u8, ext, ".png")) { - try fizzy.image.writeToPng(atlas.source, write_path); + try pixelart.image.writeToPng(atlas.source, write_path); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - try fizzy.image.writeToJpg(atlas.source, write_path); + try pixelart.image.writeToJpg(atlas.source, write_path); } else { std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{}); return error.InvalidExtension; @@ -101,7 +102,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { } const options: std.json.Stringify.Options = .{}; - const output = try std.json.Stringify.valueAlloc(fizzy.pixelart.host.arena(), atlas.data, options); + const output = try std.json.Stringify.valueAlloc(Globals.state.host.arena(), atlas.data, options); std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = output }) catch return error.CouldNotWriteAtlasData; }, diff --git a/src/plugins/pixelart/internal/Buffers.zig b/src/plugins/pixelart/src/internal/Buffers.zig similarity index 76% rename from src/plugins/pixelart/internal/Buffers.zig rename to src/plugins/pixelart/src/internal/Buffers.zig index 968c63ca..b498e92f 100644 --- a/src/plugins/pixelart/internal/Buffers.zig +++ b/src/plugins/pixelart/src/internal/Buffers.zig @@ -1,7 +1,8 @@ const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); const History = @import("History.zig"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; const Buffers = @This(); stroke: Stroke, @@ -12,7 +13,7 @@ pub const Stroke = struct { //values: std.ArrayList([4]u8), pixels: std.AutoHashMap(usize, [4]u8), - //canvas: fizzy.Internal.file.gui.canvas = .primary, + //canvas: pixelart.file.gui.canvas = .primary, pub fn init(allocator: std.mem.Allocator) Stroke { return .{ @@ -24,9 +25,9 @@ pub const Stroke = struct { pub fn append(stroke: *Stroke, index: usize, value: [4]u8) !void { const ptr = try stroke.pixels.getOrPut(index); - if (fizzy.perf.record) { - fizzy.perf.stroke_append_calls += 1; - if (!ptr.found_existing) fizzy.perf.stroke_append_new_keys += 1; + if (pixelart.perf.record) { + pixelart.perf.stroke_append_calls += 1; + if (!ptr.found_existing) pixelart.perf.stroke_append_new_keys += 1; } if (!ptr.found_existing) ptr.value_ptr.* = value; @@ -48,9 +49,9 @@ pub const Stroke = struct { /// Like `append` but the map must already have capacity for new keys (see `clearAndReserveCapacity`). pub fn appendAssumeCapacity(stroke: *Stroke, index: usize, value: [4]u8) void { const gop = stroke.pixels.getOrPutAssumeCapacity(index); - if (fizzy.perf.record) { - fizzy.perf.stroke_append_calls += 1; - if (!gop.found_existing) fizzy.perf.stroke_append_new_keys += 1; + if (pixelart.perf.record) { + pixelart.perf.stroke_append_calls += 1; + if (!gop.found_existing) pixelart.perf.stroke_append_new_keys += 1; } if (!gop.found_existing) gop.value_ptr.* = value; @@ -67,14 +68,14 @@ pub const Stroke = struct { } pub fn toChange(stroke: *Stroke, layer_id: u64) !History.Change { - const t0: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t0: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0; const n = stroke.pixels.count(); // Exact-size allocations; transform accept pre-reserves the hash map to avoid rehash during fills. - var indices = fizzy.app.allocator.alloc(usize, n) catch return error.MemoryAllocationFailed; - errdefer fizzy.app.allocator.free(indices); - var values = fizzy.app.allocator.alloc([4]u8, n) catch return error.MemoryAllocationFailed; - errdefer fizzy.app.allocator.free(values); + var indices = Globals.allocator().alloc(usize, n) catch return error.MemoryAllocationFailed; + errdefer Globals.allocator().free(indices); + var values = Globals.allocator().alloc([4]u8, n) catch return error.MemoryAllocationFailed; + errdefer Globals.allocator().free(values); var it = stroke.pixels.iterator(); @@ -87,10 +88,10 @@ pub const Stroke = struct { stroke.pixels.clearAndFree(); - if (fizzy.perf.record) { - fizzy.perf.stroke_to_change_ns +%= @intCast(fizzy.perf.nanoTimestamp() - t0); - fizzy.perf.stroke_to_change_calls += 1; - fizzy.perf.stroke_to_change_pixels_out +%= n; + if (pixelart.perf.record) { + pixelart.perf.stroke_to_change_ns +%= @intCast(pixelart.perf.nanoTimestamp() - t0); + pixelart.perf.stroke_to_change_calls += 1; + pixelart.perf.stroke_to_change_pixels_out +%= n; } return .{ .pixels = .{ diff --git a/src/plugins/pixelart/internal/File.zig b/src/plugins/pixelart/src/internal/File.zig similarity index 86% rename from src/plugins/pixelart/internal/File.zig rename to src/plugins/pixelart/src/internal/File.zig index d72ef320..f9f9e654 100644 --- a/src/plugins/pixelart/internal/File.zig +++ b/src/plugins/pixelart/src/internal/File.zig @@ -1,16 +1,18 @@ const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); -const pixelart = @import("../plugin.zig"); const zip = @import("zip"); const dvui = @import("dvui"); -const Editor = fizzy.Editor; +const Transform = @import("../Transform.zig"); +const Tools = @import("../Tools.zig"); const File = @This(); const Layer = @import("Layer.zig"); const Sprite = @import("Sprite.zig"); const Animation = @import("Animation.zig"); +const pixelart = @import("../../pixelart.zig"); +const plugin = @import("../plugin.zig"); +const Globals = pixelart.Globals; const alpha_checkerboard_count: u32 = 8; @@ -62,12 +64,12 @@ pub const EditorData = struct { /// Set by the shell each frame before draw: request the canvas recenter this frame /// (true while a workspace/panel pane is mid-animation). Read by the document render. center: bool = false, - canvas: fizzy.dvui.CanvasWidget = .{}, + canvas: pixelart.core.dvui.CanvasWidget = .{}, layers_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, sprites_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, animations_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, animations_scroll_to_index: ?usize = null, - transform: ?Editor.Transform = null, + transform: ?Transform = null, playing: bool = false, saving: bool = false, @@ -184,7 +186,7 @@ pub const EditorData = struct { was_saving: bool = false, /// Set from any thread in `setSaving(false)`; main-thread `tickSaveDoneFlash` arms the flash. save_complete: std.atomic.Value(bool) = .init(false), - /// Monotonic deadline (`fizzy.perf.nanoTimestamp`): save-complete affordance in tab / tree. + /// Monotonic deadline (`pixelart.perf.nanoTimestamp`): save-complete affordance in tab / tree. save_complete_show_duration: ?i128 = null, /// Set with `save_complete_show_duration` when the flash arms (`isSaving` → false). save_complete_show_start: ?i128 = null, @@ -200,40 +202,40 @@ pub const InitOptions = struct { row_height: u32, }; -pub fn init(path: []const u8, options: InitOptions) !fizzy.Internal.File { - var internal: fizzy.Internal.File = .{ - .id = fizzy.editor.newFileID(), - .path = try fizzy.app.allocator.dupe(u8, path), +pub fn init(path: []const u8, options: InitOptions) !pixelart.internal.File { + var internal: pixelart.internal.File = .{ + .id = Globals.state.host.allocDocId(), + .path = try Globals.allocator().dupe(u8, path), .columns = options.columns, .rows = options.rows, .column_width = options.column_width, .row_height = options.row_height, - .history = fizzy.Internal.File.History.init(fizzy.app.allocator), - .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator), + .history = pixelart.internal.File.History.init(Globals.allocator()), + .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), }; // Initialize editor layers and selected sprites internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount()); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.spriteCount()); - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height()); + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.width() * internal.height()); // Create a layer-sized checkerboard pattern for selection tools for (0..internal.width() * internal.height()) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + const value = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); internal.editor.checkerboard.setValue(i, value); } { // Create a single layer for the file - const layer: fizzy.Internal.Layer = try .init(internal.newLayerID(), "Layer", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.layers.append(fizzy.app.allocator, layer) catch return error.LayerCreateError; + const layer: Layer = try .init(internal.newLayerID(), "Layer", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); + internal.layers.append(Globals.allocator(), layer) catch return error.LayerCreateError; } // Initialize sprites for (0..internal.spriteCount()) |_| { - internal.sprites.append(fizzy.app.allocator, .{ + internal.sprites.append(Globals.allocator(), .{ .origin = .{ 0.0, 0.0 }, }) catch return error.FileLoadError; } @@ -260,11 +262,11 @@ pub fn checkerboardTileTexture(file: *File) ?dvui.Texture { dvui.textureDestroyLater(t); file.editor.checkerboard_tile = null; } - file.editor.checkerboard_tile = fizzy.image.checkerboardTile( + file.editor.checkerboard_tile = pixelart.image.checkerboardTile( want.w, want.h, - fizzy.pixelart.settings.checker_color_even, - fizzy.pixelart.settings.checker_color_odd, + Globals.state.settings.checker_color_even, + Globals.state.settings.checker_color_odd, ); return file.editor.checkerboard_tile; } @@ -292,7 +294,7 @@ pub fn setSaving(file: *File, v: bool) void { } else { // Arm the finish animation immediately so synchronous wasm saves (and any save // that completes between frames) don't leave `save_complete` stuck true. - const now = fizzy.perf.nanoTimestamp(); + const now = pixelart.perf.nanoTimestamp(); file.editor.save_complete_show_start = now; file.editor.save_complete_show_duration = now + save_done_flash_duration_ns; file.editor.save_complete.store(false, .monotonic); @@ -318,7 +320,7 @@ const save_done_flash_duration_ns: i128 = 2 * std.time.ns_per_s; /// Call once per frame from the main thread. Arms save-complete feedback when /// `isSaving()` falls from true to false. pub fn tickSaveDoneFlash(file: *File) void { - const now = fizzy.perf.nanoTimestamp(); + const now = pixelart.perf.nanoTimestamp(); const saving = file.isSaving(); const pending = file.editor.save_complete.swap(false, .monotonic); if (!saving and (pending or file.editor.was_saving) and file.editor.save_complete_show_duration == null) { @@ -349,12 +351,12 @@ pub fn showSaveDoneFlash(file: *const File) bool { return timeSinceSaveComplete(file) != null; } -/// Nanoseconds since save finished (`null` when inactive). Drives [`fizzy.dvui.bubbleSpinner`]'s +/// Nanoseconds since save finished (`null` when inactive). Drives [`pixelart.core.dvui.bubbleSpinner`]'s /// finish animation (sync → pop → check). pub fn timeSinceSaveComplete(file: *const File) ?i128 { const until = file.editor.save_complete_show_duration orelse return null; const st = file.editor.save_complete_show_start orelse return null; - const now = fizzy.perf.nanoTimestamp(); + const now = pixelart.perf.nanoTimestamp(); if (now >= until) return null; return @max(@as(i128, 0), now - st); } @@ -386,7 +388,7 @@ pub fn invalidateActiveLayerTransparencyMaskCache(file: *File) void { pub const layerOrderAfterMove = @import("layer_order.zig").layerOrderAfterMove; /// Load from in-memory bytes (browser file picker). `path` is used for extension detection and display name. -pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File { +pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?pixelart.internal.File { const extension = std.fs.path.extension(path); if (isFlatImageExtension(extension)) { return fromBytesFlatImage(path, file_bytes); @@ -398,7 +400,7 @@ pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File } /// Attempts to load a file from the given path to create a new file -pub fn fromPath(path: []const u8) !?fizzy.Internal.File { +pub fn fromPath(path: []const u8) !?pixelart.internal.File { const extension = std.fs.path.extension(path[0..path.len]); if (isFlatImageExtension(extension)) { const file = fromPathFlatImage(path) catch |err| { @@ -424,23 +426,23 @@ pub fn isFizzyExtension(ext: []const u8) bool { return std.mem.eql(u8, ext, ".fiz") or std.mem.eql(u8, ext, ".pixi"); } -pub fn fromPathFizzy(path: []const u8) !?fizzy.Internal.File { +pub fn fromPathFizzy(path: []const u8) !?pixelart.internal.File { return loadFizzyZip(path, null); } -pub fn fromBytesFizzy(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File { +pub fn fromBytesFizzy(path: []const u8, file_bytes: []const u8) !?pixelart.internal.File { return loadFizzyZip(path, file_bytes); } -fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File { +fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.File { if (!isFizzyExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; const null_terminated_path = if (file_bytes == null) - try fizzy.app.allocator.dupeZ(u8, path) + try Globals.allocator().dupeZ(u8, path) else ""; - defer if (file_bytes == null) fizzy.app.allocator.free(null_terminated_path); + defer if (file_bytes == null) Globals.allocator().free(null_terminated_path); zip_open: { const fizzy_file = if (file_bytes) |bytes| @@ -473,19 +475,19 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File .ignore_unknown_fields = true, }; - var try_parse: ?std.json.Parsed(fizzy.File) = null; - try_parse = std.json.parseFromSlice(fizzy.File, fizzy.app.allocator, content, options) catch null; + var try_parse: ?std.json.Parsed(pixelart.File) = null; + try_parse = std.json.parseFromSlice(pixelart.File, Globals.allocator(), content, options) catch null; - var ext: fizzy.File = if (try_parse) |parsed| parsed.value else undefined; + var ext: pixelart.File = if (try_parse) |parsed| parsed.value else undefined; if (try_parse == null) { // If we are here, we have tried to load the file but hit an issue because the old animation format - if (std.json.parseFromSlice(fizzy.File.FileV3, fizzy.app.allocator, content, options) catch null) |old_file| { + if (std.json.parseFromSlice(pixelart.File.FileV3, Globals.allocator(), content, options) catch null) |old_file| { std.log.info("Loading file v3: {s}", .{path}); - const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len); + const animations = try Globals.allocator().alloc(pixelart.Animation, old_file.value.animations.len); for (animations, old_file.value.animations) |*animation, old_animation| { - animation.name = try fizzy.app.allocator.dupe(u8, old_animation.name); - animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_animation.frames.len); + animation.name = try Globals.allocator().dupe(u8, old_animation.name); + animation.frames = try Globals.allocator().alloc(Animation.Frame, old_animation.frames.len); for (animation.frames, old_animation.frames) |*frame, old_frame| { frame.sprite_index = old_frame; frame.ms = @intFromFloat(1000 / old_animation.fps); @@ -502,12 +504,12 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File .sprites = old_file.value.sprites, .animations = animations, }; - } else if (std.json.parseFromSlice(fizzy.File.FileV2, fizzy.app.allocator, content, options) catch null) |old_file| { + } else if (std.json.parseFromSlice(pixelart.File.FileV2, Globals.allocator(), content, options) catch null) |old_file| { std.log.info("Loading file v2: {s}", .{path}); - const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len); + const animations = try Globals.allocator().alloc(pixelart.Animation, old_file.value.animations.len); for (animations, old_file.value.animations) |*animation, old_animation| { - animation.name = try fizzy.app.allocator.dupe(u8, old_animation.name); - animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_animation.frames.len); + animation.name = try Globals.allocator().dupe(u8, old_animation.name); + animation.frames = try Globals.allocator().alloc(Animation.Frame, old_animation.frames.len); for (animation.frames, old_animation.frames) |*frame, old_frame| { frame.sprite_index = old_frame; frame.ms = @intFromFloat(1000 / old_animation.fps); @@ -524,12 +526,12 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File .sprites = old_file.value.sprites, .animations = animations, }; - } else if (std.json.parseFromSlice(fizzy.File.FileV1, fizzy.app.allocator, content, options) catch null) |old_file| { + } else if (std.json.parseFromSlice(pixelart.File.FileV1, Globals.allocator(), content, options) catch null) |old_file| { std.log.info("Loading file v1: {s}", .{path}); - const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len); + const animations = try Globals.allocator().alloc(pixelart.Animation, old_file.value.animations.len); for (animations, 0..) |*animation, i| { - animation.name = try fizzy.app.allocator.dupe(u8, old_file.value.animations[i].name); - animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_file.value.animations[i].length); + animation.name = try Globals.allocator().dupe(u8, old_file.value.animations[i].name); + animation.frames = try Globals.allocator().alloc(Animation.Frame, old_file.value.animations[i].length); for (animation.frames, 0..old_file.value.animations[i].length) |*frame, j| { frame.sprite_index = old_file.value.animations[i].start + j; frame.ms = @intFromFloat(1000 / old_file.value.animations[i].fps); @@ -553,15 +555,15 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File //defer parsed.deinit(); - var internal: fizzy.Internal.File = .{ - .id = fizzy.editor.newFileID(), - .path = try fizzy.app.allocator.dupe(u8, path), + var internal: pixelart.internal.File = .{ + .id = Globals.state.host.allocDocId(), + .path = try Globals.allocator().dupe(u8, path), .columns = ext.columns, .rows = ext.rows, .column_width = ext.column_width, .row_height = ext.row_height, - .history = fizzy.Internal.File.History.init(fizzy.app.allocator), - .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator), + .history = pixelart.internal.File.History.init(Globals.allocator()), + .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), }; //Initialize editor layers and selected sprites @@ -570,21 +572,21 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount()); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.spriteCount()); - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height()); + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.width() * internal.height()); // Create a layer-sized checkerboard pattern for selection tools for (0..internal.width() * internal.height()) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + const value = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); internal.editor.checkerboard.setValue(i, value); } var set_layer_index: bool = false; for (ext.layers, 0..) |l, i| { - const layer_image_name = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.layer", .{l.name}, 0) catch "Memory Allocation Failed"; - defer fizzy.app.allocator.free(layer_image_name); - const png_image_name = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{l.name}, 0) catch "Memory Allocation Failed"; - defer fizzy.app.allocator.free(png_image_name); + const layer_image_name = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.layer", .{l.name}, 0) catch "Memory Allocation Failed"; + defer Globals.allocator().free(layer_image_name); + const png_image_name = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{l.name}, 0) catch "Memory Allocation Failed"; + defer Globals.allocator().free(png_image_name); var img_buf: ?*anyopaque = null; var img_len: usize = 0; @@ -593,7 +595,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len); const data = img_buf orelse continue; - var new_layer: fizzy.Internal.Layer = try .fromPixelsPMA( + var new_layer: Layer = try .fromPixelsPMA( internal.newLayerID(), l.name, @as([*]dvui.Color.PMA, @ptrCast(@constCast(data)))[0..(internal.width() * internal.height())], @@ -607,7 +609,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File new_layer.setMaskFromTransparency(true); - internal.layers.append(fizzy.app.allocator, new_layer) catch return error.FileLoadError; + internal.layers.append(Globals.allocator(), new_layer) catch return error.FileLoadError; if (l.visible and !set_layer_index) { internal.selected_layer_index = i; @@ -617,7 +619,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len); const data = img_buf orelse continue; - var new_layer: fizzy.Internal.Layer = try .fromImageFileBytes( + var new_layer: Layer = try .fromImageFileBytes( internal.newLayerID(), l.name, @as([*]u8, @ptrCast(data))[0..img_len], @@ -629,7 +631,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File new_layer.setMaskFromTransparency(true); - internal.layers.append(fizzy.app.allocator, new_layer) catch return error.FileLoadError; + internal.layers.append(Globals.allocator(), new_layer) catch return error.FileLoadError; if (l.visible and !set_layer_index) { internal.selected_layer_index = i; @@ -643,21 +645,21 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File for (0..internal.spriteCount()) |sprite_index| { if (sprite_index >= ext.sprites.len) { - internal.sprites.append(fizzy.app.allocator, .{ + internal.sprites.append(Globals.allocator(), .{ .origin = .{ 0, 0 }, }) catch return error.FileLoadError; } else { - internal.sprites.append(fizzy.app.allocator, .{ + internal.sprites.append(Globals.allocator(), .{ .origin = .{ ext.sprites[sprite_index].origin[0], ext.sprites[sprite_index].origin[1] }, }) catch return error.FileLoadError; } } for (ext.animations) |animation| { - internal.animations.append(fizzy.app.allocator, .{ + internal.animations.append(Globals.allocator(), .{ .id = internal.newAnimationID(), - .name = try fizzy.app.allocator.dupe(u8, animation.name), - .frames = try fizzy.app.allocator.dupe(Animation.Frame, animation.frames), + .name = try Globals.allocator().dupe(u8, animation.name), + .frames = try Globals.allocator().dupe(Animation.Frame, animation.frames), }) catch return error.FileLoadError; } return internal; @@ -666,7 +668,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; // var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; - // if (fizzy.fs.read(fizzy.app.allocator, path) catch null) |file_bytes| { + // if (pixelart.fs.read(Globals.allocator(), path) catch null) |file_bytes| { // std.log.debug("Read file bytes!", .{}); // var input = std.io.fixedBufferStream(file_bytes); // var iter = std.tar.iterator(input.reader(), .{ @@ -674,7 +676,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // .link_name_buffer = &link_name_buffer, // }); - // var json_content = std.array_list.Managed(u8).init(fizzy.app.allocator); + // var json_content = std.array_list.Managed(u8).init(Globals.allocator()); // defer json_content.deinit(); // while (try iter.next()) |entry| { @@ -689,23 +691,23 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // .ignore_unknown_fields = true, // }; - // if (std.json.parseFromSlice(fizzy.File, fizzy.app.allocator, json_content.items, options) catch null) |parsed| { + // if (std.json.parseFromSlice(pixelart.File, Globals.allocator(), json_content.items, options) catch null) |parsed| { // defer parsed.deinit(); // std.log.debug("Parsed fizzydata.json!", .{}); // const ext = parsed.value; - // var internal: fizzy.Internal.File = .{ - // .id = fizzy.editor.newFileID(), - // .path = try fizzy.app.allocator.dupe(u8, path), + // var internal: pixelart.internal.File = .{ + // .id = Globals.state.host.allocDocId(), + // .path = try Globals.allocator().dupe(u8, path), // .width = ext.width, // .height = ext.height, // .tile_width = ext.tile_width, // .tile_height = ext.tile_height, - // .history = fizzy.Internal.File.History.init(fizzy.app.allocator), - // .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator), - // .checkerboard = fizzy.image.init( + // .history = pixelart.internal.File.History.init(Globals.allocator()), + // .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), + // .checkerboard = pixelart.image.init( // ext.tile_width * 2, // ext.tile_height * 2, // .{ .r = 0, .g = 0, .b = 0, .a = 0 }, @@ -714,7 +716,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // .temporary_layer = undefined, // .selection_layer = undefined, // .selected_sprites = try std.DynamicBitSet.initEmpty( - // fizzy.app.allocator, + // Globals.allocator(), // @divExact(ext.width, ext.tile_width) * @divExact(ext.height, ext.tile_height), // ), // }; @@ -737,15 +739,15 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // std.log.debug("Entry name: {s}", .{entry.name}); // if (std.mem.eql(u8, entry.name, layer_image_name)) { - // var layer_content = std.array_list.Managed(u8).init(fizzy.app.allocator); + // var layer_content = std.array_list.Managed(u8).init(Globals.allocator()); // try entry.writeAll(layer_content.writer()); - // var cond: ?fizzy.Internal.Layer = fizzy.Internal.Layer.fromPixels(internal.newID(), fizzy.app.allocator.dupe(u8, ext_layer.name) catch ext_layer.name, layer_content.items, ext.width, ext.height, .ptr) catch null; + // var cond: ?pixelart.Layer = pixelart.Layer.fromPixels(internal.newID(), Globals.allocator().dupe(u8, ext_layer.name) catch ext_layer.name, layer_content.items, ext.width, ext.height, .ptr) catch null; // if (cond) |*new_layer| { // new_layer.visible = ext_layer.visible; // new_layer.collapse = ext_layer.collapse; - // internal.layers.append(fizzy.app.allocator, new_layer.*) catch return error.FileLoadError; + // internal.layers.append(Globals.allocator(), new_layer.*) catch return error.FileLoadError; // } else { // std.log.err("Failed to create layer from pixels", .{}); // } @@ -791,12 +793,12 @@ pub fn shouldConfirmFlatRasterSave(self: File) bool { return requiresFizzyCompatibleSave(self); } -pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File { +pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?pixelart.internal.File { if (!isFlatImageExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; - const image_layer: fizzy.Internal.Layer = try fizzy.Internal.Layer.fromImageFileBytes( - fizzy.editor.newFileID(), + const image_layer: Layer = try Layer.fromImageFileBytes( + Globals.state.host.allocDocId(), "Layer", file_bytes, .ptr, @@ -806,42 +808,42 @@ pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?fizzy.Inte /// Loads a PNG or JPEG as the first layer of a new file, and retains the path /// when saved; layers will be flattened to that file -pub fn fromPathFlatImage(path: []const u8) !?fizzy.Internal.File { +pub fn fromPathFlatImage(path: []const u8) !?pixelart.internal.File { if (!isFlatImageExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; - const image_layer: fizzy.Internal.Layer = try fizzy.Internal.Layer.fromImageFilePath(fizzy.editor.newFileID(), "Layer", path, .ptr); + const image_layer: Layer = try Layer.fromImageFilePath(Globals.state.host.allocDocId(), "Layer", path, .ptr); return finishFlatImageFile(path, image_layer); } -fn finishFlatImageFile(path: []const u8, image_layer: fizzy.Internal.Layer) !?fizzy.Internal.File { +fn finishFlatImageFile(path: []const u8, image_layer: Layer) !?pixelart.internal.File { const size = image_layer.size(); const column_width: u32 = @intFromFloat(size.w); const row_height: u32 = @intFromFloat(size.h); - var internal: fizzy.Internal.File = .{ - .id = fizzy.editor.newFileID(), - .path = try fizzy.app.allocator.dupe(u8, path), + var internal: pixelart.internal.File = .{ + .id = Globals.state.host.allocDocId(), + .path = try Globals.allocator().dupe(u8, path), .columns = 1, .rows = 1, .column_width = column_width, .row_height = row_height, - .history = fizzy.Internal.File.History.init(fizzy.app.allocator), - .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator), + .history = pixelart.internal.File.History.init(Globals.allocator()), + .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), }; - internal.layers.append(fizzy.app.allocator, image_layer) catch return error.LayerCreateError; + internal.layers.append(Globals.allocator(), image_layer) catch return error.LayerCreateError; // Initialize editor layers and selected sprites internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount()); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.spriteCount()); - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height()); + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.width() * internal.height()); // Create a layer-sized checkerboard pattern for selection tools for (0..internal.width() * internal.height()) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + const value = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); internal.editor.checkerboard.setValue(i, value); } @@ -853,7 +855,7 @@ pub const ResizeOptions = struct { rows: u32, history: bool = true, // If true, layer data will be recorded for undo/redo layer_data: ?[][][4]u8 = null, // If provided, the layer data will be applied to the layers after resizing - animation_data: ?[][]fizzy.Animation.Frame = null, // If provided, the animation data will be applied to the animations after resizing + animation_data: ?[][]pixelart.Animation.Frame = null, // If provided, the animation data will be applied to the animations after resizing sprite_data: ?[][2]f32 = null, // If provided, the sprite data will be applied to the sprites after resizing }; @@ -876,22 +878,22 @@ pub fn resize(file: *File, options: ResizeOptions) !void { if (options.history) { file.history.append(.{ .resize = .{ .width = file.width(), .height = file.height() } }) catch return error.HistoryAppendError; - var layer_data = try fizzy.app.allocator.alloc([][4]u8, file.layers.len); + var layer_data = try Globals.allocator().alloc([][4]u8, file.layers.len); for (0..file.layers.len) |layer_index| { var layer = file.layers.get(layer_index); - layer_data[layer_index] = fizzy.app.allocator.dupe([4]u8, layer.pixels()) catch return error.MemoryAllocationFailed; + layer_data[layer_index] = Globals.allocator().dupe([4]u8, layer.pixels()) catch return error.MemoryAllocationFailed; } file.history.undo_layer_data_stack.append(layer_data) catch return error.MemoryAllocationFailed; // Store all the animations before the resize event - var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len); + var anim_data = try Globals.allocator().alloc([]pixelart.Animation.Frame, file.animations.len); for (0..file.animations.len) |anim_index| { const animation = file.animations.get(anim_index); - anim_data[anim_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames) catch return error.MemoryAllocationFailed; + anim_data[anim_index] = Globals.allocator().dupe(pixelart.Animation.Frame, animation.frames) catch return error.MemoryAllocationFailed; } file.history.undo_animation_data_stack.append(anim_data) catch return error.MemoryAllocationFailed; - var sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount()); + var sprite_data = try Globals.allocator().alloc([2]f32, file.spriteCount()); for (0..file.spriteCount()) |sprite_index| { sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index]; } @@ -903,22 +905,22 @@ pub fn resize(file: *File, options: ResizeOptions) !void { var current_animation = file.animations.get(anim_index); const current_data = anim_data[anim_index]; - var new_animation = fizzy.Internal.Animation.init(fizzy.app.allocator, current_animation.id, current_animation.name, &.{}) catch return error.AnimationCreateError; + var new_animation = Animation.init(Globals.allocator(), current_animation.id, current_animation.name, &.{}) catch return error.AnimationCreateError; defer file.animations.set(anim_index, new_animation); - defer current_animation.deinit(fizzy.app.allocator); + defer current_animation.deinit(Globals.allocator()); for (current_data) |frame| { - new_animation.appendFrame(fizzy.app.allocator, .{ .sprite_index = frame.sprite_index, .ms = frame.ms }) catch return error.AnimationFrameAppendError; + new_animation.appendFrame(Globals.allocator(), .{ .sprite_index = frame.sprite_index, .ms = frame.ms }) catch return error.AnimationFrameAppendError; } } } else for (0..file.animations.len) |anim_index| { var animation = file.animations.get(anim_index); - var new_animation = fizzy.Internal.Animation.init(fizzy.app.allocator, animation.id, animation.name, &.{}) catch return error.AnimationCreateError; + var new_animation = Animation.init(Globals.allocator(), animation.id, animation.name, &.{}) catch return error.AnimationCreateError; defer file.animations.set(anim_index, new_animation); - defer animation.deinit(fizzy.app.allocator); + defer animation.deinit(Globals.allocator()); for (0..animation.frames.len) |frame_index| { const old_sprite_index = animation.frames[frame_index].sprite_index; if (file.getResizedIndex(old_sprite_index, new_columns, new_rows)) |new_sprite_index| { - new_animation.appendFrame(fizzy.app.allocator, .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError; + new_animation.appendFrame(Globals.allocator(), .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError; } } } @@ -927,10 +929,10 @@ pub fn resize(file: *File, options: ResizeOptions) !void { const new_sprite_count = new_columns * new_rows; var old_origins_snapshot: ?[][2]f32 = null; - defer if (old_origins_snapshot) |s| fizzy.app.allocator.free(s); + defer if (old_origins_snapshot) |s| Globals.allocator().free(s); if (options.sprite_data == null) { - const snapshot = try fizzy.app.allocator.alloc([2]f32, old_sprite_count); + const snapshot = try Globals.allocator().alloc([2]f32, old_sprite_count); for (0..old_sprite_count) |i| { snapshot[i] = file.sprites.items(.origin)[i]; } @@ -938,7 +940,7 @@ pub fn resize(file: *File, options: ResizeOptions) !void { } file.sprites.resize( - fizzy.app.allocator, + Globals.allocator(), new_sprite_count, ) catch return error.MemoryAllocationFailed; @@ -982,7 +984,7 @@ pub fn resize(file: *File, options: ResizeOptions) !void { file.editor.checkerboard.resize(new_width * new_height, false) catch return error.MemoryAllocationFailed; for (0..new_width * new_height) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i); + const value = pixelart.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i); file.editor.checkerboard.setValue(i, value); } @@ -1310,7 +1312,7 @@ pub fn reorderRows(file: *File, removed_row_index: usize, insert_before_row_inde } pub fn deinit(file: *File) void { - fizzy.render.destroyLayerCompositeResources(file); + pixelart.render.destroyLayerCompositeResources(file); strokeUndoFreeSnapshot(file); @@ -1318,15 +1320,15 @@ pub fn deinit(file: *File) void { file.buffers.deinit(); for (file.layers.items(.name)) |name| { - fizzy.app.allocator.free(name); + Globals.allocator().free(name); } for (file.animations.items(.name)) |name| { - fizzy.app.allocator.free(name); + Globals.allocator().free(name); } for (file.animations.items(.frames)) |frames| { - fizzy.app.allocator.free(frames); + Globals.allocator().free(frames); } file.editor.temporary_layer.deinit(); @@ -1337,16 +1339,16 @@ pub fn deinit(file: *File) void { file.editor.checkerboard_tile = null; } - file.editor.selected_layer_indices.deinit(fizzy.app.allocator); - file.editor.selected_animation_indices.deinit(fizzy.app.allocator); - file.editor.selected_frame_indices.deinit(fizzy.app.allocator); + file.editor.selected_layer_indices.deinit(Globals.allocator()); + file.editor.selected_animation_indices.deinit(Globals.allocator()); + file.editor.selected_frame_indices.deinit(Globals.allocator()); - file.layers.deinit(fizzy.app.allocator); - file.deleted_layers.deinit(fizzy.app.allocator); - file.sprites.deinit(fizzy.app.allocator); - file.animations.deinit(fizzy.app.allocator); - file.deleted_animations.deinit(fizzy.app.allocator); - fizzy.app.allocator.free(file.path); + file.layers.deinit(Globals.allocator()); + file.deleted_layers.deinit(Globals.allocator()); + file.sprites.deinit(Globals.allocator()); + file.animations.deinit(Globals.allocator()); + file.deleted_animations.deinit(Globals.allocator()); + Globals.allocator().free(file.path); } pub fn dirty(self: File) bool { @@ -1616,7 +1618,7 @@ pub fn promotePrimarySprite(file: *File, sprite_index: usize) void { pub fn collapseAnimationSelectionToPrimary(file: *File) void { if (file.selected_animation_index) |p| { file.editor.selected_animation_indices.clearRetainingCapacity(); - file.editor.selected_animation_indices.append(fizzy.app.allocator, p) catch return; + file.editor.selected_animation_indices.append(Globals.allocator(), p) catch return; file.editor.animation_selection_anchor = p; } } @@ -1678,9 +1680,9 @@ pub fn selectPoint(file: *File, point: dvui.Point, select_options: SelectOptions } } } else { - var iter = fizzy.pixelart.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); + var iter = Globals.state.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.pixelart.tools.offset_table[i]; + const offset = Globals.state.tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (select_options.constrain_to_tile) { @@ -1724,29 +1726,29 @@ pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_op const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height)); const diff = point2.diff(point1).normalize().scale(4, dvui.Point); - const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size); + const stroke_size: usize = @intCast(Tools.max_brush_size); - const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) }; - var mask = fizzy.pixelart.tools.stroke; + const center: dvui.Point = .{ .x = @floor(Tools.max_brush_size_float / 2), .y = @floor(Tools.max_brush_size_float / 2) }; + var mask = Globals.state.tools.stroke; - if (select_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) { + if (select_options.stroke_size > Tools.min_full_stroke_size) { for (0..(stroke_size * stroke_size)) |index| { - if (fizzy.pixelart.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { + if (Globals.state.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { mask.unset(i); } } } - if (fizzy.algorithms.brezenham.process(point1, point2) catch null) |points| { + if (pixelart.algorithms.brezenham.process(point1, point2) catch null) |points| { for (points, 0..) |point, point_i| { - if (select_options.stroke_size < fizzy.Editor.Tools.min_full_stroke_size) { + if (select_options.stroke_size < Tools.min_full_stroke_size) { selectPoint(file, point, select_options); } else { - var stroke = if (point_i == 0) fizzy.pixelart.tools.stroke else mask; + var stroke = if (point_i == 0) Globals.state.tools.stroke else mask; var iter = stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.pixelart.tools.offset_table[i]; + const offset = Globals.state.tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (select_options.constrain_to_tile) { @@ -1804,16 +1806,16 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void const bounds = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) }); if (!bounds.contains(p)) return; - const start_idx = fizzy.image.pixelIndex(read_layer.source, p) orelse return; + const start_idx = pixelart.image.pixelIndex(read_layer.source, p) orelse return; const original_color = read_layer.pixels()[start_idx]; const n = read_layer.pixels().len; if (selection_layer.mask.capacity() != n) return; - var visited = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, n); + var visited = try std.DynamicBitSet.initEmpty(Globals.allocator(), n); defer visited.deinit(); - var queue = std.array_list.Managed(dvui.Point).init(fizzy.app.allocator); + var queue = std.array_list.Managed(dvui.Point).init(Globals.allocator()); defer queue.deinit(); try queue.append(p); @@ -1827,7 +1829,7 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void }; while (queue.pop()) |qp| { - const idx = fizzy.image.pixelIndex(read_layer.source, qp) orelse continue; + const idx = pixelart.image.pixelIndex(read_layer.source, qp) orelse continue; if (!std.meta.eql(original_color, read_layer.pixels()[idx])) continue; selection_layer.mask.setValue(idx, value); @@ -1835,7 +1837,7 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void for (directions) |direction| { const np = qp.plus(direction); if (!bounds.contains(np)) continue; - if (fizzy.image.pixelIndex(read_layer.source, np)) |ni| { + if (pixelart.image.pixelIndex(read_layer.source, np)) |ni| { if (visited.isSet(ni)) continue; if (!std.meta.eql(original_color, read_layer.pixels()[ni])) continue; visited.set(ni); @@ -1941,7 +1943,7 @@ pub fn brushStampRect(file: *const File, point: dvui.Point, stroke_size: usize) fn strokeUndoFreeSnapshot(file: *File) void { if (file.editor.stroke_undo_pixels) |p| { - fizzy.app.allocator.free(p); + Globals.allocator().free(p); file.editor.stroke_undo_pixels = null; } file.editor.stroke_undo_x = 0; @@ -1968,7 +1970,7 @@ pub fn strokeUndoBegin(file: *File, cover: dvui.Rect) !void { } const n = @as(usize, b.w) * @as(usize, b.h) * 4; - const buf = try fizzy.app.allocator.alloc(u8, n); + const buf = try Globals.allocator().alloc(u8, n); const layer = file.layers.get(file.selected_layer_index); const pix = layer.pixels(); @@ -2019,7 +2021,7 @@ pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void { } const new_n = @as(usize, tw) * @as(usize, th) * 4; - const new_buf = try fizzy.app.allocator.alloc(u8, new_n); + const new_buf = try Globals.allocator().alloc(u8, new_n); const layer = file.layers.get(file.selected_layer_index); const pix = layer.pixels(); @@ -2045,7 +2047,7 @@ pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void { } } - fizzy.app.allocator.free(old_buf); + Globals.allocator().free(old_buf); file.editor.stroke_undo_pixels = new_buf; file.editor.stroke_undo_x = tx; file.editor.stroke_undo_y = ty; @@ -2339,9 +2341,9 @@ pub fn drawPoint(file: *File, point: dvui.Point, layer: DrawLayer, draw_options: } } } else { - var iter = fizzy.pixelart.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); + var iter = Globals.state.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.pixelart.tools.offset_table[i]; + const offset = Globals.state.tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (clip_rect) |cr| { @@ -2427,26 +2429,26 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height)); const diff = point2.diff(point1).normalize().scale(4, dvui.Point); - const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size); + const stroke_size: usize = @intCast(Tools.max_brush_size); - const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) }; - var mask = fizzy.pixelart.tools.stroke; + const center: dvui.Point = .{ .x = @floor(Tools.max_brush_size_float / 2), .y = @floor(Tools.max_brush_size_float / 2) }; + var mask = Globals.state.tools.stroke; - if (draw_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) { + if (draw_options.stroke_size > Tools.min_full_stroke_size) { for (0..(stroke_size * stroke_size)) |index| { - if (fizzy.pixelart.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { + if (Globals.state.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { mask.unset(i); } } } - if (fizzy.algorithms.brezenham.process(point1, point2) catch null) |points| { + if (pixelart.algorithms.brezenham.process(point1, point2) catch null) |points| { for (points, 0..) |point, point_i| { if (clip_rect) |cr| { const br = brushRect(point, draw_options.stroke_size, iw, ih); if (br.intersect(cr).empty()) continue; } - if (draw_options.stroke_size < fizzy.Editor.Tools.min_full_stroke_size) { + if (draw_options.stroke_size < Tools.min_full_stroke_size) { drawPoint(file, point, layer, .{ .color = draw_options.color, .stroke_size = draw_options.stroke_size, @@ -2457,11 +2459,11 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw .clip_rect = draw_options.clip_rect, }); } else { - var stroke = if (point_i == 0) fizzy.pixelart.tools.stroke else mask; + var stroke = if (point_i == 0) Globals.state.tools.stroke else mask; var iter = stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.pixelart.tools.offset_table[i]; + const offset = Globals.state.tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (clip_rect) |cr| { @@ -2609,7 +2611,7 @@ pub fn getLayer(self: *File, id: u64) ?Layer { } pub fn deleteLayer(self: *File, index: usize) !void { - try self.deleted_layers.append(fizzy.app.allocator, self.layers.slice().get(index)); + try self.deleted_layers.append(Globals.allocator(), self.layers.slice().get(index)); self.layers.orderedRemove(index); self.editor.layer_composite_dirty = true; self.editor.split_composite_dirty = true; @@ -2645,10 +2647,10 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: const dest_id = self.layers.items(.id)[dest_i]; const src_id = self.layers.items(.id)[src_i]; - const dest_pixels_before = try fizzy.app.allocator.dupe([4]u8, dest.pixels()); - errdefer fizzy.app.allocator.free(dest_pixels_before); + const dest_pixels_before = try Globals.allocator().dupe([4]u8, dest.pixels()); + errdefer Globals.allocator().free(dest_pixels_before); - var dest_mask_before = try dest.mask.clone(fizzy.app.allocator); + var dest_mask_before = try dest.mask.clone(Globals.allocator()); errdefer dest_mask_before.deinit(); for (0..pix_n) |i| { @@ -2663,7 +2665,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: dest.invalidate(); self.layers.set(dest_i, dest); - try self.deleted_layers.append(fizzy.app.allocator, self.layers.slice().get(src_i)); + try self.deleted_layers.append(Globals.allocator(), self.layers.slice().get(src_i)); self.layers.orderedRemove(src_i); self.editor.layer_composite_dirty = true; @@ -2682,7 +2684,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: .dest_pixels_before = dest_pixels_before, .dest_mask_before = dest_mask_before, } }); - fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); + Globals.state.host.setActiveSidebarView(plugin.view_tools); } pub fn duplicateLayer(self: *File, index: usize) !u64 { @@ -2697,7 +2699,7 @@ pub fn duplicateLayer(self: *File, index: usize) !u64 { @memcpy(new_layer.pixels(), layer.pixels()); - self.layers.insert(fizzy.app.allocator, 0, new_layer) catch { + self.layers.insert(Globals.allocator(), 0, new_layer) catch { dvui.log.err("Failed to append layer", .{}); }; @@ -2718,8 +2720,8 @@ pub fn duplicateLayer(self: *File, index: usize) !u64 { } pub fn createLayer(self: *File) !u64 { - if (fizzy.Internal.Layer.init(self.newLayerID(), "New Layer", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch null) |layer| { - self.layers.insert(fizzy.app.allocator, 0, layer) catch { + if (Layer.init(self.newLayerID(), "New Layer", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch null) |layer| { + self.layers.insert(Globals.allocator(), 0, layer) catch { dvui.log.err("Failed to append layer", .{}); }; self.selected_layer_index = 0; @@ -2743,14 +2745,14 @@ pub fn createLayer(self: *File) !u64 { pub fn createAnimation(self: *File) !usize { var animation = Animation.init( - fizzy.app.allocator, + Globals.allocator(), self.newAnimationID(), "New Animation", &[_]Animation.Frame{}, ) catch return error.FailedToCreateAnimation; if (self.editor.selected_sprites.count() > 0) { - animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, self.editor.selected_sprites.count()); + animation.frames = try Globals.allocator().alloc(Animation.Frame, self.editor.selected_sprites.count()); var iter = self.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); var i: usize = 0; @@ -2759,7 +2761,7 @@ pub fn createAnimation(self: *File) !usize { } } - self.animations.append(fizzy.app.allocator, animation) catch { + self.animations.append(Globals.allocator(), animation) catch { dvui.log.err("Failed to append animation", .{}); }; return self.animations.len - 1; @@ -2768,15 +2770,15 @@ pub fn createAnimation(self: *File) !usize { pub fn duplicateAnimation(self: *File, index: usize) !usize { const animation = self.animations.slice().get(index); const new_name = try std.fmt.allocPrint(dvui.currentWindow().lifo(), "{s}_copy", .{animation.name}); - const new_animation = Animation.init(fizzy.app.allocator, self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation; - self.animations.insert(fizzy.app.allocator, index + 1, new_animation) catch { + const new_animation = Animation.init(Globals.allocator(), self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation; + self.animations.insert(Globals.allocator(), index + 1, new_animation) catch { dvui.log.err("Failed to append animation", .{}); }; return index + 1; } pub fn deleteAnimation(self: *File, index: usize) !void { - try self.deleted_animations.append(fizzy.app.allocator, self.animations.slice().get(index)); + try self.deleted_animations.append(Globals.allocator(), self.animations.slice().get(index)); self.animations.orderedRemove(index); try self.history.append(.{ .animation_restore_delete = .{ .action = .restore, @@ -2795,16 +2797,16 @@ pub fn redo(self: *File) !void { pub fn saveTar(self: *File, window: *dvui.Window) !void { if (self.saving) return; self.saving = true; - var ext = try self.external(fizzy.app.allocator); - defer ext.deinit(fizzy.app.allocator); + var ext = try self.external(Globals.allocator()); + defer ext.deinit(Globals.allocator()); - const output_path = try fizzy.pixelart.host.arena().dupeZ(u8, self.path); + const output_path = try Globals.state.host.arena().dupeZ(u8, self.path); var handle = try std.fs.cwd().createFile(output_path, .{}); defer handle.close(); var wrt = std.tar.writer(handle.writer()); - var json = std.array_list.Managed(u8).init(fizzy.app.allocator); + var json = std.array_list.Managed(u8).init(Globals.allocator()); const out_stream = json.writer(); const options = std.json.StringifyOptions{}; @@ -2826,14 +2828,14 @@ pub fn saveTar(self: *File, window: *dvui.Window) !void { else => return error.InvalidImageSource, }; - try wrt.writeFileBytes(try std.fmt.allocPrintZ(fizzy.pixelart.host.arena(), "{s}.layer", .{layer.name}), data, .{}); + try wrt.writeFileBytes(try std.fmt.allocPrintZ(Globals.state.host.arena(), "{s}.layer", .{layer.name}), data, .{}); } } try wrt.finish(); { - const id_mutex = dvui.toastAdd(window, @src(), 0, fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), 0, pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Saved {s}", .{std.fs.path.basename(self.path)}) catch "Saved file"; dvui.dataSetSlice(window, id, "_message", message); @@ -2850,26 +2852,26 @@ fn writeFlattenedLayersToPath(self: *File, out_path: []const u8, window: *dvui.W const h = self.height(); if (w == 0 or h == 0) return error.InvalidImageSize; - try fizzy.render.syncLayerComposite(self); + try pixelart.render.syncLayerComposite(self); const target = self.editor.layer_composite_target orelse return error.NoLayerComposite; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } - var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr); + var tmp_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr); defer tmp_layer.deinit(); switch (kind) { .png => { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - try fizzy.image.writeToPngResolution(tmp_layer.source, out_path, r); + try pixelart.image.writeToPngResolution(tmp_layer.source, out_path, r); }, .jpg => { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try fizzy.image.writeToJpgPpi(tmp_layer.source, out_path, ppi); + try pixelart.image.writeToJpgPpi(tmp_layer.source, out_path, ppi); }, } } @@ -2884,7 +2886,7 @@ pub fn savePng(self: *File, window: *dvui.Window) !void { { // `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic // u64s; in practice they fit, so an `@intCast` is safe and panics if not. - const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file"; dvui.dataSetSlice(window, id, "_message", message); @@ -2905,7 +2907,7 @@ pub fn saveJpg(self: *File, window: *dvui.Window) !void { { // `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic // u64s; in practice they fit, so an `@intCast` is safe and panics if not. - const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file"; dvui.dataSetSlice(window, id, "_message", message); @@ -2924,8 +2926,8 @@ pub fn saveZip(self: *File, window: *dvui.Window) !void { // already the only writer of `self.layers` — so a snapshot would be pointless // copying. Build the snapshot inline and immediately consume it. We still // use the same code path so there's a single zip-writing function. - var snap = try SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator); - defer snap.deinit(fizzy.app.allocator); + var snap = try SaveSnapshot.fromFileOnGuiThread(self, Globals.allocator()); + defer snap.deinit(Globals.allocator()); try writeSnapshotToZip(self.id, window, &snap); } @@ -2934,7 +2936,7 @@ pub fn saveZip(self: *File, window: *dvui.Window) !void { /// `*File`, so user edits during the save can't tear `self.layers` mid-iteration /// (manifested as MultiArrayList slice OOB / corrupt layer.name). pub const SaveSnapshot = struct { - ext: fizzy.File, + ext: pixelart.File, layer_bytes: [][]u8, layer_entry_names: [][:0]const u8, null_terminated_path: [:0]u8, @@ -3000,7 +3002,7 @@ pub const SaveQueue = struct { pub fn submit(self: *SaveQueue, job: Job) !void { self.mutex.lockUncancelable(dvui.io); defer self.mutex.unlock(dvui.io); - try self.queue.append(fizzy.app.allocator, job); + try self.queue.append(Globals.allocator(), job); self.cond.signal(dvui.io); } }; @@ -3033,10 +3035,10 @@ pub fn deinitSaveQueue() void { // Anything still queued after worker exit is leaked snapshots — shouldn't // happen since the worker drains before exit, but clean up defensively. for (save_queue.queue.items) |*job| { - job.snap.deinit(fizzy.app.allocator); - fizzy.app.allocator.destroy(job.snap); + job.snap.deinit(Globals.allocator()); + Globals.allocator().destroy(job.snap); } - save_queue.queue.deinit(fizzy.app.allocator); + save_queue.queue.deinit(Globals.allocator()); } fn saveQueueWorker() void { @@ -3060,9 +3062,9 @@ fn saveQueueWorker() void { // becomes stale (silently aliasing a different file) as soon as the GUI // thread closes any earlier file from the in-flight set. defer { - job.snap.deinit(fizzy.app.allocator); - fizzy.app.allocator.destroy(job.snap); - if (fizzy.editor.open_files.getPtr(job.file_id)) |f| f.setSaving(false); + job.snap.deinit(Globals.allocator()); + Globals.allocator().destroy(job.snap); + if (Globals.state.docs.fileById(job.file_id)) |f| f.setSaving(false); dvui.refresh(job.window, @src(), null); } writeSnapshotToZip(job.file_id, job.window, job.snap) catch |err| { @@ -3095,7 +3097,7 @@ fn writeSnapshotToZip(file_id: u64, window: *dvui.Window, snap: *const SaveSnaps zip.zip_close(z); } - if (fizzy.editor.open_files.getPtr(file_id)) |f| f.history.bookmark = 0; + if (Globals.state.docs.fileById(file_id)) |f| f.history.bookmark = 0; } fn zipEntryOk(rc: c_int) !void { @@ -3104,8 +3106,8 @@ fn zipEntryOk(rc: c_int) !void { fn writeSnapshotEntriesToZip(z: *zip.struct_zip_t, snap: *const SaveSnapshot) !void { const options = std.json.Stringify.Options{}; - const output = try std.json.Stringify.valueAlloc(fizzy.app.allocator, snap.ext, options); - defer fizzy.app.allocator.free(output); + const output = try std.json.Stringify.valueAlloc(Globals.allocator(), snap.ext, options); + defer Globals.allocator().free(output); try zipEntryOk(zip.zip_entry_open(z, "fizzydata.json")); try zipEntryOk(zip.zip_entry_write(z, output.ptr, output.len)); @@ -3147,25 +3149,25 @@ pub fn saveToDownload(self: *File, window: *dvui.Window) !void { const ext = std.fs.path.extension(self.path); if (isFizzyExtension(ext)) { - var snap = try SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator); - defer snap.deinit(fizzy.app.allocator); - const bytes = try writeSnapshotToZipBytes(&snap, fizzy.app.allocator); - defer fizzy.app.allocator.free(bytes); - try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes); + var snap = try SaveSnapshot.fromFileOnGuiThread(self, Globals.allocator()); + defer snap.deinit(Globals.allocator()); + const bytes = try writeSnapshotToZipBytes(&snap, Globals.allocator()); + defer Globals.allocator().free(bytes); + try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes); } else if (std.mem.eql(u8, ext, ".png")) { const bytes = try flattenedImageBytes(self, window, .png); - defer fizzy.app.allocator.free(bytes); - try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes); + defer Globals.allocator().free(bytes); + try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { const bytes = try flattenedImageBytes(self, window, .jpg); - defer fizzy.app.allocator.free(bytes); - try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes); + defer Globals.allocator().free(bytes); + try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes); } else { return; } self.history.bookmark = 0; - const id_mutex = dvui.toastAdd(window, @src(), 0, fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), 0, pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Downloaded {s}", .{basename}) catch "Downloaded file"; dvui.dataSetSlice(window, id, "_message", message); @@ -3177,28 +3179,28 @@ fn flattenedImageBytes(self: *File, window: *dvui.Window, comptime kind: enum { const h = self.height(); if (w == 0 or h == 0) return error.InvalidImageSize; - try fizzy.render.syncLayerComposite(self); + try pixelart.render.syncLayerComposite(self); const target = self.editor.layer_composite_target orelse return error.NoLayerComposite; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } - var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr); + var tmp_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr); defer tmp_layer.deinit(); - var out = std.Io.Writer.Allocating.init(fizzy.app.allocator); + var out = std.Io.Writer.Allocating.init(Globals.allocator()); errdefer out.deinit(); switch (kind) { .png => { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - try fizzy.image.writePngToWriter(tmp_layer.source, &out.writer, r); + try pixelart.image.writePngToWriter(tmp_layer.source, &out.writer, r); }, .jpg => { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try fizzy.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi); + try pixelart.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi); }, } return out.toOwnedSlice(); @@ -3214,10 +3216,10 @@ pub fn saveAsFizzy(self: *File, new_path: []const u8, window: *dvui.Window) !voi return saveZip(self, window); } const old_path = self.path; - const new_owned = try fizzy.app.allocator.dupe(u8, new_path); + const new_owned = try Globals.allocator().dupe(u8, new_path); self.path = new_owned; errdefer { - fizzy.app.allocator.free(self.path[0..self.path.len]); + Globals.allocator().free(self.path[0..self.path.len]); self.path = old_path; } if (comptime @import("builtin").target.cpu.arch == .wasm32) { @@ -3225,7 +3227,7 @@ pub fn saveAsFizzy(self: *File, new_path: []const u8, window: *dvui.Window) !voi } else { try saveZip(self, window); } - fizzy.app.allocator.free(old_path[0..old_path.len]); + Globals.allocator().free(old_path[0..old_path.len]); } /// Default filename (with `.fiz`) for a Save As dialog, derived from the current path. @@ -3249,10 +3251,10 @@ fn deinitAllUserLayers(self: *File) void { fn clearAnimationsForSaveAs(self: *File) void { for (self.animations.items(.name)) |n| { - fizzy.app.allocator.free(n); + Globals.allocator().free(n); } for (self.animations.items(.frames)) |frames| { - fizzy.app.allocator.free(frames); + Globals.allocator().free(frames); } self.animations.clearRetainingCapacity(); self.deleted_animations.clearRetainingCapacity(); @@ -3276,15 +3278,15 @@ fn reinitEditorSurfaceForFlatDocument(self: *File) !void { self.editor.temporary_layer = try .init(self.newLayerID(), "Temporary", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); self.editor.selection_layer = try .init(self.newLayerID(), "Selection", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); self.editor.transform_layer = try .init(self.newLayerID(), "Transform", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - self.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, self.spriteCount()); + self.editor.selected_sprites = try std.DynamicBitSet.initEmpty(Globals.allocator(), self.spriteCount()); - self.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, self.width() * self.height()); + self.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), self.width() * self.height()); for (0..self.width() * self.height()) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(self.width()), .h = @floatFromInt(self.height()) }, i); + const value = pixelart.math.checker(.{ .w = @floatFromInt(self.width()), .h = @floatFromInt(self.height()) }, i); self.editor.checkerboard.setValue(i, value); } self.editor.selected_layer_indices.clearRetainingCapacity(); - try self.editor.selected_layer_indices.append(fizzy.app.allocator, 0); + try self.editor.selected_layer_indices.append(Globals.allocator(), 0); } /// Flattens visible layers (via GPU composite), writes PNG or JPEG to `output_path`, and replaces @@ -3302,16 +3304,16 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo return error.InvalidImageSize; } - try fizzy.render.syncLayerComposite(self); + try pixelart.render.syncLayerComposite(self); const target = self.editor.layer_composite_target orelse { self.setSaving(false); return error.NoLayerComposite; }; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } const ext = std.fs.path.extension(output_path); @@ -3322,49 +3324,49 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo return error.InvalidExtension; } - var single_layer: fizzy.Internal.Layer = try .fromPixelsPMA(self.newLayerID(), "Layer", pma_read, w, h, .ptr); + var single_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "Layer", pma_read, w, h, .ptr); errdefer single_layer.deinit(); if (comptime @import("builtin").target.cpu.arch == .wasm32) { const bytes = if (is_png) blk: { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - var out = std.Io.Writer.Allocating.init(fizzy.app.allocator); + var out = std.Io.Writer.Allocating.init(Globals.allocator()); errdefer out.deinit(); - try fizzy.image.writePngToWriter(single_layer.source, &out.writer, r); + try pixelart.image.writePngToWriter(single_layer.source, &out.writer, r); break :blk try out.toOwnedSlice(); } else blk: { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - var out = std.Io.Writer.Allocating.init(fizzy.app.allocator); + var out = std.Io.Writer.Allocating.init(Globals.allocator()); errdefer out.deinit(); - try fizzy.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi); + try pixelart.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi); break :blk try out.toOwnedSlice(); }; - defer fizzy.app.allocator.free(bytes); + defer Globals.allocator().free(bytes); const dl_ext = if (is_png) ".png" else ".jpg"; - try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes); + try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes); } else if (is_png) { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - try fizzy.image.writeToPngResolution(single_layer.source, output_path, r); + try pixelart.image.writeToPngResolution(single_layer.source, output_path, r); } else { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try fizzy.image.writeToJpgPpi(single_layer.source, output_path, ppi); + try pixelart.image.writeToJpgPpi(single_layer.source, output_path, ppi); } - fizzy.render.destroyLayerCompositeResources(self); - fizzy.render.destroySplitCompositeResources(self); + pixelart.render.destroyLayerCompositeResources(self); + pixelart.render.destroySplitCompositeResources(self); deinitAllUserLayers(self); clearAnimationsForSaveAs(self); self.sprites.clearRetainingCapacity(); for (0..self.spriteCount()) |_| { - self.sprites.append(fizzy.app.allocator, .{ .origin = .{ 0, 0 } }) catch { + self.sprites.append(Globals.allocator(), .{ .origin = .{ 0, 0 } }) catch { single_layer.deinit(); return error.FileLoadError; }; } - const new_path = try fizzy.app.allocator.dupe(u8, output_path); - fizzy.app.allocator.free(self.path[0..self.path.len]); + const new_path = try Globals.allocator().dupe(u8, output_path); + Globals.allocator().free(self.path[0..self.path.len]); self.path = new_path; self.columns = 1; self.rows = 1; @@ -3372,13 +3374,13 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo self.row_height = h; self.selected_layer_index = 0; self.peek_layer_index = null; - self.layers.append(fizzy.app.allocator, single_layer) catch { + self.layers.append(Globals.allocator(), single_layer) catch { single_layer.deinit(); return error.LayerCreateError; }; self.history.deinit(); - self.history = .init(fizzy.app.allocator); + self.history = .init(Globals.allocator()); try reinitEditorSurfaceForFlatDocument(self); self.editor.layer_composite_dirty = true; @@ -3387,13 +3389,13 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo { // `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic // u64s; in practice they fit, so an `@intCast` is safe and panics if not. - const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file"; dvui.dataSetSlice(window, id, "_message", message); id_mutex.mutex.unlock(dvui.io); } - fizzy.editor.requestCompositeWarmup(); + Globals.state.host.requestCompositeWarmup(); } pub const GridLayoutOptions = struct { @@ -3401,7 +3403,7 @@ pub const GridLayoutOptions = struct { row_height: u32, columns: u32, rows: u32, - anchor: fizzy.math.layout_anchor.LayoutAnchor, + anchor: pixelart.math.layout_anchor.LayoutAnchor, /// When true (default), `applyGridLayout` snapshots the previous state and pushes a /// `grid_layout` change to the file's history before mutating. Internal callers driving /// undo/redo restoration should pass `false` so the swap doesn't loop into itself. @@ -3410,34 +3412,34 @@ pub const GridLayoutOptions = struct { /// Captures everything `applyGridLayout` mutates, owning all returned slices. The caller is /// responsible for freeing via `Change.deinit` (see `History.Change.GridLayout.deinit`). -pub fn captureGridLayoutSnapshot(file: *File) !fizzy.Internal.History.Change.GridLayout { +pub fn captureGridLayoutSnapshot(file: *File) !History.Change.GridLayout { const total: usize = @as(usize, file.column_width) * @as(usize, file.columns) * @as(usize, file.row_height) * @as(usize, file.rows); const layer_count = file.layers.len; - var layer_ids = try fizzy.app.allocator.alloc(u64, layer_count); - errdefer fizzy.app.allocator.free(layer_ids); + var layer_ids = try Globals.allocator().alloc(u64, layer_count); + errdefer Globals.allocator().free(layer_ids); - var layer_pixels = try fizzy.app.allocator.alloc([][4]u8, layer_count); + var layer_pixels = try Globals.allocator().alloc([][4]u8, layer_count); var allocated: usize = 0; errdefer { - for (layer_pixels[0..allocated]) |buf| fizzy.app.allocator.free(buf); - fizzy.app.allocator.free(layer_pixels); + for (layer_pixels[0..allocated]) |buf| Globals.allocator().free(buf); + Globals.allocator().free(layer_pixels); } for (0..layer_count) |i| { layer_ids[i] = file.layers.items(.id)[i]; const src = file.layers.get(i).pixels(); std.debug.assert(src.len == total); - const dst = try fizzy.app.allocator.alloc([4]u8, total); + const dst = try Globals.allocator().alloc([4]u8, total); @memcpy(dst, src); layer_pixels[i] = dst; allocated += 1; } const sprite_count = file.sprites.len; - var sprite_origins = try fizzy.app.allocator.alloc([2]f32, sprite_count); - errdefer fizzy.app.allocator.free(sprite_origins); + var sprite_origins = try Globals.allocator().alloc([2]f32, sprite_count); + errdefer Globals.allocator().free(sprite_origins); for (0..sprite_count) |i| sprite_origins[i] = file.sprites.items(.origin)[i]; return .{ @@ -3457,7 +3459,7 @@ pub fn captureGridLayoutSnapshot(file: *File) !fizzy.Internal.History.Change.Gri /// Restores the file to the exact state described by `snap`. Mirrors the structural updates of /// `applyGridLayout` (resize layer buffers, sprite list, scratch layers, checkerboard, composite /// tear-down) but copies pixel data verbatim instead of re-anchoring it. -pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change.GridLayout) !void { +pub fn applyGridLayoutSnapshot(file: *File, snap: History.Change.GridLayout) !void { const new_w: u32 = snap.column_width * snap.columns; const new_h: u32 = snap.row_height * snap.rows; const total: usize = @as(usize, new_w) * @as(usize, new_h); @@ -3473,7 +3475,7 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change. break :blk null; }; - var rebuilt = fizzy.Internal.Layer.init( + var rebuilt = Layer.init( live.id, live.name, new_w, @@ -3494,25 +3496,25 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change. file.editor.temporary_layer.deinit(); file.editor.selection_layer.deinit(); file.editor.transform_layer.deinit(); - file.editor.temporary_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; - file.editor.selection_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; - file.editor.transform_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.temporary_layer = Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.selection_layer = Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.transform_layer = Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; file.sprites.shrinkRetainingCapacity(0); const new_sprite_count: usize = @as(usize, snap.columns) * @as(usize, snap.rows); var i: usize = 0; while (i < new_sprite_count) : (i += 1) { const origin: [2]f32 = if (i < snap.sprite_origins.len) snap.sprite_origins[i] else .{ 0.0, 0.0 }; - file.sprites.append(fizzy.app.allocator, .{ .origin = origin }) catch return error.MemoryAllocationFailed; + file.sprites.append(Globals.allocator(), .{ .origin = origin }) catch return error.MemoryAllocationFailed; } file.editor.selected_sprites.deinit(); - file.editor.selected_sprites = std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed; + file.editor.selected_sprites = std.DynamicBitSet.initEmpty(Globals.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; file.editor.checkerboard.deinit(); - file.editor.checkerboard = std.DynamicBitSet.initEmpty(fizzy.app.allocator, total) catch return error.MemoryAllocationFailed; + file.editor.checkerboard = std.DynamicBitSet.initEmpty(Globals.allocator(), total) catch return error.MemoryAllocationFailed; for (0..total) |idx| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); + const value = pixelart.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); file.editor.checkerboard.setValue(idx, value); } @@ -3528,7 +3530,7 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change. file.columns = snap.columns; file.rows = snap.rows; - fizzy.render.destroyLayerCompositeResources(file); + pixelart.render.destroyLayerCompositeResources(file); file.invalidateActiveLayerTransparencyMaskCache(); } @@ -3567,12 +3569,12 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { options.rows == file.rows; if (same) return; - var snapshot_opt: ?fizzy.Internal.History.Change.GridLayout = if (options.history) + var snapshot_opt: ?History.Change.GridLayout = if (options.history) try file.captureGridLayoutSnapshot() else null; errdefer if (snapshot_opt) |snap| { - var ch = fizzy.Internal.History.Change{ .grid_layout = snap }; + var ch = History.Change{ .grid_layout = snap }; ch.deinit(); }; @@ -3583,7 +3585,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { const new_sprite_count: usize = @as(usize, new_cols) * @as(usize, new_rows); const old_sprite_count = file.sprites.len; - file.sprites.resize(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed; + file.sprites.resize(Globals.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; if (new_sprite_count > old_sprite_count) { var i: usize = old_sprite_count; @@ -3592,7 +3594,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { } } - var new_selected = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count); + var new_selected = try std.DynamicBitSet.initEmpty(Globals.allocator(), new_sprite_count); const sel_copy = @min(old_sprite_count, new_sprite_count); for (0..sel_copy) |i| { if (file.editor.selected_sprites.isSet(i)) new_selected.set(i); @@ -3605,7 +3607,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { file.columns = new_cols; file.rows = new_rows; - fizzy.render.destroyLayerCompositeResources(file); + pixelart.render.destroyLayerCompositeResources(file); file.invalidateActiveLayerTransparencyMaskCache(); if (snapshot_opt) |snap| { @@ -3638,12 +3640,12 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { // Capture undo state up front. If allocation fails we abort *before* mutating, so the file // is left untouched and the user can retry. - var snapshot_opt: ?fizzy.Internal.History.Change.GridLayout = if (options.history) + var snapshot_opt: ?History.Change.GridLayout = if (options.history) try file.captureGridLayoutSnapshot() else null; errdefer if (snapshot_opt) |snap| { - var ch = fizzy.Internal.History.Change{ .grid_layout = snap }; + var ch = History.Change{ .grid_layout = snap }; ch.deinit(); }; @@ -3672,7 +3674,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { var old_layer = file.layers.get(layer_index); const old_pix = old_layer.pixels(); - var new_layer = fizzy.Internal.Layer.init( + var new_layer = Layer.init( old_layer.id, old_layer.name, new_w, @@ -3693,7 +3695,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { while (nrow < @min(new_rows, old_rows)) : (nrow += 1) { var ncol: u32 = 0; while (ncol < @min(new_cols, old_cols)) : (ncol += 1) { - const blk = fizzy.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw, new_rh, options.anchor); + const blk = pixelart.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw, new_rh, options.anchor); if (blk.sw == 0 or blk.sh == 0) continue; const src_x0: u32 = ncol * old_cw + blk.sx; @@ -3723,25 +3725,25 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { file.editor.temporary_layer.deinit(); file.editor.selection_layer.deinit(); file.editor.transform_layer.deinit(); - file.editor.temporary_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; - file.editor.selection_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; - file.editor.transform_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.temporary_layer = Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.selection_layer = Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.transform_layer = Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; // Sprite origins reset: cell positions and meaning change with cell size, so re-anchoring is undefined. file.sprites.shrinkRetainingCapacity(0); const new_sprite_count: usize = @as(usize, new_cols) * @as(usize, new_rows); var i: usize = 0; while (i < new_sprite_count) : (i += 1) { - file.sprites.append(fizzy.app.allocator, .{ .origin = .{ 0.0, 0.0 } }) catch return error.MemoryAllocationFailed; + file.sprites.append(Globals.allocator(), .{ .origin = .{ 0.0, 0.0 } }) catch return error.MemoryAllocationFailed; } file.editor.selected_sprites.deinit(); - file.editor.selected_sprites = std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed; + file.editor.selected_sprites = std.DynamicBitSet.initEmpty(Globals.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; file.editor.checkerboard.deinit(); - file.editor.checkerboard = std.DynamicBitSet.initEmpty(fizzy.app.allocator, @as(usize, new_w) * @as(usize, new_h)) catch return error.MemoryAllocationFailed; + file.editor.checkerboard = std.DynamicBitSet.initEmpty(Globals.allocator(), @as(usize, new_w) * @as(usize, new_h)) catch return error.MemoryAllocationFailed; for (0..@as(usize, new_w) * @as(usize, new_h)) |idx| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); + const value = pixelart.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); file.editor.checkerboard.setValue(idx, value); } @@ -3756,7 +3758,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { file.columns = new_cols; file.rows = new_rows; - fizzy.render.destroyLayerCompositeResources(file); + pixelart.render.destroyLayerCompositeResources(file); file.invalidateActiveLayerTransparencyMaskCache(); if (snapshot_opt) |snap| { @@ -3788,12 +3790,12 @@ pub fn saveAsync(self: *File) !void { // Snapshot all save-relevant data on the GUI thread NOW, before the worker // could observe a torn `self.layers` (the user can still draw / add layers // while the async save runs). Worker reads only the snapshot. - const snap_ptr = fizzy.app.allocator.create(SaveSnapshot) catch |err| { + const snap_ptr = Globals.allocator().create(SaveSnapshot) catch |err| { self.setSaving(false); return err; }; - snap_ptr.* = SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator) catch |err| { - fizzy.app.allocator.destroy(snap_ptr); + snap_ptr.* = SaveSnapshot.fromFileOnGuiThread(self, Globals.allocator()) catch |err| { + Globals.allocator().destroy(snap_ptr); self.setSaving(false); return err; }; @@ -3812,8 +3814,8 @@ pub fn saveAsync(self: *File) !void { .window = dvui.currentWindow(), .snap = snap_ptr, }) catch |err| { - snap_ptr.deinit(fizzy.app.allocator); - fizzy.app.allocator.destroy(snap_ptr); + snap_ptr.deinit(Globals.allocator()); + Globals.allocator().destroy(snap_ptr); self.setSaving(false); return err; }; @@ -3825,10 +3827,10 @@ pub fn saveAsync(self: *File) !void { } } -pub fn external(self: File, allocator: std.mem.Allocator) !fizzy.File { - const layers = try allocator.alloc(fizzy.Layer, self.layers.slice().len); - const sprites = try allocator.alloc(fizzy.Sprite, self.sprites.slice().len); - const animations = try allocator.alloc(fizzy.Animation, self.animations.slice().len); +pub fn external(self: File, allocator: std.mem.Allocator) !pixelart.File { + const layers = try allocator.alloc(pixelart.Layer, self.layers.slice().len); + const sprites = try allocator.alloc(pixelart.Sprite, self.sprites.slice().len); + const animations = try allocator.alloc(pixelart.Animation, self.animations.slice().len); for (layers, 0..) |*working_layer, i| { working_layer.name = try allocator.dupe(u8, self.layers.items(.name)[i]); @@ -3846,7 +3848,7 @@ pub fn external(self: File, allocator: std.mem.Allocator) !fizzy.File { } return .{ - .version = fizzy.version, + .version = pixelart.version, .columns = self.columns, .rows = self.rows, .column_width = self.column_width, diff --git a/src/plugins/pixelart/internal/History.zig b/src/plugins/pixelart/src/internal/History.zig similarity index 87% rename from src/plugins/pixelart/internal/History.zig rename to src/plugins/pixelart/src/internal/History.zig index 45025e7c..8b9e501a 100644 --- a/src/plugins/pixelart/internal/History.zig +++ b/src/plugins/pixelart/src/internal/History.zig @@ -1,11 +1,11 @@ const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); -const pixelart = @import("../plugin.zig"); const zgui = @import("zgui"); const History = @This(); -const Editor = fizzy.Editor; const dvui = @import("dvui"); const Layer = @import("Layer.zig"); +const pixelart = @import("../../pixelart.zig"); +const plugin = @import("../plugin.zig"); +const Globals = pixelart.Globals; pub const Action = enum { undo, redo }; pub const RestoreDelete = enum { restore, delete }; @@ -58,7 +58,7 @@ pub const Change = union(ChangeType) { pub const AnimationFrames = struct { index: usize, - frames: []fizzy.Animation.Frame, + frames: []pixelart.Animation.Frame, }; pub const AnimationRestoreDelete = struct { @@ -188,7 +188,7 @@ pub const Change = union(ChangeType) { .selected = 0, } }, .layer_name => .{ .animation_name = .{ - .name = [_:0]u8{0} ** Editor.Constants.max_name_len, + .name = [_:0]u8{0} ** pixelart.max_name_len, .index = 0, } }, else => error.NotSupported, @@ -198,25 +198,25 @@ pub const Change = union(ChangeType) { pub fn deinit(self: *Change) void { switch (self.*) { .pixels => |*pixels| { - fizzy.app.allocator.free(pixels.indices); - fizzy.app.allocator.free(pixels.values); + Globals.allocator().free(pixels.indices); + Globals.allocator().free(pixels.values); }, .origins => |*origins| { - fizzy.app.allocator.free(origins.indices); - fizzy.app.allocator.free(origins.values); + Globals.allocator().free(origins.indices); + Globals.allocator().free(origins.values); }, .layers_order => |*layers_order| { - fizzy.app.allocator.free(layers_order.order); + Globals.allocator().free(layers_order.order); }, .layer_merge => |*layer_merge| { - fizzy.app.allocator.free(layer_merge.dest_pixels_before); + Globals.allocator().free(layer_merge.dest_pixels_before); layer_merge.dest_mask_before.deinit(); }, .grid_layout => |*gl| { - for (gl.layer_pixels) |buf| fizzy.app.allocator.free(buf); - fizzy.app.allocator.free(gl.layer_pixels); - fizzy.app.allocator.free(gl.layer_ids); - fizzy.app.allocator.free(gl.sprite_origins); + for (gl.layer_pixels) |buf| Globals.allocator().free(buf); + Globals.allocator().free(gl.layer_pixels); + Globals.allocator().free(gl.layer_ids); + Globals.allocator().free(gl.sprite_origins); }, else => {}, } @@ -230,8 +230,8 @@ redo_stack: std.array_list.Managed(Change), undo_layer_data_stack: std.array_list.Managed([][][4]u8), redo_layer_data_stack: std.array_list.Managed([][][4]u8), -undo_animation_data_stack: std.array_list.Managed([][]fizzy.Animation.Frame), -redo_animation_data_stack: std.array_list.Managed([][]fizzy.Animation.Frame), +undo_animation_data_stack: std.array_list.Managed([][]pixelart.Animation.Frame), +redo_animation_data_stack: std.array_list.Managed([][]pixelart.Animation.Frame), undo_sprite_data_stack: std.array_list.Managed([][2]f32), redo_sprite_data_stack: std.array_list.Managed([][2]f32), @@ -244,8 +244,8 @@ pub fn init(allocator: std.mem.Allocator) History { .undo_layer_data_stack = std.array_list.Managed([][][4]u8).init(allocator), .redo_layer_data_stack = std.array_list.Managed([][][4]u8).init(allocator), - .undo_animation_data_stack = std.array_list.Managed([][]fizzy.Animation.Frame).init(allocator), - .redo_animation_data_stack = std.array_list.Managed([][]fizzy.Animation.Frame).init(allocator), + .undo_animation_data_stack = std.array_list.Managed([][]pixelart.Animation.Frame).init(allocator), + .redo_animation_data_stack = std.array_list.Managed([][]pixelart.Animation.Frame).init(allocator), .undo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator), .redo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator), @@ -253,12 +253,12 @@ pub fn init(allocator: std.mem.Allocator) History { } pub fn append(self: *History, change: Change) !void { - const track_pixels = fizzy.perf.record and std.meta.activeTag(change) == .pixels; + const track_pixels = pixelart.perf.record and std.meta.activeTag(change) == .pixels; const pixel_slots: usize = if (track_pixels) switch (change) { .pixels => |p| p.indices.len, else => 0, } else 0; - const t_hist: i128 = if (track_pixels) fizzy.perf.nanoTimestamp() else 0; + const t_hist: i128 = if (track_pixels) pixelart.perf.nanoTimestamp() else 0; if (self.redo_stack.items.len > 0) { for (self.redo_stack.items) |*c| { @@ -270,9 +270,9 @@ pub fn append(self: *History, change: Change) !void { if (self.redo_layer_data_stack.items.len > 0) { for (self.redo_layer_data_stack.items) |data| { for (data) |layer| { - fizzy.app.allocator.free(layer); + Globals.allocator().free(layer); } - fizzy.app.allocator.free(data); + Globals.allocator().free(data); } self.redo_layer_data_stack.clearRetainingCapacity(); } @@ -364,13 +364,13 @@ pub fn append(self: *History, change: Change) !void { } if (track_pixels and t_hist != 0) { - fizzy.perf.history_append_pixels_ns +%= @intCast(fizzy.perf.nanoTimestamp() - t_hist); - fizzy.perf.history_append_pixels_calls += 1; - fizzy.perf.history_append_pixels_slots +%= pixel_slots; + pixelart.perf.history_append_pixels_ns +%= @intCast(pixelart.perf.nanoTimestamp() - t_hist); + pixelart.perf.history_append_pixels_calls += 1; + pixelart.perf.history_append_pixels_slots +%= pixel_slots; } } -fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { +fn layerMergeUndo(file: *pixelart.internal.File, lm: *Change.LayerMerge) !void { const dest_i = for (file.layers.items(.id), 0..) |id, i| { if (id == lm.dest_layer_id) break i; } else return error.InvalidLayerMerge; @@ -378,21 +378,21 @@ fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { var dest = file.layers.get(dest_i); @memcpy(dest.pixels(), lm.dest_pixels_before); dest.mask.deinit(); - dest.mask = try lm.dest_mask_before.clone(fizzy.app.allocator); + dest.mask = try lm.dest_mask_before.clone(Globals.allocator()); dest.invalidate(); file.layers.set(dest_i, dest); const restored = file.deleted_layers.pop() orelse return error.InvalidLayerMerge; - try file.layers.insert(fizzy.app.allocator, lm.source_index, restored); + try file.layers.insert(Globals.allocator(), lm.source_index, restored); file.editor.layer_composite_dirty = true; file.editor.split_composite_dirty = true; file.selected_layer_index = lm.source_index; - fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); + Globals.state.host.setActiveSidebarView(plugin.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); } -fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { +fn layerMergeRedo(file: *pixelart.internal.File, lm: *Change.LayerMerge) !void { const src_i = for (file.layers.items(.id), 0..) |id, i| { if (id == lm.source_layer_id) break i; } else return error.InvalidLayerMerge; @@ -420,7 +420,7 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { dest.invalidate(); file.layers.set(dest_i, dest); - try file.deleted_layers.append(fizzy.app.allocator, file.layers.slice().get(src_i)); + try file.deleted_layers.append(Globals.allocator(), file.layers.slice().get(src_i)); file.layers.orderedRemove(src_i); file.editor.layer_composite_dirty = true; @@ -430,13 +430,13 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { .up => dest_i, .down => dest_i - 1, }; - fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); + Globals.state.host.setActiveSidebarView(plugin.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); } // Handling cases in this function details how an undo/redo action works, and must be symmetrical. // This means that `change` needs to be modified to contain the active state prior to changing the active state -pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !void { +pub fn undoRedo(self: *History, file: *pixelart.internal.File, action: Action) !void { var active_stack = switch (action) { .undo => &self.undo_stack, .redo => &self.redo_stack, @@ -459,8 +459,8 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi // direct `@intCast` to `usize` crashes the safe-mode build with an "integer cast // truncates value" panic every time the user undoes/redoes. `id_extra` only needs // to be a salt that varies between toasts, so truncate via u128 → low bits of usize. - const ts_us: u128 = @intCast(@divTrunc(fizzy.perf.nanoTimestamp(), 1000)); - const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), @truncate(ts_us), file.editor.canvas.id, fizzy.dvui.toastDisplay, 2_000_000); + const ts_us: u128 = @intCast(@divTrunc(pixelart.perf.nanoTimestamp(), 1000)); + const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), @truncate(ts_us), file.editor.canvas.id, pixelart.core.dvui.toastDisplay, 2_000_000); const id = id_mutex.id; const action_text = switch (action) { .undo => "Undo:", @@ -619,14 +619,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi //try file.editor.selected_sprites.append(sprite_index); } - fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites); + Globals.state.host.setActiveSidebarView(plugin.view_sprites); }, .layers_order => |*layers_order| { file.editor.layer_composite_dirty = true; file.editor.split_composite_dirty = true; // `new_order` holds layer ids (u64 in the on-disk format), not // indices — `layers_order.order` below is `[]u64` so this matches. - var new_order = try fizzy.app.allocator.alloc(u64, layers_order.order.len); + var new_order = try Globals.allocator().alloc(u64, layers_order.order.len); for (0..file.layers.len) |layer_index| { new_order[layer_index] = file.layers.items(.id)[layer_index]; } @@ -656,7 +656,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi } @memcpy(layers_order.order, new_order); - fizzy.app.allocator.free(new_order); + Globals.allocator().free(new_order); file.invalidateActiveLayerTransparencyMaskCache(); }, .layer_restore_delete => |*layer_restore_delete| { @@ -665,24 +665,24 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi const a = layer_restore_delete.action; switch (a) { .restore => { - try file.layers.insert(fizzy.app.allocator, layer_restore_delete.index, file.deleted_layers.pop().?); + try file.layers.insert(Globals.allocator(), layer_restore_delete.index, file.deleted_layers.pop().?); layer_restore_delete.action = .delete; }, .delete => { - try file.deleted_layers.append(fizzy.app.allocator, file.layers.slice().get(layer_restore_delete.index)); + try file.deleted_layers.append(Globals.allocator(), file.layers.slice().get(layer_restore_delete.index)); file.layers.orderedRemove(layer_restore_delete.index); layer_restore_delete.action = .restore; }, } - fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); + Globals.state.host.setActiveSidebarView(plugin.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); }, .layer_name => |*layer_name| { - const name = try fizzy.app.allocator.dupe(u8, file.layers.items(.name)[layer_name.index]); - fizzy.app.allocator.free(file.layers.items(.name)[layer_name.index]); - file.layers.items(.name)[layer_name.index] = try fizzy.app.allocator.dupe(u8, layer_name.name); + const name = try Globals.allocator().dupe(u8, file.layers.items(.name)[layer_name.index]); + Globals.allocator().free(file.layers.items(.name)[layer_name.index]); + file.layers.items(.name)[layer_name.index] = try Globals.allocator().dupe(u8, layer_name.name); layer_name.name = name; - fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); + Globals.state.host.setActiveSidebarView(plugin.view_tools); }, .layer_settings => |*layer_settings| { const idx = layer_settings.index; @@ -701,21 +701,21 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (visibility_changed) { file.editor.split_composite_dirty = true; } - fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools); + Globals.state.host.setActiveSidebarView(plugin.view_tools); }, .animation_restore_delete => |*animation_restore_delete| { const a = animation_restore_delete.action; switch (a) { .restore => { const animation = file.deleted_animations.pop().?; - try file.animations.insert(fizzy.app.allocator, animation_restore_delete.index, animation); + try file.animations.insert(Globals.allocator(), animation_restore_delete.index, animation); animation_restore_delete.action = .delete; file.selected_animation_index = animation_restore_delete.index; }, .delete => { const animation = file.animations.slice().get(animation_restore_delete.index); file.animations.orderedRemove(animation_restore_delete.index); - try file.deleted_animations.append(fizzy.app.allocator, animation); + try file.deleted_animations.append(Globals.allocator(), animation); animation_restore_delete.action = .restore; if (file.selected_animation_index) |selected_animation_index| { @@ -727,14 +727,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi } }, } - fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites); + Globals.state.host.setActiveSidebarView(plugin.view_sprites); }, .animation_name => |*animation_name| { - const name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[animation_name.index]); - fizzy.app.allocator.free(file.animations.items(.name)[animation_name.index]); - file.animations.items(.name)[animation_name.index] = try fizzy.app.allocator.dupe(u8, animation_name.name); + const name = try Globals.allocator().dupe(u8, file.animations.items(.name)[animation_name.index]); + Globals.allocator().free(file.animations.items(.name)[animation_name.index]); + file.animations.items(.name)[animation_name.index] = try Globals.allocator().dupe(u8, animation_name.name); animation_name.name = name; - fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites); + Globals.state.host.setActiveSidebarView(plugin.view_sprites); }, .animation_settings => {}, .animation_order => |*animation_order| { @@ -772,7 +772,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi const history_frames = &animation_frames.frames; const current_frames = &file.animations.items(.frames)[animation_frames.index]; - std.mem.swap([]fizzy.Animation.Frame, history_frames, current_frames); + std.mem.swap([]pixelart.Animation.Frame, history_frames, current_frames); file.selected_animation_index = animation_frames.index; }, @@ -783,7 +783,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi resize.height = file.height(); var layer_data: ?[][][4]u8 = null; - var animation_data: ?[][]fizzy.Animation.Frame = null; + var animation_data: ?[][]pixelart.Animation.Frame = null; var sprite_data: ?[][2]f32 = null; switch (action) { @@ -796,9 +796,9 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (self.undo_animation_data_stack.pop()) |ad| { animation_data = ad; - var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len); + var anim_data = try Globals.allocator().alloc([]pixelart.Animation.Frame, file.animations.len); for (0..file.animations.len) |animation_index| { - anim_data[animation_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; + anim_data[animation_index] = Globals.allocator().dupe(pixelart.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; } try self.redo_animation_data_stack.append(anim_data); } @@ -806,7 +806,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (self.undo_sprite_data_stack.pop()) |sd| { sprite_data = sd; - const new_sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount()); + const new_sprite_data = try Globals.allocator().alloc([2]f32, file.spriteCount()); for (0..file.spriteCount()) |sprite_index| { new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index]; } @@ -821,16 +821,16 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (self.redo_animation_data_stack.pop()) |ad| { animation_data = ad; - var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len); + var anim_data = try Globals.allocator().alloc([]pixelart.Animation.Frame, file.animations.len); for (0..file.animations.len) |animation_index| { - anim_data[animation_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; + anim_data[animation_index] = Globals.allocator().dupe(pixelart.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; } try self.undo_animation_data_stack.append(anim_data); } if (self.redo_sprite_data_stack.pop()) |sd| { sprite_data = sd; - const new_sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount()); + const new_sprite_data = try Globals.allocator().alloc([2]f32, file.spriteCount()); for (0..file.spriteCount()) |sprite_index| { new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index]; } @@ -849,11 +849,11 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi }) catch return error.ResizeError; if (animation_data) |ad| { - fizzy.app.allocator.free(ad); + Globals.allocator().free(ad); } if (sprite_data) |sd| { - fizzy.app.allocator.free(sd); + Globals.allocator().free(sd); } file.invalidateActiveLayerTransparencyMaskCache(); @@ -945,16 +945,16 @@ pub fn clearRetainingCapacity(self: *History) void { pub fn deinit(self: *History) void { for (self.undo_layer_data_stack.items) |data| { for (data) |layer| { - fizzy.app.allocator.free(layer); + Globals.allocator().free(layer); } - fizzy.app.allocator.free(data); + Globals.allocator().free(data); } for (self.redo_layer_data_stack.items) |data| { for (data) |layer| { - fizzy.app.allocator.free(layer); + Globals.allocator().free(layer); } - fizzy.app.allocator.free(data); + Globals.allocator().free(data); } self.undo_layer_data_stack.deinit(); diff --git a/src/plugins/pixelart/internal/Layer.zig b/src/plugins/pixelart/src/internal/Layer.zig similarity index 82% rename from src/plugins/pixelart/internal/Layer.zig rename to src/plugins/pixelart/src/internal/Layer.zig index 21a4fe60..29a2bd66 100644 --- a/src/plugins/pixelart/internal/Layer.zig +++ b/src/plugins/pixelart/src/internal/Layer.zig @@ -1,7 +1,8 @@ const std = @import("std"); const dvui = @import("dvui"); -const fizzy = @import("../../../fizzy.zig"); const zip = @import("zip"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; const Layer = @This(); @@ -33,13 +34,13 @@ dirty: bool = false, pub fn init(id: u64, name: []const u8, width: u32, height: u32, default_color: dvui.Color, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { const num_pixels = width * height; - const p = fizzy.app.allocator.alloc([4]u8, num_pixels) catch return error.MemoryAllocationFailed; + const p = Globals.allocator().alloc([4]u8, num_pixels) catch return error.MemoryAllocationFailed; @memset(p, default_color.toRGBA()); return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = .{ .pixelsPMA = .{ .rgba = @ptrCast(p), @@ -49,29 +50,29 @@ pub fn init(id: u64, name: []const u8, width: u32, height: u32, default_color: d .invalidation = invalidation, }, }, - .mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, num_pixels) catch return error.MemoryAllocationFailed, + .mask = std.DynamicBitSet.initEmpty(Globals.allocator(), num_pixels) catch return error.MemoryAllocationFailed, }; } pub fn fromImageFilePath(id: u64, name: []const u8, path: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { - const source = fizzy.image.fromImageFilePath(name, path, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, pixelCountForSource(source)) catch return error.MemoryAllocationFailed; + const source = pixelart.image.fromImageFilePath(name, path, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; } pub fn fromImageFileBytes(id: u64, name: []const u8, image_bytes: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { - const source = fizzy.image.fromImageFileBytes(name, image_bytes, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, pixelCountForSource(source)) catch return error.MemoryAllocationFailed; + const source = pixelart.image.fromImageFileBytes(name, image_bytes, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; @@ -79,12 +80,12 @@ pub fn fromImageFileBytes(id: u64, name: []const u8, image_bytes: []const u8, in pub fn fromPixelsPMA(id: u64, name: []const u8, pixel_data: []dvui.Color.PMA, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { if (pixel_data.len != width * height) return error.InvalidPixelDataLength; - const source = fizzy.image.fromPixelsPMA(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; + const source = pixelart.image.fromPixelsPMA(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; @@ -92,24 +93,24 @@ pub fn fromPixelsPMA(id: u64, name: []const u8, pixel_data: []dvui.Color.PMA, wi pub fn fromPixels(id: u64, name: []const u8, pixel_data: []u8, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { if (pixel_data.len != width * height) return error.InvalidPixelDataLength; - const source = fizzy.image.fromPixels(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; + const source = pixelart.image.fromPixels(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; } pub fn fromTexture(id: u64, name: []const u8, texture: dvui.Texture, invalidation: dvui.ImageSource.InvalidationStrategy) Layer { - const source = fizzy.fs.sourceFromTexture(name, texture, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, pixelCountForSource(source)) catch return error.MemoryAllocationFailed; + const source = pixelart.fs.sourceFromTexture(name, texture, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; @@ -121,53 +122,53 @@ pub fn size(self: Layer) dvui.Size { pub fn deinit(self: *Layer) void { switch (self.source) { - .imageFile => |image| fizzy.app.allocator.free(image.bytes), - .pixels => |p| fizzy.app.allocator.free(p.rgba), - .pixelsPMA => |p| fizzy.app.allocator.free(p.rgba), + .imageFile => |image| Globals.allocator().free(image.bytes), + .pixels => |p| Globals.allocator().free(p.rgba), + .pixelsPMA => |p| Globals.allocator().free(p.rgba), .texture => |t| dvui.textureDestroyLater(t), } - fizzy.app.allocator.free(self.name); + Globals.allocator().free(self.name); self.mask.deinit(); } /// Casts the source pixels into a slice of [4]u8 pub fn pixels(self: *const Layer) [][4]u8 { - return fizzy.image.pixels(self.source); + return pixelart.image.pixels(self.source); } /// Caller owns memory that must be freed! pub fn pixelsFromRect(self: *const Layer, allocator: std.mem.Allocator, rect: dvui.Rect) ?[][4]u8 { - return fizzy.image.pixelsFromRect(allocator, self.source, rect); + return pixelart.image.pixelsFromRect(allocator, self.source, rect); } /// Casts the source pixels into a slice of bytes pub fn bytes(self: *const Layer) []u8 { - return fizzy.image.bytes(self.source); + return pixelart.image.bytes(self.source); } /// Returns the index of the pixel at the given point /// returns null if the point is out of bounds pub fn pixelIndex(self: *Layer, p: dvui.Point) ?usize { - return fizzy.image.pixelIndex(self.source, p); + return pixelart.image.pixelIndex(self.source, p); } /// Returns the point at the given index /// returns null if the index is out of bounds pub fn point(self: *Layer, index: usize) ?dvui.Point { - return fizzy.image.point(self.source, index); + return pixelart.image.point(self.source, index); } /// Returns the color at the given point /// returns null if the point is out of bounds pub fn pixel(self: *Layer, p: dvui.Point) ?[4]u8 { - return fizzy.image.pixel(self.source, p); + return pixelart.image.pixel(self.source, p); } /// Sets the color at the given point /// does not invalidate the layer pub fn setPixel(self: *Layer, p: dvui.Point, color: [4]u8) void { - fizzy.image.setPixel(self.source, p, color); + pixelart.image.setPixel(self.source, p, color); } /// Sets the mask at the given point @@ -217,7 +218,7 @@ pub fn setColorFromMask(self: *Layer, color: dvui.Color) void { pub fn floodMaskPoint(layer: *Layer, p: dvui.Point, bounds: dvui.Rect, value: bool) !void { if (!bounds.contains(p)) return; - var queue = std.array_list.Managed(dvui.Point).init(fizzy.app.allocator); + var queue = std.array_list.Managed(dvui.Point).init(Globals.allocator()); defer queue.deinit(); queue.append(p) catch return error.MemoryAllocationFailed; @@ -249,7 +250,7 @@ pub fn floodMaskPoint(layer: *Layer, p: dvui.Point, bounds: dvui.Rect, value: bo } pub fn setPixelIndex(self: *Layer, index: usize, color: [4]u8) void { - fizzy.image.setPixelIndex(self.source, index, color); + pixelart.image.setPixelIndex(self.source, index, color); } pub const ShapeOffsetResult = struct { @@ -266,8 +267,8 @@ pub fn invalidate(self: *Layer) void { /// Only used for handling getting the pixels surrounding the origin /// for stroke sizes larger than 1 pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usize) ?ShapeOffsetResult { - const shape = fizzy.pixelart.tools.stroke_shape; - const s: i32 = @intCast(fizzy.pixelart.tools.stroke_size); + const shape = Globals.state.tools.stroke_shape; + const s: i32 = @intCast(Globals.state.tools.stroke_size); if (s == 1) { if (current_index != 0) @@ -322,15 +323,15 @@ pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usiz /// Porter–Duff "source over" for premultiplied RGBA (`pixelsPMA` byte layout). /// `top` is composited over `bottom`. The implementation is generic byte math and /// lives in `core` math; re-exported here for the pixel-art call sites. -pub const blendPmaSrcOver = fizzy.math.blendPmaSrcOver; +pub const blendPmaSrcOver = pixelart.math.blendPmaSrcOver; pub fn clearRect(self: *Layer, rect: dvui.Rect) void { - fizzy.image.clearRect(self.source, rect); + pixelart.image.clearRect(self.source, rect); self.invalidate(); } pub fn setRect(self: *Layer, rect: dvui.Rect, color: [4]u8) void { - fizzy.image.setRect(self.source, rect, color); + pixelart.image.setRect(self.source, rect, color); self.invalidate(); } @@ -412,10 +413,10 @@ pub fn writeSourceToZip( const w = @as(c_int, @intFromFloat(s.w)); const h = @as(c_int, @intFromFloat(s.h)); - var writer = std.Io.Writer.Allocating.init(fizzy.pixelart.host.arena()); + var writer = std.Io.Writer.Allocating.init(Globals.state.host.arena()); - try fizzy.image.ensurePngWriterBuffer(&writer.writer); - try dvui.PNGEncoder.writeWithResolution(&writer.writer, fizzy.image.bytes(source), @intCast(w), @intCast(h), resolution); + try pixelart.image.ensurePngWriterBuffer(&writer.writer); + try dvui.PNGEncoder.writeWithResolution(&writer.writer, pixelart.image.bytes(source), @intCast(w), @intCast(h), resolution); if (@as(?*zip.struct_zip_t, @ptrCast(zip_file))) |z| { _ = zip.zip_entry_write(z, writer.written().ptr, @as(usize, writer.written().len)); @@ -423,7 +424,7 @@ pub fn writeSourceToZip( } pub fn writeSourceToPng(layer: *const Layer, path: []const u8) !void { - return fizzy.fs.writeSourceToPng(layer.source, path); + return pixelart.fs.writeSourceToPng(layer.source, path); } pub fn resize(layer: *Layer, new_size: dvui.Size) !void { @@ -432,7 +433,7 @@ pub fn resize(layer: *Layer, new_size: dvui.Size) !void { var new_layer = Layer.init( layer.id, - fizzy.app.allocator.dupe(u8, layer.name) catch return error.MemoryAllocationFailed, + Globals.allocator().dupe(u8, layer.name) catch return error.MemoryAllocationFailed, @as(u32, @intFromFloat(new_size.w)), @as(u32, @intFromFloat(new_size.h)), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, @@ -460,14 +461,14 @@ pub fn resize(layer: *Layer, new_size: dvui.Size) !void { /// Tighten `src` to the smallest sub-rect of this layer containing every opaque pixel. /// Returns null when `src` is empty, off-layer, or covers only fully-transparent pixels. /// -/// Pure scalar logic lives in `fizzy.algorithms.reduce.reduce` so it can be exercised by +/// Pure scalar logic lives in `pixelart.algorithms.reduce.reduce` so it can be exercised by /// unit tests without dvui / fizzy globals — see that module for the contract details. pub fn reduce(layer: *Layer, src: dvui.Rect) ?dvui.Rect { const sz = layer.size(); const layer_w: u32 = @intFromFloat(sz.w); const layer_h: u32 = @intFromFloat(sz.h); - const r = fizzy.algorithms.reduce.reduce(layer.pixels(), layer_w, layer_h, .{ + const r = pixelart.algorithms.reduce.reduce(layer.pixels(), layer_w, layer_h, .{ .x = @intFromFloat(src.x), .y = @intFromFloat(src.y), .w = @intFromFloat(src.w), diff --git a/src/plugins/pixelart/internal/Palette.zig b/src/plugins/pixelart/src/internal/Palette.zig similarity index 81% rename from src/plugins/pixelart/internal/Palette.zig rename to src/plugins/pixelart/src/internal/Palette.zig index 63e1b0f1..bc15b826 100644 --- a/src/plugins/pixelart/internal/Palette.zig +++ b/src/plugins/pixelart/src/internal/Palette.zig @@ -1,8 +1,9 @@ const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const palette_parse = @import("palette_parse.zig"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; pub const Palette = @This(); @@ -19,8 +20,8 @@ pub fn loadFromFile(allocator: std.mem.Allocator, file: []const u8) !Palette { const ext = std.fs.path.extension(file); if (std.mem.eql(u8, ext, ".hex")) { - if (fizzy.fs.read(fizzy.app.allocator, dvui.io, file) catch null) |read| { - defer fizzy.app.allocator.free(read); + if (pixelart.fs.read(Globals.allocator(), dvui.io, file) catch null) |read| { + defer Globals.allocator().free(read); return loadFromBytes(allocator, std.fs.path.basename(file), read); } @@ -46,6 +47,6 @@ pub fn loadFromBytes(allocator: std.mem.Allocator, name: []const u8, bytes: []co } pub fn deinit(self: *Palette) void { - fizzy.app.allocator.free(self.name); - fizzy.app.allocator.free(self.colors); + Globals.allocator().free(self.name); + Globals.allocator().free(self.colors); } diff --git a/src/plugins/pixelart/internal/Sprite.zig b/src/plugins/pixelart/src/internal/Sprite.zig similarity index 100% rename from src/plugins/pixelart/internal/Sprite.zig rename to src/plugins/pixelart/src/internal/Sprite.zig diff --git a/src/plugins/pixelart/internal/grid_layout_validate.zig b/src/plugins/pixelart/src/internal/grid_layout_validate.zig similarity index 100% rename from src/plugins/pixelart/internal/grid_layout_validate.zig rename to src/plugins/pixelart/src/internal/grid_layout_validate.zig diff --git a/src/plugins/pixelart/internal/layer_order.zig b/src/plugins/pixelart/src/internal/layer_order.zig similarity index 100% rename from src/plugins/pixelart/internal/layer_order.zig rename to src/plugins/pixelart/src/internal/layer_order.zig diff --git a/src/plugins/pixelart/internal/palette_parse.zig b/src/plugins/pixelart/src/internal/palette_parse.zig similarity index 100% rename from src/plugins/pixelart/internal/palette_parse.zig rename to src/plugins/pixelart/src/internal/palette_parse.zig diff --git a/src/plugins/pixelart/panel/sprites.zig b/src/plugins/pixelart/src/panel/sprites.zig similarity index 97% rename from src/plugins/pixelart/panel/sprites.zig rename to src/plugins/pixelart/src/panel/sprites.zig index 76c03c15..f2b6b06e 100644 --- a/src/plugins/pixelart/panel/sprites.zig +++ b/src/plugins/pixelart/src/panel/sprites.zig @@ -1,11 +1,11 @@ const std = @import("std"); const icons = @import("icons"); const dvui = @import("dvui"); -const fizzy = @import("../../../fizzy.zig"); -const Editor = fizzy.Editor; -const ReflectionLagSample = fizzy.sprite_render.ReflectionLagSample; -const reflection_surface_cols = fizzy.sprite_render.reflection_surface_cols; -const wsurf = fizzy.water_surface; +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; +const ReflectionLagSample = pixelart.sprite_render.ReflectionLagSample; +const reflection_surface_cols = pixelart.sprite_render.reflection_surface_cols; +const wsurf = pixelart.water_surface; const Sprites = @This(); @@ -114,12 +114,12 @@ const SpriteSlot = struct { } }; -/// Cover-flow scrub momentum tuning (sprite-index units). See `fizzy.Fling`. +/// Cover-flow scrub momentum tuning (sprite-index units). See `pixelart.Fling`. /// Mouse/trackpad release velocity is measured over a position/time window /// (`releaseWindowed`), not a per-frame EMA — the EMA converged per frame, so a quick /// flick built up too little velocity at 60 Hz (e.g. Safari on a deployed build) even /// though it worked at 120 Hz. The window is wall-clock based, so it's refresh-independent. -const sprite_fling: fizzy.Fling.Tuning = .{ +const sprite_fling: pixelart.Fling.Tuning = .{ .decay = 4.0, .min_start = 1.2, .stop = 0.6, @@ -131,7 +131,7 @@ const sprite_fling_window_s: f32 = 0.08; /// Touch scrub: a finger flick is short and bursty, so start coasting at a lower /// speed and tolerate the small gap the browser leaves before `touchend`. Velocity is /// measured over a position/time window (`releaseWindowed`) rather than the last frame. -const sprite_fling_touch: fizzy.Fling.Tuning = .{ +const sprite_fling_touch: pixelart.Fling.Tuning = .{ .decay = 4.0, .min_start = 0.6, .stop = 0.6, @@ -186,7 +186,7 @@ moved_since_press: bool = false, /// True when the active scrub began with a touch press (not mouse). drag_was_touch: bool = false, /// Release momentum for the scrub: coasts the flow after a flick, then snaps. -fling: fizzy.Fling = .{}, +fling: pixelart.Fling = .{}, /// Set once we've seeded `scroll_pos` from the initial selection. initialized: bool = false, /// Previous "flown" state (see `sideCardsFlown`), so we can fire the fly-out / @@ -209,7 +209,7 @@ prev_scroll_pos: f32 = 0.0, shelf_vel: f32 = 0.0, pub fn draw(self: *Sprites) !void { - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { const prev_clip = dvui.clip(dvui.parentGet().data().rectScale().r); defer dvui.clipSet(prev_clip); @@ -222,10 +222,10 @@ pub fn draw(self: *Sprites) !void { // Since not all panel screens will likely want shadows, which should be reserved for canvases? // Text editors, consoles, etc would likely want flat panels or to handle shadows themselves. defer { - fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .top, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .bottom, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .left, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .right, .{ .opacity = 0.15 }); + pixelart.core.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .top, .{ .opacity = 0.15 }); + pixelart.core.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .bottom, .{ .opacity = 0.15 }); + pixelart.core.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .left, .{ .opacity = 0.15 }); + pixelart.core.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .right, .{ .opacity = 0.15 }); } const parent = dvui.parentGet().data().rect; @@ -275,7 +275,7 @@ pub fn draw(self: *Sprites) !void { // ---- Animated fit-scale: aim the front sprite at a fraction of the // pane so several neighbours are visible at once. ---- const scale = blk: { - const steps = fizzy.pixelart.settings.zoom_steps; + const steps = Globals.state.settings.zoom_steps; const sprite_width = src_rect.w; const sprite_height = src_rect.h; const target_width = parent.w * 0.34; @@ -438,8 +438,8 @@ pub fn draw(self: *Sprites) !void { return; } - const perf_sp = fizzy.perf.spritePreviewBegin(); - defer fizzy.perf.spritePreviewEnd(perf_sp); + const perf_sp = pixelart.perf.spritePreviewBegin(); + defer pixelart.perf.spritePreviewEnd(perf_sp); const center_x = parent.center().x; // Lift the row a little so the reflection has room below it. @@ -749,7 +749,7 @@ pub fn draw(self: *Sprites) !void { const tiltness = if (max_depth > 0.0) std.math.clamp(@abs(cd.depth) / max_depth, 0.0, 1.0) else 0.0; const refl_detail = std.math.lerp(1.0, skewed_reflection_detail, tiltness); - _ = fizzy.sprite_render.sprite(SpriteSlot.src(), .{ + _ = pixelart.sprite_render.sprite(SpriteSlot.src(), .{ .source = file.layers.items(.source)[file.selected_layer_index], .file = file, .alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null, @@ -803,12 +803,12 @@ pub fn draw(self: *Sprites) !void { /// Side cards lift away during playback, while a drawing tool is active, or when /// `settings.scrolling_cards` is off (focus mode; toggled in settings or the sprites pane). fn sideCardsFlown(playing: bool) bool { - return playing or drawingToolActive() or !fizzy.pixelart.settings.scrolling_cards; + return playing or drawingToolActive() or !Globals.state.settings.scrolling_cards; } /// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee). fn drawingToolActive() bool { - return switch (fizzy.pixelart.tools.current) { + return switch (Globals.state.tools.current) { .pointer, .selection => false, .pencil, .eraser, .bucket => true, }; @@ -1050,7 +1050,7 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px // Dialogs/subwindows stack above the sprites pane in z-order but share the same // screen rect — don't capture clicks meant for their footer or chrome. - if (fizzy.dvui.canvasPointerInputSuppressed()) { + if (pixelart.core.dvui.canvasPointerInputSuppressed()) { if (dvui.captured(id)) { for (dvui.events()) |*e| { if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { @@ -1190,7 +1190,7 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px } pub fn drawAnimationControlsDialog(_: *Sprites) void { - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { const rect = dvui.parentGet().data().rectScale().r; if (dvui.parentGet().data().rect.h < 48.0) { @@ -1237,8 +1237,8 @@ pub fn drawAnimationControlsDialog(_: *Sprites) void { !fly_forced, flown, ) and !fly_forced) { - fizzy.pixelart.settings.scrolling_cards = !fizzy.pixelart.settings.scrolling_cards; - fizzy.pixelart.settings.save(fizzy.pixelart.host); + Globals.state.settings.scrolling_cards = !Globals.state.settings.scrolling_cards; + Globals.state.settings.save(Globals.state.host); dvui.refresh(null, @src(), dvui.parentGet().data().id); } } diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/src/plugin.zig similarity index 86% rename from src/plugins/pixelart/plugin.zig rename to src/plugins/pixelart/src/plugin.zig index 6a1934bb..db5fbd15 100644 --- a/src/plugins/pixelart/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -4,16 +4,19 @@ //! through the `fizzy.*` globals. Registered from `Editor.postInit`. const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); -const sdk = fizzy.sdk; +const pixelart = @import("../pixelart.zig"); +const sdk = pixelart.sdk; +const Globals = pixelart.Globals; +const State = pixelart.State; const CanvasData = @import("CanvasData.zig"); const FileWidget = @import("widgets/FileWidget.zig"); const ImageWidget = @import("widgets/ImageWidget.zig"); const PixelArtSettings = @import("Settings.zig"); const DocHandle = sdk.DocHandle; -const Internal = fizzy.Internal; +const Internal = pixelart.internal; /// Stable contribution ids (plugin-namespaced) referenced across modules. pub const view_tools = "pixelart.tools"; @@ -41,11 +44,10 @@ const vtable: sdk.Plugin.VTable = .{ .drawDocument = drawDocument, }; -/// A `DocHandle` whose `ptr` is one of this plugin's `*Internal.File`s. The shell -/// gets the owning plugin from the file-type registry and round-trips the document -/// back through these hooks, so it never needs to know the concrete pixel-art type. +/// A `DocHandle` for one of this plugin's open `*Internal.File`s. Resolved by `doc.id` +/// because `docs.files` may reallocate and stale `doc.ptr` values. fn docFile(doc: DocHandle) *Internal.File { - return @ptrCast(@alignCast(doc.ptr)); + return Globals.state.docs.fileById(doc.id).?; } /// Priority for opening `ext` (lower wins). Pixel art owns its native `.fiz`/`.pixi` @@ -111,18 +113,18 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { defer chrome.processColumnReorder(file); defer chrome.processRowReorder(file); - fizzy.perf.canvasPaneDrawn(); + pixelart.perf.canvasPaneDrawn(); - if (fizzy.pixelart.settings.show_rulers and !dvui.firstFrame(container.id)) { - defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .top, .{}); + if (Globals.state.settings.show_rulers and !dvui.firstFrame(container.id)) { + defer pixelart.core.dvui.drawEdgeShadow(container.rectScale(), .top, .{}); chrome.drawRuler(file, .horizontal); } var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); defer canvas_hbox.deinit(); - if (fizzy.pixelart.settings.show_rulers and !dvui.firstFrame(container.id)) { - defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .left, .{}); + if (Globals.state.settings.show_rulers and !dvui.firstFrame(container.id)) { + defer pixelart.core.dvui.drawEdgeShadow(container.rectScale(), .left, .{}); chrome.drawRuler(file, .vertical); } @@ -164,20 +166,17 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { var content_color = dvui.themeGet().color(.window, .fill); - switch (builtin.os.tag) { - .macos => { - content_color = if (!fizzy.pixelart.host.isMaximized()) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; - }, - .windows => { - content_color = if (!fizzy.pixelart.host.isMaximized()) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color; - }, - else => {}, + if (Globals.state.host.appliesNativeWindowOpacity()) { + content_color = if (!Globals.state.host.isMaximized()) + content_color.opacity(Globals.state.host.contentOpacity()) + else + content_color; } const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32) - fizzy.packer.atlas != null + Globals.packer.atlas != null else - fizzy.pixelart.host.folder() != null and fizzy.packer.atlas != null; + Globals.state.host.folder() != null and Globals.packer.atlas != null; // Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage). var canvas_vbox = Workspace.workspaceMainCanvasVbox(content_color, show_packed_atlas, ws.grouping); @@ -188,7 +187,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { } if (show_packed_atlas) { - const atlas = &fizzy.packer.atlas.?; + const atlas = &Globals.packer.atlas.?; var image_widget = ImageWidget.init(@src(), .{ .source = atlas.source, .canvas = &atlas.canvas, @@ -218,7 +217,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32) "Pack open files to see the preview." - else if (fizzy.pixelart.host.folder() == null) + else if (Globals.state.host.folder() == null) "Open a project folder, then pack to see the preview." else "Pack the project to see the preview."; @@ -248,10 +247,11 @@ fn redo(_: *anyopaque, doc: DocHandle) anyerror!void { } pub fn register(host: *sdk.Host) !void { - // Adopt the app-owned pixel-art state as this plugin's `state`. Stage B keeps - // it reachable through the `fizzy.pixelart` global too; Stage D drops the global - // and routes plugin access through `state` + the SDK. - plugin.state = fizzy.pixelart; + // Adopt the app-owned pixel-art state as this plugin's `state`. Wire Globals + // here too so plugin code and the shell share one injection site (App also sets + // these before State.init, but register re-syncs after postInit ordering). + Globals.state = fizzy.pixelart; + plugin.state = @ptrCast(@alignCast(fizzy.pixelart)); try host.registerPlugin(&plugin); try host.registerSidebarView(.{ .id = view_tools, @@ -289,24 +289,30 @@ pub fn register(host: *sdk.Host) !void { }); } +/// Stable `*Plugin` for constructing `DocHandle.owner` fields. +pub fn pluginPtr() *sdk.Plugin { + return &plugin; +} + fn drawTools(_: ?*anyopaque) anyerror!void { - try fizzy.pixelart.tools_pane.draw(); + try Globals.state.tools_pane.draw(); } fn drawSprites(_: ?*anyopaque) anyerror!void { - try fizzy.pixelart.sprites_pane.draw(); + try Globals.state.sprites_pane.draw(); } fn drawProject(_: ?*anyopaque) anyerror!void { try fizzy.Editor.Explorer.project.draw(); } fn drawSpritesPanel(_: ?*anyopaque) anyerror!void { - try fizzy.editor.panel.sprites.draw(); + try Globals.state.sprites_panel.draw(); } /// Pixel-art editing + tool keybinds. The shell registers its own global/region /// binds in `Keybinds.register`; this fills in the pixel-art half. Platform: see -/// `Keybinds.register` for why `fizzy.platform.isMacOS()` (not `builtin`) is used. -fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void { - if (fizzy.platform.isMacOS()) { +/// `Keybinds.register` for why `host.isMacOS()` (not `builtin`) is used. +fn contributeKeybinds(state: *anyopaque, win: *dvui.Window) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + if (st.host.isMacOS()) { try win.keybinds.putNoClobber(win.gpa, "new_file", .{ .key = .n, .command = true }); try win.keybinds.putNoClobber(win.gpa, "undo", .{ .key = .z, .command = true, .shift = false }); try win.keybinds.putNoClobber(win.gpa, "redo", .{ .key = .z, .command = true, .shift = true }); diff --git a/src/plugins/pixelart/render.zig b/src/plugins/pixelart/src/render.zig similarity index 92% rename from src/plugins/pixelart/render.zig rename to src/plugins/pixelart/src/render.zig index a3ec3daf..4cefd7e2 100644 --- a/src/plugins/pixelart/render.zig +++ b/src/plugins/pixelart/src/render.zig @@ -1,14 +1,15 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); -const perf = fizzy.perf; +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const perf = pixelart.perf; /// Monotonic frame counter, incremented once per frame from Editor.tick. pub var frame_index: u64 = 0; pub const RenderFileOptions = struct { - file: *fizzy.Internal.File, + file: *pixelart.internal.File, rs: dvui.RectScale, color_mod: dvui.Color = .white, fade: f32 = 0.0, @@ -60,7 +61,7 @@ fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void { uploadSubRectAndSyncCache( source_key, &tex, - fizzy.image.bytes(source).ptr, + pixelart.image.bytes(source).ptr, @intFromFloat(dirty.x), @intFromFloat(dirty.y), @intFromFloat(dirty.w), @@ -84,7 +85,7 @@ fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void { uploadSubRectAndSyncCache( temp_key, &tex, - fizzy.image.bytes(temp_source).ptr, + pixelart.image.bytes(temp_source).ptr, @intFromFloat(dirty.x), @intFromFloat(dirty.y), @intFromFloat(dirty.w), @@ -112,7 +113,7 @@ fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_inde if (init_opts.file.editor.isolate_layer) { if (init_opts.file.peek_layer_index) |peek_layer_index| { min_layer_index = peek_layer_index; - } else if (!fizzy.pixelart.tools_pane.layersHovered()) { + } else if (!Globals.state.tools_pane.layersHovered()) { min_layer_index = init_opts.file.selected_layer_index; } } @@ -122,11 +123,11 @@ fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_inde } /// Non-null while layer list DnD preview is active (`File.editor.layer_drag_preview_*`); maps list position → storage index. -fn layerOrderBufForDragPreview(file: *fizzy.Internal.File, buf: []usize) ?[]const usize { +fn layerOrderBufForDragPreview(file: *pixelart.internal.File, buf: []usize) ?[]const usize { const r = file.editor.layer_drag_preview_removed orelse return null; const ins = file.editor.layer_drag_preview_insert_before orelse return null; if (file.layers.len == 0 or file.layers.len > buf.len) return null; - fizzy.Internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]); + pixelart.internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]); return buf[0..file.layers.len]; } @@ -288,22 +289,22 @@ pub fn renderLayersMagnifierSample(init_opts: RenderFileOptions) !void { const vs = layerViewStateForRender(init_opts); - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(Globals.allocator()); defer path.deinit(); path.addRect(init_opts.rs.r, dvui.Rect.Physical.all(0)); - var triangles = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = init_opts.color_mod, .fade = init_opts.fade }); - defer triangles.deinit(fizzy.app.allocator); + var triangles = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade }); + defer triangles.deinit(Globals.allocator()); triangles.uvFromRectuv(init_opts.rs.r, init_opts.uv); var dimmed_triangles: ?dvui.Triangles = null; defer { - if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator); + if (dimmed_triangles) |*dt| dt.deinit(Globals.allocator()); } if (vs.needs_dimmed) { - var dt = try triangles.dupe(fizzy.app.allocator); + var dt = try triangles.dupe(Globals.allocator()); dt.color(.gray); dimmed_triangles = dt; } @@ -370,7 +371,7 @@ fn splitCompositeEligible( /// Pixel size of the flattened layer stack — prefers the first layer (`canvasPixelSize`) so the /// composite matches bitmap data even when `columns × column_width` / `rows × row_height` disagree /// (slice/grid previews use the canvas as the locked image rect). -fn layerCompositeExtent(file: *fizzy.Internal.File) struct { w: u32, h: u32 } { +fn layerCompositeExtent(file: *pixelart.internal.File) struct { w: u32, h: u32 } { const c = file.canvasPixelSize(); if (c.w > 0 and c.h > 0) return .{ .w = c.w, .h = c.h }; const w = file.width(); @@ -389,7 +390,7 @@ pub fn compositeTargetPixelFormat() dvui.enums.TexturePixelFormat { /// Rebuilds the full-canvas flattened layer texture (all layers included). /// Used when NOT actively drawing. -pub fn syncLayerComposite(file: *fizzy.Internal.File) !void { +pub fn syncLayerComposite(file: *pixelart.internal.File) !void { const ce = layerCompositeExtent(file); const w = ce.w; const h = ce.h; @@ -441,7 +442,7 @@ pub fn syncLayerComposite(file: *fizzy.Internal.File) !void { /// The "below" target flattens layers visually below (higher index), and /// the "above" target flattens layers visually above (lower index). /// Only rebuilt when the split layer changes or a structural change occurs. -fn syncSplitComposite(file: *fizzy.Internal.File) !void { +fn syncSplitComposite(file: *pixelart.internal.File) !void { const ce = layerCompositeExtent(file); const w = ce.w; const h = ce.h; @@ -526,7 +527,7 @@ fn syncSplitComposite(file: *fizzy.Internal.File) !void { /// Pre-builds split-composite GPU targets and touches temp/selection textures so the first /// stroke does not pay allocation + flatten cost. Safe to call once after open or when /// selecting a drawing tool; no-op if composites are already current. -pub fn warmupDrawingComposites(file: *fizzy.Internal.File) !void { +pub fn warmupDrawingComposites(file: *pixelart.internal.File) !void { const w0 = perf.nanoTimestamp(); try syncSplitComposite(file); _ = file.editor.temporary_layer.source.getTexture() catch null; @@ -539,7 +540,7 @@ pub fn warmupDrawingComposites(file: *fizzy.Internal.File) !void { /// from high index (visually bottom) to low index (visually top). An optional /// `skip_index` excludes a single layer. fn renderLayersIntoTarget( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, target: dvui.Texture.Target, min_index: usize, max_index: usize, @@ -563,12 +564,12 @@ fn renderLayersIntoTarget( defer dvui.clipSet(prev_clip); dvui.clipSet(image_rect); - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(Globals.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); - var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0 }); - defer tris.deinit(fizzy.app.allocator); + var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = .white, .fade = 0 }); + defer tris.deinit(Globals.allocator()); tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 }); var order_buf: [1024]usize = undefined; @@ -596,7 +597,7 @@ fn renderLayersIntoTarget( /// sprite panel then draws each card (front and reflection) as a single textured /// pass sampling this, instead of replaying the whole stack as several /// overlapping alpha-blended fills per card. Rebuilt at most once per frame. -pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void { +pub fn syncPreviewComposite(file: *pixelart.internal.File) !void { const ce = layerCompositeExtent(file); const w = ce.w; const h = ce.h; @@ -658,32 +659,32 @@ pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void { // 1) Opaque content-fill base — the transparency backdrop, matching the card. { - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(Globals.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); - var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = dvui.themeGet().color(.content, .fill), .fade = 0 }); - defer tris.deinit(fizzy.app.allocator); + var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 0 }); + defer tris.deinit(Globals.allocator()); dvui.renderTriangles(tris, null) catch {}; } // 2) Checkerboard tile — one tile per sprite cell (uv repeats columns × rows). if (file.checkerboardTileTexture()) |checker| { - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(Globals.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); const tint = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5); - var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = tint, .fade = 0 }); - defer tris.deinit(fizzy.app.allocator); + var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = tint, .fade = 0 }); + defer tris.deinit(Globals.allocator()); tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = @floatFromInt(file.columns), .h = @floatFromInt(file.rows) }); dvui.renderTriangles(tris, checker) catch {}; } // 3) Flattened layers, then selection + temp overlays — sampled 1:1. - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(Globals.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); - var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0 }); - defer tris.deinit(fizzy.app.allocator); + var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = .white, .fade = 0 }); + defer tris.deinit(Globals.allocator()); tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 }); if (file.editor.layer_composite_target) |ct| { @@ -700,7 +701,7 @@ pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void { /// Returns the baked cover-flow preview composite texture for single-pass card /// drawing, or null when the fast path isn't eligible (peek / isolate / dimming / /// active drawing / transform). Callers fall back to the multi-pass stack. -pub fn spritePreviewComposite(file: *fizzy.Internal.File) ?dvui.Texture { +pub fn spritePreviewComposite(file: *pixelart.internal.File) ?dvui.Texture { if (file.peek_layer_index != null) return null; if (file.editor.isolate_layer) return null; if (file.editor.transform != null) return null; @@ -712,7 +713,7 @@ pub fn spritePreviewComposite(file: *fizzy.Internal.File) ?dvui.Texture { return dvui.Texture.fromTargetTemp(t) catch null; } -pub fn destroyLayerCompositeResources(file: *fizzy.Internal.File) void { +pub fn destroyLayerCompositeResources(file: *pixelart.internal.File) void { if (file.editor.layer_composite_target) |t| { t.destroyLater(); file.editor.layer_composite_target = null; @@ -728,7 +729,7 @@ pub fn destroyLayerCompositeResources(file: *fizzy.Internal.File) void { destroySplitCompositeResources(file); } -pub fn destroySplitCompositeResources(file: *fizzy.Internal.File) void { +pub fn destroySplitCompositeResources(file: *pixelart.internal.File) void { if (file.editor.split_composite_below) |t| { t.destroyLater(); file.editor.split_composite_below = null; @@ -766,35 +767,35 @@ pub fn renderLayers(init_opts: RenderFileOptions) !void { var triangles = if (init_opts.quad) |q| blk: { // Skewed quad: build a subdivided mesh so the texture follows the // perspective instead of being mapped onto an axis-aligned rect. - var qpath: dvui.Path.Builder = .init(fizzy.app.allocator); + var qpath: dvui.Path.Builder = .init(Globals.allocator()); defer qpath.deinit(); qpath.addPoint(q[0]); qpath.addPoint(q[1]); qpath.addPoint(q[2]); qpath.addPoint(q[3]); - break :blk try fizzy.sprite_render.pathToSubdividedQuad(qpath.build(), fizzy.app.allocator, .{ + break :blk try pixelart.sprite_render.pathToSubdividedQuad(qpath.build(), Globals.allocator(), .{ .subdivisions = init_opts.quad_subdivisions, .uv = init_opts.uv, .color_mod = init_opts.color_mod, }); } else blk: { - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(Globals.allocator()); defer path.deinit(); path.addRect(content_rs.r, init_opts.corner_radius.scale(content_rs.s, dvui.Rect.Physical)); - var t = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = init_opts.color_mod, .fade = init_opts.fade }); + var t = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade }); t.uvFromRectuv(content_rs.r, init_opts.uv); break :blk t; }; - defer triangles.deinit(fizzy.app.allocator); + defer triangles.deinit(Globals.allocator()); var dimmed_triangles: ?dvui.Triangles = null; defer { - if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator); + if (dimmed_triangles) |*dt| dt.deinit(Globals.allocator()); } if (needs_dimmed) { - var dt = try triangles.dupe(fizzy.app.allocator); + var dt = try triangles.dupe(Globals.allocator()); dt.color(.gray); dimmed_triangles = dt; } diff --git a/src/plugins/pixelart/sprite_render.zig b/src/plugins/pixelart/src/sprite_render.zig similarity index 97% rename from src/plugins/pixelart/sprite_render.zig rename to src/plugins/pixelart/src/sprite_render.zig index efd519dd..2b0d705e 100644 --- a/src/plugins/pixelart/sprite_render.zig +++ b/src/plugins/pixelart/src/sprite_render.zig @@ -2,16 +2,17 @@ //! //! Heavy rendering on top of `core.Sprite` rects: layer compositing, file previews, //! reflections, and water-surface meshes. Shell/workbench UI icons use -//! `fizzy.core.Sprite.draw` from core instead of this module. +//! `pixelart.core_sprite.draw` from core instead of this module. const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; pub const SpriteInitOptions = struct { source: dvui.ImageSource, - file: ?*fizzy.Internal.File = null, + file: ?*pixelart.internal.File = null, alpha_source: ?dvui.ImageSource = null, - sprite: fizzy.core.Sprite, + sprite: pixelart.core_sprite, scale: f32 = 1.0, depth: f32 = 0.0, // -1.0 is front, 1.0 is back reflection: bool = false, @@ -33,7 +34,7 @@ pub const SpriteInitOptions = struct { /// Columns the reflection mesh samples across a card's width (waterline strip). /// Matches `water_surface.cols_per_slot` (+1) so finer ripples render per card. -pub const reflection_surface_cols = fizzy.water_surface.reflection_surface_cols; +pub const reflection_surface_cols = pixelart.water_surface.reflection_surface_cols; /// Reflection-only waterline sample across the card width (logical px). `cols_dx` /// is horizontal refraction from surface slope; `cols_dy` is vertical height at @@ -144,7 +145,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt // checker + layers + selection + temp are baked into one texture once per // frame, so each card (front and reflection) is a single textured pass // instead of several overlapping alpha-blended fills. Null → multi-pass path. - const preview_tex: ?dvui.Texture = if (init_opts.file) |f| fizzy.render.spritePreviewComposite(f) else null; + const preview_tex: ?dvui.Texture = if (init_opts.file) |f| pixelart.render.spritePreviewComposite(f) else null; if (init_opts.reflection) { var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena()); @@ -237,7 +238,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt }; if (init_opts.file) |file| { - const preview_opts = fizzy.render.RenderFileOptions{ + const preview_opts = pixelart.render.RenderFileOptions{ .file = file, .rs = .{ .r = wd.contentRectScale().r, @@ -246,7 +247,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt .uv = uv, .corner_radius = .all(0), }; - fizzy.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| { + pixelart.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| { dvui.log.err("Failed to render reflection layer stack: {any}", .{err}); }; @@ -328,7 +329,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt dvui.log.err("Failed to render sprite preview composite", .{}); }; } else if (init_opts.file) |file| { - fizzy.render.renderLayers(.{ + pixelart.render.renderLayers(.{ .file = file, .rs = .{ .r = wd.contentRectScale().r, @@ -646,7 +647,7 @@ pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, optio return builder.build(); } -pub fn renderSprite(source: dvui.ImageSource, s: fizzy.core.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void { +pub fn renderSprite(source: dvui.ImageSource, s: pixelart.core_sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void { const atlas_size = dvui.imageSize(source) catch { std.log.err("Failed to get atlas size", .{}); return; diff --git a/src/plugins/pixelart/widgets/CanvasBridge.zig b/src/plugins/pixelart/src/widgets/CanvasBridge.zig similarity index 66% rename from src/plugins/pixelart/widgets/CanvasBridge.zig rename to src/plugins/pixelart/src/widgets/CanvasBridge.zig index 93d05774..7fe8869a 100644 --- a/src/plugins/pixelart/widgets/CanvasBridge.zig +++ b/src/plugins/pixelart/src/widgets/CanvasBridge.zig @@ -1,12 +1,13 @@ //! Bridges the decoupled `CanvasWidget` back to editor/app globals. The canvas takes the //! pan/zoom scheme as config and input-suppression as a hook so it stays a reusable //! viewport; these helpers supply the pixel-art editor's wiring at the install sites. -const fizzy = @import("../../../fizzy.zig"); -const CanvasWidget = fizzy.dvui.CanvasWidget; +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; +const CanvasWidget = pixelart.core.dvui.CanvasWidget; /// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum. pub fn scheme() CanvasWidget.PanZoomScheme { - return switch (fizzy.PixelArt.Settings.resolvedPanZoomScheme(&fizzy.pixelart.settings)) { + return switch (pixelart.Settings.resolvedPanZoomScheme(&Globals.state.settings, Globals.state.host)) { .mouse => .mouse, .trackpad => .trackpad, }; @@ -14,10 +15,10 @@ pub fn scheme() CanvasWidget.PanZoomScheme { /// Suppression hook for a main-scope canvas (the document editing surface, image previews). pub fn mainSuppressed(_: ?*anyopaque) bool { - return fizzy.dvui.canvasPointerInputSuppressed(); + return pixelart.core.dvui.canvasPointerInputSuppressed(); } /// Suppression hook for a dialog-scope canvas (embedded previews like Grid Layout). pub fn dialogSuppressed(_: ?*anyopaque) bool { - return fizzy.dvui.dialogCanvasPointerInputSuppressed(); + return pixelart.core.dvui.dialogCanvasPointerInputSuppressed(); } diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/src/widgets/FileWidget.zig similarity index 95% rename from src/plugins/pixelart/widgets/FileWidget.zig rename to src/plugins/pixelart/src/widgets/FileWidget.zig index 217c3209..3f4328c6 100644 --- a/src/plugins/pixelart/widgets/FileWidget.zig +++ b/src/plugins/pixelart/src/widgets/FileWidget.zig @@ -1,7 +1,7 @@ const std = @import("std"); const math = std.math; const dvui = @import("dvui"); -const fizzy = @import("../../../fizzy.zig"); +const fizzy = @import("../../../../fizzy.zig"); const builtin = @import("builtin"); const sdl3 = @import("backend").c; @@ -16,24 +16,26 @@ const ScrollContainerWidget = dvui.ScrollContainerWidget; const ScaleWidget = dvui.ScaleWidget; pub const FileWidget = @This(); -const CanvasWidget = fizzy.dvui.CanvasWidget; +const CanvasWidget = pixelart.core.dvui.CanvasWidget; const CanvasBridge = @import("CanvasBridge.zig"); const Workspace = fizzy.Editor.Workspace; const CanvasData = @import("../CanvasData.zig"); const icons = @import("icons"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; // ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is // otherwise a generic viewport; these supply the editor's behavior at install time. ---- /// Off-artboard tap (no move, no hold) → clear the current selection. fn onEmptyTap(_: ?*anyopaque) void { - fizzy.editor.cancel() catch {}; + Globals.state.host.cancel() catch {}; } /// Off-artboard hold past the hold-menu duration → open the radial tool menu at the press /// point. The canvas releases its own capture afterward so the menu buttons can be hovered. fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void { - const rm = &fizzy.pixelart.tools.radial_menu; + const rm = &Globals.state.tools.radial_menu; rm.mouse_position = press_p; rm.center = press_p; rm.visible = true; @@ -45,7 +47,7 @@ fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void { /// A modified (ctrl/cmd or shift) off-artboard press is the sprite-selection marquee's /// while the pointer tool is active — yield it instead of starting a viewport pan. fn yieldModifiedEmptyPress(_: ?*anyopaque) bool { - return fizzy.pixelart.tools.current == .pointer; + return Globals.state.tools.current == .pointer; } init_options: InitOptions, @@ -76,7 +78,7 @@ const SpriteReorderMode = enum { }; pub const InitOptions = struct { - file: *fizzy.Internal.File, + file: *pixelart.internal.File, center: bool = false, }; @@ -241,7 +243,7 @@ pub fn processSample(self: *FileWidget) void { /// Set `file.peek_layer_index` to the visible layer with an opaque pixel at `point`, mirroring /// `sampleColorAtPoint`'s selection rule (bottommost match wins). Called every frame while the /// sample key is held so other layers dim like during layer-list hover. -pub fn peekLayerAtPoint(file: *fizzy.Internal.File, point: dvui.Point) void { +pub fn peekLayerAtPoint(file: *pixelart.internal.File, point: dvui.Point) void { if (file.editor.isolate_layer) return; var layer_index: usize = file.layers.len; @@ -261,7 +263,7 @@ pub fn peekLayerAtPoint(file: *fizzy.Internal.File, point: dvui.Point) void { /// Walk visible layers for an opaque pixel at `point`. Optionally selects the hit layer, /// sets the primary color (`apply_primary`), and/or adjusts the active tool (`change_tool`). pub fn sampleColorAtPoint( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, point: dvui.Point, change_layer: bool, apply_primary: bool, @@ -273,7 +275,7 @@ pub fn sampleColorAtPoint( if (file.editor.isolate_layer) { if (file.peek_layer_index) |peek_layer_index| { min_layer_index = peek_layer_index; - } else if (!fizzy.pixelart.tools_pane.layersHovered()) { + } else if (!Globals.state.tools_pane.layersHovered()) { min_layer_index = file.selected_layer_index; } } @@ -293,7 +295,7 @@ pub fn sampleColorAtPoint( // Sample acts as a focused layer-pick: narrow multi-selection to just this layer // so the ctrl modifier (also the layer-list multi-select toggle) doesn't accumulate. file.editor.selected_layer_indices.clearRetainingCapacity(); - file.editor.selected_layer_indices.append(fizzy.app.allocator, layer_index) catch {}; + file.editor.selected_layer_indices.append(Globals.allocator(), layer_index) catch {}; file.editor.layer_selection_anchor = layer_index; } } @@ -307,27 +309,27 @@ pub fn sampleColorAtPoint( if (off_canvas) { // Sampling the empty margin outside the artboard isn't an erase — drop back // to the pointer tool so the click reads as "leave drawing mode". - if (fizzy.pixelart.tools.current != .pointer) { - fizzy.pixelart.tools.set(.pointer); + if (Globals.state.tools.current != .pointer) { + Globals.state.tools.set(.pointer); } } else if (color[3] == 0) { - if (fizzy.pixelart.tools.current != .eraser) { - fizzy.pixelart.tools.set(.eraser); + if (Globals.state.tools.current != .eraser) { + Globals.state.tools.set(.eraser); } } else { - fizzy.pixelart.colors.primary = color; - if (switch (fizzy.pixelart.tools.current) { + Globals.state.colors.primary = color; + if (switch (Globals.state.tools.current) { .pencil, .bucket => false, else => true, }) - fizzy.pixelart.tools.set(fizzy.pixelart.tools.previous_drawing_tool); + Globals.state.tools.set(Globals.state.tools.previous_drawing_tool); } } else if (apply_primary and color[3] > 0) { - fizzy.pixelart.colors.primary = color; + Globals.state.colors.primary = color; } } -fn sample(self: *FileWidget, file: *fizzy.Internal.File, point: dvui.Point, screen_p: dvui.Point.Physical, change_layer: bool, change_tool: bool) void { +fn sample(self: *FileWidget, file: *pixelart.internal.File, point: dvui.Point, screen_p: dvui.Point.Physical, change_layer: bool, change_tool: bool) void { if (!file.editor.canvas.samplePointerInViewport(screen_p)) { self.sample_data_point = null; return; @@ -349,7 +351,7 @@ pub fn processAnimationSelection(self: *FileWidget) void { switch (e.evt) { .mouse => |me| { - if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (fizzy.pixelart.tools.current != .pointer and self.sample_data_point == null)) { + if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (Globals.state.tools.current != .pointer and self.sample_data_point == null)) { if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| { var found: bool = false; for (file.animations.items(.frames), 0..) |frames, anim_index| { @@ -378,7 +380,7 @@ pub fn processAnimationSelection(self: *FileWidget) void { } pub fn processCellReorder(self: *FileWidget) void { - if (fizzy.pixelart.tools.current != .pointer) return; + if (Globals.state.tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; if (self.drag_data_point != null) return; @@ -444,12 +446,12 @@ pub fn processCellReorder(self: *FileWidget) void { if (self.removed_sprite_indices) |removed_sprite_indices| { if (self.insert_before_sprite_indices) |insert_before_sprite_indices| { - fizzy.app.allocator.free(insert_before_sprite_indices); + Globals.allocator().free(insert_before_sprite_indices); self.insert_before_sprite_indices = null; } // This will actually trigger the drag/drop - var insert_before_sprite_indices = fizzy.app.allocator.alloc(usize, file.editor.selected_sprites.count()) catch { + var insert_before_sprite_indices = Globals.allocator().alloc(usize, file.editor.selected_sprites.count()) catch { dvui.log.err("Failed to allocate insert before sprite indices", .{}); return; }; @@ -474,11 +476,11 @@ pub fn processCellReorder(self: *FileWidget) void { file.history.append(.{ .reorder_cell = .{ - .removed_sprite_indices = fizzy.app.allocator.dupe(usize, removed_sprite_indices) catch { + .removed_sprite_indices = Globals.allocator().dupe(usize, removed_sprite_indices) catch { dvui.log.err("Failed to duplicate removed sprite indices", .{}); return; }, - .insert_before_sprite_indices = fizzy.app.allocator.dupe(usize, insert_before_sprite_indices) catch { + .insert_before_sprite_indices = Globals.allocator().dupe(usize, insert_before_sprite_indices) catch { dvui.log.err("Failed to duplicate insert before sprite indices", .{}); return; }, @@ -502,7 +504,7 @@ pub fn processCellReorder(self: *FileWidget) void { dvui.cursorSet(.hand); defer e.handle(@src(), file.editor.canvas.scroll_container.data()); if (self.removed_sprite_indices == null and file.editor.selected_sprites.count() > 0) { - var removed_sprite_indices = fizzy.app.allocator.alloc(usize, file.editor.selected_sprites.count()) catch { + var removed_sprite_indices = Globals.allocator().alloc(usize, file.editor.selected_sprites.count()) catch { dvui.log.err("Failed to allocate removed sprite indices", .{}); return; }; @@ -529,7 +531,7 @@ pub fn processCellReorder(self: *FileWidget) void { /// /// Supports add/remove, drag selection, etc. pub fn processSpriteSelection(self: *FileWidget) void { - if (fizzy.pixelart.tools.current != .pointer) return; + if (Globals.state.tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -604,7 +606,7 @@ pub fn processSpriteSelection(self: *FileWidget) void { file.editor.primary_sprite_index = sprite_index; } } else if (!file.editor.canvas.hovered) { - fizzy.editor.cancel() catch { + Globals.state.host.cancel() catch { dvui.log.err("Failed to cancel", .{}); }; } @@ -706,7 +708,7 @@ fn bubblePanSharedForGrid(self: *FileWidget) ?BubblePanShared { const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id; const cw = dvui.currentWindow(); - const tool_not_pointer = fizzy.pixelart.tools.current != .pointer; + const tool_not_pointer = Globals.state.tools.current != .pointer; const mod_shift = cw.modifiers.matchBind("shift"); const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd"); const sample_active = self.sample_data_point != null; @@ -878,10 +880,10 @@ pub fn drawSpriteBubbles(self: *FileWidget) void { const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id; const cw = dvui.currentWindow(); const drag_sprite_selection = dvui.dragName("sprite_selection_drag"); - const tool_not_pointer = fizzy.pixelart.tools.current != .pointer; + const tool_not_pointer = Globals.state.tools.current != .pointer; const mod_shift = cw.modifiers.matchBind("shift"); const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd"); - const radial_visible = fizzy.pixelart.tools.radial_menu.visible; + const radial_visible = Globals.state.tools.radial_menu.visible; const sample_active = self.sample_data_point != null; const canvas_gesturing = self.init_options.file.editor.canvas.trackpadPinching() or self.init_options.file.editor.canvas.gestureActive(); @@ -1097,7 +1099,7 @@ fn bubbleSpriteDataRect(col_in_row: usize, base_y: f32, col_w: f32, row_h: f32) /// When `accs` is null and `shadow_only` is false, only UI elements are drawn. fn drawSpriteBubbleForRow( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, sprite_index: usize, sprite_rect: dvui.Rect, accs: ?*BubbleAccs, @@ -1134,7 +1136,7 @@ fn drawSpriteBubbleForRow( if (animation_index) |ai| { const id = file.animations.get(ai).id; - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { + if (Globals.state.colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(id)); } if (file.selected_animation_index == ai) { @@ -1440,7 +1442,7 @@ pub fn drawSpriteBubble( var add_rem_message: ?[]const u8 = null; var border_color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { + if (Globals.state.colors.file_tree_palette) |*palette| { if (self.init_options.file.selected_animation_index) |index| { border_color = palette.getDVUIColor(@intCast(self.init_options.file.animations.get(index).id)); add_rem_message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{self.init_options.file.animations.get(index).name}) catch { @@ -1543,7 +1545,7 @@ pub fn drawSpriteBubble( var anim = self.init_options.file.animations.get(anim_index); - var frames = std.array_list.Managed(fizzy.Animation.Frame).init(fizzy.app.allocator); + var frames = std.array_list.Managed(pixelart.Animation.Frame).init(Globals.allocator()); frames.appendSlice(anim.frames) catch { dvui.log.err("Failed to append frames", .{}); return false; @@ -1620,7 +1622,7 @@ pub fn drawSpriteBubble( self.init_options.file.history.append(.{ .animation_frames = .{ .index = anim_index, - .frames = fizzy.app.allocator.dupe(fizzy.Animation.Frame, anim.frames) catch { + .frames = Globals.allocator().dupe(pixelart.Animation.Frame, anim.frames) catch { dvui.log.err("Failed to dupe frames", .{}); return false; }, @@ -1629,7 +1631,7 @@ pub fn drawSpriteBubble( dvui.log.err("Failed to append history", .{}); }; - fizzy.app.allocator.free(anim.frames); + Globals.allocator().free(anim.frames); anim.frames = frames.toOwnedSlice() catch { dvui.log.err("Failed to free frames", .{}); return false; @@ -1642,12 +1644,12 @@ pub fn drawSpriteBubble( self.init_options.file.selected_animation_index = anim_index; self.init_options.file.collapseAnimationSelectionToPrimary(); self.init_options.file.editor.animations_scroll_to_index = anim_index; - fizzy.pixelart.sprites_pane.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index]; - fizzy.pixelart.host.setActiveSidebarView(@import("../plugin.zig").view_sprites); + Globals.state.sprites_pane.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index]; + Globals.state.host.setActiveSidebarView(@import("../plugin.zig").view_sprites); var anim = self.init_options.file.animations.get(anim_index); if (anim.frames.len == 0) { - anim.appendFrame(fizzy.app.allocator, .{ .sprite_index = sprite_index, .ms = temp_ms }) catch { + anim.appendFrame(Globals.allocator(), .{ .sprite_index = sprite_index, .ms = temp_ms }) catch { dvui.log.err("Failed to append frame", .{}); return false; }; @@ -1781,7 +1783,7 @@ pub fn drawSpriteBubble( /// Draw the highlight colored selection box for each selected sprite. pub fn drawSpriteSelection(self: *FileWidget) void { - if (fizzy.pixelart.tools.current != .pointer) return; + if (Globals.state.tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -1911,8 +1913,8 @@ fn strokePolylineDashedPhysical( } fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void { - if (fizzy.pixelart.tools.current != .selection) return; - if (fizzy.pixelart.tools.selection_mode != .box) return; + if (Globals.state.tools.current != .selection) return; + if (Globals.state.tools.selection_mode != .box) return; const start = self.drag_data_point orelse return; if (dvui.dragging(dvui.currentWindow().mouse_pt, "stroke_drag") == null) return; @@ -1957,8 +1959,8 @@ fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void { /// Preview for rectangular selection while dragging (box mode). fn applySelectionBoxPreview( - file: *fizzy.Internal.File, - active_layer: *const fizzy.Internal.Layer, + file: *pixelart.internal.File, + active_layer: *const pixelart.internal.Layer, start: dvui.Point, end: dvui.Point, mod: dvui.enums.Mod, @@ -2001,7 +2003,7 @@ fn applySelectionBoxPreview( /// This selection is pixel-based, and includes shift/ctrl/cmd modifiers to support add/remove. /// The selection uses the same logic as the stroke tool to brush the selection over existing pixels. pub fn processSelection(self: *FileWidget) void { - if (switch (fizzy.pixelart.tools.current) { + if (switch (Globals.state.tools.current) { .selection, => false, else => true, @@ -2024,7 +2026,7 @@ pub fn processSelection(self: *FileWidget) void { // Pixel mode: draw the committed selection before handling events (brush preview layers on top). // Box mode: skip — the mask is updated on mouse release in the same frame as this paint; drawing // here would use stale data until the next frame. Box repaints from the current mask after events. - if (fizzy.pixelart.tools.selection_mode == .pixel or fizzy.pixelart.tools.selection_mode == .color) { + if (Globals.state.tools.selection_mode == .pixel or Globals.state.tools.selection_mode == .color) { @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); file.editor.temporary_layer.clearMask(); @@ -2044,21 +2046,21 @@ pub fn processSelection(self: *FileWidget) void { switch (e.evt) { .key => |ke| { var update: bool = false; - if (fizzy.pixelart.tools.selection_mode == .pixel) { + if (Globals.state.tools.selection_mode == .pixel) { if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.pixelart.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1) - fizzy.pixelart.tools.stroke_size += 1; + if (Globals.state.tools.stroke_size < pixelart.Tools.max_brush_size - 1) + Globals.state.tools.stroke_size += 1; - fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size); + Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size); e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); update = true; } if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.pixelart.tools.stroke_size > 1) - fizzy.pixelart.tools.stroke_size -= 1; + if (Globals.state.tools.stroke_size > 1) + Globals.state.tools.stroke_size -= 1; - fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size); + Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size); e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); update = true; } @@ -2081,7 +2083,7 @@ pub fn processSelection(self: *FileWidget) void { .temporary, .{ .mask_only = true, - .stroke_size = fizzy.pixelart.tools.stroke_size, + .stroke_size = Globals.state.tools.stroke_size, }, ); @@ -2099,8 +2101,8 @@ pub fn processSelection(self: *FileWidget) void { const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p); if (me.action == .position) { - const box_mode = fizzy.pixelart.tools.selection_mode == .box; - const color_mode = fizzy.pixelart.tools.selection_mode == .color; + const box_mode = Globals.state.tools.selection_mode == .box; + const color_mode = Globals.state.tools.selection_mode == .color; const is_drag = dvui.dragging(me.p, "stroke_drag") != null; const box_drag = box_mode and is_drag and self.drag_data_point != null; @@ -2151,7 +2153,7 @@ pub fn processSelection(self: *FileWidget) void { .temporary, .{ .mask_only = true, - .stroke_size = fizzy.pixelart.tools.stroke_size, + .stroke_size = Globals.state.tools.stroke_size, }, ); @@ -2182,7 +2184,7 @@ pub fn processSelection(self: *FileWidget) void { if (!widget_active) continue; e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - if (fizzy.pixelart.tools.selection_mode == .color) { + if (Globals.state.tools.selection_mode == .color) { // Only clear the mask if we don't have ctrl/cmd pressed if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) file.editor.selection_layer.clearMask(); @@ -2200,14 +2202,14 @@ pub fn processSelection(self: *FileWidget) void { if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) file.editor.selection_layer.clearMask(); - if (fizzy.pixelart.tools.selection_mode == .box) { + if (Globals.state.tools.selection_mode == .box) { self.drag_data_point = current_point; } else { file.selectPoint( current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.pixelart.tools.stroke_size, + .stroke_size = Globals.state.tools.stroke_size, }, ); @@ -2220,23 +2222,23 @@ pub fn processSelection(self: *FileWidget) void { dvui.captureMouse(null, e.num); dvui.dragEnd(); - if (fizzy.pixelart.tools.selection_mode == .box) { + if (Globals.state.tools.selection_mode == .box) { if (self.drag_data_point) |start| { file.selectRectBetweenPoints( start, current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.pixelart.tools.stroke_size, + .stroke_size = Globals.state.tools.stroke_size, }, ); } - } else if (fizzy.pixelart.tools.selection_mode != .color) { + } else if (Globals.state.tools.selection_mode != .color) { file.selectPoint( current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.pixelart.tools.stroke_size, + .stroke_size = Globals.state.tools.stroke_size, }, ); } @@ -2268,14 +2270,14 @@ pub fn processSelection(self: *FileWidget) void { }); } - if (fizzy.pixelart.tools.selection_mode == .pixel) { + if (Globals.state.tools.selection_mode == .pixel) { if (self.drag_data_point) |previous_point| { file.selectLine( previous_point, current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.pixelart.tools.stroke_size, + .stroke_size = Globals.state.tools.stroke_size, }, ); } @@ -2290,7 +2292,7 @@ pub fn processSelection(self: *FileWidget) void { } } - if (fizzy.pixelart.tools.selection_mode == .box) { + if (Globals.state.tools.selection_mode == .box) { const mouse_pt = dvui.currentWindow().mouse_pt; const is_drag = dvui.dragging(mouse_pt, "stroke_drag") != null; if (!(is_drag and self.drag_data_point != null)) { @@ -2312,7 +2314,7 @@ pub fn processSelection(self: *FileWidget) void { fn processStrokeDragSegment( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, previous_point: dvui.Point, current_point: dvui.Point, screen_pt: dvui.Point.Physical, @@ -2373,7 +2375,7 @@ fn processStrokeDragSegment( .stroke_size = stroke_size, }, ); - fizzy.perf.draw_event_count += 1; + pixelart.perf.draw_event_count += 1; } else |err| { dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err}); } @@ -2388,7 +2390,7 @@ fn processStrokeDragSegment( { if (self.sample_data_point == null or color[3] == 0) { clearTempPreview(&file.editor); - const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; file.drawPoint( current_point, .temporary, @@ -2409,12 +2411,12 @@ fn processStrokeDragSegment( /// Supports using shift to draw a line between two points, and increasing/decreasing stroke size pub fn processStroke(self: *FileWidget) void { const file = self.init_options.file; - const stroke_size = fizzy.pixelart.tools.stroke_size; + const stroke_size = Globals.state.tools.stroke_size; const widget_active = self.active(); if (self.cell_reorder_point != null) return; - if (switch (fizzy.pixelart.tools.current) { + if (switch (Globals.state.tools.current) { .pencil, .eraser, => false, @@ -2423,8 +2425,8 @@ pub fn processStroke(self: *FileWidget) void { if (self.sample_key_down or self.right_mouse_down) return; - const color: [4]u8 = switch (fizzy.pixelart.tools.current) { - .pencil => fizzy.pixelart.colors.primary, + const color: [4]u8 = switch (Globals.state.tools.current) { + .pencil => Globals.state.colors.primary, .eraser => [_]u8{ 0, 0, 0, 0 }, else => unreachable, }; @@ -2569,7 +2571,7 @@ pub fn processStroke(self: *FileWidget) void { self.sample_data_point == null) { clearTempPreview(&file.editor); - const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; file.drawPoint( current_point, .temporary, @@ -2595,10 +2597,10 @@ pub fn processStroke(self: *FileWidget) void { /// Supports using ctrl/cmd to replace all existing pixels of the same color with the new color, /// or without modifiers to flood fill the layer with the new color. pub fn processFill(self: *FileWidget) void { - if (fizzy.pixelart.tools.current != .bucket) return; + if (Globals.state.tools.current != .bucket) return; if (self.sample_key_down) return; const file = self.init_options.file; - const color = fizzy.pixelart.colors.primary; + const color = Globals.state.colors.primary; const widget_active = self.active(); // Skip the cursor-follow temp preview on touch: the finger occludes the pixel and @@ -2608,7 +2610,7 @@ pub fn processFill(self: *FileWidget) void { self.sample_data_point == null) { clearTempPreview(&file.editor); - const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; const fill_preview_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); file.drawPoint( fill_preview_pt, @@ -2681,7 +2683,7 @@ pub fn processTransform(self: *FileWidget) void { triangles.rotate(.{ .x = transform.point(.pivot).x, .y = transform.point(.pivot).y }, transform.rotation); for (transform.data_points[0..6], 0..) |*data_point, point_index| { - const transform_point = @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index)); + const transform_point = @as(pixelart.Transform.TransformPoint, @enumFromInt(point_index)); const screen_point = if (point_index < 4) file.editor.canvas.screenFromDataPoint(.{ .x = triangles.vertexes[point_index].pos.x, .y = triangles.vertexes[point_index].pos.y }) else file.editor.canvas.screenFromDataPoint(data_point.*); var screen_rect = dvui.Rect.Physical.fromPoint(screen_point); @@ -2698,7 +2700,7 @@ pub fn processTransform(self: *FileWidget) void { if (screen_rect.contains(dvui.currentWindow().mouse_pt)) { dvui.cursorSet(.hand); } else if (transform.active_point) |active_point| { - if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) { + if (active_point == @as(pixelart.Transform.TransformPoint, @enumFromInt(point_index))) { dvui.cursorSet(.hand); } } @@ -2793,7 +2795,7 @@ pub fn processTransform(self: *FileWidget) void { new_point.y = @round(new_point.y); // Now we have to un-rotate the vertex and set the original location - new_point = fizzy.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation); + new_point = pixelart.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation); const opposite_index: usize = switch (point_index) { 0 => 2, @@ -2844,8 +2846,8 @@ pub fn processTransform(self: *FileWidget) void { const opposite_point = &transform.data_points[opposite_index]; - var rotation_direction: dvui.Point = fizzy.math.rotate(dvui.Point{ .x = 1, .y = 0 }, transform.point(.pivot).*, 0); - var rotation_perp: dvui.Point = fizzy.math.rotate(dvui.Point{ .x = 0, .y = 1 }, transform.point(.pivot).*, 0); + var rotation_direction: dvui.Point = pixelart.math.rotate(dvui.Point{ .x = 1, .y = 0 }, transform.point(.pivot).*, 0); + var rotation_perp: dvui.Point = pixelart.math.rotate(dvui.Point{ .x = 0, .y = 1 }, transform.point(.pivot).*, 0); // Calculate the difference between the adjacent points and the new point @@ -2899,7 +2901,7 @@ pub fn processTransform(self: *FileWidget) void { transform.rotation = std.math.degreesToRadians(@round(std.math.radiansToDegrees(transform.start_rotation + (angle - drag_angle)))); if (me.mod.matchBind("ctrl/cmd")) { // Lock rotation to cardinal directions - const direction = fizzy.math.Direction.fromRadians(transform.rotation); + const direction = pixelart.math.Direction.fromRadians(transform.rotation); transform.rotation = switch (direction) { .n => std.math.pi / 2.0, .ne => std.math.pi / 4.0, @@ -3037,7 +3039,7 @@ pub fn drawTransform(self: *FileWidget) void { } var centroid = transform.centroid(); - centroid = fizzy.math.rotate(centroid, transform.point(.pivot).*, transform.rotation); + centroid = pixelart.math.rotate(centroid, transform.point(.pivot).*, transform.rotation); // Full-sprite center guides (magenta). When ortho cell dimensions are shown, centering is // indicated on those dimension lines (blue) instead — avoids overlapping magenta guides. @@ -3308,7 +3310,7 @@ pub fn drawTransform(self: *FileWidget) void { const bottom_left_v = triangles.vertexes[3].pos; const bottom_right_v = triangles.vertexes[2].pos; - const offset_v = fizzy.math.rotate( + const offset_v = pixelart.math.rotate( dvui.Point{ .x = label_off_screen, .y = 0 }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3320,7 +3322,7 @@ pub fn drawTransform(self: *FileWidget) void { const simple_v = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(inner_h_f)))}) catch "—"; renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v)); - const offset_h = fizzy.math.rotate( + const offset_h = pixelart.math.rotate( dvui.Point{ .x = 0, .y = -label_off_screen }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3337,7 +3339,7 @@ pub fn drawTransform(self: *FileWidget) void { const bottom_left = triangles.vertexes[3].pos; const bottom_right = triangles.vertexes[2].pos; - const offset_v = fizzy.math.rotate( + const offset_v = pixelart.math.rotate( dvui.Point{ .x = label_off_screen, .y = 0 }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3353,7 +3355,7 @@ pub fn drawTransform(self: *FileWidget) void { ) catch "—"; renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v)); - const offset_h = fizzy.math.rotate( + const offset_h = pixelart.math.rotate( dvui.Point{ .x = 0, .y = -label_off_screen }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3453,7 +3455,7 @@ pub fn drawTransform(self: *FileWidget) void { var color = dvui.themeGet().color(.window, .text); if (transform.active_point) |active_point| { - if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) { + if (active_point == @as(pixelart.Transform.TransformPoint, @enumFromInt(point_index))) { color = dvui.themeGet().color(.highlight, .fill); } } else if (screen_rect.contains(dvui.currentWindow().mouse_pt)) { @@ -3563,7 +3565,7 @@ fn doubleStrokeDimensionTickColor(points: []const dvui.Point.Physical, thickness /// axis-aligned quad (4 vertices, 2 triangles) submitted via one `renderTriangles`. fn drawBatchedGridLines( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, columns: usize, rows: usize, grid_color: dvui.Color, @@ -3689,7 +3691,7 @@ fn appendLineQuad(builder: *dvui.Triangles.Builder, tl: dvui.Point.Physical, br: } /// Viewport in data space + row/column index range for culling (matches bubble / grid logic). -fn fileCanvasVisibleGridParams(file: *fizzy.Internal.File) ?struct { +fn fileCanvasVisibleGridParams(file: *pixelart.internal.File) ?struct { visible_data: dvui.Rect, row_h: f32, col_w: f32, @@ -3776,7 +3778,7 @@ fn appendHorizontalGridRunsForRow( /// Batches grid lines for the resize-shrink overlay (original layer_rect shown in error tint). fn drawBatchedResizeOverlayGrid( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, columns: usize, layer_rect: dvui.Rect, grid_thickness: f32, @@ -3863,8 +3865,8 @@ fn checkerboardVertexColor( } /// Animation color for transparency tint; matches bubble arc palette lookup order (selected animation first, else first containing animation). -fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color { - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { +fn spriteAnimationPaletteColor(file: *pixelart.internal.File, sprite_index: usize) ?dvui.Color { + if (Globals.state.colors.file_tree_palette) |*palette| { var animation_index: ?usize = null; if (file.selected_animation_index) |selected_animation_index| { @@ -3896,8 +3898,8 @@ fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) } fn checkerboardCellCornerColor( - effect: fizzy.PixelArt.Settings.TransparencyEffect, - file: *fizzy.Internal.File, + effect: pixelart.Settings.TransparencyEffect, + file: *pixelart.internal.File, sprite_index: usize, c_tl: dvui.Color, c_tr: dvui.Color, @@ -3938,10 +3940,10 @@ fn checkerboardGridPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr: } /// Same tint as the batched checkerboard for the cell under `sprite_index` (center UV), for bubbles etc. -fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index: usize) dvui.Color { +fn checkerboardTintAtSpriteCellCenter(file: *pixelart.internal.File, sprite_index: usize) dvui.Color { const pal = checkerboardGridPalette(); const tone = pal.tone; - switch (fizzy.pixelart.settings.transparency_effect) { + switch (Globals.state.settings.transparency_effect) { .none => return tone, .rainbow => { const mu_mv = dvui.dataGet(null, file.editor.canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 }; @@ -3960,11 +3962,11 @@ fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index: /// Checkerboard behind layers: one batched quad per visible cell (UV 0..1 per cell — vertex colors /// vary per cell for rainbow / animation effects, which is why this isn't a single wrapped quad). -fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void { +fn drawCheckerboardCellsBatched(file: *pixelart.internal.File) void { const n = file.spriteCount(); if (n == 0) return; - const te = fizzy.pixelart.settings.transparency_effect; + const te = Globals.state.settings.transparency_effect; const pal = checkerboardGridPalette(); const tone = pal.tone; const rs = file.editor.canvas.screen_rect_scale; @@ -4063,7 +4065,7 @@ fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void { } pub fn active(self: *FileWidget) bool { - if (fizzy.editor.activeFile()) |file| { + if (Globals.state.docs.activeFile(Globals.state.host)) |file| { if (file.id == self.init_options.file.id) { return true; } @@ -4072,9 +4074,9 @@ pub fn active(self: *FileWidget) bool { } pub fn drawCursor(self: *FileWidget) void { - if (fizzy.dvui.canvasPointerInputSuppressed()) return; - if (fizzy.pixelart.tools.current == .pointer and self.sample_data_point == null) return; - if (fizzy.pixelart.tools.radial_menu.visible) return; + if (pixelart.core.dvui.canvasPointerInputSuppressed()) return; + if (Globals.state.tools.current == .pointer and self.sample_data_point == null) return; + if (Globals.state.tools.radial_menu.visible) return; if (self.init_options.file.editor.transform != null) return; if (self.init_options.file.editor.canvas.gestureActive()) return; if (self.init_options.file.editor.canvas.trackpadPinching()) return; @@ -4113,20 +4115,20 @@ pub fn drawCursor(self: *FileWidget) void { const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point); - const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) { - .box => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default], - .pixel => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default], - .color => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default], + const selection_sprite = switch (Globals.state.tools.selection_mode) { + .box => if (subtract) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_rem_default] else if (add) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_add_default] else Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_default], + .pixel => if (subtract) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_rem_default] else if (add) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_add_default] else Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_default], + .color => if (subtract) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_rem_default] else if (add) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_add_default] else Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_default], }; - if (switch (fizzy.pixelart.tools.current) { - .pencil => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pencil_default], - .eraser => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.eraser_default], - .bucket => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.bucket_default], + if (switch (Globals.state.tools.current) { + .pencil => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pencil_default], + .eraser => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.eraser_default], + .bucket => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.bucket_default], .selection => selection_sprite, else => null, }) |sprite| { - const atlas_size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch { + const atlas_size = dvui.imageSize(Globals.state.host.uiAtlas().source) catch { dvui.log.err("Failed to get atlas size", .{}); return; }; @@ -4164,7 +4166,7 @@ pub fn drawCursor(self: *FileWidget) void { const rs = box.data().rectScale(); - dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{ + dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{ .uv = uv, }) catch { dvui.log.err("Failed to render cursor image", .{}); @@ -4215,7 +4217,7 @@ fn mapDataRectToPhysicalStrip(sr: dvui.Rect, parent_data: dvui.Rect, parent_phys /// Draw the checkerboard alpha pattern into `dest_phys`. Uses wrap=.repeat on the tile texture so /// the entire region is one quad with UV scaled so each `cw × ch` of data space spans one tile. fn drawSampleMagnifierCheckerboardTiles( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, region_data: dvui.Rect, dest_phys: dvui.Rect.Physical, scale: f32, @@ -4242,7 +4244,7 @@ fn drawSampleMagnifierCheckerboardTiles( /// Build checkerboard + layers into an offscreen target. Layer composites are synced on the screen /// target first so `renderLayers` does not rebind this target via `syncLayerComposite`. fn drawSampleMagnifierCompositeBuild( - file: *fizzy.Internal.File, + file: *pixelart.internal.File, region_data: dvui.Rect, content_rs: dvui.RectScale, file_w: f32, @@ -4254,18 +4256,18 @@ fn drawSampleMagnifierCompositeBuild( const h: u32 = @intFromFloat(@max(@ceil(content_rs.r.h), 1)); const layer_region = region_data.intersect(dvui.Rect{ .x = 0, .y = 0, .w = file_w, .h = file_h }); - const layer_opts_base = fizzy.render.RenderFileOptions{ + const layer_opts_base = pixelart.render.RenderFileOptions{ .file = file, .rs = content_rs, .allow_peek = false, }; // Refresh cached layer composites on the screen target (not the magnifier target). - fizzy.render.ensureLayerCompositesForPreview(layer_opts_base) catch { + pixelart.render.ensureLayerCompositesForPreview(layer_opts_base) catch { dvui.log.err("Failed to sync layer composites for magnifier", .{}); }; - const target = dvui.textureCreateTarget(.{ .width = w, .height = h, .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + const target = dvui.textureCreateTarget(.{ .width = w, .height = h, .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create magnifier composite target", .{}); return null; }; @@ -4291,7 +4293,7 @@ fn drawSampleMagnifierCompositeBuild( .w = layer_region.w / file_w, .h = layer_region.h / file_h, }; - fizzy.render.renderLayersMagnifierSample(.{ + pixelart.render.renderLayersMagnifierSample(.{ .file = file, .rs = .{ .r = layer_phys, .s = 1.0 }, .uv = uv_rect, @@ -4392,9 +4394,9 @@ fn drawSampleMagnifierPresent( } }, .{ .thickness = 2, .color = .black }); } -pub fn drawSampleMagnifier(file: *fizzy.Internal.File, data_point: dvui.Point) void { +pub fn drawSampleMagnifier(file: *pixelart.internal.File, data_point: dvui.Point) void { const canvas = &file.editor.canvas; - if (fizzy.dvui.canvasPointerInputSuppressed()) return; + if (pixelart.core.dvui.canvasPointerInputSuppressed()) return; if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return; _ = dvui.cursorSet(.hidden); @@ -4492,8 +4494,8 @@ pub fn updateActiveLayerMask(self: *FileWidget) void { } pub fn drawLayers(self: *FileWidget) void { - const perf_t0 = fizzy.perf.drawLayersBegin(); - defer fizzy.perf.drawLayersEnd(perf_t0); + const perf_t0 = pixelart.perf.drawLayersBegin(); + defer pixelart.perf.drawLayersEnd(perf_t0); var file = self.init_options.file; var columns: usize = file.columns; @@ -4567,7 +4569,7 @@ pub fn drawLayers(self: *FileWidget) void { self.drawColumnRowReorderPreview(); return; } else { - fizzy.render.renderLayers(.{ + pixelart.render.renderLayers(.{ .file = file, .rs = .{ .r = self.init_options.file.editor.canvas.rect, @@ -4619,14 +4621,14 @@ pub fn drawLayers(self: *FileWidget) void { } // Draw the selection box for the selected sprites - if (fizzy.pixelart.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) { + if (Globals.state.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) { var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { const sprite_rect = file.spriteRect(i); const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect); // Draw the origins when in the sprites pane - if (fizzy.pixelart.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) { + if (Globals.state.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) { const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] }; const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y }; @@ -4659,7 +4661,7 @@ const ReorderAxis = enum { columns, rows }; /// Checkerboard alpha over each cell of the floating column/row, matching `drawCheckerboardCellsBatched` tint/UVs at half opacity. fn drawCheckerboardReorderFloatingStrip( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, removed_data_rect: dvui.Rect, strip_phys: dvui.Rect.Physical, axis: ReorderAxis, @@ -4689,7 +4691,7 @@ fn drawCheckerboardReorderFloatingStrip( const c_tr = pal.c_tr; const c_bl = pal.c_bl; const c_br = pal.c_br; - const te = fizzy.pixelart.settings.transparency_effect; + const te = Globals.state.settings.transparency_effect; const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0); const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0); @@ -4781,7 +4783,7 @@ fn drawColumnRowReorderPreview(self: *FileWidget) void { fn renderLayersInDataRect( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, data_rect: dvui.Rect, screen_rect_override: ?dvui.Rect.Physical, ) void { @@ -4789,7 +4791,7 @@ fn renderLayersInDataRect( const w = @as(f32, @floatFromInt(file.width())); const h = @as(f32, @floatFromInt(file.height())); const r = screen_rect_override orelse file.editor.canvas.screenFromDataRect(data_rect); - fizzy.render.renderLayers(.{ + pixelart.render.renderLayers(.{ .file = file, .rs = .{ .r = r, .s = scale }, .uv = .{ @@ -4803,7 +4805,7 @@ fn renderLayersInDataRect( fn reorderSegmentRects( axis: ReorderAxis, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, target_index: usize, removed_index: usize, target_rect: dvui.Rect, @@ -4877,7 +4879,7 @@ fn reorderSegmentRects( fn drawReorderPreviewForAxis( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixelart.internal.File, axis: ReorderAxis, target_index: ?usize, removed_index: usize, @@ -5027,10 +5029,10 @@ fn drawReorderPreviewForAxis( }); { - fizzy.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{ + pixelart.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{ .opacity = 0.5, }); - fizzy.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{ + pixelart.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{ .opacity = 0.5, }); } @@ -5288,22 +5290,22 @@ pub fn drawCellReorderPreview(self: *FileWidget) void { if (left_index) |left_index_value| { if (!temp_selected_sprite.isSet(left_index_value)) { - fizzy.dvui.drawEdgeShadow(image_rect_scale, .left, .{ .opacity = 0.35 }); + pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .left, .{ .opacity = 0.35 }); } } if (right_index) |right_index_value| { if (!temp_selected_sprite.isSet(right_index_value)) { - fizzy.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 }); + pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 }); } } if (top_index) |top_index_value| { if (!temp_selected_sprite.isSet(top_index_value)) { - fizzy.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 }); + pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 }); } } if (bottom_index) |bottom_index_value| { if (!temp_selected_sprite.isSet(bottom_index_value)) { - fizzy.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 }); + pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 }); } } } @@ -5483,7 +5485,7 @@ fn autoPanForResize(self: *FileWidget, mouse_pt: dvui.Point.Physical) void { } pub fn processResize(self: *FileWidget) void { - if (fizzy.pixelart.tools.current != .pointer) return; + if (Globals.state.tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -5765,7 +5767,7 @@ pub fn processEvents(self: *FileWidget) void { const canvas_ptr = &self.init_options.file.editor.canvas; const mouse_pt = dvui.currentWindow().mouse_pt; - canvas_ptr.hovered = !fizzy.dvui.canvasPointerInputSuppressed() and + canvas_ptr.hovered = !pixelart.core.dvui.canvasPointerInputSuppressed() and canvas_ptr.pointerOverDrawable(mouse_pt); // Cursor-leave: when hover transitions true → false, the last brush/fill preview @@ -5807,18 +5809,18 @@ pub fn processEvents(self: *FileWidget) void { // current single touch will become one — otherwise the bucket/pencil hover preview would // flash on the pinned finger as the user starts a pan gesture. if (self.hovered() and !self.init_options.file.editor.canvas.gestureActive()) { - const pe_t0 = fizzy.perf.processEventsBegin(); - defer fizzy.perf.processEventsEnd(pe_t0); + const pe_t0 = pixelart.perf.processEventsBegin(); + defer pixelart.perf.processEventsEnd(pe_t0); resetTempLayerPreview(&self.init_options.file.editor); { - const mask_t0 = fizzy.perf.updateMaskBegin(); - defer fizzy.perf.updateMaskEnd(mask_t0); + const mask_t0 = pixelart.perf.updateMaskBegin(); + defer pixelart.perf.updateMaskEnd(mask_t0); self.updateActiveLayerMask(); } - if (fizzy.pixelart.tools.current == .selection) { + if (Globals.state.tools.current == .selection) { if (dvui.timerDoneOrNone(self.init_options.file.editor.canvas.scroll_container.data().id)) { self.init_options.file.editor.checkerboard.toggleAll(); @@ -5827,14 +5829,14 @@ pub fn processEvents(self: *FileWidget) void { } if (self.init_options.file.editor.transform == null) { - const tool_t0 = fizzy.perf.toolProcessBegin(); - switch (fizzy.pixelart.tools.current) { + const tool_t0 = pixelart.perf.toolProcessBegin(); + switch (Globals.state.tools.current) { .bucket => self.processFill(), .pencil, .eraser => self.processStroke(), .selection => self.processSelection(), else => {}, } - fizzy.perf.toolProcessEnd(tool_t0); + pixelart.perf.toolProcessEnd(tool_t0); } } else if (self.hovered() and self.init_options.file.editor.canvas.gestureActive()) { // A 2-finger gesture (or its pending evaluation) just took over. Make sure any @@ -5889,10 +5891,10 @@ pub fn processEvents(self: *FileWidget) void { } // Draw shadows for the scroll container - fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .top, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .bottom, .{}); - fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .left, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .right, .{}); + pixelart.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .top, .{ .opacity = 0.15 }); + pixelart.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .bottom, .{}); + pixelart.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .left, .{ .opacity = 0.15 }); + pixelart.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .right, .{}); self.drawTransform(); self.processSample(); @@ -5911,7 +5913,7 @@ pub fn deinit(self: *FileWidget) void { } pub fn hovered(self: *FileWidget) bool { - if (fizzy.dvui.canvasPointerInputSuppressed()) return false; + if (pixelart.core.dvui.canvasPointerInputSuppressed()) return false; return self.init_options.file.editor.canvas.hovered; } @@ -5965,7 +5967,7 @@ fn tempBrushRect(point: dvui.Point, stroke_size: usize, img_w: u32, img_h: u32) } /// Data-space rect of the on-screen canvas, outset by brush size so edge stamps are not clipped. -fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const fizzy.Internal.File, stroke_size: usize) dvui.Rect { +fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const pixelart.internal.File, stroke_size: usize) dvui.Rect { const vis = canvas.dataFromScreenRect(canvas.rect); const m: f32 = @floatFromInt(stroke_size); const inflated = vis.outsetAll(m); @@ -5974,7 +5976,7 @@ fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const fizzy.Internal. return dvui.Rect.intersect(inflated, .{ .x = 0, .y = 0, .w = iw, .h = ih }); } -fn expandTempGpuDirtyRect(editor: *fizzy.Internal.File.EditorData, rect: dvui.Rect) void { +fn expandTempGpuDirtyRect(editor: *pixelart.internal.File.EditorData, rect: dvui.Rect) void { if (editor.temp_gpu_dirty_rect) |existing| { editor.temp_gpu_dirty_rect = existing.unionWith(rect); } else { @@ -5988,10 +5990,10 @@ fn expandTempGpuDirtyRect(editor: *fizzy.Internal.File.EditorData, rect: dvui.Re /// Clears the pixels covered by the current temp preview dirty rect, then /// resets the tracking state. Used before redrawing the brush preview at a /// new position. -fn clearTempPreview(editor: *fizzy.Internal.File.EditorData) void { +fn clearTempPreview(editor: *pixelart.internal.File.EditorData) void { if (editor.temp_preview_dirty_rect) |dirty| { if (dirty.w > 0 and dirty.h > 0) { - fizzy.image.clearRect(editor.temporary_layer.source, dirty); + pixelart.image.clearRect(editor.temporary_layer.source, dirty); expandTempGpuDirtyRect(editor, dirty); } } @@ -5999,10 +6001,10 @@ fn clearTempPreview(editor: *fizzy.Internal.File.EditorData) void { } /// Clears the temporary brush preview layer and marks GPU/composite dirty. -fn resetTempLayerPreview(editor: *fizzy.Internal.File.EditorData) void { +fn resetTempLayerPreview(editor: *pixelart.internal.File.EditorData) void { if (editor.temp_preview_dirty_rect) |dirty| { if (dirty.w > 0 and dirty.h > 0) { - fizzy.image.clearRect(editor.temporary_layer.source, dirty); + pixelart.image.clearRect(editor.temporary_layer.source, dirty); expandTempGpuDirtyRect(editor, dirty); } editor.temp_preview_dirty_rect = null; diff --git a/src/plugins/pixelart/widgets/ImageWidget.zig b/src/plugins/pixelart/src/widgets/ImageWidget.zig similarity index 93% rename from src/plugins/pixelart/widgets/ImageWidget.zig rename to src/plugins/pixelart/src/widgets/ImageWidget.zig index 3ce4de64..e314d129 100644 --- a/src/plugins/pixelart/widgets/ImageWidget.zig +++ b/src/plugins/pixelart/src/widgets/ImageWidget.zig @@ -1,5 +1,5 @@ pub const ImageWidget = @This(); -const CanvasWidget = fizzy.dvui.CanvasWidget; +const CanvasWidget = pixelart.core.dvui.CanvasWidget; const CanvasBridge = @import("CanvasBridge.zig"); init_options: InitOptions, @@ -144,27 +144,27 @@ fn sample(self: *ImageWidget, point: dvui.Point, screen_p: dvui.Point.Physical) var color: [4]u8 = .{ 0, 0, 0, 0 }; - if (fizzy.image.pixelIndex(self.init_options.source, point)) |index| { - const c = fizzy.image.pixels(self.init_options.source)[index]; + if (pixelart.image.pixelIndex(self.init_options.source, point)) |index| { + const c = pixelart.image.pixels(self.init_options.source)[index]; if (c[3] > 0) { color = c; } } - fizzy.pixelart.colors.primary = color; + Globals.state.colors.primary = color; self.sample_data_point = point; if (color[3] == 0) { - if (fizzy.pixelart.tools.current != .eraser) { - fizzy.pixelart.tools.set(.eraser); + if (Globals.state.tools.current != .eraser) { + Globals.state.tools.set(.eraser); } } else { - fizzy.pixelart.tools.set(fizzy.pixelart.tools.previous_drawing_tool); + Globals.state.tools.set(Globals.state.tools.previous_drawing_tool); } } pub fn drawCursor(self: *ImageWidget) void { - if (fizzy.dvui.canvasPointerInputSuppressed()) return; + if (pixelart.core.dvui.canvasPointerInputSuppressed()) return; for (dvui.events()) |*e| { if (!self.init_options.canvas.scroll_container.matchEvent(e)) { continue; @@ -207,7 +207,7 @@ pub fn drawSample(self: *ImageWidget) void { } pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data_point: dvui.Point) void { - if (fizzy.dvui.canvasPointerInputSuppressed()) return; + if (pixelart.core.dvui.canvasPointerInputSuppressed()) return; if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return; _ = dvui.cursorSet(.hidden); @@ -268,7 +268,7 @@ pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data }); defer fw.deinit(); - const size = fizzy.image.size(source); + const size = pixelart.image.size(source); const uv_rect = dvui.Rect{ .x = (data_point.x - sample_region_size / 2) / size.w, .y = (data_point.y - sample_region_size / 2) / size.h, @@ -319,7 +319,7 @@ pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data } fn packedAtlasCheckerboardTexture() ?dvui.Texture { - if (fizzy.packer.atlas) |atlas| return atlas.checkerboard_tile; + if (Globals.packer.atlas) |atlas| return atlas.checkerboard_tile; return null; } @@ -385,7 +385,7 @@ pub fn drawImage(self: *ImageWidget) void { // by `syncTransformCachesFromWidgets` before `updateTouchGesture` runs. The mismatch // is the visible "image moves at a different rate than the alpha layer" jitter on the // packed-atlas preview during pinch zoom. Mirror FileWidget.drawLayers, which renders - // its layer textures via `fizzy.render.renderLayers` against the cached `canvas.rect` + // its layer textures via `pixelart.render.renderLayers` against the cached `canvas.rect` // for the same reason. dvui.renderImage(self.init_options.source, .{ .r = self.init_options.canvas.rect, @@ -434,10 +434,10 @@ pub fn processEvents(self: *ImageWidget) void { self.drawImage(); - fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .top, .{}); - fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .bottom, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .left, .{}); - fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .right, .{ .opacity = 0.15 }); + pixelart.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .top, .{}); + pixelart.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .bottom, .{ .opacity = 0.15 }); + pixelart.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .left, .{}); + pixelart.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .right, .{ .opacity = 0.15 }); self.drawCursor(); self.drawSample(); @@ -469,8 +469,9 @@ const ScaleWidget = dvui.ScaleWidget; const std = @import("std"); const math = std.math; const dvui = @import("dvui"); -const fizzy = @import("../../../fizzy.zig"); const builtin = @import("builtin"); +const pixelart = @import("../../pixelart.zig"); +const Globals = pixelart.Globals; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/plugins/workbench/module.zig b/src/plugins/workbench/module.zig new file mode 100644 index 00000000..f5996363 --- /dev/null +++ b/src/plugins/workbench/module.zig @@ -0,0 +1,11 @@ +//! Workbench plugin compile-time module root. +//! +//! Wired in `build.zig` as `b.addModule("workbench", …)` (future). Shell code can +//! import this as `@import("workbench")`. Plugin files inside `src/` import +//! `../workbench.zig` for shared sdk/core access. +pub const workbench = @import("workbench.zig"); +pub const plugin = @import("src/plugin.zig"); +pub const files = @import("src/files.zig"); +pub const Workspace = @import("src/Workspace.zig"); +pub const Workbench = @import("src/Workbench.zig"); +pub const FileLoadJob = @import("src/FileLoadJob.zig"); diff --git a/src/plugins/workbench/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig similarity index 99% rename from src/plugins/workbench/FileLoadJob.zig rename to src/plugins/workbench/src/FileLoadJob.zig index c2150345..2d58d3ab 100644 --- a/src/plugins/workbench/FileLoadJob.zig +++ b/src/plugins/workbench/src/FileLoadJob.zig @@ -15,7 +15,7 @@ //! but only writes through atomic fields + the worker-only `result`/`err`/`canvas_target_grouping` fields. const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const perf = fizzy.perf; diff --git a/src/plugins/workbench/Workbench.zig b/src/plugins/workbench/src/Workbench.zig similarity index 98% rename from src/plugins/workbench/Workbench.zig rename to src/plugins/workbench/src/Workbench.zig index d799a8ef..ea1a6f11 100644 --- a/src/plugins/workbench/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -11,7 +11,7 @@ const std = @import("std"); const dvui = @import("dvui"); const icons = @import("icons"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const files = @import("files.zig"); pub const Workbench = @This(); @@ -221,7 +221,7 @@ fn svcOpenCount(ctx: *anyopaque) usize { fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 { const editor = editorOf(ctx); if (index >= editor.open_files.count()) return null; - return editor.open_files.values()[index].path; + return if (editor.fileAt(index)) |file| file.path else null; } fn svcCreateFile(_: *anyopaque, path: []const u8) anyerror!void { return files.createFilePath(path); diff --git a/src/plugins/workbench/Workspace.zig b/src/plugins/workbench/src/Workspace.zig similarity index 95% rename from src/plugins/workbench/Workspace.zig rename to src/plugins/workbench/src/Workspace.zig index 000429a6..19b4cd30 100644 --- a/src/plugins/workbench/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -2,7 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const icons = @import("icons"); const App = fizzy.App; @@ -183,8 +183,7 @@ fn drawTabs(self: *Workspace) void { }); defer tabs_hbox.deinit(); - const files = fizzy.editor.open_files.values(); - const files_len = files.len; + const files_len = fizzy.editor.open_files.count(); // Find the neighbouring tabs (within this workspace grouping) of the active tab. var prev_same_group_index: ?usize = null; @@ -193,7 +192,8 @@ fn drawTabs(self: *Workspace) void { const active_in_this_group = blk: { if (fizzy.editor.open_workspace_grouping != self.grouping) break :blk false; if (self.open_file_index >= files_len) break :blk false; - if (files[self.open_file_index].editor.grouping != self.grouping) break :blk false; + const active_file = fizzy.editor.fileAt(self.open_file_index) orelse break :blk false; + if (active_file.editor.grouping != self.grouping) break :blk false; break :blk true; }; @@ -204,7 +204,8 @@ fn drawTabs(self: *Workspace) void { var j: usize = active_index; while (j > 0) { j -= 1; - if (files[j].editor.grouping == self.grouping) { + const tab_file = fizzy.editor.fileAt(j) orelse continue; + if (tab_file.editor.grouping == self.grouping) { prev_same_group_index = j; break; } @@ -213,14 +214,16 @@ fn drawTabs(self: *Workspace) void { // Scan right from the active tab to find the next tab in this grouping. j = active_index + 1; while (j < files_len) : (j += 1) { - if (files[j].editor.grouping == self.grouping) { + const tab_file = fizzy.editor.fileAt(j) orelse continue; + if (tab_file.editor.grouping == self.grouping) { next_same_group_index = j; break; } } } - for (files, 0..) |file, i| { + for (0..files_len) |i| { + const file = fizzy.editor.fileAt(i) orelse continue; const is_fizzy_file = fizzy.Internal.File.isFizzyExtension(std.fs.path.extension(file.path)); if (file.editor.grouping != self.grouping) continue; @@ -494,16 +497,16 @@ pub fn processTabsDrag(self: *Workspace) void { if (removed > fizzy.editor.open_files.count()) return; if (removed > insert_before) { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); + std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); fizzy.editor.setActiveFile(insert_before); } else { if (insert_before > 0) { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]); + std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]); std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]); fizzy.editor.setActiveFile(insert_before - 1); } else { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); + std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); fizzy.editor.setActiveFile(insert_before); } @@ -515,21 +518,21 @@ pub fn processTabsDrag(self: *Workspace) void { for (fizzy.editor.workspaces.values()) |*workspace| { if (workspace.tabs_removed_index) |removed| { if (removed > insert_before) { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); + std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.open_files.values()[insert_before].editor.grouping = self.grouping; + fizzy.editor.fileAt(insert_before).?.editor.grouping = self.grouping; fizzy.editor.setActiveFile(insert_before); } else { if (insert_before > 0) { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]); + std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]); std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]); - fizzy.editor.open_files.values()[insert_before - 1].editor.grouping = self.grouping; + fizzy.editor.fileAt(insert_before - 1).?.editor.grouping = self.grouping; fizzy.editor.setActiveFile(insert_before - 1); } else { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); + std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.open_files.values()[insert_before].editor.grouping = self.grouping; + fizzy.editor.fileAt(insert_before).?.editor.grouping = self.grouping; fizzy.editor.setActiveFile(insert_before); } } @@ -547,10 +550,11 @@ pub fn processTabsDrag(self: *Workspace) void { /// Repoint `open_file_index` on workspaces that were showing the dragged tab as active. fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace, drag_index: usize) void { - const dragged_file = &editor.open_files.values()[drag_index]; + const dragged_file = editor.fileAt(drag_index) orelse return; if (tab_bar_workspace) |workspace| { if (workspace.open_file_index == editor.open_files.getIndex(dragged_file.id)) { - for (editor.open_files.values()) |f| { + for (editor.open_files.values()) |doc| { + const f = editor.fileFromDoc(doc); if (f.editor.grouping == workspace.grouping and f.id != dragged_file.id) { workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0; break; @@ -560,7 +564,8 @@ fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace } else { for (editor.workspaces.values()) |*w| { if (w.open_file_index == drag_index) { - for (editor.open_files.values()) |f| { + for (editor.open_files.values()) |doc| { + const f = editor.fileFromDoc(doc); if (f.editor.grouping == w.grouping and f.id != dragged_file.id) { w.open_file_index = editor.open_files.getIndex(f.id) orelse 0; break; @@ -634,7 +639,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); - var dragged_file = &fizzy.editor.open_files.values()[drag_index]; + const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue; dragged_file.editor.grouping = fizzy.editor.newGroupingID(); fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; } @@ -653,7 +658,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); - var dragged_file = &fizzy.editor.open_files.values()[drag_index]; + const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue; dragged_file.editor.grouping = self.grouping; fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0; @@ -679,7 +684,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); - var dragged_file = &fizzy.editor.open_files.values()[drag_index]; + const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue; dragged_file.editor.grouping = fizzy.editor.newGroupingID(); fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; } @@ -697,7 +702,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); - var dragged_file = &fizzy.editor.open_files.values()[drag_index]; + const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue; dragged_file.editor.grouping = self.grouping; fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0; @@ -796,7 +801,7 @@ pub fn drawCanvas(self: *Workspace) !void { self.open_file_index = fizzy.editor.open_files.values().len - 1; } - const file = &fizzy.editor.open_files.values()[self.open_file_index]; + if (fizzy.editor.fileAt(self.open_file_index)) |file| { // The workbench owns only the content region (this container) + tab/split frame; // bind it to the document and route the entire in-region render to the owning // plugin (pixel art draws its rulers, overlays, and editing widget itself). @@ -807,6 +812,7 @@ pub fn drawCanvas(self: *Workspace) !void { if (fizzy.editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { _ = try plugin.drawDocument(.{ .ptr = file, .owner = plugin, .id = file.id }); } + } } else { var box = workspaceEmptyStateCard(content_color, self.grouping); defer box.deinit(); diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/src/files.zig similarity index 99% rename from src/plugins/workbench/files.zig rename to src/plugins/workbench/src/files.zig index 1e07143a..cff326ac 100644 --- a/src/plugins/workbench/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const Editor = fizzy.Editor; const builtin = @import("builtin"); @@ -1258,7 +1258,8 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File .directory => { std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); - for (fizzy.editor.open_files.values()) |*file| { + for (fizzy.editor.open_files.values()) |doc| { + const file = fizzy.editor.fileFromDoc(doc); if (std.mem.containsAtLeast(u8, file.path, 1, full_path)) { const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(file.path)) catch "Failed to duplicate path"; fizzy.app.allocator.free(file.path); diff --git a/src/plugins/workbench/plugin.zig b/src/plugins/workbench/src/plugin.zig similarity index 98% rename from src/plugins/workbench/plugin.zig rename to src/plugins/workbench/src/plugin.zig index 8e6ed926..334cf8df 100644 --- a/src/plugins/workbench/plugin.zig +++ b/src/plugins/workbench/src/plugin.zig @@ -3,7 +3,7 @@ //! than owning new code. Later phases move more behind it until it becomes a //! runtime-loaded dylib. Registered from `Editor.postInit`. const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); +const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const sdk = fizzy.sdk; const files = @import("files.zig"); diff --git a/src/plugins/workbench/workbench.zig b/src/plugins/workbench/workbench.zig new file mode 100644 index 00000000..0ad8c505 --- /dev/null +++ b/src/plugins/workbench/workbench.zig @@ -0,0 +1,9 @@ +//! Intra-plugin import hub for the workbench plugin. +//! +//! Files inside `src/plugins/workbench/src/**` import this as `../workbench.zig` (or +//! `../../workbench.zig` from nested dirs). The compile-time module root is `module.zig`. +const std = @import("std"); + +pub const sdk = @import("sdk"); +pub const core = @import("core"); +pub const dvui = @import("dvui"); diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index db08f64d..e884d458 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -8,6 +8,7 @@ //! `Editor` type across the SDK boundary. const std = @import("std"); const dvui = @import("dvui"); +const DocHandle = @import("DocHandle.zig"); const EditorAPI = @This(); @@ -35,6 +36,17 @@ pub const SaveDialogFilter = extern struct { /// Invoked when a native save dialog resolves: the chosen paths, or null if cancelled. pub const SaveDialogCallback = *const fn (?[][:0]const u8) void; +/// Grid dimensions for `createDocument`. +pub const NewDocGrid = struct { + columns: u32 = 1, + rows: u32 = 1, + column_width: u32, + row_height: u32, +}; + +/// Web save-dialog kind (wasm only; native ignores). +pub const WebSaveKind = enum { save, save_as }; + ctx: *anyopaque, vtable: *const VTable, @@ -52,6 +64,10 @@ pub const VTable = struct { contentOpacity: *const fn (ctx: *anyopaque) f32, /// Whether the OS window is currently maximized (always false on web). isMaximized: *const fn (ctx: *anyopaque) bool, + /// Runtime macOS detection (uses `navigator.platform` on web, `os.tag` on native). + isMacOS: *const fn (ctx: *anyopaque) bool, + /// True on native macOS/Windows where unfocused window chrome dims content opacity. + appliesNativeWindowOpacity: *const fn (ctx: *anyopaque) bool, /// The explorer pane's content rect (shell layout); plugins drawn inside the explorer /// read it to size their content. Zero rect when no shell is installed. explorerRect: *const fn (ctx: *anyopaque) dvui.Rect, @@ -70,6 +86,50 @@ pub const VTable = struct { /// Shell-owned UI icon spritesheet (cursors, tool icons, logo). Stable for the /// editor lifetime; plugins read `.source` / `.sprites` but never mutate it. uiAtlas: *const fn (ctx: *anyopaque) UiAtlasView, + /// The actively focused open document, or null when none. + activeDoc: *const fn (ctx: *anyopaque) ?DocHandle, + /// Open document by ordered index (tab order), or null when out of range. + docByIndex: *const fn (ctx: *anyopaque, index: usize) ?DocHandle, + /// Open document by stable id, or null when not open. + docById: *const fn (ctx: *anyopaque, id: u64) ?DocHandle, + /// Ordered index of document `id`, or null when not open. + docIndex: *const fn (ctx: *anyopaque, id: u64) ?usize, + /// Number of open documents. + openDocCount: *const fn (ctx: *anyopaque) usize, + /// Focus the document at `index` (updates workspace tab selection). + setActiveDocIndex: *const fn (ctx: *anyopaque, index: usize) void, + /// Allocate the next shell document id (monotonic). + allocDocId: *const fn (ctx: *anyopaque) u64, + + // ---- document editing (active file) ---- + accept: *const fn (ctx: *anyopaque) anyerror!void, + cancel: *const fn (ctx: *anyopaque) anyerror!void, + copy: *const fn (ctx: *anyopaque) anyerror!void, + paste: *const fn (ctx: *anyopaque) anyerror!void, + transform: *const fn (ctx: *anyopaque) anyerror!void, + save: *const fn (ctx: *anyopaque) anyerror!void, + requestCompositeWarmup: *const fn (ctx: *anyopaque) void, + requestGridLayoutDialog: *const fn (ctx: *anyopaque) void, + + // ---- new document ---- + /// Heap-owned unique basename like `untitled-1`; caller frees with the app allocator. + allocUntitledPath: *const fn (ctx: *anyopaque) anyerror![]u8, + /// Create and open a new document at `path` (path ownership transfers to the shell). + createDocument: *const fn (ctx: *anyopaque, path: []const u8, grid: NewDocGrid) anyerror!DocHandle, + + // ---- save / quit flow ---- + requestSaveAs: *const fn (ctx: *anyopaque) void, + requestWebSave: *const fn (ctx: *anyopaque, kind: WebSaveKind) void, + cancelPendingSaveDialog: *const fn (ctx: *anyopaque) void, + setPendingCloseDocId: *const fn (ctx: *anyopaque, id: u64) void, + queueCloseAfterSave: *const fn (ctx: *anyopaque, id: u64) anyerror!void, + trackQuitSaveInFlight: *const fn (ctx: *anyopaque, id: u64) anyerror!void, + resumeSaveAllQuit: *const fn (ctx: *anyopaque) void, + abortSaveAllQuit: *const fn (ctx: *anyopaque) void, + + // ---- project pack ---- + startPackProject: *const fn (ctx: *anyopaque) anyerror!void, + isPackingActive: *const fn (ctx: *anyopaque) bool, }; pub fn arena(self: EditorAPI) std.mem.Allocator { @@ -96,6 +156,14 @@ pub fn isMaximized(self: EditorAPI) bool { return self.vtable.isMaximized(self.ctx); } +pub fn isMacOS(self: EditorAPI) bool { + return self.vtable.isMacOS(self.ctx); +} + +pub fn appliesNativeWindowOpacity(self: EditorAPI) bool { + return self.vtable.appliesNativeWindowOpacity(self.ctx); +} + pub fn explorerRect(self: EditorAPI) dvui.Rect { return self.vtable.explorerRect(self.ctx); } @@ -117,3 +185,111 @@ pub fn showSaveDialog( pub fn uiAtlas(self: EditorAPI) UiAtlasView { return self.vtable.uiAtlas(self.ctx); } + +pub fn activeDoc(self: EditorAPI) ?DocHandle { + return self.vtable.activeDoc(self.ctx); +} + +pub fn docByIndex(self: EditorAPI, index: usize) ?DocHandle { + return self.vtable.docByIndex(self.ctx, index); +} + +pub fn docById(self: EditorAPI, id: u64) ?DocHandle { + return self.vtable.docById(self.ctx, id); +} + +pub fn docIndex(self: EditorAPI, id: u64) ?usize { + return self.vtable.docIndex(self.ctx, id); +} + +pub fn openDocCount(self: EditorAPI) usize { + return self.vtable.openDocCount(self.ctx); +} + +pub fn setActiveDocIndex(self: EditorAPI, index: usize) void { + self.vtable.setActiveDocIndex(self.ctx, index); +} + +pub fn allocDocId(self: EditorAPI) u64 { + return self.vtable.allocDocId(self.ctx); +} + +pub fn accept(self: EditorAPI) !void { + return self.vtable.accept(self.ctx); +} + +pub fn cancel(self: EditorAPI) !void { + return self.vtable.cancel(self.ctx); +} + +pub fn copy(self: EditorAPI) !void { + return self.vtable.copy(self.ctx); +} + +pub fn paste(self: EditorAPI) !void { + return self.vtable.paste(self.ctx); +} + +pub fn transform(self: EditorAPI) !void { + return self.vtable.transform(self.ctx); +} + +pub fn save(self: EditorAPI) !void { + return self.vtable.save(self.ctx); +} + +pub fn requestCompositeWarmup(self: EditorAPI) void { + self.vtable.requestCompositeWarmup(self.ctx); +} + +pub fn requestGridLayoutDialog(self: EditorAPI) void { + self.vtable.requestGridLayoutDialog(self.ctx); +} + +pub fn allocUntitledPath(self: EditorAPI) ![]u8 { + return self.vtable.allocUntitledPath(self.ctx); +} + +pub fn createDocument(self: EditorAPI, path: []const u8, grid: NewDocGrid) !DocHandle { + return self.vtable.createDocument(self.ctx, path, grid); +} + +pub fn requestSaveAs(self: EditorAPI) void { + self.vtable.requestSaveAs(self.ctx); +} + +pub fn requestWebSave(self: EditorAPI, kind: WebSaveKind) void { + self.vtable.requestWebSave(self.ctx, kind); +} + +pub fn cancelPendingSaveDialog(self: EditorAPI) void { + self.vtable.cancelPendingSaveDialog(self.ctx); +} + +pub fn setPendingCloseDocId(self: EditorAPI, id: u64) void { + self.vtable.setPendingCloseDocId(self.ctx, id); +} + +pub fn queueCloseAfterSave(self: EditorAPI, id: u64) !void { + return self.vtable.queueCloseAfterSave(self.ctx, id); +} + +pub fn trackQuitSaveInFlight(self: EditorAPI, id: u64) !void { + return self.vtable.trackQuitSaveInFlight(self.ctx, id); +} + +pub fn resumeSaveAllQuit(self: EditorAPI) void { + self.vtable.resumeSaveAllQuit(self.ctx); +} + +pub fn abortSaveAllQuit(self: EditorAPI) void { + self.vtable.abortSaveAllQuit(self.ctx); +} + +pub fn startPackProject(self: EditorAPI) !void { + return self.vtable.startPackProject(self.ctx); +} + +pub fn isPackingActive(self: EditorAPI) bool { + return self.vtable.isPackingActive(self.ctx); +} diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 5b0cad6f..6d401751 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -10,6 +10,7 @@ const dvui = @import("dvui"); const Plugin = @import("Plugin.zig"); const regions = @import("regions.zig"); const EditorAPI = @import("EditorAPI.zig"); +const DocHandle = @import("DocHandle.zig"); pub const Host = @This(); @@ -120,6 +121,14 @@ pub fn isMaximized(self: *Host) bool { return if (self.shell_api) |a| a.isMaximized() else false; } +pub fn isMacOS(self: *Host) bool { + return if (self.shell_api) |a| a.isMacOS() else false; +} + +pub fn appliesNativeWindowOpacity(self: *Host) bool { + return if (self.shell_api) |a| a.appliesNativeWindowOpacity() else false; +} + /// The explorer pane's content rect (shell layout). Zero rect if no shell installed. pub fn explorerRect(self: *Host) dvui.Rect { return if (self.shell_api) |a| a.explorerRect() else .{}; @@ -146,6 +155,115 @@ pub fn uiAtlas(self: *Host) EditorAPI.UiAtlasView { return self.shell_api.?.uiAtlas(); } +/// The actively focused open document, or null when none. +pub fn activeDoc(self: *Host) ?DocHandle { + return if (self.shell_api) |a| a.activeDoc() else null; +} + +pub fn docByIndex(self: *Host, index: usize) ?DocHandle { + return if (self.shell_api) |a| a.docByIndex(index) else null; +} + +pub fn docById(self: *Host, id: u64) ?DocHandle { + return if (self.shell_api) |a| a.docById(id) else null; +} + +pub fn docIndex(self: *Host, id: u64) ?usize { + return if (self.shell_api) |a| a.docIndex(id) else null; +} + +pub fn openDocCount(self: *Host) usize { + return if (self.shell_api) |a| a.openDocCount() else 0; +} + +pub fn setActiveDocIndex(self: *Host, index: usize) void { + if (self.shell_api) |a| a.setActiveDocIndex(index); +} + +pub fn allocDocId(self: *Host) u64 { + return if (self.shell_api) |a| a.allocDocId() else 0; +} + +pub fn accept(self: *Host) !void { + if (self.shell_api) |a| return a.accept(); +} + +pub fn cancel(self: *Host) !void { + if (self.shell_api) |a| return a.cancel(); +} + +pub fn copy(self: *Host) !void { + if (self.shell_api) |a| return a.copy(); +} + +pub fn paste(self: *Host) !void { + if (self.shell_api) |a| return a.paste(); +} + +pub fn transform(self: *Host) !void { + if (self.shell_api) |a| return a.transform(); +} + +pub fn save(self: *Host) !void { + if (self.shell_api) |a| return a.save(); +} + +pub fn requestCompositeWarmup(self: *Host) void { + if (self.shell_api) |a| a.requestCompositeWarmup(); +} + +pub fn requestGridLayoutDialog(self: *Host) void { + if (self.shell_api) |a| a.requestGridLayoutDialog(); +} + +pub fn allocUntitledPath(self: *Host) ![]u8 { + return if (self.shell_api) |a| try a.allocUntitledPath() else error.ShellNotInstalled; +} + +pub fn createDocument(self: *Host, path: []const u8, grid: EditorAPI.NewDocGrid) !DocHandle { + return if (self.shell_api) |a| try a.createDocument(path, grid) else error.ShellNotInstalled; +} + +pub fn requestSaveAs(self: *Host) void { + if (self.shell_api) |a| a.requestSaveAs(); +} + +pub fn requestWebSave(self: *Host, kind: EditorAPI.WebSaveKind) void { + if (self.shell_api) |a| a.requestWebSave(kind); +} + +pub fn cancelPendingSaveDialog(self: *Host) void { + if (self.shell_api) |a| a.cancelPendingSaveDialog(); +} + +pub fn setPendingCloseDocId(self: *Host, id: u64) void { + if (self.shell_api) |a| a.setPendingCloseDocId(id); +} + +pub fn queueCloseAfterSave(self: *Host, id: u64) !void { + if (self.shell_api) |a| return a.queueCloseAfterSave(id); +} + +pub fn trackQuitSaveInFlight(self: *Host, id: u64) !void { + if (self.shell_api) |a| return a.trackQuitSaveInFlight(id); +} + +pub fn resumeSaveAllQuit(self: *Host) void { + if (self.shell_api) |a| a.resumeSaveAllQuit(); +} + +pub fn abortSaveAllQuit(self: *Host) void { + if (self.shell_api) |a| a.abortSaveAllQuit(); +} + +pub fn startPackProject(self: *Host) !void { + if (self.shell_api) |a| return a.startPackProject(); +} + +pub fn isPackingActive(self: *Host) bool { + return if (self.shell_api) |a| a.isPackingActive() else false; +} + // ---- per-plugin settings store --------------------------------------------- /// The stored settings blob for `id` (serialized JSON), or null if none. The returned diff --git a/src/web_main.zig b/src/web_main.zig index daafcbbf..734cffef 100644 --- a/src/web_main.zig +++ b/src/web_main.zig @@ -57,7 +57,7 @@ comptime { // Custom dvui wrapper + widgets — types compile even though the widget files // contain dead `@import("backend")` SDL3 imports at file scope. - _ = @import("plugins/pixelart/widgets/FileWidget.zig"); + _ = @import("plugins/pixelart/src/widgets/FileWidget.zig"); _ = fizzy.dvui.CanvasWidget; // The big ones: Editor + App. Type-level reference only — passes because Zig From 1fb0af2e9549a07af3200d1101789b8f362c239a Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 17:01:28 -0500 Subject: [PATCH 21/49] Phase 4 stage d and e --- HANDOFF.md | 60 ++++++++---- build.zig | 93 +++++++++++++++++-- src/editor/Editor.zig | 9 ++ src/editor/dialogs/Dialogs.zig | 79 ++-------------- src/fizzy.zig | 2 +- src/plugins/pixelart/module.zig | 7 ++ src/plugins/pixelart/pixelart.zig | 11 ++- src/plugins/pixelart/src/CanvasData.zig | 42 ++------- src/plugins/pixelart/src/State.zig | 28 ++++++ src/plugins/pixelart/src/dialogs/Export.zig | 12 +-- src/plugins/pixelart/src/dialogs/NewFile.zig | 10 +- .../pixelart/src/dialogs/dimensions_label.zig | 73 +++++++++++++++ src/plugins/pixelart/src/explorer/sprites.zig | 3 - src/plugins/pixelart/src/internal/Atlas.zig | 4 +- src/plugins/pixelart/src/internal/File.zig | 8 +- src/plugins/pixelart/src/plugin.zig | 38 +++----- src/plugins/pixelart/src/web_file_io.zig | 30 ++++++ .../pixelart/src/widgets/FileWidget.zig | 13 +-- src/plugins/workbench/src/Workspace.zig | 48 +++------- src/sdk/EditorAPI.zig | 6 ++ src/sdk/Host.zig | 4 + src/sdk/WorkbenchPane.zig | 10 ++ src/sdk/pane_layout.zig | 27 ++++++ src/sdk/regions.zig | 6 +- src/sdk/sdk.zig | 4 + src/web_main.zig | 2 +- 26 files changed, 397 insertions(+), 232 deletions(-) create mode 100644 src/plugins/pixelart/src/dialogs/dimensions_label.zig create mode 100644 src/plugins/pixelart/src/web_file_io.zig create mode 100644 src/sdk/WorkbenchPane.zig create mode 100644 src/sdk/pane_layout.zig diff --git a/HANDOFF.md b/HANDOFF.md index 7a780283..de0a2acd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -11,12 +11,10 @@ it can become its own compile-time module. storage inversion, save/pack/editor-action decoupling, platform detection, explorer pane lift, sprites bottom-panel lift. -**In progress:** **Stage D** — module scaffold (`module.zig`, `State.zig`, `pixelart.zig`, -`Globals.zig`), hub consolidation through `fizzy.pixelart_mod`, plugin import migration off -`fizzy.zig`. +**In progress:** **Stage D (substantially complete)** — module scaffold, `Globals` injection, +Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart")` wired. -**Next:** wire `b.addModule("pixelart", …)` in `build.zig`, break `plugin.zig` → -`Editor.Workspace` dep. Then Stage E. +**Next:** Stage E — strip remaining `fizzy.pixelart.*` from shell `Editor.zig`. > **Read this first if you're a fresh agent:** start at "Stage D — remaining work" > below. All three build configs are green right now. @@ -196,6 +194,39 @@ remaining `fizzy.app.allocator` refs in `src/plugins/pixelart/**`. `wireSdkModule` adds `@import("sdk")` to native, web, and test roots. `fizzy.zig` imports sdk via `@import("sdk")` (not a duplicate file-path import). +### SDK pane layout + workspace decoupling (done) + +- **`src/sdk/pane_layout.zig`** — shared `mainCanvasVbox` / `emptyStateCard` helpers. +- **`src/sdk/WorkbenchPane.zig`** — `WorkbenchPaneView { grouping, canvas_rect_physical }` + passed to sidebar `draw_workspace` hooks (plugins no longer cast back to `Workspace`). +- **`State.canvas_by_grouping`** — pixel-art owns per-pane `CanvasData`; `canvasForGrouping` / + `removeCanvasPane` replace the old `Workspace.plugin_view_state` opaque slot. +- **`plugin.zig`** — `drawDocument` uses `CanvasData.forGrouping`; `drawProjectView` uses + `sdk.WorkbenchPaneView` + `sdk.pane_layout`; no `fizzy` import. +- **`FileWidget.zig`** — `canvasData()` reads `Globals.state.canvas_by_grouping`; no `fizzy`. +- **`workbench/Workspace.zig`** — passes `WorkbenchPaneView` to `draw_workspace`; `deinit` + calls `fizzy.State.removeCanvasPane`; layout helpers delegate to `sdk.pane_layout`. + +### Runtime fixes (session) + +| Bug | Fix | +|-----|-----| +| Startup crash in `Tools.init` | Use `self.stroke_shape/size`; set `Globals` before `State.init` | +| Duplicate `Globals` module | `module.zig`: `pub const Globals = pixelart.Globals` | +| Crash opening multiple files | Resolve docs by `doc.id`, not cached `doc.ptr` | +| Crash on close with files open | `State.persistProject()` before `editor.deinit` | + +### Build module wired (done) + +- **`wirePixelartModule`** in `build.zig` — native, web, and test roots import + `@import("pixelart")` with deps: `core`, `sdk`, `dvui`, `assets`, `zip`, `zstbi`, + `msf_gif`, `icons`, `backend` (native/test only). +- **`fizzy.zig`** — `pixelart_mod = @import("pixelart")` (no path import). +- **Zero `@import("fizzy.zig")` in plugin** — last shell leaks removed: + - `dialogs/dimensions_label.zig` + `web_file_io.zig` (plugin-local helpers) + - `EditorAPI.setExplorerNewFilePath` (replaces `Explorer.files.new_file_path` touch) + - `web_main.zig` probes `FileWidget` via `@import("pixelart")` + ### Still direct-importing pixel-art files (shell) ``` @@ -207,19 +238,13 @@ src/web_main.zig → FileWidget.zig force-import (wasm link — mi ## Stage D — remaining work (start here) -1. **Wire `b.addModule("pixelart", …)` in `build.zig`** (native, web, test) with deps: - `core`, `sdk`, `dvui`, `assets`, `zip`, `zstbi`, etc. — mirroring how `core` is wired. - Point the module root at `module.zig`. Today the plugin compiles through path imports - in `fizzy.zig`; the build module is scaffold-only. - -2. **Break `plugin.zig` dependency on `fizzy.Editor.Workspace`** (project view drawing - still reaches into shell types). +1. **Route any straggler shell path imports** of pixel-art files through `pixelart_mod` + or `@import("pixelart")` (mostly done; `process_assets.zig` stays separate). -3. **Route `web_main.zig` FileWidget import** through `pixelart_mod` or the future build - module. +2. **Optional:** wire `b.addModule("workbench", …)` the same way. -4. **Optional cleanup:** shell `Editor.zig` still uses `fizzy.pixelart.*` extensively — - shrink as plugin vtable / EditorAPI surface grows (Stage E). +3. **Stage E cleanup:** shell `Editor.zig` still uses `fizzy.pixelart.*` extensively — + shrink as plugin vtable / EditorAPI surface grows. Do **not** re-introduce a duplicate `@import("plugins/pixelart/module.zig")` from both `App.zig` and `fizzy.zig` via a third path; always go through `fizzy.pixelart_mod` in @@ -299,7 +324,8 @@ grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live grep -rn 'fizzy\.platform' src/plugins/pixelart → 0 grep -rn 'fizzy\.app\.allocator' src/plugins/pixelart → 0 grep -rn 'bridge\.' src/plugins/pixelart → 0 -grep -rn 'plugins/pixelart/' src --include='*.zig' → process_assets, fizzy module import, web_main +grep -rn '@import.*fizzy' src/plugins/pixelart → 0 +grep -rn 'editor/(dialogs|WebFileIo)' src/plugins/pixelart → 0 ``` All three configs green: `zig build`, `zig build check-web`, `zig build test`. diff --git a/build.zig b/build.zig index 85a57c51..79bd5e78 100644 --- a/build.zig +++ b/build.zig @@ -358,7 +358,7 @@ pub fn build(b: *std.Build) !void { core_module_web.addImport("icons", dep.module("icons")); } web_exe.root_module.addImport("core", core_module_web); - wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), web_exe.root_module); + const sdk_module_web = wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), web_exe.root_module); // Three editor files have `const sdl3 = @import("backend").c;` at file // scope. After refactoring all `sdl3.SDL_DialogFileFilter` references @@ -412,6 +412,18 @@ pub fn build(b: *std.Build) !void { }); web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module); + wirePixelartModule(b, web_target, optimize, .{ + .dvui = dvui_web_dep.module("dvui_web"), + .core = core_module_web, + .sdk = sdk_module_web, + .assets = assets_module, + .zip = zip_pkg.module, + .zstbi = zstbi_web_lib.root_module, + .msf_gif = msf_gif_web_lib.root_module, + .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null, + .backend = null, + }, web_exe.root_module); + const web_install_dir: std.Build.InstallDir = .{ .custom = "web" }; const install_wasm = b.addInstallArtifact(web_exe, .{ .dest_dir = .{ .override = web_install_dir }, @@ -847,7 +859,18 @@ pub fn build(b: *std.Build) !void { core_module_test.addImport("icons", dep.module("icons")); } fizzy_test_module.addImport("core", core_module_test); - wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module); + const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module); + wirePixelartModule(b, target, optimize, .{ + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + .assets = assets_module, + .zip = zip_pkg.module, + .zstbi = zstbi_module, + .msf_gif = msf_gif_module, + .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null, + .backend = dvui_testing_dep.module("testing"), + }, fizzy_test_module); if (target.result.os.tag == .macos) { if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| { @@ -1172,7 +1195,24 @@ fn addFizzyExecutableForTarget( core_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); core_module.addImport("known-folders", known_folders); exe.root_module.addImport("core", core_module); - wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module); + const sdk_module = wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module); + var icons_module: ?*std.Build.Module = null; + if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| { + exe.root_module.addImport("icons", dep.module("icons")); + core_module.addImport("icons", dep.module("icons")); + icons_module = dep.module("icons"); + } + wirePixelartModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + .assets = assets_module, + .zip = zip_pkg.module, + .zstbi = zstbi_module, + .msf_gif = msf_gif_module, + .icons = icons_module, + .backend = dvui_dep.module("sdl3"), + }, exe.root_module); const singleton_app_dep = b.dependency("dvui_singleton_app", .{ .target = resolved_target, @@ -1180,11 +1220,6 @@ fn addFizzyExecutableForTarget( }); exe.root_module.addImport("singleton_app", singleton_app_dep.module("singleton_app")); - if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| { - exe.root_module.addImport("icons", dep.module("icons")); - core_module.addImport("icons", dep.module("icons")); - } - if (resolved_target.result.os.tag == .macos) { if (macos_sdl_paths) |p| { // Non-"native" macOS targets (`-Dtarget=aarch64-macos` on Apple Silicon, etc.) need the @@ -1248,7 +1283,7 @@ fn wireSdkModule( optimize: std.builtin.OptimizeMode, dvui_module: *std.Build.Module, consumer: *std.Build.Module, -) void { +) *std.Build.Module { const sdk_module = b.createModule(.{ .target = target, .optimize = optimize, @@ -1256,6 +1291,46 @@ fn wireSdkModule( }); sdk_module.addImport("dvui", dvui_module); consumer.addImport("sdk", sdk_module); + return sdk_module; +} + +const PixelartModuleDeps = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + assets: *std.Build.Module, + zip: *std.Build.Module, + zstbi: *std.Build.Module, + msf_gif: *std.Build.Module, + icons: ?*std.Build.Module, + backend: ?*std.Build.Module, +}; + +/// Pixel-art plugin (`src/plugins/pixelart/module.zig`). +fn wirePixelartModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + deps: PixelartModuleDeps, + consumer: *std.Build.Module, +) void { + const pixelart_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/plugins/pixelart/module.zig"), + .link_libc = target.result.cpu.arch != .wasm32, + .single_threaded = target.result.cpu.arch == .wasm32, + }); + pixelart_module.addImport("dvui", deps.dvui); + pixelart_module.addImport("core", deps.core); + pixelart_module.addImport("sdk", deps.sdk); + pixelart_module.addImport("assets", deps.assets); + pixelart_module.addImport("zip", deps.zip); + pixelart_module.addImport("zstbi", deps.zstbi); + pixelart_module.addImport("msf_gif", deps.msf_gif); + if (deps.icons) |icons| pixelart_module.addImport("icons", icons); + if (deps.backend) |backend| pixelart_module.addImport("backend", backend); + consumer.addImport("pixelart", pixelart_module); } inline fn thisDir() []const u8 { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 206310bc..c03797a6 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -574,6 +574,7 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .requestGridLayoutDialog = shellRequestGridLayoutDialog, .allocUntitledPath = shellAllocUntitledPath, .createDocument = shellCreateDocument, + .setExplorerNewFilePath = shellSetExplorerNewFilePath, .requestSaveAs = shellRequestSaveAs, .requestWebSave = shellRequestWebSave, .cancelPendingSaveDialog = shellCancelPendingSaveDialog, @@ -699,6 +700,14 @@ fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.Ne const owner = fizzy.pixelart_mod.plugin.pluginPtr(); return .{ .ptr = file, .owner = owner, .id = file.id }; } +fn shellSetExplorerNewFilePath(ctx: *anyopaque, path: []const u8) anyerror!void { + const Files = fizzy.Explorer.files; + if (Files.new_file_path) |old| { + fizzy.app.allocator.free(old); + } + Files.new_file_path = try fizzy.app.allocator.dupe(u8, path); + _ = ctx; +} fn shellRequestSaveAs(ctx: *anyopaque) void { shellCtx(ctx).requestSaveAs(); } diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig index 43d7cbac..9dc99d99 100644 --- a/src/editor/dialogs/Dialogs.zig +++ b/src/editor/dialogs/Dialogs.zig @@ -32,74 +32,13 @@ else } }; -pub fn drawDimensionsLabel(src: std.builtin.SourceLocation, width: u32, height: u32, font: dvui.Font, unit: []const u8, opts: dvui.Options) void { - { - var hbox = dvui.box(src, .{ .dir = .horizontal }, opts); - defer hbox.deinit(); - - dvui.label( - src, - "{d}", - .{width}, - .{ - .font = font, - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 1.0, - .id_extra = 1, - }, - ); - - dvui.label( - src, - "{s}", - .{unit}, - .{ - .font = dvui.Font.theme(.body).withSize(font.size - 1.0), - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 0.5, - .id_extra = 2, - }, - ); - - dvui.label( - src, - "x", - .{}, - .{ - .font = dvui.Font.theme(.body).withSize(font.size - 1.0), - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 0.5, - .id_extra = 3, - }, - ); - - dvui.label( - src, - "{d}", - .{height}, - .{ - .font = font, - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 0.5, - .id_extra = 4, - }, - ); - - dvui.label( - src, - "{s}", - .{unit}, - .{ - .font = dvui.Font.theme(.body).withSize(font.size - 1.0), - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 0.5, - .id_extra = 5, - }, - ); - } +pub fn drawDimensionsLabel( + src: std.builtin.SourceLocation, + width: u32, + height: u32, + font: dvui.Font, + unit: []const u8, + opts: dvui.Options, +) void { + fizzy.pixelart_mod.dialogs.DimensionsLabel.drawDimensionsLabel(src, width, height, font, unit, opts); } diff --git a/src/fizzy.zig b/src/fizzy.zig index d9c46493..f684926a 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -17,7 +17,7 @@ pub const version: std.SemanticVersion = .{ pub const atlas = core.atlas; // Other helpers and namespaces -pub const pixelart_mod = @import("plugins/pixelart/module.zig"); +pub const pixelart_mod = @import("pixelart"); pub const algorithms = pixelart_mod.algorithms; pub const render = pixelart_mod.render; pub const sprite_render = pixelart_mod.sprite_render; diff --git a/src/plugins/pixelart/module.zig b/src/plugins/pixelart/module.zig index 55dc09c6..348139ab 100644 --- a/src/plugins/pixelart/module.zig +++ b/src/plugins/pixelart/module.zig @@ -21,12 +21,19 @@ pub const dialogs = struct { pub const Export = @import("src/dialogs/Export.zig"); pub const GridLayout = @import("src/dialogs/GridLayout.zig"); pub const FlatRasterSaveWarning = @import("src/dialogs/FlatRasterSaveWarning.zig"); + pub const DimensionsLabel = @import("src/dialogs/dimensions_label.zig"); }; pub const explorer = struct { pub const project = @import("src/explorer/project.zig"); }; +pub const widgets = struct { + pub const FileWidget = @import("src/widgets/FileWidget.zig"); + pub const ImageWidget = @import("src/widgets/ImageWidget.zig"); + pub const CanvasBridge = @import("src/widgets/CanvasBridge.zig"); +}; + pub const render = @import("src/render.zig"); pub const sprite_render = @import("src/sprite_render.zig"); pub const algorithms = @import("src/algorithms/algorithms.zig"); diff --git a/src/plugins/pixelart/pixelart.zig b/src/plugins/pixelart/pixelart.zig index 66e29e95..88d31974 100644 --- a/src/plugins/pixelart/pixelart.zig +++ b/src/plugins/pixelart/pixelart.zig @@ -2,11 +2,8 @@ //! //! Files inside `src/plugins/pixelart/src/**` import this as `../pixelart.zig` (or //! `../../pixelart.zig` from nested dirs) instead of `fizzy.zig` for sdk/core/Globals -//! and shared plugin types. The compile-time module root for the build is `module.zig`; -//! shell code reaches the plugin through `@import("pixelart")`. -//! -//! Files that still need shell workbench types (`Editor.Workspace`) keep a local -//! `fizzy` import until that surface moves behind EditorAPI. +//! and shared plugin types. The compile-time module root for the build is `module.zig` +//! (`@import("pixelart")`); shell code reaches the plugin through `fizzy.pixelart_mod`. const std = @import("std"); pub const sdk = @import("sdk"); @@ -39,6 +36,10 @@ pub const render = @import("src/render.zig"); pub const sprite_render = @import("src/sprite_render.zig"); pub const algorithms = @import("src/algorithms/algorithms.zig"); +pub const explorer = struct { + pub const project = @import("src/explorer/project.zig"); +}; + pub const internal = struct { pub const File = @import("src/internal/File.zig"); pub const Layer = @import("src/internal/Layer.zig"); diff --git a/src/plugins/pixelart/src/CanvasData.zig b/src/plugins/pixelart/src/CanvasData.zig index 3cb74427..cdb1d48b 100644 --- a/src/plugins/pixelart/src/CanvasData.zig +++ b/src/plugins/pixelart/src/CanvasData.zig @@ -4,20 +4,19 @@ //! the column/row rulers, the floating Edit pill and color-sample button, the transform dialog, //! and the grid (column/row) reorder drag state, plus the matching draw helpers. //! -//! It is pixel-art-owned and lives per pane. The plugin lazily allocates one (`ensure`) and -//! stashes the pointer in the workbench `Workspace.plugin_view_state` opaque slot; the workbench -//! never dereferences it and frees it through `plugin_view_destroy` when the pane is torn down. +//! It is pixel-art-owned and lives per workspace pane (keyed by workbench `grouping` id on +//! `State.canvas_by_grouping`). The workbench never dereferences it; `State.removeCanvasPane` +//! frees it when a pane is torn down. //! State the shell itself needs (the pane's physical content rect, used to center load/save -//! toasts) intentionally stays on `Workspace`. +//! toasts) stays on the workbench `Workspace` and is exposed through `WorkbenchPaneView`. const std = @import("std"); const dvui = @import("dvui"); -const fizzy = @import("../../../fizzy.zig"); const icons = @import("icons"); const FileWidget = @import("widgets/FileWidget.zig"); +const Export = @import("dialogs/Export.zig"); const pixelart = @import("../pixelart.zig"); const Globals = pixelart.Globals; -const Workspace = fizzy.Editor.Workspace; const File = pixelart.internal.File; const CanvasData = @This(); @@ -60,30 +59,9 @@ pub fn init(grouping: u64) CanvasData { /// the pre-relocation behavior where the names lived on `Workspace` and were never freed. pub fn deinit(_: *CanvasData) void {} -/// Get the pixel-art chrome for `ws`, lazily allocating it and registering its teardown on -/// first use. Called from the plugin's `drawDocument` each frame a document pane renders. -pub fn ensure(ws: *Workspace) *CanvasData { - if (ws.plugin_view_state) |p| return @ptrCast(@alignCast(p)); - const self = Globals.allocator().create(CanvasData) catch @panic("OOM allocating CanvasData"); - self.* = CanvasData.init(ws.grouping); - ws.plugin_view_state = self; - ws.plugin_view_destroy = destroyOpaque; - return self; -} - -/// The data already attached to `ws`, or null if none exists yet (e.g. the pane has not -/// drawn a document this session). `FileWidget` uses this for its read-only reorder checks. -/// Only pixel art writes `plugin_view_state`, so the cast is sound. -pub fn fromWorkspace(ws: *Workspace) ?*CanvasData { - const p = ws.plugin_view_state orelse return null; - return @ptrCast(@alignCast(p)); -} - -/// `plugin_view_destroy` target: free the chrome when the workbench tears down its pane. -fn destroyOpaque(state: *anyopaque) void { - const self: *CanvasData = @ptrCast(@alignCast(state)); - self.deinit(); - Globals.allocator().destroy(self); +/// Per-pane chrome for `grouping`, lazily allocated on first document draw. +pub fn forGrouping(grouping: u64) *CanvasData { + return Globals.state.canvasForGrouping(grouping); } pub const RulerOrientation = enum { @@ -1049,8 +1027,8 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { .exportd => { // Open the Export dialog (same configuration the `export` keybind uses). var mutex = pixelart.core.dvui.dialog(@src(), .{ - .displayFn = fizzy.Editor.Dialogs.Export.dialog, - .callafterFn = fizzy.Editor.Dialogs.Export.callAfter, + .displayFn = Export.dialog, + .callafterFn = Export.callAfter, .title = "Export...", .ok_label = "Export", .cancel_label = "Cancel", diff --git a/src/plugins/pixelart/src/State.zig b/src/plugins/pixelart/src/State.zig index e89361c0..f76dad21 100644 --- a/src/plugins/pixelart/src/State.zig +++ b/src/plugins/pixelart/src/State.zig @@ -19,6 +19,8 @@ const ToolsPane = @import("explorer/tools.zig"); const SpritesPane = @import("explorer/sprites.zig"); const SpritesPanel = @import("panel/sprites.zig"); const Palette = @import("internal/Palette.zig"); +const CanvasData = @import("CanvasData.zig"); +const Globals = @import("Globals.zig"); pub const Settings = @import("Settings.zig"); pub const Docs = @import("Docs.zig"); @@ -68,6 +70,25 @@ sprite_clipboard: ?SpriteClipboard = null, /// most recent request produces a visible atlas update. pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty, +/// Per-workspace-pane canvas chrome (rulers, edit pill, grid reorder), keyed by grouping id. +canvas_by_grouping: std.AutoArrayHashMapUnmanaged(u64, *CanvasData) = .{}, + +pub fn canvasForGrouping(st: *State, grouping: u64) *CanvasData { + const gpa = Globals.allocator(); + if (st.canvas_by_grouping.get(grouping)) |existing| return existing; + const cd = gpa.create(CanvasData) catch @panic("OOM allocating CanvasData"); + cd.* = CanvasData.init(grouping); + st.canvas_by_grouping.put(gpa, grouping, cd) catch @panic("OOM allocating CanvasData"); + return cd; +} + +pub fn removeCanvasPane(st: *State, allocator: std.mem.Allocator, grouping: u64) void { + const cd = st.canvas_by_grouping.get(grouping) orelse return; + cd.deinit(); + allocator.destroy(cd); + _ = st.canvas_by_grouping.swapRemove(grouping); +} + pub fn init(allocator: std.mem.Allocator, host: *sdk.Host) !State { var st: State = .{ .host = host, @@ -105,6 +126,13 @@ pub fn deinit(st: *State, allocator: std.mem.Allocator) void { project.deinit(allocator); } + var canvas_it = st.canvas_by_grouping.iterator(); + while (canvas_it.next()) |entry| { + entry.value_ptr.*.deinit(); + allocator.destroy(entry.value_ptr.*); + } + st.canvas_by_grouping.deinit(allocator); + st.tools.deinit(allocator); st.docs.deinit(allocator); } diff --git a/src/plugins/pixelart/src/dialogs/Export.zig b/src/plugins/pixelart/src/dialogs/Export.zig index 4024005f..e28e94f2 100644 --- a/src/plugins/pixelart/src/dialogs/Export.zig +++ b/src/plugins/pixelart/src/dialogs/Export.zig @@ -1,18 +1,16 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); -const zigimg = @import("zigimg"); const msf_gif = @import("msf_gif"); const zstbi = @import("zstbi"); -const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../../../../editor/WebFileIo.zig") else struct {}; - -const ExportImageFormat = enum { png, jpg }; - -const Dialogs = @import("../../../../editor/dialogs/Dialogs.zig"); +const DimensionsLabel = @import("dimensions_label.zig"); +const WebFileIo = @import("../web_file_io.zig"); const pixelart = @import("../../pixelart.zig"); const Globals = pixelart.Globals; +const ExportImageFormat = enum { png, jpg }; + pub var mode: enum(usize) { single, animation, @@ -443,7 +441,7 @@ fn exportScaleSlider(max_scale_val: f32) void { fn exportDimensionsLabelForExport(column_w: u32, row_h: u32) void { const entry_font = dvui.Font.theme(.mono).larger(-2); - Dialogs.drawDimensionsLabel(@src(), column_w, row_h, entry_font, "px", .{ .gravity_x = 0.5 }); + DimensionsLabel.drawDimensionsLabel(@src(), column_w, row_h, entry_font, "px", .{ .gravity_x = 0.5 }); } const ExportFullPreviewKind = enum { layer, composite }; diff --git a/src/plugins/pixelart/src/dialogs/NewFile.zig b/src/plugins/pixelart/src/dialogs/NewFile.zig index 9554493c..c9950e30 100644 --- a/src/plugins/pixelart/src/dialogs/NewFile.zig +++ b/src/plugins/pixelart/src/dialogs/NewFile.zig @@ -1,10 +1,9 @@ const std = @import("std"); const dvui = @import("dvui"); -const Dialogs = @import("../../../../editor/dialogs/Dialogs.zig"); +const DimensionsLabel = @import("dimensions_label.zig"); const pixelart = @import("../../pixelart.zig"); const Globals = pixelart.Globals; -const fizzy = @import("../../../../fizzy.zig"); pub var mode: enum(usize) { single, @@ -176,7 +175,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { const width = column_width * (if (mode == .single) 1 else columns); const height = row_height * (if (mode == .single) 1 else rows); - Dialogs.drawDimensionsLabel(@src(), width, height, entry_font, "px", .{ .gravity_x = 0.5 }); + DimensionsLabel.drawDimensionsLabel(@src(), width, height, entry_font, "px", .{ .gravity_x = 0.5 }); return valid; } @@ -208,10 +207,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void return error.FailedToSaveFile; }; - if (fizzy.Editor.Explorer.files.new_file_path) |old| { - Globals.allocator().free(old); - } - fizzy.Editor.Explorer.files.new_file_path = try Globals.allocator().dupe(u8, file.path); + try Globals.state.host.setExplorerNewFilePath(file.path); dvui.refresh(null, @src(), dvui.currentWindow().data().id); } else { const new_path = try Globals.state.host.allocUntitledPath(); diff --git a/src/plugins/pixelart/src/dialogs/dimensions_label.zig b/src/plugins/pixelart/src/dialogs/dimensions_label.zig new file mode 100644 index 00000000..42db8da8 --- /dev/null +++ b/src/plugins/pixelart/src/dialogs/dimensions_label.zig @@ -0,0 +1,73 @@ +//! Shared "W x H unit" label row for New File / Export dialogs. +const std = @import("std"); +const dvui = @import("dvui"); + +pub fn drawDimensionsLabel(src: std.builtin.SourceLocation, width: u32, height: u32, font: dvui.Font, unit: []const u8, opts: dvui.Options) void { + var hbox = dvui.box(src, .{ .dir = .horizontal }, opts); + defer hbox.deinit(); + + dvui.label( + src, + "{d}", + .{width}, + .{ + .font = font, + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 1.0, + .id_extra = 1, + }, + ); + + dvui.label( + src, + "{s}", + .{unit}, + .{ + .font = dvui.Font.theme(.body).withSize(font.size - 1.0), + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 0.5, + .id_extra = 2, + }, + ); + + dvui.label( + src, + "x", + .{}, + .{ + .font = dvui.Font.theme(.body).withSize(font.size - 1.0), + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 0.5, + .id_extra = 3, + }, + ); + + dvui.label( + src, + "{d}", + .{height}, + .{ + .font = font, + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 0.5, + .id_extra = 4, + }, + ); + + dvui.label( + src, + "{s}", + .{unit}, + .{ + .font = dvui.Font.theme(.body).withSize(font.size - 1.0), + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 0.5, + .id_extra = 5, + }, + ); +} diff --git a/src/plugins/pixelart/src/explorer/sprites.zig b/src/plugins/pixelart/src/explorer/sprites.zig index 1e55629d..888304e5 100644 --- a/src/plugins/pixelart/src/explorer/sprites.zig +++ b/src/plugins/pixelart/src/explorer/sprites.zig @@ -3,9 +3,6 @@ const dvui = @import("dvui"); const icons = @import("icons"); const pixelart = @import("../../pixelart.zig"); const Globals = pixelart.Globals; -const fizzy = @import("../../../../fizzy.zig"); - -const Editor = fizzy.Editor; const Sprites = @This(); diff --git a/src/plugins/pixelart/src/internal/Atlas.zig b/src/plugins/pixelart/src/internal/Atlas.zig index 03fb0c88..dc160a02 100644 --- a/src/plugins/pixelart/src/internal/Atlas.zig +++ b/src/plugins/pixelart/src/internal/Atlas.zig @@ -65,7 +65,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { } const bytes = try out.toOwnedSlice(); defer allocator.free(bytes); - try @import("../../../../editor/WebFileIo.zig").downloadBytes(path, bytes); + try @import("../web_file_io.zig").downloadBytes(path, bytes); }, .data => { if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) { @@ -75,7 +75,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { const options: std.json.Stringify.Options = .{}; const output = try std.json.Stringify.valueAlloc(allocator, atlas.data, options); defer allocator.free(output); - try @import("../../../../editor/WebFileIo.zig").downloadBytes(path, output); + try @import("../web_file_io.zig").downloadBytes(path, output); }, } return; diff --git a/src/plugins/pixelart/src/internal/File.zig b/src/plugins/pixelart/src/internal/File.zig index f9f9e654..a61ca14a 100644 --- a/src/plugins/pixelart/src/internal/File.zig +++ b/src/plugins/pixelart/src/internal/File.zig @@ -3153,15 +3153,15 @@ pub fn saveToDownload(self: *File, window: *dvui.Window) !void { defer snap.deinit(Globals.allocator()); const bytes = try writeSnapshotToZipBytes(&snap, Globals.allocator()); defer Globals.allocator().free(bytes); - try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes); + try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".fiz", bytes); } else if (std.mem.eql(u8, ext, ".png")) { const bytes = try flattenedImageBytes(self, window, .png); defer Globals.allocator().free(bytes); - try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes); + try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".png", bytes); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { const bytes = try flattenedImageBytes(self, window, .jpg); defer Globals.allocator().free(bytes); - try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes); + try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".jpg", bytes); } else { return; } @@ -3343,7 +3343,7 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo }; defer Globals.allocator().free(bytes); const dl_ext = if (is_png) ".png" else ".jpg"; - try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes); + try @import("../web_file_io.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes); } else if (is_png) { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); try pixelart.image.writeToPngResolution(single_layer.source, output_path, r); diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig index db5fbd15..1d08ee96 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -1,10 +1,9 @@ //! The pixel-art editor plugin. Phase 2 thin shim — the pixel-art stack still //! lives inline under `src/editor/` (Phase 3 relocates it whole behind this //! plugin). For now its contributions point at the existing draw entry points -//! through the `fizzy.*` globals. Registered from `Editor.postInit`. +//! through the `Globals` injection. Registered from `Editor.postInit`. const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const pixelart = @import("../pixelart.zig"); const sdk = pixelart.sdk; @@ -97,11 +96,10 @@ fn closeDocument(_: *anyopaque, doc: DocHandle) void { /// `canvas.id` / `workspace_handle` / `center` before routing here; pixel art owns the /// entire region: rulers, the canvas hbox, the transform/edit/sample overlays, the editing /// widget, and the sample magnifier. The per-pane ruler/overlay/reorder state + draw helpers -/// live on the pixel-art-owned `CanvasData` (stashed in the pane's `plugin_view_state`). +/// live on the pixel-art-owned `CanvasData` (keyed by workbench pane `grouping` on `State`). fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { const file = docFile(doc); - const ws = fizzy.Editor.Workspace.ofFile(file) orelse return; - const chrome = CanvasData.ensure(ws); + const chrome = CanvasData.forGrouping(file.editor.grouping); const container = dvui.parentGet().data(); // Grid (column/row) reorder is driven by the rulers and consumed by `FileWidget`; commit @@ -133,7 +131,8 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { // Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom). chrome.drawSampleButton(container); - if (ws.grouping != file.editor.grouping) return; + const pane_grouping = container.options.id_extra orelse return; + if (@as(u64, @intCast(pane_grouping)) != file.editor.grouping) return; var file_widget = FileWidget.init(@src(), .{ .file = file, @@ -155,15 +154,8 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { /// Take over a workspace pane to show the pixel-art packed-atlas preview (the "Project" /// sidebar view's `draw_workspace`). The workbench owns the pane frame and routes here when -/// `view_project` is the active sidebar view; we cast the opaque handle back to the document -/// host's `Workspace` and render the whole content region (atlas image or empty-state hint). -/// Mirrors what `Workspace.drawCanvas` does for documents: reuses the workbench's shared -/// canvas vbox / empty-state card helpers so switching project ↔ canvas keeps stable widget ids, -/// and stamps `canvas_rect_physical` (read by the editor's load/save toast overlays). -fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { - const Workspace = fizzy.Editor.Workspace; - const ws: *Workspace = @ptrCast(@alignCast(workspace_handle)); - +/// `view_project` is the active sidebar view. +fn drawProjectView(_: ?*anyopaque, pane: *sdk.WorkbenchPaneView) anyerror!void { var content_color = dvui.themeGet().color(.window, .fill); if (Globals.state.host.appliesNativeWindowOpacity()) { @@ -178,10 +170,9 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { else Globals.state.host.folder() != null and Globals.packer.atlas != null; - // Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage). - var canvas_vbox = Workspace.workspaceMainCanvasVbox(content_color, show_packed_atlas, ws.grouping); + var canvas_vbox = sdk.pane_layout.mainCanvasVbox(content_color, show_packed_atlas, pane.grouping); defer { - ws.canvas_rect_physical = canvas_vbox.data().contentRectScale().r; + pane.canvas_rect_physical.* = canvas_vbox.data().contentRectScale().r; dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural()); canvas_vbox.deinit(); } @@ -191,9 +182,9 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { var image_widget = ImageWidget.init(@src(), .{ .source = atlas.source, .canvas = &atlas.canvas, - .grouping = ws.grouping, + .grouping = pane.grouping, }, .{ - .id_extra = @intCast(ws.grouping), + .id_extra = @intCast(pane.grouping), .expand = .both, .background = false, .color_fill = .transparent, @@ -208,7 +199,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void { } } } else { - var box = Workspace.workspaceEmptyStateCard(content_color, ws.grouping); + var box = sdk.pane_layout.emptyStateCard(content_color, pane.grouping); defer box.deinit(); const alpha = dvui.alpha(1.0); @@ -250,8 +241,7 @@ pub fn register(host: *sdk.Host) !void { // Adopt the app-owned pixel-art state as this plugin's `state`. Wire Globals // here too so plugin code and the shell share one injection site (App also sets // these before State.init, but register re-syncs after postInit ordering). - Globals.state = fizzy.pixelart; - plugin.state = @ptrCast(@alignCast(fizzy.pixelart)); + plugin.state = @ptrCast(@alignCast(Globals.state)); try host.registerPlugin(&plugin); try host.registerSidebarView(.{ .id = view_tools, @@ -301,7 +291,7 @@ fn drawSprites(_: ?*anyopaque) anyerror!void { try Globals.state.sprites_pane.draw(); } fn drawProject(_: ?*anyopaque) anyerror!void { - try fizzy.Editor.Explorer.project.draw(); + try pixelart.explorer.project.draw(); } fn drawSpritesPanel(_: ?*anyopaque) anyerror!void { try Globals.state.sprites_panel.draw(); diff --git a/src/plugins/pixelart/src/web_file_io.zig b/src/plugins/pixelart/src/web_file_io.zig new file mode 100644 index 00000000..62718bfc --- /dev/null +++ b/src/plugins/pixelart/src/web_file_io.zig @@ -0,0 +1,30 @@ +//! Browser download helpers for the wasm build (no shell `fizzy` dependency). +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; + +fn downloadNameWithExtension(allocator: std.mem.Allocator, filename: []const u8, ext: []const u8) ![]const u8 { + if (std.ascii.eqlIgnoreCase(std.fs.path.extension(filename), ext)) { + return try allocator.dupe(u8, filename); + } + const base = std.fs.path.basename(filename); + const stem: []const u8 = if (std.mem.lastIndexOf(u8, base, ".")) |i| base[0..i] else base; + if (stem.len == 0) { + return try std.fmt.allocPrint(allocator, "download{s}", .{ext}); + } + return try std.fmt.allocPrint(allocator, "{s}{s}", .{ stem, ext }); +} + +pub fn downloadBytes(filename: []const u8, data: []const u8) !void { + if (comptime builtin.target.cpu.arch != .wasm32) return; + try dvui.backend.downloadData(filename, data); +} + +pub fn downloadBytesWithExtension(filename: []const u8, ext: []const u8, data: []const u8) !void { + if (comptime builtin.target.cpu.arch != .wasm32) return; + const name = try downloadNameWithExtension(Globals.allocator(), filename, ext); + defer Globals.allocator().free(name); + try downloadBytes(name, data); +} diff --git a/src/plugins/pixelart/src/widgets/FileWidget.zig b/src/plugins/pixelart/src/widgets/FileWidget.zig index 3f4328c6..6aa49d85 100644 --- a/src/plugins/pixelart/src/widgets/FileWidget.zig +++ b/src/plugins/pixelart/src/widgets/FileWidget.zig @@ -1,7 +1,6 @@ const std = @import("std"); const math = std.math; const dvui = @import("dvui"); -const fizzy = @import("../../../../fizzy.zig"); const builtin = @import("builtin"); const sdl3 = @import("backend").c; @@ -18,7 +17,6 @@ const ScaleWidget = dvui.ScaleWidget; pub const FileWidget = @This(); const CanvasWidget = pixelart.core.dvui.CanvasWidget; const CanvasBridge = @import("CanvasBridge.zig"); -const Workspace = fizzy.Editor.Workspace; const CanvasData = @import("../CanvasData.zig"); const icons = @import("icons"); const pixelart = @import("../../pixelart.zig"); @@ -679,17 +677,10 @@ const BubblePanShared = struct { tool_not_pointer: bool, }; -/// The workspace currently drawing this file, recovered from the file's opaque -/// slot handle. Valid during draw/processEvents — the shell sets the handle each -/// frame (in `Workspace.drawCanvas`) before invoking the widget. -fn workspace(self: *FileWidget) *Workspace { - return Workspace.ofFile(self.init_options.file).?; -} - /// The pixel-art per-pane `CanvasData` for the pane drawing this file, or null if none is -/// attached yet. Holds the column/row reorder drag state this widget reads while previewing. +/// allocated yet. Holds the column/row reorder drag state this widget reads while previewing. fn canvasData(self: *FileWidget) ?*CanvasData { - return CanvasData.fromWorkspace(self.workspace()); + return Globals.state.canvas_by_grouping.get(self.init_options.file.editor.grouping); } /// True while a column or row is mid-drag in this pane's rulers. diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index 19b4cd30..87f20b3a 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -2,6 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); +const sdk = @import("sdk"); const fizzy = @import("../../../fizzy.zig"); const icons = @import("icons"); @@ -23,15 +24,6 @@ tabs_drag_index: ?usize = null, tabs_removed_index: ?usize = null, tabs_insert_before_index: ?usize = null, -/// Opaque per-pane state owned by the plugin that renders documents into this pane (today -/// only pixel art, via `CanvasData`: rulers, edit pill, grid-reorder drag, etc.). The -/// workbench never dereferences it — it just frees it through `plugin_view_destroy` when the -/// pane is torn down (`deinit`). Lazily created by the owning plugin on first document draw. -plugin_view_state: ?*anyopaque = null, -/// Teardown for `plugin_view_state`, set by the owner alongside the state. Null when no -/// plugin view has been attached. -plugin_view_destroy: ?*const fn (state: *anyopaque) void = null, - /// Physical-pixel content rect of this workspace's canvas vbox, captured each frame during /// `drawCanvas` (or a sidebar view's `draw_workspace` takeover, e.g. pixel art's Project view). /// `null` until the workspace has rendered at least once. Used @@ -43,14 +35,10 @@ pub fn init(grouping: u64) Workspace { return .{ .grouping = grouping }; } -/// Release any plugin-owned per-pane view state. Called when a pane is removed +/// Release any plugin-owned per-pane canvas chrome. Called when a pane is removed /// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown. pub fn deinit(self: *Workspace) void { - if (self.plugin_view_state) |state| { - if (self.plugin_view_destroy) |destroy| destroy(state); - self.plugin_view_state = null; - self.plugin_view_destroy = null; - } + fizzy.State.removeCanvasPane(fizzy.pixelart, fizzy.app.allocator, self.grouping); } /// Recover the typed workspace currently drawing `file` from its opaque slot @@ -109,7 +97,11 @@ pub fn draw(self: *Workspace) !dvui.App.Result { // workbench owns only the pane frame; it hands the active view the opaque workspace handle. const active = fizzy.editor.host.activeSidebarView(); if (active != null and active.?.draw_workspace != null) { - try active.?.draw_workspace.?(active.?.ctx, self); + var pane_view: sdk.WorkbenchPaneView = .{ + .grouping = self.grouping, + .canvas_rect_physical = &self.canvas_rect_physical, + }; + try active.?.draw_workspace.?(active.?.ctx, &pane_view); } else { self.drawTabs(); try self.drawCanvas(); @@ -120,30 +112,14 @@ pub fn draw(self: *Workspace) !dvui.App.Result { /// Same `@src()` for every call so DVUI sees one stable id when switching between `drawCanvas` and /// a plugin's `draw_workspace` takeover (avoids first-frame min-size / layout flash). Use `grouping` -/// so multi-workspace panes stay distinct. -/// `pub` so a plugin's `draw_workspace` takeover (pixel art's Project view) can reuse the exact same -/// vbox so switching project ↔ canvas does not churn the widget id. +/// so multi-workspace panes stay distinct. Delegates to `sdk.pane_layout` for a single definition. pub fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget { - return dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = background, - .color_fill = content_color, - .id_extra = @intCast(grouping), - }); + return sdk.pane_layout.mainCanvasVbox(content_color, background, grouping); } -/// Rounded “card” behind the project empty state and the homepage. Shared id base + `grouping` so -/// switching project tab ↔ file pane (no open files) does not create a new widget each time. -/// `pub` so pixel art's Project-view takeover (`draw_workspace`) reuses the identical empty-state card. +/// Rounded “card” behind the project empty state and the homepage. Delegates to `sdk.pane_layout`. pub fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget { - return dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = true, - .color_fill = content_color, - .corner_radius = dvui.Rect.all(16), - .margin = .{ .y = 10 }, - .id_extra = @intCast(grouping), - }); + return sdk.pane_layout.emptyStateCard(content_color, grouping); } fn drawTabs(self: *Workspace) void { diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index e884d458..882589bb 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -116,6 +116,8 @@ pub const VTable = struct { allocUntitledPath: *const fn (ctx: *anyopaque) anyerror![]u8, /// Create and open a new document at `path` (path ownership transfers to the shell). createDocument: *const fn (ctx: *anyopaque, path: []const u8, grid: NewDocGrid) anyerror!DocHandle, + /// Hint the files tree to scroll/highlight a path just created (e.g. New File dialog). + setExplorerNewFilePath: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, // ---- save / quit flow ---- requestSaveAs: *const fn (ctx: *anyopaque) void, @@ -254,6 +256,10 @@ pub fn createDocument(self: EditorAPI, path: []const u8, grid: NewDocGrid) !DocH return self.vtable.createDocument(self.ctx, path, grid); } +pub fn setExplorerNewFilePath(self: EditorAPI, path: []const u8) !void { + return self.vtable.setExplorerNewFilePath(self.ctx, path); +} + pub fn requestSaveAs(self: EditorAPI) void { self.vtable.requestSaveAs(self.ctx); } diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 6d401751..392278e5 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -224,6 +224,10 @@ pub fn createDocument(self: *Host, path: []const u8, grid: EditorAPI.NewDocGrid) return if (self.shell_api) |a| try a.createDocument(path, grid) else error.ShellNotInstalled; } +pub fn setExplorerNewFilePath(self: *Host, path: []const u8) !void { + return if (self.shell_api) |a| try a.setExplorerNewFilePath(path) else error.ShellNotInstalled; +} + pub fn requestSaveAs(self: *Host) void { if (self.shell_api) |a| a.requestSaveAs(); } diff --git a/src/sdk/WorkbenchPane.zig b/src/sdk/WorkbenchPane.zig new file mode 100644 index 00000000..bb3cf5a9 --- /dev/null +++ b/src/sdk/WorkbenchPane.zig @@ -0,0 +1,10 @@ +//! Opaque workbench pane handle passed to a sidebar view's `draw_workspace` hook. +//! Plugins use this instead of casting back to the workbench's internal `Workspace` type. +const dvui = @import("dvui"); + +pub const WorkbenchPaneView = struct { + grouping: u64, + /// Workbench-owned slot; the plugin writes the physical content rect each frame so + /// shell toasts can center over the pane the user is looking at. + canvas_rect_physical: *?dvui.Rect.Physical, +}; diff --git a/src/sdk/pane_layout.zig b/src/sdk/pane_layout.zig new file mode 100644 index 00000000..a896fff5 --- /dev/null +++ b/src/sdk/pane_layout.zig @@ -0,0 +1,27 @@ +//! Shared dvui layout helpers for workbench content panes. Used by the workbench when +//! drawing document canvases and by plugins that take over a pane via `draw_workspace` +//! (e.g. pixel art's Project atlas preview). Stable `@src()` + `grouping` ids avoid +//! widget churn when switching between document and project views. +const dvui = @import("dvui"); + +/// Main vertical canvas region inside a workspace pane. +pub fn mainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget { + return dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = background, + .color_fill = content_color, + .id_extra = @intCast(grouping), + }); +} + +/// Rounded card behind empty states (homepage, project hint, etc.). +pub fn emptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget { + return dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .both, + .background = true, + .color_fill = content_color, + .corner_radius = dvui.Rect.all(16), + .margin = .{ .y = 10 }, + .id_extra = @intCast(grouping), + }); +} diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig index 903254bb..7eeac06f 100644 --- a/src/sdk/regions.zig +++ b/src/sdk/regions.zig @@ -10,6 +10,7 @@ //! cross-plugin references survive without a compile-time dependency. const dvui = @import("dvui"); const Plugin = @import("Plugin.zig"); +const WorkbenchPaneView = @import("WorkbenchPane.zig").WorkbenchPaneView; /// A left-region (explorer) view, selected by its sidebar icon. Exactly one /// sidebar view is active at a time; its `draw` fills the left pane. @@ -24,9 +25,8 @@ pub const SidebarView = struct { draw: *const fn (ctx: ?*anyopaque) anyerror!void, /// Optional: while this view is the active sidebar view, it takes over the workspace /// content region instead of the normal document tabs+canvas. The workbench calls this - /// per workspace pane, passing the opaque workspace handle (cast back to the document - /// host's `Workspace`). Used by pixel art's "Project" view to show the packed atlas. - draw_workspace: ?*const fn (ctx: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void = null, + /// per workspace pane with a `WorkbenchPaneView` (grouping + toast rect slot). + draw_workspace: ?*const fn (ctx: ?*anyopaque, pane: *WorkbenchPaneView) anyerror!void = null, }; /// A bottom-panel view. The panel shows a tab strip across all registered views; diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index dbd90bb7..aae47821 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -23,3 +23,7 @@ pub const SaveDialogFilter = EditorAPI.SaveDialogFilter; pub const SaveDialogCallback = EditorAPI.SaveDialogCallback; pub const UiSprite = EditorAPI.UiSprite; pub const UiAtlasView = EditorAPI.UiAtlasView; + +pub const WorkbenchPane = @import("WorkbenchPane.zig"); +pub const WorkbenchPaneView = WorkbenchPane.WorkbenchPaneView; +pub const pane_layout = @import("pane_layout.zig"); diff --git a/src/web_main.zig b/src/web_main.zig index 734cffef..6534dad9 100644 --- a/src/web_main.zig +++ b/src/web_main.zig @@ -57,7 +57,7 @@ comptime { // Custom dvui wrapper + widgets — types compile even though the widget files // contain dead `@import("backend")` SDL3 imports at file scope. - _ = @import("plugins/pixelart/src/widgets/FileWidget.zig"); + _ = @import("pixelart").widgets.FileWidget; _ = fizzy.dvui.CanvasWidget; // The big ones: Editor + App. Type-level reference only — passes because Zig From 5789d33b3012b288a648a85943625403f364a256 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 17:12:20 -0500 Subject: [PATCH 22/49] Phase 4 stage e --- HANDOFF.md | 26 +- src/App.zig | 16 +- src/editor/Editor.zig | 421 ++++----------------- src/editor/Keybinds.zig | 70 ---- src/editor/Menu.zig | 6 +- src/editor/dialogs/Dialogs.zig | 12 +- src/editor/dialogs/UnsavedClose.zig | 12 +- src/editor/explorer/Explorer.zig | 4 +- src/editor/panel/Panel.zig | 3 +- src/fizzy.zig | 28 +- src/plugins/pixelart/pixelart.zig | 2 +- src/plugins/pixelart/src/keybind_ticks.zig | 82 ++++ src/plugins/pixelart/src/plugin.zig | 22 ++ src/plugins/pixelart/src/radial_menu.zig | 238 ++++++++++++ src/plugins/workbench/src/FileLoadJob.zig | 6 +- src/plugins/workbench/src/Workspace.zig | 8 +- src/plugins/workbench/src/files.zig | 2 +- src/sdk/DocHandle.zig | 2 +- src/sdk/Plugin.zig | 22 ++ src/web_main.zig | 36 +- tests/fizzy_shim.zig | 14 +- tests/integration.zig | 51 +-- 22 files changed, 568 insertions(+), 515 deletions(-) create mode 100644 src/plugins/pixelart/src/keybind_ticks.zig create mode 100644 src/plugins/pixelart/src/radial_menu.zig diff --git a/HANDOFF.md b/HANDOFF.md index de0a2acd..789551ff 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -14,7 +14,7 @@ lift, sprites bottom-panel lift. **In progress:** **Stage D (substantially complete)** — module scaffold, `Globals` injection, Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart")` wired. -**Next:** Stage E — strip remaining `fizzy.pixelart.*` from shell `Editor.zig`. +**Next:** Stage E — trim `fizzy.zig` re-exports; route copy/paste/pack through plugin vtable. > **Read this first if you're a fresh agent:** start at "Stage D — remaining work" > below. All three build configs are green right now. @@ -252,14 +252,22 @@ app code until the build module is fully wired. --- -## Stage E — strip pixel-art names from shell hubs (later) - -- Remove pixel-art type names from `fizzy.zig` hub (consumers import `pixelart` module). -- Remove `editor/dialogs/` pixel-art dialog aliases (plugins register dialogs via SDK). -- Shell `Editor` radial-menu / copy-paste / pack code still touches `fizzy.pixelart.tools` — - route through plugin vtable or EditorAPI. -- Shell still uses `fizzy.Internal.File` directly in several `Editor.zig` helpers — shrink - as doc ownership solidifies. +## Stage E — strip pixel-art names from shell hubs (in progress) + +**Done this session:** +- **`Editor.pixelart_state`** — shell reaches plugin state through the editor, not scattered `fizzy.pixelart.*` (53 → 0 direct field accesses in shell code; `fizzy.pixelart` global remains only in `App.zig` lifecycle). +- **Plugin vtable hooks** — `tickKeybinds`, `processRadialMenuInput`, `radialMenuVisible`, `drawRadialMenu`; radial menu + tool keybind ticks moved to `pixelart/src/radial_menu.zig` and `keybind_ticks.zig`. +- **Shell `Keybinds.tick`** — pixel-art handlers removed (shell-only binds remain). +- **`editor/dialogs/Dialogs.zig`** — imports `@import("pixelart")` directly. +- **Explorer, UnsavedClose, files, Workspace** — use `fizzy.editor.pixelart_state` or `@import("pixelart")`. +- **`fizzy.zig` hub trimmed** — removed re-export aliases (`Tools`, `Internal`, `render`, `Packer`, on-disk types, …). Shell/workbench/tests/web probes now `@import("pixelart")` (or `fizzy.pixelart_mod` in integration tests). `fizzy.zig` keeps only `pixelart_mod` alias + lifecycle globals (`app`, `editor`, `packer`, `pixelart`). +- **`App.zig`** — wires `pixelart.Globals` directly (not `fizzy.pixelart_mod.Globals`). + +**Still remaining:** +- `fizzy.pixelart` global — fold into `Editor.pixelart_state` + `Globals` only. +- Shell `Editor` copy/paste/pack/project still touch `editor.pixelart_state` fields directly — route through plugin vtable or EditorAPI. +- `pixelart.internal.File` in workbench + shell helpers — shrink as doc ownership solidifies. +- Integration test shim updated for `pixelart.State` settings; `check-integration` still blocked on native `backend_native` SDL import under dvui-testing (pre-existing). --- diff --git a/src/App.zig b/src/App.zig index e93f5bb3..ac32b7b2 100644 --- a/src/App.zig +++ b/src/App.zig @@ -8,6 +8,7 @@ const assets = @import("assets"); const icon = assets.files.@"icon.png"; const fizzy = @import("fizzy.zig"); +const pixelart = @import("pixelart"); const auto_update = @import("backend/auto_update.zig"); const update_notify = @import("backend/update_notify.zig"); const singleton = @import("backend/singleton.zig"); @@ -15,7 +16,7 @@ const paths = fizzy.paths; const App = @This(); const Editor = fizzy.Editor; -const Packer = fizzy.Packer; +const Packer = pixelart.Packer; // App fields allocator: std.mem.Allocator = undefined, @@ -168,10 +169,11 @@ pub fn AppInit(win: *dvui.Window) !void { // Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created // before `postInit` so the pixel-art plugin's `register` can adopt it as its // `state`. Owned here for the app's lifetime; torn down in `AppDeinit`. - fizzy.pixelart = try allocator.create(fizzy.State); - fizzy.pixelart_mod.Globals.gpa = allocator; - fizzy.pixelart_mod.Globals.state = fizzy.pixelart; - fizzy.pixelart.* = fizzy.State.init(allocator, &fizzy.editor.host) catch unreachable; + fizzy.pixelart = try allocator.create(pixelart.State); + pixelart.Globals.gpa = allocator; + pixelart.Globals.state = fizzy.pixelart; + fizzy.pixelart.* = pixelart.State.init(allocator, &fizzy.editor.host) catch unreachable; + fizzy.editor.pixelart_state = fizzy.pixelart; // Second-stage init that needs the editor at its final heap address (e.g. // registering the workbench-api service whose `ctx` is this pointer). @@ -183,7 +185,7 @@ pub fn AppInit(win: *dvui.Window) !void { fizzy.packer = try allocator.create(Packer); fizzy.packer.* = Packer.init(allocator) catch unreachable; - fizzy.pixelart_mod.Globals.packer = fizzy.packer; + pixelart.Globals.packer = fizzy.packer; // Hand the window to the listener thread and queue our own argv so the // first frame opens any files / project folder supplied on the command line. @@ -231,7 +233,7 @@ pub fn AppDeinit() void { // Persist the current windowed frame while the window still exists. No-op off macOS. fizzy.backend.saveWindowGeometry(fizzy.app.window); // Persist `.fizproject` while `editor.host` and `editor.folder` are still live. - fizzy.State.persistProject(fizzy.pixelart); + pixelart.State.persistProject(fizzy.pixelart); fizzy.editor.deinit() catch unreachable; // Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs). // After the editor so any editor teardown that still reads pixel-art state runs first. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index c03797a6..00913396 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -14,16 +14,17 @@ const plus_jakarta_sans_ttf = assets.files.fonts.@"PlusJakartaSans-Regular.ttf"; const plus_jakarta_sans_bold_ttf = assets.files.fonts.@"PlusJakartaSans-Bold.ttf"; const fizzy = @import("../fizzy.zig"); +const pixelart = @import("pixelart"); +const Internal = pixelart.internal; const dvui = @import("dvui"); const update_notify = @import("../backend/update_notify.zig"); const App = fizzy.App; const Editor = @This(); -const Project = fizzy.pixelart_mod.Project; +const Project = pixelart.Project; pub const Recents = @import("Recents.zig"); pub const Settings = @import("Settings.zig"); -const Tools = fizzy.Tools; pub const Dialogs = @import("dialogs/Dialogs.zig"); pub const Keybinds = @import("Keybinds.zig"); @@ -36,7 +37,7 @@ pub const Sidebar = @import("Sidebar.zig"); pub const Infobar = @import("Infobar.zig"); pub const Menu = @import("Menu.zig"); pub const FileLoadJob = @import("../plugins/workbench/src/FileLoadJob.zig"); -const PackJob = fizzy.PackJob; +const PackJob = pixelart.PackJob; pub const sdk = fizzy.sdk; pub const Host = sdk.Host; @@ -57,6 +58,9 @@ atlas: fizzy.core.Atlas, /// Plugin registry + service locator exposed to plugins host: Host, +/// Pixel-art plugin runtime state (owned by App; shell reaches it here instead of `fizzy.pixelart`). +pixelart_state: *pixelart.State, + /// File-management workbench (per-branch explorer decorations, …) workbench: Workbench, @@ -253,6 +257,7 @@ pub fn init( }, .themes = .empty, .host = .init(app.allocator), + .pixelart_state = undefined, .workbench = .init(app.allocator), }; @@ -270,7 +275,7 @@ pub fn init( // Start the long-lived save-queue worker. All .fiz async saves get // serialized through this single thread (see `File.SaveQueue`); concurrent // worker spawns were causing one save to wedge under contention. - try fizzy.Internal.File.initSaveQueue(); + try Internal.File.initSaveQueue(); { // Setup themes var fizzy_dark = dvui.themeGet(); @@ -477,7 +482,8 @@ pub fn postInit(editor: *Editor) !void { // hardcoding panes. Web-safe — the draw fns reach the same inline code the // editor tick already runs on wasm. Order = sidebar order. try @import("../plugins/workbench/src/plugin.zig").register(&editor.host); - try fizzy.pixelart_mod.plugin.register(&editor.host); +const pixelart_plugin = pixelart.plugin; + try pixelart_plugin.register(&editor.host); // Shell built-in: Settings (owner = null; not a plugin). try editor.host.registerSidebarView(.{ @@ -697,7 +703,7 @@ fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.Ne .column_width = grid.column_width, .row_height = grid.row_height, }); - const owner = fizzy.pixelart_mod.plugin.pluginPtr(); + const owner = pixelart.plugin.pluginPtr(); return .{ .ptr = file, .owner = owner, .id = file.id }; } fn shellSetExplorerNewFilePath(ctx: *anyopaque, path: []const u8) anyerror!void { @@ -745,8 +751,8 @@ fn shellIsPackingActive(ctx: *anyopaque) bool { /// Resolve a shell `DocHandle` to the plugin-owned file. Uses `doc.id`, not `doc.ptr`: /// `docs.files` may reallocate and invalidate pointers stored at insert time. -pub fn fileFromDoc(_: *Editor, doc: sdk.DocHandle) *fizzy.Internal.File { - return fizzy.pixelart.docs.fileById(doc.id).?; +pub fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File { + return editor.pixelart_state.docs.fileById(doc.id).?; } pub fn docAt(editor: *Editor, index: usize) ?sdk.DocHandle { @@ -766,9 +772,9 @@ pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { } /// Store a loaded/created document in the plugin registry and register its handle. -pub fn insertOpenDoc(editor: *Editor, file: fizzy.Internal.File, owner: *sdk.Plugin) !void { - try fizzy.pixelart.docs.files.put(fizzy.app.allocator, file.id, file); - const ptr = fizzy.pixelart.docs.files.getPtr(file.id).?; +pub fn insertOpenDoc(editor: *Editor, file: Internal.File, owner: *sdk.Plugin) !void { + try editor.pixelart_state.docs.files.put(fizzy.app.allocator, file.id, file); + const ptr = editor.pixelart_state.docs.files.getPtr(file.id).?; try editor.open_files.put(fizzy.app.allocator, file.id, .{ // `ptr` is a hint only; consumers must resolve via `fileFromDoc` / `doc.id`. .ptr = ptr, @@ -1043,7 +1049,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.setTitlebarColor(); editor.setWindowStyle(); - fizzy.render.frame_index +%= 1; + pixelart.render.frame_index +%= 1; if (fizzy.perf.record) fizzy.perf.beginFrame(); defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog(); @@ -1070,7 +1076,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { const area = @as(u64, w) * @as(u64, h); // Skip tiny canvases; large docs benefit most from moving split-target work off the first stroke. if (area >= 512 * 512) { - fizzy.render.warmupDrawingComposites(file) catch |err| { + pixelart.render.warmupDrawingComposites(file) catch |err| { dvui.log.err("Composite warmup failed: {any}", .{err}); }; } @@ -1491,18 +1497,16 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } } - { // Radial Menu - + { // Radial Menu (pixel-art plugin) + const pa = pixelart.plugin.pluginPtr(); + try pa.tickKeybinds(); Keybinds.tick() catch { dvui.log.err("Failed to tick hotkeys", .{}); }; - processHoldOpenRadialMenu(editor); - - if (fizzy.pixelart.tools.radial_menu.visible) { - editor.drawRadialMenu() catch { - dvui.log.err("Failed to draw radial menu", .{}); - }; + pa.processRadialMenuInput(); + if (pa.radialMenuVisible()) { + try pa.drawRadialMenu(); } } @@ -1697,267 +1701,6 @@ pub fn setWindowStyle(_: *Editor) void { fizzy.backend.setWindowStyle(dvui.currentWindow()); } -/// Dismiss rules for the hold-opened radial menu (empty workspace area): stay open after -/// the opening finger lifts; close on tool button click or a non-drag click outside. -fn processHoldOpenRadialMenu(_: *Editor) void { - const rm = &fizzy.pixelart.tools.radial_menu; - if (!rm.visible or !rm.opened_by_press) { - rm.outside_click_press_p = null; - return; - } - - const dismiss_move_threshold: f32 = dvui.Dragging.threshold; - - for (dvui.events()) |*e| { - if (e.evt != .mouse) continue; - const me = e.evt.mouse; - rm.mouse_position = me.p; - - const primary = me.button.pointer() or me.button.touch(); - if (!primary) continue; - - switch (me.action) { - .press => { - if (!rm.containsPhysical(me.p)) { - rm.outside_click_press_p = me.p; - } else { - rm.outside_click_press_p = null; - } - }, - .motion => { - if (rm.outside_click_press_p) |press_p| { - if (me.p.diff(press_p).length() > dismiss_move_threshold) { - rm.outside_click_press_p = null; - } - } - }, - .release => { - if (rm.suppress_next_pointer_release) { - rm.suppress_next_pointer_release = false; - rm.outside_click_press_p = null; - continue; - } - if (rm.outside_click_press_p) |press_p| { - const moved = me.p.diff(press_p).length() > dismiss_move_threshold; - if (!moved and !rm.containsPhysical(me.p) and !rm.containsPhysical(press_p)) { - rm.close(); - } - rm.outside_click_press_p = null; - } - }, - else => {}, - } - } -} - -pub fn drawRadialMenu(editor: *Editor) !void { - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{}, .{ - .rect = .cast(dvui.windowRect()), - }); - defer fw.deinit(); - - const menu_color = dvui.themeGet().color(.content, .fill).lighten(4.0); - - // `center` is set when the menu opens (Space down or hold on empty workspace) and stays - // fixed until close so tool buttons remain hoverable/clickable. - const center = fw.data().rectScale().pointFromPhysical(fizzy.pixelart.tools.radial_menu.center); - - const tool_count: usize = std.meta.fields(Tools.Tool).len; - - const radius: f32 = 50.0; - const width: f32 = radius * 2.0; - const height: f32 = radius * 2.0; - const step: f32 = (2.0 * std.math.pi) / @as(f32, @floatFromInt(tool_count)); - - var angle: f32 = 180.0; - - var outer_anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{}); - - const temp_radius: f32 = 3.0 * radius * (outer_anim.val orelse 1.0); - - var outer_rect = dvui.Rect.fromPoint(center); - outer_rect.w = temp_radius; - outer_rect.h = temp_radius; - outer_rect.x -= outer_rect.w / 2.0; - outer_rect.y -= outer_rect.h / 2.0; - - var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .rect = outer_rect, - .expand = .none, - .background = true, - .corner_radius = dvui.Rect.all(100000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -4.0, .y = 4.0 }, - .fade = 8.0, - .alpha = 0.35, - }, - .color_fill = menu_color.opacity(0.75), - .border = dvui.Rect.all(0.0), - }); - - box.deinit(); - - outer_anim.deinit(); - - for (0..tool_count) |i| { - var anim = dvui.animate(@src(), .{ .duration = 100_000 + 50_000 * @as(i32, @intCast(i)), .kind = .alpha, .easing = dvui.easing.linear }, .{ - .id_extra = i, - }); - defer anim.deinit(); - - if (anim.val) |val| { - angle += ((1 - val) * 100.0) * 0.015; - } - - var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.pixelart.colors.file_tree_palette) |*palette| { - color = palette.getDVUIColor(i); - } - - const x: f32 = std.math.round(width / 2.0 + radius * std.math.cos(angle) - width / 2.0); - const y: f32 = std.math.round(height / 2.0 + radius * std.math.sin(angle) - height / 2.0); - - const new_center = center.plus(.{ .x = x, .y = y }); - - { // Draw line along pie slice - // const line_x: f32 = std.math.round(width / 2.0 + radius * std.math.cos(angle + step / 2.0) - width / 2.0); - // const line_y: f32 = std.math.round(height / 2.0 + radius * std.math.sin(angle + step / 2.0) - height / 2.0); - - // const new_line_center = center.plus((dvui.Point{ .x = line_x, .y = line_y }).normalize().scale(radius * 1.5, dvui.Point)); - - // dvui.Path.stroke(.{ .points = &.{ center.scale(scale, dvui.Point.Physical), new_line_center.scale(scale, dvui.Point.Physical) } }, .{ - // .color = dvui.themeGet().color(.control, .text), - // .thickness = 1.0, - // }); - } - - var rect = dvui.Rect.fromPoint(new_center); - - rect.w = 40.0; - rect.h = 40.0; - rect.x -= rect.w / 2.0; - rect.y -= rect.h / 2.0; - - const tool = @as(Tools.Tool, @enumFromInt(i)); - - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{}, .{ - .rect = rect, - .id_extra = i, - .corner_radius = dvui.Rect.all(1000.0), - .color_fill = if (tool == fizzy.pixelart.tools.current) dvui.themeGet().color(.content, .fill) else .transparent, - .box_shadow = if (tool == fizzy.pixelart.tools.current) .{ - .color = .black, - .offset = .{ .x = -2.5, .y = 2.5 }, - .fade = 4.0, - .alpha = 0.25, - .corner_radius = dvui.Rect.all(1000), - } else null, - .padding = .all(0), - .margin = .all(0), - }); - - { - fizzy.pixelart.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {}; - } - - const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) { - .box => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.box_selection_default], - .pixel => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pixel_selection_default], - .color => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.color_selection_default], - }; - - const sprite = switch (@as(Tools.Tool, @enumFromInt(i))) { - .pointer => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.cursor_default], - .pencil => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pencil_default], - .eraser => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.eraser_default], - .bucket => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.bucket_default], - .selection => selection_sprite, - }; - const size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 1, .h = 1 }; - const atlas_w = if (size.w > 0) size.w else 1; - const atlas_h = if (size.h > 0) size.h else 1; - - const uv = dvui.Rect{ - .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_w, - .y = @as(f32, @floatFromInt(sprite.source[1])) / atlas_h, - .w = @as(f32, @floatFromInt(sprite.source[2])) / atlas_w, - .h = @as(f32, @floatFromInt(sprite.source[3])) / atlas_h, - }; - - button.processEvents(); - button.drawBackground(); - - var rs = button.data().contentRectScale(); - - const w = @as(f32, @floatFromInt(sprite.source[2])) * rs.s; - const h = @as(f32, @floatFromInt(sprite.source[3])) * rs.s; - - rs.r.x += (rs.r.w - w) / 2.0; - rs.r.y += (rs.r.h - h) / 2.0; - rs.r.w = w; - rs.r.h = h; - - dvui.renderImage(fizzy.editor.atlas.source, rs, .{ - .uv = uv, - .fade = 0.0, - }) catch { - std.log.err("Failed to render image", .{}); - }; - angle += step; - - if (button.hovered()) { - fizzy.pixelart.tools.set(tool); - } - if (button.clicked()) { - fizzy.pixelart.tools.set(tool); - fizzy.pixelart.tools.radial_menu.close(); - } - - button.deinit(); - } - - { // Center play/pause button - - var anim = dvui.animate(@src(), .{ .duration = 100_000, .kind = .alpha, .easing = dvui.easing.linear }, .{ - .id_extra = tool_count + 1, - }); - defer anim.deinit(); - - var rect = dvui.Rect.fromPoint(center); - - rect.w = 40.0; - rect.h = 40.0; - rect.x -= rect.w / 2.0; - rect.y -= rect.h / 2.0; - - { - if (editor.activeFile()) |file| { - if (dvui.buttonIcon(@src(), "Play", if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, .{}, .{}, .{ - .expand = .none, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.5, .y = 2.5 }, - .fade = 4.0, - .alpha = 0.25, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill_hover), - .rect = rect, - })) { - file.editor.playing = !file.editor.playing; - if (fizzy.pixelart.tools.radial_menu.opened_by_press) { - fizzy.pixelart.tools.radial_menu.close(); - } - } - } - } - } -} - pub fn rebuildWorkspaces(editor: *Editor) !void { // Create workspaces for each grouping ID @@ -2115,7 +1858,7 @@ fn tickPendingSaveCloses(editor: *Editor) void { var i: usize = 0; while (i < editor.pending_close_after_save.count()) { const id = editor.pending_close_after_save.keys()[i]; - if (fizzy.pixelart.docs.fileById(id)) |f| { + if (editor.pixelart_state.docs.fileById(id)) |f| { if (f.isSaving()) { i += 1; continue; @@ -2145,7 +1888,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { // Pass 1: kick off any queued saves we haven't started yet. while (editor.quit_save_all_ids.items.len > 0) { const id = editor.quit_save_all_ids.items[0]; - const file_ptr = fizzy.pixelart.docs.fileById(id) orelse { + const file_ptr = editor.pixelart_state.docs.fileById(id) orelse { _ = editor.quit_save_all_ids.swapRemove(0); continue; }; @@ -2154,7 +1897,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { continue; } - if (!fizzy.Internal.File.hasRecognizedSaveExtension(file_ptr.path)) { + if (!Internal.File.hasRecognizedSaveExtension(file_ptr.path)) { // Save As dialog needs a single active file — bail out of the parallel // kickoff for this one and let the existing Save As + pending_close_file_id // flow handle it. Next frame, pending_quit_continue will re-enter us. @@ -2194,7 +1937,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { var i: usize = 0; while (i < editor.quit_saves_in_flight.count()) { const id = editor.quit_saves_in_flight.keys()[i]; - if (fizzy.pixelart.docs.fileById(id)) |f| { + if (editor.pixelart_state.docs.fileById(id)) |f| { if (f.isSaving()) { i += 1; continue; @@ -2238,7 +1981,7 @@ pub fn close(app: *App, editor: *Editor) void { pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { if (editor.folder) |folder| { editor.ignore.deinit(fizzy.app.allocator); - if (fizzy.pixelart.project) |*project| { + if (editor.pixelart_state.project) |*project| { project.save() catch { dvui.log.err("Failed to save project", .{}); }; @@ -2249,7 +1992,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); editor.host.setActiveSidebarView(@import("../plugins/workbench/src/plugin.zig").view_files); - fizzy.pixelart.project = Project.load(fizzy.app.allocator) catch null; + editor.pixelart_state.project = Project.load(fizzy.app.allocator) catch null; editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); } @@ -2344,7 +2087,7 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { } /// Synchronous open from browser file-picker bytes. Caller owns `path` on success (stored in `File.path`). -pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !fizzy.Internal.File { +pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !Internal.File { for (editor.open_files.values()) |doc| { const file = editor.fileFromDoc(doc); if (std.mem.eql(u8, file.path, path)) { @@ -2361,7 +2104,7 @@ pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, groupin return error.InvalidExtension; }; - var file: fizzy.Internal.File = undefined; + var file: Internal.File = undefined; const handled = owner.loadDocumentFromBytes(path, bytes, &file) catch |err| { fizzy.app.allocator.free(path); return err; @@ -2499,7 +2242,7 @@ pub fn startPackProject(editor: *Editor) !void { // predecessor publishes `done` between append and cancel: `processPackJob` walks the list // newest-first and would otherwise see an old non-cancelled ready job and install its // (stale) atlas. Cancelled predecessors are skipped during install selection. - for (fizzy.pixelart.pack_jobs.items) |old| { + for (editor.pixelart_state.pack_jobs.items) |old| { old.cancelled.store(true, .monotonic); } @@ -2507,8 +2250,8 @@ pub fn startPackProject(editor: *Editor) !void { owned_inputs = null; errdefer job.destroy(); - try fizzy.pixelart.pack_jobs.append(fizzy.app.allocator, job); - errdefer _ = fizzy.pixelart.pack_jobs.pop(); + try editor.pixelart_state.pack_jobs.append(fizzy.app.allocator, job); + errdefer _ = editor.pixelart_state.pack_jobs.pop(); if (comptime builtin.target.cpu.arch == .wasm32) { // Worker runs at end of `tick` (after the explorer draws) so the Pack @@ -2522,8 +2265,8 @@ pub fn startPackProject(editor: *Editor) !void { /// True while a pack is queued, running, or finished but not yet installed into /// `fizzy.packer.atlas`. Drives the explorer Pack button spinner. -pub fn isPackingActive(_: *const Editor) bool { - for (fizzy.pixelart.pack_jobs.items) |job| { +pub fn isPackingActive(editor: *const Editor) bool { + for (editor.pixelart_state.pack_jobs.items) |job| { if (job.cancelled.load(.monotonic)) continue; if (!job.done.load(.acquire)) return true; if (!job.result_consumed) return true; @@ -2532,8 +2275,8 @@ pub fn isPackingActive(_: *const Editor) bool { } /// Run queued wasm pack workers after UI has drawn so `isPackingActive` can show feedback. -fn runWasmPackWorkers(_: *Editor) void { - for (fizzy.pixelart.pack_jobs.items) |job| { +fn runWasmPackWorkers(editor: *Editor) void { + for (editor.pixelart_state.pack_jobs.items) |job| { if (job.cancelled.load(.monotonic)) continue; if (job.done.load(.acquire)) continue; PackJob.workerMain(job); @@ -2562,7 +2305,7 @@ fn gatherPackInputs( while (try iter.next(io)) |entry| { if (entry.kind == .file) { const ext = std.fs.path.extension(entry.name); - if (!fizzy.Internal.File.isFizzyExtension(ext)) continue; + if (!Internal.File.isFizzyExtension(ext)) continue; const abs_path = try std.fs.path.join(fizzy.app.allocator, &.{ directory, entry.name }); defer fizzy.app.allocator.free(abs_path); @@ -2581,7 +2324,7 @@ fn gatherPackInputs( } /// Match a project-tree path to an open file (`file.path` may differ in normalization from `join` vs `joinZ`). -fn findOpenFileForPackPath(editor: *Editor, path: []const u8) ?*fizzy.Internal.File { +fn findOpenFileForPackPath(editor: *Editor, path: []const u8) ?*Internal.File { if (editor.getFileFromPath(path)) |file| return file; const basename = std.fs.path.basename(path); @@ -2618,17 +2361,17 @@ fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void { /// rest. Older or cancelled jobs' results — even successful ones — are freed without affecting /// `fizzy.packer.atlas` so coalesced re-triggers can't briefly flicker stale atlases. pub fn processPackJob(editor: *Editor) void { - if (fizzy.pixelart.pack_jobs.items.len == 0) return; + if (editor.pixelart_state.pack_jobs.items.len == 0) return; // Identify the newest (last appended) job that finished with a `.ready` result and was // not cancelled. Only its result is installed; older successful results are stale and // get discarded along with cancelled / failed ones. var install_index: ?usize = null; { - var i = fizzy.pixelart.pack_jobs.items.len; + var i = editor.pixelart_state.pack_jobs.items.len; while (i > 0) { i -= 1; - const job = fizzy.pixelart.pack_jobs.items[i]; + const job = editor.pixelart_state.pack_jobs.items[i]; if (!job.done.load(.acquire)) continue; if (job.cancelled.load(.monotonic)) continue; if (job.currentPhase() == .ready and job.result_atlas != null) { @@ -2639,7 +2382,7 @@ pub fn processPackJob(editor: *Editor) void { } if (install_index) |idx| { - const job = fizzy.pixelart.pack_jobs.items[idx]; + const job = editor.pixelart_state.pack_jobs.items[idx]; const new_atlas = job.result_atlas.?; // Free the previously-installed atlas's allocations so the new one can take its // place — matches the synchronous `packAndClear` cleanup ordering. @@ -2659,16 +2402,16 @@ pub fn processPackJob(editor: *Editor) void { } fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp(); job.result_consumed = true; - editor.host.setActiveSidebarView(fizzy.pixelart_mod.plugin.view_project); + editor.host.setActiveSidebarView(pixelart.plugin.view_project); const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null; showPackToast("Project packed", toast_canvas); } else blk: { // Newest finished job had no atlas (empty inputs / no packable frames). Tell the user // so the Pack button doesn't look like it silently did nothing. - var i = fizzy.pixelart.pack_jobs.items.len; + var i = editor.pixelart_state.pack_jobs.items.len; while (i > 0) { i -= 1; - const job = fizzy.pixelart.pack_jobs.items[i]; + const job = editor.pixelart_state.pack_jobs.items[i]; if (!job.done.load(.acquire)) continue; if (job.cancelled.load(.monotonic)) continue; if (job.currentPhase() == .ready and job.result_atlas == null) { @@ -2681,9 +2424,9 @@ pub fn processPackJob(editor: *Editor) void { // Reap everything that has published `done`. Successful-but-superseded jobs leave their // `result_atlas` un-consumed; `destroy()` frees those allocations for us. var write: usize = 0; - for (fizzy.pixelart.pack_jobs.items) |job| { + for (editor.pixelart_state.pack_jobs.items) |job| { if (!job.done.load(.acquire)) { - fizzy.pixelart.pack_jobs.items[write] = job; + editor.pixelart_state.pack_jobs.items[write] = job; write += 1; continue; } @@ -2698,7 +2441,7 @@ pub fn processPackJob(editor: *Editor) void { } job.destroy(); } - fizzy.pixelart.pack_jobs.shrinkRetainingCapacity(write); + editor.pixelart_state.pack_jobs.shrinkRetainingCapacity(write); } /// Returns the active workspace's canvas content rect (physical pixels) captured from the @@ -2895,21 +2638,21 @@ pub fn requestCompositeWarmup(editor: *Editor) void { editor.pending_composite_warmup = true; } -pub fn newFile(editor: *Editor, path: []const u8, options: fizzy.Internal.File.InitOptions) !*fizzy.Internal.File { +pub fn newFile(editor: *Editor, path: []const u8, options: Internal.File.InitOptions) !*Internal.File { if (editor.getFileFromPath(path)) |_| { return error.FileAlreadyExists; } - const file = fizzy.Internal.File.init(path, options) catch { + const file = Internal.File.init(path, options) catch { dvui.log.err("Failed to create file: {s}", .{path}); return error.FailedToCreateFile; }; - try editor.insertOpenDoc(file, fizzy.pixelart_mod.plugin.pluginPtr()); + try editor.insertOpenDoc(file, pixelart.plugin.pluginPtr()); editor.setActiveFile(editor.open_files.count() - 1); editor.pending_composite_warmup = true; - return fizzy.pixelart.docs.fileById(file.id) orelse return error.FailedToCreateFile; + return editor.pixelart_state.docs.fileById(file.id) orelse return error.FailedToCreateFile; } /// Heap-owned path like `untitled-1`, unique among open-document basenames. @@ -2983,22 +2726,22 @@ pub fn setActiveFile(editor: *Editor, index: usize) void { } /// Returns the actively focused file, through workspace grouping. -pub fn activeFile(editor: *Editor) ?*fizzy.Internal.File { +pub fn activeFile(editor: *Editor) ?*Internal.File { const doc = editor.activeDoc() orelse return null; return editor.fileFromDoc(doc); } -pub fn getFile(editor: *Editor, index: usize) ?*fizzy.Internal.File { +pub fn getFile(editor: *Editor, index: usize) ?*Internal.File { return editor.fileAt(index); } -pub fn fileAt(editor: *Editor, index: usize) ?*fizzy.Internal.File { +pub fn fileAt(editor: *Editor, index: usize) ?*Internal.File { const doc = editor.docAt(index) orelse return null; return editor.fileFromDoc(doc); } -pub fn getFileFromPath(_: *Editor, path: []const u8) ?*fizzy.Internal.File { - return fizzy.pixelart.docs.fileFromPath(path); +pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*Internal.File { + return editor.pixelart_state.docs.fileFromPath(path); } pub fn forceCloseFile(editor: *Editor, index: usize) !void { @@ -3035,15 +2778,15 @@ pub fn copy(editor: *Editor) !void { if (editor.activeFile()) |file| { if (file.editor.transform != null) return; - if (fizzy.pixelart.sprite_clipboard) |*clipboard| { + if (editor.pixelart_state.sprite_clipboard) |*clipboard| { fizzy.app.allocator.free(fizzy.image.bytes(clipboard.source)); - fizzy.pixelart.sprite_clipboard = null; + editor.pixelart_state.sprite_clipboard = null; } file.editor.transform_layer.clear(); var selected_layer = file.layers.get(file.selected_layer_index); - switch (fizzy.pixelart.tools.current) { + switch (editor.pixelart_state.tools.current) { .selection => { // We are in the selection tool, so we should assume that the user has painted a selection // into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing @@ -3108,7 +2851,7 @@ pub fn copy(editor: *Editor) !void { if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { const sprite_tl = file.spritePoint(reduced_data_rect.topLeft()); - fizzy.pixelart.sprite_clipboard = .{ + editor.pixelart_state.sprite_clipboard = .{ .source = fizzy.image.fromPixelsPMA( @ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)), @intFromFloat(reduced_data_rect.w), @@ -3131,7 +2874,7 @@ pub fn copy(editor: *Editor) !void { } pub fn paste(editor: *Editor) !void { - if (fizzy.pixelart.sprite_clipboard) |*clipboard| { + if (editor.pixelart_state.sprite_clipboard) |*clipboard| { if (editor.activeFile()) |file| { const active_layer = file.layers.get(file.selected_layer_index); @@ -3145,7 +2888,7 @@ pub fn paste(editor: *Editor) !void { dst_rect.y = sprite_rect.y + clipboard.offset.y; file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create target texture", .{}); return; }, @@ -3188,7 +2931,7 @@ pub fn paste(editor: *Editor) !void { dst_rect.y = rect.y + clipboard.offset.y; file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create target texture", .{}); return; }, @@ -3217,7 +2960,7 @@ pub fn paste(editor: *Editor) !void { } file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create target texture", .{}); return; }, @@ -3259,7 +3002,7 @@ pub fn transform(editor: *Editor) !void { var selected_layer = file.layers.get(file.selected_layer_index); - switch (fizzy.pixelart.tools.current) { + switch (editor.pixelart_state.tools.current) { .selection => { file.editor.transform_layer.clear(); // We are in the selection tool, so we should assume that the user has painted a selection @@ -3338,7 +3081,7 @@ pub fn transform(editor: *Editor) !void { if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { defer file.editor.selection_layer.clearMask(); file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create target texture", .{}); return; }, @@ -3374,7 +3117,7 @@ pub fn transform(editor: *Editor) !void { /// Paths without a recognized on-disk extension (e.g. in-memory `untitled-n`) open Save As instead. pub fn save(editor: *Editor) !void { const file = editor.activeFile() orelse return; - if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) { + if (!Internal.File.hasRecognizedSaveExtension(file.path)) { editor.requestSaveAs(); return; } @@ -3407,7 +3150,7 @@ pub fn saveAll(editor: *Editor) !void { for (editor.open_files.values()) |doc| { const file = editor.fileFromDoc(doc); if (!file.dirty()) continue; - if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) continue; + if (!Internal.File.hasRecognizedSaveExtension(file.path)) continue; if (file.shouldConfirmFlatRasterSave()) continue; const plugin = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse continue; plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id }) catch |err| { @@ -3425,7 +3168,7 @@ const save_as_dialog_filters: [3]fizzy.backend.DialogFileFilter = .{ /// Opens a Save As dialog: `.fiz` (all layers; `.pixi` also accepted for legacy) or flat `.png` / `.jpg` / `.jpeg` (visible layers composited). pub fn requestSaveAs(_: *Editor) void { const active = fizzy.editor.activeFile() orelse return; - const def = fizzy.Internal.File.defaultSaveAsFilename(fizzy.app.allocator, active.path) catch { + const def = Internal.File.defaultSaveAsFilename(fizzy.app.allocator, active.path) catch { std.log.err("Failed to build default save-as name", .{}); return; }; @@ -3453,7 +3196,7 @@ pub fn cancelPendingSaveDialog(editor: *Editor) void { if (file_id) |id| { _ = editor.pending_close_after_save.swapRemove(id); - if (fizzy.pixelart.docs.fileById(id)) |f| { + if (editor.pixelart_state.docs.fileById(id)) |f| { f.resetSaveUIState(); } } else if (editor.activeFile()) |f| { @@ -3503,7 +3246,7 @@ fn processPendingSaveAs(editor: *Editor) void { const file = editor.activeFile() orelse return; const ext = std.fs.path.extension(path); const saved: bool = blk: { - if (fizzy.Internal.File.isFizzyExtension(ext)) { + if (Internal.File.isFizzyExtension(ext)) { file.saveAsFizzy(path, dvui.currentWindow()) catch |err| { dvui.log.err("Save As: {any}", .{err}); break :blk false; @@ -3541,7 +3284,7 @@ fn processPendingSaveAs(editor: *Editor) void { }; const saved: bool = blk: { - if (fizzy.Internal.File.isFizzyExtension(ext)) { + if (Internal.File.isFizzyExtension(ext)) { file.saveAsFizzy(path, dvui.currentWindow()) catch |err| { dvui.log.err("Save As: {any}", .{err}); break :blk false; @@ -3604,7 +3347,7 @@ pub fn openInFileBrowser(_: *Editor, path: []const u8) !void { pub fn closeFileID(editor: *Editor, id: u64) !void { if (editor.open_files.contains(id)) { - if (fizzy.pixelart.docs.fileById(id)) |file| { + if (editor.pixelart_state.docs.fileById(id)) |file| { if (file.dirty()) { Dialogs.UnsavedClose.request(id); return; @@ -3624,11 +3367,11 @@ pub fn closeFile(editor: *Editor, index: usize) !void { /// the matching `DocHandle` from `open_files`. fn closeDocumentResources(editor: *Editor, doc: sdk.DocHandle) void { if (doc.owner.closeDocument(doc)) { - _ = fizzy.pixelart.docs.files.swapRemove(doc.id); + _ = editor.pixelart_state.docs.files.swapRemove(doc.id); return; } editor.fileFromDoc(doc).deinit(); - _ = fizzy.pixelart.docs.files.swapRemove(doc.id); + _ = editor.pixelart_state.docs.files.swapRemove(doc.id); } pub fn rawCloseFile(editor: *Editor, index: usize) !void { @@ -3673,14 +3416,14 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void { pub fn closeReference(editor: *Editor, index: usize) !void { editor.open_reference_index = 0; - var reference: fizzy.Internal.Reference = editor.open_references.orderedRemove(index); + var reference: Internal.Reference = editor.open_references.orderedRemove(index); reference.deinit(); } pub fn deinit(editor: *Editor) !void { // Drain & join the save-queue worker before tearing anything else down. Any // queued jobs need to finish writing or be dropped before File data is freed. - fizzy.Internal.File.deinitSaveQueue(); + Internal.File.deinitSaveQueue(); // Signal cancel to any in-flight load workers. They check the flag after `fromPath` returns // and discard the result; we can't synchronously join them without blocking quit, so we // accept a brief window where a worker may still be running with a discardable result. diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index c5714204..f7289a5a 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -71,34 +71,6 @@ pub fn tick() !void { } } - if (ke.matchBind("quick_tools")) { - const rm = &fizzy.pixelart.tools.radial_menu; - switch (ke.action) { - .down => { - const mp = dvui.currentWindow().mouse_pt; - rm.mouse_position = mp; - rm.center = mp; - rm.opened_by_press = false; - rm.suppress_next_pointer_release = false; - rm.outside_click_press_p = null; - rm.visible = true; - }, - .repeat => rm.visible = true, - .up => rm.close(), - } - // If we include a refresh here, the underlying gui has a chance to reset the cursor - dvui.refresh(null, @src(), dvui.currentWindow().data().id); - } - - if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) { - if (fizzy.pixelart.tools.stroke_size < fizzy.Tools.max_brush_size - 1) - fizzy.pixelart.tools.stroke_size += 1; - - fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size); - } - } - if (ke.matchBind("save_as") and ke.action == .down) { fizzy.editor.requestSaveAs(); } @@ -109,32 +81,6 @@ pub fn tick() !void { }; } - if (ke.matchBind("export") and ke.action == .down) { - // Create a generic dialog that contains typical okay and cancel buttons and header - // The displayFn will be called during the drawing of the dialog, prior to ok and cancel buttons - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = fizzy.Editor.Dialogs.Export.dialog, - .callafterFn = fizzy.Editor.Dialogs.Export.callAfter, - .title = "Export...", - .ok_label = "Export", - .cancel_label = "Cancel", - .resizeable = false, - .modal = false, - .header_kind = .info, - .default = .ok, - }); - mutex.mutex.unlock(dvui.io); - } - - if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) { - if (fizzy.pixelart.tools.stroke_size > 1) - fizzy.pixelart.tools.stroke_size -= 1; - - fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size); - } - } - if (ke.matchBind("delete_selection_contents")) { if (ke.action == .down) { fizzy.editor.deleteSelectedContents(); @@ -210,22 +156,6 @@ pub fn tick() !void { } } } - - if (ke.matchBind("pencil") and ke.action == .down) { - fizzy.pixelart.tools.set(.pencil); - } - if (ke.matchBind("eraser") and ke.action == .down) { - fizzy.pixelart.tools.set(.eraser); - } - if (ke.matchBind("bucket") and ke.action == .down) { - fizzy.pixelart.tools.set(.bucket); - } - if (ke.matchBind("pointer") and ke.action == .down) { - fizzy.pixelart.tools.set(.pointer); - } - if (ke.matchBind("selection") and ke.action == .down) { - fizzy.pixelart.tools.set(.selection); - } }, else => {}, } diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 6a83cc57..cdc13904 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -1,5 +1,7 @@ const std = @import("std"); const fizzy = @import("../fizzy.zig"); +const pixelart = @import("pixelart"); +const Internal = pixelart.internal; const dvui = @import("dvui"); const Editor = fizzy.Editor; const settings = fizzy.settings; @@ -134,7 +136,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { _ = dvui.separator(@src(), .{ .expand = .horizontal }); if (menuItemWithHotkey(@src(), "Save", dvui.currentWindow().keybinds.get("save") orelse .{}, if (fizzy.editor.activeFile()) |file| - (file.dirty() or !fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) + (file.dirty() or !Internal.File.hasRecognizedSaveExtension(file.path)) else false, .{}, .{ .expand = .horizontal, @@ -159,7 +161,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { const any_dirty = blk: { for (fizzy.editor.open_files.values()) |doc| { const f = fizzy.editor.fileFromDoc(doc); - if (f.dirty() and fizzy.Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true; + if (f.dirty() and Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true; } break :blk false; }; diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig index 9dc99d99..b851d228 100644 --- a/src/editor/dialogs/Dialogs.zig +++ b/src/editor/dialogs/Dialogs.zig @@ -1,16 +1,16 @@ const std = @import("std"); const builtin = @import("builtin"); +const pixelart = @import("pixelart"); const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); const Dialogs = @This(); -pub const NewFile = fizzy.pixelart_mod.dialogs.NewFile; -pub const Export = fizzy.pixelart_mod.dialogs.Export; +pub const NewFile = pixelart.dialogs.NewFile; +pub const Export = pixelart.dialogs.Export; pub const UnsavedClose = @import("UnsavedClose.zig"); pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig"); -pub const GridLayout = fizzy.pixelart_mod.dialogs.GridLayout; -pub const FlatRasterSaveWarning = fizzy.pixelart_mod.dialogs.FlatRasterSaveWarning; +pub const GridLayout = pixelart.dialogs.GridLayout; +pub const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning; pub const AboutFizzy = @import("AboutFizzy.zig"); pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32) @import("WebFolderUnavailable.zig") @@ -40,5 +40,5 @@ pub fn drawDimensionsLabel( unit: []const u8, opts: dvui.Options, ) void { - fizzy.pixelart_mod.dialogs.DimensionsLabel.drawDimensionsLabel(src, width, height, font, unit, opts); + pixelart.dialogs.DimensionsLabel.drawDimensionsLabel(src, width, height, font, unit, opts); } diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index 32cb0511..87a0d436 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -1,7 +1,9 @@ const std = @import("std"); const fizzy = @import("../../fizzy.zig"); +const pixelart = @import("pixelart"); +const Internal = pixelart.internal; const dvui = @import("dvui"); -const FlatRasterSaveWarning = fizzy.pixelart_mod.dialogs.FlatRasterSaveWarning; +const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning; pub fn request(file_id: u64) void { var mutex = fizzy.dvui.dialog(@src(), .{ @@ -21,7 +23,7 @@ pub fn request(file_id: u64) void { } fn fileBasename(file_id: u64) []const u8 { - const file = fizzy.pixelart.docs.fileById(file_id) orelse return "?"; + const file = fizzy.editor.pixelart_state.docs.fileById(file_id) orelse return "?"; return std.fs.path.basename(file.path); } @@ -97,7 +99,7 @@ fn onCancel() void { /// on the GUI thread) and queue the close for once `File.isSaving()` clears. /// `Editor.tickPendingSaveCloses` does the actual close on the next frame after /// the worker settles, so the GUI thread never blocks on the save. -fn beginSaveAndClose(file: *fizzy.Internal.File, file_id: u64) !void { +fn beginSaveAndClose(file: *Internal.File, file_id: u64) !void { if (file.isSaving()) return; if (comptime @import("builtin").target.cpu.arch == .wasm32) { const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; @@ -111,8 +113,8 @@ fn beginSaveAndClose(file: *fizzy.Internal.File, file_id: u64) !void { } fn onSaveAndClose(file_id: u64) !void { - const file = fizzy.pixelart.docs.fileById(file_id) orelse return; - if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) { + const file = fizzy.editor.pixelart_state.docs.fileById(file_id) orelse return; + if (!Internal.File.hasRecognizedSaveExtension(file.path)) { const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; fizzy.editor.setActiveFile(idx); fizzy.editor.pending_close_file_id = file_id; diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 731678b5..7af9aed0 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -7,7 +7,7 @@ const icons = @import("icons"); const Core = @import("mach").Core; const App = fizzy.App; const Editor = fizzy.Editor; -const Packer = fizzy.Packer; +const pixelart = @import("pixelart"); const nfd = @import("nfd"); @@ -16,7 +16,7 @@ pub const Explorer = @This(); pub const files = @import("../../plugins/workbench/src/files.zig"); // pub const animations = @import("animations.zig"); // pub const keyframe_animations = @import("keyframe_animations.zig"); -pub const project = fizzy.pixelart_mod.explorer.project; +pub const project = pixelart.explorer.project; pub const settings = @import("settings.zig"); paned: *fizzy.dvui.PanedWidget = undefined, diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig index f63c1c39..fc4684e1 100644 --- a/src/editor/panel/Panel.zig +++ b/src/editor/panel/Panel.zig @@ -2,12 +2,13 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); +const pixelart = @import("pixelart"); const fizzy = @import("../../fizzy.zig"); const Core = @import("mach").Core; const App = fizzy.App; const Editor = fizzy.Editor; -const Packer = fizzy.Packer; +const Packer = pixelart.Packer; pub const Panel = @This(); diff --git a/src/fizzy.zig b/src/fizzy.zig index f684926a..61ae745c 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -17,13 +17,6 @@ pub const version: std.SemanticVersion = .{ pub const atlas = core.atlas; // Other helpers and namespaces -pub const pixelart_mod = @import("pixelart"); -pub const algorithms = pixelart_mod.algorithms; -pub const render = pixelart_mod.render; -pub const sprite_render = pixelart_mod.sprite_render; -pub const Tools = pixelart_mod.Tools; -pub const Transform = pixelart_mod.Transform; -pub const PackJob = pixelart_mod.PackJob; pub const fs = core.fs; pub const image = core.image; pub const perf = core.perf; @@ -34,28 +27,19 @@ pub const App = @import("App.zig"); pub const Editor = @import("editor/Editor.zig"); pub const Explorer = @import("editor/explorer/Explorer.zig"); pub const Fling = core.Fling; -pub const Packer = pixelart_mod.Packer; //pub const Popups = @import("editor/popups/Popups.zig"); pub const Sidebar = @import("editor/Sidebar.zig"); -/// Pixel-art plugin state (Phase 4 Stage B/D): reached via `fizzy.pixelart` global. -pub const State = pixelart_mod.State; +/// Pixel-art plugin module. Shell code should `@import("pixelart")` directly; +/// this alias exists only for `App.zig` lifecycle wiring (can't name it `pixelart` +/// — that name is the runtime `*State` global below). +pub const pixelart_mod = @import("pixelart"); // Global pointers pub var app: *App = undefined; pub var editor: *Editor = undefined; -pub var packer: *Packer = undefined; -pub var pixelart: *State = undefined; - -/// Internal runtime types for open documents (cameras, history, buffers, …). -pub const Internal = pixelart_mod.internal; - -/// On-disk / JSON pixel-art types. -pub const Animation = pixelart_mod.Animation; -pub const Atlas = pixelart_mod.Atlas; -pub const File = pixelart_mod.File; -pub const Layer = pixelart_mod.Layer; -pub const Sprite = pixelart_mod.Sprite; +pub var packer: *pixelart_mod.Packer = undefined; +pub var pixelart: *pixelart_mod.State = undefined; /// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web /// builds, where `builtin.os.tag` is always `.freestanding`. diff --git a/src/plugins/pixelart/pixelart.zig b/src/plugins/pixelart/pixelart.zig index 88d31974..7bb86e8c 100644 --- a/src/plugins/pixelart/pixelart.zig +++ b/src/plugins/pixelart/pixelart.zig @@ -3,7 +3,7 @@ //! Files inside `src/plugins/pixelart/src/**` import this as `../pixelart.zig` (or //! `../../pixelart.zig` from nested dirs) instead of `fizzy.zig` for sdk/core/Globals //! and shared plugin types. The compile-time module root for the build is `module.zig` -//! (`@import("pixelart")`); shell code reaches the plugin through `fizzy.pixelart_mod`. +//! (`@import("pixelart")`); shell code imports the module directly. const std = @import("std"); pub const sdk = @import("sdk"); diff --git a/src/plugins/pixelart/src/keybind_ticks.zig b/src/plugins/pixelart/src/keybind_ticks.zig new file mode 100644 index 00000000..fff4a09f --- /dev/null +++ b/src/plugins/pixelart/src/keybind_ticks.zig @@ -0,0 +1,82 @@ +//! Global keybind handlers for pixel-art editing (tool shortcuts, radial menu, export). +const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const Tools = pixelart.Tools; +const Export = @import("dialogs/Export.zig"); + +pub fn tick() !void { + for (dvui.events()) |e| { + if (e.handled) continue; + + switch (e.evt) { + .key => |ke| { + if (ke.matchBind("quick_tools")) { + const rm = &Globals.state.tools.radial_menu; + switch (ke.action) { + .down => { + const mp = dvui.currentWindow().mouse_pt; + rm.mouse_position = mp; + rm.center = mp; + rm.opened_by_press = false; + rm.suppress_next_pointer_release = false; + rm.outside_click_press_p = null; + rm.visible = true; + }, + .repeat => rm.visible = true, + .up => rm.close(), + } + dvui.refresh(null, @src(), dvui.currentWindow().data().id); + } + + if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { + if (Globals.state.tools.current != .selection or Globals.state.tools.selection_mode == .pixel) { + if (Globals.state.tools.stroke_size < Tools.max_brush_size - 1) + Globals.state.tools.stroke_size += 1; + Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size); + } + } + + if (ke.matchBind("export") and ke.action == .down) { + var mutex = pixelart.core.dvui.dialog(@src(), .{ + .displayFn = Export.dialog, + .callafterFn = Export.callAfter, + .title = "Export...", + .ok_label = "Export", + .cancel_label = "Cancel", + .resizeable = false, + .modal = false, + .header_kind = .info, + .default = .ok, + }); + mutex.mutex.unlock(dvui.io); + } + + if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) { + if (Globals.state.tools.current != .selection or Globals.state.tools.selection_mode == .pixel) { + if (Globals.state.tools.stroke_size > 1) + Globals.state.tools.stroke_size -= 1; + Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size); + } + } + + if (ke.matchBind("pencil") and ke.action == .down) { + Globals.state.tools.set(.pencil); + } + if (ke.matchBind("eraser") and ke.action == .down) { + Globals.state.tools.set(.eraser); + } + if (ke.matchBind("bucket") and ke.action == .down) { + Globals.state.tools.set(.bucket); + } + if (ke.matchBind("pointer") and ke.action == .down) { + Globals.state.tools.set(.pointer); + } + if (ke.matchBind("selection") and ke.action == .down) { + Globals.state.tools.set(.selection); + } + }, + else => {}, + } + } +} diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig index 1d08ee96..73fd96b0 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -13,6 +13,8 @@ const CanvasData = @import("CanvasData.zig"); const FileWidget = @import("widgets/FileWidget.zig"); const ImageWidget = @import("widgets/ImageWidget.zig"); const PixelArtSettings = @import("Settings.zig"); +const KeybindTicks = @import("keybind_ticks.zig"); +const RadialMenu = @import("radial_menu.zig"); const DocHandle = sdk.DocHandle; const Internal = pixelart.internal; @@ -41,6 +43,10 @@ const vtable: sdk.Plugin.VTable = .{ .undo = undo, .redo = redo, .drawDocument = drawDocument, + .tickKeybinds = tickKeybinds, + .processRadialMenuInput = processRadialMenuInput, + .radialMenuVisible = radialMenuVisible, + .drawRadialMenu = drawRadialMenu, }; /// A `DocHandle` for one of this plugin's open `*Internal.File`s. Resolved by `doc.id` @@ -297,6 +303,22 @@ fn drawSpritesPanel(_: ?*anyopaque) anyerror!void { try Globals.state.sprites_panel.draw(); } +fn tickKeybinds(_: *anyopaque) anyerror!void { + try KeybindTicks.tick(); +} + +fn processRadialMenuInput(_: *anyopaque) void { + RadialMenu.processHoldOpenInput(); +} + +fn radialMenuVisible(_: *anyopaque) bool { + return RadialMenu.visible(); +} + +fn drawRadialMenu(_: *anyopaque) anyerror!void { + try RadialMenu.draw(); +} + /// Pixel-art editing + tool keybinds. The shell registers its own global/region /// binds in `Keybinds.register`; this fills in the pixel-art half. Platform: see /// `Keybinds.register` for why `host.isMacOS()` (not `builtin`) is used. diff --git a/src/plugins/pixelart/src/radial_menu.zig b/src/plugins/pixelart/src/radial_menu.zig new file mode 100644 index 00000000..103c7e2a --- /dev/null +++ b/src/plugins/pixelart/src/radial_menu.zig @@ -0,0 +1,238 @@ +//! Radial tool menu overlay — opened via Space / hold on empty workspace. +const std = @import("std"); +const dvui = @import("dvui"); +const icons = @import("icons"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const Tools = pixelart.Tools; + +pub fn visible() bool { + return Globals.state.tools.radial_menu.visible; +} + +pub fn processHoldOpenInput() void { + const rm = &Globals.state.tools.radial_menu; + if (!rm.visible or !rm.opened_by_press) { + rm.outside_click_press_p = null; + return; + } + + const dismiss_move_threshold: f32 = dvui.Dragging.threshold; + + for (dvui.events()) |*e| { + if (e.evt != .mouse) continue; + const me = e.evt.mouse; + rm.mouse_position = me.p; + + const primary = me.button.pointer() or me.button.touch(); + if (!primary) continue; + + switch (me.action) { + .press => { + if (!rm.containsPhysical(me.p)) { + rm.outside_click_press_p = me.p; + } else { + rm.outside_click_press_p = null; + } + }, + .motion => { + if (rm.outside_click_press_p) |press_p| { + if (me.p.diff(press_p).length() > dismiss_move_threshold) { + rm.outside_click_press_p = null; + } + } + }, + .release => { + if (rm.suppress_next_pointer_release) { + rm.suppress_next_pointer_release = false; + rm.outside_click_press_p = null; + continue; + } + if (rm.outside_click_press_p) |press_p| { + const moved = me.p.diff(press_p).length() > dismiss_move_threshold; + if (!moved and !rm.containsPhysical(me.p) and !rm.containsPhysical(press_p)) { + rm.close(); + } + rm.outside_click_press_p = null; + } + }, + else => {}, + } + } +} + +pub fn draw() !void { + var fw: dvui.FloatingWidget = undefined; + fw.init(@src(), .{}, .{ + .rect = .cast(dvui.windowRect()), + }); + defer fw.deinit(); + + const menu_color = dvui.themeGet().color(.content, .fill).lighten(4.0); + const center = fw.data().rectScale().pointFromPhysical(Globals.state.tools.radial_menu.center); + const tool_count: usize = std.meta.fields(Tools.Tool).len; + const radius: f32 = 50.0; + const width: f32 = radius * 2.0; + const height: f32 = radius * 2.0; + const step: f32 = (2.0 * std.math.pi) / @as(f32, @floatFromInt(tool_count)); + var angle: f32 = 180.0; + + var outer_anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{}); + const temp_radius: f32 = 3.0 * radius * (outer_anim.val orelse 1.0); + var outer_rect = dvui.Rect.fromPoint(center); + outer_rect.w = temp_radius; + outer_rect.h = temp_radius; + outer_rect.x -= outer_rect.w / 2.0; + outer_rect.y -= outer_rect.h / 2.0; + + var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .rect = outer_rect, + .expand = .none, + .background = true, + .corner_radius = dvui.Rect.all(100000), + .box_shadow = .{ + .color = .black, + .offset = .{ .x = -4.0, .y = 4.0 }, + .fade = 8.0, + .alpha = 0.35, + }, + .color_fill = menu_color.opacity(0.75), + .border = dvui.Rect.all(0.0), + }); + box.deinit(); + outer_anim.deinit(); + + const ui_atlas = Globals.state.host.uiAtlas(); + + for (0..tool_count) |i| { + var anim = dvui.animate(@src(), .{ .duration = 100_000 + 50_000 * @as(i32, @intCast(i)), .kind = .alpha, .easing = dvui.easing.linear }, .{ + .id_extra = i, + }); + defer anim.deinit(); + + if (anim.val) |val| { + angle += ((1 - val) * 100.0) * 0.015; + } + + var color = dvui.themeGet().color(.control, .fill_hover); + if (Globals.state.colors.file_tree_palette) |*palette| { + color = palette.getDVUIColor(i); + } + + const x: f32 = std.math.round(width / 2.0 + radius * std.math.cos(angle) - width / 2.0); + const y: f32 = std.math.round(height / 2.0 + radius * std.math.sin(angle) - height / 2.0); + const new_center = center.plus(.{ .x = x, .y = y }); + var rect = dvui.Rect.fromPoint(new_center); + rect.w = 40.0; + rect.h = 40.0; + rect.x -= rect.w / 2.0; + rect.y -= rect.h / 2.0; + + const tool = @as(Tools.Tool, @enumFromInt(i)); + var button: dvui.ButtonWidget = undefined; + button.init(@src(), .{}, .{ + .rect = rect, + .id_extra = i, + .corner_radius = dvui.Rect.all(1000.0), + .color_fill = if (tool == Globals.state.tools.current) dvui.themeGet().color(.content, .fill) else .transparent, + .box_shadow = if (tool == Globals.state.tools.current) .{ + .color = .black, + .offset = .{ .x = -2.5, .y = 2.5 }, + .fade = 4.0, + .alpha = 0.25, + .corner_radius = dvui.Rect.all(1000), + } else null, + .padding = .all(0), + .margin = .all(0), + }); + + Globals.state.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {}; + + const selection_sprite = switch (Globals.state.tools.selection_mode) { + .box => ui_atlas.sprites[pixelart.atlas.sprites.box_selection_default], + .pixel => ui_atlas.sprites[pixelart.atlas.sprites.pixel_selection_default], + .color => ui_atlas.sprites[pixelart.atlas.sprites.color_selection_default], + }; + + const sprite = switch (tool) { + .pointer => ui_atlas.sprites[pixelart.atlas.sprites.cursor_default], + .pencil => ui_atlas.sprites[pixelart.atlas.sprites.pencil_default], + .eraser => ui_atlas.sprites[pixelart.atlas.sprites.eraser_default], + .bucket => ui_atlas.sprites[pixelart.atlas.sprites.bucket_default], + .selection => selection_sprite, + }; + + const size: dvui.Size = dvui.imageSize(ui_atlas.source) catch .{ .w = 1, .h = 1 }; + const atlas_w = if (size.w > 0) size.w else 1; + const atlas_h = if (size.h > 0) size.h else 1; + const uv = dvui.Rect{ + .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_w, + .y = @as(f32, @floatFromInt(sprite.source[1])) / atlas_h, + .w = @as(f32, @floatFromInt(sprite.source[2])) / atlas_w, + .h = @as(f32, @floatFromInt(sprite.source[3])) / atlas_h, + }; + + button.processEvents(); + button.drawBackground(); + + var rs = button.data().contentRectScale(); + const sw = @as(f32, @floatFromInt(sprite.source[2])) * rs.s; + const sh = @as(f32, @floatFromInt(sprite.source[3])) * rs.s; + rs.r.x += (rs.r.w - sw) / 2.0; + rs.r.y += (rs.r.h - sh) / 2.0; + rs.r.w = sw; + rs.r.h = sh; + + dvui.renderImage(ui_atlas.source, rs, .{ + .uv = uv, + .fade = 0.0, + }) catch { + std.log.err("Failed to render image", .{}); + }; + angle += step; + + if (button.hovered()) { + Globals.state.tools.set(tool); + } + if (button.clicked()) { + Globals.state.tools.set(tool); + Globals.state.tools.radial_menu.close(); + } + + button.deinit(); + } + + var anim = dvui.animate(@src(), .{ .duration = 100_000, .kind = .alpha, .easing = dvui.easing.linear }, .{ + .id_extra = tool_count + 1, + }); + defer anim.deinit(); + + var rect = dvui.Rect.fromPoint(center); + rect.w = 40.0; + rect.h = 40.0; + rect.x -= rect.w / 2.0; + rect.y -= rect.h / 2.0; + + if (Globals.state.host.activeDoc()) |doc| { + if (Globals.state.docs.fileById(doc.id)) |file| { + if (dvui.buttonIcon(@src(), "Play", if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, .{}, .{}, .{ + .expand = .none, + .corner_radius = dvui.Rect.all(1000), + .box_shadow = .{ + .color = .black, + .offset = .{ .x = -2.5, .y = 2.5 }, + .fade = 4.0, + .alpha = 0.25, + .corner_radius = dvui.Rect.all(1000), + }, + .color_fill = dvui.themeGet().color(.control, .fill_hover), + .rect = rect, + })) { + file.editor.playing = !file.editor.playing; + if (Globals.state.tools.radial_menu.opened_by_press) { + Globals.state.tools.radial_menu.close(); + } + } + } + } +} diff --git a/src/plugins/workbench/src/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig index 2d58d3ab..cfac2907 100644 --- a/src/plugins/workbench/src/FileLoadJob.zig +++ b/src/plugins/workbench/src/FileLoadJob.zig @@ -16,6 +16,8 @@ const std = @import("std"); const fizzy = @import("../../../fizzy.zig"); +const pixelart = @import("pixelart"); +const Internal = pixelart.internal; const dvui = @import("dvui"); const perf = fizzy.perf; @@ -67,7 +69,7 @@ cancelled: std.atomic.Value(bool) = .init(false), done: std.atomic.Value(bool) = .init(false), /// Filled by worker iff load succeeds AND wasn't cancelled. Safe to read after `done.load(.acquire)`. -result: ?fizzy.Internal.File = null, +result: ?Internal.File = null, /// Filled by worker iff load failed. Safe to read after `done.load(.acquire)`. err: ?anyerror = null, @@ -117,7 +119,7 @@ pub fn workerMain(job: *FileLoadJob) void { // Route the actual load through the owning plugin (filled into a stack buffer the // shell owns; the plugin knows its concrete document type). Mirrors the inline-value // model below — no heap handoff. - var file: fizzy.Internal.File = undefined; + var file: Internal.File = undefined; const handled = job.owner.loadDocument(job.path, &file) catch |e| { job.err = e; job.phase.store(@intFromEnum(Phase.failed), .release); diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index 87f20b3a..0a489e42 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -3,6 +3,8 @@ const builtin = @import("builtin"); const dvui = @import("dvui"); const sdk = @import("sdk"); +const pixelart = @import("pixelart"); +const Internal = pixelart.internal; const fizzy = @import("../../../fizzy.zig"); const icons = @import("icons"); @@ -38,13 +40,13 @@ pub fn init(grouping: u64) Workspace { /// Release any plugin-owned per-pane canvas chrome. Called when a pane is removed /// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown. pub fn deinit(self: *Workspace) void { - fizzy.State.removeCanvasPane(fizzy.pixelart, fizzy.app.allocator, self.grouping); + pixelart.State.removeCanvasPane(fizzy.editor.pixelart_state, fizzy.app.allocator, self.grouping); } /// Recover the typed workspace currently drawing `file` from its opaque slot /// handle (`File.EditorData.workspace_handle`, set each frame in `drawCanvas`). /// Returns null before the file has been laid out this session. -pub fn ofFile(file: *fizzy.Internal.File) ?*Workspace { +pub fn ofFile(file: *Internal.File) ?*Workspace { const handle = file.editor.workspace_handle orelse return null; return @ptrCast(@alignCast(handle)); } @@ -200,7 +202,7 @@ fn drawTabs(self: *Workspace) void { for (0..files_len) |i| { const file = fizzy.editor.fileAt(i) orelse continue; - const is_fizzy_file = fizzy.Internal.File.isFizzyExtension(std.fs.path.extension(file.path)); + const is_fizzy_file = Internal.File.isFizzyExtension(std.fs.path.extension(file.path)); if (file.editor.grouping != self.grouping) continue; diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index cff326ac..d6dc5712 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -497,7 +497,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg try visible_file_rows_order.append(fizzy.app.allocator, .{ .id = inner_id_extra.*, .path = abs_path }); var color = dvui.themeGet().color(.control, .fill); - if (fizzy.pixelart.colors.palette) |*palette| { + if (fizzy.editor.pixelart_state.colors.palette) |*palette| { color = palette.getDVUIColor(color_id.*); } diff --git a/src/sdk/DocHandle.zig b/src/sdk/DocHandle.zig index bd1d980f..edbc2e35 100644 --- a/src/sdk/DocHandle.zig +++ b/src/sdk/DocHandle.zig @@ -1,7 +1,7 @@ //! An opaque handle to an open document. The shell stores these per tab/workspace //! and never inspects `ptr` — it only routes operations to `owner` (the plugin //! that opened the document and knows how to render/save/undo it). For pixel art -//! `ptr` is a `*fizzy.Internal.File`; a text plugin would point it at its own type. +//! `ptr` is a `*pixelart.internal.File`; a text plugin would point it at its own type. //! //! Phase 0: defined but not yet produced/consumed anywhere (see the modular-editor //! plan). Wired into the open/render/save path in Phase 3. diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index d4f5ea2f..06f0d526 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -57,6 +57,12 @@ pub const VTable = struct { // ---- shell contributions ---- contributeMenu: ?*const fn (state: *anyopaque) anyerror!void = null, contributeKeybinds: ?*const fn (state: *anyopaque, win: *dvui.Window) anyerror!void = null, + + // ---- per-frame shell hooks (global keybinds, overlays) ---- + tickKeybinds: ?*const fn (state: *anyopaque) anyerror!void = null, + processRadialMenuInput: ?*const fn (state: *anyopaque) void = null, + radialMenuVisible: ?*const fn (state: *anyopaque) bool = null, + drawRadialMenu: ?*const fn (state: *anyopaque) anyerror!void = null, }; // Thin wrappers so callers don't repeat the optional-vtable dance. @@ -69,6 +75,22 @@ pub fn contributeKeybinds(self: Plugin, win: *dvui.Window) !void { if (self.vtable.contributeKeybinds) |f| try f(self.state, win); } +pub fn tickKeybinds(self: Plugin) !void { + if (self.vtable.tickKeybinds) |f| try f(self.state); +} + +pub fn processRadialMenuInput(self: Plugin) void { + if (self.vtable.processRadialMenuInput) |f| f(self.state); +} + +pub fn radialMenuVisible(self: Plugin) bool { + return if (self.vtable.radialMenuVisible) |f| f(self.state) else false; +} + +pub fn drawRadialMenu(self: Plugin) !void { + if (self.vtable.drawRadialMenu) |f| try f(self.state); +} + // ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ---- /// Load `path` into the shell-owned buffer at `out_doc`. Returns whether the plugin diff --git a/src/web_main.zig b/src/web_main.zig index 6534dad9..e810a970 100644 --- a/src/web_main.zig +++ b/src/web_main.zig @@ -11,6 +11,8 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("fizzy.zig"); +const pixelart = @import("pixelart"); +const Internal = pixelart.internal; // Wasm-cleanliness probes. Referencing each symbol forces semantic analysis of its // module graph; any compile error pinpoints what to gate next. Zero-cost at runtime. @@ -26,25 +28,25 @@ comptime { _ = fizzy.atlas; // Algorithms — pure Zig + dvui - _ = fizzy.algorithms.brezenham; - _ = fizzy.algorithms.reduce; + _ = pixelart.algorithms.brezenham; + _ = pixelart.algorithms.reduce; // Top-level data types (.pixi format on-disk shapes) - _ = fizzy.Animation; - _ = fizzy.Atlas; - _ = fizzy.File; - _ = fizzy.Layer; - _ = fizzy.Sprite; + _ = pixelart.Animation; + _ = pixelart.Atlas; + _ = pixelart.File; + _ = pixelart.Layer; + _ = pixelart.Sprite; // Internal editor-side data types - _ = fizzy.Internal.Animation; - _ = fizzy.Internal.Atlas; - _ = fizzy.Internal.Buffers; - _ = fizzy.Internal.File.init; - _ = fizzy.Internal.History; - _ = fizzy.Internal.Layer; - _ = fizzy.Internal.Palette; - _ = fizzy.Internal.Sprite; + _ = Internal.Animation; + _ = Internal.Atlas; + _ = Internal.Buffers; + _ = Internal.File.init; + _ = Internal.History; + _ = Internal.Layer; + _ = Internal.Palette; + _ = Internal.Sprite; // Math + graphics helpers _ = fizzy.math.checker; @@ -53,11 +55,11 @@ comptime { _ = fizzy.image.init; _ = fizzy.image.pixels; _ = fizzy.perf.record; - _ = fizzy.render; + _ = pixelart.render; // Custom dvui wrapper + widgets — types compile even though the widget files // contain dead `@import("backend")` SDL3 imports at file scope. - _ = @import("pixelart").widgets.FileWidget; + _ = pixelart.widgets.FileWidget; _ = fizzy.dvui.CanvasWidget; // The big ones: Editor + App. Type-level reference only — passes because Zig diff --git a/tests/fizzy_shim.zig b/tests/fizzy_shim.zig index 13f7a0f6..6fbd6b6c 100644 --- a/tests/fizzy_shim.zig +++ b/tests/fizzy_shim.zig @@ -22,6 +22,8 @@ pub const Ctx = struct { editor: *fizzy.Editor, pub fn deinit(self: *Ctx, gpa: std.mem.Allocator) void { + self.editor.pixelart_state.deinit(gpa); + gpa.destroy(self.editor.pixelart_state); self.editor.arena.deinit(); gpa.destroy(self.editor); gpa.destroy(self.app); @@ -51,10 +53,18 @@ pub fn init(gpa: std.mem.Allocator) !Ctx { // top of that test rather than expanding the shim. const editor_ptr = try gpa.create(fizzy.Editor); @memset(@as([*]u8, @ptrCast(editor_ptr))[0..@sizeOf(fizzy.Editor)], 0); - editor_ptr.settings.checker_color_even = .{ 200, 200, 200, 255 }; - editor_ptr.settings.checker_color_odd = .{ 100, 100, 100, 255 }; editor_ptr.arena = std.heap.ArenaAllocator.init(gpa); + editor_ptr.host.allocator = gpa; fizzy.editor = editor_ptr; + const pixelart = fizzy.pixelart_mod; + const state_ptr = try gpa.create(pixelart.State); + pixelart.Globals.gpa = gpa; + pixelart.Globals.state = state_ptr; + state_ptr.* = pixelart.State.init(gpa, &editor_ptr.host) catch unreachable; + editor_ptr.pixelart_state = state_ptr; + state_ptr.settings.checker_color_even = .{ 200, 200, 200, 255 }; + state_ptr.settings.checker_color_odd = .{ 100, 100, 100, 255 }; + return .{ .t = t, .app = app_ptr, .editor = editor_ptr }; } diff --git a/tests/integration.zig b/tests/integration.zig index fcbc2361..17f0b086 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -12,8 +12,9 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("fizzy"); const shim = @import("fizzy_shim.zig"); +const pixelart = fizzy.pixelart_mod; -const Internal = fizzy.Internal; +const Internal = pixelart.internal; /// Create a small in-memory `Internal.File` suitable for tests. The /// caller must already have a live shim context (so `fizzy.app` / @@ -206,15 +207,15 @@ test "selectColorFloodFromPoint out-of-bounds is a no-op" { // ------------------------------------------------------------------- // `.pixi` JSON parser fallbacks. The on-disk format has been bumped -// three times. `fromPathFizzy` first tries the current `fizzy.File` +// three times. `fromPathFizzy` first tries the current `pixelart.File` // shape and, on failure, retries against `FileV3`, `FileV2`, and // `FileV1`. This test exercises just the JSON layer (no zip, no // `Internal.File` materialization) by parsing a small in-memory // fixture for each version. It catches the kind of bug where someone -// renames or retypes a field on the public `fizzy.File` types and +// renames or retypes a field on the public `pixelart.File` types and // silently breaks loading older saves. // ------------------------------------------------------------------- -test "fizzy.File parses current-format JSON and round-trips" { +test "pixelart.File parses current-format JSON and round-trips" { const json = \\{ \\ "version": { "major": 1, "minor": 0, "patch": 0, "pre": null, "build": null }, @@ -234,7 +235,7 @@ test "fizzy.File parses current-format JSON and round-trips" { ; const parsed = try std.json.parseFromSlice( - fizzy.File, + pixelart.File, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -258,7 +259,7 @@ test "fizzy.File parses current-format JSON and round-trips" { defer std.testing.allocator.free(round_tripped); const reparsed = try std.json.parseFromSlice( - fizzy.File, + pixelart.File, std.testing.allocator, round_tripped, .{ .ignore_unknown_fields = true }, @@ -275,7 +276,7 @@ test "fizzy.File parses current-format JSON and round-trips" { try std.testing.expectEqual(parsed.value.animations[0].frames[0].ms, reparsed.value.animations[0].frames[0].ms); } -test "fizzy.File.FileV3 fixture parses" { +test "pixelart.File.FileV3 fixture parses" { // V3 keeps the columns/rows shape but uses the older `AnimationV2` // (frame indices + fps) form. const json = @@ -295,7 +296,7 @@ test "fizzy.File.FileV3 fixture parses" { ; const parsed = try std.json.parseFromSlice( - fizzy.File.FileV3, + pixelart.File.FileV3, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -307,7 +308,7 @@ test "fizzy.File.FileV3 fixture parses" { try std.testing.expectEqual(@as(f32, 10.0), parsed.value.animations[0].fps); } -test "fizzy.File.FileV2 fixture parses (width/height + tile_size shape)" { +test "pixelart.File.FileV2 fixture parses (width/height + tile_size shape)" { const json = \\{ \\ "version": { "major": 0, "minor": 5, "patch": 0, "pre": null, "build": null }, @@ -325,7 +326,7 @@ test "fizzy.File.FileV2 fixture parses (width/height + tile_size shape)" { ; const parsed = try std.json.parseFromSlice( - fizzy.File.FileV2, + pixelart.File.FileV2, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -336,7 +337,7 @@ test "fizzy.File.FileV2 fixture parses (width/height + tile_size shape)" { try std.testing.expectEqual(@as(u32, 8), parsed.value.tile_width); } -test "fizzy.File.FileV1 fixture parses (start/length animation shape)" { +test "pixelart.File.FileV1 fixture parses (start/length animation shape)" { const json = \\{ \\ "version": { "major": 0, "minor": 1, "patch": 0, "pre": null, "build": null }, @@ -354,7 +355,7 @@ test "fizzy.File.FileV1 fixture parses (start/length animation shape)" { ; const parsed = try std.json.parseFromSlice( - fizzy.File.FileV1, + pixelart.File.FileV1, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -469,7 +470,7 @@ test "Packer.append reduces painted sprite and offsets origin to keep anchor ali px[3 * 16 + 3] = .{ 255, 0, 0, 255 }; // Cell 1: leave fully transparent so the packer skips the bitmap (image == null). - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixelart.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -517,7 +518,7 @@ test "Packer.append: tighten preserves world-space anchor across cells" { } } - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixelart.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -552,7 +553,7 @@ test "Packer.append: tightened bitmap content matches the source pixels" { px[5 * 8 + 3] = .{ 21, 22, 23, 255 }; px[5 * 8 + 4] = .{ 31, 32, 33, 255 }; - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixelart.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -590,7 +591,7 @@ test "Packer.append skips invisible layers" { .dirty = layer.dirty, }); - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixelart.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -633,7 +634,7 @@ test "Packer.packRects: produced rects fit inside the texture and never overlap" } } - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixelart.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -811,7 +812,7 @@ test "fillPoint on temporary layer leaves selected-layer mask cache alone" { test "Internal.Animation appendFrame, insertFrame, removeFrame" { const alloc = std.testing.allocator; - var initial_frames = [_]fizzy.Animation.Frame{.{ + var initial_frames = [_]pixelart.Animation.Frame{.{ .sprite_index = 0, .ms = 100, }}; @@ -819,14 +820,14 @@ test "Internal.Animation appendFrame, insertFrame, removeFrame" { defer anim.deinit(alloc); try anim.appendFrame(alloc, .{ .sprite_index = 1, .ms = 50 }); - var expect_two = [_]fizzy.Animation.Frame{ + var expect_two = [_]pixelart.Animation.Frame{ .{ .sprite_index = 0, .ms = 100 }, .{ .sprite_index = 1, .ms = 50 }, }; try std.testing.expect(anim.eqlFrames(expect_two[0..])); try anim.insertFrame(alloc, 1, .{ .sprite_index = 9, .ms = 12 }); - var expect_three = [_]fizzy.Animation.Frame{ + var expect_three = [_]pixelart.Animation.Frame{ .{ .sprite_index = 0, .ms = 100 }, .{ .sprite_index = 9, .ms = 12 }, .{ .sprite_index = 1, .ms = 50 }, @@ -834,7 +835,7 @@ test "Internal.Animation appendFrame, insertFrame, removeFrame" { try std.testing.expect(anim.eqlFrames(expect_three[0..])); anim.removeFrame(alloc, 0); - var expect_after_remove = [_]fizzy.Animation.Frame{ + var expect_after_remove = [_]pixelart.Animation.Frame{ .{ .sprite_index = 9, .ms = 12 }, .{ .sprite_index = 1, .ms = 50 }, }; @@ -983,7 +984,7 @@ test "Packer.append merges collapsed layer stack before reducing sprites" { file.layers.get(0).pixels()[0] = .{ 255, 0, 0, 255 }; file.layers.get(1).pixels()[7 * 8 + 7] = .{ 0, 255, 0, 255 }; - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixelart.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -1006,10 +1007,10 @@ test "drawPoint with to_change records history; undo restores pixels" { file.editor.canvas.id = .zero; - // `drawPoint` reads `fizzy.editor.tools.stroke_size` for stamps smaller than `min_full_stroke_size`; + // `drawPoint` reads plugin tools stroke size for stamps smaller than `min_full_stroke_size`; // the shim zero-fills the editor, so brush size must be set explicitly. - fizzy.editor.tools.stroke_size = 1; - fizzy.editor.tools.pencil_stroke_size = 1; + fizzy.editor.pixelart_state.tools.stroke_size = 1; + fizzy.editor.pixelart_state.tools.pencil_stroke_size = 1; const idx: usize = 3 * 8 + 4; From 0656c54af2a5ee35c8b69a7cae6c83d6cf8e7693 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 17:20:35 -0500 Subject: [PATCH 23/49] Phase 4 stage e ctnd --- HANDOFF.md | 8 +- src/App.zig | 16 +- src/editor/Editor.zig | 652 ++------------------- src/editor/dialogs/UnsavedClose.zig | 4 +- src/fizzy.zig | 5 +- src/plugins/pixelart/src/State.zig | 7 +- src/plugins/pixelart/src/clipboard.zig | 220 +++++++ src/plugins/pixelart/src/docs_registry.zig | 32 + src/plugins/pixelart/src/pack_project.zig | 236 ++++++++ src/plugins/pixelart/src/plugin.zig | 86 ++- src/plugins/pixelart/src/transform_op.zig | 123 ++++ src/plugins/workbench/src/Workspace.zig | 2 +- src/plugins/workbench/src/files.zig | 3 +- src/sdk/Plugin.zig | 74 +++ tests/integration.zig | 4 +- 15 files changed, 847 insertions(+), 625 deletions(-) create mode 100644 src/plugins/pixelart/src/clipboard.zig create mode 100644 src/plugins/pixelart/src/docs_registry.zig create mode 100644 src/plugins/pixelart/src/pack_project.zig create mode 100644 src/plugins/pixelart/src/transform_op.zig diff --git a/HANDOFF.md b/HANDOFF.md index 789551ff..614401de 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -262,11 +262,13 @@ app code until the build module is fully wired. - **Explorer, UnsavedClose, files, Workspace** — use `fizzy.editor.pixelart_state` or `@import("pixelart")`. - **`fizzy.zig` hub trimmed** — removed re-export aliases (`Tools`, `Internal`, `render`, `Packer`, on-disk types, …). Shell/workbench/tests/web probes now `@import("pixelart")` (or `fizzy.pixelart_mod` in integration tests). `fizzy.zig` keeps only `pixelart_mod` alias + lifecycle globals (`app`, `editor`, `packer`, `pixelart`). - **`App.zig`** — wires `pixelart.Globals` directly (not `fizzy.pixelart_mod.Globals`). +- **Copy/paste + pack/project** — moved to `pixelart/src/clipboard.zig` and `pack_project.zig`; plugin vtable hooks (`copy`, `paste`, `startPackProject`, `isPackingActive`, `tickPackJobs`, `runPackWorkers`). Shell `Editor` delegates; `setProjectFolder` uses plugin `persistProjectFolder` / `reloadProjectFolder`. +- **Transform + doc registry** — `transform_op.zig` + `docs_registry.zig`; vtable hooks (`transform`, `registerOpenDocument`, `documentPtr`, `documentByPath`, `unregisterDocument`). Shell `fileFromDoc` / `insertOpenDoc` / `fileById` route through `doc.owner`; no direct `pixelart_state.docs` access in `Editor.zig`. +- **`fizzy.pixelart` global removed** — single ownership on `Editor.pixelart_state` + `Globals.state`; `App.zig` alloc/deinit via `fizzy.editor.pixelart_state` only. **Still remaining:** -- `fizzy.pixelart` global — fold into `Editor.pixelart_state` + `Globals` only. -- Shell `Editor` copy/paste/pack/project still touch `editor.pixelart_state` fields directly — route through plugin vtable or EditorAPI. -- `pixelart.internal.File` in workbench + shell helpers — shrink as doc ownership solidifies. +- Shell `Editor` still types `*Internal.File` in helpers (`activeFile`, `fileFromDoc`) — shrink as multi-plugin doc types arrive. +- `pixelart.internal.File` in workbench tab paths — type-agnostic `DocHandle` only at boundary. - Integration test shim updated for `pixelart.State` settings; `check-integration` still blocked on native `backend_native` SDL import under dvui-testing (pre-existing). --- diff --git a/src/App.zig b/src/App.zig index ac32b7b2..ef0076a2 100644 --- a/src/App.zig +++ b/src/App.zig @@ -168,12 +168,12 @@ pub fn AppInit(win: *dvui.Window) !void { // Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created // before `postInit` so the pixel-art plugin's `register` can adopt it as its - // `state`. Owned here for the app's lifetime; torn down in `AppDeinit`. - fizzy.pixelart = try allocator.create(pixelart.State); + // `state`. Owned on `Editor`; torn down in `AppDeinit`. + const pixelart_state = try allocator.create(pixelart.State); pixelart.Globals.gpa = allocator; - pixelart.Globals.state = fizzy.pixelart; - fizzy.pixelart.* = pixelart.State.init(allocator, &fizzy.editor.host) catch unreachable; - fizzy.editor.pixelart_state = fizzy.pixelart; + pixelart.Globals.state = pixelart_state; + pixelart_state.* = pixelart.State.init(allocator, &fizzy.editor.host) catch unreachable; + fizzy.editor.pixelart_state = pixelart_state; // Second-stage init that needs the editor at its final heap address (e.g. // registering the workbench-api service whose `ctx` is this pointer). @@ -233,12 +233,12 @@ pub fn AppDeinit() void { // Persist the current windowed frame while the window still exists. No-op off macOS. fizzy.backend.saveWindowGeometry(fizzy.app.window); // Persist `.fizproject` while `editor.host` and `editor.folder` are still live. - pixelart.State.persistProject(fizzy.pixelart); + pixelart.State.persistProject(fizzy.editor.pixelart_state); fizzy.editor.deinit() catch unreachable; // Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs). // After the editor so any editor teardown that still reads pixel-art state runs first. - fizzy.pixelart.deinit(fizzy.app.allocator); - fizzy.app.allocator.destroy(fizzy.pixelart); + fizzy.editor.pixelart_state.deinit(fizzy.app.allocator); + fizzy.app.allocator.destroy(fizzy.editor.pixelart_state); // Tear down the singleton listener after the editor so any callback // currently in flight finishes before we free state it touches. singleton.deinit(); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 00913396..121e6095 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -22,7 +22,6 @@ const update_notify = @import("../backend/update_notify.zig"); const App = fizzy.App; const Editor = @This(); -const Project = pixelart.Project; pub const Recents = @import("Recents.zig"); pub const Settings = @import("Settings.zig"); pub const Dialogs = @import("dialogs/Dialogs.zig"); @@ -37,7 +36,6 @@ pub const Sidebar = @import("Sidebar.zig"); pub const Infobar = @import("Infobar.zig"); pub const Menu = @import("Menu.zig"); pub const FileLoadJob = @import("../plugins/workbench/src/FileLoadJob.zig"); -const PackJob = pixelart.PackJob; pub const sdk = fizzy.sdk; pub const Host = sdk.Host; @@ -58,7 +56,7 @@ atlas: fizzy.core.Atlas, /// Plugin registry + service locator exposed to plugins host: Host, -/// Pixel-art plugin runtime state (owned by App; shell reaches it here instead of `fizzy.pixelart`). +/// Pixel-art plugin runtime state (owned by App; wired into `Globals.state`). pixelart_state: *pixelart.State, /// File-management workbench (per-branch explorer decorations, …) @@ -442,8 +440,8 @@ pub fn init( return err; }; - // Pixel-art tools/colors/palettes now init in `State.init` (App owns the - // `fizzy.pixelart` instance, created just after this `Editor.init` returns). + // Pixel-art tools/colors/palettes now init in `State.init` (App allocates + // `editor.pixelart_state` just after this `Editor.init` returns). try Keybinds.register(); @@ -750,9 +748,17 @@ fn shellIsPackingActive(ctx: *anyopaque) bool { } /// Resolve a shell `DocHandle` to the plugin-owned file. Uses `doc.id`, not `doc.ptr`: -/// `docs.files` may reallocate and invalidate pointers stored at insert time. +/// the plugin registry may reallocate and invalidate pointers stored at insert time. pub fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File { - return editor.pixelart_state.docs.fileById(doc.id).?; + _ = editor; + return @ptrCast(@alignCast(doc.owner.documentPtr(doc.id).?)); +} + +/// Resolve an open document id to the plugin-owned file, or null when not open. +pub fn fileById(editor: *Editor, id: u64) ?*Internal.File { + const doc = editor.docById(id) orelse return null; + const ptr = doc.owner.documentPtr(doc.id) orelse return null; + return @ptrCast(@alignCast(ptr)); } pub fn docAt(editor: *Editor, index: usize) ?sdk.DocHandle { @@ -773,8 +779,8 @@ pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { /// Store a loaded/created document in the plugin registry and register its handle. pub fn insertOpenDoc(editor: *Editor, file: Internal.File, owner: *sdk.Plugin) !void { - try editor.pixelart_state.docs.files.put(fizzy.app.allocator, file.id, file); - const ptr = editor.pixelart_state.docs.files.getPtr(file.id).?; + var file_mut = file; + const ptr = try owner.registerOpenDocument(&file_mut); try editor.open_files.put(fizzy.app.allocator, file.id, .{ // `ptr` is a hint only; consumers must resolve via `fileFromDoc` / `doc.id`. .ptr = ptr, @@ -1541,7 +1547,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { }; if (comptime builtin.target.cpu.arch == .wasm32) { - runWasmPackWorkers(editor); + pixelart.plugin.pluginPtr().runPackWorkers(); } _ = editor.arena.reset(.retain_capacity); @@ -1858,7 +1864,7 @@ fn tickPendingSaveCloses(editor: *Editor) void { var i: usize = 0; while (i < editor.pending_close_after_save.count()) { const id = editor.pending_close_after_save.keys()[i]; - if (editor.pixelart_state.docs.fileById(id)) |f| { + if (editor.fileById(id)) |f| { if (f.isSaving()) { i += 1; continue; @@ -1888,7 +1894,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { // Pass 1: kick off any queued saves we haven't started yet. while (editor.quit_save_all_ids.items.len > 0) { const id = editor.quit_save_all_ids.items[0]; - const file_ptr = editor.pixelart_state.docs.fileById(id) orelse { + const file_ptr = editor.fileById(id) orelse { _ = editor.quit_save_all_ids.swapRemove(0); continue; }; @@ -1937,7 +1943,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { var i: usize = 0; while (i < editor.quit_saves_in_flight.count()) { const id = editor.quit_saves_in_flight.keys()[i]; - if (editor.pixelart_state.docs.fileById(id)) |f| { + if (editor.fileById(id)) |f| { if (f.isSaving()) { i += 1; continue; @@ -1981,18 +1987,14 @@ pub fn close(app: *App, editor: *Editor) void { pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { if (editor.folder) |folder| { editor.ignore.deinit(fizzy.app.allocator); - if (editor.pixelart_state.project) |*project| { - project.save() catch { - dvui.log.err("Failed to save project", .{}); - }; - } + pixelart.plugin.pluginPtr().persistProjectFolder(); fizzy.app.allocator.free(folder); } editor.folder = try fizzy.app.allocator.dupe(u8, path); try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); editor.host.setActiveSidebarView(@import("../plugins/workbench/src/plugin.zig").view_files); - editor.pixelart_state.project = Project.load(fizzy.app.allocator) catch null; + pixelart.plugin.pluginPtr().reloadProjectFolder(fizzy.app.allocator); editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); } @@ -2190,264 +2192,24 @@ pub fn processLoadingJobs(editor: *Editor) void { } } -/// Kick off an async project-pack. Walks the project directory once on the main thread to -/// gather inputs: open files contribute a thread-isolated snapshot (so unsaved edits make it -/// into the pack); unopened files just contribute their paths and the worker reads them. Once -/// inputs are gathered the heavy work — pixel reduction, rect packing, atlas blit — runs on a -/// worker thread. -/// -/// Rapid re-triggers (e.g. save-all-then-repack, or rapid button clicks) coalesce: any -/// in-flight jobs are cancelled before the new one spawns. The cancelled workers continue -/// running long enough to observe the flag and exit cleanly; their results are discarded by -/// `processPackJob`. Only the most recently-started job's result is installed. +/// Kick off an async project-pack via the pixel-art plugin vtable. pub fn startPackProject(editor: *Editor) !void { - var inputs: std.ArrayListUnmanaged(PackJob.PackInput) = .empty; - errdefer { - for (inputs.items) |*input| input.deinit(fizzy.app.allocator); - inputs.deinit(fizzy.app.allocator); - } - - if (comptime builtin.target.cpu.arch == .wasm32) { - // Web: no project folder to walk — pack every open document (fiz, pixi, png, - // jpg, in-memory untitled, etc.). Saved-path tracking is not available in the - // browser, so the open tab set is the only source of truth. - try appendOpenPackInputs(editor, &inputs); - } else { - const root = editor.folder orelse return; - // Snapshot open files first so unsaved edits are included and gather can skip - // duplicates when it walks the project tree. - try appendOpenPackInputs(editor, &inputs); - try gatherPackInputs(editor, &inputs, root); - } - - if (inputs.items.len == 0) { - const msg = if (comptime builtin.target.cpu.arch == .wasm32) - "No open files to pack" - else - "No .fiz or .pixi files to pack"; - showPackToast(msg, null); - return; - } - - // `owned_inputs` is nulled out once ownership transfers into the job, so the errdefer - // below is a no-op on the success path and avoids the double-free of letting both this - // and `job.destroy()` reclaim the same allocations. - var owned_inputs: ?[]PackJob.PackInput = try inputs.toOwnedSlice(fizzy.app.allocator); - errdefer if (owned_inputs) |o| { - for (o) |*input| input.deinit(fizzy.app.allocator); - fizzy.app.allocator.free(o); - }; - - // Cancel every predecessor BEFORE appending the new job. This avoids a race where a - // predecessor publishes `done` between append and cancel: `processPackJob` walks the list - // newest-first and would otherwise see an old non-cancelled ready job and install its - // (stale) atlas. Cancelled predecessors are skipped during install selection. - for (editor.pixelart_state.pack_jobs.items) |old| { - old.cancelled.store(true, .monotonic); - } - - const job = try PackJob.create(fizzy.app.allocator, owned_inputs.?); - owned_inputs = null; - errdefer job.destroy(); - - try editor.pixelart_state.pack_jobs.append(fizzy.app.allocator, job); - errdefer _ = editor.pixelart_state.pack_jobs.pop(); - - if (comptime builtin.target.cpu.arch == .wasm32) { - // Worker runs at end of `tick` (after the explorer draws) so the Pack - // button can show a spinner for at least one frame before work starts. - dvui.refresh(dvui.currentWindow(), @src(), null); - } else { - const thread = try std.Thread.spawn(.{}, PackJob.workerMain, .{job}); - thread.detach(); - } + _ = editor; + try pixelart.plugin.pluginPtr().startPackProject(); } -/// True while a pack is queued, running, or finished but not yet installed into -/// `fizzy.packer.atlas`. Drives the explorer Pack button spinner. +/// True while a pack is queued, running, or finished but not yet installed. pub fn isPackingActive(editor: *const Editor) bool { - for (editor.pixelart_state.pack_jobs.items) |job| { - if (job.cancelled.load(.monotonic)) continue; - if (!job.done.load(.acquire)) return true; - if (!job.result_consumed) return true; - } - return false; -} - -/// Run queued wasm pack workers after UI has drawn so `isPackingActive` can show feedback. -fn runWasmPackWorkers(editor: *Editor) void { - for (editor.pixelart_state.pack_jobs.items) |job| { - if (job.cancelled.load(.monotonic)) continue; - if (job.done.load(.acquire)) continue; - PackJob.workerMain(job); - return; - } -} - -fn appendOpenPackInputs(editor: *Editor, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void { - for (editor.open_files.values()) |doc| { - const open_file = editor.fileFromDoc(doc); - const snapshot = try PackJob.PackFile.fromOpenFile(fizzy.app.allocator, open_file); - try inputs.append(fizzy.app.allocator, .{ .open = snapshot }); - } -} - -fn gatherPackInputs( - editor: *Editor, - inputs: *std.ArrayListUnmanaged(PackJob.PackInput), - directory: []const u8, -) !void { - const io = dvui.io; - var dir = try std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true }); - defer dir.close(io); - - var iter = dir.iterate(); - while (try iter.next(io)) |entry| { - if (entry.kind == .file) { - const ext = std.fs.path.extension(entry.name); - if (!Internal.File.isFizzyExtension(ext)) continue; - - const abs_path = try std.fs.path.join(fizzy.app.allocator, &.{ directory, entry.name }); - defer fizzy.app.allocator.free(abs_path); - - // Open files were snapshotted in `appendOpenPackInputs` (including unsaved edits). - if (findOpenFileForPackPath(editor, abs_path) != null) continue; - - const owned_path = try fizzy.app.allocator.dupe(u8, abs_path); - try inputs.append(fizzy.app.allocator, .{ .path = owned_path }); - } else if (entry.kind == .directory) { - const abs_path = try std.fs.path.join(fizzy.app.allocator, &.{ directory, entry.name }); - defer fizzy.app.allocator.free(abs_path); - try gatherPackInputs(editor, inputs, abs_path); - } - } -} - -/// Match a project-tree path to an open file (`file.path` may differ in normalization from `join` vs `joinZ`). -fn findOpenFileForPackPath(editor: *Editor, path: []const u8) ?*Internal.File { - if (editor.getFileFromPath(path)) |file| return file; - - const basename = std.fs.path.basename(path); - for (editor.open_files.values()) |doc| { - const file = editor.fileFromDoc(doc); - if (!std.mem.eql(u8, std.fs.path.basename(file.path), basename)) continue; - if (std.mem.eql(u8, file.path, path)) return file; - if (editor.folder) |folder| { - const joined = std.fs.path.join(fizzy.app.allocator, &.{ folder, basename }) catch continue; - defer fizzy.app.allocator.free(joined); - if (std.mem.eql(u8, file.path, joined)) return file; - } - } - return null; + _ = editor; + return pixelart.plugin.pluginPtr().isPackingActive(); } -fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void { - const anchor = canvas_id orelse blk: { - if (fizzy.editor.activeWorkspaceCanvasRectPhysical()) |r| { - if (fizzy.editor.activeFile()) |file| break :blk file.editor.canvas.id; - _ = r; - } - break :blk dvui.currentWindow().data().id; - }; - const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, anchor, fizzy.dvui.toastDisplay, 2_500_000); - const id = id_mutex.id; - const msg_copy = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{message}) catch message; - dvui.dataSetSlice(dvui.currentWindow(), id, "_message", msg_copy); - id_mutex.mutex.unlock(dvui.io); -} - -/// Per-frame sweep called from `tick`. Reaps any pack jobs whose worker has published `done`, -/// installs the result of the newest non-cancelled job (and only that one), and discards the -/// rest. Older or cancelled jobs' results — even successful ones — are freed without affecting -/// `fizzy.packer.atlas` so coalesced re-triggers can't briefly flicker stale atlases. +/// Per-frame pack-job sweep (delegates to the pixel-art plugin). pub fn processPackJob(editor: *Editor) void { - if (editor.pixelart_state.pack_jobs.items.len == 0) return; - - // Identify the newest (last appended) job that finished with a `.ready` result and was - // not cancelled. Only its result is installed; older successful results are stale and - // get discarded along with cancelled / failed ones. - var install_index: ?usize = null; - { - var i = editor.pixelart_state.pack_jobs.items.len; - while (i > 0) { - i -= 1; - const job = editor.pixelart_state.pack_jobs.items[i]; - if (!job.done.load(.acquire)) continue; - if (job.cancelled.load(.monotonic)) continue; - if (job.currentPhase() == .ready and job.result_atlas != null) { - install_index = i; - break; - } - } - } - - if (install_index) |idx| { - const job = editor.pixelart_state.pack_jobs.items[idx]; - const new_atlas = job.result_atlas.?; - // Free the previously-installed atlas's allocations so the new one can take its - // place — matches the synchronous `packAndClear` cleanup ordering. - if (fizzy.packer.atlas) |*current_atlas| { - current_atlas.deinitCheckerboardTile(); - for (current_atlas.data.animations) |*anim| fizzy.app.allocator.free(anim.name); - fizzy.app.allocator.free(current_atlas.data.sprites); - fizzy.app.allocator.free(current_atlas.data.animations); - fizzy.app.allocator.free(fizzy.image.bytes(current_atlas.source)); - - current_atlas.source = new_atlas.source; - current_atlas.data = new_atlas.data; - current_atlas.initCheckerboardTile(); - } else { - fizzy.packer.atlas = new_atlas; - fizzy.packer.atlas.?.initCheckerboardTile(); - } - fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp(); - job.result_consumed = true; - editor.host.setActiveSidebarView(pixelart.plugin.view_project); - const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null; - showPackToast("Project packed", toast_canvas); - } else blk: { - // Newest finished job had no atlas (empty inputs / no packable frames). Tell the user - // so the Pack button doesn't look like it silently did nothing. - var i = editor.pixelart_state.pack_jobs.items.len; - while (i > 0) { - i -= 1; - const job = editor.pixelart_state.pack_jobs.items[i]; - if (!job.done.load(.acquire)) continue; - if (job.cancelled.load(.monotonic)) continue; - if (job.currentPhase() == .ready and job.result_atlas == null) { - showPackToast("Nothing to pack in the selected files", null); - break :blk; - } - } - } - - // Reap everything that has published `done`. Successful-but-superseded jobs leave their - // `result_atlas` un-consumed; `destroy()` frees those allocations for us. - var write: usize = 0; - for (editor.pixelart_state.pack_jobs.items) |job| { - if (!job.done.load(.acquire)) { - editor.pixelart_state.pack_jobs.items[write] = job; - write += 1; - continue; - } - const phase = job.currentPhase(); - switch (phase) { - .ready, .cancelled => {}, - .failed => { - dvui.log.err("Pack project failed: {any}", .{job.err}); - showPackToast("Pack failed", null); - }, - else => dvui.log.err("Pack job finished in unexpected phase {s}", .{@tagName(phase)}), - } - job.destroy(); - } - editor.pixelart_state.pack_jobs.shrinkRetainingCapacity(write); + _ = editor; + pixelart.plugin.pluginPtr().tickPackJobs(); } -/// Returns the active workspace's canvas content rect (physical pixels) captured from the -/// previous frame's draw, if available. Falls back to `null` before the first workspace draw. -/// Used by `drawLoadingOverlay` / `drawSaveToasts` to center their cards over the canvas area -/// the user is currently looking at, instead of the raw OS window rect. pub fn activeWorkspaceCanvasRectPhysical(editor: *Editor) ?dvui.Rect.Physical { const workspace = editor.workspaces.getPtr(editor.open_workspace_grouping) orelse return null; return workspace.canvas_rect_physical; @@ -2652,7 +2414,7 @@ pub fn newFile(editor: *Editor, path: []const u8, options: Internal.File.InitOpt editor.setActiveFile(editor.open_files.count() - 1); editor.pending_composite_warmup = true; - return editor.pixelart_state.docs.fileById(file.id) orelse return error.FailedToCreateFile; + return editor.fileById(file.id) orelse return error.FailedToCreateFile; } /// Heap-owned path like `untitled-1`, unique among open-document basenames. @@ -2741,7 +2503,12 @@ pub fn fileAt(editor: *Editor, index: usize) ?*Internal.File { } pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*Internal.File { - return editor.pixelart_state.docs.fileFromPath(path); + for (editor.open_files.values()) |doc| { + if (doc.owner.documentByPath(path)) |ptr| { + return @ptrCast(@alignCast(ptr)); + } + } + return null; } pub fn forceCloseFile(editor: *Editor, index: usize) !void { @@ -2775,216 +2542,13 @@ pub fn cancel(editor: *Editor) !void { } pub fn copy(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (file.editor.transform != null) return; - - if (editor.pixelart_state.sprite_clipboard) |*clipboard| { - fizzy.app.allocator.free(fizzy.image.bytes(clipboard.source)); - editor.pixelart_state.sprite_clipboard = null; - } - - file.editor.transform_layer.clear(); - - var selected_layer = file.layers.get(file.selected_layer_index); - switch (editor.pixelart_state.tools.current) { - .selection => { - // We are in the selection tool, so we should assume that the user has painted a selection - // into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing - var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); - while (pixel_iterator.next()) |pixel_index| { - @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]); - file.editor.transform_layer.mask.set(pixel_index); - } - }, - else => { - if (file.editor.selected_sprites.count() > 0) { - var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (sprite_iterator.next()) |index| { - const source_rect = file.spriteRect(index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - source_rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - source_rect, - .{ .transparent = true, .mask = true }, - ); - } - } - } else { - if (file.editor.canvas.hovered) { - if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { - const rect = file.spriteRect(sprite_index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - rect, - .{ .transparent = true, .mask = true }, - ); - } - } - } else if (file.selected_animation_index) |animation_index| { - const animation = file.animations.get(animation_index); - if (file.selected_animation_frame_index < animation.frames.len) { - const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - rect, - .{ .transparent = true, .mask = true }, - ); - } - } - } - } - }, - } - - const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size()); - if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { - const sprite_tl = file.spritePoint(reduced_data_rect.topLeft()); - - editor.pixelart_state.sprite_clipboard = .{ - .source = fizzy.image.fromPixelsPMA( - @ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)), - @intFromFloat(reduced_data_rect.w), - @intFromFloat(reduced_data_rect.h), - .ptr, - ) catch return error.MemoryAllocationFailed, - .offset = reduced_data_rect.topLeft().diff(sprite_tl), - }; - - // Show a toast so its evident a copy action was completed - { - const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, file.editor.canvas.id, fizzy.dvui.toastDisplay, 2_000_000); - const id = id_mutex.id; - const message = std.fmt.allocPrint(dvui.currentWindow().arena(), "Copied selection", .{}) catch "Copied selection."; - dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message); - id_mutex.mutex.unlock(dvui.io); - } - } - } + _ = editor; + try pixelart.plugin.pluginPtr().copy(); } pub fn paste(editor: *Editor) !void { - if (editor.pixelart_state.sprite_clipboard) |*clipboard| { - if (editor.activeFile()) |file| { - const active_layer = file.layers.get(file.selected_layer_index); - - var dst_rect: dvui.Rect = .fromSize(fizzy.image.size(clipboard.source)); - - var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (sprite_iterator.next()) |sprite_index| { - const sprite_rect = file.spriteRect(sprite_index); - - dst_rect.x = sprite_rect.x + clipboard.offset.x; - dst_rect.y = sprite_rect.y + clipboard.offset.y; - - file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { - dvui.log.err("Failed to create target texture", .{}); - return; - }, - .file_id = file.id, - .layer_id = active_layer.id, - .data_points = .{ - dst_rect.topLeft(), - dst_rect.topRight(), - dst_rect.bottomRight(), - dst_rect.bottomLeft(), - dst_rect.center(), - dst_rect.center(), - }, - .source = clipboard.source, - }; - - for (file.editor.transform.?.data_points[0..4]) |*point| { - const d = point.diff(file.editor.transform.?.point(.pivot).*); - if (d.length() > file.editor.transform.?.radius) { - file.editor.transform.?.radius = d.length() + 4; - } - } - - return; - } - - dst_rect.x = clipboard.offset.x; - dst_rect.y = clipboard.offset.y; - - if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { - const rect = file.spriteRect(sprite_index); - dst_rect.x = rect.x + clipboard.offset.x; - dst_rect.y = rect.y + clipboard.offset.y; - } else if (file.selected_animation_index) |animation_index| { - const animation = file.animations.get(animation_index); - - if (file.selected_animation_frame_index < animation.frames.len) { - const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); - dst_rect.x = rect.x + clipboard.offset.x; - dst_rect.y = rect.y + clipboard.offset.y; - - file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { - dvui.log.err("Failed to create target texture", .{}); - return; - }, - .file_id = file.id, - .layer_id = active_layer.id, - .data_points = .{ - dst_rect.topLeft(), - dst_rect.topRight(), - dst_rect.bottomRight(), - dst_rect.bottomLeft(), - dst_rect.center(), - dst_rect.center(), - }, - .source = clipboard.source, - }; - - for (file.editor.transform.?.data_points[0..4]) |*point| { - const d = point.diff(file.editor.transform.?.point(.pivot).*); - if (d.length() > file.editor.transform.?.radius) { - file.editor.transform.?.radius = d.length() + 4; - } - } - - return; - } - } - - file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { - dvui.log.err("Failed to create target texture", .{}); - return; - }, - .file_id = file.id, - .layer_id = active_layer.id, - .data_points = .{ - dst_rect.topLeft(), - dst_rect.topRight(), - dst_rect.bottomRight(), - dst_rect.bottomLeft(), - dst_rect.center(), - dst_rect.center(), - }, - .source = clipboard.source, - }; - - for (file.editor.transform.?.data_points[0..4]) |*point| { - const d = point.diff(file.editor.transform.?.point(.pivot).*); - if (d.length() > file.editor.transform.?.radius) { - file.editor.transform.?.radius = d.length() + 4; - } - } - } - } + _ = editor; + try pixelart.plugin.pluginPtr().paste(); } pub fn deleteSelectedContents(editor: *Editor) void { @@ -2995,122 +2559,8 @@ pub fn deleteSelectedContents(editor: *Editor) void { /// Begins a transform operation on the currently active file. pub fn transform(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (file.editor.transform) |*t| { - t.cancel(); - } - - var selected_layer = file.layers.get(file.selected_layer_index); - - switch (editor.pixelart_state.tools.current) { - .selection => { - file.editor.transform_layer.clear(); - // We are in the selection tool, so we should assume that the user has painted a selection - // into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing - var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); - while (pixel_iterator.next()) |pixel_index| { - @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]); - selected_layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 }; - file.editor.transform_layer.mask.set(pixel_index); - } - selected_layer.invalidate(); - }, - else => { - // Current tool is the pointer, so we potentially have a sprite selection in - // selected sprites that we need to copy to the selection layer. - file.editor.transform_layer.clear(); - - if (file.editor.selected_sprites.count() > 0) { - var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - - while (sprite_iterator.next()) |index| { - const source_rect = file.spriteRect(index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - source_rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - source_rect, - .{ .transparent = true, .mask = true }, - ); - selected_layer.clearRect(source_rect); - } - } - } else { - if (file.editor.canvas.hovered) { - if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { - const rect = file.spriteRect(sprite_index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - rect, - .{ .transparent = true, .mask = true }, - ); - selected_layer.clearRect(rect); - } - } - } else if (file.selected_animation_index) |animation_index| { - const animation = file.animations.get(animation_index); - if (file.selected_animation_frame_index < animation.frames.len) { - const source_rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - source_rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - source_rect, - .{ .transparent = true, .mask = true }, - ); - selected_layer.clearRect(source_rect); - } - } - } - } - }, - } - - // We now have a transform layer that contains: - // 1. the unaltered colored pixels of the active transform - // 2. a mask containing bits for the pixels of the selection being transformed - const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size()); - if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { - defer file.editor.selection_layer.clearMask(); - file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { - dvui.log.err("Failed to create target texture", .{}); - return; - }, - .file_id = file.id, - .layer_id = selected_layer.id, - .data_points = .{ - reduced_data_rect.topLeft(), - reduced_data_rect.topRight(), - reduced_data_rect.bottomRight(), - reduced_data_rect.bottomLeft(), - reduced_data_rect.center(), - reduced_data_rect.center(), // This point constantly moves - }, - .source = fizzy.image.fromPixelsPMA( - @ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)), - @intFromFloat(reduced_data_rect.w), - @intFromFloat(reduced_data_rect.h), - .ptr, - ) catch return error.MemoryAllocationFailed, - }; - - for (file.editor.transform.?.data_points[0..4]) |*point| { - const d = point.diff(file.editor.transform.?.point(.pivot).*); - if (d.length() > file.editor.transform.?.radius) { - file.editor.transform.?.radius = d.length() + 4; - } - } - } - } + _ = editor; + try pixelart.plugin.pluginPtr().transform(); } /// Performs a save operation on the currently open file. @@ -3196,7 +2646,7 @@ pub fn cancelPendingSaveDialog(editor: *Editor) void { if (file_id) |id| { _ = editor.pending_close_after_save.swapRemove(id); - if (editor.pixelart_state.docs.fileById(id)) |f| { + if (editor.fileById(id)) |f| { f.resetSaveUIState(); } } else if (editor.activeFile()) |f| { @@ -3346,12 +2796,10 @@ pub fn openInFileBrowser(_: *Editor, path: []const u8) !void { } pub fn closeFileID(editor: *Editor, id: u64) !void { - if (editor.open_files.contains(id)) { - if (editor.pixelart_state.docs.fileById(id)) |file| { - if (file.dirty()) { - Dialogs.UnsavedClose.request(id); - return; - } + if (editor.open_files.get(id)) |doc| { + if (doc.owner.isDirty(doc)) { + Dialogs.UnsavedClose.request(id); + return; } try editor.rawCloseFileID(id); } @@ -3367,11 +2815,11 @@ pub fn closeFile(editor: *Editor, index: usize) !void { /// the matching `DocHandle` from `open_files`. fn closeDocumentResources(editor: *Editor, doc: sdk.DocHandle) void { if (doc.owner.closeDocument(doc)) { - _ = editor.pixelart_state.docs.files.swapRemove(doc.id); + doc.owner.unregisterDocument(doc.id); return; } editor.fileFromDoc(doc).deinit(); - _ = editor.pixelart_state.docs.files.swapRemove(doc.id); + doc.owner.unregisterDocument(doc.id); } pub fn rawCloseFile(editor: *Editor, index: usize) !void { diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index 87a0d436..b7720980 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -23,7 +23,7 @@ pub fn request(file_id: u64) void { } fn fileBasename(file_id: u64) []const u8 { - const file = fizzy.editor.pixelart_state.docs.fileById(file_id) orelse return "?"; + const file = fizzy.editor.fileById(file_id) orelse return "?"; return std.fs.path.basename(file.path); } @@ -113,7 +113,7 @@ fn beginSaveAndClose(file: *Internal.File, file_id: u64) !void { } fn onSaveAndClose(file_id: u64) !void { - const file = fizzy.editor.pixelart_state.docs.fileById(file_id) orelse return; + const file = fizzy.editor.fileById(file_id) orelse return; if (!Internal.File.hasRecognizedSaveExtension(file.path)) { const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; fizzy.editor.setActiveFile(idx); diff --git a/src/fizzy.zig b/src/fizzy.zig index 61ae745c..63fc8f0b 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -30,16 +30,13 @@ pub const Fling = core.Fling; //pub const Popups = @import("editor/popups/Popups.zig"); pub const Sidebar = @import("editor/Sidebar.zig"); -/// Pixel-art plugin module. Shell code should `@import("pixelart")` directly; -/// this alias exists only for `App.zig` lifecycle wiring (can't name it `pixelart` -/// — that name is the runtime `*State` global below). +/// Pixel-art plugin module. Shell code should `@import("pixelart")` directly. pub const pixelart_mod = @import("pixelart"); // Global pointers pub var app: *App = undefined; pub var editor: *Editor = undefined; pub var packer: *pixelart_mod.Packer = undefined; -pub var pixelart: *pixelart_mod.State = undefined; /// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web /// builds, where `builtin.os.tag` is always `.freestanding`. diff --git a/src/plugins/pixelart/src/State.zig b/src/plugins/pixelart/src/State.zig index f76dad21..79d6290b 100644 --- a/src/plugins/pixelart/src/State.zig +++ b/src/plugins/pixelart/src/State.zig @@ -5,7 +5,7 @@ //! project's pack config, the sprite clipboard, and the background pack-job queue. //! //! Each plugin has a `State.zig` holding its live state. The shell still reaches -//! this through `fizzy.pixelart` during migration; plugin code uses `Globals.state`. +//! plugin code uses `Globals.state`. const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); @@ -111,6 +111,11 @@ pub fn persistProject(st: *State) void { } } +/// Load `.fizproject` for the shell's currently-open project folder. +pub fn reloadProjectForFolder(st: *State, allocator: std.mem.Allocator) void { + st.project = Project.load(allocator) catch null; +} + pub fn deinit(st: *State, allocator: std.mem.Allocator) void { for (st.pack_jobs.items) |job| { // Detached workers still reference each job. Signal cancellation and leak the structs diff --git a/src/plugins/pixelart/src/clipboard.zig b/src/plugins/pixelart/src/clipboard.zig new file mode 100644 index 00000000..3cab67d6 --- /dev/null +++ b/src/plugins/pixelart/src/clipboard.zig @@ -0,0 +1,220 @@ +//! Sprite copy/paste for the pixel-art plugin. Invoked from the plugin vtable; +//! the shell routes `EditorAPI.copy` / `paste` here instead of owning the logic. +const std = @import("std"); +const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const State = pixelart.State; +const Internal = pixelart.internal; + +fn activeFile(st: *State) ?*Internal.File { + const doc = st.host.activeDoc() orelse return null; + return st.docs.fileById(doc.id); +} + +pub fn copy(st: *State) !void { + const file = activeFile(st) orelse return; + if (file.editor.transform != null) return; + + if (st.sprite_clipboard) |*clipboard| { + Globals.allocator().free(pixelart.image.bytes(clipboard.source)); + st.sprite_clipboard = null; + } + + file.editor.transform_layer.clear(); + + var selected_layer = file.layers.get(file.selected_layer_index); + switch (st.tools.current) { + .selection => { + var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); + while (pixel_iterator.next()) |pixel_index| { + @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]); + file.editor.transform_layer.mask.set(pixel_index); + } + }, + else => { + if (file.editor.selected_sprites.count() > 0) { + var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); + while (sprite_iterator.next()) |index| { + const source_rect = file.spriteRect(index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + source_rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + source_rect, + .{ .transparent = true, .mask = true }, + ); + } + } + } else { + if (file.editor.canvas.hovered) { + if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { + const rect = file.spriteRect(sprite_index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + rect, + .{ .transparent = true, .mask = true }, + ); + } + } + } else if (file.selected_animation_index) |animation_index| { + const animation = file.animations.get(animation_index); + if (file.selected_animation_frame_index < animation.frames.len) { + const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + rect, + .{ .transparent = true, .mask = true }, + ); + } + } + } + } + }, + } + + const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size()); + if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { + const sprite_tl = file.spritePoint(reduced_data_rect.topLeft()); + const gpa = Globals.allocator(); + + st.sprite_clipboard = .{ + .source = pixelart.image.fromPixelsPMA( + @ptrCast(file.editor.transform_layer.pixelsFromRect(gpa, reduced_data_rect)), + @intFromFloat(reduced_data_rect.w), + @intFromFloat(reduced_data_rect.h), + .ptr, + ) catch return error.MemoryAllocationFailed, + .offset = reduced_data_rect.topLeft().diff(sprite_tl), + }; + + const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, file.editor.canvas.id, pixelart.core.dvui.toastDisplay, 2_000_000); + const id = id_mutex.id; + const message = std.fmt.allocPrint(dvui.currentWindow().arena(), "Copied selection", .{}) catch "Copied selection."; + dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message); + id_mutex.mutex.unlock(dvui.io); + } +} + +pub fn paste(st: *State) !void { + if (st.sprite_clipboard) |*clipboard| { + const file = activeFile(st) orelse return; + const active_layer = file.layers.get(file.selected_layer_index); + + var dst_rect: dvui.Rect = .fromSize(pixelart.image.size(clipboard.source)); + + var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); + while (sprite_iterator.next()) |sprite_index| { + const sprite_rect = file.spriteRect(sprite_index); + + dst_rect.x = sprite_rect.x + clipboard.offset.x; + dst_rect.y = sprite_rect.y + clipboard.offset.y; + + file.editor.transform = .{ + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + dvui.log.err("Failed to create target texture", .{}); + return; + }, + .file_id = file.id, + .layer_id = active_layer.id, + .data_points = .{ + dst_rect.topLeft(), + dst_rect.topRight(), + dst_rect.bottomRight(), + dst_rect.bottomLeft(), + dst_rect.center(), + dst_rect.center(), + }, + .source = clipboard.source, + }; + + for (file.editor.transform.?.data_points[0..4]) |*point| { + const d = point.diff(file.editor.transform.?.point(.pivot).*); + if (d.length() > file.editor.transform.?.radius) { + file.editor.transform.?.radius = d.length() + 4; + } + } + + return; + } + + dst_rect.x = clipboard.offset.x; + dst_rect.y = clipboard.offset.y; + + if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { + const rect = file.spriteRect(sprite_index); + dst_rect.x = rect.x + clipboard.offset.x; + dst_rect.y = rect.y + clipboard.offset.y; + } else if (file.selected_animation_index) |animation_index| { + const animation = file.animations.get(animation_index); + + if (file.selected_animation_frame_index < animation.frames.len) { + const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); + dst_rect.x = rect.x + clipboard.offset.x; + dst_rect.y = rect.y + clipboard.offset.y; + + file.editor.transform = .{ + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + dvui.log.err("Failed to create target texture", .{}); + return; + }, + .file_id = file.id, + .layer_id = active_layer.id, + .data_points = .{ + dst_rect.topLeft(), + dst_rect.topRight(), + dst_rect.bottomRight(), + dst_rect.bottomLeft(), + dst_rect.center(), + dst_rect.center(), + }, + .source = clipboard.source, + }; + + for (file.editor.transform.?.data_points[0..4]) |*point| { + const d = point.diff(file.editor.transform.?.point(.pivot).*); + if (d.length() > file.editor.transform.?.radius) { + file.editor.transform.?.radius = d.length() + 4; + } + } + + return; + } + } + + file.editor.transform = .{ + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + dvui.log.err("Failed to create target texture", .{}); + return; + }, + .file_id = file.id, + .layer_id = active_layer.id, + .data_points = .{ + dst_rect.topLeft(), + dst_rect.topRight(), + dst_rect.bottomRight(), + dst_rect.bottomLeft(), + dst_rect.center(), + dst_rect.center(), + }, + .source = clipboard.source, + }; + + for (file.editor.transform.?.data_points[0..4]) |*point| { + const d = point.diff(file.editor.transform.?.point(.pivot).*); + if (d.length() > file.editor.transform.?.radius) { + file.editor.transform.?.radius = d.length() + 4; + } + } + } +} diff --git a/src/plugins/pixelart/src/docs_registry.zig b/src/plugins/pixelart/src/docs_registry.zig new file mode 100644 index 00000000..b6744e28 --- /dev/null +++ b/src/plugins/pixelart/src/docs_registry.zig @@ -0,0 +1,32 @@ +//! Open-document registry bridge: the shell stores `DocHandle`s; this owns `Internal.File`. +const std = @import("std"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const State = pixelart.State; +const Internal = pixelart.internal; + +pub fn registerOpenDocument(st: *State, file: *Internal.File) !*Internal.File { + const gpa = Globals.allocator(); + try st.docs.files.put(gpa, file.id, file.*); + return st.docs.files.getPtr(file.id).?; +} + +pub fn documentPtr(st: *State, id: u64) ?*Internal.File { + return st.docs.fileById(id); +} + +pub fn documentByPath(st: *State, path: []const u8) ?*Internal.File { + return st.docs.fileFromPath(path); +} + +pub fn unregisterDocument(st: *State, id: u64) void { + _ = st.docs.files.swapRemove(id); +} + +pub fn persistProjectFolder(st: *State) void { + st.persistProject(); +} + +pub fn reloadProjectFolder(st: *State, allocator: std.mem.Allocator) void { + st.reloadProjectForFolder(allocator); +} diff --git a/src/plugins/pixelart/src/pack_project.zig b/src/plugins/pixelart/src/pack_project.zig new file mode 100644 index 00000000..303c1c64 --- /dev/null +++ b/src/plugins/pixelart/src/pack_project.zig @@ -0,0 +1,236 @@ +//! Async project packing for the pixel-art plugin. Invoked from the plugin vtable; +//! the shell routes `EditorAPI.startPackProject` / `isPackingActive` here. +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const State = pixelart.State; +const PackJob = @import("PackJob.zig"); +const Internal = pixelart.internal; + +fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void { + const anchor = canvas_id orelse blk: { + if (Globals.state.host.activeDoc()) |doc| { + if (Globals.state.docs.fileById(doc.id)) |file| break :blk file.editor.canvas.id; + } + break :blk dvui.currentWindow().data().id; + }; + const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, anchor, pixelart.core.dvui.toastDisplay, 2_500_000); + const id = id_mutex.id; + const msg_copy = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{message}) catch message; + dvui.dataSetSlice(dvui.currentWindow(), id, "_message", msg_copy); + id_mutex.mutex.unlock(dvui.io); +} + +fn appendOpenPackInputs(st: *State, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void { + const gpa = Globals.allocator(); + const host = st.host; + var i: usize = 0; + while (i < host.openDocCount()) : (i += 1) { + const doc = host.docByIndex(i) orelse continue; + const open_file = st.docs.fileById(doc.id) orelse continue; + const snapshot = try PackJob.PackFile.fromOpenFile(gpa, open_file); + try inputs.append(gpa, .{ .open = snapshot }); + } +} + +fn findOpenFileForPackPath(st: *State, path: []const u8) ?*Internal.File { + if (st.docs.fileFromPath(path)) |file| return file; + + const basename = std.fs.path.basename(path); + const gpa = Globals.allocator(); + const host = st.host; + var i: usize = 0; + while (i < host.openDocCount()) : (i += 1) { + const doc = host.docByIndex(i) orelse continue; + const file = st.docs.fileById(doc.id) orelse continue; + if (!std.mem.eql(u8, std.fs.path.basename(file.path), basename)) continue; + if (std.mem.eql(u8, file.path, path)) return file; + if (host.folder()) |folder| { + const joined = std.fs.path.join(gpa, &.{ folder, basename }) catch continue; + defer gpa.free(joined); + if (std.mem.eql(u8, file.path, joined)) return file; + } + } + return null; +} + +fn gatherPackInputs( + st: *State, + inputs: *std.ArrayListUnmanaged(PackJob.PackInput), + directory: []const u8, +) !void { + const gpa = Globals.allocator(); + const io = dvui.io; + var dir = try std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true }); + defer dir.close(io); + + var iter = dir.iterate(); + while (try iter.next(io)) |entry| { + if (entry.kind == .file) { + const ext = std.fs.path.extension(entry.name); + if (!Internal.File.isFizzyExtension(ext)) continue; + + const abs_path = try std.fs.path.join(gpa, &.{ directory, entry.name }); + defer gpa.free(abs_path); + + if (findOpenFileForPackPath(st, abs_path) != null) continue; + + const owned_path = try gpa.dupe(u8, abs_path); + try inputs.append(gpa, .{ .path = owned_path }); + } else if (entry.kind == .directory) { + const abs_path = try std.fs.path.join(gpa, &.{ directory, entry.name }); + defer gpa.free(abs_path); + try gatherPackInputs(st, inputs, abs_path); + } + } +} + +pub fn start(st: *State) !void { + const gpa = Globals.allocator(); + var inputs: std.ArrayListUnmanaged(PackJob.PackInput) = .empty; + errdefer { + for (inputs.items) |*input| input.deinit(gpa); + inputs.deinit(gpa); + } + + if (comptime builtin.target.cpu.arch == .wasm32) { + try appendOpenPackInputs(st, &inputs); + } else { + const root = st.host.folder() orelse return; + try appendOpenPackInputs(st, &inputs); + try gatherPackInputs(st, &inputs, root); + } + + if (inputs.items.len == 0) { + const msg = if (comptime builtin.target.cpu.arch == .wasm32) + "No open files to pack" + else + "No .fiz or .pixi files to pack"; + showPackToast(msg, null); + return; + } + + var owned_inputs: ?[]PackJob.PackInput = try inputs.toOwnedSlice(gpa); + errdefer if (owned_inputs) |o| { + for (o) |*input| input.deinit(gpa); + gpa.free(o); + }; + + for (st.pack_jobs.items) |old| { + old.cancelled.store(true, .monotonic); + } + + const job = try PackJob.create(gpa, owned_inputs.?); + owned_inputs = null; + errdefer job.destroy(); + + try st.pack_jobs.append(gpa, job); + errdefer _ = st.pack_jobs.pop(); + + if (comptime builtin.target.cpu.arch == .wasm32) { + dvui.refresh(dvui.currentWindow(), @src(), null); + } else { + const thread = try std.Thread.spawn(.{}, PackJob.workerMain, .{job}); + thread.detach(); + } +} + +pub fn isActive(st: *const State) bool { + for (st.pack_jobs.items) |job| { + if (job.cancelled.load(.monotonic)) continue; + if (!job.done.load(.acquire)) return true; + if (!job.result_consumed) return true; + } + return false; +} + +pub fn runWasmWorkers(st: *State) void { + if (comptime builtin.target.cpu.arch != .wasm32) return; + for (st.pack_jobs.items) |job| { + if (job.cancelled.load(.monotonic)) continue; + if (job.done.load(.acquire)) continue; + PackJob.workerMain(job); + return; + } +} + +pub fn tick(st: *State) void { + if (st.pack_jobs.items.len == 0) return; + + const gpa = Globals.allocator(); + var install_index: ?usize = null; + { + var i = st.pack_jobs.items.len; + while (i > 0) { + i -= 1; + const job = st.pack_jobs.items[i]; + if (!job.done.load(.acquire)) continue; + if (job.cancelled.load(.monotonic)) continue; + if (job.currentPhase() == .ready and job.result_atlas != null) { + install_index = i; + break; + } + } + } + + if (install_index) |idx| { + const job = st.pack_jobs.items[idx]; + const new_atlas = job.result_atlas.?; + if (Globals.packer.atlas) |*current_atlas| { + current_atlas.deinitCheckerboardTile(); + for (current_atlas.data.animations) |*anim| gpa.free(anim.name); + gpa.free(current_atlas.data.sprites); + gpa.free(current_atlas.data.animations); + gpa.free(pixelart.image.bytes(current_atlas.source)); + + current_atlas.source = new_atlas.source; + current_atlas.data = new_atlas.data; + current_atlas.initCheckerboardTile(); + } else { + Globals.packer.atlas = new_atlas; + Globals.packer.atlas.?.initCheckerboardTile(); + } + Globals.packer.last_packed_at_ns = pixelart.perf.nanoTimestamp(); + job.result_consumed = true; + st.host.setActiveSidebarView("pixelart.project"); + const toast_canvas: ?dvui.Id = if (st.host.activeDoc()) |doc| + if (st.docs.fileById(doc.id)) |file| file.editor.canvas.id else null + else + null; + showPackToast("Project packed", toast_canvas); + } else blk: { + var i = st.pack_jobs.items.len; + while (i > 0) { + i -= 1; + const job = st.pack_jobs.items[i]; + if (!job.done.load(.acquire)) continue; + if (job.cancelled.load(.monotonic)) continue; + if (job.currentPhase() == .ready and job.result_atlas == null) { + showPackToast("Nothing to pack in the selected files", null); + break :blk; + } + } + } + + var write: usize = 0; + for (st.pack_jobs.items) |job| { + if (!job.done.load(.acquire)) { + st.pack_jobs.items[write] = job; + write += 1; + continue; + } + const phase = job.currentPhase(); + switch (phase) { + .ready, .cancelled => {}, + .failed => { + dvui.log.err("Pack project failed: {any}", .{job.err}); + showPackToast("Pack failed", null); + }, + else => dvui.log.err("Pack job finished in unexpected phase {s}", .{@tagName(phase)}), + } + job.destroy(); + } + st.pack_jobs.shrinkRetainingCapacity(write); +} diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig index 73fd96b0..c122aece 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -15,6 +15,10 @@ const ImageWidget = @import("widgets/ImageWidget.zig"); const PixelArtSettings = @import("Settings.zig"); const KeybindTicks = @import("keybind_ticks.zig"); const RadialMenu = @import("radial_menu.zig"); +const Clipboard = @import("clipboard.zig"); +const PackProject = @import("pack_project.zig"); +const TransformOp = @import("transform_op.zig"); +const DocsRegistry = @import("docs_registry.zig"); const DocHandle = sdk.DocHandle; const Internal = pixelart.internal; @@ -42,11 +46,24 @@ const vtable: sdk.Plugin.VTable = .{ .closeDocument = closeDocument, .undo = undo, .redo = redo, + .registerOpenDocument = registerOpenDocument, + .documentPtr = documentPtr, + .documentByPath = documentByPath, + .unregisterDocument = unregisterDocument, .drawDocument = drawDocument, .tickKeybinds = tickKeybinds, .processRadialMenuInput = processRadialMenuInput, .radialMenuVisible = radialMenuVisible, .drawRadialMenu = drawRadialMenu, + .transform = pluginTransform, + .copy = pluginCopy, + .paste = pluginPaste, + .startPackProject = pluginStartPackProject, + .isPackingActive = pluginIsPackingActive, + .tickPackJobs = pluginTickPackJobs, + .runPackWorkers = pluginRunPackWorkers, + .persistProjectFolder = pluginPersistProjectFolder, + .reloadProjectFolder = pluginReloadProjectFolder, }; /// A `DocHandle` for one of this plugin's open `*Internal.File`s. Resolved by `doc.id` @@ -319,7 +336,74 @@ fn drawRadialMenu(_: *anyopaque) anyerror!void { try RadialMenu.draw(); } -/// Pixel-art editing + tool keybinds. The shell registers its own global/region +fn pluginCopy(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + try Clipboard.copy(st); +} + +fn pluginTransform(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + try TransformOp.begin(st); +} + +fn registerOpenDocument(state: *anyopaque, file: *anyopaque) anyerror!*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + const internal_file: *Internal.File = @ptrCast(@alignCast(file)); + const ptr = try DocsRegistry.registerOpenDocument(st, internal_file); + return ptr; +} + +fn documentPtr(state: *anyopaque, id: u64) ?*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + return DocsRegistry.documentPtr(st, id); +} + +fn documentByPath(state: *anyopaque, path: []const u8) ?*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + return DocsRegistry.documentByPath(st, path); +} + +fn unregisterDocument(state: *anyopaque, id: u64) void { + const st: *State = @ptrCast(@alignCast(state)); + DocsRegistry.unregisterDocument(st, id); +} + +fn pluginPersistProjectFolder(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocsRegistry.persistProjectFolder(st); +} + +fn pluginReloadProjectFolder(state: *anyopaque, allocator: std.mem.Allocator) void { + const st: *State = @ptrCast(@alignCast(state)); + DocsRegistry.reloadProjectFolder(st, allocator); +} + +fn pluginPaste(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + try Clipboard.paste(st); +} + +fn pluginStartPackProject(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + try PackProject.start(st); +} + +fn pluginIsPackingActive(state: *const anyopaque) bool { + const st: *const State = @ptrCast(@alignCast(state)); + return PackProject.isActive(st); +} + +fn pluginTickPackJobs(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + PackProject.tick(st); +} + +fn pluginRunPackWorkers(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + PackProject.runWasmWorkers(st); +} + +/// Pixel-art editing + tool keybinds. /// binds in `Keybinds.register`; this fills in the pixel-art half. Platform: see /// `Keybinds.register` for why `host.isMacOS()` (not `builtin`) is used. fn contributeKeybinds(state: *anyopaque, win: *dvui.Window) anyerror!void { diff --git a/src/plugins/pixelart/src/transform_op.zig b/src/plugins/pixelart/src/transform_op.zig new file mode 100644 index 00000000..ad03b262 --- /dev/null +++ b/src/plugins/pixelart/src/transform_op.zig @@ -0,0 +1,123 @@ +//! Begin a transform on the active document (selection → transform handles). +const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const State = pixelart.State; +const Internal = pixelart.internal; + +fn activeFile(st: *State) ?*Internal.File { + const doc = st.host.activeDoc() orelse return null; + return st.docs.fileById(doc.id); +} + +pub fn begin(st: *State) !void { + const file = activeFile(st) orelse return; + if (file.editor.transform) |*t| { + t.cancel(); + } + + var selected_layer = file.layers.get(file.selected_layer_index); + + switch (st.tools.current) { + .selection => { + file.editor.transform_layer.clear(); + var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); + while (pixel_iterator.next()) |pixel_index| { + @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]); + selected_layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 }; + file.editor.transform_layer.mask.set(pixel_index); + } + selected_layer.invalidate(); + }, + else => { + file.editor.transform_layer.clear(); + + if (file.editor.selected_sprites.count() > 0) { + var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); + + while (sprite_iterator.next()) |index| { + const source_rect = file.spriteRect(index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + source_rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + source_rect, + .{ .transparent = true, .mask = true }, + ); + selected_layer.clearRect(source_rect); + } + } + } else { + if (file.editor.canvas.hovered) { + if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { + const rect = file.spriteRect(sprite_index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + rect, + .{ .transparent = true, .mask = true }, + ); + selected_layer.clearRect(rect); + } + } + } else if (file.selected_animation_index) |animation_index| { + const animation = file.animations.get(animation_index); + if (file.selected_animation_frame_index < animation.frames.len) { + const source_rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + source_rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + source_rect, + .{ .transparent = true, .mask = true }, + ); + selected_layer.clearRect(source_rect); + } + } + } + } + }, + } + + const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size()); + if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { + defer file.editor.selection_layer.clearMask(); + const gpa = Globals.allocator(); + file.editor.transform = .{ + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + dvui.log.err("Failed to create target texture", .{}); + return; + }, + .file_id = file.id, + .layer_id = selected_layer.id, + .data_points = .{ + reduced_data_rect.topLeft(), + reduced_data_rect.topRight(), + reduced_data_rect.bottomRight(), + reduced_data_rect.bottomLeft(), + reduced_data_rect.center(), + reduced_data_rect.center(), + }, + .source = pixelart.image.fromPixelsPMA( + @ptrCast(file.editor.transform_layer.pixelsFromRect(gpa, reduced_data_rect)), + @intFromFloat(reduced_data_rect.w), + @intFromFloat(reduced_data_rect.h), + .ptr, + ) catch return error.MemoryAllocationFailed, + }; + + for (file.editor.transform.?.data_points[0..4]) |*point| { + const d = point.diff(file.editor.transform.?.point(.pivot).*); + if (d.length() > file.editor.transform.?.radius) { + file.editor.transform.?.radius = d.length() + 4; + } + } + } +} diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index 0a489e42..5df09127 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -40,7 +40,7 @@ pub fn init(grouping: u64) Workspace { /// Release any plugin-owned per-pane canvas chrome. Called when a pane is removed /// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown. pub fn deinit(self: *Workspace) void { - pixelart.State.removeCanvasPane(fizzy.editor.pixelart_state, fizzy.app.allocator, self.grouping); + pixelart.State.removeCanvasPane(pixelart.Globals.state, fizzy.app.allocator, self.grouping); } /// Recover the typed workspace currently drawing `file` from its opaque slot diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index d6dc5712..9b55ab9a 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -1,5 +1,6 @@ const std = @import("std"); const fizzy = @import("../../../fizzy.zig"); +const pixelart = @import("pixelart"); const dvui = @import("dvui"); const Editor = fizzy.Editor; const builtin = @import("builtin"); @@ -497,7 +498,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg try visible_file_rows_order.append(fizzy.app.allocator, .{ .id = inner_id_extra.*, .path = abs_path }); var color = dvui.themeGet().color(.control, .fill); - if (fizzy.editor.pixelart_state.colors.palette) |*palette| { + if (pixelart.Globals.state.colors.palette) |*palette| { color = palette.getDVUIColor(color_id.*); } diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index 06f0d526..c918af2f 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -46,6 +46,17 @@ pub const VTable = struct { undo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, redo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + /// Register a loaded/created document in the plugin's open-doc map. `file` points at + /// the plugin's document type (for pixel art, `*Internal.File` on the caller's stack). + /// Returns the stable registry pointer for `DocHandle.ptr`. + registerOpenDocument: ?*const fn (state: *anyopaque, file: *anyopaque) anyerror!*anyopaque = null, + /// Resolve a document id to the plugin's registry pointer, or null when not open. + documentPtr: ?*const fn (state: *anyopaque, id: u64) ?*anyopaque = null, + /// Lookup an open document by absolute path. + documentByPath: ?*const fn (state: *anyopaque, path: []const u8) ?*anyopaque = null, + /// Drop the registry entry after `closeDocument` has torn down resources. + unregisterDocument: ?*const fn (state: *anyopaque, id: u64) void = null, + // ---- render hooks (the plugin draws its own dvui UI into the host window) ---- /// Draw the plugin's explorer/sidebar pane (left region). drawExplorerPane: ?*const fn (state: *anyopaque) anyerror!void = null, @@ -63,6 +74,17 @@ pub const VTable = struct { processRadialMenuInput: ?*const fn (state: *anyopaque) void = null, radialMenuVisible: ?*const fn (state: *anyopaque) bool = null, drawRadialMenu: ?*const fn (state: *anyopaque) anyerror!void = null, + + // ---- editing + project pack (pixel-art today; future plugins opt in) ---- + transform: ?*const fn (state: *anyopaque) anyerror!void = null, + copy: ?*const fn (state: *anyopaque) anyerror!void = null, + paste: ?*const fn (state: *anyopaque) anyerror!void = null, + startPackProject: ?*const fn (state: *anyopaque) anyerror!void = null, + isPackingActive: ?*const fn (state: *const anyopaque) bool = null, + tickPackJobs: ?*const fn (state: *anyopaque) void = null, + runPackWorkers: ?*const fn (state: *anyopaque) void = null, + persistProjectFolder: ?*const fn (state: *anyopaque) void = null, + reloadProjectFolder: ?*const fn (state: *anyopaque, allocator: std.mem.Allocator) void = null, }; // Thin wrappers so callers don't repeat the optional-vtable dance. @@ -91,6 +113,58 @@ pub fn drawRadialMenu(self: Plugin) !void { if (self.vtable.drawRadialMenu) |f| try f(self.state); } +pub fn copy(self: Plugin) !void { + if (self.vtable.copy) |f| try f(self.state); +} + +pub fn paste(self: Plugin) !void { + if (self.vtable.paste) |f| try f(self.state); +} + +pub fn startPackProject(self: Plugin) !void { + if (self.vtable.startPackProject) |f| try f(self.state); +} + +pub fn isPackingActive(self: Plugin) bool { + return if (self.vtable.isPackingActive) |f| f(self.state) else false; +} + +pub fn tickPackJobs(self: Plugin) void { + if (self.vtable.tickPackJobs) |f| f(self.state); +} + +pub fn runPackWorkers(self: Plugin) void { + if (self.vtable.runPackWorkers) |f| f(self.state); +} + +pub fn transform(self: Plugin) !void { + if (self.vtable.transform) |f| try f(self.state); +} + +pub fn registerOpenDocument(self: Plugin, file: *anyopaque) !*anyopaque { + return if (self.vtable.registerOpenDocument) |f| try f(self.state, file) else error.Unsupported; +} + +pub fn documentPtr(self: Plugin, id: u64) ?*anyopaque { + return if (self.vtable.documentPtr) |f| f(self.state, id) else null; +} + +pub fn documentByPath(self: Plugin, path: []const u8) ?*anyopaque { + return if (self.vtable.documentByPath) |f| f(self.state, path) else null; +} + +pub fn unregisterDocument(self: Plugin, id: u64) void { + if (self.vtable.unregisterDocument) |f| f(self.state, id); +} + +pub fn persistProjectFolder(self: Plugin) void { + if (self.vtable.persistProjectFolder) |f| f(self.state); +} + +pub fn reloadProjectFolder(self: Plugin, allocator: std.mem.Allocator) void { + if (self.vtable.reloadProjectFolder) |f| f(self.state, allocator); +} + // ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ---- /// Load `path` into the shell-owned buffer at `out_doc`. Returns whether the plugin diff --git a/tests/integration.zig b/tests/integration.zig index 17f0b086..cbf904c2 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -1009,8 +1009,8 @@ test "drawPoint with to_change records history; undo restores pixels" { // `drawPoint` reads plugin tools stroke size for stamps smaller than `min_full_stroke_size`; // the shim zero-fills the editor, so brush size must be set explicitly. - fizzy.editor.pixelart_state.tools.stroke_size = 1; - fizzy.editor.pixelart_state.tools.pencil_stroke_size = 1; + pixelart.Globals.state.tools.stroke_size = 1; + pixelart.Globals.state.tools.pencil_stroke_size = 1; const idx: usize = 3 * 8 + 4; From e05fb1c07e3b459e846257be0a279a7e0e855480 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 18 Jun 2026 17:27:12 -0500 Subject: [PATCH 24/49] Phase 4 stage e final --- HANDOFF.md | 7 +- src/editor/Editor.zig | 47 +++++++--- src/editor/Menu.zig | 3 +- src/editor/dialogs/AppQuitUnsaved.zig | 5 +- src/editor/dialogs/UnsavedClose.zig | 25 ++---- src/plugins/pixelart/src/doc_bridge.zig | 78 ++++++++++++++++ src/plugins/pixelart/src/plugin.zig | 67 ++++++++++++++ src/plugins/workbench/src/Workbench.zig | 10 +-- src/plugins/workbench/src/Workspace.zig | 113 ++++++++++-------------- src/plugins/workbench/src/files.zig | 38 ++++---- src/sdk/Plugin.zig | 57 ++++++++++++ 11 files changed, 319 insertions(+), 131 deletions(-) create mode 100644 src/plugins/pixelart/src/doc_bridge.zig diff --git a/HANDOFF.md b/HANDOFF.md index 614401de..919529cd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -265,11 +265,12 @@ app code until the build module is fully wired. - **Copy/paste + pack/project** — moved to `pixelart/src/clipboard.zig` and `pack_project.zig`; plugin vtable hooks (`copy`, `paste`, `startPackProject`, `isPackingActive`, `tickPackJobs`, `runPackWorkers`). Shell `Editor` delegates; `setProjectFolder` uses plugin `persistProjectFolder` / `reloadProjectFolder`. - **Transform + doc registry** — `transform_op.zig` + `docs_registry.zig`; vtable hooks (`transform`, `registerOpenDocument`, `documentPtr`, `documentByPath`, `unregisterDocument`). Shell `fileFromDoc` / `insertOpenDoc` / `fileById` route through `doc.owner`; no direct `pixelart_state.docs` access in `Editor.zig`. - **`fizzy.pixelart` global removed** — single ownership on `Editor.pixelart_state` + `Globals.state`; `App.zig` alloc/deinit via `fizzy.editor.pixelart_state` only. +- **DocHandle at workbench boundary** — `doc_bridge.zig` + plugin vtable metadata hooks (`bindDocumentToPane`, `documentGrouping`, `documentPath`, `setDocumentPath`, save/dirty indicators, …). `Workspace.zig` + `files.zig` use `DocHandle` + `doc.owner` only (no `Internal.File`). Shell helpers `docFromPath`, `docPath`, `setDocGrouping`, `bindDocToPane`; `fileFromDoc`/`fileById` are shell-internal. **Still remaining:** -- Shell `Editor` still types `*Internal.File` in helpers (`activeFile`, `fileFromDoc`) — shrink as multi-plugin doc types arrive. -- `pixelart.internal.File` in workbench tab paths — type-agnostic `DocHandle` only at boundary. -- Integration test shim updated for `pixelart.State` settings; `check-integration` still blocked on native `backend_native` SDL import under dvui-testing (pre-existing). +- Shell `Editor` still types `*Internal.File` in internal save/new-file paths (`newFile`, `openFileFromBytes`, save queue). +- `FileLoadJob` staging buffer still uses `Internal.File` (loader contract). +- Menu/Infobar still use `activeFile()` for pixel-art-specific UI (undo stacks, save enabled). --- diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 121e6095..5c8e1435 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -747,15 +747,14 @@ fn shellIsPackingActive(ctx: *anyopaque) bool { return shellCtx(ctx).isPackingActive(); } -/// Resolve a shell `DocHandle` to the plugin-owned file. Uses `doc.id`, not `doc.ptr`: -/// the plugin registry may reallocate and invalidate pointers stored at insert time. -pub fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File { +/// Resolve a shell `DocHandle` to the plugin-owned file (shell-internal; workbench uses `DocHandle` + owner hooks). +fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File { _ = editor; return @ptrCast(@alignCast(doc.owner.documentPtr(doc.id).?)); } -/// Resolve an open document id to the plugin-owned file, or null when not open. -pub fn fileById(editor: *Editor, id: u64) ?*Internal.File { +/// Resolve an open document id to the plugin-owned file (shell-internal). +fn fileById(editor: *Editor, id: u64) ?*Internal.File { const doc = editor.docById(id) orelse return null; const ptr = doc.owner.documentPtr(doc.id) orelse return null; return @ptrCast(@alignCast(ptr)); @@ -777,6 +776,30 @@ pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { return null; } +/// Workbench routing helpers (type-agnostic; dispatch through `doc.owner`). +pub fn docGrouping(_: *Editor, doc: sdk.DocHandle) u64 { + return doc.owner.documentGrouping(doc); +} + +pub fn setDocGrouping(_: *Editor, doc: sdk.DocHandle, grouping: u64) void { + doc.owner.setDocumentGrouping(doc, grouping); +} + +pub fn docPath(_: *Editor, doc: sdk.DocHandle) []const u8 { + return doc.owner.documentPath(doc); +} + +pub fn docFromPath(editor: *Editor, path: []const u8) ?sdk.DocHandle { + for (editor.open_files.values()) |doc| { + if (doc.owner.documentByPath(path) != null) return doc; + } + return null; +} + +pub fn bindDocToPane(_: *Editor, doc: sdk.DocHandle, canvas_id: dvui.Id, workspace: *anyopaque, center: bool) void { + doc.owner.bindDocumentToPane(doc, canvas_id, workspace, center); +} + /// Store a loaded/created document in the plugin registry and register its handle. pub fn insertOpenDoc(editor: *Editor, file: Internal.File, owner: *sdk.Plugin) !void { var file_mut = file; @@ -2015,9 +2038,9 @@ pub fn saving(editor: *Editor) bool { /// worker hasn't landed it yet and there is no valid `open_files` index to act on. The async /// load will auto-focus once the worker completes (see `processLoadingJobs`). pub fn openOrFocusFileAtGrouping(editor: *Editor, path: []const u8, grouping: u64) !?usize { - if (editor.getFileFromPath(path)) |file| { - const idx = editor.open_files.getIndex(file.id) orelse return error.Unexpected; - editor.fileAt(idx).?.editor.grouping = grouping; + if (editor.docFromPath(path)) |doc| { + const idx = editor.open_files.getIndex(doc.id) orelse return error.Unexpected; + editor.setDocGrouping(doc, grouping); editor.setActiveFile(idx); return idx; } @@ -2503,12 +2526,8 @@ pub fn fileAt(editor: *Editor, index: usize) ?*Internal.File { } pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*Internal.File { - for (editor.open_files.values()) |doc| { - if (doc.owner.documentByPath(path)) |ptr| { - return @ptrCast(@alignCast(ptr)); - } - } - return null; + const doc = editor.docFromPath(path) orelse return null; + return editor.fileFromDoc(doc); } pub fn forceCloseFile(editor: *Editor, index: usize) !void { diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index cdc13904..18e88878 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -160,8 +160,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { // extension. Worker queue handles them serially; UI stays responsive. const any_dirty = blk: { for (fizzy.editor.open_files.values()) |doc| { - const f = fizzy.editor.fileFromDoc(doc); - if (f.dirty() and Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true; + if (doc.owner.isDirty(doc) and Internal.File.hasRecognizedSaveExtension(fizzy.editor.docPath(doc))) break :blk true; } break :blk false; }; diff --git a/src/editor/dialogs/AppQuitUnsaved.zig b/src/editor/dialogs/AppQuitUnsaved.zig index b2e05023..48aafd04 100644 --- a/src/editor/dialogs/AppQuitUnsaved.zig +++ b/src/editor/dialogs/AppQuitUnsaved.zig @@ -32,7 +32,7 @@ pub fn request() void { fn dirtyCount() usize { var n: usize = 0; for (fizzy.editor.open_files.values()) |doc| { - if (fizzy.editor.fileFromDoc(doc).dirty()) n += 1; + if (doc.owner.isDirty(doc)) n += 1; } return n; } @@ -113,8 +113,7 @@ fn onSaveAllAndQuit() !void { fizzy.editor.quit_save_all_ids.clearRetainingCapacity(); for (fizzy.editor.open_files.values()) |doc| { - const f = fizzy.editor.fileFromDoc(doc); - if (f.dirty()) try fizzy.editor.quit_save_all_ids.append(fizzy.app.allocator, f.id); + if (doc.owner.isDirty(doc)) try fizzy.editor.quit_save_all_ids.append(fizzy.app.allocator, doc.id); } if (fizzy.editor.quit_save_all_ids.items.len == 0) { fizzy.editor.pending_app_close = true; diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index b7720980..d6403478 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -23,8 +23,8 @@ pub fn request(file_id: u64) void { } fn fileBasename(file_id: u64) []const u8 { - const file = fizzy.editor.fileById(file_id) orelse return "?"; - return std.fs.path.basename(file.path); + const doc = fizzy.editor.docById(file_id) orelse return "?"; + return std.fs.path.basename(fizzy.editor.docPath(doc)); } fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool { @@ -95,12 +95,8 @@ fn onCancel() void { fizzy.dvui.closeFloatingDialogAnchored(); } -/// Start an async save for the file (`.fizzy` runs on a worker, PNG/JPG runs sync -/// on the GUI thread) and queue the close for once `File.isSaving()` clears. -/// `Editor.tickPendingSaveCloses` does the actual close on the next frame after -/// the worker settles, so the GUI thread never blocks on the save. -fn beginSaveAndClose(file: *Internal.File, file_id: u64) !void { - if (file.isSaving()) return; +fn beginSaveAndClose(doc: fizzy.sdk.DocHandle, file_id: u64) !void { + if (doc.owner.isDocumentSaving(doc)) return; if (comptime @import("builtin").target.cpu.arch == .wasm32) { const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; fizzy.editor.setActiveFile(idx); @@ -108,13 +104,13 @@ fn beginSaveAndClose(file: *Internal.File, file_id: u64) !void { fizzy.editor.requestWebSaveDialog(.save); return; } - try file.saveAsync(); + try doc.owner.saveDocumentAsync(doc); try fizzy.editor.pending_close_after_save.put(fizzy.app.allocator, file_id, {}); } fn onSaveAndClose(file_id: u64) !void { - const file = fizzy.editor.fileById(file_id) orelse return; - if (!Internal.File.hasRecognizedSaveExtension(file.path)) { + const doc = fizzy.editor.docById(file_id) orelse return; + if (!Internal.File.hasRecognizedSaveExtension(fizzy.editor.docPath(doc))) { const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; fizzy.editor.setActiveFile(idx); fizzy.editor.pending_close_file_id = file_id; @@ -122,16 +118,13 @@ fn onSaveAndClose(file_id: u64) !void { fizzy.editor.requestSaveAs(); return; } - if (file.shouldConfirmFlatRasterSave()) { + if (doc.owner.shouldConfirmFlatRasterSave(doc)) { FlatRasterSaveWarning.pending_from_save_all_quit = false; fizzy.dvui.closeFloatingDialogAnchored(); FlatRasterSaveWarning.request(file_id, .save_and_close); return; } - beginSaveAndClose(file, file_id) catch |err| { - dvui.log.err("Save and Close failed: {s}", .{@errorName(err)}); - return; - }; + try beginSaveAndClose(doc, file_id); fizzy.dvui.closeFloatingDialogAnchored(); } diff --git a/src/plugins/pixelart/src/doc_bridge.zig b/src/plugins/pixelart/src/doc_bridge.zig new file mode 100644 index 00000000..a515e8ad --- /dev/null +++ b/src/plugins/pixelart/src/doc_bridge.zig @@ -0,0 +1,78 @@ +//! Document metadata + pane-binding hooks for shell/workbench routing without +//! typing `Internal.File` at the SDK boundary. +const std = @import("std"); +const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const State = pixelart.State; +const Internal = pixelart.internal; +const DocHandle = pixelart.sdk.DocHandle; + +fn docFile(st: *State, doc: DocHandle) ?*Internal.File { + return st.docs.fileById(doc.id); +} + +pub fn bindDocumentToPane( + st: *State, + doc: DocHandle, + canvas_id: dvui.Id, + workspace_handle: *anyopaque, + center: bool, +) void { + const file = docFile(st, doc) orelse return; + file.editor.canvas.id = canvas_id; + file.editor.workspace_handle = workspace_handle; + file.editor.center = center; +} + +pub fn documentGrouping(st: *State, doc: DocHandle) u64 { + const file = docFile(st, doc) orelse return 0; + return file.editor.grouping; +} + +pub fn setDocumentGrouping(st: *State, doc: DocHandle, grouping: u64) void { + const file = docFile(st, doc) orelse return; + file.editor.grouping = grouping; +} + +pub fn documentPath(st: *State, doc: DocHandle) []const u8 { + const file = docFile(st, doc) orelse return ""; + return file.path; +} + +pub fn setDocumentPath(st: *State, doc: DocHandle, path: []const u8) !void { + const file = docFile(st, doc) orelse return error.DocumentNotFound; + const gpa = Globals.allocator(); + gpa.free(file.path); + file.path = try gpa.dupe(u8, path); +} + +pub fn documentHasNativeExtension(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return Internal.File.isFizzyExtension(std.fs.path.extension(file.path)); +} + +pub fn showsSaveStatusIndicator(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.showsSaveStatusIndicator(); +} + +pub fn isDocumentSaving(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.isSaving(); +} + +pub fn shouldConfirmFlatRasterSave(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.shouldConfirmFlatRasterSave(); +} + +pub fn saveDocumentAsync(st: *State, doc: DocHandle) !void { + const file = docFile(st, doc) orelse return error.DocumentNotFound; + try file.saveAsync(); +} + +pub fn timeSinceSaveCompleteNs(st: *State, doc: DocHandle) ?i128 { + const file = docFile(st, doc) orelse return null; + return file.timeSinceSaveComplete(); +} diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig index c122aece..b6a840ad 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -19,6 +19,7 @@ const Clipboard = @import("clipboard.zig"); const PackProject = @import("pack_project.zig"); const TransformOp = @import("transform_op.zig"); const DocsRegistry = @import("docs_registry.zig"); +const DocBridge = @import("doc_bridge.zig"); const DocHandle = sdk.DocHandle; const Internal = pixelart.internal; @@ -50,6 +51,17 @@ const vtable: sdk.Plugin.VTable = .{ .documentPtr = documentPtr, .documentByPath = documentByPath, .unregisterDocument = unregisterDocument, + .bindDocumentToPane = bindDocumentToPane, + .documentGrouping = documentGrouping, + .setDocumentGrouping = setDocumentGrouping, + .documentPath = documentPath, + .setDocumentPath = setDocumentPath, + .documentHasNativeExtension = documentHasNativeExtension, + .showsSaveStatusIndicator = showsSaveStatusIndicator, + .isDocumentSaving = isDocumentSaving, + .shouldConfirmFlatRasterSave = shouldConfirmFlatRasterSave, + .saveDocumentAsync = saveDocumentAsync, + .timeSinceSaveCompleteNs = timeSinceSaveCompleteNs, .drawDocument = drawDocument, .tickKeybinds = tickKeybinds, .processRadialMenuInput = processRadialMenuInput, @@ -368,6 +380,61 @@ fn unregisterDocument(state: *anyopaque, id: u64) void { DocsRegistry.unregisterDocument(st, id); } +fn bindDocumentToPane(state: *anyopaque, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void { + const st: *State = @ptrCast(@alignCast(state)); + DocBridge.bindDocumentToPane(st, doc, canvas_id, workspace_handle, center); +} + +fn documentGrouping(state: *anyopaque, doc: DocHandle) u64 { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.documentGrouping(st, doc); +} + +fn setDocumentGrouping(state: *anyopaque, doc: DocHandle, grouping: u64) void { + const st: *State = @ptrCast(@alignCast(state)); + DocBridge.setDocumentGrouping(st, doc, grouping); +} + +fn documentPath(state: *anyopaque, doc: DocHandle) []const u8 { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.documentPath(st, doc); +} + +fn setDocumentPath(state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.setDocumentPath(st, doc, path); +} + +fn documentHasNativeExtension(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.documentHasNativeExtension(st, doc); +} + +fn showsSaveStatusIndicator(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.showsSaveStatusIndicator(st, doc); +} + +fn isDocumentSaving(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.isDocumentSaving(st, doc); +} + +fn shouldConfirmFlatRasterSave(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.shouldConfirmFlatRasterSave(st, doc); +} + +fn saveDocumentAsync(state: *anyopaque, doc: DocHandle) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.saveDocumentAsync(st, doc); +} + +fn timeSinceSaveCompleteNs(state: *anyopaque, doc: DocHandle) ?i128 { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.timeSinceSaveCompleteNs(st, doc); +} + fn pluginPersistProjectFolder(state: *anyopaque) void { const st: *State = @ptrCast(@alignCast(state)); DocsRegistry.persistProjectFolder(st); diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index ea1a6f11..f535b9b5 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -65,8 +65,8 @@ pub fn drawBranchDecorations(self: *Workbench, path: []const u8, id_extra: usize /// Built-in: a dot on rows whose file is open with unsaved changes. Mirrors the /// tab dirty indicator (`Workspace.zig` ~:528) so the two stay visually consistent. fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void { - const file = fizzy.editor.getFileFromPath(path) orelse return; - if (!file.dirty()) return; + const doc = fizzy.editor.docFromPath(path) orelse return; + if (!doc.owner.isDirty(doc)) return; dvui.icon(@src(), "explorer_dirty", icons.tvg.lucide.@"circle-small", .{ .stroke_color = dvui.themeGet().color(.window, .text), }, .{ @@ -213,15 +213,15 @@ fn svcSave(ctx: *anyopaque) anyerror!void { return editorOf(ctx).save(); } fn svcIsOpen(ctx: *anyopaque, path: []const u8) bool { - return editorOf(ctx).getFileFromPath(path) != null; + return editorOf(ctx).docFromPath(path) != null; } fn svcOpenCount(ctx: *anyopaque) usize { return editorOf(ctx).open_files.count(); } fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 { const editor = editorOf(ctx); - if (index >= editor.open_files.count()) return null; - return if (editor.fileAt(index)) |file| file.path else null; + const doc = editor.docAt(index) orelse return null; + return editor.docPath(doc); } fn svcCreateFile(_: *anyopaque, path: []const u8) anyerror!void { return files.createFilePath(path); diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index 5df09127..c196f430 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -4,7 +4,6 @@ const builtin = @import("builtin"); const dvui = @import("dvui"); const sdk = @import("sdk"); const pixelart = @import("pixelart"); -const Internal = pixelart.internal; const fizzy = @import("../../../fizzy.zig"); const icons = @import("icons"); @@ -43,14 +42,6 @@ pub fn deinit(self: *Workspace) void { pixelart.State.removeCanvasPane(pixelart.Globals.state, fizzy.app.allocator, self.grouping); } -/// Recover the typed workspace currently drawing `file` from its opaque slot -/// handle (`File.EditorData.workspace_handle`, set each frame in `drawCanvas`). -/// Returns null before the file has been laid out this session. -pub fn ofFile(file: *Internal.File) ?*Workspace { - const handle = file.editor.workspace_handle orelse return null; - return @ptrCast(@alignCast(handle)); -} - const handle_size = 10; const handle_dist = 60; @@ -170,30 +161,28 @@ fn drawTabs(self: *Workspace) void { const active_in_this_group = blk: { if (fizzy.editor.open_workspace_grouping != self.grouping) break :blk false; if (self.open_file_index >= files_len) break :blk false; - const active_file = fizzy.editor.fileAt(self.open_file_index) orelse break :blk false; - if (active_file.editor.grouping != self.grouping) break :blk false; + const active_doc = fizzy.editor.docAt(self.open_file_index) orelse break :blk false; + if (fizzy.editor.docGrouping(active_doc) != self.grouping) break :blk false; break :blk true; }; if (active_in_this_group) { const active_index = self.open_file_index; - // Scan left from the active tab to find the previous tab in this grouping. var j: usize = active_index; while (j > 0) { j -= 1; - const tab_file = fizzy.editor.fileAt(j) orelse continue; - if (tab_file.editor.grouping == self.grouping) { + const tab_doc = fizzy.editor.docAt(j) orelse continue; + if (fizzy.editor.docGrouping(tab_doc) == self.grouping) { prev_same_group_index = j; break; } } - // Scan right from the active tab to find the next tab in this grouping. j = active_index + 1; while (j < files_len) : (j += 1) { - const tab_file = fizzy.editor.fileAt(j) orelse continue; - if (tab_file.editor.grouping == self.grouping) { + const tab_doc = fizzy.editor.docAt(j) orelse continue; + if (fizzy.editor.docGrouping(tab_doc) == self.grouping) { next_same_group_index = j; break; } @@ -201,10 +190,10 @@ fn drawTabs(self: *Workspace) void { } for (0..files_len) |i| { - const file = fizzy.editor.fileAt(i) orelse continue; - const is_fizzy_file = Internal.File.isFizzyExtension(std.fs.path.extension(file.path)); + const doc = fizzy.editor.docAt(i) orelse continue; + const is_fizzy_file = doc.owner.documentHasNativeExtension(doc); - if (file.editor.grouping != self.grouping) continue; + if (fizzy.editor.docGrouping(doc) != self.grouping) continue; var reorderable = tabs.reorderable(@src(), .{}, .{ .expand = .vertical, @@ -291,7 +280,7 @@ fn drawTabs(self: *Workspace) void { }); } - dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{ + dvui.label(@src(), "{s}", .{std.fs.path.basename(fizzy.editor.docPath(doc))}, .{ .color_text = if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), .padding = dvui.Rect.all(4), .gravity_y = 0.5, @@ -313,13 +302,13 @@ fn drawTabs(self: *Workspace) void { // button so the layout doesn't shift when saving starts/ends. `editor.saving` // can be written by a background save worker (`saveZip`), so we read it with an // atomic load — the write side uses an atomic store in matching `save*` paths. - const save_flash_elapsed = file.timeSinceSaveComplete(); + const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc); const save_in_check_phase = if (save_flash_elapsed) |elapsed| fizzy.dvui.bubbleSpinnerSaveInCheckPhase(elapsed) else false; - const save_blocks_tab_close = file.isSaving() or - (file.showsSaveStatusIndicator() and !save_in_check_phase); + const save_blocks_tab_close = doc.owner.isDocumentSaving(doc) or + (doc.owner.showsSaveStatusIndicator(doc) and !save_in_check_phase); if (save_blocks_tab_close) { fizzy.dvui.bubbleSpinner(@src(), .{ @@ -369,12 +358,12 @@ fn drawTabs(self: *Workspace) void { } if (tab_close_button.clicked()) { - fizzy.editor.closeFileID(file.id) catch |err| { + fizzy.editor.closeFileID(doc.id) catch |err| { dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) }); }; break; } - } else if (selected and !file.dirty()) { + } else if (selected and !doc.owner.isDirty(doc)) { const tab_text = dvui.themeGet().color(.window, .text); var ghost_close: dvui.ButtonWidget = undefined; ghost_close.init(@src(), .{ .draw_focus = false }, fizzy.dvui.windowHeaderCloseButtonOptions(.{ @@ -414,12 +403,12 @@ fn drawTabs(self: *Workspace) void { }); if (ghost_close.clicked()) { - fizzy.editor.closeFileID(file.id) catch |err| { + fizzy.editor.closeFileID(doc.id) catch |err| { dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) }); }; break; } - } else if (file.dirty()) { + } else if (doc.owner.isDirty(doc)) { dvui.icon(@src(), "dirty_icon", icons.tvg.lucide.@"circle-small", .{ .stroke_color = dvui.themeGet().color(.window, .text), }, .{ @@ -499,18 +488,18 @@ pub fn processTabsDrag(self: *Workspace) void { std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.fileAt(insert_before).?.editor.grouping = self.grouping; + fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before).?, self.grouping); fizzy.editor.setActiveFile(insert_before); } else { if (insert_before > 0) { std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]); std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]); - fizzy.editor.fileAt(insert_before - 1).?.editor.grouping = self.grouping; + fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before - 1).?, self.grouping); fizzy.editor.setActiveFile(insert_before - 1); } else { std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.fileAt(insert_before).?.editor.grouping = self.grouping; + fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before).?, self.grouping); fizzy.editor.setActiveFile(insert_before); } } @@ -528,13 +517,12 @@ pub fn processTabsDrag(self: *Workspace) void { /// Repoint `open_file_index` on workspaces that were showing the dragged tab as active. fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace, drag_index: usize) void { - const dragged_file = editor.fileAt(drag_index) orelse return; + const dragged_doc = editor.docAt(drag_index) orelse return; if (tab_bar_workspace) |workspace| { - if (workspace.open_file_index == editor.open_files.getIndex(dragged_file.id)) { + if (workspace.open_file_index == editor.open_files.getIndex(dragged_doc.id)) { for (editor.open_files.values()) |doc| { - const f = editor.fileFromDoc(doc); - if (f.editor.grouping == workspace.grouping and f.id != dragged_file.id) { - workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0; + if (editor.docGrouping(doc) == workspace.grouping and doc.id != dragged_doc.id) { + workspace.open_file_index = editor.open_files.getIndex(doc.id) orelse 0; break; } } @@ -543,9 +531,8 @@ fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace for (editor.workspaces.values()) |*w| { if (w.open_file_index == drag_index) { for (editor.open_files.values()) |doc| { - const f = editor.fileFromDoc(doc); - if (f.editor.grouping == w.grouping and f.id != dragged_file.id) { - w.open_file_index = editor.open_files.getIndex(f.id) orelse 0; + if (editor.docGrouping(doc) == w.grouping and doc.id != dragged_doc.id) { + w.open_file_index = editor.open_files.getIndex(doc.id) orelse 0; break; } } @@ -565,8 +552,8 @@ const WorkspaceTabDragSrc = union(enum) { if (w.tabs_drag_index) |i| return .{ .tab_bar = .{ .ws = w, .index = i } }; } if (editor.tab_drag_from_tree_path) |p| { - if (editor.getFileFromPath(p)) |f| { - const idx = editor.open_files.getIndex(f.id) orelse return .none; + if (editor.docFromPath(p)) |doc| { + const idx = editor.open_files.getIndex(doc.id) orelse return .none; return .{ .tree_open = idx }; } return .{ .tree_closed = p }; @@ -617,9 +604,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); - const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue; - dragged_file.editor.grouping = fizzy.editor.newGroupingID(); - fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; + const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue; + const new_g = fizzy.editor.newGroupingID(); + fizzy.editor.setDocGrouping(dragged_doc, new_g); + fizzy.editor.open_workspace_grouping = new_g; } } else if (data.rectScale().r.contains(e.evt.mouse.p)) { if (e.evt == .mouse and e.evt.mouse.action == .position) { @@ -636,10 +624,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); - const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue; - dragged_file.editor.grouping = self.grouping; - fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; - self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0; + const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue; + fizzy.editor.setDocGrouping(dragged_doc, self.grouping); + fizzy.editor.open_workspace_grouping = self.grouping; + self.open_file_index = fizzy.editor.open_files.getIndex(dragged_doc.id) orelse 0; } } }, @@ -662,9 +650,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); - const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue; - dragged_file.editor.grouping = fizzy.editor.newGroupingID(); - fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; + const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue; + const new_g = fizzy.editor.newGroupingID(); + fizzy.editor.setDocGrouping(dragged_doc, new_g); + fizzy.editor.open_workspace_grouping = new_g; } } else if (data.rectScale().r.contains(e.evt.mouse.p)) { if (e.evt == .mouse and e.evt.mouse.action == .position) { @@ -680,10 +669,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); - const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue; - dragged_file.editor.grouping = self.grouping; - fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; - self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0; + const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue; + fizzy.editor.setDocGrouping(dragged_doc, self.grouping); + fizzy.editor.open_workspace_grouping = self.grouping; + self.open_file_index = fizzy.editor.open_files.getIndex(dragged_doc.id) orelse 0; } } }, @@ -779,17 +768,9 @@ pub fn drawCanvas(self: *Workspace) !void { self.open_file_index = fizzy.editor.open_files.values().len - 1; } - if (fizzy.editor.fileAt(self.open_file_index)) |file| { - // The workbench owns only the content region (this container) + tab/split frame; - // bind it to the document and route the entire in-region render to the owning - // plugin (pixel art draws its rulers, overlays, and editing widget itself). - file.editor.canvas.id = canvas_vbox.data().id; - file.editor.workspace_handle = self; - file.editor.center = self.center; - - if (fizzy.editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { - _ = try plugin.drawDocument(.{ .ptr = file, .owner = plugin, .id = file.id }); - } + if (fizzy.editor.docAt(self.open_file_index)) |doc| { + fizzy.editor.bindDocToPane(doc, canvas_vbox.data().id, self, self.center); + _ = try doc.owner.drawDocument(doc); } } else { var box = workspaceEmptyStateCard(content_color, self.grouping); diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index 9b55ab9a..b670a99c 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -796,22 +796,16 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg editableLabel( inner_id_extra.*, if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", fizzy.editor.folder.?, abs_path) catch entry.name else entry.name, - if (fizzy.editor.getFileFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), + if (fizzy.editor.docFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), entry.kind, abs_path, ) catch { dvui.log.err("Failed to draw editable label", .{}); }; - if (fizzy.editor.getFileFromPath(abs_path)) |file| { - // Save spinner takes priority over the dirty dot: while a file is - // mid-save it's no longer "dirty waiting to be saved", it's "saving - // right now", and the user needs that distinction at a glance when - // multiple files are flushing in parallel. `isSaving` reads via an - // atomic load so the background `saveZip` worker can flip the flag - // safely from another thread. - const save_flash_elapsed = file.timeSinceSaveComplete(); - if (file.showsSaveStatusIndicator()) { + if (fizzy.editor.docFromPath(abs_path)) |doc| { + const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc); + if (doc.owner.showsSaveStatusIndicator(doc)) { fizzy.dvui.bubbleSpinner(@src(), .{ .id_extra = inner_id_extra.* +% 4001, .expand = .none, @@ -822,7 +816,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg }, .{ .complete_elapsed_ns = save_flash_elapsed, }); - } else if (file.dirty()) { + } else if (doc.owner.isDirty(doc)) { _ = dvui.icon( @src(), "DirtyIcon", @@ -1236,9 +1230,8 @@ pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.m return false; }; - if (fizzy.editor.getFileFromPath(source_path)) |file| { - fizzy.app.allocator.free(file.path); - file.path = fizzy.app.allocator.dupe(u8, new_path) catch { + if (fizzy.editor.docFromPath(source_path)) |doc| { + doc.owner.setDocumentPath(doc, new_path) catch { dvui.log.err("Failed to duplicate path: {s}", .{new_path}); return error.FailedToDuplicatePath; }; @@ -1260,20 +1253,21 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); for (fizzy.editor.open_files.values()) |doc| { - const file = fizzy.editor.fileFromDoc(doc); - if (std.mem.containsAtLeast(u8, file.path, 1, full_path)) { - const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(file.path)) catch "Failed to duplicate path"; - fizzy.app.allocator.free(file.path); - file.path = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name }); + const path = fizzy.editor.docPath(doc); + if (std.mem.containsAtLeast(u8, path, 1, full_path)) { + const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(path)) catch "Failed to duplicate path"; + const new_full = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name }); + doc.owner.setDocumentPath(doc, new_full) catch { + dvui.log.err("Failed to update open document path", .{}); + }; } } }, .file => { std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); - if (fizzy.editor.getFileFromPath(full_path)) |file| { - fizzy.app.allocator.free(file.path); - file.path = fizzy.app.allocator.dupe(u8, new_path) catch { + if (fizzy.editor.docFromPath(full_path)) |doc| { + doc.owner.setDocumentPath(doc, new_path) catch { dvui.log.err("Failed to duplicate path: {s}", .{new_path}); return error.FailedToDuplicatePath; }; diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index c918af2f..95d29605 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -57,6 +57,19 @@ pub const VTable = struct { /// Drop the registry entry after `closeDocument` has torn down resources. unregisterDocument: ?*const fn (state: *anyopaque, id: u64) void = null, + /// Bind a document to a workbench pane before `drawDocument` (canvas id, workspace handle, center flag). + bindDocumentToPane: ?*const fn (state: *anyopaque, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void = null, + documentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle) u64 = null, + setDocumentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle, grouping: u64) void = null, + documentPath: ?*const fn (state: *anyopaque, doc: DocHandle) []const u8 = null, + setDocumentPath: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void = null, + documentHasNativeExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + showsSaveStatusIndicator: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + isDocumentSaving: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + shouldConfirmFlatRasterSave: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + saveDocumentAsync: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + timeSinceSaveCompleteNs: ?*const fn (state: *anyopaque, doc: DocHandle) ?i128 = null, + // ---- render hooks (the plugin draws its own dvui UI into the host window) ---- /// Draw the plugin's explorer/sidebar pane (left region). drawExplorerPane: ?*const fn (state: *anyopaque) anyerror!void = null, @@ -165,6 +178,50 @@ pub fn reloadProjectFolder(self: Plugin, allocator: std.mem.Allocator) void { if (self.vtable.reloadProjectFolder) |f| f(self.state, allocator); } +pub fn bindDocumentToPane(self: Plugin, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void { + if (self.vtable.bindDocumentToPane) |f| f(self.state, doc, canvas_id, workspace_handle, center); +} + +pub fn documentGrouping(self: Plugin, doc: DocHandle) u64 { + return if (self.vtable.documentGrouping) |f| f(self.state, doc) else 0; +} + +pub fn setDocumentGrouping(self: Plugin, doc: DocHandle, grouping: u64) void { + if (self.vtable.setDocumentGrouping) |f| f(self.state, doc, grouping); +} + +pub fn documentPath(self: Plugin, doc: DocHandle) []const u8 { + return if (self.vtable.documentPath) |f| f(self.state, doc) else ""; +} + +pub fn setDocumentPath(self: Plugin, doc: DocHandle, path: []const u8) !void { + if (self.vtable.setDocumentPath) |f| try f(self.state, doc, path); +} + +pub fn documentHasNativeExtension(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.documentHasNativeExtension) |f| f(self.state, doc) else false; +} + +pub fn showsSaveStatusIndicator(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.showsSaveStatusIndicator) |f| f(self.state, doc) else false; +} + +pub fn isDocumentSaving(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.isDocumentSaving) |f| f(self.state, doc) else false; +} + +pub fn shouldConfirmFlatRasterSave(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.shouldConfirmFlatRasterSave) |f| f(self.state, doc) else false; +} + +pub fn saveDocumentAsync(self: Plugin, doc: DocHandle) !void { + if (self.vtable.saveDocumentAsync) |f| try f(self.state, doc); +} + +pub fn timeSinceSaveCompleteNs(self: Plugin, doc: DocHandle) ?i128 { + return if (self.vtable.timeSinceSaveCompleteNs) |f| f(self.state, doc) else null; +} + // ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ---- /// Load `path` into the shell-owned buffer at `out_doc`. Returns whether the plugin From 09d2a9d2b2cd60a2bc798472cb5ed3051044f682 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 07:52:10 -0500 Subject: [PATCH 25/49] Phase 4 post stage e --- HANDOFF.md | 68 ++- src/editor/Editor.zig | 519 +++++++------------- src/editor/Infobar.zig | 60 +-- src/editor/Keybinds.zig | 2 +- src/editor/Menu.zig | 38 +- src/editor/WebFileIo.zig | 19 +- src/editor/dialogs/UnsavedClose.zig | 3 +- src/editor/panel/Panel.zig | 2 - src/plugins/pixelart/src/doc_bridge.zig | 15 + src/plugins/pixelart/src/doc_lifecycle.zig | 161 ++++++ src/plugins/pixelart/src/infobar_status.zig | 83 ++++ src/plugins/pixelart/src/plugin.zig | 153 ++++++ src/plugins/workbench/src/FileLoadJob.zig | 59 +-- src/sdk/Plugin.zig | 138 ++++++ 14 files changed, 810 insertions(+), 510 deletions(-) create mode 100644 src/plugins/pixelart/src/doc_lifecycle.zig create mode 100644 src/plugins/pixelart/src/infobar_status.zig diff --git a/HANDOFF.md b/HANDOFF.md index 919529cd..7af1f424 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -14,10 +14,18 @@ lift, sprites bottom-panel lift. **In progress:** **Stage D (substantially complete)** — module scaffold, `Globals` injection, Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart")` wired. -**Next:** Stage E — trim `fizzy.zig` re-exports; route copy/paste/pack through plugin vtable. +**Stage E — polish complete** (see "Stage E polish — DONE" below): shell no longer imports +`pixelart.internal`; `pixelart_state` field access fully routed to lifecycle + vtable; +`Plugin.beginFrame` hook removes the last shell→`pixelart.render` poke; dead imports pruned. +**Sprite/atlas → `core` big rock: DONE** (verified — generic atlas type + sprite-draw +primitive + sprite-id index all in `core`; neither shell nor plugin reaches the other's atlas). -> **Read this first if you're a fresh agent:** start at "Stage D — remaining work" -> below. All three build configs are green right now. +**Next:** the only remaining shell→plugin concrete reaches are `pixelart.dialogs.*` + +`pixelart.explorer.project` — needs a generic dialog-registry vtable to lift (deferred). +Then: wire `b.addModule("workbench", …)` + lift workbench off `fizzy.editor` (logo atlas draw). + +> **Read this first if you're a fresh agent:** Stage D/E are done bar the dialog-registry +> lift. All three build configs are green right now. All three build configs are green: @@ -266,25 +274,51 @@ app code until the build module is fully wired. - **Transform + doc registry** — `transform_op.zig` + `docs_registry.zig`; vtable hooks (`transform`, `registerOpenDocument`, `documentPtr`, `documentByPath`, `unregisterDocument`). Shell `fileFromDoc` / `insertOpenDoc` / `fileById` route through `doc.owner`; no direct `pixelart_state.docs` access in `Editor.zig`. - **`fizzy.pixelart` global removed** — single ownership on `Editor.pixelart_state` + `Globals.state`; `App.zig` alloc/deinit via `fizzy.editor.pixelart_state` only. - **DocHandle at workbench boundary** — `doc_bridge.zig` + plugin vtable metadata hooks (`bindDocumentToPane`, `documentGrouping`, `documentPath`, `setDocumentPath`, save/dirty indicators, …). `Workspace.zig` + `files.zig` use `DocHandle` + `doc.owner` only (no `Internal.File`). Shell helpers `docFromPath`, `docPath`, `setDocGrouping`, `bindDocToPane`; `fileFromDoc`/`fileById` are shell-internal. - -**Still remaining:** -- Shell `Editor` still types `*Internal.File` in internal save/new-file paths (`newFile`, `openFileFromBytes`, save queue). -- `FileLoadJob` staging buffer still uses `Internal.File` (loader contract). -- Menu/Infobar still use `activeFile()` for pixel-art-specific UI (undo stacks, save enabled). +- **Menu/Infobar off `activeFile()`** — `Menu.zig` + `Infobar.zig` route through `activeDoc()` + plugin hooks (`canUndo`/`canRedo`, `documentHasRecognizedSaveExtension`, `drawDocumentInfobar`). Active-doc infobar UI moved to `pixelart/src/infobar_status.zig`. Shell save/keybind paths (`save`, `saveAll`, quit-save-all, `UnsavedClose`) use `DocHandle` + owner hooks. +- **Shell `Internal.File` removed** — `Editor.zig` no longer types `*Internal.File` (removed `activeFile`, `fileFromDoc`, `fileById`, `getFile`, …). Document create/load/save-as routed through plugin vtable + `doc_lifecycle.zig` (`createDocument`, `saveDocumentAs`, `documentDefaultSaveAsFilename`, frame ticks, accept/cancel/delete). `insertOpenDoc` takes `*anyopaque` + id; `newFile` returns `DocHandle`; `openFileFromBytes` returns doc id. `FileLoadJob` uses opaque staging buffer via `Plugin.allocDocumentBuffer`. Save-queue worker owned by plugin (`initPlugin`/`deinit`). + +**Stage E polish — DONE:** +- ✅ Removed dead `Editor.closeReference` (referenced a non-existent `open_references` + field + `Internal.Reference` type; survived only via Zig lazy analysis). With it gone, + the `const Internal = pixelart.internal;` import is dropped — **shell no longer imports + `pixelart.internal` at all.** +- ✅ `editor.pixelart_state` direct field access already routed away: `pixelart_state` + now appears only as the `Editor` field declaration + `App.zig` lifecycle + (create/init/persist/deinit/destroy). No shell member access remains. +- ✅ **`Plugin.beginFrame` vtable hook** — shell no longer pokes `pixelart.render.frame_index` + directly. `Editor.frame` now calls `plugin.beginFrame()` for every registered plugin; the + pixel-art impl advances its own composite-cache frame clock. **No `pixelart.render` in shell.** +- ✅ Removed dead `pixelart`/`Packer` imports from `editor/panel/Panel.zig`. + +**Shell → plugin surface now (grep `pixelart\.X` in `src/editor` + `src/plugins/workbench`):** +`pixelart.plugin` ×15 (the vtable boundary — intended), `pixelart.dialogs` ×6, +`pixelart.State` ×2, `pixelart.Globals` ×2, `pixelart.explorer` ×1, +`"pixelart.menu.edit"` ×1 (a registered-menu **id string**, not a symbol ref). +The remaining real reaches are `pixelart.dialogs.*` (NewFile/Export/GridLayout/ +FlatRasterSaveWarning/DimensionsLabel re-exported by `editor/dialogs/Dialogs.zig` + +`UnsavedClose.zig`) and `pixelart.explorer.project` — concrete pixel-art UI the shell still +constructs directly. Lifting these needs a generic dialog-registry vtable; deferred, not blocking. --- -## Next big rock: sprite / atlas → `core` (parallel track) +## Next big rock: sprite / atlas → `core` — DONE -Resolves `editor.atlas` coupling and the shell reaching into the plugin for UI icons. +End-state achieved. Verified this session: -- Shell only needs a static atlas sprite draw (logo/icons) — `workbench/files.zig`, - `workbench/Workspace.zig`. -- **`core`:** generic atlas type + "draw sprite N" primitive. -- **Plugin:** `renderSprite`, composites, reflections, `water_surface`. -- End-state: **shell → core**, **plugin → core**, neither depends on the other. +- **`core.Atlas`** (`src/core/Atlas.zig`) — generic atlas type, `loadSpritesFromBytes`. +- **`core.atlas`** (`src/core/generated/atlas.zig`) — generated sprite-id index + (`sprites.logo_default`, …). `fizzy.atlas = core.atlas`. +- **`core.Sprite.draw`** — the "draw sprite N" primitive. +- **Shell** holds its own static atlas instance (`editor.atlas`, loaded via + `core.Atlas.loadSpritesFromBytes`) for logo/icons and exposes it to plugins as + `EditorAPI.UiSprite`. Draws via `core.Sprite.draw`. +- **Plugin** consumes `core.Atlas`/`core.Sprite` for its own rendering (composites, + reflections, `water_surface`) and builds its own packed `internal/Atlas.zig` at pack time. +- **Neither side reaches the other's atlas** — `grep 'editor.atlas|fizzy.atlas' src/plugins/pixelart/src` → 0. -(User signed off; sequenced after settings, can proceed alongside late Stage D.) +Residual: `workbench/files.zig` + `workbench/Workspace.zig` draw the logo via +`fizzy.editor.atlas` — that's the workbench plugin still routing through `fizzy.editor` +(a separate "workbench off the app hub" concern), not a sprite/atlas-in-core gap. --- @@ -313,7 +347,7 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl | `src/plugins/workbench/workbench.zig` | Workbench intra-plugin hub | | `src/plugins/workbench/src/` | Workbench implementation tree | | `src/sdk/EditorAPI.zig`, `Host.zig` | Full shell API surface | -| `src/editor/Editor.zig` | Shell; still uses `fizzy.pixelart.*` and `Internal.File` helpers | +| `src/editor/Editor.zig` | Shell; `DocHandle`-only at UI boundary; no `Internal.File` | | `src/fizzy.zig` | App hub; mid-migration to `pixelart_mod` re-exports | | `process_assets.zig` | Build-time asset atlas generator (repo root, beside `build.zig`) | | `src/backend/` | Platform backend: native/web stubs, singleton, auto-update, objc, MSVC shim | diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 5c8e1435..eece5348 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -15,7 +15,6 @@ const plus_jakarta_sans_bold_ttf = assets.files.fonts.@"PlusJakartaSans-Bold.ttf const fizzy = @import("../fizzy.zig"); const pixelart = @import("pixelart"); -const Internal = pixelart.internal; const dvui = @import("dvui"); const update_notify = @import("../backend/update_notify.zig"); @@ -84,8 +83,8 @@ themes: std.ArrayList(dvui.Theme) = .empty, open_files: std.AutoArrayHashMapUnmanaged(u64, sdk.DocHandle) = .empty, -/// Background file-load jobs in flight. Keyed by absolute path. Each job's worker thread runs -/// `Internal.File.fromPath` off the main thread; the main thread polls via `processLoadingJobs` +/// Background file-load jobs in flight. Keyed by absolute path. Each job's worker thread loads +/// the document bytes off the main thread; the main thread polls via `processLoadingJobs` /// and moves completed results into `open_files`. The map owns its key strings via each job's /// `path` allocation; the StringHashMap stores key slices that point into job memory. loading_jobs: std.StringHashMapUnmanaged(*FileLoadJob) = .empty, @@ -270,10 +269,7 @@ pub fn init( Settings.loadPluginStore(app.allocator, settings_path, &editor.host.plugin_settings); } - // Start the long-lived save-queue worker. All .fiz async saves get - // serialized through this single thread (see `File.SaveQueue`); concurrent - // worker spawns were causing one save to wedge under contention. - try Internal.File.initSaveQueue(); + // Save-queue worker is owned by the pixel-art plugin (`initPlugin` in `postInit`). { // Setup themes var fizzy_dark = dvui.themeGet(); @@ -482,6 +478,7 @@ pub fn postInit(editor: *Editor) !void { try @import("../plugins/workbench/src/plugin.zig").register(&editor.host); const pixelart_plugin = pixelart.plugin; try pixelart_plugin.register(&editor.host); + try pixelart_plugin.pluginPtr().initPlugin(); // Shell built-in: Settings (owner = null; not a plugin). try editor.host.registerSidebarView(.{ @@ -694,15 +691,7 @@ fn shellAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 { return shellCtx(ctx).allocNextUntitledPath(); } fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) anyerror!sdk.DocHandle { - const editor = shellCtx(ctx); - const file = try editor.newFile(path, .{ - .columns = grid.columns, - .rows = grid.rows, - .column_width = grid.column_width, - .row_height = grid.row_height, - }); - const owner = pixelart.plugin.pluginPtr(); - return .{ .ptr = file, .owner = owner, .id = file.id }; + return shellCtx(ctx).newFile(path, grid); } fn shellSetExplorerNewFilePath(ctx: *anyopaque, path: []const u8) anyerror!void { const Files = fizzy.Explorer.files; @@ -747,19 +736,15 @@ fn shellIsPackingActive(ctx: *anyopaque) bool { return shellCtx(ctx).isPackingActive(); } -/// Resolve a shell `DocHandle` to the plugin-owned file (shell-internal; workbench uses `DocHandle` + owner hooks). -fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File { - _ = editor; - return @ptrCast(@alignCast(doc.owner.documentPtr(doc.id).?)); -} - -/// Resolve an open document id to the plugin-owned file (shell-internal). -fn fileById(editor: *Editor, id: u64) ?*Internal.File { - const doc = editor.docById(id) orelse return null; - const ptr = doc.owner.documentPtr(doc.id) orelse return null; - return @ptrCast(@alignCast(ptr)); +/// Store a loaded/created document in the plugin registry and register its handle. +pub fn insertOpenDoc(editor: *Editor, doc_buf: *anyopaque, owner: *sdk.Plugin, id: u64) !void { + const ptr = try owner.registerOpenDocument(doc_buf); + try editor.open_files.put(fizzy.app.allocator, id, .{ + .ptr = ptr, + .owner = owner, + .id = id, + }); } - pub fn docAt(editor: *Editor, index: usize) ?sdk.DocHandle { if (index >= editor.open_files.values().len) return null; return editor.open_files.values()[index]; @@ -800,18 +785,6 @@ pub fn bindDocToPane(_: *Editor, doc: sdk.DocHandle, canvas_id: dvui.Id, workspa doc.owner.bindDocumentToPane(doc, canvas_id, workspace, center); } -/// Store a loaded/created document in the plugin registry and register its handle. -pub fn insertOpenDoc(editor: *Editor, file: Internal.File, owner: *sdk.Plugin) !void { - var file_mut = file; - const ptr = try owner.registerOpenDocument(&file_mut); - try editor.open_files.put(fizzy.app.allocator, file.id, .{ - // `ptr` is a hint only; consumers must resolve via `fileFromDoc` / `doc.id`. - .ptr = ptr, - .owner = owner, - .id = file.id, - }); -} - /// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void { const themes_dir = try std.fs.path.join(gpa, &.{ editor.config_folder, "Themes" }); @@ -938,8 +911,8 @@ pub fn markSettingsDirty(editor: *Editor) void { } fn activelyDrawing(editor: *Editor) bool { - for (editor.open_files.values()) |doc| { - if (editor.fileFromDoc(doc).editor.active_drawing) return true; + for (editor.host.plugins.items) |plugin| { + if (plugin.isAnyDocumentActivelyDrawing()) return true; } return false; } @@ -1033,10 +1006,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Drain any "Save and Close" requests whose async save has settled. editor.tickPendingSaveCloses(); var needs_save_status_anim_tick = false; - for (editor.open_files.values()) |doc| { - const f = editor.fileFromDoc(doc); - f.tickSaveDoneFlash(); - if (f.showsSaveStatusIndicator()) needs_save_status_anim_tick = true; + for (editor.host.plugins.items) |plugin| { + if (plugin.tickOpenDocuments()) needs_save_status_anim_tick = true; } // Re-poll the quit walker while saves are in flight on worker threads. if (editor.quit_saves_in_flight.count() > 0) editor.pending_quit_continue = true; @@ -1060,7 +1031,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { var dirty_n: usize = 0; for (editor.open_files.values()) |doc| { - if (editor.fileFromDoc(doc).dirty()) dirty_n += 1; + if (doc.owner.isDirty(doc)) dirty_n += 1; } if (dirty_n == 0) continue; @@ -1078,7 +1049,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.setTitlebarColor(); editor.setWindowStyle(); - pixelart.render.frame_index +%= 1; + for (editor.host.plugins.items) |plugin| plugin.beginFrame(); if (fizzy.perf.record) fizzy.perf.beginFrame(); defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog(); @@ -1098,31 +1069,14 @@ pub fn tick(editor: *Editor) !dvui.App.Result { if (editor.pending_composite_warmup) { editor.pending_composite_warmup = false; - if (editor.activeFile()) |file| { - const w = file.width(); - const h = file.height(); - if (w > 0 and h > 0) { - const area = @as(u64, w) * @as(u64, h); - // Skip tiny canvases; large docs benefit most from moving split-target work off the first stroke. - if (area >= 512 * 512) { - pixelart.render.warmupDrawingComposites(file) catch |err| { - dvui.log.err("Composite warmup failed: {any}", .{err}); - }; - } - } - } + for (editor.host.plugins.items) |plugin| plugin.warmupActiveDocumentComposites(); } { var any_drawing = false; - fizzy.perf.draw_stroke_buf_count = 0; // no active stroke → 0; else first active file's map size - for (editor.open_files.values()) |doc| { - const file = editor.fileFromDoc(doc); - if (file.editor.active_drawing) { - any_drawing = true; - fizzy.perf.draw_stroke_buf_count = file.buffers.stroke.pixels.count(); - break; - } + fizzy.perf.draw_stroke_buf_count = 0; + for (editor.host.plugins.items) |plugin| { + if (plugin.isAnyDocumentActivelyDrawing()) any_drawing = true; } fizzy.perf.drawFrameBegin(any_drawing); } @@ -1309,38 +1263,13 @@ pub fn tick(editor: *Editor) !dvui.App.Result { ); defer base_box.deinit(); - // Advance the animation frame if we are in play mode - if (editor.activeFile()) |file| { - if (file.editor.playing) { - if (file.selected_animation_index) |index| { - const animation = file.animations.get(index); - - if (animation.frames.len > 0) { - if (dvui.timerDoneOrNone(base_box.data().id)) { - if (file.selected_animation_frame_index >= animation.frames.len - 1) { - file.selected_animation_frame_index = 0; - } else { - file.selected_animation_frame_index += 1; - } - const millis_per_frame = animation.frames[file.selected_animation_frame_index].ms; - - dvui.timer(base_box.data().id, @intCast(millis_per_frame * 1000)); - } - } - } - } + for (editor.host.plugins.items) |plugin| { + plugin.tickActiveDocumentPlayback(base_box.data().id); } // Always reset the peek layer index back, but we need to do this outside of the file widget so // other editor windows can use it - defer for (editor.open_files.values()) |doc| { - const file = editor.fileFromDoc(doc); - if (file.editor.isolate_layer) { - file.peek_layer_index = file.selected_layer_index; - } else { - file.peek_layer_index = null; - } - }; + defer for (editor.host.plugins.items) |plugin| plugin.resetDocumentPeekLayers(); // Sidebar area // Since sidebar is drawn before the explorer, and we want to allow expanding the explorer @@ -1482,7 +1411,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { defer editor.panel.paned.deinit(); if (!editor.panel.paned.dragging) { - if (editor.activeFile()) |_| { + if (editor.activeDoc() != null) { if ((editor.panel.paned.split_ratio.* == 1.0 and !editor.panel.paned.collapsed()) and fizzy.editor.settings.panel_ratio > 0.0) { editor.panel.paned.animateSplit(1.0 - fizzy.editor.settings.panel_ratio, dvui.easing.outQuint); } @@ -1653,42 +1582,42 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA editor.requestSaveAs(); }, .copy => { - if (editor.activeFile() != null) { + if (editor.activeDoc() != null) { editor.copy() catch { std.log.err("Failed to copy", .{}); }; } }, .paste => { - if (editor.activeFile() != null) { + if (editor.activeDoc() != null) { editor.paste() catch { std.log.err("Failed to paste", .{}); }; } }, .undo => { - if (editor.activeFile()) |file| { - file.history.undoRedo(file, .undo) catch { + if (editor.activeDoc()) |doc| { + doc.owner.undo(doc) catch { std.log.err("Failed to undo", .{}); }; } }, .redo => { - if (editor.activeFile()) |file| { - file.history.undoRedo(file, .redo) catch { + if (editor.activeDoc()) |doc| { + doc.owner.redo(doc) catch { std.log.err("Failed to redo", .{}); }; } }, .transform => { - if (editor.activeFile() != null) { + if (editor.activeDoc() != null) { editor.transform() catch { std.log.err("Failed to transform", .{}); }; } }, .grid_layout => { - if (editor.activeFile() != null) { + if (editor.activeDoc() != null) { editor.requestGridLayoutDialog(); } }, @@ -1734,17 +1663,16 @@ pub fn rebuildWorkspaces(editor: *Editor) !void { // Create workspaces for each grouping ID for (editor.open_files.values()) |doc| { - const file = editor.fileFromDoc(doc); - if (!editor.workspaces.contains(file.editor.grouping)) { - var workspace: fizzy.Editor.Workspace = .init(file.editor.grouping); + const grouping = editor.docGrouping(doc); + if (!editor.workspaces.contains(grouping)) { + var workspace: fizzy.Editor.Workspace = .init(grouping); for (editor.open_files.values()) |d| { - const f = editor.fileFromDoc(d); - if (f.editor.grouping == file.editor.grouping) { - workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0; + if (editor.docGrouping(d) == grouping) { + workspace.open_file_index = editor.open_files.getIndex(d.id) orelse 0; } } - editor.workspaces.put(fizzy.app.allocator, file.editor.grouping, workspace) catch |err| { + editor.workspaces.put(fizzy.app.allocator, grouping, workspace) catch |err| { std.log.err("Failed to create workspace: {s}", .{@errorName(err)}); return err; }; @@ -1759,8 +1687,7 @@ pub fn rebuildWorkspaces(editor: *Editor) !void { var contains: bool = false; for (editor.open_files.values()) |doc| { - const file = editor.fileFromDoc(doc); - if (file.editor.grouping == workspace.grouping) { + if (editor.docGrouping(doc) == workspace.grouping) { contains = true; break; } @@ -1784,8 +1711,8 @@ pub fn rebuildWorkspaces(editor: *Editor) !void { // Ensure the selected file for each workspace is still valid for (editor.workspaces.values()) |*workspace| { - if (editor.getFile(workspace.open_file_index)) |file| { - if (file.editor.grouping == workspace.grouping) { + if (editor.docAt(workspace.open_file_index)) |doc| { + if (editor.docGrouping(doc) == workspace.grouping) { continue; } } @@ -1793,9 +1720,8 @@ pub fn rebuildWorkspaces(editor: *Editor) !void { var i: usize = editor.open_files.count(); while (i > 0) { i -= 1; - - if (editor.getFile(i)) |file| { - if (file.editor.grouping == workspace.grouping) { + if (editor.docAt(i)) |d| { + if (editor.docGrouping(d) == workspace.grouping) { workspace.open_file_index = i; break; } @@ -1887,8 +1813,8 @@ fn tickPendingSaveCloses(editor: *Editor) void { var i: usize = 0; while (i < editor.pending_close_after_save.count()) { const id = editor.pending_close_after_save.keys()[i]; - if (editor.fileById(id)) |f| { - if (f.isSaving()) { + if (editor.docById(id)) |doc| { + if (doc.owner.isDocumentSaving(doc)) { i += 1; continue; } @@ -1917,16 +1843,16 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { // Pass 1: kick off any queued saves we haven't started yet. while (editor.quit_save_all_ids.items.len > 0) { const id = editor.quit_save_all_ids.items[0]; - const file_ptr = editor.fileById(id) orelse { + const doc = editor.docById(id) orelse { _ = editor.quit_save_all_ids.swapRemove(0); continue; }; - if (!file_ptr.dirty()) { + if (!doc.owner.isDirty(doc)) { _ = editor.quit_save_all_ids.swapRemove(0); continue; } - if (!Internal.File.hasRecognizedSaveExtension(file_ptr.path)) { + if (!doc.owner.documentHasRecognizedSaveExtension(doc)) { // Save As dialog needs a single active file — bail out of the parallel // kickoff for this one and let the existing Save As + pending_close_file_id // flow handle it. Next frame, pending_quit_continue will re-enter us. @@ -1936,7 +1862,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { editor.requestSaveAs(); return; } - if (file_ptr.shouldConfirmFlatRasterSave()) { + if (doc.owner.shouldConfirmFlatRasterSave(doc)) { // Flat-raster prompt is a modal dialog — same reason as Save As, do // it serially and rejoin afterwards. if (editor.open_files.getIndex(id)) |idx| editor.setActiveFile(idx); @@ -1946,7 +1872,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { } // Async-safe path: kick off, move to in-flight, drop from queue. - file_ptr.saveAsync() catch |err| { + doc.owner.saveDocumentAsync(doc) catch |err| { dvui.log.err("Save all quit kickoff: {s}", .{@errorName(err)}); editor.abortSaveAllQuit(); return; @@ -1966,8 +1892,8 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { var i: usize = 0; while (i < editor.quit_saves_in_flight.count()) { const id = editor.quit_saves_in_flight.keys()[i]; - if (editor.fileById(id)) |f| { - if (f.isSaving()) { + if (editor.docById(id)) |doc| { + if (doc.owner.isDocumentSaving(doc)) { i += 1; continue; } @@ -1998,7 +1924,7 @@ pub fn close(app: *App, editor: *Editor) void { } var dirty_n: usize = 0; for (editor.open_files.values()) |doc| { - if (editor.fileFromDoc(doc).dirty()) dirty_n += 1; + if (doc.owner.isDirty(doc)) dirty_n += 1; } if (dirty_n > 0) { Dialogs.AppQuitUnsaved.request(); @@ -2023,7 +1949,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { pub fn saving(editor: *Editor) bool { for (editor.open_files.values()) |doc| { - if (editor.fileFromDoc(doc).saving) return true; + if (doc.owner.isDocumentSaving(doc)) return true; } return false; } @@ -2064,8 +1990,7 @@ pub fn clearFileTreeTabDragDropState(editor: *Editor) void { pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { // Already open? Just focus it. for (editor.open_files.values(), 0..) |doc, i| { - const file = editor.fileFromDoc(doc); - if (std.mem.eql(u8, file.path, path)) { + if (std.mem.eql(u8, editor.docPath(doc), path)) { editor.setActiveFile(i); return false; } @@ -2111,17 +2036,14 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { return true; } -/// Synchronous open from browser file-picker bytes. Caller owns `path` on success (stored in `File.path`). -pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !Internal.File { - for (editor.open_files.values()) |doc| { - const file = editor.fileFromDoc(doc); - if (std.mem.eql(u8, file.path, path)) { - if (editor.open_files.getIndex(file.id)) |idx| { - editor.setActiveFile(idx); - } - fizzy.app.allocator.free(path); - return error.AlreadyOpen; +/// Synchronous open from browser file-picker bytes. Registers the document and returns its id. +pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !u64 { + if (editor.docFromPath(path)) |existing| { + if (editor.open_files.getIndex(existing.id)) |idx| { + editor.setActiveFile(idx); } + fizzy.app.allocator.free(path); + return error.AlreadyOpen; } const owner = editor.host.pluginForExtension(std.fs.path.extension(path)) orelse { @@ -2129,8 +2051,10 @@ pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, groupin return error.InvalidExtension; }; - var file: Internal.File = undefined; - const handled = owner.loadDocumentFromBytes(path, bytes, &file) catch |err| { + const staging = try owner.allocDocumentBuffer(fizzy.app.allocator); + defer fizzy.app.allocator.free(staging.backing); + + const handled = owner.loadDocumentFromBytes(path, bytes, staging.buf.ptr) catch |err| { fizzy.app.allocator.free(path); return err; }; @@ -2138,8 +2062,11 @@ pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, groupin fizzy.app.allocator.free(path); return error.InvalidFile; } - file.editor.grouping = grouping; - return file; + + owner.setDocumentGroupingOnBuffer(staging.buf.ptr, grouping); + const id = owner.documentIdFromBuffer(staging.buf.ptr); + try editor.insertOpenDoc(staging.buf.ptr, owner, id); + return id; } /// Per-frame sweep called from `tick`. Moves completed load jobs into `open_files`, cleans up @@ -2164,47 +2091,33 @@ pub fn processLoadingJobs(editor: *Editor) void { const phase = job.currentPhase(); switch (phase) { .ready => { - if (job.result) |result| { - var file = result; - file.editor.grouping = job.target_grouping; - - const owner = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse { - dvui.log.err("No plugin for loaded file: {s}", .{job.path}); - var f = file; - f.deinit(); - job.destroy(); - continue; - }; - - editor.insertOpenDoc(file, owner) catch { - dvui.log.err("Failed to insert loaded file into open_files: {s}", .{job.path}); - // We still own `file` here — clean it up. - var f = file; - f.deinit(); - job.destroy(); - continue; - }; + const owner = job.owner; + owner.setDocumentGroupingOnBuffer(job.doc_buf.ptr, job.target_grouping); + const id = owner.documentIdFromBuffer(job.doc_buf.ptr); + + editor.insertOpenDoc(job.doc_buf.ptr, owner, id) catch { + dvui.log.err("Failed to insert loaded file into open_files: {s}", .{job.path}); + owner.deinitDocumentBuffer(job.doc_buf.ptr); + job.destroy(); + continue; + }; - // Focus this file iff it's the most recently requested load. Multiple - // simultaneous loads only auto-focus the latest; others land silently. - const should_focus = editor.last_load_request_path != null and - std.mem.eql(u8, editor.last_load_request_path.?, job.path); - if (should_focus) { - if (editor.open_files.getIndex(file.id)) |idx| { - editor.setActiveFile(idx); - editor.last_load_request_path = null; - } - editor.pending_composite_warmup = true; + const should_focus = editor.last_load_request_path != null and + std.mem.eql(u8, editor.last_load_request_path.?, job.path); + if (should_focus) { + if (editor.open_files.getIndex(id)) |idx| { + editor.setActiveFile(idx); + editor.last_load_request_path = null; } - } else { - dvui.log.err("Load job reported ready but result was null: {s}", .{job.path}); + editor.pending_composite_warmup = true; } }, .failed => { dvui.log.err("Failed to open file: {s} ({any})", .{ job.path, job.err }); + job.owner.deinitDocumentBuffer(job.doc_buf.ptr); }, .cancelled => { - // No-op: result already discarded by the worker. + job.owner.deinitDocumentBuffer(job.doc_buf.ptr); }, else => { dvui.log.err("Load job finished in unexpected phase {s}: {s}", .{ @tagName(phase), job.path }); @@ -2423,29 +2336,34 @@ pub fn requestCompositeWarmup(editor: *Editor) void { editor.pending_composite_warmup = true; } -pub fn newFile(editor: *Editor, path: []const u8, options: Internal.File.InitOptions) !*Internal.File { - if (editor.getFileFromPath(path)) |_| { +pub fn newFile(editor: *Editor, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) !sdk.DocHandle { + if (editor.docFromPath(path) != null) { return error.FileAlreadyExists; } - const file = Internal.File.init(path, options) catch { + const owner = pixelart.plugin.pluginPtr(); + const staging = try owner.allocDocumentBuffer(fizzy.app.allocator); + defer fizzy.app.allocator.free(staging.backing); + + owner.createDocument(path, grid, staging.buf.ptr) catch { + owner.deinitDocumentBuffer(staging.buf.ptr); dvui.log.err("Failed to create file: {s}", .{path}); return error.FailedToCreateFile; }; - try editor.insertOpenDoc(file, pixelart.plugin.pluginPtr()); + const id = owner.documentIdFromBuffer(staging.buf.ptr); + try editor.insertOpenDoc(staging.buf.ptr, owner, id); editor.setActiveFile(editor.open_files.count() - 1); editor.pending_composite_warmup = true; - return editor.fileById(file.id) orelse return error.FailedToCreateFile; + return editor.docById(id) orelse return error.FailedToCreateFile; } /// Heap-owned path like `untitled-1`, unique among open-document basenames. pub fn allocNextUntitledPath(editor: *Editor) ![]u8 { var max_n: u32 = 0; for (editor.open_files.values()) |doc| { - const f = editor.fileFromDoc(doc); - const base = std.fs.path.basename(f.path); + const base = std.fs.path.basename(editor.docPath(doc)); if (std.mem.startsWith(u8, base, "untitled-")) { const suffix = base["untitled-".len..]; const n = std.fmt.parseUnsigned(u32, suffix, 10) catch continue; @@ -2463,9 +2381,8 @@ pub fn allocNextUntitledPath(editor: *Editor) ![]u8 { /// The dialog rebinds the active file via the `_grid_layout_file_id` data slot so the form and /// preview can survive frames where `fizzy.editor.activeFile()` momentarily returns null. pub fn requestGridLayoutDialog(editor: *Editor) void { - const file = editor.activeFile() orelse return; - - Dialogs.GridLayout.presetFromFile(file); + const doc = editor.activeDoc() orelse return; + doc.owner.prepareGridLayoutDialog(doc); var mutex = fizzy.dvui.dialog(@src(), .{ .displayFn = Dialogs.GridLayout.dialog, @@ -2478,7 +2395,7 @@ pub fn requestGridLayoutDialog(editor: *Editor) void { .header_kind = .info, .default = .ok, }); - dvui.dataSet(null, mutex.id, "_grid_layout_file_id", file.id); + dvui.dataSet(null, mutex.id, "_grid_layout_file_id", doc.id); // Let `GridLayout.windowFn` run `autoSize` only until the open animation finishes; otherwise // `auto_size` stays true every frame and the shell snaps back to content min (user resize breaks). dvui.dataSet(null, mutex.id, "_grid_dialog_open_done", false); @@ -2501,8 +2418,8 @@ pub fn requestNewFileDialog(_: *Editor) void { } pub fn setActiveFile(editor: *Editor, index: usize) void { - const file = editor.fileAt(index) orelse return; - const grouping = file.editor.grouping; + const doc = editor.docAt(index) orelse return; + const grouping = editor.docGrouping(doc); if (editor.workspaces.getPtr(grouping)) |workspace| { editor.open_workspace_grouping = grouping; @@ -2510,54 +2427,20 @@ pub fn setActiveFile(editor: *Editor, index: usize) void { } } -/// Returns the actively focused file, through workspace grouping. -pub fn activeFile(editor: *Editor) ?*Internal.File { - const doc = editor.activeDoc() orelse return null; - return editor.fileFromDoc(doc); -} - -pub fn getFile(editor: *Editor, index: usize) ?*Internal.File { - return editor.fileAt(index); -} - -pub fn fileAt(editor: *Editor, index: usize) ?*Internal.File { - const doc = editor.docAt(index) orelse return null; - return editor.fileFromDoc(doc); -} - -pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*Internal.File { - const doc = editor.docFromPath(path) orelse return null; - return editor.fileFromDoc(doc); -} - pub fn forceCloseFile(editor: *Editor, index: usize) !void { - if (editor.getFile(index) != null) { + if (editor.docAt(index) != null) { return editor.rawCloseFile(index); } } pub fn accept(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (file.editor.transform) |*t| { - t.accept(); - } - } + _ = editor; + pixelart.plugin.pluginPtr().acceptEdit(); } pub fn cancel(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (file.editor.transform) |*t| { - t.cancel(); - } - - if (file.editor.selected_sprites.count() > 0) { - file.clearSelectedSprites(); - } - - if (file.selected_animation_index != null) { - file.selected_animation_index = null; - } - } + _ = editor; + pixelart.plugin.pluginPtr().cancelEdit(); } pub fn copy(editor: *Editor) !void { @@ -2571,9 +2454,8 @@ pub fn paste(editor: *Editor) !void { } pub fn deleteSelectedContents(editor: *Editor) void { - if (editor.activeFile()) |file| { - file.deleteSelectedContents(); - } + _ = editor; + pixelart.plugin.pluginPtr().deleteSelection(); } /// Begins a transform operation on the currently active file. @@ -2585,29 +2467,27 @@ pub fn transform(editor: *Editor) !void { /// Performs a save operation on the currently open file. /// Paths without a recognized on-disk extension (e.g. in-memory `untitled-n`) open Save As instead. pub fn save(editor: *Editor) !void { - const file = editor.activeFile() orelse return; - if (!Internal.File.hasRecognizedSaveExtension(file.path)) { + const doc = editor.activeDoc() orelse return; + if (!doc.owner.documentHasRecognizedSaveExtension(doc)) { editor.requestSaveAs(); return; } - if (file.shouldConfirmFlatRasterSave()) { - Dialogs.FlatRasterSaveWarning.request(file.id, .editor_save); + if (doc.owner.shouldConfirmFlatRasterSave(doc)) { + Dialogs.FlatRasterSaveWarning.request(doc.id, .editor_save); return; } if (comptime builtin.target.cpu.arch == .wasm32) { editor.requestWebSaveDialog(.save); return; } - if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { - try plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id }); - } + try doc.owner.saveDocument(doc); } /// Browser: pick download filename/extension before encoding (`processPendingSaveAs`). pub fn requestWebSaveDialog(editor: *Editor, kind: Dialogs.WebSaveAs.Kind) void { if (comptime builtin.target.cpu.arch != .wasm32) return; - const file = editor.activeFile() orelse return; - Dialogs.WebSaveAs.request(std.fs.path.basename(file.path), kind); + const doc = editor.activeDoc() orelse return; + Dialogs.WebSaveAs.request(std.fs.path.basename(editor.docPath(doc)), kind); } /// Kick off an async save for every dirty file with a recognized extension. @@ -2617,13 +2497,11 @@ pub fn requestWebSaveDialog(editor: *Editor, kind: Dialogs.WebSaveAs.Kind) void /// Files that are already saving are also skipped (their `saveAsync` no-ops). pub fn saveAll(editor: *Editor) !void { for (editor.open_files.values()) |doc| { - const file = editor.fileFromDoc(doc); - if (!file.dirty()) continue; - if (!Internal.File.hasRecognizedSaveExtension(file.path)) continue; - if (file.shouldConfirmFlatRasterSave()) continue; - const plugin = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse continue; - plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id }) catch |err| { - dvui.log.err("Save All: file {s} failed: {s}", .{ file.path, @errorName(err) }); + if (!doc.owner.isDirty(doc)) continue; + if (!doc.owner.documentHasRecognizedSaveExtension(doc)) continue; + if (doc.owner.shouldConfirmFlatRasterSave(doc)) continue; + doc.owner.saveDocument(doc) catch |err| { + dvui.log.err("Save All: file {s} failed: {s}", .{ editor.docPath(doc), @errorName(err) }); }; } } @@ -2636,13 +2514,13 @@ const save_as_dialog_filters: [3]fizzy.backend.DialogFileFilter = .{ /// Opens a Save As dialog: `.fiz` (all layers; `.pixi` also accepted for legacy) or flat `.png` / `.jpg` / `.jpeg` (visible layers composited). pub fn requestSaveAs(_: *Editor) void { - const active = fizzy.editor.activeFile() orelse return; - const def = Internal.File.defaultSaveAsFilename(fizzy.app.allocator, active.path) catch { + const doc = fizzy.editor.activeDoc() orelse return; + const def = doc.owner.documentDefaultSaveAsFilename(doc, fizzy.app.allocator) catch { std.log.err("Failed to build default save-as name", .{}); return; }; defer fizzy.app.allocator.free(def); - const current_file_dir: ?[]const u8 = std.fs.path.dirname(active.path); + const current_file_dir: ?[]const u8 = std.fs.path.dirname(fizzy.editor.docPath(doc)); fizzy.backend.showSaveFileDialog(saveAsDialogCallback, &save_as_dialog_filters, def, current_file_dir); } @@ -2660,16 +2538,16 @@ pub fn cancelPendingSaveDialog(editor: *Editor) void { } } - const file_id = editor.pending_close_file_id orelse if (editor.activeFile()) |f| f.id else null; + const file_id = editor.pending_close_file_id orelse if (editor.activeDoc()) |doc| doc.id else null; editor.pending_close_file_id = null; if (file_id) |id| { _ = editor.pending_close_after_save.swapRemove(id); - if (editor.fileById(id)) |f| { - f.resetSaveUIState(); + if (editor.docById(id)) |doc| { + doc.owner.resetDocumentSaveUIState(doc); } - } else if (editor.activeFile()) |f| { - f.resetSaveUIState(); + } else if (editor.activeDoc()) |doc| { + doc.owner.resetDocumentSaveUIState(doc); } if (editor.quit_save_all_ids.items.len > 0 or editor.quit_in_progress) { @@ -2697,85 +2575,40 @@ pub fn saveAsDialogCallback(paths: ?[][:0]const u8) void { } fn processPendingSaveAs(editor: *Editor) void { - if (comptime builtin.target.cpu.arch == .wasm32) { - const path = blk: { - if (editor.pending_save_as_path) |p| break :blk p; + const path = blk: { + if (editor.pending_save_as_path) |p| break :blk p; + if (comptime builtin.target.cpu.arch == .wasm32) { const WebFileIo = @import("WebFileIo.zig"); if (WebFileIo.pending_save_filename) |p| break :blk p; - return; - }; - const owned_by_editor = editor.pending_save_as_path != null; - editor.pending_save_as_path = null; + } + return; + }; + const owned_by_editor = editor.pending_save_as_path != null; + editor.pending_save_as_path = null; + if (comptime builtin.target.cpu.arch == .wasm32) { if (!owned_by_editor) { const WebFileIo = @import("WebFileIo.zig"); WebFileIo.pending_save_filename = null; } - defer fizzy.app.allocator.free(path); - - const file = editor.activeFile() orelse return; - const ext = std.fs.path.extension(path); - const saved: bool = blk: { - if (Internal.File.isFizzyExtension(ext)) { - file.saveAsFizzy(path, dvui.currentWindow()) catch |err| { - dvui.log.err("Save As: {any}", .{err}); - break :blk false; - }; - } else if (std.mem.eql(u8, ext, ".png") or std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - file.saveAsFlattened(path, dvui.currentWindow()) catch |err| { - dvui.log.err("Save As: {any}", .{err}); - break :blk false; - }; - } else { - dvui.log.err("Save As: choose extension .fiz, .png, .jpg, or .jpeg (got {s})", .{ext}); - break :blk false; - } - break :blk true; - }; - if (!saved) return; - if (editor.pending_close_file_id) |cid| { - if (file.id == cid) { - editor.pending_close_file_id = null; - editor.rawCloseFileID(cid) catch |err| { - dvui.log.err("Failed to close file after Save As: {s}", .{@errorName(err)}); - }; - } - } - return; } - const path = editor.pending_save_as_path orelse return; - editor.pending_save_as_path = null; defer fizzy.app.allocator.free(path); - const ext = std.fs.path.extension(path); - const file = editor.activeFile() orelse { + const doc = editor.activeDoc() orelse { editor.pending_close_file_id = null; return; }; - const saved: bool = blk: { - if (Internal.File.isFizzyExtension(ext)) { - file.saveAsFizzy(path, dvui.currentWindow()) catch |err| { - dvui.log.err("Save As: {any}", .{err}); - break :blk false; - }; - } else if (std.mem.eql(u8, ext, ".png") or - std.mem.eql(u8, ext, ".jpg") or - std.mem.eql(u8, ext, ".jpeg")) - { - file.saveAsFlattened(path, dvui.currentWindow()) catch |err| { - dvui.log.err("Save As: {any}", .{err}); - break :blk false; - }; + doc.owner.saveDocumentAs(doc, path, dvui.currentWindow()) catch |err| { + if (err == error.UnsupportedSaveExtension) { + dvui.log.err("Save As: choose extension .fiz, .png, .jpg, or .jpeg (got {s})", .{std.fs.path.extension(path)}); } else { - dvui.log.err("Save As: choose extension .fiz, .png, .jpg, or .jpeg (got {s})", .{ext}); - break :blk false; + dvui.log.err("Save As: {any}", .{err}); } - break :blk true; + return; }; - if (!saved) return; if (editor.pending_close_file_id) |cid| { - if (file.id == cid) { + if (doc.id == cid) { editor.pending_close_file_id = null; editor.rawCloseFileID(cid) catch |err| { dvui.log.err("Failed to close file after Save As: {s}", .{@errorName(err)}); @@ -2791,19 +2624,13 @@ fn processPendingSaveAs(editor: *Editor) void { } pub fn undo(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { - try plugin.undo(.{ .ptr = file, .owner = plugin, .id = file.id }); - } - } + const doc = editor.activeDoc() orelse return; + try doc.owner.undo(doc); } pub fn redo(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| { - try plugin.redo(.{ .ptr = file, .owner = plugin, .id = file.id }); - } - } + const doc = editor.activeDoc() orelse return; + try doc.owner.redo(doc); } pub fn openInFileBrowser(_: *Editor, path: []const u8) !void { @@ -2832,24 +2659,19 @@ pub fn closeFile(editor: *Editor, index: usize) !void { /// Tear down a document via its owning plugin, falling back to a direct `deinit`. /// Removes the entry from the plugin's document registry; the shell still removes /// the matching `DocHandle` from `open_files`. -fn closeDocumentResources(editor: *Editor, doc: sdk.DocHandle) void { - if (doc.owner.closeDocument(doc)) { - doc.owner.unregisterDocument(doc.id); - return; - } - editor.fileFromDoc(doc).deinit(); +fn closeDocumentResources(_: *Editor, doc: sdk.DocHandle) void { + _ = doc.owner.closeDocument(doc); doc.owner.unregisterDocument(doc.id); } pub fn rawCloseFile(editor: *Editor, index: usize) !void { const doc = editor.docAt(index) orelse return; - const file = editor.fileFromDoc(doc); + const grouping = editor.docGrouping(doc); - if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| { + if (editor.workspaces.getPtr(grouping)) |workspace| { if (workspace.open_file_index == index) { for (editor.open_files.values(), 0..) |d, i| { - const f = editor.fileFromDoc(d); - if (f.editor.grouping == workspace.grouping and f.id != file.id) { + if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) { workspace.open_file_index = i; break; } @@ -2863,13 +2685,12 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void { pub fn rawCloseFileID(editor: *Editor, id: u64) !void { const doc = editor.open_files.get(id) orelse return; - const file = editor.fileFromDoc(doc); + const grouping = editor.docGrouping(doc); - if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| { - if (workspace.open_file_index == editor.open_files.getIndex(file.id)) { + if (editor.workspaces.getPtr(grouping)) |workspace| { + if (workspace.open_file_index == editor.open_files.getIndex(doc.id)) { for (editor.open_files.values(), 0..) |d, i| { - const f = editor.fileFromDoc(d); - if (f.editor.grouping == workspace.grouping and f.id != file.id) { + if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) { workspace.open_file_index = i; break; } @@ -2881,16 +2702,10 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void { _ = editor.open_files.orderedRemove(id); } -pub fn closeReference(editor: *Editor, index: usize) !void { - editor.open_reference_index = 0; - var reference: Internal.Reference = editor.open_references.orderedRemove(index); - reference.deinit(); -} - pub fn deinit(editor: *Editor) !void { // Drain & join the save-queue worker before tearing anything else down. Any // queued jobs need to finish writing or be dropped before File data is freed. - Internal.File.deinitSaveQueue(); + for (editor.host.plugins.items) |plugin| plugin.deinit(); // Signal cancel to any in-flight load workers. They check the flag after `fromPath` returns // and discard the result; we can't synchronously join them without blocking quit, so we // accept a brief window where a worker may still be running with a discardable result. diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig index 0d0beb1a..9e728177 100644 --- a/src/editor/Infobar.zig +++ b/src/editor/Infobar.zig @@ -23,7 +23,6 @@ pub fn deinit() void { pub fn draw(_: Infobar) !void { const font = dvui.Font.theme(.body).larger(-1.0); - const font_mono = dvui.Font.theme(.mono).larger(-3.0); var scrollarea = dvui.scrollArea(@src(), .{}, .{ .expand = .horizontal, @@ -106,60 +105,9 @@ pub fn draw(_: Infobar) !void { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); - if (fizzy.editor.activeFile()) |file| { - dvui.icon( - @src(), - "file_icon", - icons.tvg.lucide.file, - .{ .stroke_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5 }, - ); - dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{ .font = font, .gravity_y = 0.5 }); - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); - - dvui.icon( - @src(), - "width_icon", - icons.tvg.lucide.@"ruler-dimension-line", - .{ .stroke_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5 }, - ); - - fizzy.Editor.Dialogs.drawDimensionsLabel(@src(), file.width(), file.height(), font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); - - dvui.icon( - @src(), - "sprite_icon", - dvui.entypo.grid, - .{ .fill_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5 }, - ); - - fizzy.Editor.Dialogs.drawDimensionsLabel(@src(), file.column_width, file.row_height, font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); - - //dvui.label(@src(), "{d}x{d} - {d}x{d}", .{ file.width(), file.height(), file.column_width, file.row_height }, .{ .font = font, .gravity_y = 0.5 }); - - const mouse_pt = dvui.currentWindow().mouse_pt; - const data_pt = file.editor.canvas.dataFromScreenPoint(mouse_pt); - - const file_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) }); - - if (file_rect.contains(data_pt)) { - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); - - dvui.icon( - @src(), - "mouse_icon", - icons.tvg.lucide.@"mouse-pointer", - .{ .stroke_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5 }, - ); - - const sprite_pt = file.spritePoint(data_pt); - dvui.label(@src(), "{d:0.0},{d:0.0} - {d:0.0},{d:0.0}", .{ @floor(data_pt.x), @floor(data_pt.y), @floor(sprite_pt.x / @as(f32, @floatFromInt(file.column_width))), @floor(sprite_pt.y / @as(f32, @floatFromInt(file.row_height))) }, .{ .gravity_y = 0.5, .font = font_mono }); - } + if (fizzy.editor.activeDoc()) |doc| { + doc.owner.drawDocumentInfobar(doc) catch { + dvui.log.err("Failed to draw document infobar", .{}); + }; } } diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index f7289a5a..f8a41f9c 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -151,7 +151,7 @@ pub fn tick() !void { } if (ke.matchBind("grid_layout") and ke.action == .down) { - if (fizzy.editor.activeFile() != null) { + if (fizzy.editor.activeDoc() != null) { fizzy.editor.requestGridLayoutDialog(); } } diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 18e88878..722816f3 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -1,7 +1,5 @@ const std = @import("std"); const fizzy = @import("../fizzy.zig"); -const pixelart = @import("pixelart"); -const Internal = pixelart.internal; const dvui = @import("dvui"); const Editor = fizzy.Editor; const settings = fizzy.settings; @@ -135,8 +133,8 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { _ = dvui.separator(@src(), .{ .expand = .horizontal }); - if (menuItemWithHotkey(@src(), "Save", dvui.currentWindow().keybinds.get("save") orelse .{}, if (fizzy.editor.activeFile()) |file| - (file.dirty() or !Internal.File.hasRecognizedSaveExtension(file.path)) + if (menuItemWithHotkey(@src(), "Save", dvui.currentWindow().keybinds.get("save") orelse .{}, if (fizzy.editor.activeDoc()) |doc| + (doc.owner.isDirty(doc) or !doc.owner.documentHasRecognizedSaveExtension(doc)) else false, .{}, .{ .expand = .horizontal, @@ -148,7 +146,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { fw.close(); } - if (menuItemWithHotkey(@src(), "Save As…", dvui.currentWindow().keybinds.get("save_as") orelse .{}, fizzy.editor.activeFile() != null, .{}, .{ + if (menuItemWithHotkey(@src(), "Save As…", dvui.currentWindow().keybinds.get("save_as") orelse .{}, fizzy.editor.activeDoc() != null, .{}, .{ .expand = .horizontal, .color_text = dvui.themeGet().color(.window, .text), }) != null) { @@ -160,7 +158,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { // extension. Worker queue handles them serially; UI stays responsive. const any_dirty = blk: { for (fizzy.editor.open_files.values()) |doc| { - if (doc.owner.isDirty(doc) and Internal.File.hasRecognizedSaveExtension(fizzy.editor.docPath(doc))) break :blk true; + if (doc.owner.isDirty(doc) and doc.owner.documentHasRecognizedSaveExtension(doc)) break :blk true; } break :blk false; }; @@ -203,11 +201,11 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { @src(), "Copy", dvui.currentWindow().keybinds.get("copy") orelse .{}, - if (fizzy.editor.activeFile() != null) true else false, + fizzy.editor.activeDoc() != null, .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile() != null) { + if (fizzy.editor.activeDoc() != null) { fizzy.editor.copy() catch { std.log.err("Failed to copy", .{}); }; @@ -219,11 +217,11 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { @src(), "Paste", dvui.currentWindow().keybinds.get("paste") orelse .{}, - if (fizzy.editor.activeFile() != null) true else false, + fizzy.editor.activeDoc() != null, .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile() != null) { + if (fizzy.editor.activeDoc() != null) { fizzy.editor.paste() catch { std.log.err("Failed to paste", .{}); }; @@ -237,12 +235,12 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { @src(), "Undo", dvui.currentWindow().keybinds.get("undo") orelse .{}, - if (fizzy.editor.activeFile()) |file| if (file.history.undo_stack.items.len > 0) true else false else false, + if (fizzy.editor.activeDoc()) |doc| doc.owner.canUndo(doc) else false, .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile()) |file| { - file.history.undoRedo(file, .undo) catch { + if (fizzy.editor.activeDoc()) |doc| { + doc.owner.undo(doc) catch { std.log.err("Failed to undo", .{}); }; } @@ -252,12 +250,12 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { @src(), "Redo", dvui.currentWindow().keybinds.get("redo") orelse .{}, - if (fizzy.editor.activeFile()) |file| if (file.history.redo_stack.items.len > 0) true else false else false, + if (fizzy.editor.activeDoc()) |doc| doc.owner.canRedo(doc) else false, .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile()) |file| { - file.history.undoRedo(file, .redo) catch { + if (fizzy.editor.activeDoc()) |doc| { + doc.owner.redo(doc) catch { std.log.err("Failed to redo", .{}); }; } @@ -269,11 +267,11 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { @src(), "Transform", dvui.currentWindow().keybinds.get("transform") orelse .{}, - if (fizzy.editor.activeFile() != null) true else false, + fizzy.editor.activeDoc() != null, .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile() != null) { + if (fizzy.editor.activeDoc() != null) { fizzy.editor.transform() catch { std.log.err("Failed to transform", .{}); }; @@ -287,11 +285,11 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { @src(), "Grid Layout…", dvui.currentWindow().keybinds.get("grid_layout") orelse .{}, - if (fizzy.editor.activeFile() != null) true else false, + fizzy.editor.activeDoc() != null, .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile() != null) { + if (fizzy.editor.activeDoc() != null) { fizzy.editor.requestGridLayoutDialog(); fw.close(); } diff --git a/src/editor/WebFileIo.zig b/src/editor/WebFileIo.zig index 8acf190a..a7d7992e 100644 --- a/src/editor/WebFileIo.zig +++ b/src/editor/WebFileIo.zig @@ -79,25 +79,12 @@ pub fn pollOpenPicker(editor: *fizzy.Editor) void { defer fizzy.app.allocator.free(bytes); const path_owned = fizzy.app.allocator.dupe(u8, wasm_file.name) catch continue; - if (editor.openFileFromBytes(path_owned, bytes, open_grouping)) |file| { - const owner = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse { - var f = file; - f.deinit(); - fizzy.app.allocator.free(path_owned); - continue; - }; - editor.insertOpenDoc(file, owner) catch { - var f = file; - f.deinit(); - fizzy.app.allocator.free(path_owned); - }; - if (editor.open_files.getIndex(file.id)) |idx| { + if (editor.openFileFromBytes(path_owned, bytes, open_grouping)) |doc_id| { + if (editor.open_files.getIndex(doc_id)) |idx| { editor.setActiveFile(idx); editor.pending_composite_warmup = true; } - } else |_| { - fizzy.app.allocator.free(path_owned); - } + } else |_| {} } open_callback = null; diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index d6403478..7aa149b7 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -1,7 +1,6 @@ const std = @import("std"); const fizzy = @import("../../fizzy.zig"); const pixelart = @import("pixelart"); -const Internal = pixelart.internal; const dvui = @import("dvui"); const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning; @@ -110,7 +109,7 @@ fn beginSaveAndClose(doc: fizzy.sdk.DocHandle, file_id: u64) !void { fn onSaveAndClose(file_id: u64) !void { const doc = fizzy.editor.docById(file_id) orelse return; - if (!Internal.File.hasRecognizedSaveExtension(fizzy.editor.docPath(doc))) { + if (!doc.owner.documentHasRecognizedSaveExtension(doc)) { const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; fizzy.editor.setActiveFile(idx); fizzy.editor.pending_close_file_id = file_id; diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig index fc4684e1..15ffdb82 100644 --- a/src/editor/panel/Panel.zig +++ b/src/editor/panel/Panel.zig @@ -2,13 +2,11 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); -const pixelart = @import("pixelart"); const fizzy = @import("../../fizzy.zig"); const Core = @import("mach").Core; const App = fizzy.App; const Editor = fizzy.Editor; -const Packer = pixelart.Packer; pub const Panel = @This(); diff --git a/src/plugins/pixelart/src/doc_bridge.zig b/src/plugins/pixelart/src/doc_bridge.zig index a515e8ad..b9d48914 100644 --- a/src/plugins/pixelart/src/doc_bridge.zig +++ b/src/plugins/pixelart/src/doc_bridge.zig @@ -52,6 +52,21 @@ pub fn documentHasNativeExtension(st: *State, doc: DocHandle) bool { return Internal.File.isFizzyExtension(std.fs.path.extension(file.path)); } +pub fn documentHasRecognizedSaveExtension(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return Internal.File.hasRecognizedSaveExtension(file.path); +} + +pub fn canUndo(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.history.undo_stack.items.len > 0; +} + +pub fn canRedo(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.history.redo_stack.items.len > 0; +} + pub fn showsSaveStatusIndicator(st: *State, doc: DocHandle) bool { const file = docFile(st, doc) orelse return false; return file.showsSaveStatusIndicator(); diff --git a/src/plugins/pixelart/src/doc_lifecycle.zig b/src/plugins/pixelart/src/doc_lifecycle.zig new file mode 100644 index 00000000..69f1b3f5 --- /dev/null +++ b/src/plugins/pixelart/src/doc_lifecycle.zig @@ -0,0 +1,161 @@ +//! Document create/load buffer contract + shell frame hooks without typing +//! `Internal.File` at the SDK boundary. +const std = @import("std"); +const dvui = @import("dvui"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const State = pixelart.State; +const Internal = pixelart.internal; +const DocHandle = pixelart.sdk.DocHandle; +const NewDocGrid = pixelart.sdk.EditorAPI.NewDocGrid; +const GridLayout = @import("dialogs/GridLayout.zig"); + +fn docFile(st: *State, doc: DocHandle) ?*Internal.File { + return st.docs.fileById(doc.id); +} + +fn activeFile(st: *State) ?*Internal.File { + const doc = st.host.activeDoc() orelse return null; + return docFile(st, doc); +} + +pub fn documentStackSize(_: *State) usize { + return @sizeOf(Internal.File); +} + +pub fn documentStackAlign(_: *State) usize { + return @alignOf(Internal.File); +} + +pub fn documentIdFromBuffer(_: *State, doc: *anyopaque) u64 { + const file: *Internal.File = @ptrCast(@alignCast(doc)); + return file.id; +} + +pub fn deinitDocumentBuffer(_: *State, doc: *anyopaque) void { + const file: *Internal.File = @ptrCast(@alignCast(doc)); + file.deinit(); +} + +pub fn setDocumentGroupingOnBuffer(_: *State, doc: *anyopaque, grouping: u64) void { + const file: *Internal.File = @ptrCast(@alignCast(doc)); + file.editor.grouping = grouping; +} + +pub fn createDocument(_: *State, path: []const u8, grid: NewDocGrid, out_doc: *anyopaque) !void { + const file: *Internal.File = @ptrCast(@alignCast(out_doc)); + file.* = try Internal.File.init(path, .{ + .columns = grid.columns, + .rows = grid.rows, + .column_width = grid.column_width, + .row_height = grid.row_height, + }); +} + +pub fn documentDefaultSaveAsFilename(st: *State, doc: DocHandle, allocator: std.mem.Allocator) ![]const u8 { + const file = docFile(st, doc) orelse return error.DocumentNotFound; + return Internal.File.defaultSaveAsFilename(allocator, file.path); +} + +pub fn saveDocumentAs(st: *State, doc: DocHandle, path: []const u8, window: *dvui.Window) !void { + const file = docFile(st, doc) orelse return error.DocumentNotFound; + const ext = std.fs.path.extension(path); + if (Internal.File.isFizzyExtension(ext)) { + try file.saveAsFizzy(path, window); + } else if (std.mem.eql(u8, ext, ".png") or std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { + try file.saveAsFlattened(path, window); + } else { + return error.UnsupportedSaveExtension; + } +} + +pub fn resetDocumentSaveUIState(st: *State, doc: DocHandle) void { + const file = docFile(st, doc) orelse return; + file.resetSaveUIState(); +} + +pub fn prepareGridLayoutDialog(st: *State, doc: DocHandle) void { + const file = docFile(st, doc) orelse return; + GridLayout.presetFromFile(file); +} + +pub fn tickOpenDocuments(st: *State) bool { + var needs_save_status_anim_tick = false; + for (st.docs.files.values()) |*file| { + file.tickSaveDoneFlash(); + if (file.showsSaveStatusIndicator()) needs_save_status_anim_tick = true; + } + return needs_save_status_anim_tick; +} + +pub fn resetDocumentPeekLayers(st: *State) void { + for (st.docs.files.values()) |*file| { + if (file.editor.isolate_layer) { + file.peek_layer_index = file.selected_layer_index; + } else { + file.peek_layer_index = null; + } + } +} + +pub fn tickActiveDocumentPlayback(st: *State, timer_host_id: dvui.Id) void { + const file = activeFile(st) orelse return; + if (!file.editor.playing) return; + if (file.selected_animation_index) |index| { + const animation = file.animations.get(index); + if (animation.frames.len == 0) return; + if (dvui.timerDoneOrNone(timer_host_id)) { + if (file.selected_animation_frame_index >= animation.frames.len - 1) { + file.selected_animation_frame_index = 0; + } else { + file.selected_animation_frame_index += 1; + } + const millis_per_frame = animation.frames[file.selected_animation_frame_index].ms; + dvui.timer(timer_host_id, @intCast(millis_per_frame * 1000)); + } + } +} + +pub fn warmupActiveDocumentComposites(st: *State) void { + const file = activeFile(st) orelse return; + const w = file.width(); + const h = file.height(); + if (w == 0 or h == 0) return; + const area = @as(u64, w) * @as(u64, h); + if (area < 512 * 512) return; + pixelart.render.warmupDrawingComposites(file) catch |err| { + dvui.log.err("Composite warmup failed: {any}", .{err}); + }; +} + +pub fn isAnyDocumentActivelyDrawing(st: *State) bool { + for (st.docs.files.values()) |*file| { + if (file.editor.active_drawing) return true; + } + return false; +} + +pub fn acceptEdit(st: *State) void { + const file = activeFile(st) orelse return; + if (file.editor.transform) |*t| t.accept(); +} + +pub fn cancelEdit(st: *State) void { + const file = activeFile(st) orelse return; + if (file.editor.transform) |*t| t.cancel(); + if (file.editor.selected_sprites.count() > 0) file.clearSelectedSprites(); + if (file.selected_animation_index != null) file.selected_animation_index = null; +} + +pub fn deleteSelection(st: *State) void { + const file = activeFile(st) orelse return; + file.deleteSelectedContents(); +} + +pub fn initPlugin(_: *State) !void { + try Internal.File.initSaveQueue(); +} + +pub fn deinitPlugin(_: *State) void { + Internal.File.deinitSaveQueue(); +} diff --git a/src/plugins/pixelart/src/infobar_status.zig b/src/plugins/pixelart/src/infobar_status.zig new file mode 100644 index 00000000..2476d10d --- /dev/null +++ b/src/plugins/pixelart/src/infobar_status.zig @@ -0,0 +1,83 @@ +//! Active-document infobar status (path, dimensions, cursor) for the shell infobar. +const std = @import("std"); +const dvui = @import("dvui"); +const icons = @import("icons"); +const pixelart = @import("../pixelart.zig"); +const Globals = pixelart.Globals; +const State = pixelart.State; +const Internal = pixelart.internal; +const DocHandle = pixelart.sdk.DocHandle; +const DimensionsLabel = @import("dialogs/dimensions_label.zig"); + +fn docFile(st: *State, doc: DocHandle) ?*Internal.File { + return st.docs.fileById(doc.id); +} + +pub fn drawDocumentInfobar(st: *State, doc: DocHandle) !void { + const file = docFile(st, doc) orelse return; + const font = dvui.Font.theme(.body).larger(-1.0); + const font_mono = dvui.Font.theme(.mono).larger(-3.0); + + dvui.icon( + @src(), + "file_icon", + icons.tvg.lucide.file, + .{ .stroke_color = dvui.themeGet().color(.window, .text) }, + .{ .gravity_y = 0.5 }, + ); + dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{ .font = font, .gravity_y = 0.5 }); + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); + + dvui.icon( + @src(), + "width_icon", + icons.tvg.lucide.@"ruler-dimension-line", + .{ .stroke_color = dvui.themeGet().color(.window, .text) }, + .{ .gravity_y = 0.5 }, + ); + + DimensionsLabel.drawDimensionsLabel(@src(), file.width(), file.height(), font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); + + dvui.icon( + @src(), + "sprite_icon", + dvui.entypo.grid, + .{ .fill_color = dvui.themeGet().color(.window, .text) }, + .{ .gravity_y = 0.5 }, + ); + + DimensionsLabel.drawDimensionsLabel(@src(), file.column_width, file.row_height, font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); + + const mouse_pt = dvui.currentWindow().mouse_pt; + const data_pt = file.editor.canvas.dataFromScreenPoint(mouse_pt); + + const file_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) }); + + if (file_rect.contains(data_pt)) { + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); + + dvui.icon( + @src(), + "mouse_icon", + icons.tvg.lucide.@"mouse-pointer", + .{ .stroke_color = dvui.themeGet().color(.window, .text) }, + .{ .gravity_y = 0.5 }, + ); + + const sprite_pt = file.spritePoint(data_pt); + dvui.label( + @src(), + "{d:0.0},{d:0.0} - {d:0.0},{d:0.0}", + .{ + @floor(data_pt.x), + @floor(data_pt.y), + @floor(sprite_pt.x / @as(f32, @floatFromInt(file.column_width))), + @floor(sprite_pt.y / @as(f32, @floatFromInt(file.row_height))), + }, + .{ .gravity_y = 0.5, .font = font_mono }, + ); + } +} diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig index b6a840ad..d0ecfade 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -20,6 +20,8 @@ const PackProject = @import("pack_project.zig"); const TransformOp = @import("transform_op.zig"); const DocsRegistry = @import("docs_registry.zig"); const DocBridge = @import("doc_bridge.zig"); +const DocLifecycle = @import("doc_lifecycle.zig"); +const InfobarStatus = @import("infobar_status.zig"); const DocHandle = sdk.DocHandle; const Internal = pixelart.internal; @@ -38,15 +40,25 @@ var plugin: sdk.Plugin = .{ }; const vtable: sdk.Plugin.VTable = .{ + .deinit = pluginDeinit, + .initPlugin = pluginInit, .fileTypePriority = fileTypePriority, .contributeKeybinds = contributeKeybinds, .loadDocument = loadDocument, .loadDocumentFromBytes = loadDocumentFromBytes, + .documentStackSize = documentStackSize, + .documentStackAlign = documentStackAlign, + .documentIdFromBuffer = documentIdFromBuffer, + .deinitDocumentBuffer = deinitDocumentBuffer, + .setDocumentGroupingOnBuffer = setDocumentGroupingOnBuffer, + .createDocument = createDocument, .isDirty = isDirty, .saveDocument = saveDocument, .closeDocument = closeDocument, .undo = undo, .redo = redo, + .canUndo = canUndo, + .canRedo = canRedo, .registerOpenDocument = registerOpenDocument, .documentPtr = documentPtr, .documentByPath = documentByPath, @@ -57,19 +69,34 @@ const vtable: sdk.Plugin.VTable = .{ .documentPath = documentPath, .setDocumentPath = setDocumentPath, .documentHasNativeExtension = documentHasNativeExtension, + .documentHasRecognizedSaveExtension = documentHasRecognizedSaveExtension, .showsSaveStatusIndicator = showsSaveStatusIndicator, .isDocumentSaving = isDocumentSaving, .shouldConfirmFlatRasterSave = shouldConfirmFlatRasterSave, .saveDocumentAsync = saveDocumentAsync, .timeSinceSaveCompleteNs = timeSinceSaveCompleteNs, + .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename, + .saveDocumentAs = saveDocumentAs, + .resetDocumentSaveUIState = resetDocumentSaveUIState, + .prepareGridLayoutDialog = prepareGridLayoutDialog, .drawDocument = drawDocument, + .drawDocumentInfobar = drawDocumentInfobar, + .beginFrame = beginFrame, .tickKeybinds = tickKeybinds, + .tickOpenDocuments = tickOpenDocuments, + .tickActiveDocumentPlayback = tickActiveDocumentPlayback, + .resetDocumentPeekLayers = resetDocumentPeekLayers, + .warmupActiveDocumentComposites = warmupActiveDocumentComposites, + .isAnyDocumentActivelyDrawing = isAnyDocumentActivelyDrawing, .processRadialMenuInput = processRadialMenuInput, .radialMenuVisible = radialMenuVisible, .drawRadialMenu = drawRadialMenu, .transform = pluginTransform, .copy = pluginCopy, .paste = pluginPaste, + .acceptEdit = pluginAcceptEdit, + .cancelEdit = pluginCancelEdit, + .deleteSelection = pluginDeleteSelection, .startPackProject = pluginStartPackProject, .isPackingActive = pluginIsPackingActive, .tickPackJobs = pluginTickPackJobs, @@ -262,6 +289,11 @@ fn drawProjectView(_: ?*anyopaque, pane: *sdk.WorkbenchPaneView) anyerror!void { } } +fn drawDocumentInfobar(state: *anyopaque, doc: DocHandle) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return InfobarStatus.drawDocumentInfobar(st, doc); +} + fn undo(_: *anyopaque, doc: DocHandle) anyerror!void { const file = docFile(doc); try file.history.undoRedo(file, .undo); @@ -272,6 +304,16 @@ fn redo(_: *anyopaque, doc: DocHandle) anyerror!void { try file.history.undoRedo(file, .redo); } +fn canUndo(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.canUndo(st, doc); +} + +fn canRedo(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.canRedo(st, doc); +} + pub fn register(host: *sdk.Host) !void { // Adopt the app-owned pixel-art state as this plugin's `state`. Wire Globals // here too so plugin code and the shell share one injection site (App also sets @@ -410,6 +452,11 @@ fn documentHasNativeExtension(state: *anyopaque, doc: DocHandle) bool { return DocBridge.documentHasNativeExtension(st, doc); } +fn documentHasRecognizedSaveExtension(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.documentHasRecognizedSaveExtension(st, doc); +} + fn showsSaveStatusIndicator(state: *anyopaque, doc: DocHandle) bool { const st: *State = @ptrCast(@alignCast(state)); return DocBridge.showsSaveStatusIndicator(st, doc); @@ -435,6 +482,112 @@ fn timeSinceSaveCompleteNs(state: *anyopaque, doc: DocHandle) ?i128 { return DocBridge.timeSinceSaveCompleteNs(st, doc); } +fn pluginDeinit(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.deinitPlugin(st); +} + +fn pluginInit(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.initPlugin(st); +} + +fn documentStackSize(state: *anyopaque) usize { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.documentStackSize(st); +} + +fn documentStackAlign(state: *anyopaque) usize { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.documentStackAlign(st); +} + +fn documentIdFromBuffer(state: *anyopaque, doc: *anyopaque) u64 { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.documentIdFromBuffer(st, doc); +} + +fn deinitDocumentBuffer(state: *anyopaque, doc: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.deinitDocumentBuffer(st, doc); +} + +fn setDocumentGroupingOnBuffer(state: *anyopaque, doc: *anyopaque, grouping: u64) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.setDocumentGroupingOnBuffer(st, doc, grouping); +} + +fn createDocument(state: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid, out_doc: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.createDocument(st, path, grid, out_doc); +} + +fn documentDefaultSaveAsFilename(state: *anyopaque, doc: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.documentDefaultSaveAsFilename(st, doc, allocator); +} + +fn saveDocumentAs(state: *anyopaque, doc: DocHandle, path: []const u8, window: *dvui.Window) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.saveDocumentAs(st, doc, path, window); +} + +fn resetDocumentSaveUIState(state: *anyopaque, doc: DocHandle) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.resetDocumentSaveUIState(st, doc); +} + +fn prepareGridLayoutDialog(state: *anyopaque, doc: DocHandle) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.prepareGridLayoutDialog(st, doc); +} + +fn beginFrame(state: *anyopaque) void { + _ = state; + // Advance the per-frame render clock used as a composite-cache invalidation key. + pixelart.render.frame_index +%= 1; +} + +fn tickOpenDocuments(state: *anyopaque) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.tickOpenDocuments(st); +} + +fn tickActiveDocumentPlayback(state: *anyopaque, timer_host_id: dvui.Id) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.tickActiveDocumentPlayback(st, timer_host_id); +} + +fn resetDocumentPeekLayers(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.resetDocumentPeekLayers(st); +} + +fn warmupActiveDocumentComposites(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.warmupActiveDocumentComposites(st); +} + +fn isAnyDocumentActivelyDrawing(state: *anyopaque) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.isAnyDocumentActivelyDrawing(st); +} + +fn pluginAcceptEdit(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.acceptEdit(st); +} + +fn pluginCancelEdit(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.cancelEdit(st); +} + +fn pluginDeleteSelection(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.deleteSelection(st); +} + fn pluginPersistProjectFolder(state: *anyopaque) void { const st: *State = @ptrCast(@alignCast(state)); DocsRegistry.persistProjectFolder(st); diff --git a/src/plugins/workbench/src/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig index cfac2907..164e05c3 100644 --- a/src/plugins/workbench/src/FileLoadJob.zig +++ b/src/plugins/workbench/src/FileLoadJob.zig @@ -9,15 +9,13 @@ //! //! Ownership / threading model: //! - `path` is owned by the job, freed in `destroy()`. -//! - `result` is written by the worker, read by the main thread only after `done.load(.acquire)`. +//! - `doc_buf` is written by the worker, read by the main thread only after `done.load(.acquire)`. //! - `phase` / `cancelled` are written by either side, read by either side. //! - The job pointer itself is owned by `Editor.loading_jobs`. Worker holds a borrowed pointer -//! but only writes through atomic fields + the worker-only `result`/`err`/`canvas_target_grouping` fields. +//! but only writes through atomic fields + the worker-only `doc_buf`/`err` fields. const std = @import("std"); const fizzy = @import("../../../fizzy.zig"); -const pixelart = @import("pixelart"); -const Internal = pixelart.internal; const dvui = @import("dvui"); const perf = fizzy.perf; @@ -37,48 +35,36 @@ allocator: std.mem.Allocator, path: []u8, /// Plugin that owns this file's extension (resolved on the main thread before spawn). -/// The worker routes the load through `owner.loadDocument` instead of hardcoding the -/// pixel-art loader, so open is decoupled from any one editor plugin. owner: *fizzy.sdk.Plugin, /// Workspace grouping the file should land in once loaded. target_grouping: u64, -/// Captured at create time on the GUI thread. The worker uses this to wake the main loop -/// (`dvui.refresh(window, ...)`) the instant the load finishes, so small files don't sit -/// completed-but-unconsumed waiting for an unrelated input event to tick the editor. window: *dvui.Window, - -/// Monotonic timestamp (boot clock, nanos) captured on the main thread at job creation. -/// Compared against the main thread's current `perf.nanoTimestamp` to gate the 150ms toast -/// threshold. Only read on the main thread. started_at_ns: i128, -/// Atomic phase, written by worker, read by main. Cast through `Phase`. phase: std.atomic.Value(u8) = .init(@intFromEnum(Phase.queued)), - -/// Optional progress hint, written by worker. `den == 0` means indeterminate. progress_num: std.atomic.Value(u32) = .init(0), progress_den: std.atomic.Value(u32) = .init(0), - -/// Main thread sets true on close-while-loading / quit. Worker checks after `fromPath` returns -/// and discards the result instead of publishing. cancelled: std.atomic.Value(bool) = .init(false), - -/// Worker → main publish flag. `release` on write, `acquire` on read. done: std.atomic.Value(bool) = .init(false), -/// Filled by worker iff load succeeds AND wasn't cancelled. Safe to read after `done.load(.acquire)`. -result: ?Internal.File = null, +/// Plugin-document staging buffer (size/align from `owner.documentStackSize/Align`). +doc_slab: []u8, +doc_buf: []u8, -/// Filled by worker iff load failed. Safe to read after `done.load(.acquire)`. err: ?anyerror = null, pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk.Plugin, target_grouping: u64) !*FileLoadJob { const path_copy = try allocator.dupe(u8, path); errdefer allocator.free(path_copy); + const staging = try owner.allocDocumentBuffer(allocator); + errdefer allocator.free(staging.backing); + const job = try allocator.create(FileLoadJob); + errdefer allocator.destroy(job); + job.* = .{ .allocator = allocator, .path = path_copy, @@ -86,6 +72,8 @@ pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk. .target_grouping = target_grouping, .window = dvui.currentWindow(), .started_at_ns = perf.nanoTimestamp(), + .doc_slab = staging.backing, + .doc_buf = staging.buf, }; return job; } @@ -93,19 +81,13 @@ pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk. pub fn destroy(job: *FileLoadJob) void { const a = job.allocator; a.free(job.path); + a.free(job.doc_slab); a.destroy(job); } -/// Worker entry point. Spawn with `std.Thread.spawn(.{}, FileLoadJob.workerMain, .{job})`. pub fn workerMain(job: *FileLoadJob) void { defer { - // Publish before waking the GUI thread so `done.load(.acquire)` on the consumer side - // sees `result` / `err` / `phase` already in place. job.done.store(true, .release); - // Wake the GUI thread from this thread. `dvui.refresh` with a non-null Window pointer - // is the documented thread-safe entry — it goes through the backend to interrupt the - // event-driven idle loop, so the editor processes our completion immediately instead - // of waiting for the next unrelated input event. dvui.refresh(job.window, @src(), null); } @@ -116,11 +98,7 @@ pub fn workerMain(job: *FileLoadJob) void { job.phase.store(@intFromEnum(Phase.reading), .release); - // Route the actual load through the owning plugin (filled into a stack buffer the - // shell owns; the plugin knows its concrete document type). Mirrors the inline-value - // model below — no heap handoff. - var file: Internal.File = undefined; - const handled = job.owner.loadDocument(job.path, &file) catch |e| { + const handled = job.owner.loadDocument(job.path, job.doc_buf.ptr) catch |e| { job.err = e; job.phase.store(@intFromEnum(Phase.failed), .release); return; @@ -131,22 +109,15 @@ pub fn workerMain(job: *FileLoadJob) void { return; } - // Cancellation check post-load: if the user closed the tab / quit while we were loading, - // discard the file rather than publishing it. if (job.cancelled.load(.monotonic)) { - var f = file; - f.deinit(); + job.owner.deinitDocumentBuffer(job.doc_buf.ptr); job.phase.store(@intFromEnum(Phase.cancelled), .release); return; } - job.result = file; job.phase.store(@intFromEnum(Phase.ready), .release); } -/// True iff at least `threshold_ms` of wall-clock time has elapsed since job creation. Used -/// to delay the toast appearance so sub-threshold loads don't flash a UI element. Must be -/// called from the main thread (uses `dvui.io` via `perf.nanoTimestamp`). pub fn elapsedExceeds(job: *const FileLoadJob, threshold_ms: i64) bool { const elapsed_ns = perf.nanoTimestamp() - job.started_at_ns; return @divTrunc(elapsed_ns, std.time.ns_per_ms) >= threshold_ms; diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index 95d29605..3f0e8de7 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -10,6 +10,7 @@ const std = @import("std"); const dvui = @import("dvui"); const DocHandle = @import("DocHandle.zig"); +const EditorAPI = @import("EditorAPI.zig"); pub const Plugin = @This(); @@ -25,6 +26,8 @@ display_name: []const u8, pub const VTable = struct { /// Tear down `state`. Called when the plugin is unregistered / app shuts down. deinit: ?*const fn (state: *anyopaque) void = null, + /// One-time plugin setup (e.g. background worker threads). + initPlugin: ?*const fn (state: *anyopaque) anyerror!void = null, /// Priority for opening files with extension `ext` (including the dot, e.g. /// ".fiz"); lower value wins. `null` = this plugin does not handle `ext`. @@ -40,11 +43,20 @@ pub const VTable = struct { /// `loadDocument`, but from in-memory bytes (browser file picker). `path` is used /// for extension detection + display name. Synchronous (web has no load worker). loadDocumentFromBytes: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void = null, + /// Size of the plugin's document type for stack/heap staging buffers (`loadDocument`, etc.). + documentStackSize: ?*const fn (state: *anyopaque) usize = null, + documentStackAlign: ?*const fn (state: *anyopaque) usize = null, + documentIdFromBuffer: ?*const fn (state: *anyopaque, doc: *anyopaque) u64 = null, + deinitDocumentBuffer: ?*const fn (state: *anyopaque, doc: *anyopaque) void = null, + setDocumentGroupingOnBuffer: ?*const fn (state: *anyopaque, doc: *anyopaque, grouping: u64) void = null, + createDocument: ?*const fn (state: *anyopaque, path: []const u8, grid: EditorAPI.NewDocGrid, out_doc: *anyopaque) anyerror!void = null, saveDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, closeDocument: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, isDirty: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, undo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, redo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + canUndo: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + canRedo: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, /// Register a loaded/created document in the plugin's open-doc map. `file` points at /// the plugin's document type (for pixel art, `*Internal.File` on the caller's stack). @@ -64,11 +76,17 @@ pub const VTable = struct { documentPath: ?*const fn (state: *anyopaque, doc: DocHandle) []const u8 = null, setDocumentPath: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void = null, documentHasNativeExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + /// True when `saveDocument` can write the document without Save As (e.g. `.fiz` or flat image). + documentHasRecognizedSaveExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, showsSaveStatusIndicator: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, isDocumentSaving: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, shouldConfirmFlatRasterSave: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, saveDocumentAsync: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, timeSinceSaveCompleteNs: ?*const fn (state: *anyopaque, doc: DocHandle) ?i128 = null, + documentDefaultSaveAsFilename: ?*const fn (state: *anyopaque, doc: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 = null, + saveDocumentAs: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8, window: *dvui.Window) anyerror!void = null, + resetDocumentSaveUIState: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, + prepareGridLayoutDialog: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, // ---- render hooks (the plugin draws its own dvui UI into the host window) ---- /// Draw the plugin's explorer/sidebar pane (left region). @@ -77,13 +95,23 @@ pub const VTable = struct { drawDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, /// Draw the plugin's bottom panel content. drawBottomPanel: ?*const fn (state: *anyopaque) anyerror!void = null, + /// Draw active-document status into the shell infobar (dimensions, cursor, etc.). + drawDocumentInfobar: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, // ---- shell contributions ---- contributeMenu: ?*const fn (state: *anyopaque) anyerror!void = null, contributeKeybinds: ?*const fn (state: *anyopaque, win: *dvui.Window) anyerror!void = null, // ---- per-frame shell hooks (global keybinds, overlays) ---- + /// Called once at the top of every shell frame, before any document drawing. Plugins + /// use this to advance their internal frame clock / invalidate per-frame caches. + beginFrame: ?*const fn (state: *anyopaque) void = null, tickKeybinds: ?*const fn (state: *anyopaque) anyerror!void = null, + tickOpenDocuments: ?*const fn (state: *anyopaque) bool = null, + tickActiveDocumentPlayback: ?*const fn (state: *anyopaque, timer_host_id: dvui.Id) void = null, + resetDocumentPeekLayers: ?*const fn (state: *anyopaque) void = null, + warmupActiveDocumentComposites: ?*const fn (state: *anyopaque) void = null, + isAnyDocumentActivelyDrawing: ?*const fn (state: *anyopaque) bool = null, processRadialMenuInput: ?*const fn (state: *anyopaque) void = null, radialMenuVisible: ?*const fn (state: *anyopaque) bool = null, drawRadialMenu: ?*const fn (state: *anyopaque) anyerror!void = null, @@ -92,6 +120,9 @@ pub const VTable = struct { transform: ?*const fn (state: *anyopaque) anyerror!void = null, copy: ?*const fn (state: *anyopaque) anyerror!void = null, paste: ?*const fn (state: *anyopaque) anyerror!void = null, + acceptEdit: ?*const fn (state: *anyopaque) void = null, + cancelEdit: ?*const fn (state: *anyopaque) void = null, + deleteSelection: ?*const fn (state: *anyopaque) void = null, startPackProject: ?*const fn (state: *anyopaque) anyerror!void = null, isPackingActive: ?*const fn (state: *const anyopaque) bool = null, tickPackJobs: ?*const fn (state: *anyopaque) void = null, @@ -202,6 +233,10 @@ pub fn documentHasNativeExtension(self: Plugin, doc: DocHandle) bool { return if (self.vtable.documentHasNativeExtension) |f| f(self.state, doc) else false; } +pub fn documentHasRecognizedSaveExtension(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.documentHasRecognizedSaveExtension) |f| f(self.state, doc) else false; +} + pub fn showsSaveStatusIndicator(self: Plugin, doc: DocHandle) bool { return if (self.vtable.showsSaveStatusIndicator) |f| f(self.state, doc) else false; } @@ -270,6 +305,14 @@ pub fn redo(self: Plugin, doc: DocHandle) !void { if (self.vtable.redo) |f| try f(self.state, doc); } +pub fn canUndo(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.canUndo) |f| f(self.state, doc) else false; +} + +pub fn canRedo(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.canRedo) |f| f(self.state, doc) else false; +} + // ---- render hook wrappers ---- /// Draw an open document into the current dvui parent (the workbench sets up the @@ -282,6 +325,101 @@ pub fn drawDocument(self: Plugin, doc: DocHandle) !bool { return false; } +pub fn drawDocumentInfobar(self: Plugin, doc: DocHandle) !void { + if (self.vtable.drawDocumentInfobar) |f| try f(self.state, doc); +} + pub fn deinit(self: Plugin) void { if (self.vtable.deinit) |f| f(self.state); } + +pub fn initPlugin(self: Plugin) !void { + if (self.vtable.initPlugin) |f| try f(self.state); +} + +pub fn documentStackSize(self: Plugin) usize { + return if (self.vtable.documentStackSize) |f| f(self.state) else 0; +} + +pub fn documentStackAlign(self: Plugin) usize { + return if (self.vtable.documentStackAlign) |f| f(self.state) else 1; +} + +pub fn documentIdFromBuffer(self: Plugin, doc: *anyopaque) u64 { + return if (self.vtable.documentIdFromBuffer) |f| f(self.state, doc) else 0; +} + +pub fn deinitDocumentBuffer(self: Plugin, doc: *anyopaque) void { + if (self.vtable.deinitDocumentBuffer) |f| f(self.state, doc); +} + +pub fn setDocumentGroupingOnBuffer(self: Plugin, doc: *anyopaque, grouping: u64) void { + if (self.vtable.setDocumentGroupingOnBuffer) |f| f(self.state, doc, grouping); +} + +pub fn createDocument(self: Plugin, path: []const u8, grid: EditorAPI.NewDocGrid, out_doc: *anyopaque) !void { + if (self.vtable.createDocument) |f| try f(self.state, path, grid, out_doc) else return error.Unsupported; +} + +pub fn documentDefaultSaveAsFilename(self: Plugin, doc: DocHandle, allocator: std.mem.Allocator) ![]const u8 { + return if (self.vtable.documentDefaultSaveAsFilename) |f| try f(self.state, doc, allocator) else error.Unsupported; +} + +pub fn saveDocumentAs(self: Plugin, doc: DocHandle, path: []const u8, window: *dvui.Window) !void { + if (self.vtable.saveDocumentAs) |f| try f(self.state, doc, path, window) else return error.Unsupported; +} + +pub fn resetDocumentSaveUIState(self: Plugin, doc: DocHandle) void { + if (self.vtable.resetDocumentSaveUIState) |f| f(self.state, doc); +} + +pub fn prepareGridLayoutDialog(self: Plugin, doc: DocHandle) void { + if (self.vtable.prepareGridLayoutDialog) |f| f(self.state, doc); +} + +pub fn beginFrame(self: Plugin) void { + if (self.vtable.beginFrame) |f| f(self.state); +} + +pub fn tickOpenDocuments(self: Plugin) bool { + return if (self.vtable.tickOpenDocuments) |f| f(self.state) else false; +} + +pub fn tickActiveDocumentPlayback(self: Plugin, timer_host_id: dvui.Id) void { + if (self.vtable.tickActiveDocumentPlayback) |f| f(self.state, timer_host_id); +} + +pub fn resetDocumentPeekLayers(self: Plugin) void { + if (self.vtable.resetDocumentPeekLayers) |f| f(self.state); +} + +pub fn warmupActiveDocumentComposites(self: Plugin) void { + if (self.vtable.warmupActiveDocumentComposites) |f| f(self.state); +} + +pub fn isAnyDocumentActivelyDrawing(self: Plugin) bool { + return if (self.vtable.isAnyDocumentActivelyDrawing) |f| f(self.state) else false; +} + +pub fn acceptEdit(self: Plugin) void { + if (self.vtable.acceptEdit) |f| f(self.state); +} + +pub fn cancelEdit(self: Plugin) void { + if (self.vtable.cancelEdit) |f| f(self.state); +} + +pub fn deleteSelection(self: Plugin) void { + if (self.vtable.deleteSelection) |f| f(self.state); +} + +/// Allocate a buffer suitable for staging `loadDocument` / `createDocument`. Caller frees `backing`. +pub fn allocDocumentBuffer(self: Plugin, allocator: std.mem.Allocator) !struct { backing: []u8, buf: []u8 } { + const size = self.documentStackSize(); + const align_req = self.documentStackAlign(); + if (size == 0 or align_req == 0) return error.Unsupported; + const pad = align_req - 1; + const backing = try allocator.alloc(u8, size + pad); + const offset = std.mem.alignForward(usize, @intFromPtr(backing.ptr), align_req) - @intFromPtr(backing.ptr); + return .{ .backing = backing, .buf = backing[offset..][0..size] }; +} From a2a0e2200660ceac7612790b9802656332ef8246 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 08:10:27 -0500 Subject: [PATCH 26/49] dialogs and project --- HANDOFF.md | 78 ++++++++++++++++--- src/editor/Editor.zig | 57 +++----------- src/editor/dialogs/Dialogs.zig | 20 +---- src/editor/dialogs/UnsavedClose.zig | 5 +- src/editor/explorer/Explorer.zig | 4 +- src/plugins/pixelart/src/CanvasData.zig | 5 +- .../src/dialogs/FlatRasterSaveWarning.zig | 26 +++---- .../pixelart/src/dialogs/GridLayout.zig | 27 +++++++ src/plugins/pixelart/src/dialogs/NewFile.zig | 21 +++++ src/plugins/pixelart/src/doc_lifecycle.zig | 6 -- src/plugins/pixelart/src/plugin.zig | 20 ++++- src/plugins/workbench/src/files.zig | 32 +------- src/sdk/EditorAPI.zig | 5 -- src/sdk/Host.zig | 17 +++- src/sdk/Plugin.zig | 40 ++++++++-- 15 files changed, 210 insertions(+), 153 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 7af1f424..6da744d8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -20,12 +20,15 @@ Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart **Sprite/atlas → `core` big rock: DONE** (verified — generic atlas type + sprite-draw primitive + sprite-id index all in `core`; neither shell nor plugin reaches the other's atlas). -**Next:** the only remaining shell→plugin concrete reaches are `pixelart.dialogs.*` + -`pixelart.explorer.project` — needs a generic dialog-registry vtable to lift (deferred). -Then: wire `b.addModule("workbench", …)` + lift workbench off `fizzy.editor` (logo atlas draw). +**Dialog-registry lift — DONE** (see "Multi-plugin readiness"): the shell no longer names any +pixel-art dialog. `pixelart.dialogs` is gone from `src/editor` + `src/plugins/workbench`. -> **Read this first if you're a fresh agent:** Stage D/E are done bar the dialog-registry -> lift. All three build configs are green right now. +**Next:** wire `b.addModule("workbench", …)` + lift workbench off `fizzy.editor` +(logo atlas draw, `fizzy.editor.host.requestNewDocument`, etc.). + +> **Read this first if you're a fresh agent:** Stage D/E + the dialog-registry lift are done. +> Shell→pixelart surface is now only `pixelart.plugin` (vtable) + `State`/`Globals` (lifecycle). +> All three build configs are green right now. All three build configs are green: @@ -289,15 +292,66 @@ app code until the build module is fully wired. directly. `Editor.frame` now calls `plugin.beginFrame()` for every registered plugin; the pixel-art impl advances its own composite-cache frame clock. **No `pixelart.render` in shell.** - ✅ Removed dead `pixelart`/`Packer` imports from `editor/panel/Panel.zig`. +- ✅ Removed dead `pixelart.explorer.project` re-export from `editor/explorer/Explorer.zig` + (the project view is contributed via `Host.registerSidebarView`, not the shell hub). +- ✅ Removed dead `Plugin.drawBottomPanel` / `drawExplorerPane` vtable hooks — superseded by + the `registerSidebarView` / `registerBottomView` registries (see "Multi-plugin readiness"). + +- ✅ **Dialog-registry lift** (see "Multi-plugin readiness"): all pixel-art dialogs lifted off + the shell hub onto plugin vtable hooks. `editor/dialogs/Dialogs.zig` no longer imports + `pixelart`; owns only shell-level dialogs (UnsavedClose, AppQuitUnsaved, AboutFizzy, Web*). **Shell → plugin surface now (grep `pixelart\.X` in `src/editor` + `src/plugins/workbench`):** -`pixelart.plugin` ×15 (the vtable boundary — intended), `pixelart.dialogs` ×6, -`pixelart.State` ×2, `pixelart.Globals` ×2, `pixelart.explorer` ×1, -`"pixelart.menu.edit"` ×1 (a registered-menu **id string**, not a symbol ref). -The remaining real reaches are `pixelart.dialogs.*` (NewFile/Export/GridLayout/ -FlatRasterSaveWarning/DimensionsLabel re-exported by `editor/dialogs/Dialogs.zig` + -`UnsavedClose.zig`) and `pixelart.explorer.project` — concrete pixel-art UI the shell still -constructs directly. Lifting these needs a generic dialog-registry vtable; deferred, not blocking. +`pixelart.plugin` ×15 (the vtable boundary — intended), `pixelart.State` ×2, +`pixelart.Globals` ×2, `"pixelart.menu.edit"` ×1 (a registered-menu **id string**, not a +symbol ref). **No concrete pixel-art type (dialogs/render/explorer/Packer) is named in the +shell anymore** — only the plugin vtable boundary + lifecycle. + +--- + +## Multi-plugin readiness (context for the upcoming **textedit** plugin) + +> Direction (user, 2026-06-19): a textedit plugin will render `.txt`/`.atlas`/`.json` etc., +> coexisting in tabs/splits beside pixel-art docs. The bottom panel should likewise host +> per-plugin tabs (a console plugin one day). **This is NOT current scope** — captured here +> so the decoupling doesn't bake in single-plugin assumptions. + +**Audit result (this session): the architecture is already positioned for all of it.** + +| Concern | Mechanism today | textedit slots in by | +|---------|-----------------|----------------------| +| Which plugin owns an opened file | `Host.pluginForExtension(ext)` picks lowest `fileTypePriority` across **all** plugins (`Host.zig`) | registering `.txt/.atlas/.json` with a priority | +| Per-document ops (save/dirty/undo/path/grouping/…) | all route through `DocHandle.owner` vtable (opaque handle; shell never inspects `ptr`) | implementing the doc vtable hooks | +| Rendering a doc into a tab/split | `Workspace.zig` calls `doc.owner.drawDocument(doc)` — type-agnostic | implementing `drawDocument` | +| Sidebar/explorer panes | `Host.registerSidebarView(.{id,owner,title,draw[,draw_workspace]})`; shell renders the set (`Sidebar.zig`) | calling `registerSidebarView` | +| **Bottom panel tabs** | `Host.registerBottomView(.{id,owner,title,draw})`; `Panel.zig` draws a **tab strip when >1 view** + active-view get/set on `Host` | calling `registerBottomView` (a console is just another bottom view) | +| Menus | `Host.registerMenu` + `contributeMenu` | registering its menus | + +So tabs/splits and multi-plugin bottom panels are **already** registry-driven, not +pixelart-hardcoded. No corner-painting risk found. + +**Dialogs — lifted (was the one single-plugin seam, now DONE).** All pixel-art dialog launches +moved out of the shell hub onto the plugin; the shell never names a plugin dialog: + +- **Doc-scoped dialogs** route through `DocHandle.owner` vtable hooks (added to `sdk/Plugin.zig`): + - `requestGridLayoutDialog(doc)` — shell `Editor.requestGridLayoutDialog` resolves the active + doc and dispatches; launch + `presetFromFile` now live in `dialogs/GridLayout.request`. + Removed the old `prepareGridLayoutDialog` hook and the `EditorAPI.requestGridLayoutDialog` + round-trip (plugin `CanvasData` calls `GridLayout.request` directly now). + - `requestFlatRasterSaveWarning(doc, mode, from_save_all_quit)` — `mode` is the new SDK enum + `Plugin.FlatRasterSaveMode {editor_save, save_and_close}`. The save/quit flag is now captured + per-dialog in a `_flat_raster_from_quit` data slot instead of an externally-reset module var, + so `Editor.abortSaveAllQuit` no longer pokes dialog state. +- **Type-selecting dialog** (not doc-scoped): `Host.requestNewDocument(parent_path, id_extra)` + dispatches to the first plugin advertising `requestNewDocumentDialog` (vtable). Shell + `Editor.requestNewFileDialog` and `workbench/files.zig` "New File…" call the Host method; + launch lives in `dialogs/NewFile.request`. + **TODO(multi-plugin):** with textedit registered, "New File" is ambiguous — turn this into a + typed `New > ` chooser (each editor plugin contributes a new-doc kind) instead of + first-provider dispatch. The seam (shell decoupled from the dialog impl) is already in place. + +Dead dialog re-exports removed in the same pass: `Dialogs.Export`, `Dialogs.drawDimensionsLabel` +(both had zero shell callers). --- diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index eece5348..83108d85 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -572,7 +572,6 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .transform = shellTransform, .save = shellSave, .requestCompositeWarmup = shellRequestCompositeWarmup, - .requestGridLayoutDialog = shellRequestGridLayoutDialog, .allocUntitledPath = shellAllocUntitledPath, .createDocument = shellCreateDocument, .setExplorerNewFilePath = shellSetExplorerNewFilePath, @@ -684,9 +683,6 @@ fn shellSave(ctx: *anyopaque) anyerror!void { fn shellRequestCompositeWarmup(ctx: *anyopaque) void { shellCtx(ctx).requestCompositeWarmup(); } -fn shellRequestGridLayoutDialog(ctx: *anyopaque) void { - shellCtx(ctx).requestGridLayoutDialog(); -} fn shellAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 { return shellCtx(ctx).allocNextUntitledPath(); } @@ -1792,7 +1788,6 @@ pub fn drawWorkspaces(editor: *Editor, index: usize) !dvui.App.Result { } pub fn abortSaveAllQuit(editor: *Editor) void { - Dialogs.FlatRasterSaveWarning.pending_from_save_all_quit = false; editor.quit_save_all_ids.clearAndFree(fizzy.app.allocator); editor.quit_saves_in_flight.clearRetainingCapacity(); editor.quit_in_progress = false; @@ -1866,8 +1861,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { // Flat-raster prompt is a modal dialog — same reason as Save As, do // it serially and rejoin afterwards. if (editor.open_files.getIndex(id)) |idx| editor.setActiveFile(idx); - Dialogs.FlatRasterSaveWarning.pending_from_save_all_quit = true; - Dialogs.FlatRasterSaveWarning.request(id, .save_and_close); + doc.owner.requestFlatRasterSaveWarning(doc, .save_and_close, true); return; } @@ -2375,46 +2369,17 @@ pub fn allocNextUntitledPath(editor: *Editor) ![]u8 { return std.fmt.allocPrint(fizzy.app.allocator, "untitled-{d}", .{max_n + 1}); } -/// Opens the Grid Layout dialog for the active file. Uses a custom `windowFn` that matches -/// `dialogWindow`'s open animation while capping the window to half the main window size; the -/// dialog can still be resized afterward. -/// The dialog rebinds the active file via the `_grid_layout_file_id` data slot so the form and -/// preview can survive frames where `fizzy.editor.activeFile()` momentarily returns null. +/// Opens the active document owner's grid-layout dialog. The shell only resolves the active +/// document and dispatches to `doc.owner`; the dialog itself is owned by the plugin. pub fn requestGridLayoutDialog(editor: *Editor) void { const doc = editor.activeDoc() orelse return; - doc.owner.prepareGridLayoutDialog(doc); - - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = Dialogs.GridLayout.dialog, - .callafterFn = Dialogs.GridLayout.callAfter, - .windowFn = Dialogs.GridLayout.windowFn, - .title = "Grid Layout...", - .ok_label = "Apply", - .cancel_label = "Cancel", - .resizeable = true, - .header_kind = .info, - .default = .ok, - }); - dvui.dataSet(null, mutex.id, "_grid_layout_file_id", doc.id); - // Let `GridLayout.windowFn` run `autoSize` only until the open animation finishes; otherwise - // `auto_size` stays true every frame and the shell snaps back to content min (user resize breaks). - dvui.dataSet(null, mutex.id, "_grid_dialog_open_done", false); - mutex.mutex.unlock(dvui.io); -} - -/// Opens the New File dimensions dialog; on confirm, creates an in-memory `untitled-n` document (or on-disk from explorer when `_parent_path` is set). -pub fn requestNewFileDialog(_: *Editor) void { - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = Dialogs.NewFile.dialog, - .callafterFn = Dialogs.NewFile.callAfter, - .title = "New File...", - .ok_label = "Create", - .cancel_label = "Cancel", - .resizeable = false, - .header_kind = .info, - .default = .ok, - }); - mutex.mutex.unlock(dvui.io); + doc.owner.requestGridLayoutDialog(doc); +} + +/// Opens the New File dialog via the plugin that provides one (dispatched by `Host`); on confirm +/// the owner creates an in-memory `untitled-n` document (or on-disk when a parent folder is set). +pub fn requestNewFileDialog(editor: *Editor) void { + editor.host.requestNewDocument(null, 0); } pub fn setActiveFile(editor: *Editor, index: usize) void { @@ -2473,7 +2438,7 @@ pub fn save(editor: *Editor) !void { return; } if (doc.owner.shouldConfirmFlatRasterSave(doc)) { - Dialogs.FlatRasterSaveWarning.request(doc.id, .editor_save); + doc.owner.requestFlatRasterSaveWarning(doc, .editor_save, false); return; } if (comptime builtin.target.cpu.arch == .wasm32) { diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig index b851d228..629a9c79 100644 --- a/src/editor/dialogs/Dialogs.zig +++ b/src/editor/dialogs/Dialogs.zig @@ -1,16 +1,13 @@ -const std = @import("std"); const builtin = @import("builtin"); -const pixelart = @import("pixelart"); const dvui = @import("dvui"); const Dialogs = @This(); -pub const NewFile = pixelart.dialogs.NewFile; -pub const Export = pixelart.dialogs.Export; +// Plugin-owned dialogs (New File, Grid Layout, Export, Flat-raster save warning) are no longer +// re-exported here. The shell triggers them through plugin vtable hooks / `Host.requestNewDocument` +// so it never names a plugin's dialog implementation. This hub owns only shell-level dialogs. pub const UnsavedClose = @import("UnsavedClose.zig"); pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig"); -pub const GridLayout = pixelart.dialogs.GridLayout; -pub const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning; pub const AboutFizzy = @import("AboutFizzy.zig"); pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32) @import("WebFolderUnavailable.zig") @@ -31,14 +28,3 @@ else return false; } }; - -pub fn drawDimensionsLabel( - src: std.builtin.SourceLocation, - width: u32, - height: u32, - font: dvui.Font, - unit: []const u8, - opts: dvui.Options, -) void { - pixelart.dialogs.DimensionsLabel.drawDimensionsLabel(src, width, height, font, unit, opts); -} diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index 7aa149b7..8c3d12ba 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -1,8 +1,6 @@ const std = @import("std"); const fizzy = @import("../../fizzy.zig"); -const pixelart = @import("pixelart"); const dvui = @import("dvui"); -const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning; pub fn request(file_id: u64) void { var mutex = fizzy.dvui.dialog(@src(), .{ @@ -118,9 +116,8 @@ fn onSaveAndClose(file_id: u64) !void { return; } if (doc.owner.shouldConfirmFlatRasterSave(doc)) { - FlatRasterSaveWarning.pending_from_save_all_quit = false; fizzy.dvui.closeFloatingDialogAnchored(); - FlatRasterSaveWarning.request(file_id, .save_and_close); + doc.owner.requestFlatRasterSaveWarning(doc, .save_and_close, false); return; } try beginSaveAndClose(doc, file_id); diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 7af9aed0..7469271e 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -7,7 +7,6 @@ const icons = @import("icons"); const Core = @import("mach").Core; const App = fizzy.App; const Editor = fizzy.Editor; -const pixelart = @import("pixelart"); const nfd = @import("nfd"); @@ -16,7 +15,8 @@ pub const Explorer = @This(); pub const files = @import("../../plugins/workbench/src/files.zig"); // pub const animations = @import("animations.zig"); // pub const keyframe_animations = @import("keyframe_animations.zig"); -pub const project = pixelart.explorer.project; +// The pixel-art project view is contributed by the plugin via `Host.registerSidebarView`, +// not re-exported here. pub const settings = @import("settings.zig"); paned: *fizzy.dvui.PanedWidget = undefined, diff --git a/src/plugins/pixelart/src/CanvasData.zig b/src/plugins/pixelart/src/CanvasData.zig index cdb1d48b..2c13e4a9 100644 --- a/src/plugins/pixelart/src/CanvasData.zig +++ b/src/plugins/pixelart/src/CanvasData.zig @@ -14,6 +14,7 @@ const dvui = @import("dvui"); const icons = @import("icons"); const FileWidget = @import("widgets/FileWidget.zig"); const Export = @import("dialogs/Export.zig"); +const GridLayout = @import("dialogs/GridLayout.zig"); const pixelart = @import("../pixelart.zig"); const Globals = pixelart.Globals; @@ -1054,7 +1055,9 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { .transform => Globals.state.host.transform() catch { dvui.log.err("Failed to start transform", .{}); }, - .grid_layout => Globals.state.host.requestGridLayoutDialog(), + .grid_layout => { + if (Globals.state.host.activeDoc()) |doc| GridLayout.request(doc.id); + }, } } } diff --git a/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig index d301db5b..213c350b 100644 --- a/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig +++ b/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig @@ -3,21 +3,15 @@ const dvui = @import("dvui"); const pixelart = @import("../../pixelart.zig"); const Globals = pixelart.Globals; -/// When `pending_mode == .save_and_close`, resume `Editor.advanceSaveAllQuit` after flat save. -pub var pending_from_save_all_quit: bool = false; +pub const Mode = pixelart.sdk.Plugin.FlatRasterSaveMode; pub var pending_mode: Mode = .editor_save; -pub const Mode = enum { - editor_save, - save_and_close, -}; - -pub fn request(file_id: u64, mode: Mode) void { +/// Open the flat-raster save confirmation for `file_id`. `from_save_all_quit` (whether this +/// request was issued during the shell's quit walk) is captured per-dialog in a data slot so +/// no externally-mutated module flag has to be reset when the quit walk aborts. +pub fn request(file_id: u64, mode: Mode, from_save_all_quit: bool) void { pending_mode = mode; - if (mode == .editor_save) { - pending_from_save_all_quit = false; - } var mutex = pixelart.core.dvui.dialog(@src(), .{ .displayFn = dialog, .callafterFn = callAfter, @@ -31,6 +25,7 @@ pub fn request(file_id: u64, mode: Mode) void { .header_kind = .warning, }); dvui.dataSet(null, mutex.id, "_flat_raster_file_id", file_id); + dvui.dataSet(null, mutex.id, "_flat_raster_from_quit", from_save_all_quit); mutex.mutex.unlock(dvui.io); } @@ -62,6 +57,7 @@ fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: pub fn dialog(id: dvui.Id) anyerror!bool { const file_id = dvui.dataGet(null, id, "_flat_raster_file_id", u64) orelse return false; + const from_quit = dvui.dataGet(null, id, "_flat_raster_from_quit", bool) orelse false; const file = fileRef(file_id) orelse return false; const ext_raw = std.fs.path.extension(file.path); @@ -103,7 +99,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { } _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } }); if (dialogButton(@src(), ext_disp, .control, 2, 1)) { - try onChooseFlatRaster(file_id); + try onChooseFlatRaster(file_id, from_quit); } _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } }); if (dialogButton(@src(), "Cancel", .control, 3, 2)) { @@ -123,7 +119,7 @@ fn onChooseFizzy(file_id: u64) !void { Globals.state.host.requestSaveAs(); } -fn onChooseFlatRaster(file_id: u64) !void { +fn onChooseFlatRaster(file_id: u64, from_save_all_quit: bool) !void { const f = fileRef(file_id) orelse return; switch (pending_mode) { .editor_save => { @@ -144,10 +140,10 @@ fn onChooseFlatRaster(file_id: u64) !void { // otherwise this is a single-doc save-and-close. f.saveAsync() catch |err| { dvui.log.err("Save failed: {s}", .{@errorName(err)}); - if (pending_from_save_all_quit) Globals.state.host.abortSaveAllQuit(); + if (from_save_all_quit) Globals.state.host.abortSaveAllQuit(); return; }; - if (pending_from_save_all_quit) { + if (from_save_all_quit) { Globals.state.host.trackQuitSaveInFlight(file_id) catch |err| { dvui.log.err("Save all quit track: {s}", .{@errorName(err)}); Globals.state.host.abortSaveAllQuit(); diff --git a/src/plugins/pixelart/src/dialogs/GridLayout.zig b/src/plugins/pixelart/src/dialogs/GridLayout.zig index 9f021824..b45f942c 100644 --- a/src/plugins/pixelart/src/dialogs/GridLayout.zig +++ b/src/plugins/pixelart/src/dialogs/GridLayout.zig @@ -85,6 +85,33 @@ const anchors: [9]pixelart.math.layout_anchor.LayoutAnchor = .{ const anchor_labels = [_][]const u8{ "NW", "N", "NE", "W", "C", "E", "SW", "S", "SE" }; +/// Open the Grid Layout dialog for the document `file_id`. Seeds the form from the file's +/// current grid, then launches the floating dialog. Uses a custom `windowFn` that matches +/// `dialogWindow`'s open animation while capping the window to half the main window size. +/// The `_grid_layout_file_id` slot rebinds the active file so the form/preview survive frames +/// where the active document momentarily resolves null. +pub fn request(file_id: u64) void { + const file = Globals.state.docs.fileById(file_id) orelse return; + presetFromFile(file); + + var mutex = pixelart.core.dvui.dialog(@src(), .{ + .displayFn = dialog, + .callafterFn = callAfter, + .windowFn = windowFn, + .title = "Grid Layout...", + .ok_label = "Apply", + .cancel_label = "Cancel", + .resizeable = true, + .header_kind = .info, + .default = .ok, + }); + dvui.dataSet(null, mutex.id, "_grid_layout_file_id", file_id); + // Let `windowFn` run `autoSize` only until the open animation finishes; otherwise + // `auto_size` stays true every frame and the shell snaps back to content min (user resize breaks). + dvui.dataSet(null, mutex.id, "_grid_dialog_open_done", false); + mutex.mutex.unlock(dvui.io); +} + /// Seed both mode forms with the active file's current grid so the dialog opens "no-op" by default. pub fn presetFromFile(file: *pixelart.internal.File) void { resize_form = .{ diff --git a/src/plugins/pixelart/src/dialogs/NewFile.zig b/src/plugins/pixelart/src/dialogs/NewFile.zig index c9950e30..6a221ab8 100644 --- a/src/plugins/pixelart/src/dialogs/NewFile.zig +++ b/src/plugins/pixelart/src/dialogs/NewFile.zig @@ -18,6 +18,27 @@ pub var row_height: u32 = 32; pub const max_size: [2]u32 = .{ 4096, 4096 }; pub const min_size: [2]u32 = .{ 1, 1 }; +/// Open the "New File" dimensions dialog. When `parent_path` is set the new document is created +/// on disk inside that folder (explorer-initiated); otherwise an in-memory `untitled-n` is made. +/// `id_extra` disambiguates dialogs launched from distinct explorer rows. +pub fn request(parent_path: ?[]const u8, id_extra: usize) void { + var mutex = pixelart.core.dvui.dialog(@src(), .{ + .displayFn = dialog, + .callafterFn = callAfter, + .title = "New File...", + .ok_label = "Create", + .cancel_label = "Cancel", + .resizeable = false, + .header_kind = .info, + .default = .ok, + .id_extra = id_extra, + }); + // `dataSetSlice` copies the bytes into dvui's per-widget store, so the borrowed slice + // only needs to be valid for this call. + if (parent_path) |p| dvui.dataSetSlice(null, mutex.id, "_parent_path", p); + mutex.mutex.unlock(dvui.io); +} + pub fn dialog(id: dvui.Id) anyerror!bool { const entry_font = dvui.Font.theme(.mono).larger(-2); diff --git a/src/plugins/pixelart/src/doc_lifecycle.zig b/src/plugins/pixelart/src/doc_lifecycle.zig index 69f1b3f5..b84071a1 100644 --- a/src/plugins/pixelart/src/doc_lifecycle.zig +++ b/src/plugins/pixelart/src/doc_lifecycle.zig @@ -8,7 +8,6 @@ const State = pixelart.State; const Internal = pixelart.internal; const DocHandle = pixelart.sdk.DocHandle; const NewDocGrid = pixelart.sdk.EditorAPI.NewDocGrid; -const GridLayout = @import("dialogs/GridLayout.zig"); fn docFile(st: *State, doc: DocHandle) ?*Internal.File { return st.docs.fileById(doc.id); @@ -74,11 +73,6 @@ pub fn resetDocumentSaveUIState(st: *State, doc: DocHandle) void { file.resetSaveUIState(); } -pub fn prepareGridLayoutDialog(st: *State, doc: DocHandle) void { - const file = docFile(st, doc) orelse return; - GridLayout.presetFromFile(file); -} - pub fn tickOpenDocuments(st: *State) bool { var needs_save_status_anim_tick = false; for (st.docs.files.values()) |*file| { diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig index d0ecfade..158689bb 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -22,6 +22,9 @@ const DocsRegistry = @import("docs_registry.zig"); const DocBridge = @import("doc_bridge.zig"); const DocLifecycle = @import("doc_lifecycle.zig"); const InfobarStatus = @import("infobar_status.zig"); +const GridLayout = @import("dialogs/GridLayout.zig"); +const FlatRasterSaveWarning = @import("dialogs/FlatRasterSaveWarning.zig"); +const NewFile = @import("dialogs/NewFile.zig"); const DocHandle = sdk.DocHandle; const Internal = pixelart.internal; @@ -78,7 +81,9 @@ const vtable: sdk.Plugin.VTable = .{ .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename, .saveDocumentAs = saveDocumentAs, .resetDocumentSaveUIState = resetDocumentSaveUIState, - .prepareGridLayoutDialog = prepareGridLayoutDialog, + .requestNewDocumentDialog = requestNewDocumentDialog, + .requestGridLayoutDialog = requestGridLayoutDialog, + .requestFlatRasterSaveWarning = requestFlatRasterSaveWarning, .drawDocument = drawDocument, .drawDocumentInfobar = drawDocumentInfobar, .beginFrame = beginFrame, @@ -537,9 +542,16 @@ fn resetDocumentSaveUIState(state: *anyopaque, doc: DocHandle) void { DocLifecycle.resetDocumentSaveUIState(st, doc); } -fn prepareGridLayoutDialog(state: *anyopaque, doc: DocHandle) void { - const st: *State = @ptrCast(@alignCast(state)); - DocLifecycle.prepareGridLayoutDialog(st, doc); +fn requestNewDocumentDialog(_: *anyopaque, parent_path: ?[]const u8, id_extra: usize) void { + NewFile.request(parent_path, id_extra); +} + +fn requestGridLayoutDialog(_: *anyopaque, doc: DocHandle) void { + GridLayout.request(doc.id); +} + +fn requestFlatRasterSaveWarning(_: *anyopaque, doc: DocHandle, mode: sdk.Plugin.FlatRasterSaveMode, from_save_all_quit: bool) void { + FlatRasterSaveWarning.request(doc.id, mode, from_save_all_quit); } fn beginFrame(state: *anyopaque) void { diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index b670a99c..117c9e30 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -288,20 +288,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u if ((dvui.menuItemLabel(@src(), "New File...", .{}, .{ .expand = .horizontal })) != null) { defer fw2.close(); - const parent_owned = try dvui.currentWindow().arena().dupe(u8, project_path); - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = fizzy.Editor.Dialogs.NewFile.dialog, - .callafterFn = fizzy.Editor.Dialogs.NewFile.callAfter, - .title = "New File...", - .ok_label = "Create", - .cancel_label = "Cancel", - .resizeable = false, - .header_kind = .info, - .default = .ok, - .id_extra = root_branch_id.asUsize(), - }); - dvui.dataSetSlice(null, mutex.id, "_parent_path", parent_owned); - mutex.mutex.unlock(dvui.io); + fizzy.editor.host.requestNewDocument(project_path, root_branch_id.asUsize()); } if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) { @@ -696,22 +683,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg defer fw2.close(); const parent_dir: []const u8 = if (entry.kind == .directory) abs_path else directory; - const parent_owned = try dvui.currentWindow().arena().dupe(u8, parent_dir); - // Create a generic dialog that contains typical okay and cancel buttons and header - // The displayFn will be called during the drawing of the dialog, prior to ok and cancel buttons - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = fizzy.Editor.Dialogs.NewFile.dialog, - .callafterFn = fizzy.Editor.Dialogs.NewFile.callAfter, - .title = "New File...", - .ok_label = "Create", - .cancel_label = "Cancel", - .resizeable = false, - .header_kind = .info, - .default = .ok, - .id_extra = branch_id.asUsize(), - }); - dvui.dataSetSlice(null, mutex.id, "_parent_path", parent_owned); - mutex.mutex.unlock(dvui.io); + fizzy.editor.host.requestNewDocument(parent_dir, branch_id.asUsize()); } if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) { diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index 882589bb..83ed48f5 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -109,7 +109,6 @@ pub const VTable = struct { transform: *const fn (ctx: *anyopaque) anyerror!void, save: *const fn (ctx: *anyopaque) anyerror!void, requestCompositeWarmup: *const fn (ctx: *anyopaque) void, - requestGridLayoutDialog: *const fn (ctx: *anyopaque) void, // ---- new document ---- /// Heap-owned unique basename like `untitled-1`; caller frees with the app allocator. @@ -244,10 +243,6 @@ pub fn requestCompositeWarmup(self: EditorAPI) void { self.vtable.requestCompositeWarmup(self.ctx); } -pub fn requestGridLayoutDialog(self: EditorAPI) void { - self.vtable.requestGridLayoutDialog(self.ctx); -} - pub fn allocUntitledPath(self: EditorAPI) ![]u8 { return self.vtable.allocUntitledPath(self.ctx); } diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 392278e5..fb43b5f6 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -212,9 +212,6 @@ pub fn requestCompositeWarmup(self: *Host) void { if (self.shell_api) |a| a.requestCompositeWarmup(); } -pub fn requestGridLayoutDialog(self: *Host) void { - if (self.shell_api) |a| a.requestGridLayoutDialog(); -} pub fn allocUntitledPath(self: *Host) ![]u8 { return if (self.shell_api) |a| try a.allocUntitledPath() else error.ShellNotInstalled; @@ -398,3 +395,17 @@ pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin { } return best; } + +/// Open a "new document" dialog. `parent_path` (when set) targets an on-disk folder; `id_extra` +/// disambiguates launches from distinct explorer rows. Dispatches to the first plugin that +/// provides a new-document dialog. +/// TODO(multi-plugin): with >1 editor plugin, present a typed "New > " chooser instead of +/// picking the first provider. +pub fn requestNewDocument(self: *Host, parent_path: ?[]const u8, id_extra: usize) void { + for (self.plugins.items) |plugin| { + if (plugin.vtable.requestNewDocumentDialog) |f| { + f(plugin.state, parent_path, id_extra); + return; + } + } +} diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index 3f0e8de7..4e75836d 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -23,6 +23,11 @@ id: []const u8, /// User-facing name shown in UI. display_name: []const u8, +/// Context for an owner's "save would flatten lossy data" confirmation +/// (`requestFlatRasterSaveWarning`). `editor_save` is a plain in-place save; `save_and_close` +/// is part of a close/quit flow and resumes the shell close walk once the save settles. +pub const FlatRasterSaveMode = enum { editor_save, save_and_close }; + pub const VTable = struct { /// Tear down `state`. Called when the plugin is unregistered / app shuts down. deinit: ?*const fn (state: *anyopaque) void = null, @@ -86,15 +91,26 @@ pub const VTable = struct { documentDefaultSaveAsFilename: ?*const fn (state: *anyopaque, doc: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 = null, saveDocumentAs: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8, window: *dvui.Window) anyerror!void = null, resetDocumentSaveUIState: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, - prepareGridLayoutDialog: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, + /// Open the owner's "new document" dialog. Not doc-scoped — the host dispatches to a plugin + /// that provides one (see `Host.requestNewDocument`). `parent_path` (when set) creates the + /// document on disk in that folder; `id_extra` disambiguates per-explorer-row launches. + /// TODO(multi-plugin): with >1 editor plugin this becomes a typed "New > " chooser. + requestNewDocumentDialog: ?*const fn (state: *anyopaque, parent_path: ?[]const u8, id_extra: usize) void = null, + /// Open the owner's grid-layout dialog for `doc` (pixel-art specific; the shell only + /// resolves the active doc and dispatches here so it never names the plugin's dialog). + requestGridLayoutDialog: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, + /// Open the owner's "save would flatten lossy data" confirmation for `doc`. The shell calls + /// this when `shouldConfirmFlatRasterSave(doc)` is true; the dialog drives the save through + /// the shell save/close API. `from_save_all_quit` marks requests issued during the quit walk. + requestFlatRasterSaveWarning: ?*const fn (state: *anyopaque, doc: DocHandle, mode: FlatRasterSaveMode, from_save_all_quit: bool) void = null, // ---- render hooks (the plugin draws its own dvui UI into the host window) ---- - /// Draw the plugin's explorer/sidebar pane (left region). - drawExplorerPane: ?*const fn (state: *anyopaque) anyerror!void = null, - /// Draw an open document (center/workspace region). + // Sidebar/explorer panes and bottom-panel tabs are NOT vtable hooks — plugins + // contribute them as named, owned views via `Host.registerSidebarView` / + // `Host.registerBottomView`, which the shell renders as tab strips when more than + // one is registered. Only per-document rendering routes through the vtable below. + /// Draw an open document (center/workspace region), dispatched via `DocHandle.owner`. drawDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, - /// Draw the plugin's bottom panel content. - drawBottomPanel: ?*const fn (state: *anyopaque) anyerror!void = null, /// Draw active-document status into the shell infobar (dimensions, cursor, etc.). drawDocumentInfobar: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, @@ -373,8 +389,16 @@ pub fn resetDocumentSaveUIState(self: Plugin, doc: DocHandle) void { if (self.vtable.resetDocumentSaveUIState) |f| f(self.state, doc); } -pub fn prepareGridLayoutDialog(self: Plugin, doc: DocHandle) void { - if (self.vtable.prepareGridLayoutDialog) |f| f(self.state, doc); +pub fn requestFlatRasterSaveWarning(self: Plugin, doc: DocHandle, mode: FlatRasterSaveMode, from_save_all_quit: bool) void { + if (self.vtable.requestFlatRasterSaveWarning) |f| f(self.state, doc, mode, from_save_all_quit); +} + +pub fn requestNewDocumentDialog(self: Plugin, parent_path: ?[]const u8, id_extra: usize) void { + if (self.vtable.requestNewDocumentDialog) |f| f(self.state, parent_path, id_extra); +} + +pub fn requestGridLayoutDialog(self: Plugin, doc: DocHandle) void { + if (self.vtable.requestGridLayoutDialog) |f| f(self.state, doc); } pub fn beginFrame(self: Plugin) void { From 5a067c353d94536d689cd5bceff845fac43811de Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 09:25:32 -0500 Subject: [PATCH 27/49] lift workbench --- HANDOFF.md | 31 ++++++++++ src/App.zig | 7 +++ src/editor/Editor.zig | 6 ++ src/plugins/workbench/src/Globals.zig | 15 +++++ src/plugins/workbench/src/Workspace.zig | 78 ++++++++++++------------- src/plugins/workbench/src/files.zig | 11 ++-- src/sdk/EditorAPI.zig | 7 +++ src/sdk/Host.zig | 4 ++ 8 files changed, 113 insertions(+), 46 deletions(-) create mode 100644 src/plugins/workbench/src/Globals.zig diff --git a/HANDOFF.md b/HANDOFF.md index 6da744d8..f5bc41de 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -355,6 +355,37 @@ Dead dialog re-exports removed in the same pass: `Dialogs.Export`, `Dialogs.draw --- +## Stage W — workbench lift (IN PROGRESS, user signed off 2026-06-19) + +Workbench is the last "half-shell" plugin: 225 `fizzy` refs (163 `fizzy.editor`) across +`files.zig`, `Workspace.zig`, `Workbench.zig`, `FileLoadJob.zig`, `plugin.zig`. Unlike pixelart +it has **no state-injection yet** — `plugin.state = undefined`, draw hooks call +`fizzy.editor.*` directly, and the `Workbench` struct instance lives on `Editor`. Tab order *is* +the order of `Editor.open_files`, which workbench mutates in place (`std.mem.swap` on +values/keys at `Workspace.zig:467+`) — that's the deep coupling. + +**Plan (mirrors pixelart Stage C–E), each stage builds all 3 configs green:** + +- **W1 — host-injection seam + doc-collection routing — DONE.** Added + `workbench/src/Globals.zig` (`host: *sdk.Host`, `gpa`), injected in `App.zig` (path import + until W5). Added `EditorAPI.swapDocs(a,b)` primitive (+ Host forwarder + shell impl) — the + only mutation of open-doc *order* plugins do; replaces workbench's in-place `std.mem.swap` + on `open_files`. Converted in `Workspace.zig` + `files.zig`: `open_files.count/.values().len` + → `Globals.host.openDocCount()`, `open_files.values()[i]`/`docAt` → `docByIndex`, + `open_files.getIndex` → `docIndex`, `setActiveFile` → `setActiveDocIndex`, + `fizzy.editor.host` → `Globals.host`. **Workbench `fizzy.editor` refs: 163 → 106.** +- **W2 — workspace/grouping ownership.** Move `workspaces`, `open_workspace_grouping`, + grouping-id counters (`newGroupingID`/`currentGroupingID`), and file-tree tab drag-drop + state (`tab_drag_from_tree_path`/`file_tree_data_id`/`clearFileTreeTabDragDropState`, today + shared with shell `Explorer`/`Editor`) onto the `Workbench` struct; shell routes through it. +- **W3 — remaining `fizzy.editor.*` (doc ops, folder/settings/recents/atlas) → EditorAPI/Host.** + Add missing EditorAPI surface as needed (`folder`, `setProjectFolder`, `openFilePath`, …). +- **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core**; then + **W5 — `b.addModule("workbench")`** + `@import("workbench")`, drop the shell path imports + (`Editor.zig` re-exports of `Workspace`/`FileLoadJob`/`Workbench`) and the `fizzy` import. + +--- + ## Next big rock: sprite / atlas → `core` — DONE End-state achieved. Verified this session: diff --git a/src/App.zig b/src/App.zig index ef0076a2..eb9c0eb5 100644 --- a/src/App.zig +++ b/src/App.zig @@ -9,6 +9,8 @@ const icon = assets.files.@"icon.png"; const fizzy = @import("fizzy.zig"); const pixelart = @import("pixelart"); +// Path import until workbench becomes a build module (Stage W5); see HANDOFF "Stage W". +const WorkbenchGlobals = @import("plugins/workbench/src/Globals.zig"); const auto_update = @import("backend/auto_update.zig"); const update_notify = @import("backend/update_notify.zig"); const singleton = @import("backend/singleton.zig"); @@ -166,6 +168,11 @@ pub fn AppInit(win: *dvui.Window) !void { fizzy.editor = try allocator.create(Editor); fizzy.editor.* = Editor.init(fizzy.app) catch unreachable; + // Workbench plugin runtime injection (Stage W): host + allocator, so workbench code + // reaches the EditorAPI surface without importing `fizzy.zig`. Mirrors pixelart.Globals. + WorkbenchGlobals.gpa = allocator; + WorkbenchGlobals.host = &fizzy.editor.host; + // Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created // before `postInit` so the pixel-art plugin's `register` can adopt it as its // `state`. Owned on `Editor`; torn down in `AppDeinit`. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 83108d85..12bed749 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -564,6 +564,7 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .docIndex = shellDocIndex, .openDocCount = shellOpenDocCount, .setActiveDocIndex = shellSetActiveDocIndex, + .swapDocs = shellSwapDocs, .allocDocId = shellAllocDocId, .accept = shellAccept, .cancel = shellCancel, @@ -659,6 +660,11 @@ fn shellOpenDocCount(ctx: *anyopaque) usize { fn shellSetActiveDocIndex(ctx: *anyopaque, index: usize) void { shellCtx(ctx).setActiveFile(index); } +fn shellSwapDocs(ctx: *anyopaque, a: usize, b: usize) void { + const editor = shellCtx(ctx); + std.mem.swap(sdk.DocHandle, &editor.open_files.values()[a], &editor.open_files.values()[b]); + std.mem.swap(u64, &editor.open_files.keys()[a], &editor.open_files.keys()[b]); +} fn shellAllocDocId(ctx: *anyopaque) u64 { return shellCtx(ctx).newFileID(); } diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig new file mode 100644 index 00000000..1bf8393f --- /dev/null +++ b/src/plugins/workbench/src/Globals.zig @@ -0,0 +1,15 @@ +//! Runtime injection points for the workbench plugin (Stage W). +//! +//! The shell sets these once during `App` startup so workbench code can reach the +//! app allocator and the Host (EditorAPI surface) without importing `fizzy.zig`. +//! Mirrors `plugins/pixelart/src/Globals.zig`. +const std = @import("std"); +const workbench = @import("../workbench.zig"); +const sdk = workbench.sdk; + +pub var gpa: std.mem.Allocator = undefined; +pub var host: *sdk.Host = undefined; + +pub fn allocator() std.mem.Allocator { + return gpa; +} diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index c196f430..b74e5ee9 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -5,6 +5,7 @@ const dvui = @import("dvui"); const sdk = @import("sdk"); const pixelart = @import("pixelart"); const fizzy = @import("../../../fizzy.zig"); +const Globals = @import("Globals.zig"); const icons = @import("icons"); const App = fizzy.App; @@ -88,7 +89,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result { // A sidebar view may optionally take over this workspace pane's content region (e.g. pixel // art's "Project" view renders the packed atlas here instead of document tabs+canvas). The // workbench owns only the pane frame; it hands the active view the opaque workspace handle. - const active = fizzy.editor.host.activeSidebarView(); + const active = Globals.host.activeSidebarView(); if (active != null and active.?.draw_workspace != null) { var pane_view: sdk.WorkbenchPaneView = .{ .grouping = self.grouping, @@ -116,7 +117,7 @@ pub fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.B } fn drawTabs(self: *Workspace) void { - if (fizzy.editor.open_files.values().len == 0) return; + if (Globals.host.openDocCount() == 0) return; // Handle dragging of tabs between workspace reorderables (tab bars) defer self.processTabsDrag(); @@ -152,7 +153,7 @@ fn drawTabs(self: *Workspace) void { }); defer tabs_hbox.deinit(); - const files_len = fizzy.editor.open_files.count(); + const files_len = Globals.host.openDocCount(); // Find the neighbouring tabs (within this workspace grouping) of the active tab. var prev_same_group_index: ?usize = null; @@ -161,7 +162,7 @@ fn drawTabs(self: *Workspace) void { const active_in_this_group = blk: { if (fizzy.editor.open_workspace_grouping != self.grouping) break :blk false; if (self.open_file_index >= files_len) break :blk false; - const active_doc = fizzy.editor.docAt(self.open_file_index) orelse break :blk false; + const active_doc = Globals.host.docByIndex(self.open_file_index) orelse break :blk false; if (fizzy.editor.docGrouping(active_doc) != self.grouping) break :blk false; break :blk true; }; @@ -172,7 +173,7 @@ fn drawTabs(self: *Workspace) void { var j: usize = active_index; while (j > 0) { j -= 1; - const tab_doc = fizzy.editor.docAt(j) orelse continue; + const tab_doc = Globals.host.docByIndex(j) orelse continue; if (fizzy.editor.docGrouping(tab_doc) == self.grouping) { prev_same_group_index = j; break; @@ -181,7 +182,7 @@ fn drawTabs(self: *Workspace) void { j = active_index + 1; while (j < files_len) : (j += 1) { - const tab_doc = fizzy.editor.docAt(j) orelse continue; + const tab_doc = Globals.host.docByIndex(j) orelse continue; if (fizzy.editor.docGrouping(tab_doc) == self.grouping) { next_same_group_index = j; break; @@ -190,7 +191,7 @@ fn drawTabs(self: *Workspace) void { } for (0..files_len) |i| { - const doc = fizzy.editor.docAt(i) orelse continue; + const doc = Globals.host.docByIndex(i) orelse continue; const is_fizzy_file = doc.owner.documentHasNativeExtension(doc); if (fizzy.editor.docGrouping(doc) != self.grouping) continue; @@ -427,7 +428,7 @@ fn drawTabs(self: *Workspace) void { switch (e.evt) { .mouse => |me| { if (me.action == .press and me.button.pointer()) { - fizzy.editor.setActiveFile(i); + Globals.host.setActiveDocIndex(i); dvui.refresh(null, @src(), hbox.data().id); e.handle(@src(), hbox.data()); @@ -452,7 +453,7 @@ fn drawTabs(self: *Workspace) void { } } if (tabs.finalSlot()) { - self.tabs_insert_before_index = fizzy.editor.open_files.values().len; + self.tabs_insert_before_index = Globals.host.openDocCount(); } } } @@ -462,20 +463,17 @@ pub fn processTabsDrag(self: *Workspace) void { if (self.tabs_insert_before_index) |insert_before| { if (self.tabs_removed_index) |removed| { // Dragging from this workspace - if (removed > fizzy.editor.open_files.count()) return; + if (removed > Globals.host.openDocCount()) return; if (removed > insert_before) { - std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.setActiveFile(insert_before); + Globals.host.swapDocs(removed, insert_before); + Globals.host.setActiveDocIndex(insert_before); } else { if (insert_before > 0) { - std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]); - fizzy.editor.setActiveFile(insert_before - 1); + Globals.host.swapDocs(removed, insert_before - 1); + Globals.host.setActiveDocIndex(insert_before - 1); } else { - std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.setActiveFile(insert_before); + Globals.host.swapDocs(removed, insert_before); + Globals.host.setActiveDocIndex(insert_before); } } @@ -485,22 +483,18 @@ pub fn processTabsDrag(self: *Workspace) void { for (fizzy.editor.workspaces.values()) |*workspace| { if (workspace.tabs_removed_index) |removed| { if (removed > insert_before) { - std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - - fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before).?, self.grouping); - fizzy.editor.setActiveFile(insert_before); + Globals.host.swapDocs(removed, insert_before); + fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before).?, self.grouping); + Globals.host.setActiveDocIndex(insert_before); } else { if (insert_before > 0) { - std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]); - fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before - 1).?, self.grouping); - fizzy.editor.setActiveFile(insert_before - 1); + Globals.host.swapDocs(removed, insert_before - 1); + fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before - 1).?, self.grouping); + Globals.host.setActiveDocIndex(insert_before - 1); } else { - std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before).?, self.grouping); - fizzy.editor.setActiveFile(insert_before); + Globals.host.swapDocs(removed, insert_before); + fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before).?, self.grouping); + Globals.host.setActiveDocIndex(insert_before); } } @@ -604,7 +598,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); - const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue; + const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; const new_g = fizzy.editor.newGroupingID(); fizzy.editor.setDocGrouping(dragged_doc, new_g); fizzy.editor.open_workspace_grouping = new_g; @@ -624,10 +618,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); - const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue; + const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; fizzy.editor.setDocGrouping(dragged_doc, self.grouping); fizzy.editor.open_workspace_grouping = self.grouping; - self.open_file_index = fizzy.editor.open_files.getIndex(dragged_doc.id) orelse 0; + self.open_file_index = Globals.host.docIndex(dragged_doc.id) orelse 0; } } }, @@ -650,7 +644,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); - const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue; + const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; const new_g = fizzy.editor.newGroupingID(); fizzy.editor.setDocGrouping(dragged_doc, new_g); fizzy.editor.open_workspace_grouping = new_g; @@ -669,10 +663,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { fizzy.editor.clearFileTreeTabDragDropState(); repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); - const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue; + const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; fizzy.editor.setDocGrouping(dragged_doc, self.grouping); fizzy.editor.open_workspace_grouping = self.grouping; - self.open_file_index = fizzy.editor.open_files.getIndex(dragged_doc.id) orelse 0; + self.open_file_index = Globals.host.docIndex(dragged_doc.id) orelse 0; } } }, @@ -753,7 +747,7 @@ pub fn drawCanvas(self: *Workspace) !void { else => {}, } - const has_files = fizzy.editor.open_files.values().len > 0; + const has_files = Globals.host.openDocCount() > 0; var canvas_vbox = workspaceMainCanvasVbox(content_color, has_files, self.grouping); defer { @@ -764,11 +758,11 @@ pub fn drawCanvas(self: *Workspace) !void { defer self.processTabDrag(canvas_vbox.data()); if (has_files) { - if (self.open_file_index >= fizzy.editor.open_files.values().len) { - self.open_file_index = fizzy.editor.open_files.values().len - 1; + if (self.open_file_index >= Globals.host.openDocCount()) { + self.open_file_index = Globals.host.openDocCount() - 1; } - if (fizzy.editor.docAt(self.open_file_index)) |doc| { + if (Globals.host.docByIndex(self.open_file_index)) |doc| { fizzy.editor.bindDocToPane(doc, canvas_vbox.data().id, self, self.center); _ = try doc.owner.drawDocument(doc); } diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index 117c9e30..50bbf690 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -1,5 +1,6 @@ const std = @import("std"); const fizzy = @import("../../../fizzy.zig"); +const Globals = @import("Globals.zig"); const pixelart = @import("pixelart"); const dvui = @import("dvui"); const Editor = fizzy.Editor; @@ -288,7 +289,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u if ((dvui.menuItemLabel(@src(), "New File...", .{}, .{ .expand = .horizontal })) != null) { defer fw2.close(); - fizzy.editor.host.requestNewDocument(project_path, root_branch_id.asUsize()); + Globals.host.requestNewDocument(project_path, root_branch_id.asUsize()); } if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) { @@ -654,7 +655,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg var have_grouping = false; for (to_open) |p| { if (!have_grouping) { - side_grouping = if (fizzy.editor.open_files.count() == 0) + side_grouping = if (Globals.host.openDocCount() == 0) fizzy.editor.currentGroupingID() else fizzy.editor.newGroupingID(); @@ -683,7 +684,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg defer fw2.close(); const parent_dir: []const u8 = if (entry.kind == .directory) abs_path else directory; - fizzy.editor.host.requestNewDocument(parent_dir, branch_id.asUsize()); + Globals.host.requestNewDocument(parent_dir, branch_id.asUsize()); } if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) { @@ -1224,7 +1225,9 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File .directory => { std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); - for (fizzy.editor.open_files.values()) |doc| { + var di: usize = 0; + while (di < Globals.host.openDocCount()) : (di += 1) { + const doc = Globals.host.docByIndex(di) orelse continue; const path = fizzy.editor.docPath(doc); if (std.mem.containsAtLeast(u8, path, 1, full_path)) { const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(path)) catch "Failed to duplicate path"; diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index 83ed48f5..7c047b2b 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -98,6 +98,9 @@ pub const VTable = struct { openDocCount: *const fn (ctx: *anyopaque) usize, /// Focus the document at `index` (updates workspace tab selection). setActiveDocIndex: *const fn (ctx: *anyopaque, index: usize) void, + /// Swap the open documents at indices `a` and `b` (used by tab drag-reorder). The shell + /// owns the open-document collection; this is the only mutation of its order plugins do. + swapDocs: *const fn (ctx: *anyopaque, a: usize, b: usize) void, /// Allocate the next shell document id (monotonic). allocDocId: *const fn (ctx: *anyopaque) u64, @@ -211,6 +214,10 @@ pub fn setActiveDocIndex(self: EditorAPI, index: usize) void { self.vtable.setActiveDocIndex(self.ctx, index); } +pub fn swapDocs(self: EditorAPI, a: usize, b: usize) void { + self.vtable.swapDocs(self.ctx, a, b); +} + pub fn allocDocId(self: EditorAPI) u64 { return self.vtable.allocDocId(self.ctx); } diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index fb43b5f6..94b79ab9 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -180,6 +180,10 @@ pub fn setActiveDocIndex(self: *Host, index: usize) void { if (self.shell_api) |a| a.setActiveDocIndex(index); } +pub fn swapDocs(self: *Host, a_index: usize, b_index: usize) void { + if (self.shell_api) |a| a.swapDocs(a_index, b_index); +} + pub fn allocDocId(self: *Host) u64 { return if (self.shell_api) |a| a.allocDocId() else 0; } From e28095324b26e075153c7753a13d5c20ca94eb14 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 10:18:43 -0500 Subject: [PATCH 28/49] Stage W1-2 --- HANDOFF.md | 11 +- src/App.zig | 1 + src/backend/singleton_native.zig | 2 +- src/editor/Editor.zig | 197 ++---------------- src/editor/Keybinds.zig | 2 +- src/editor/WebFileIo.zig | 2 +- src/editor/explorer/Explorer.zig | 7 +- src/plugins/pixelart/src/plugin.zig | 6 + src/plugins/workbench/src/Globals.zig | 6 +- src/plugins/workbench/src/Workbench.zig | 70 ++++++- src/plugins/workbench/src/Workspace.zig | 137 ++++++------ src/plugins/workbench/src/files.zig | 18 +- .../workbench/src/workbench_layout.zig | 138 ++++++++++++ src/sdk/Plugin.zig | 5 + 14 files changed, 341 insertions(+), 261 deletions(-) create mode 100644 src/plugins/workbench/src/workbench_layout.zig diff --git a/HANDOFF.md b/HANDOFF.md index f5bc41de..b5696d3b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -374,10 +374,13 @@ values/keys at `Workspace.zig:467+`) — that's the deep coupling. → `Globals.host.openDocCount()`, `open_files.values()[i]`/`docAt` → `docByIndex`, `open_files.getIndex` → `docIndex`, `setActiveFile` → `setActiveDocIndex`, `fizzy.editor.host` → `Globals.host`. **Workbench `fizzy.editor` refs: 163 → 106.** -- **W2 — workspace/grouping ownership.** Move `workspaces`, `open_workspace_grouping`, - grouping-id counters (`newGroupingID`/`currentGroupingID`), and file-tree tab drag-drop - state (`tab_drag_from_tree_path`/`file_tree_data_id`/`clearFileTreeTabDragDropState`, today - shared with shell `Explorer`/`Editor`) onto the `Workbench` struct; shell routes through it. +- **W2 — workspace/grouping ownership — DONE.** Moved `workspaces`, `open_workspace_grouping`, + `grouping_id_counter`, `tab_drag_from_tree_path`, `file_tree_data_id` onto `Workbench`; + added `Globals.workbench`, `workbench_layout.zig` (`rebuildWorkspaces`/`drawWorkspaces`), + and `Plugin.removeCanvasPane` (pixelart implements; `Workspace.deinit` iterates host plugins). + Shell `Editor` delegates `activeDoc`/`setActiveFile`/`rebuildWorkspaces`/`drawWorkspaces`/ + grouping helpers through `editor.workbench`. Workbench plugin code uses `Globals.workbench` + for workspace state; `setDocGrouping` → `doc.owner.setDocumentGrouping` in tab-drag paths. - **W3 — remaining `fizzy.editor.*` (doc ops, folder/settings/recents/atlas) → EditorAPI/Host.** Add missing EditorAPI surface as needed (`folder`, `setProjectFolder`, `openFilePath`, …). - **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core**; then diff --git a/src/App.zig b/src/App.zig index eb9c0eb5..a2265166 100644 --- a/src/App.zig +++ b/src/App.zig @@ -172,6 +172,7 @@ pub fn AppInit(win: *dvui.Window) !void { // reaches the EditorAPI surface without importing `fizzy.zig`. Mirrors pixelart.Globals. WorkbenchGlobals.gpa = allocator; WorkbenchGlobals.host = &fizzy.editor.host; + WorkbenchGlobals.workbench = &fizzy.editor.workbench; // Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created // before `postInit` so the pixel-art plugin's `register` can adopt it as its diff --git a/src/backend/singleton_native.zig b/src/backend/singleton_native.zig index dd453999..749cd16e 100644 --- a/src/backend/singleton_native.zig +++ b/src/backend/singleton_native.zig @@ -197,7 +197,7 @@ fn dispatchPath(path: []const u8) !void { return err; }; file.close(io); - _ = try fizzy.editor.openFilePath(path, fizzy.editor.open_workspace_grouping); + _ = try fizzy.editor.openFilePath(path, fizzy.editor.workbench.open_workspace_grouping); } /// Walk upward from `file_path`'s parent directory, returning the first diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 12bed749..60b6eaca 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -69,8 +69,7 @@ panel: *Panel, last_titlebar_color: dvui.Color, -/// Workspaces stored by their grouping ID -workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, +/// Workspaces stored by their grouping ID (owned by `workbench`, Stage W2). sidebar: Sidebar, infobar: Infobar, @@ -94,16 +93,6 @@ loading_jobs: std.StringHashMapUnmanaged(*FileLoadJob) = .empty, /// loads only auto-focus the most recently requested one. last_load_request_path: ?[]const u8 = null, -// The actively focused workspace grouping ID -// This will contain tabs for all open files with a matching grouping ID -open_workspace_grouping: u64 = 0, - -/// Files tree cross-workspace drag (`tab_drag`): heap copy of absolute path. See `files.zig`. -tab_drag_from_tree_path: ?[]u8 = null, -/// `drawFiles` data id for `removed_path`; clear after drop on workspace canvas. -file_tree_data_id: ?dvui.Id = null, - -grouping_id_counter: u64 = 0, file_id_counter: u64 = 0, window_opacity: f32 = 1.0, @@ -430,11 +419,7 @@ pub fn init( editor.explorer.* = .init(); editor.panel.* = .init(); editor.open_files = .empty; - editor.workspaces = .empty; - editor.workspaces.put(fizzy.app.allocator, 0, .init(0)) catch |err| { - std.log.err("Failed to create workspace: {s}", .{@errorName(err)}); - return err; - }; + try editor.workbench.initDefaultWorkspace(); // Pixel-art tools/colors/palettes now init in `State.init` (App allocates // `editor.pixelart_state` just after this `Editor.init` returns). @@ -757,10 +742,7 @@ pub fn docById(editor: *Editor, id: u64) ?sdk.DocHandle { } pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { - if (editor.workspaces.get(editor.open_workspace_grouping)) |workspace| { - return editor.docAt(workspace.open_file_index); - } - return null; + return editor.workbench.activeDoc(); } /// Workbench routing helpers (type-agnostic; dispatch through `doc.owner`). @@ -894,12 +876,11 @@ pub fn applyHoldMenuDuration(editor: *Editor) void { } pub fn currentGroupingID(editor: *Editor) u64 { - return editor.open_workspace_grouping; + return editor.workbench.currentGroupingID(); } pub fn newGroupingID(editor: *Editor) u64 { - editor.grouping_id_counter += 1; - return editor.grouping_id_counter; + return editor.workbench.newGroupingID(); } pub fn newFileID(editor: *Editor) u64 { @@ -1452,7 +1433,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } else { // Explorer peek/collapse hides the workspace subtree, so `drawWorkspaces` does not // run and `workspace.center` would otherwise stay latched from a prior panel animation. - for (editor.workspaces.values()) |*ws| { + for (editor.workbench.workspaces.values()) |*ws| { ws.center = false; } } @@ -1561,7 +1542,7 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA .filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" }, })) |files| { for (files) |file| { - _ = editor.openFilePath(file, editor.open_workspace_grouping) catch { + _ = editor.openFilePath(file, editor.workbench.open_workspace_grouping) catch { std.log.err("Failed to open file: {s}", .{file}); }; } @@ -1662,135 +1643,16 @@ pub fn setWindowStyle(_: *Editor) void { } pub fn rebuildWorkspaces(editor: *Editor) !void { - - // Create workspaces for each grouping ID - for (editor.open_files.values()) |doc| { - const grouping = editor.docGrouping(doc); - if (!editor.workspaces.contains(grouping)) { - var workspace: fizzy.Editor.Workspace = .init(grouping); - for (editor.open_files.values()) |d| { - if (editor.docGrouping(d) == grouping) { - workspace.open_file_index = editor.open_files.getIndex(d.id) orelse 0; - } - } - - editor.workspaces.put(fizzy.app.allocator, grouping, workspace) catch |err| { - std.log.err("Failed to create workspace: {s}", .{@errorName(err)}); - return err; - }; - } - } - - // Remove workspaces that are no longer needed - for (editor.workspaces.values()) |*workspace| { - if (editor.workspaces.count() == 1) { - break; - } - - var contains: bool = false; - for (editor.open_files.values()) |doc| { - if (editor.docGrouping(doc) == workspace.grouping) { - contains = true; - break; - } - } - - if (!contains) { - if (editor.open_workspace_grouping == workspace.grouping) { - for (editor.workspaces.values()) |*w| { - if (w.grouping != workspace.grouping) { - editor.open_workspace_grouping = w.grouping; - break; - } - } - } - - workspace.deinit(); - _ = editor.workspaces.orderedRemove(workspace.grouping); - break; - } - } - - // Ensure the selected file for each workspace is still valid - for (editor.workspaces.values()) |*workspace| { - if (editor.docAt(workspace.open_file_index)) |doc| { - if (editor.docGrouping(doc) == workspace.grouping) { - continue; - } - } - - var i: usize = editor.open_files.count(); - while (i > 0) { - i -= 1; - if (editor.docAt(i)) |d| { - if (editor.docGrouping(d) == workspace.grouping) { - workspace.open_file_index = i; - break; - } - } - } - } + try editor.workbench.rebuildWorkspaces(); } pub fn drawWorkspaces(editor: *Editor, index: usize) !dvui.App.Result { - if (index >= editor.workspaces.count()) return .ok; - - var s = fizzy.dvui.paned(@src(), .{ - .direction = .horizontal, - .collapsed_size = if (index == editor.workspaces.count() - 1) std.math.floatMax(f32) else 0, - .handle_size = handle_size, - .handle_dynamic = .{ .handle_size_max = handle_size, .distance_max = handle_dist }, - }, .{ - .expand = .both, - .background = false, - }); - defer s.deinit(); - - const dragging = editor.panel.paned.dragging or s.dragging; - - if (!dragging) { - const should_center = (s.animating and s.split_ratio.* < 1.0) or - (editor.panel.paned.animating and editor.panel.paned.split_ratio.* < 1.0); - if (index + 1 < editor.workspaces.count()) { - editor.workspaces.values()[index + 1].center = should_center; - } else if (editor.workspaces.count() == 1) { - editor.workspaces.values()[index].center = should_center; - } - } - - // Ens - if (s.collapsing and s.split_ratio.* < 0.5) { - s.animateSplit(1.0, dvui.easing.outBack); - } - - if (!s.dragging and !s.animating and !s.collapsing and !s.collapsed_state) { - if (index == editor.workspaces.count() - 1) { - if (s.split_ratio.* != 1.0) { - s.animateSplit(1.0, dvui.easing.outBack); - } - } else { - if (dvui.firstFrame(s.wd.id)) { - s.split_ratio.* = 1.0; - s.animateSplit(0.5, dvui.easing.outBack); - } - } - } - - if (s.showFirst()) { - const result = try editor.workspaces.values()[index].draw(); - if (result != .ok) { - return result; - } - } - - if (s.showSecond()) { - const result = try drawWorkspaces(editor, index + 1); - if (result != .ok) { - return result; - } - } - - return .ok; + const panel = editor.panel.paned; + return editor.workbench.drawWorkspaces(.{ + .dragging = panel.dragging, + .animating = panel.animating, + .split_ratio = panel.split_ratio, + }, index); } pub fn abortSaveAllQuit(editor: *Editor) void { @@ -1976,11 +1838,8 @@ pub fn openOrFocusFileAtGrouping(editor: *Editor, path: []const u8, grouping: u6 /// After a workspace drop from the Files tree or when `tab_drag` ends; frees path and clears tree reorder stash. pub fn clearFileTreeTabDragDropState(editor: *Editor) void { - if (editor.tab_drag_from_tree_path) |p| { - fizzy.app.allocator.free(p); - editor.tab_drag_from_tree_path = null; - } - if (editor.file_tree_data_id) |id| { + editor.workbench.clearFileTreeTabDragDropState(); + if (editor.workbench.file_tree_data_id) |id| { dvui.dataRemove(null, id, "removed_path"); } // `file_tree_data_id` is reassigned each `drawFiles` frame; do not clear the id here so @@ -2147,8 +2006,7 @@ pub fn processPackJob(editor: *Editor) void { } pub fn activeWorkspaceCanvasRectPhysical(editor: *Editor) ?dvui.Rect.Physical { - const workspace = editor.workspaces.getPtr(editor.open_workspace_grouping) orelse return null; - return workspace.canvas_rect_physical; + return editor.workbench.activeWorkspaceCanvasRectPhysical(); } /// Cancel every in-flight load. Workers exit at the next cancellation checkpoint (after @@ -2389,13 +2247,7 @@ pub fn requestNewFileDialog(editor: *Editor) void { } pub fn setActiveFile(editor: *Editor, index: usize) void { - const doc = editor.docAt(index) orelse return; - const grouping = editor.docGrouping(doc); - - if (editor.workspaces.getPtr(grouping)) |workspace| { - editor.open_workspace_grouping = grouping; - workspace.open_file_index = index; - } + editor.workbench.setActiveDocIndex(index); } pub fn forceCloseFile(editor: *Editor, index: usize) !void { @@ -2639,7 +2491,7 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void { const doc = editor.docAt(index) orelse return; const grouping = editor.docGrouping(doc); - if (editor.workspaces.getPtr(grouping)) |workspace| { + if (editor.workbench.workspaces.getPtr(grouping)) |workspace| { if (workspace.open_file_index == index) { for (editor.open_files.values(), 0..) |d, i| { if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) { @@ -2658,7 +2510,7 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void { const doc = editor.open_files.get(id) orelse return; const grouping = editor.docGrouping(doc); - if (editor.workspaces.getPtr(grouping)) |workspace| { + if (editor.workbench.workspaces.getPtr(grouping)) |workspace| { if (workspace.open_file_index == editor.open_files.getIndex(doc.id)) { for (editor.open_files.values(), 0..) |d, i| { if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) { @@ -2695,10 +2547,7 @@ pub fn deinit(editor: *Editor) !void { editor.loading_jobs.deinit(fizzy.app.allocator); } - if (editor.tab_drag_from_tree_path) |p| { - fizzy.app.allocator.free(p); - editor.tab_drag_from_tree_path = null; - } + editor.workbench.clearFileTreeTabDragDropState(); if (editor.pending_save_as_path) |p| { fizzy.app.allocator.free(p); @@ -2726,9 +2575,7 @@ pub fn deinit(editor: *Editor) !void { editor.explorer.deinit(); - for (editor.workspaces.values()) |*workspace| workspace.deinit(); - editor.workspaces.deinit(fizzy.app.allocator); - + editor.workbench.deinitWorkspaces(); editor.host.deinit(); editor.workbench.deinit(); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index f8a41f9c..64824f99 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -63,7 +63,7 @@ pub fn tick() !void { .{ .title = "Open Files...", .filter_description = ".fiz, .pixi, .png, .jpg, .jpeg", .filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" } }, )) |files| { for (files) |file| { - _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch { + _ = fizzy.editor.openFilePath(file, fizzy.editor.workbench.open_workspace_grouping) catch { std.log.err("Failed to open file: {s}", .{file}); }; } diff --git a/src/editor/WebFileIo.zig b/src/editor/WebFileIo.zig index a7d7992e..eaac597c 100644 --- a/src/editor/WebFileIo.zig +++ b/src/editor/WebFileIo.zig @@ -46,7 +46,7 @@ pub fn showOpenFileDialog( ) void { if (comptime builtin.target.cpu.arch != .wasm32) return; open_callback = cb; - open_grouping = fizzy.editor.open_workspace_grouping; + open_grouping = fizzy.editor.workbench.open_workspace_grouping; open_picker_id = dvui.Id.extendId(null, @src(), 0); dvui.dialogWasmFileOpenMultiple(open_picker_id.?, .{ .accept = open_accept }); } diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 7469271e..93359cb2 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -108,11 +108,8 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { }); if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/src/plugin.zig").view_files)) { - fizzy.editor.file_tree_data_id = null; - if (fizzy.editor.tab_drag_from_tree_path) |p| { - fizzy.app.allocator.free(p); - fizzy.editor.tab_drag_from_tree_path = null; - } + fizzy.editor.workbench.file_tree_data_id = null; + fizzy.editor.workbench.clearFileTreeTabDragDropState(); } if (fizzy.editor.host.activeSidebarView()) |view| { diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig index 158689bb..196e4d98 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -69,6 +69,7 @@ const vtable: sdk.Plugin.VTable = .{ .bindDocumentToPane = bindDocumentToPane, .documentGrouping = documentGrouping, .setDocumentGrouping = setDocumentGrouping, + .removeCanvasPane = removeCanvasPane, .documentPath = documentPath, .setDocumentPath = setDocumentPath, .documentHasNativeExtension = documentHasNativeExtension, @@ -442,6 +443,11 @@ fn setDocumentGrouping(state: *anyopaque, doc: DocHandle, grouping: u64) void { DocBridge.setDocumentGrouping(st, doc, grouping); } +fn removeCanvasPane(state: *anyopaque, grouping: u64, allocator: std.mem.Allocator) void { + const st: *State = @ptrCast(@alignCast(state)); + State.removeCanvasPane(st, allocator, grouping); +} + fn documentPath(state: *anyopaque, doc: DocHandle) []const u8 { const st: *State = @ptrCast(@alignCast(state)); return DocBridge.documentPath(st, doc); diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig index 1bf8393f..8ec402f9 100644 --- a/src/plugins/workbench/src/Globals.zig +++ b/src/plugins/workbench/src/Globals.zig @@ -4,11 +4,13 @@ //! app allocator and the Host (EditorAPI surface) without importing `fizzy.zig`. //! Mirrors `plugins/pixelart/src/Globals.zig`. const std = @import("std"); -const workbench = @import("../workbench.zig"); -const sdk = workbench.sdk; +const wb_mod = @import("../workbench.zig"); +const sdk = wb_mod.sdk; +const Workbench = @import("Workbench.zig"); pub var gpa: std.mem.Allocator = undefined; pub var host: *sdk.Host = undefined; +pub var workbench: *Workbench = undefined; pub fn allocator() std.mem.Allocator { return gpa; diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index f535b9b5..fdceacee 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -13,6 +13,10 @@ const dvui = @import("dvui"); const icons = @import("icons"); const fizzy = @import("../../../fizzy.zig"); const files = @import("files.zig"); +const Workspace = @import("Workspace.zig"); +const Globals = @import("Globals.zig"); +const workbench_layout = @import("workbench_layout.zig"); +const sdk = @import("sdk"); pub const Workbench = @This(); @@ -27,6 +31,13 @@ pub const BranchDecorator = struct { allocator: std.mem.Allocator, decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty, +/// Workspaces keyed by tab-grouping id (Stage W2: owned here, not on the shell Editor). +workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, +open_workspace_grouping: u64 = 0, +grouping_id_counter: u64 = 0, +tab_drag_from_tree_path: ?[]u8 = null, +file_tree_data_id: ?dvui.Id = null, + /// The `workbench-api` service instance handed to plugins. Its `ctx` must be the /// editor's FINAL heap address, so it's filled in by `initService` from /// `Editor.postInit` (after `Editor.init`'s by-value result is copied to the heap), @@ -41,6 +52,61 @@ pub fn deinit(self: *Workbench) void { self.decorators.deinit(self.allocator); } +pub fn initDefaultWorkspace(self: *Workbench) !void { + self.workspaces = .empty; + try self.workspaces.put(self.allocator, 0, Workspace.init(0)); +} + +pub fn deinitWorkspaces(self: *Workbench) void { + for (self.workspaces.values()) |*workspace| workspace.deinit(); + self.workspaces.deinit(self.allocator); +} + +pub fn currentGroupingID(self: *Workbench) u64 { + return self.open_workspace_grouping; +} + +pub fn newGroupingID(self: *Workbench) u64 { + self.grouping_id_counter += 1; + return self.grouping_id_counter; +} + +pub fn clearFileTreeTabDragDropState(self: *Workbench) void { + if (self.tab_drag_from_tree_path) |p| { + self.allocator.free(p); + self.tab_drag_from_tree_path = null; + } +} + +pub fn rebuildWorkspaces(self: *Workbench) !void { + return workbench_layout.rebuildWorkspaces(self); +} + +pub fn drawWorkspaces(self: *Workbench, panel: workbench_layout.PanelPanedState, index: usize) !dvui.App.Result { + return workbench_layout.drawWorkspaces(self, panel, index); +} + +pub fn activeDoc(self: *Workbench) ?sdk.DocHandle { + if (self.workspaces.get(self.open_workspace_grouping)) |workspace| { + return Globals.host.docByIndex(workspace.open_file_index); + } + return null; +} + +pub fn setActiveDocIndex(self: *Workbench, index: usize) void { + const doc = Globals.host.docByIndex(index) orelse return; + const grouping = doc.owner.documentGrouping(doc); + if (self.workspaces.getPtr(grouping)) |workspace| { + self.open_workspace_grouping = grouping; + workspace.open_file_index = index; + } +} + +pub fn activeWorkspaceCanvasRectPhysical(self: *Workbench) ?dvui.Rect.Physical { + const workspace = self.workspaces.getPtr(self.open_workspace_grouping) orelse return null; + return workspace.canvas_rect_physical; +} + /// Build the `workbench-api` service. `editor_ctx` is the host's heap `*Editor`, /// passed opaquely so the API has no compile-time dependency back on the editor. pub fn initService(self: *Workbench, editor_ctx: *anyopaque) void { @@ -201,10 +267,10 @@ fn svcOpen(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool { return editorOf(ctx).openFilePath(path, grouping); } fn svcCurrentGrouping(ctx: *anyopaque) u64 { - return editorOf(ctx).currentGroupingID(); + return editorOf(ctx).workbench.currentGroupingID(); } fn svcNewGrouping(ctx: *anyopaque) u64 { - return editorOf(ctx).newGroupingID(); + return editorOf(ctx).workbench.newGroupingID(); } fn svcClose(ctx: *anyopaque, id: u64) anyerror!void { return editorOf(ctx).closeFileID(id); diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index b74e5ee9..ff4e9609 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -40,7 +40,9 @@ pub fn init(grouping: u64) Workspace { /// Release any plugin-owned per-pane canvas chrome. Called when a pane is removed /// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown. pub fn deinit(self: *Workspace) void { - pixelart.State.removeCanvasPane(pixelart.Globals.state, fizzy.app.allocator, self.grouping); + for (Globals.host.plugins.items) |plugin| { + plugin.removeCanvasPane(self.grouping, Globals.allocator()); + } } const handle_size = 10; @@ -81,7 +83,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result { if (e.evt == .mouse) { if (e.evt.mouse.action == .press or (e.evt.mouse.action == .position and e.evt.mouse.mod.matchBind("ctrl/cmd"))) { - fizzy.editor.open_workspace_grouping = self.grouping; + Globals.workbench.open_workspace_grouping = self.grouping; } } } @@ -160,10 +162,10 @@ fn drawTabs(self: *Workspace) void { var next_same_group_index: ?usize = null; const active_in_this_group = blk: { - if (fizzy.editor.open_workspace_grouping != self.grouping) break :blk false; + if (Globals.workbench.open_workspace_grouping != self.grouping) break :blk false; if (self.open_file_index >= files_len) break :blk false; const active_doc = Globals.host.docByIndex(self.open_file_index) orelse break :blk false; - if (fizzy.editor.docGrouping(active_doc) != self.grouping) break :blk false; + if (active_doc.owner.documentGrouping(active_doc) != self.grouping) break :blk false; break :blk true; }; @@ -174,7 +176,7 @@ fn drawTabs(self: *Workspace) void { while (j > 0) { j -= 1; const tab_doc = Globals.host.docByIndex(j) orelse continue; - if (fizzy.editor.docGrouping(tab_doc) == self.grouping) { + if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) { prev_same_group_index = j; break; } @@ -183,7 +185,7 @@ fn drawTabs(self: *Workspace) void { j = active_index + 1; while (j < files_len) : (j += 1) { const tab_doc = Globals.host.docByIndex(j) orelse continue; - if (fizzy.editor.docGrouping(tab_doc) == self.grouping) { + if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) { next_same_group_index = j; break; } @@ -194,7 +196,7 @@ fn drawTabs(self: *Workspace) void { const doc = Globals.host.docByIndex(i) orelse continue; const is_fizzy_file = doc.owner.documentHasNativeExtension(doc); - if (fizzy.editor.docGrouping(doc) != self.grouping) continue; + if (doc.owner.documentGrouping(doc) != self.grouping) continue; var reorderable = tabs.reorderable(@src(), .{}, .{ .expand = .vertical, @@ -204,7 +206,7 @@ fn drawTabs(self: *Workspace) void { }); defer reorderable.deinit(); - const selected = self.open_file_index == i and fizzy.editor.open_workspace_grouping == self.grouping; + const selected = self.open_file_index == i and Globals.workbench.open_workspace_grouping == self.grouping; var anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{}); defer anim.deinit(); @@ -480,20 +482,26 @@ pub fn processTabsDrag(self: *Workspace) void { self.tabs_removed_index = null; self.tabs_insert_before_index = null; } else { // Dragging from another workspace - for (fizzy.editor.workspaces.values()) |*workspace| { + for (Globals.workbench.workspaces.values()) |*workspace| { if (workspace.tabs_removed_index) |removed| { if (removed > insert_before) { Globals.host.swapDocs(removed, insert_before); - fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before).?, self.grouping); + if (Globals.host.docByIndex(insert_before)) |d| { + d.owner.setDocumentGrouping(d, self.grouping); + } Globals.host.setActiveDocIndex(insert_before); } else { if (insert_before > 0) { Globals.host.swapDocs(removed, insert_before - 1); - fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before - 1).?, self.grouping); + if (Globals.host.docByIndex(insert_before - 1)) |d| { + d.owner.setDocumentGrouping(d, self.grouping); + } Globals.host.setActiveDocIndex(insert_before - 1); } else { Globals.host.swapDocs(removed, insert_before); - fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before).?, self.grouping); + if (Globals.host.docByIndex(insert_before)) |d| { + d.owner.setDocumentGrouping(d, self.grouping); + } Globals.host.setActiveDocIndex(insert_before); } } @@ -510,23 +518,27 @@ pub fn processTabsDrag(self: *Workspace) void { } /// Repoint `open_file_index` on workspaces that were showing the dragged tab as active. -fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace, drag_index: usize) void { - const dragged_doc = editor.docAt(drag_index) orelse return; +fn repointWorkspacesAfterTabDrag(tab_bar_workspace: ?*Workspace, drag_index: usize) void { + const dragged_doc = Globals.host.docByIndex(drag_index) orelse return; if (tab_bar_workspace) |workspace| { - if (workspace.open_file_index == editor.open_files.getIndex(dragged_doc.id)) { - for (editor.open_files.values()) |doc| { - if (editor.docGrouping(doc) == workspace.grouping and doc.id != dragged_doc.id) { - workspace.open_file_index = editor.open_files.getIndex(doc.id) orelse 0; + if (workspace.open_file_index == Globals.host.docIndex(dragged_doc.id)) { + var i: usize = 0; + while (i < Globals.host.openDocCount()) : (i += 1) { + const doc = Globals.host.docByIndex(i).?; + if (doc.owner.documentGrouping(doc) == workspace.grouping and doc.id != dragged_doc.id) { + workspace.open_file_index = i; break; } } } } else { - for (editor.workspaces.values()) |*w| { + for (Globals.workbench.workspaces.values()) |*w| { if (w.open_file_index == drag_index) { - for (editor.open_files.values()) |doc| { - if (editor.docGrouping(doc) == w.grouping and doc.id != dragged_doc.id) { - w.open_file_index = editor.open_files.getIndex(doc.id) orelse 0; + var i: usize = 0; + while (i < Globals.host.openDocCount()) : (i += 1) { + const doc = Globals.host.docByIndex(i).?; + if (doc.owner.documentGrouping(doc) == w.grouping and doc.id != dragged_doc.id) { + w.open_file_index = i; break; } } @@ -541,14 +553,17 @@ const WorkspaceTabDragSrc = union(enum) { tree_closed: []const u8, none, - fn resolve(editor: *Editor) WorkspaceTabDragSrc { - for (editor.workspaces.values()) |*w| { + fn resolve() WorkspaceTabDragSrc { + for (Globals.workbench.workspaces.values()) |*w| { if (w.tabs_drag_index) |i| return .{ .tab_bar = .{ .ws = w, .index = i } }; } - if (editor.tab_drag_from_tree_path) |p| { - if (editor.docFromPath(p)) |doc| { - const idx = editor.open_files.getIndex(doc.id) orelse return .none; - return .{ .tree_open = idx }; + if (Globals.workbench.tab_drag_from_tree_path) |p| { + var i: usize = 0; + while (i < Globals.host.openDocCount()) : (i += 1) { + const doc = Globals.host.docByIndex(i).?; + if (doc.owner.documentByPath(p) != null) { + return .{ .tree_open = i }; + } } return .{ .tree_closed = p }; } @@ -560,11 +575,11 @@ const WorkspaceTabDragSrc = union(enum) { /// Also handles the same `tab_drag` from the Files tree (see `files.zig` + DVUI reorder_tree cross-widget pattern). pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { if (!dvui.dragName("tab_drag")) { - fizzy.editor.clearFileTreeTabDragDropState(); + Globals.workbench.clearFileTreeTabDragDropState(); return; } - const drag_src = WorkspaceTabDragSrc.resolve(fizzy.editor); + const drag_src = WorkspaceTabDragSrc.resolve(); switch (drag_src) { .none => return, else => {}, @@ -583,7 +598,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { right_side.w /= 2; right_side.x += right_side.w; - if (right_side.contains(e.evt.mouse.p) and fizzy.editor.workspaces.keys()[fizzy.editor.workspaces.keys().len - 1] == self.grouping) { + if (right_side.contains(e.evt.mouse.p) and Globals.workbench.workspaces.keys()[Globals.workbench.workspaces.keys().len - 1] == self.grouping) { if (e.evt == .mouse and e.evt.mouse.action == .position) { right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), @@ -595,13 +610,13 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - fizzy.editor.clearFileTreeTabDragDropState(); + Globals.workbench.clearFileTreeTabDragDropState(); - repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); + repointWorkspacesAfterTabDrag(workspace, drag_index); const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; - const new_g = fizzy.editor.newGroupingID(); - fizzy.editor.setDocGrouping(dragged_doc, new_g); - fizzy.editor.open_workspace_grouping = new_g; + const new_g = Globals.workbench.newGroupingID(); + dragged_doc.owner.setDocumentGrouping(dragged_doc, new_g); + Globals.workbench.open_workspace_grouping = new_g; } } else if (data.rectScale().r.contains(e.evt.mouse.p)) { if (e.evt == .mouse and e.evt.mouse.action == .position) { @@ -615,12 +630,12 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - fizzy.editor.clearFileTreeTabDragDropState(); + Globals.workbench.clearFileTreeTabDragDropState(); - repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); + repointWorkspacesAfterTabDrag(workspace, drag_index); const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; - fizzy.editor.setDocGrouping(dragged_doc, self.grouping); - fizzy.editor.open_workspace_grouping = self.grouping; + dragged_doc.owner.setDocumentGrouping(dragged_doc, self.grouping); + Globals.workbench.open_workspace_grouping = self.grouping; self.open_file_index = Globals.host.docIndex(dragged_doc.id) orelse 0; } } @@ -630,7 +645,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { right_side.w /= 2; right_side.x += right_side.w; - if (right_side.contains(e.evt.mouse.p) and fizzy.editor.workspaces.keys()[fizzy.editor.workspaces.keys().len - 1] == self.grouping) { + if (right_side.contains(e.evt.mouse.p) and Globals.workbench.workspaces.keys()[Globals.workbench.workspaces.keys().len - 1] == self.grouping) { if (e.evt == .mouse and e.evt.mouse.action == .position) { right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), @@ -641,13 +656,13 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - fizzy.editor.clearFileTreeTabDragDropState(); + Globals.workbench.clearFileTreeTabDragDropState(); - repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); + repointWorkspacesAfterTabDrag(null, drag_index); const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; - const new_g = fizzy.editor.newGroupingID(); - fizzy.editor.setDocGrouping(dragged_doc, new_g); - fizzy.editor.open_workspace_grouping = new_g; + const new_g = Globals.workbench.newGroupingID(); + dragged_doc.owner.setDocumentGrouping(dragged_doc, new_g); + Globals.workbench.open_workspace_grouping = new_g; } } else if (data.rectScale().r.contains(e.evt.mouse.p)) { if (e.evt == .mouse and e.evt.mouse.action == .position) { @@ -660,12 +675,12 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - fizzy.editor.clearFileTreeTabDragDropState(); + Globals.workbench.clearFileTreeTabDragDropState(); - repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); + repointWorkspacesAfterTabDrag(null, drag_index); const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue; - fizzy.editor.setDocGrouping(dragged_doc, self.grouping); - fizzy.editor.open_workspace_grouping = self.grouping; + dragged_doc.owner.setDocumentGrouping(dragged_doc, self.grouping); + Globals.workbench.open_workspace_grouping = self.grouping; self.open_file_index = Globals.host.docIndex(dragged_doc.id) orelse 0; } } @@ -675,7 +690,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { right_side.w /= 2; right_side.x += right_side.w; - if (right_side.contains(e.evt.mouse.p) and fizzy.editor.workspaces.keys()[fizzy.editor.workspaces.keys().len - 1] == self.grouping) { + if (right_side.contains(e.evt.mouse.p) and Globals.workbench.workspaces.keys()[Globals.workbench.workspaces.keys().len - 1] == self.grouping) { if (e.evt == .mouse and e.evt.mouse.action == .position) { right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), @@ -686,23 +701,23 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - const new_g = fizzy.editor.newGroupingID(); + const new_g = Globals.workbench.newGroupingID(); const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, new_g) catch { - fizzy.editor.clearFileTreeTabDragDropState(); + Globals.workbench.clearFileTreeTabDragDropState(); continue :events_loop; }; if (maybe_idx) |idx| { // File was already open and moved between groupings — repoint the // workspaces that were showing it, and focus the new pane now. - repointWorkspacesAfterTabDrag(fizzy.editor, null, idx); - fizzy.editor.open_workspace_grouping = new_g; + repointWorkspacesAfterTabDrag(null, idx); + Globals.workbench.open_workspace_grouping = new_g; } // Else: async load — leave `open_workspace_grouping` alone. Switching // to the not-yet-extant workspace would make `activeFile()` null and // collapse the bottom panel mid-load; `processLoadingJobs` will focus // the new pane once the worker lands the file, matching the // "Open to the side" menu action. - fizzy.editor.clearFileTreeTabDragDropState(); + Globals.workbench.clearFileTreeTabDragDropState(); } } else if (data.rectScale().r.contains(e.evt.mouse.p)) { if (e.evt == .mouse and e.evt.mouse.action == .position) { @@ -716,17 +731,17 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { dvui.dragEnd(); dvui.refresh(null, @src(), data.id); const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, self.grouping) catch { - fizzy.editor.clearFileTreeTabDragDropState(); + Globals.workbench.clearFileTreeTabDragDropState(); continue :events_loop; }; if (maybe_idx) |idx| { - repointWorkspacesAfterTabDrag(fizzy.editor, null, idx); + repointWorkspacesAfterTabDrag(null, idx); self.open_file_index = idx; } // Else: async load into this workspace's existing grouping. The // worker's `processLoadingJobs` focus handler will set the active // file once it lands. - fizzy.editor.clearFileTreeTabDragDropState(); + Globals.workbench.clearFileTreeTabDragDropState(); } } }, @@ -936,7 +951,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { // .filters = &.{ "*.pixi", "*.png" }, // })) |files| { // for (files) |file| { - // _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch { + // _ = fizzy.editor.openFilePath(file, Globals.workbench.open_workspace_grouping) catch { // std.log.err("Failed to open file: {s}", .{file}); // }; // } @@ -1064,7 +1079,7 @@ pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void { pub fn openFilesCallback(files: ?[][:0]const u8) void { if (files) |f| { for (f) |file| { - _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch { + _ = fizzy.editor.openFilePath(file, Globals.workbench.open_workspace_grouping) catch { dvui.log.err("Failed to open file: {s}", .{file}); }; } diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index 50bbf690..f626b8b8 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -83,7 +83,7 @@ pub fn draw() !void { if (fizzy.editor.folder) |path| { try drawFiles(path, tree); } else { - fizzy.editor.file_tree_data_id = null; + Globals.workbench.file_tree_data_id = null; dvui.labelNoFmt( @src(), "Open a project folder to begin.", @@ -143,7 +143,7 @@ fn drawWeb() !void { pub fn drawFiles(path: []const u8, tree: *fizzy.dvui.TreeWidget) !void { const unique_id = dvui.parentGet().extendId(@src(), 0); - fizzy.editor.file_tree_data_id = unique_id; + Globals.workbench.file_tree_data_id = unique_id; var filter_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); dvui.icon( @@ -580,13 +580,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg dvui.dataSetSlice(null, inner_unique_id, "removed_path", abs_path); if (entry.kind == .file and tree.id_branch == inner_id_extra.*) { - if (fizzy.editor.tab_drag_from_tree_path) |old| { + if (Globals.workbench.tab_drag_from_tree_path) |old| { if (!std.mem.eql(u8, old, abs_path)) { fizzy.app.allocator.free(old); - fizzy.editor.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null; + Globals.workbench.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null; } } else { - fizzy.editor.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null; + Globals.workbench.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null; } } } @@ -635,7 +635,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg break :blk &[_][]const u8{}; }; for (to_open) |p| { - _ = fizzy.editor.openFilePath(p, fizzy.editor.currentGroupingID()) catch |e| { + _ = fizzy.editor.openFilePath(p, Globals.workbench.currentGroupingID()) catch |e| { dvui.log.err("Failed to open file: {any} ({s})", .{ e, p }); }; } @@ -656,9 +656,9 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg for (to_open) |p| { if (!have_grouping) { side_grouping = if (Globals.host.openDocCount() == 0) - fizzy.editor.currentGroupingID() + Globals.workbench.currentGroupingID() else - fizzy.editor.newGroupingID(); + Globals.workbench.newGroupingID(); have_grouping = true; } _ = fizzy.editor.openFilePath(p, side_grouping) catch { @@ -810,7 +810,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg if (mode == .replace) { switch (ext) { .fizzy, .png, .jpg => { - _ = fizzy.editor.openFilePath(abs_path, fizzy.editor.currentGroupingID()) catch |err| { + _ = fizzy.editor.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| { dvui.log.err("{any}: {s}", .{ err, abs_path }); }; }, diff --git a/src/plugins/workbench/src/workbench_layout.zig b/src/plugins/workbench/src/workbench_layout.zig new file mode 100644 index 00000000..dd2b5ad6 --- /dev/null +++ b/src/plugins/workbench/src/workbench_layout.zig @@ -0,0 +1,138 @@ +//! Workspace map maintenance + recursive split drawing (Stage W2). +const std = @import("std"); +const dvui = @import("dvui"); +const sdk = @import("sdk"); +const fizzy = @import("../../../fizzy.zig"); +const Globals = @import("Globals.zig"); +const Workbench = @import("Workbench.zig"); +const Workspace = @import("Workspace.zig"); + +const handle_size = 10; +const handle_dist = 60; + +pub fn rebuildWorkspaces(wb: *Workbench) !void { + const host = Globals.host; + + var i: usize = 0; + while (i < host.openDocCount()) : (i += 1) { + const doc = host.docByIndex(i) orelse continue; + const grouping = doc.owner.documentGrouping(doc); + if (!wb.workspaces.contains(grouping)) { + var workspace: Workspace = .init(grouping); + var j: usize = 0; + while (j < host.openDocCount()) : (j += 1) { + const d = host.docByIndex(j) orelse continue; + if (d.owner.documentGrouping(d) == grouping) { + workspace.open_file_index = host.docIndex(d.id) orelse 0; + } + } + try wb.workspaces.put(Globals.allocator(), grouping, workspace); + } + } + + for (wb.workspaces.values()) |*workspace| { + if (wb.workspaces.count() == 1) break; + + var contains = false; + var k: usize = 0; + while (k < host.openDocCount()) : (k += 1) { + const doc = host.docByIndex(k) orelse continue; + if (doc.owner.documentGrouping(doc) == workspace.grouping) { + contains = true; + break; + } + } + + if (!contains) { + if (wb.open_workspace_grouping == workspace.grouping) { + for (wb.workspaces.values()) |*w| { + if (w.grouping != workspace.grouping) { + wb.open_workspace_grouping = w.grouping; + break; + } + } + } + workspace.deinit(); + _ = wb.workspaces.orderedRemove(workspace.grouping); + break; + } + } + + for (wb.workspaces.values()) |*workspace| { + if (host.docByIndex(workspace.open_file_index)) |doc| { + if (doc.owner.documentGrouping(doc) == workspace.grouping) continue; + } + var idx: usize = host.openDocCount(); + while (idx > 0) { + idx -= 1; + if (host.docByIndex(idx)) |d| { + if (d.owner.documentGrouping(d) == workspace.grouping) { + workspace.open_file_index = idx; + break; + } + } + } + } +} + +pub const PanelPanedState = struct { + dragging: bool, + animating: bool, + split_ratio: *f32, +}; + +pub fn drawWorkspaces(wb: *Workbench, panel: PanelPanedState, index: usize) !dvui.App.Result { + if (index >= wb.workspaces.count()) return .ok; + + var s = fizzy.dvui.paned(@src(), .{ + .direction = .horizontal, + .collapsed_size = if (index == wb.workspaces.count() - 1) std.math.floatMax(f32) else 0, + .handle_size = handle_size, + .handle_dynamic = .{ .handle_size_max = handle_size, .distance_max = handle_dist }, + }, .{ + .expand = .both, + .background = false, + }); + defer s.deinit(); + + const dragging = panel.dragging or s.dragging; + + if (!dragging) { + const should_center = (s.animating and s.split_ratio.* < 1.0) or + (panel.animating and panel.split_ratio.* < 1.0); + if (index + 1 < wb.workspaces.count()) { + wb.workspaces.values()[index + 1].center = should_center; + } else if (wb.workspaces.count() == 1) { + wb.workspaces.values()[index].center = should_center; + } + } + + if (s.collapsing and s.split_ratio.* < 0.5) { + s.animateSplit(1.0, dvui.easing.outBack); + } + + if (!s.dragging and !s.animating and !s.collapsing and !s.collapsed_state) { + if (index == wb.workspaces.count() - 1) { + if (s.split_ratio.* != 1.0) { + s.animateSplit(1.0, dvui.easing.outBack); + } + } else { + if (dvui.firstFrame(s.wd.id)) { + s.split_ratio.* = 1.0; + s.animateSplit(0.5, dvui.easing.outBack); + } + } + } + + if (s.showFirst()) { + const result = try wb.workspaces.values()[index].draw(); + if (result != .ok) return result; + } + + if (s.showSecond()) { + const result = try drawWorkspaces(wb, panel, index + 1); + if (result != .ok) return result; + } + + return .ok; +} diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index 4e75836d..a2cf5eaf 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -78,6 +78,7 @@ pub const VTable = struct { bindDocumentToPane: ?*const fn (state: *anyopaque, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void = null, documentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle) u64 = null, setDocumentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle, grouping: u64) void = null, + removeCanvasPane: ?*const fn (state: *anyopaque, grouping: u64, allocator: std.mem.Allocator) void = null, documentPath: ?*const fn (state: *anyopaque, doc: DocHandle) []const u8 = null, setDocumentPath: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void = null, documentHasNativeExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, @@ -237,6 +238,10 @@ pub fn setDocumentGrouping(self: Plugin, doc: DocHandle, grouping: u64) void { if (self.vtable.setDocumentGrouping) |f| f(self.state, doc, grouping); } +pub fn removeCanvasPane(self: Plugin, grouping: u64, allocator: std.mem.Allocator) void { + if (self.vtable.removeCanvasPane) |f| f(self.state, grouping, allocator); +} + pub fn documentPath(self: Plugin, doc: DocHandle) []const u8 { return if (self.vtable.documentPath) |f| f(self.state, doc) else ""; } From 40d044a4535af21eaa88fff2a27122578d3d715b Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 10:27:33 -0500 Subject: [PATCH 29/49] stage w3 --- HANDOFF.md | 10 ++- src/editor/Editor.zig | 78 ++++++++++++++++++++ src/plugins/workbench/src/Workbench.zig | 2 +- src/plugins/workbench/src/Workspace.zig | 35 ++++----- src/plugins/workbench/src/files.zig | 71 +++++++++--------- src/plugins/workbench/src/plugin.zig | 3 +- src/sdk/EditorAPI.zig | 95 +++++++++++++++++++++++++ src/sdk/Host.zig | 62 ++++++++++++++++ 8 files changed, 299 insertions(+), 57 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index b5696d3b..38646b80 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -381,8 +381,14 @@ values/keys at `Workspace.zig:467+`) — that's the deep coupling. Shell `Editor` delegates `activeDoc`/`setActiveFile`/`rebuildWorkspaces`/`drawWorkspaces`/ grouping helpers through `editor.workbench`. Workbench plugin code uses `Globals.workbench` for workspace state; `setDocGrouping` → `doc.owner.setDocumentGrouping` in tab-drag paths. -- **W3 — remaining `fizzy.editor.*` (doc ops, folder/settings/recents/atlas) → EditorAPI/Host.** - Add missing EditorAPI surface as needed (`folder`, `setProjectFolder`, `openFilePath`, …). +- **W3 — remaining `fizzy.editor.*` → EditorAPI/Host — DONE.** Extended `EditorAPI`/`Host` + with doc/file ops (`docFromPath`, `openFilePath`, `openOrFocusFileAtGrouping`, + `closeDocById`), project folder (`setProjectFolder`, `closeProjectFolder`, `isPathIgnored`, + `recentFolderCount`/`recentFolderAt`, `openInFileBrowser`), explorer state + (`explorerViewportWidth`, `explorerBranchIsOpen`, `setExplorerBranchOpen`), and + `drawWorkspaces`. Workbench `files.zig`/`Workspace.zig`/`Workbench.zig`/`plugin.zig` + now route through `Globals.host` + `Globals.workbench`; zero runtime `fizzy.editor` + refs remain in workbench draw paths (comments only). - **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core**; then **W5 — `b.addModule("workbench")`** + `@import("workbench")`, drop the shell path imports (`Editor.zig` re-exports of `Workspace`/`FileLoadJob`/`Workbench`) and the `fizzy` import. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 60b6eaca..83891efe 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -551,6 +551,20 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .setActiveDocIndex = shellSetActiveDocIndex, .swapDocs = shellSwapDocs, .allocDocId = shellAllocDocId, + .explorerViewportWidth = shellExplorerViewportWidth, + .docFromPath = shellDocFromPath, + .openFilePath = shellOpenFilePath, + .openOrFocusFileAtGrouping = shellOpenOrFocusFileAtGrouping, + .closeDocById = shellCloseDocById, + .setProjectFolder = shellSetProjectFolder, + .closeProjectFolder = shellCloseProjectFolder, + .recentFolderCount = shellRecentFolderCount, + .recentFolderAt = shellRecentFolderAt, + .openInFileBrowser = shellOpenInFileBrowser, + .isPathIgnored = shellIsPathIgnored, + .explorerBranchIsOpen = shellExplorerBranchIsOpen, + .setExplorerBranchOpen = shellSetExplorerBranchOpen, + .drawWorkspaces = shellDrawWorkspaces, .accept = shellAccept, .cancel = shellCancel, .copy = shellCopy, @@ -653,6 +667,61 @@ fn shellSwapDocs(ctx: *anyopaque, a: usize, b: usize) void { fn shellAllocDocId(ctx: *anyopaque) u64 { return shellCtx(ctx).newFileID(); } +fn shellExplorerViewportWidth(ctx: *anyopaque) f32 { + return shellCtx(ctx).explorer.scroll_info.viewport.w; +} +fn shellDocFromPath(ctx: *anyopaque, path: []const u8) ?sdk.DocHandle { + return shellCtx(ctx).docFromPath(path); +} +fn shellOpenFilePath(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool { + return shellCtx(ctx).openFilePath(path, grouping); +} +fn shellOpenOrFocusFileAtGrouping(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!?usize { + return shellCtx(ctx).openOrFocusFileAtGrouping(path, grouping); +} +fn shellCloseDocById(ctx: *anyopaque, id: u64) anyerror!void { + return shellCtx(ctx).closeFileID(id); +} +fn shellSetProjectFolder(ctx: *anyopaque, path: []const u8) anyerror!void { + return shellCtx(ctx).setProjectFolder(path); +} +fn shellCloseProjectFolder(ctx: *anyopaque) void { + shellCtx(ctx).closeProjectFolder(); +} +fn shellRecentFolderCount(ctx: *anyopaque) usize { + return shellCtx(ctx).recents.folders.items.len; +} +fn shellRecentFolderAt(ctx: *anyopaque, index: usize) ?[]const u8 { + const editor = shellCtx(ctx); + if (index >= editor.recents.folders.items.len) return null; + return editor.recents.folders.items[index]; +} +fn shellOpenInFileBrowser(ctx: *anyopaque, path: []const u8) anyerror!void { + return shellCtx(ctx).openInFileBrowser(path); +} +fn shellIsPathIgnored( + ctx: *anyopaque, + project_root: []const u8, + abs_path: []const u8, + name: []const u8, + kind: std.Io.File.Kind, +) bool { + return shellCtx(ctx).ignore.isIgnored(project_root, abs_path, name, kind); +} +fn shellExplorerBranchIsOpen(ctx: *anyopaque, branch_id: dvui.Id) bool { + return shellCtx(ctx).explorer.open_branches.contains(branch_id); +} +fn shellSetExplorerBranchOpen(ctx: *anyopaque, branch_id: dvui.Id, open: bool) void { + const editor = shellCtx(ctx); + if (open) { + editor.explorer.open_branches.put(branch_id, {}) catch {}; + } else { + _ = editor.explorer.open_branches.remove(branch_id); + } +} +fn shellDrawWorkspaces(ctx: *anyopaque, index: usize) anyerror!dvui.App.Result { + return drawWorkspaces(shellCtx(ctx), index); +} fn shellAccept(ctx: *anyopaque) anyerror!void { return shellCtx(ctx).accept(); } @@ -1809,6 +1878,15 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); } +pub fn closeProjectFolder(editor: *Editor) void { + if (editor.folder) |folder| { + editor.ignore.deinit(fizzy.app.allocator); + pixelart.plugin.pluginPtr().persistProjectFolder(); + fizzy.app.allocator.free(folder); + editor.folder = null; + } +} + pub fn saving(editor: *Editor) bool { for (editor.open_files.values()) |doc| { if (doc.owner.isDocumentSaving(doc)) return true; diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index fdceacee..c5b88234 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -131,7 +131,7 @@ pub fn drawBranchDecorations(self: *Workbench, path: []const u8, id_extra: usize /// Built-in: a dot on rows whose file is open with unsaved changes. Mirrors the /// tab dirty indicator (`Workspace.zig` ~:528) so the two stay visually consistent. fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void { - const doc = fizzy.editor.docFromPath(path) orelse return; + const doc = Globals.host.docFromPath(path) orelse return; if (!doc.owner.isDirty(doc)) return; dvui.icon(@src(), "explorer_dirty", icons.tvg.lucide.@"circle-small", .{ .stroke_color = dvui.themeGet().color(.window, .text), diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index ff4e9609..7d069a8d 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -215,7 +215,7 @@ fn drawTabs(self: *Workspace) void { hbox.init(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .border = .all(0), - .color_fill = if (selected) .transparent else dvui.themeGet().color(.window, .fill).opacity(fizzy.editor.settings.content_opacity), + .color_fill = if (selected) .transparent else dvui.themeGet().color(.window, .fill).opacity(Globals.host.contentOpacity()), .background = true, .id_extra = i, .padding = dvui.Rect.all(2), @@ -270,7 +270,10 @@ fn drawTabs(self: *Workspace) void { } if (is_fizzy_file) { - _ = fizzy.core.Sprite.draw(fizzy.editor.atlas.sprites[fizzy.atlas.sprites.logo_default], @src(), fizzy.editor.atlas.source, 2.0, .{ + const ui_atlas = Globals.host.uiAtlas(); + const ui_sprite = ui_atlas.sprites[fizzy.atlas.sprites.logo_default]; + const logo_sprite = fizzy.core.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source }; + _ = fizzy.core.Sprite.draw(logo_sprite, @src(), ui_atlas.source, 2.0, .{ .gravity_y = 0.5, .padding = dvui.Rect.all(4), }); @@ -283,7 +286,7 @@ fn drawTabs(self: *Workspace) void { }); } - dvui.label(@src(), "{s}", .{std.fs.path.basename(fizzy.editor.docPath(doc))}, .{ + dvui.label(@src(), "{s}", .{std.fs.path.basename(doc.owner.documentPath(doc))}, .{ .color_text = if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), .padding = dvui.Rect.all(4), .gravity_y = 0.5, @@ -361,7 +364,7 @@ fn drawTabs(self: *Workspace) void { } if (tab_close_button.clicked()) { - fizzy.editor.closeFileID(doc.id) catch |err| { + Globals.host.closeDocById(doc.id) catch |err| { dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) }); }; break; @@ -406,7 +409,7 @@ fn drawTabs(self: *Workspace) void { }); if (ghost_close.clicked()) { - fizzy.editor.closeFileID(doc.id) catch |err| { + Globals.host.closeDocById(doc.id) catch |err| { dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) }); }; break; @@ -702,7 +705,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { dvui.dragEnd(); dvui.refresh(null, @src(), data.id); const new_g = Globals.workbench.newGroupingID(); - const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, new_g) catch { + const maybe_idx = Globals.host.openOrFocusFileAtGrouping(path, new_g) catch { Globals.workbench.clearFileTreeTabDragDropState(); continue :events_loop; }; @@ -730,7 +733,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { e.handle(@src(), data); dvui.dragEnd(); dvui.refresh(null, @src(), data.id); - const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, self.grouping) catch { + const maybe_idx = Globals.host.openOrFocusFileAtGrouping(path, self.grouping) catch { Globals.workbench.clearFileTreeTabDragDropState(); continue :events_loop; }; @@ -754,10 +757,10 @@ pub fn drawCanvas(self: *Workspace) !void { switch (builtin.os.tag) { .macos => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; + content_color = if (!Globals.host.isMaximized()) content_color.opacity(Globals.host.contentOpacity()) else content_color; }, .windows => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; + content_color = if (!Globals.host.isMaximized()) content_color.opacity(Globals.host.contentOpacity()) else content_color; }, else => {}, } @@ -778,7 +781,7 @@ pub fn drawCanvas(self: *Workspace) !void { } if (Globals.host.docByIndex(self.open_file_index)) |doc| { - fizzy.editor.bindDocToPane(doc, canvas_vbox.data().id, self, self.center); + doc.owner.bindDocumentToPane(doc, canvas_vbox.data().id, self, self.center); _ = try doc.owner.drawDocument(doc); } } else { @@ -890,7 +893,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { ); if (button.clicked()) { - fizzy.editor.requestNewFileDialog(); + Globals.host.requestNewDocument(null, 0); } } { @@ -982,7 +985,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { }); defer scroll_area.deinit(); - var i: usize = fizzy.editor.recents.folders.items.len; + var i: usize = Globals.host.recentFolderCount(); while (i > 0) : (i -= 1) { var anim = dvui.animate(@src(), .{ .kind = .horizontal, @@ -994,7 +997,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { }); defer anim.deinit(); - const folder = fizzy.editor.recents.folders.items[i - 1]; + const folder = Globals.host.recentFolderAt(i - 1) orelse continue; if (dvui.button(@src(), folder, .{ .draw_focus = false, }, .{ @@ -1008,7 +1011,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { .color_fill_press = dvui.themeGet().color(.window, .fill_press), .color_text = dvui.themeGet().color(.control, .text).opacity(0.5), })) { - try fizzy.editor.setProjectFolder(folder); + try Globals.host.setProjectFolder(folder); } } } @@ -1070,7 +1073,7 @@ pub fn drawBubble(rect: dvui.Rect, rs: dvui.RectScale, color: [4]u8, _: usize) ! // This should never be able to return more than one folder pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void { if (folder) |f| { - fizzy.editor.setProjectFolder(f[0]) catch { + Globals.host.setProjectFolder(f[0]) catch { dvui.log.err("Failed to set project folder: {s}", .{f[0]}); }; } @@ -1079,7 +1082,7 @@ pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void { pub fn openFilesCallback(files: ?[][:0]const u8) void { if (files) |f| { for (f) |file| { - _ = fizzy.editor.openFilePath(file, Globals.workbench.open_workspace_grouping) catch { + _ = Globals.host.openFilePath(file, Globals.workbench.open_workspace_grouping) catch { dvui.log.err("Failed to open file: {s}", .{file}); }; } diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index f626b8b8..16f6d4ab 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -80,7 +80,7 @@ pub fn draw() !void { // Safe as long as `selected_paths` isn't mutated between now and `tree.deinit`. tree.selected_branch_ids = selectionBranchIdsForMultiDrag(dvui.currentWindow().arena()) catch selected_paths.keys(); - if (fizzy.editor.folder) |path| { + if (Globals.host.folder()) |path| { try drawFiles(path, tree); } else { Globals.workbench.file_tree_data_id = null; @@ -93,7 +93,7 @@ pub fn draw() !void { if (dvui.button(@src(), "Open Folder", .{ .draw_focus = false }, .{ .expand = .horizontal, .style = .highlight })) { if (try dvui.dialogNativeFolderSelect(dvui.currentWindow().arena(), .{ .title = "Open Project Folder" })) |folder| { - try fizzy.editor.setProjectFolder(folder); + try Globals.host.setProjectFolder(folder); } } } @@ -103,7 +103,7 @@ fn drawWeb() !void { var tree = fizzy.dvui.TreeWidget.tree(@src(), .{}, .{ .background = false, .expand = .both }); defer tree.deinit(); - const viewport_w = fizzy.editor.explorer.scroll_info.viewport.w; + const viewport_w = Globals.host.explorerViewportWidth(); const wrap_w: f32 = if (viewport_w > 0) viewport_w else 200; { @@ -267,11 +267,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u if ((dvui.menuItemLabel(@src(), "Close", .{}, .{ .expand = .horizontal, })) != null) { - if (fizzy.editor.folder) |f| { - fizzy.editor.ignore.deinit(fizzy.app.allocator); - fizzy.app.allocator.free(f); - fizzy.editor.folder = null; - } + Globals.host.closeProjectFolder(); fw2.close(); } @@ -279,7 +275,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u _ = dvui.separator(@src(), .{ .expand = .horizontal }); if ((dvui.menuItemLabel(@src(), open_message, .{}, .{ .expand = .horizontal })) != null) { - fizzy.editor.openInFileBrowser(project_path) catch { + Globals.host.openInFileBrowser(project_path) catch { dvui.log.err("Failed to open file browser", .{}); }; @@ -417,7 +413,7 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind .expand = .horizontal, .gravity_y = 0.5, }); - fizzy.editor.workbench.drawBranchDecorations(full_path, id_extra); + Globals.workbench.drawBranchDecorations(full_path, id_extra); } else { dvui.label(@src(), "{s}", .{label}, .{ .color_text = color, @@ -467,8 +463,8 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg &.{ directory, entry.name }, ); - if (fizzy.editor.folder) |proj_root| { - if (fizzy.editor.ignore.isIgnored(proj_root, abs_path, entry.name, entry.kind)) { + if (Globals.host.folder()) |proj_root| { + if (Globals.host.isPathIgnored(proj_root, abs_path, entry.name, entry.kind)) { continue; } } @@ -500,7 +496,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg var expanded = false; const expanded_indent: f32 = 14.0; - if (fizzy.editor.explorer.open_branches.get(branch_id) != null) { + if (Globals.host.explorerBranchIsOpen(branch_id)) { expanded = true; } @@ -599,7 +595,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg if (branch.dropInto() and entry.kind == .directory) { try applyFileMove(inner_unique_id, tree, abs_path); // Expand the folder so the dropped item is visible - fizzy.editor.explorer.open_branches.put(branch_id, {}) catch {}; + Globals.host.setExplorerBranchOpen(branch_id, true); } { // Add right click context menu for item options @@ -635,7 +631,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg break :blk &[_][]const u8{}; }; for (to_open) |p| { - _ = fizzy.editor.openFilePath(p, Globals.workbench.currentGroupingID()) catch |e| { + _ = Globals.host.openFilePath(p, Globals.workbench.currentGroupingID()) catch |e| { dvui.log.err("Failed to open file: {any} ({s})", .{ e, p }); }; } @@ -661,7 +657,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg Globals.workbench.newGroupingID(); have_grouping = true; } - _ = fizzy.editor.openFilePath(p, side_grouping) catch { + _ = Globals.host.openFilePath(p, side_grouping) catch { dvui.log.err("Failed to open file: {s}", .{p}); }; } @@ -673,7 +669,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg } if ((dvui.menuItemLabel(@src(), open_message, .{}, .{ .expand = .horizontal })) != null) { - fizzy.editor.openInFileBrowser(if (entry.kind == .file) std.fs.path.dirname(abs_path) orelse abs_path else abs_path) catch { + Globals.host.openInFileBrowser(if (entry.kind == .file) std.fs.path.dirname(abs_path) orelse abs_path else abs_path) catch { dvui.log.err("Failed to open file browser", .{}); }; @@ -745,13 +741,16 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color; if (ext == .fizzy) { - _ = fizzy.core.Sprite.draw( - fizzy.editor.atlas.sprites[fizzy.atlas.sprites.logo_default], - @src(), - fizzy.editor.atlas.source, - 2.0, - .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false }, - ); + const ui_atlas = Globals.host.uiAtlas(); + const ui_sprite = ui_atlas.sprites[fizzy.atlas.sprites.logo_default]; + const logo_sprite = fizzy.core.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source }; + _ = fizzy.core.Sprite.draw( + logo_sprite, + @src(), + ui_atlas.source, + 2.0, + .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false }, + ); } else { dvui.icon( @src(), @@ -768,15 +767,15 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg editableLabel( inner_id_extra.*, - if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", fizzy.editor.folder.?, abs_path) catch entry.name else entry.name, - if (fizzy.editor.docFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), + if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", Globals.host.folder().?, abs_path) catch entry.name else entry.name, + if (Globals.host.docFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), entry.kind, abs_path, ) catch { dvui.log.err("Failed to draw editable label", .{}); }; - if (fizzy.editor.docFromPath(abs_path)) |doc| { + if (Globals.host.docFromPath(abs_path)) |doc| { const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc); if (doc.owner.showsSaveStatusIndicator(doc)) { fizzy.dvui.bubbleSpinner(@src(), .{ @@ -810,7 +809,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg if (mode == .replace) { switch (ext) { .fizzy, .png, .jpg => { - _ = fizzy.editor.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| { + _ = Globals.host.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| { dvui.log.err("{any}: {s}", .{ err, abs_path }); }; }, @@ -880,9 +879,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg // .alpha = 0.15 * t, // }, })) { - fizzy.editor.explorer.open_branches.put(branch_id, {}) catch { - dvui.log.debug("Failed to track branch state!", .{}); - }; + Globals.host.setExplorerBranchOpen(branch_id, true); try search( abs_path, tree, @@ -893,13 +890,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg branch, ); } else { - if (fizzy.editor.explorer.open_branches.contains(branch_id)) { - _ = fizzy.editor.explorer.open_branches.remove(branch_id); + if (Globals.host.explorerBranchIsOpen(branch_id)) { + Globals.host.setExplorerBranchOpen(branch_id, false); } } // Keep open_branches in sync so hover-expand and drop-into expand persist next frame if (branch.expanded) { - fizzy.editor.explorer.open_branches.put(branch_id, {}) catch {}; + Globals.host.setExplorerBranchOpen(branch_id, true); } color_id.* = color_id.* + 1; }, @@ -1203,7 +1200,7 @@ pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.m return false; }; - if (fizzy.editor.docFromPath(source_path)) |doc| { + if (Globals.host.docFromPath(source_path)) |doc| { doc.owner.setDocumentPath(doc, new_path) catch { dvui.log.err("Failed to duplicate path: {s}", .{new_path}); return error.FailedToDuplicatePath; @@ -1228,7 +1225,7 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File var di: usize = 0; while (di < Globals.host.openDocCount()) : (di += 1) { const doc = Globals.host.docByIndex(di) orelse continue; - const path = fizzy.editor.docPath(doc); + const path = doc.owner.documentPath(doc); if (std.mem.containsAtLeast(u8, path, 1, full_path)) { const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(path)) catch "Failed to duplicate path"; const new_full = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name }); @@ -1241,7 +1238,7 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File .file => { std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); - if (fizzy.editor.docFromPath(full_path)) |doc| { + if (Globals.host.docFromPath(full_path)) |doc| { doc.owner.setDocumentPath(doc, new_path) catch { dvui.log.err("Failed to duplicate path: {s}", .{new_path}); return error.FailedToDuplicatePath; diff --git a/src/plugins/workbench/src/plugin.zig b/src/plugins/workbench/src/plugin.zig index 334cf8df..fff1c9a7 100644 --- a/src/plugins/workbench/src/plugin.zig +++ b/src/plugins/workbench/src/plugin.zig @@ -6,6 +6,7 @@ const std = @import("std"); const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); const sdk = fizzy.sdk; +const Globals = @import("Globals.zig"); const files = @import("files.zig"); /// Stable contribution ids (plugin-namespaced) referenced across modules. @@ -45,7 +46,7 @@ fn drawFiles(_: ?*anyopaque) anyerror!void { } fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result { - return fizzy.editor.drawWorkspaces(0); + return Globals.host.drawWorkspaces(0); } /// File-management keybinds (open / save). The shell registers its own diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index 7c047b2b..f24ae2b7 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -104,6 +104,39 @@ pub const VTable = struct { /// Allocate the next shell document id (monotonic). allocDocId: *const fn (ctx: *anyopaque) u64, + /// Explorer scroll viewport width (0 when unavailable). + explorerViewportWidth: *const fn (ctx: *anyopaque) f32, + /// Lookup an open document by absolute path. + docFromPath: *const fn (ctx: *anyopaque, path: []const u8) ?DocHandle, + /// Open `path` in `grouping` (async load when needed). Returns true when a new load started. + openFilePath: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool, + /// Focus an open doc or queue load; returns index when already open, null when loading. + openOrFocusFileAtGrouping: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!?usize, + /// Close document `id` (may prompt when dirty). + closeDocById: *const fn (ctx: *anyopaque, id: u64) anyerror!void, + /// Open/switch the project root folder. + setProjectFolder: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, + /// Close the current project folder (no-op when none open). + closeProjectFolder: *const fn (ctx: *anyopaque) void, + /// Recent project folders (most recent last). + recentFolderCount: *const fn (ctx: *anyopaque) usize, + recentFolderAt: *const fn (ctx: *anyopaque, index: usize) ?[]const u8, + /// Reveal `path` in the OS file browser. + openInFileBrowser: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, + /// True when `abs_path` is ignored by `.fizignore`/`.gitignore` at `project_root`. + isPathIgnored: *const fn ( + ctx: *anyopaque, + project_root: []const u8, + abs_path: []const u8, + name: []const u8, + kind: std.Io.File.Kind, + ) bool, + /// Explorer tree branch expanded state. + explorerBranchIsOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id) bool, + setExplorerBranchOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id, open: bool) void, + /// Draw workspace panes (center region); `index` is the root pane (usually 0). + drawWorkspaces: *const fn (ctx: *anyopaque, index: usize) anyerror!dvui.App.Result, + // ---- document editing (active file) ---- accept: *const fn (ctx: *anyopaque) anyerror!void, cancel: *const fn (ctx: *anyopaque) anyerror!void, @@ -222,6 +255,68 @@ pub fn allocDocId(self: EditorAPI) u64 { return self.vtable.allocDocId(self.ctx); } +pub fn explorerViewportWidth(self: EditorAPI) f32 { + return self.vtable.explorerViewportWidth(self.ctx); +} + +pub fn docFromPath(self: EditorAPI, path: []const u8) ?DocHandle { + return self.vtable.docFromPath(self.ctx, path); +} + +pub fn openFilePath(self: EditorAPI, path: []const u8, grouping: u64) !bool { + return self.vtable.openFilePath(self.ctx, path, grouping); +} + +pub fn openOrFocusFileAtGrouping(self: EditorAPI, path: []const u8, grouping: u64) !?usize { + return self.vtable.openOrFocusFileAtGrouping(self.ctx, path, grouping); +} + +pub fn closeDocById(self: EditorAPI, id: u64) !void { + return self.vtable.closeDocById(self.ctx, id); +} + +pub fn setProjectFolder(self: EditorAPI, path: []const u8) !void { + return self.vtable.setProjectFolder(self.ctx, path); +} + +pub fn closeProjectFolder(self: EditorAPI) void { + self.vtable.closeProjectFolder(self.ctx); +} + +pub fn recentFolderCount(self: EditorAPI) usize { + return self.vtable.recentFolderCount(self.ctx); +} + +pub fn recentFolderAt(self: EditorAPI, index: usize) ?[]const u8 { + return self.vtable.recentFolderAt(self.ctx, index); +} + +pub fn openInFileBrowser(self: EditorAPI, path: []const u8) !void { + return self.vtable.openInFileBrowser(self.ctx, path); +} + +pub fn isPathIgnored( + self: EditorAPI, + project_root: []const u8, + abs_path: []const u8, + name: []const u8, + kind: std.Io.File.Kind, +) bool { + return self.vtable.isPathIgnored(self.ctx, project_root, abs_path, name, kind); +} + +pub fn explorerBranchIsOpen(self: EditorAPI, branch_id: dvui.Id) bool { + return self.vtable.explorerBranchIsOpen(self.ctx, branch_id); +} + +pub fn setExplorerBranchOpen(self: EditorAPI, branch_id: dvui.Id, open: bool) void { + self.vtable.setExplorerBranchOpen(self.ctx, branch_id, open); +} + +pub fn drawWorkspaces(self: EditorAPI, index: usize) !dvui.App.Result { + return self.vtable.drawWorkspaces(self.ctx, index); +} + pub fn accept(self: EditorAPI) !void { return self.vtable.accept(self.ctx); } diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 94b79ab9..d1dca264 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -188,6 +188,68 @@ pub fn allocDocId(self: *Host) u64 { return if (self.shell_api) |a| a.allocDocId() else 0; } +pub fn explorerViewportWidth(self: *Host) f32 { + return if (self.shell_api) |a| a.explorerViewportWidth() else 0; +} + +pub fn docFromPath(self: *Host, path: []const u8) ?DocHandle { + return if (self.shell_api) |a| a.docFromPath(path) else null; +} + +pub fn openFilePath(self: *Host, path: []const u8, grouping: u64) !bool { + return if (self.shell_api) |a| try a.openFilePath(path, grouping) else false; +} + +pub fn openOrFocusFileAtGrouping(self: *Host, path: []const u8, grouping: u64) !?usize { + return if (self.shell_api) |a| try a.openOrFocusFileAtGrouping(path, grouping) else null; +} + +pub fn closeDocById(self: *Host, id: u64) !void { + if (self.shell_api) |a| return a.closeDocById(id); +} + +pub fn setProjectFolder(self: *Host, path: []const u8) !void { + return if (self.shell_api) |a| try a.setProjectFolder(path) else error.ShellNotInstalled; +} + +pub fn closeProjectFolder(self: *Host) void { + if (self.shell_api) |a| a.closeProjectFolder(); +} + +pub fn recentFolderCount(self: *Host) usize { + return if (self.shell_api) |a| a.recentFolderCount() else 0; +} + +pub fn recentFolderAt(self: *Host, index: usize) ?[]const u8 { + return if (self.shell_api) |a| a.recentFolderAt(index) else null; +} + +pub fn openInFileBrowser(self: *Host, path: []const u8) !void { + return if (self.shell_api) |a| try a.openInFileBrowser(path) else error.ShellNotInstalled; +} + +pub fn isPathIgnored( + self: *Host, + project_root: []const u8, + abs_path: []const u8, + name: []const u8, + kind: std.Io.File.Kind, +) bool { + return if (self.shell_api) |a| a.isPathIgnored(project_root, abs_path, name, kind) else false; +} + +pub fn explorerBranchIsOpen(self: *Host, branch_id: dvui.Id) bool { + return if (self.shell_api) |a| a.explorerBranchIsOpen(branch_id) else false; +} + +pub fn setExplorerBranchOpen(self: *Host, branch_id: dvui.Id, open: bool) void { + if (self.shell_api) |a| a.setExplorerBranchOpen(branch_id, open); +} + +pub fn drawWorkspaces(self: *Host, index: usize) !dvui.App.Result { + return if (self.shell_api) |a| try a.drawWorkspaces(index) else .ok; +} + pub fn accept(self: *Host) !void { if (self.shell_api) |a| return a.accept(); } From dd53062890772b5d684ecd6dbf39d3aedf9025ca Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 10:30:33 -0500 Subject: [PATCH 30/49] stage w4 --- HANDOFF.md | 11 ++- build.zig | 66 ++++++++++++++++- src/App.zig | 4 +- src/editor/Editor.zig | 31 ++++++-- src/editor/explorer/Explorer.zig | 5 +- src/plugins/workbench/module.zig | 1 + src/plugins/workbench/src/FileLoadJob.zig | 11 +-- src/plugins/workbench/src/Workbench.zig | 37 +++++----- src/plugins/workbench/src/Workspace.zig | 63 ++++++++-------- src/plugins/workbench/src/files.zig | 73 +++++++++---------- src/plugins/workbench/src/plugin.zig | 11 +-- .../workbench/src/workbench_layout.zig | 5 +- src/plugins/workbench/workbench.zig | 9 +++ src/sdk/EditorAPI.zig | 27 +++++++ src/sdk/Host.zig | 14 ++++ 15 files changed, 245 insertions(+), 123 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 38646b80..939cd1cd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -389,9 +389,14 @@ values/keys at `Workspace.zig:467+`) — that's the deep coupling. `drawWorkspaces`. Workbench `files.zig`/`Workspace.zig`/`Workbench.zig`/`plugin.zig` now route through `Globals.host` + `Globals.workbench`; zero runtime `fizzy.editor` refs remain in workbench draw paths (comments only). -- **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core**; then - **W5 — `b.addModule("workbench")`** + `@import("workbench")`, drop the shell path imports - (`Editor.zig` re-exports of `Workspace`/`FileLoadJob`/`Workbench`) and the `fizzy` import. +- **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core — DONE.** + Workbench hub (`workbench.zig`) re-exports `wdvui` (= `core.dvui`), `math`, `atlas`, + `platform`, `Sprite`, `perf`. Plugin sources use `Globals.allocator()` instead of + `fizzy.app`; native open dialogs via `host.showOpenFolderDialog`/`showOpenFileDialog`. + `workbench-api` service ctx is `*Host` (no `fizzy.Editor` in workbench). +- **W5 — `b.addModule("workbench")` + shell `@import("workbench")` — DONE.** + `wireWorkbenchModule` in `build.zig` (native, web, test). `Editor.zig`/`App.zig`/ + `Explorer.zig` import the module; path imports removed. --- diff --git a/build.zig b/build.zig index 79bd5e78..dabd2f9c 100644 --- a/build.zig +++ b/build.zig @@ -412,7 +412,7 @@ pub fn build(b: *std.Build) !void { }); web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module); - wirePixelartModule(b, web_target, optimize, .{ + const pixelart_module_web = wirePixelartModule(b, web_target, optimize, .{ .dvui = dvui_web_dep.module("dvui_web"), .core = core_module_web, .sdk = sdk_module_web, @@ -423,6 +423,14 @@ pub fn build(b: *std.Build) !void { .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null, .backend = null, }, web_exe.root_module); + wireWorkbenchModule(b, web_target, optimize, .{ + .dvui = dvui_web_dep.module("dvui_web"), + .core = core_module_web, + .sdk = sdk_module_web, + .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null, + .pixelart = pixelart_module_web, + .backend = null, + }, web_exe.root_module); const web_install_dir: std.Build.InstallDir = .{ .custom = "web" }; const install_wasm = b.addInstallArtifact(web_exe, .{ @@ -860,7 +868,7 @@ pub fn build(b: *std.Build) !void { } fizzy_test_module.addImport("core", core_module_test); const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module); - wirePixelartModule(b, target, optimize, .{ + const pixelart_module_test = wirePixelartModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), .core = core_module_test, .sdk = sdk_module_test, @@ -871,6 +879,14 @@ pub fn build(b: *std.Build) !void { .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null, .backend = dvui_testing_dep.module("testing"), }, fizzy_test_module); + wireWorkbenchModule(b, target, optimize, .{ + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null, + .pixelart = pixelart_module_test, + .backend = dvui_testing_dep.module("testing"), + }, fizzy_test_module); if (target.result.os.tag == .macos) { if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| { @@ -1202,7 +1218,7 @@ fn addFizzyExecutableForTarget( core_module.addImport("icons", dep.module("icons")); icons_module = dep.module("icons"); } - wirePixelartModule(b, resolved_target, optimize, .{ + const pixelart_module = wirePixelartModule(b, resolved_target, optimize, .{ .dvui = dvui_dep.module("dvui_sdl3"), .core = core_module, .sdk = sdk_module, @@ -1213,6 +1229,14 @@ fn addFizzyExecutableForTarget( .icons = icons_module, .backend = dvui_dep.module("sdl3"), }, exe.root_module); + wireWorkbenchModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + .icons = icons_module, + .pixelart = pixelart_module, + .backend = dvui_dep.module("sdl3"), + }, exe.root_module); const singleton_app_dep = b.dependency("dvui_singleton_app", .{ .target = resolved_target, @@ -1306,6 +1330,39 @@ const PixelartModuleDeps = struct { backend: ?*std.Build.Module, }; +const WorkbenchModuleDeps = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + icons: ?*std.Build.Module, + pixelart: *std.Build.Module, + backend: ?*std.Build.Module, +}; + +/// Workbench plugin (`src/plugins/workbench/module.zig`). +fn wireWorkbenchModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + deps: WorkbenchModuleDeps, + consumer: *std.Build.Module, +) void { + const workbench_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/plugins/workbench/module.zig"), + .link_libc = target.result.cpu.arch != .wasm32, + .single_threaded = target.result.cpu.arch == .wasm32, + }); + workbench_module.addImport("dvui", deps.dvui); + workbench_module.addImport("core", deps.core); + workbench_module.addImport("sdk", deps.sdk); + workbench_module.addImport("pixelart", deps.pixelart); + if (deps.icons) |icons| workbench_module.addImport("icons", icons); + if (deps.backend) |backend| workbench_module.addImport("backend", backend); + consumer.addImport("workbench", workbench_module); +} + /// Pixel-art plugin (`src/plugins/pixelart/module.zig`). fn wirePixelartModule( b: *std.Build, @@ -1313,7 +1370,7 @@ fn wirePixelartModule( optimize: std.builtin.OptimizeMode, deps: PixelartModuleDeps, consumer: *std.Build.Module, -) void { +) *std.Build.Module { const pixelart_module = b.createModule(.{ .target = target, .optimize = optimize, @@ -1331,6 +1388,7 @@ fn wirePixelartModule( if (deps.icons) |icons| pixelart_module.addImport("icons", icons); if (deps.backend) |backend| pixelart_module.addImport("backend", backend); consumer.addImport("pixelart", pixelart_module); + return pixelart_module; } inline fn thisDir() []const u8 { diff --git a/src/App.zig b/src/App.zig index a2265166..f957caab 100644 --- a/src/App.zig +++ b/src/App.zig @@ -8,9 +8,9 @@ const assets = @import("assets"); const icon = assets.files.@"icon.png"; const fizzy = @import("fizzy.zig"); +const workbench = @import("workbench"); const pixelart = @import("pixelart"); -// Path import until workbench becomes a build module (Stage W5); see HANDOFF "Stage W". -const WorkbenchGlobals = @import("plugins/workbench/src/Globals.zig"); +const WorkbenchGlobals = workbench.Globals; const auto_update = @import("backend/auto_update.zig"); const update_notify = @import("backend/update_notify.zig"); const singleton = @import("backend/singleton.zig"); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 83891efe..c1fb56c1 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -27,21 +27,23 @@ pub const Dialogs = @import("dialogs/Dialogs.zig"); pub const Keybinds = @import("Keybinds.zig"); -pub const Workspace = @import("../plugins/workbench/src/Workspace.zig"); +const workbench_mod = @import("workbench"); + +pub const Workspace = workbench_mod.Workspace; pub const Explorer = @import("explorer/Explorer.zig"); pub const IgnoreRules = @import("explorer/IgnoreRules.zig"); pub const Panel = @import("panel/Panel.zig"); pub const Sidebar = @import("Sidebar.zig"); pub const Infobar = @import("Infobar.zig"); pub const Menu = @import("Menu.zig"); -pub const FileLoadJob = @import("../plugins/workbench/src/FileLoadJob.zig"); +pub const FileLoadJob = workbench_mod.FileLoadJob; pub const sdk = fizzy.sdk; pub const Host = sdk.Host; /// Workbench (Phase 1): file-management home — currently the per-branch /// decoration registry for the explorer; grows to own files + tabs/splits. -pub const Workbench = @import("../plugins/workbench/src/Workbench.zig"); +pub const Workbench = workbench_mod.Workbench; /// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels. /// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame @@ -460,7 +462,7 @@ pub fn postInit(editor: *Editor) !void { // near-empty shell's content: it iterates the Host registries rather than // hardcoding panes. Web-safe — the draw fns reach the same inline code the // editor tick already runs on wasm. Order = sidebar order. - try @import("../plugins/workbench/src/plugin.zig").register(&editor.host); + try workbench_mod.plugin.register(&editor.host); const pixelart_plugin = pixelart.plugin; try pixelart_plugin.register(&editor.host); try pixelart_plugin.pluginPtr().initPlugin(); @@ -495,7 +497,7 @@ const pixelart_plugin = pixelart.plugin; // wasm analysis entirely (the codebase's dead-branch convention; see // `web_main.zig`). if (comptime builtin.target.cpu.arch != .wasm32) { - editor.workbench.initService(editor); + editor.workbench.initService(&editor.host); try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api); } } @@ -565,6 +567,8 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .explorerBranchIsOpen = shellExplorerBranchIsOpen, .setExplorerBranchOpen = shellSetExplorerBranchOpen, .drawWorkspaces = shellDrawWorkspaces, + .showOpenFolderDialog = shellShowOpenFolderDialog, + .showOpenFileDialog = shellShowOpenFileDialog, .accept = shellAccept, .cancel = shellCancel, .copy = shellCopy, @@ -722,6 +726,21 @@ fn shellSetExplorerBranchOpen(ctx: *anyopaque, branch_id: dvui.Id, open: bool) v fn shellDrawWorkspaces(ctx: *anyopaque, index: usize) anyerror!dvui.App.Result { return drawWorkspaces(shellCtx(ctx), index); } +fn shellShowOpenFolderDialog(ctx: *anyopaque, cb: sdk.EditorAPI.OpenPathsCallback, default_folder: ?[]const u8) void { + _ = ctx; + fizzy.backend.showOpenFolderDialog(cb, default_folder); +} +fn shellShowOpenFileDialog( + ctx: *anyopaque, + cb: sdk.EditorAPI.OpenPathsCallback, + filters: []const sdk.EditorAPI.SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + _ = ctx; + const native_filters: [*]const fizzy.backend.DialogFileFilter = @ptrCast(filters.ptr); + fizzy.backend.showOpenFileDialog(cb, native_filters[0..filters.len], default_filename, default_folder); +} fn shellAccept(ctx: *anyopaque) anyerror!void { return shellCtx(ctx).accept(); } @@ -1872,7 +1891,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { } editor.folder = try fizzy.app.allocator.dupe(u8, path); try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); - editor.host.setActiveSidebarView(@import("../plugins/workbench/src/plugin.zig").view_files); + editor.host.setActiveSidebarView(workbench_mod.plugin.view_files); pixelart.plugin.pluginPtr().reloadProjectFolder(fizzy.app.allocator); editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 93359cb2..ae597de7 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -2,6 +2,7 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("../../fizzy.zig"); +const workbench = @import("workbench"); const icons = @import("icons"); const Core = @import("mach").Core; @@ -12,7 +13,7 @@ const nfd = @import("nfd"); pub const Explorer = @This(); -pub const files = @import("../../plugins/workbench/src/files.zig"); +pub const files = workbench.files; // pub const animations = @import("animations.zig"); // pub const keyframe_animations = @import("keyframe_animations.zig"); // The pixel-art project view is contributed by the plugin via `Host.registerSidebarView`, @@ -107,7 +108,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { .background = false, }); - if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/src/plugin.zig").view_files)) { + if (!fizzy.editor.host.isActiveSidebarView(workbench.plugin.view_files)) { fizzy.editor.workbench.file_tree_data_id = null; fizzy.editor.workbench.clearFileTreeTabDragDropState(); } diff --git a/src/plugins/workbench/module.zig b/src/plugins/workbench/module.zig index f5996363..d29a7613 100644 --- a/src/plugins/workbench/module.zig +++ b/src/plugins/workbench/module.zig @@ -9,3 +9,4 @@ pub const files = @import("src/files.zig"); pub const Workspace = @import("src/Workspace.zig"); pub const Workbench = @import("src/Workbench.zig"); pub const FileLoadJob = @import("src/FileLoadJob.zig"); +pub const Globals = @import("src/Globals.zig"); diff --git a/src/plugins/workbench/src/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig index 164e05c3..eee291d7 100644 --- a/src/plugins/workbench/src/FileLoadJob.zig +++ b/src/plugins/workbench/src/FileLoadJob.zig @@ -15,9 +15,10 @@ //! but only writes through atomic fields + the worker-only `doc_buf`/`err` fields. const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); -const dvui = @import("dvui"); -const perf = fizzy.perf; +const wb = @import("../workbench.zig"); +const dvui = wb.dvui; +const perf = wb.perf; +const sdk = wb.sdk; const FileLoadJob = @This(); @@ -35,7 +36,7 @@ allocator: std.mem.Allocator, path: []u8, /// Plugin that owns this file's extension (resolved on the main thread before spawn). -owner: *fizzy.sdk.Plugin, +owner: *sdk.Plugin, /// Workspace grouping the file should land in once loaded. target_grouping: u64, @@ -55,7 +56,7 @@ doc_buf: []u8, err: ?anyerror = null, -pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk.Plugin, target_grouping: u64) !*FileLoadJob { +pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *sdk.Plugin, target_grouping: u64) !*FileLoadJob { const path_copy = try allocator.dupe(u8, path); errdefer allocator.free(path_copy); diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index c5b88234..4a402049 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -11,7 +11,6 @@ const std = @import("std"); const dvui = @import("dvui"); const icons = @import("icons"); -const fizzy = @import("../../../fizzy.zig"); const files = @import("files.zig"); const Workspace = @import("Workspace.zig"); const Globals = @import("Globals.zig"); @@ -107,10 +106,9 @@ pub fn activeWorkspaceCanvasRectPhysical(self: *Workbench) ?dvui.Rect.Physical { return workspace.canvas_rect_physical; } -/// Build the `workbench-api` service. `editor_ctx` is the host's heap `*Editor`, -/// passed opaquely so the API has no compile-time dependency back on the editor. -pub fn initService(self: *Workbench, editor_ctx: *anyopaque) void { - self.api = .{ .ctx = editor_ctx, .vtable = &service_vtable }; +/// Build the `workbench-api` service. `host_ctx` is the shell `*Host`. +pub fn initService(self: *Workbench, host_ctx: *sdk.Host) void { + self.api = .{ .ctx = host_ctx, .vtable = &service_vtable }; } /// Register the decorations the shell ships with. Called once after the editor is @@ -259,35 +257,34 @@ const service_vtable: Api.VTable = .{ .registerBranchDecorator = svcRegisterBranchDecorator, }; -inline fn editorOf(ctx: *anyopaque) *fizzy.Editor { +inline fn hostOf(ctx: *anyopaque) *sdk.Host { return @ptrCast(@alignCast(ctx)); } fn svcOpen(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool { - return editorOf(ctx).openFilePath(path, grouping); + return hostOf(ctx).openFilePath(path, grouping); } -fn svcCurrentGrouping(ctx: *anyopaque) u64 { - return editorOf(ctx).workbench.currentGroupingID(); +fn svcCurrentGrouping(_: *anyopaque) u64 { + return Globals.workbench.currentGroupingID(); } -fn svcNewGrouping(ctx: *anyopaque) u64 { - return editorOf(ctx).workbench.newGroupingID(); +fn svcNewGrouping(_: *anyopaque) u64 { + return Globals.workbench.newGroupingID(); } fn svcClose(ctx: *anyopaque, id: u64) anyerror!void { - return editorOf(ctx).closeFileID(id); + return hostOf(ctx).closeDocById(id); } fn svcSave(ctx: *anyopaque) anyerror!void { - return editorOf(ctx).save(); + return hostOf(ctx).save(); } fn svcIsOpen(ctx: *anyopaque, path: []const u8) bool { - return editorOf(ctx).docFromPath(path) != null; + return hostOf(ctx).docFromPath(path) != null; } fn svcOpenCount(ctx: *anyopaque) usize { - return editorOf(ctx).open_files.count(); + return hostOf(ctx).openDocCount(); } fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 { - const editor = editorOf(ctx); - const doc = editor.docAt(index) orelse return null; - return editor.docPath(doc); + const doc = hostOf(ctx).docByIndex(index) orelse return null; + return doc.owner.documentPath(doc); } fn svcCreateFile(_: *anyopaque, path: []const u8) anyerror!void { return files.createFilePath(path); @@ -304,6 +301,6 @@ fn svcDelete(_: *anyopaque, path: []const u8) void { fn svcMove(_: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool { return files.moveOnePath(path, target_dir, dvui.currentWindow().arena()); } -fn svcRegisterBranchDecorator(ctx: *anyopaque, decorator: BranchDecorator) anyerror!void { - return editorOf(ctx).workbench.registerBranchDecorator(decorator); +fn svcRegisterBranchDecorator(_: *anyopaque, decorator: BranchDecorator) anyerror!void { + return Globals.workbench.registerBranchDecorator(decorator); } diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index 7d069a8d..d69c49e6 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -1,16 +1,13 @@ const std = @import("std"); const builtin = @import("builtin"); -const dvui = @import("dvui"); -const sdk = @import("sdk"); -const pixelart = @import("pixelart"); -const fizzy = @import("../../../fizzy.zig"); +const wb = @import("../workbench.zig"); +const dvui = wb.dvui; +const wdvui = wb.wdvui; +const sdk = wb.sdk; const Globals = @import("Globals.zig"); const icons = @import("icons"); -const App = fizzy.App; -const Editor = fizzy.Editor; - /// Workspaces are drawn recursively inside of the explorer paned widget /// second pane, and contains drag/drop enabled tabs. Tabs can freely be dragged to /// panes or other tab bars. @@ -50,14 +47,14 @@ const handle_dist = 60; const opacity = 60; -const color_0 = fizzy.math.Color.initBytes(0, 0, 0, 0); -const color_1 = fizzy.math.Color.initBytes(230, 175, 137, opacity); -const color_2 = fizzy.math.Color.initBytes(216, 145, 115, opacity); -const color_3 = fizzy.math.Color.initBytes(41, 23, 41, opacity); -const color_4 = fizzy.math.Color.initBytes(194, 109, 92, opacity); -const color_5 = fizzy.math.Color.initBytes(180, 89, 76, opacity); +const color_0 = wb.math.Color.initBytes(0, 0, 0, 0); +const color_1 = wb.math.Color.initBytes(230, 175, 137, opacity); +const color_2 = wb.math.Color.initBytes(216, 145, 115, opacity); +const color_3 = wb.math.Color.initBytes(41, 23, 41, opacity); +const color_4 = wb.math.Color.initBytes(194, 109, 92, opacity); +const color_5 = wb.math.Color.initBytes(180, 89, 76, opacity); -const logo_colors: [12]fizzy.math.Color = [_]fizzy.math.Color{ +const logo_colors: [12]wb.math.Color = [_]wb.math.Color{ color_1, color_1, color_1, color_2, color_2, color_3, color_4, color_3, color_0, @@ -224,7 +221,7 @@ fn drawTabs(self: *Workspace) void { defer hbox.deinit(); - const tab_hovered = fizzy.dvui.hovered(hbox.data()); + const tab_hovered = wdvui.hovered(hbox.data()); if (selected) { if (!reorderable.floating()) { @@ -251,14 +248,14 @@ fn drawTabs(self: *Workspace) void { if (prev_same_group_index) |prev_index| { if (i == prev_index) { // This tab is directly to the left of the active tab. - fizzy.dvui.drawEdgeShadow(hbox.data().rectScale(), .right, .{}); + wdvui.drawEdgeShadow(hbox.data().rectScale(), .right, .{}); } } if (next_same_group_index) |next_index| { if (i == next_index) { // This tab is directly to the right of the active tab. - fizzy.dvui.drawEdgeShadow(hbox.data().rectScale(), .left, .{}); + wdvui.drawEdgeShadow(hbox.data().rectScale(), .left, .{}); } } } @@ -271,9 +268,9 @@ fn drawTabs(self: *Workspace) void { if (is_fizzy_file) { const ui_atlas = Globals.host.uiAtlas(); - const ui_sprite = ui_atlas.sprites[fizzy.atlas.sprites.logo_default]; - const logo_sprite = fizzy.core.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source }; - _ = fizzy.core.Sprite.draw(logo_sprite, @src(), ui_atlas.source, 2.0, .{ + const ui_sprite = ui_atlas.sprites[wb.atlas.sprites.logo_default]; + const logo_sprite = wb.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source }; + _ = wb.Sprite.draw(logo_sprite, @src(), ui_atlas.source, 2.0, .{ .gravity_y = 0.5, .padding = dvui.Rect.all(4), }); @@ -292,8 +289,8 @@ fn drawTabs(self: *Workspace) void { .gravity_y = 0.5, }); - const close_inner = fizzy.dvui.windowHeaderCloseInnerSide(); - const close_pad = fizzy.dvui.window_header_close_margin; + const close_inner = wdvui.windowHeaderCloseInnerSide(); + const close_pad = wdvui.window_header_close_margin; const tab_status_slot = close_inner + close_pad.x + close_pad.w; const status_close_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ @@ -310,14 +307,14 @@ fn drawTabs(self: *Workspace) void { // atomic load — the write side uses an atomic store in matching `save*` paths. const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc); const save_in_check_phase = if (save_flash_elapsed) |elapsed| - fizzy.dvui.bubbleSpinnerSaveInCheckPhase(elapsed) + wdvui.bubbleSpinnerSaveInCheckPhase(elapsed) else false; const save_blocks_tab_close = doc.owner.isDocumentSaving(doc) or (doc.owner.showsSaveStatusIndicator(doc) and !save_in_check_phase); if (save_blocks_tab_close) { - fizzy.dvui.bubbleSpinner(@src(), .{ + wdvui.bubbleSpinner(@src(), .{ .id_extra = i *% 16 + 5, .expand = .none, .min_size_content = .{ .w = close_inner, .h = close_inner }, @@ -328,7 +325,7 @@ fn drawTabs(self: *Workspace) void { .complete_elapsed_ns = save_flash_elapsed, }); } else if (save_in_check_phase and !tab_hovered) { - fizzy.dvui.bubbleSpinner(@src(), .{ + wdvui.bubbleSpinner(@src(), .{ .id_extra = i *% 16 + 5, .expand = .none, .min_size_content = .{ .w = close_inner, .h = close_inner }, @@ -340,7 +337,7 @@ fn drawTabs(self: *Workspace) void { }); } else if (tab_hovered) { var tab_close_button: dvui.ButtonWidget = undefined; - tab_close_button.init(@src(), .{ .draw_focus = false }, fizzy.dvui.windowHeaderCloseButtonOptions(.{ + tab_close_button.init(@src(), .{ .draw_focus = false }, wdvui.windowHeaderCloseButtonOptions(.{ .expand = .none, .min_size_content = .{ .w = close_inner, .h = close_inner }, .id_extra = i *% 16 + 1, @@ -372,7 +369,7 @@ fn drawTabs(self: *Workspace) void { } else if (selected and !doc.owner.isDirty(doc)) { const tab_text = dvui.themeGet().color(.window, .text); var ghost_close: dvui.ButtonWidget = undefined; - ghost_close.init(@src(), .{ .draw_focus = false }, fizzy.dvui.windowHeaderCloseButtonOptions(.{ + ghost_close.init(@src(), .{ .draw_focus = false }, wdvui.windowHeaderCloseButtonOptions(.{ .expand = .none, .min_size_content = .{ .w = close_inner, .h = close_inner }, .id_extra = i *% 16 + 3, @@ -837,7 +834,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { if (fizzy_color.value[3] < 1.0 and fizzy_color.value[3] > 0.0) { const theme_bg = dvui.themeGet().color(.window, .fill); - fizzy_color = fizzy_color.lerp(fizzy.math.Color.initBytes(theme_bg.r, theme_bg.g, theme_bg.b, 255), fizzy_color.value[3]); + fizzy_color = fizzy_color.lerp(wb.math.Color.initBytes(theme_bg.r, theme_bg.g, theme_bg.b, 255), fizzy_color.value[3]); fizzy_color.value[3] = 1.0; } @@ -884,7 +881,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { button.processEvents(); button.drawBackground(); - fizzy.dvui.labelWithKeybind( + wdvui.labelWithKeybind( "New File", dvui.currentWindow().keybinds.get("new_file") orelse .{}, true, @@ -911,7 +908,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { button.processEvents(); button.drawBackground(); - fizzy.dvui.labelWithKeybind( + wdvui.labelWithKeybind( "Open Folder", dvui.currentWindow().keybinds.get("open_folder") orelse .{}, true, @@ -920,7 +917,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { ); if (button.clicked()) { - fizzy.backend.showOpenFolderDialog(setProjectFolderCallback, null); + Globals.host.showOpenFolderDialog(setProjectFolderCallback, null); } } @@ -939,7 +936,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { button.processEvents(); button.drawBackground(); - fizzy.dvui.labelWithKeybind( + wdvui.labelWithKeybind( "Open Files", dvui.currentWindow().keybinds.get("open_files") orelse .{}, true, @@ -960,7 +957,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { // } // } - fizzy.backend.showOpenFileDialog(openFilesCallback, &.{ + Globals.host.showOpenFileDialog(openFilesCallback, &.{ .{ .name = "Image Files", .pattern = "fizzy;png;jpg;jpeg" }, }, "", null); } diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index 16f6d4ab..5a94574f 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -1,22 +1,19 @@ const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); +const builtin = @import("builtin"); +const wb = @import("../workbench.zig"); const Globals = @import("Globals.zig"); const pixelart = @import("pixelart"); -const dvui = @import("dvui"); -const Editor = fizzy.Editor; -const builtin = @import("builtin"); - +const dvui = wb.dvui; +const wdvui = wb.wdvui; const icons = @import("icons"); -const nfd = @import("nfd"); - pub var tree_removed_path: ?[]const u8 = null; pub var selected_id: ?usize = null; pub var edit_id: ?usize = null; /// Multi-selection for the file tree. Maps `id_extra` (hash of absolute path) to the heap-owned /// absolute path string. The primary `selected_id` is always a key here when set. Paths are -/// allocated from `fizzy.app.allocator` so they outlive the dvui arena used during draw. +/// allocated from `Globals.allocator()` so they outlive the dvui arena used during draw. pub var selected_paths: std.AutoArrayHashMapUnmanaged(usize, []u8) = .empty; pub var selection_anchor: ?usize = null; @@ -64,7 +61,7 @@ pub fn draw() !void { } // `tab_drag` matches workspace tab strips so file rows can drop on the canvas like tabs (DVUI reorder_tree cross-widget pattern). - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true, .drag_name = "tab_drag" }, .{ .background = false, .expand = .both }); + var tree = wdvui.TreeWidget.tree(@src(), .{ .enable_reordering = true, .drag_name = "tab_drag" }, .{ .background = false, .expand = .both }); defer tree.deinit(); // Same as tools pane header: first frame after open (or after Files wasn't drawn last frame) @@ -100,7 +97,7 @@ pub fn draw() !void { } fn drawWeb() !void { - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{}, .{ .background = false, .expand = .both }); + var tree = wdvui.TreeWidget.tree(@src(), .{}, .{ .background = false, .expand = .both }); defer tree.deinit(); const viewport_w = Globals.host.explorerViewportWidth(); @@ -130,7 +127,7 @@ fn drawWeb() !void { .style = .highlight, .min_size_content = .{ .w = 110, .h = 0 }, })) { - fizzy.backend.showOpenFileDialog( + Globals.host.showOpenFileDialog( struct { fn cb(_: ?[][:0]const u8) void {} }.cb, @@ -141,7 +138,7 @@ fn drawWeb() !void { } } -pub fn drawFiles(path: []const u8, tree: *fizzy.dvui.TreeWidget) !void { +pub fn drawFiles(path: []const u8, tree: *wdvui.TreeWidget) !void { const unique_id = dvui.parentGet().extendId(@src(), 0); Globals.workbench.file_tree_data_id = unique_id; @@ -252,7 +249,7 @@ pub fn drawFiles(path: []const u8, tree: *fizzy.dvui.TreeWidget) !void { } /// Context menu for the project root directory: close project, reveal on disk, new file / folder. -fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u8, tree: *fizzy.dvui.TreeWidget) !void { +fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u8, tree: *wdvui.TreeWidget) !void { var fw2 = dvui.floatingMenu(@src(), .{ .from = dvui.Rect.Natural.fromPoint(point) }, .{ .box_shadow = .{ .color = .black, .offset = .{ .x = 0, .y = 0 }, @@ -427,7 +424,7 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind } } -pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidget, unique_id: dvui.Id, outer_filter_text: []const u8) !void { +pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, unique_id: dvui.Id, outer_filter_text: []const u8) !void { var color_i: usize = 0; var id_extra: usize = 0; @@ -435,7 +432,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg errdefer pending_file_shift_range = null; const recursor = struct { - fn search(directory: []const u8, tree: *fizzy.dvui.TreeWidget, inner_unique_id: dvui.Id, inner_id_extra: *usize, color_id: *usize, filter_text: []const u8, parent_branch: ?*fizzy.dvui.TreeWidget.Branch) !void { + fn search(directory: []const u8, tree: *wdvui.TreeWidget, inner_unique_id: dvui.Id, inner_id_extra: *usize, color_id: *usize, filter_text: []const u8, parent_branch: ?*wdvui.TreeWidget.Branch) !void { const io = dvui.io; var dir = std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true }) catch return; defer dir.close(io); @@ -479,7 +476,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg } inner_id_extra.* = dvui.Id.update(tree.data().id, abs_path).asUsize(); - try visible_file_rows_order.append(fizzy.app.allocator, .{ .id = inner_id_extra.*, .path = abs_path }); + try visible_file_rows_order.append(Globals.allocator(), .{ .id = inner_id_extra.*, .path = abs_path }); var color = dvui.themeGet().color(.control, .fill); if (pixelart.Globals.state.colors.palette) |*palette| { @@ -534,7 +531,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg selected_id = inner_id_extra.*; var close_rect = branch.button.data().borderRectScale().r; close_rect.h = @max(10.0, close_rect.h); - fizzy.dvui.dialog_close_rect_override = close_rect; + wdvui.dialog_close_rect_override = close_rect; new_file_path = null; } } @@ -578,11 +575,11 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg if (entry.kind == .file and tree.id_branch == inner_id_extra.*) { if (Globals.workbench.tab_drag_from_tree_path) |old| { if (!std.mem.eql(u8, old, abs_path)) { - fizzy.app.allocator.free(old); - Globals.workbench.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null; + Globals.allocator().free(old); + Globals.workbench.tab_drag_from_tree_path = Globals.allocator().dupe(u8, abs_path) catch null; } } else { - Globals.workbench.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null; + Globals.workbench.tab_drag_from_tree_path = Globals.allocator().dupe(u8, abs_path) catch null; } } } @@ -742,9 +739,9 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg if (ext == .fizzy) { const ui_atlas = Globals.host.uiAtlas(); - const ui_sprite = ui_atlas.sprites[fizzy.atlas.sprites.logo_default]; - const logo_sprite = fizzy.core.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source }; - _ = fizzy.core.Sprite.draw( + const ui_sprite = ui_atlas.sprites[wb.atlas.sprites.logo_default]; + const logo_sprite = wb.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source }; + _ = wb.Sprite.draw( logo_sprite, @src(), ui_atlas.source, @@ -778,7 +775,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg if (Globals.host.docFromPath(abs_path)) |doc| { const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc); if (doc.owner.showsSaveStatusIndicator(doc)) { - fizzy.dvui.bubbleSpinner(@src(), .{ + wdvui.bubbleSpinner(@src(), .{ .id_extra = inner_id_extra.* +% 4001, .expand = .none, .min_size_content = .{ .w = 14, .h = 14 }, @@ -919,33 +916,33 @@ pub fn isFileSelected(id: usize) bool { fn selectionFreeAll() void { var it = selected_paths.iterator(); - while (it.next()) |e| fizzy.app.allocator.free(e.value_ptr.*); + while (it.next()) |e| Globals.allocator().free(e.value_ptr.*); selected_paths.clearRetainingCapacity(); } fn selectionPut(id: usize, path: []const u8) void { if (selected_paths.getPtr(id)) |existing| { if (std.mem.eql(u8, existing.*, path)) return; - fizzy.app.allocator.free(existing.*); - existing.* = fizzy.app.allocator.dupe(u8, path) catch return; + Globals.allocator().free(existing.*); + existing.* = Globals.allocator().dupe(u8, path) catch return; return; } - const copy = fizzy.app.allocator.dupe(u8, path) catch return; - selected_paths.put(fizzy.app.allocator, id, copy) catch { - fizzy.app.allocator.free(copy); + const copy = Globals.allocator().dupe(u8, path) catch return; + selected_paths.put(Globals.allocator(), id, copy) catch { + Globals.allocator().free(copy); }; } fn selectionRemove(id: usize) bool { if (selected_paths.fetchSwapRemove(id)) |kv| { - fizzy.app.allocator.free(kv.value); + Globals.allocator().free(kv.value); return true; } return false; } /// Apply a modifier-aware click to the file-tree selection. Indexed by id_extra (path hash). -fn applyFileClick(id: usize, path: []const u8, mode: fizzy.dvui.TreeSelection.ClickMode) void { +fn applyFileClick(id: usize, path: []const u8, mode: wdvui.TreeSelection.ClickMode) void { switch (mode) { .replace => { selectionFreeAll(); @@ -1009,14 +1006,14 @@ fn applyFileShiftRange(clicked_id: usize, clicked_path: []const u8, anchor_id: u /// Derive the click mode from the most recent pointer release event that falls within `rect`. /// Used after `branch.button.clicked()` so we can honor ctrl/cmd/shift without intercepting the /// button's own event handling. -fn detectClickMode(rect: dvui.Rect.Physical) fizzy.dvui.TreeSelection.ClickMode { - var mode: fizzy.dvui.TreeSelection.ClickMode = .replace; +fn detectClickMode(rect: dvui.Rect.Physical) wdvui.TreeSelection.ClickMode { + var mode: wdvui.TreeSelection.ClickMode = .replace; for (dvui.events()) |*e| { if (e.evt != .mouse) continue; const me = e.evt.mouse; if (me.action != .release or !me.button.pointer()) continue; if (!rect.contains(me.p)) continue; - mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod); + mode = wdvui.TreeSelection.clickModeFromMod(me.mod); } return mode; } @@ -1140,7 +1137,7 @@ fn selectionBranchIdsForMultiDrag(arena: std.mem.Allocator) ![]const usize { /// Move the drag source (and, for a multi-drag, every other selected path) into `target_dir`. /// Renames files/folders on disk and rewrites open-file paths in-place. Clears the drag's /// stashed `removed_path` when complete. -fn applyFileMove(unique_id: dvui.Id, tree: *fizzy.dvui.TreeWidget, target_dir: []const u8) !void { +fn applyFileMove(unique_id: dvui.Id, tree: *wdvui.TreeWidget, target_dir: []const u8) !void { const arena = dvui.currentWindow().arena(); // The primary (floating) row's path is stashed here by the branch that reports `floating()`. @@ -1228,7 +1225,7 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File const path = doc.owner.documentPath(doc); if (std.mem.containsAtLeast(u8, path, 1, full_path)) { const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(path)) catch "Failed to duplicate path"; - const new_full = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name }); + const new_full = try std.fs.path.join(Globals.allocator(), &.{ new_path, file_name }); doc.owner.setDocumentPath(doc, new_full) catch { dvui.log.err("Failed to update open document path", .{}); }; @@ -1281,7 +1278,7 @@ pub fn pruneMissingSelections() void { continue; }; if (selected_id == removed.key) selected_id = null; - fizzy.app.allocator.free(removed.value); + Globals.allocator().free(removed.value); continue; }; i += 1; diff --git a/src/plugins/workbench/src/plugin.zig b/src/plugins/workbench/src/plugin.zig index fff1c9a7..1dad6d7e 100644 --- a/src/plugins/workbench/src/plugin.zig +++ b/src/plugins/workbench/src/plugin.zig @@ -1,11 +1,8 @@ -//! The workbench plugin: file management. Phase 2 thin shim — its contributions -//! point at the existing draw entry points through the `fizzy.*` globals rather -//! than owning new code. Later phases move more behind it until it becomes a -//! runtime-loaded dylib. Registered from `Editor.postInit`. +//! The workbench plugin: file management. Registered from `Editor.postInit`. const std = @import("std"); -const fizzy = @import("../../../fizzy.zig"); const dvui = @import("dvui"); -const sdk = fizzy.sdk; +const wb = @import("../workbench.zig"); +const sdk = wb.sdk; const Globals = @import("Globals.zig"); const files = @import("files.zig"); @@ -53,7 +50,7 @@ fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result { /// global/region binds in `Keybinds.register`; this fills in the file half. /// Platform: see `Keybinds.register` for why `fizzy.platform.isMacOS()` is used. fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void { - if (fizzy.platform.isMacOS()) { + if (wb.platform.isMacOS()) { try win.keybinds.putNoClobber(win.gpa, "open_folder", .{ .key = .f, .command = true }); try win.keybinds.putNoClobber(win.gpa, "open_files", .{ .key = .o, .command = true }); try win.keybinds.putNoClobber(win.gpa, "save", .{ .command = true, .key = .s }); diff --git a/src/plugins/workbench/src/workbench_layout.zig b/src/plugins/workbench/src/workbench_layout.zig index dd2b5ad6..c785bce8 100644 --- a/src/plugins/workbench/src/workbench_layout.zig +++ b/src/plugins/workbench/src/workbench_layout.zig @@ -1,8 +1,7 @@ //! Workspace map maintenance + recursive split drawing (Stage W2). const std = @import("std"); const dvui = @import("dvui"); -const sdk = @import("sdk"); -const fizzy = @import("../../../fizzy.zig"); +const wbench = @import("../workbench.zig"); const Globals = @import("Globals.zig"); const Workbench = @import("Workbench.zig"); const Workspace = @import("Workspace.zig"); @@ -84,7 +83,7 @@ pub const PanelPanedState = struct { pub fn drawWorkspaces(wb: *Workbench, panel: PanelPanedState, index: usize) !dvui.App.Result { if (index >= wb.workspaces.count()) return .ok; - var s = fizzy.dvui.paned(@src(), .{ + var s = wbench.wdvui.paned(@src(), .{ .direction = .horizontal, .collapsed_size = if (index == wb.workspaces.count() - 1) std.math.floatMax(f32) else 0, .handle_size = handle_size, diff --git a/src/plugins/workbench/workbench.zig b/src/plugins/workbench/workbench.zig index 0ad8c505..8811d7a9 100644 --- a/src/plugins/workbench/workbench.zig +++ b/src/plugins/workbench/workbench.zig @@ -7,3 +7,12 @@ const std = @import("std"); pub const sdk = @import("sdk"); pub const core = @import("core"); pub const dvui = @import("dvui"); + +pub const math = core.math; +pub const atlas = core.atlas; +pub const platform = core.platform; +pub const perf = core.perf; +pub const Sprite = core.Sprite; + +/// Shell's custom dvui widgets/helpers (TreeWidget, paned, labelWithKeybind, …). +pub const wdvui = core.dvui; diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index f24ae2b7..9df1a345 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -36,6 +36,9 @@ pub const SaveDialogFilter = extern struct { /// Invoked when a native save dialog resolves: the chosen paths, or null if cancelled. pub const SaveDialogCallback = *const fn (?[][:0]const u8) void; +/// Invoked when a native open-file/folder dialog resolves. +pub const OpenPathsCallback = *const fn (?[][:0]const u8) void; + /// Grid dimensions for `createDocument`. pub const NewDocGrid = struct { columns: u32 = 1, @@ -136,6 +139,16 @@ pub const VTable = struct { setExplorerBranchOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id, open: bool) void, /// Draw workspace panes (center region); `index` is the root pane (usually 0). drawWorkspaces: *const fn (ctx: *anyopaque, index: usize) anyerror!dvui.App.Result, + /// Native open-folder dialog (no-op on web). + showOpenFolderDialog: *const fn (ctx: *anyopaque, cb: OpenPathsCallback, default_folder: ?[]const u8) void, + /// Native open-file dialog (web: file picker). + showOpenFileDialog: *const fn ( + ctx: *anyopaque, + cb: OpenPathsCallback, + filters: []const SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, + ) void, // ---- document editing (active file) ---- accept: *const fn (ctx: *anyopaque) anyerror!void, @@ -317,6 +330,20 @@ pub fn drawWorkspaces(self: EditorAPI, index: usize) !dvui.App.Result { return self.vtable.drawWorkspaces(self.ctx, index); } +pub fn showOpenFolderDialog(self: EditorAPI, cb: OpenPathsCallback, default_folder: ?[]const u8) void { + self.vtable.showOpenFolderDialog(self.ctx, cb, default_folder); +} + +pub fn showOpenFileDialog( + self: EditorAPI, + cb: OpenPathsCallback, + filters: []const SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + self.vtable.showOpenFileDialog(self.ctx, cb, filters, default_filename, default_folder); +} + pub fn accept(self: EditorAPI) !void { return self.vtable.accept(self.ctx); } diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index d1dca264..c2c97cc9 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -250,6 +250,20 @@ pub fn drawWorkspaces(self: *Host, index: usize) !dvui.App.Result { return if (self.shell_api) |a| try a.drawWorkspaces(index) else .ok; } +pub fn showOpenFolderDialog(self: *Host, cb: EditorAPI.OpenPathsCallback, default_folder: ?[]const u8) void { + if (self.shell_api) |a| a.showOpenFolderDialog(cb, default_folder); +} + +pub fn showOpenFileDialog( + self: *Host, + cb: EditorAPI.OpenPathsCallback, + filters: []const EditorAPI.SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + if (self.shell_api) |a| a.showOpenFileDialog(cb, filters, default_filename, default_folder); +} + pub fn accept(self: *Host) !void { if (self.shell_api) |a| return a.accept(); } From dc372c6730027563bde8d3d057aff28ffd21c1e2 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 10:44:42 -0500 Subject: [PATCH 31/49] finish workbench --- HANDOFF.md | 104 +++++++++++++++--------- src/fizzy.zig | 2 - src/plugins/workbench/module.zig | 6 +- src/plugins/workbench/src/Workspace.zig | 12 --- src/plugins/workbench/src/plugin.zig | 4 + 5 files changed, 71 insertions(+), 57 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 939cd1cd..a64b96f3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,18 +1,27 @@ -# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, Stage D in progress) +# Fizzy Modular-Plugin Refactor — Handoff (Phase 4 COMPLETE → Phase 5: runtime dylib plugins) ## TL;DR -We are turning the monolithic editor into a **core shell + plugins** layout. Phase 4 -makes `core` a real, separately-wired Zig module with no dependency on the `fizzy` -app hub, then (Stages B–E) lifts the pixel-art editor fully behind the plugin SDK so -it can become its own compile-time module. +We turned the monolithic editor into a **core shell + plugins** layout. **Phase 4 (compile-time +modular separation) is COMPLETE:** `core`, `pixelart`, and `workbench` are all decoupled build +modules; the shell imports plugins only via `@import("pixelart")` / `@import("workbench")` and +talks to them through the SDK vtable + `Host`/`EditorAPI` registries. All three configs green. -**Done:** Stage A1, A2, A3, B, and **Stage C (full)** — per-plugin settings, docs/tabs -storage inversion, save/pack/editor-action decoupling, platform detection, explorer pane -lift, sprites bottom-panel lift. +**The next phase (Phase 5) is runtime dylib plugins** — see **"Phase 5 — Runtime dylib plugins"** +immediately below. Everything under "Phase 4 history" further down is DONE reference material. -**In progress:** **Stage D (substantially complete)** — module scaffold, `Globals` injection, -Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart")` wired. +--- + +### Phase 4 history (all DONE — reference) + +Phase 4 made `core` a standalone Zig module, then (Stages B–E) lifted the pixel-art editor fully +behind the plugin SDK, then (Stage W) did the same for workbench. + +**Stage A1–A3, B, C (full)** — `core` module; per-plugin settings, docs/tabs storage inversion, +save/pack/editor-action decoupling, platform detection, explorer pane lift, sprites bottom-panel lift. + +**Stage D — DONE** — module scaffold, `Globals` injection, Workspace decoupling, zero `fizzy.zig` +imports in plugin, `b.addModule("pixelart")` wired. **Stage E — polish complete** (see "Stage E polish — DONE" below): shell no longer imports `pixelart.internal`; `pixelart_state` field access fully routed to lifecycle + vtable; @@ -23,12 +32,24 @@ primitive + sprite-id index all in `core`; neither shell nor plugin reaches the **Dialog-registry lift — DONE** (see "Multi-plugin readiness"): the shell no longer names any pixel-art dialog. `pixelart.dialogs` is gone from `src/editor` + `src/plugins/workbench`. -**Next:** wire `b.addModule("workbench", …)` + lift workbench off `fizzy.editor` -(logo atlas draw, `fizzy.editor.host.requestNewDocument`, etc.). - -> **Read this first if you're a fresh agent:** Stage D/E + the dialog-registry lift are done. -> Shell→pixelart surface is now only `pixelart.plugin` (vtable) + `State`/`Globals` (lifecycle). -> All three build configs are green right now. +**Workbench lift (Stage W1–W5) — DONE** (see "Stage W" below): workbench is now a real +`@import("workbench")` build module (`wireWorkbenchModule` in `build.zig`, native/web/test). +**Zero live `fizzy.*` refs in `src/plugins/workbench/**`** (was 225). Workspace/grouping/tab-drag +state moved onto the `Workbench` struct; doc-collection + folder/settings/etc. route through +`Globals.host` (EditorAPI) and `doc.owner`. Shell imports both plugins ONLY via +`@import("pixelart")` / `@import("workbench")`. + +> **Read this first if you're a fresh agent:** the **compile-time modular-separation phase is +> complete** — `core`, `pixelart`, `workbench` are all decoupled build modules; the only shell +> path-import into a plugin tree is the documented build-time `process_assets.zig → Atlas.zig`. +> Shell→plugin is now just the vtable/registry boundary plus the shell owning each plugin's +> state struct on `Editor` (`pixelart_state`, `workbench`) for lifecycle — the same arrangement +> for both. All three build configs are green. +> +> **Next big rock (not started):** runtime dylib plugins ("one source / two link modes" — +> dynamic desktop, static web). Optional polish first: route the few remaining +> `editor.workbench.` / `editor.pixelart_state.` direct reaches through +> accessors/vtable (a "Stage E" for workbench), and consider symmetry cleanups in `fizzy.zig`. All three build configs are green: @@ -58,15 +79,15 @@ src/plugins// | File | Role | |------|------| -| `module.zig` | Compile-time module root; shell reaches it via `fizzy.pixelart_mod` / future `@import("pixelart")` | +| `module.zig` | Compile-time module root; shell imports via `@import("pixelart")` / `@import("workbench")` | | `pixelart.zig` / `workbench.zig` | Hub named after the plugin folder; files in `src/**` import as `../.zig` or `../../.zig` | -| `src/State.zig` | Plugin runtime state (`pixelart` only) | -| `src/Globals.zig` | Runtime injection: `gpa`, `state`, `packer` (`pixelart` only) | +| `src/State.zig` (pixelart) / `src/Workbench.zig` (workbench) | Plugin runtime state struct (owned on `Editor`) | +| `src/Globals.zig` | Runtime injection — pixelart: `gpa`/`state`/`packer`; workbench: `gpa`/`host`/`workbench` | | `src/plugin.zig` | Plugin registration + draw entry points | | `src/deps/` | Third-party deps (`pixelart` only) | -Shell still uses `fizzy.pixelart: *State` global during migration; plugin code uses -`Globals.state`. +Both plugins keep their state struct on `Editor` (`editor.pixelart_state`, `editor.workbench`) +for lifecycle; plugin code reaches it + the Host through its `Globals`. ### macOS case-insensitive rename protocol @@ -355,16 +376,17 @@ Dead dialog re-exports removed in the same pass: `Dialogs.Export`, `Dialogs.draw --- -## Stage W — workbench lift (IN PROGRESS, user signed off 2026-06-19) +## Stage W — workbench lift — COMPLETE (signed off 2026-06-19) -Workbench is the last "half-shell" plugin: 225 `fizzy` refs (163 `fizzy.editor`) across -`files.zig`, `Workspace.zig`, `Workbench.zig`, `FileLoadJob.zig`, `plugin.zig`. Unlike pixelart -it has **no state-injection yet** — `plugin.state = undefined`, draw hooks call -`fizzy.editor.*` directly, and the `Workbench` struct instance lives on `Editor`. Tab order *is* -the order of `Editor.open_files`, which workbench mutates in place (`std.mem.swap` on -values/keys at `Workspace.zig:467+`) — that's the deep coupling. +Workbench was the last "half-shell" plugin: it started this stage at **225 `fizzy` refs** +(163 `fizzy.editor`) across `files.zig`, `Workspace.zig`, `Workbench.zig`, `FileLoadJob.zig`, +`plugin.zig`, with no state-injection (`plugin.state = undefined`, draw hooks calling +`fizzy.editor.*`), the `Workbench` struct on `Editor`, and tab order living in +`Editor.open_files` (mutated in place via `std.mem.swap`). After W1–W5 below: +**zero live `fizzy.*` refs remain** (comments only), workbench is a `@import("workbench")` +build module, and all three configs are green. Verified 2026-06-19. -**Plan (mirrors pixelart Stage C–E), each stage builds all 3 configs green:** +**Plan (mirrored pixelart Stage C–E), each stage built all 3 configs green:** - **W1 — host-injection seam + doc-collection routing — DONE.** Added `workbench/src/Globals.zig` (`host: *sdk.Host`, `gpa`), injected in `App.zig` (path import @@ -455,21 +477,23 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl ## State of the tree -**Uncommitted** — nothing in this Phase-4 effort has been committed (commit on request). - -Beyond Stages A–C, the working tree now also has Stage D scaffold changes: -`module.zig`, `pixelart.zig`, `State.zig`, `Globals.zig`, hub re-exports in `fizzy.zig`, -shell import migration, `State.docs` + explorer/bottom-panel fields, `bridge.zig` removed. +**Committed** — Phase-4 is committed through the workbench lift (latest: `stage w4` + +follow-up). The compile-time modular-separation phase is complete; working tree is clean +apart from in-flight HANDOFF/cleanup edits. -Sanity greps: +Sanity greps (verified 2026-06-19): ``` +# pixelart — fully decoupled grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live -grep -rn 'fizzy\.platform' src/plugins/pixelart → 0 -grep -rn 'fizzy\.app\.allocator' src/plugins/pixelart → 0 -grep -rn 'bridge\.' src/plugins/pixelart → 0 -grep -rn '@import.*fizzy' src/plugins/pixelart → 0 -grep -rn 'editor/(dialogs|WebFileIo)' src/plugins/pixelart → 0 +grep -rn '@import.*fizzy' src/plugins/pixelart → 0 + +# workbench — fully decoupled (Stage W) +grep -rn 'fizzy\.' src/plugins/workbench/src → comments only, 0 live +grep -rn '@import("workbench")' src/editor src/App.zig → module import (no path imports) + +# shell imports plugins only via build modules; only build-time exception: +grep -rn 'plugins/.*/src' src/ *.zig (excl. src/plugins) → process_assets.zig → Atlas.zig ``` All three configs green: `zig build`, `zig build check-web`, `zig build test`. diff --git a/src/fizzy.zig b/src/fizzy.zig index 63fc8f0b..cfa78dbb 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const mach = @import("mach"); -const Core = mach.Core; /// Shared infrastructure module (gfx, math, fs, generated atlas, platform, /// paths, the generic dvui hub + widgets). Consumed by the shell and plugins. diff --git a/src/plugins/workbench/module.zig b/src/plugins/workbench/module.zig index d29a7613..dbdfd671 100644 --- a/src/plugins/workbench/module.zig +++ b/src/plugins/workbench/module.zig @@ -1,8 +1,8 @@ //! Workbench plugin compile-time module root. //! -//! Wired in `build.zig` as `b.addModule("workbench", …)` (future). Shell code can -//! import this as `@import("workbench")`. Plugin files inside `src/` import -//! `../workbench.zig` for shared sdk/core access. +//! Wired in `build.zig` via `wireWorkbenchModule` (`b.addModule("workbench", …)`) for the +//! native, web, and test roots. Shell code imports this as `@import("workbench")`. Plugin +//! files inside `src/` import `../workbench.zig` for shared sdk/core access. pub const workbench = @import("workbench.zig"); pub const plugin = @import("src/plugin.zig"); pub const files = @import("src/files.zig"); diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index d69c49e6..69ced3ed 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -945,18 +945,6 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { ); if (button.clicked()) { - // if (try dvui.dialogNativeFileOpenMultiple(dvui.currentWindow().arena(), .{ - // .title = "Open Files...", - // .filter_description = ".pixi, .png", - // .filters = &.{ "*.pixi", "*.png" }, - // })) |files| { - // for (files) |file| { - // _ = fizzy.editor.openFilePath(file, Globals.workbench.open_workspace_grouping) catch { - // std.log.err("Failed to open file: {s}", .{file}); - // }; - // } - // } - Globals.host.showOpenFileDialog(openFilesCallback, &.{ .{ .name = "Image Files", .pattern = "fizzy;png;jpg;jpeg" }, }, "", null); diff --git a/src/plugins/workbench/src/plugin.zig b/src/plugins/workbench/src/plugin.zig index 1dad6d7e..9e83a078 100644 --- a/src/plugins/workbench/src/plugin.zig +++ b/src/plugins/workbench/src/plugin.zig @@ -10,6 +10,10 @@ const files = @import("files.zig"); pub const view_files = "workbench.files"; pub const center_workspaces = "workbench.workspaces"; +// `state` is intentionally unused: the workbench owns no documents (no doc vtable hooks, so +// `DocHandle.owner` is never this plugin) and its registered hooks reach the `Workbench` +// instance + Host through `Globals`, not the vtable `state` arg. Kept `undefined` so a stray +// dereference fails loudly rather than reading a bogus pointer. var plugin: sdk.Plugin = .{ .state = undefined, .vtable = &vtable, From 7bc1f8beb372db014424b38bcb472e0f2a936408 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 11:14:53 -0500 Subject: [PATCH 32/49] plan Phase 5 --- HANDOFF.md | 234 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 207 insertions(+), 27 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index a64b96f3..fb0d8d46 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,8 +7,10 @@ modular separation) is COMPLETE:** `core`, `pixelart`, and `workbench` are all d modules; the shell imports plugins only via `@import("pixelart")` / `@import("workbench")` and talks to them through the SDK vtable + `Host`/`EditorAPI` registries. All three configs green. -**The next phase (Phase 5) is runtime dylib plugins** — see **"Phase 5 — Runtime dylib plugins"** -immediately below. Everything under "Phase 4 history" further down is DONE reference material. +**The next phase (Phase 5) is runtime dylib plugins** — desktop dynamic libraries +(macOS/Linux/Windows, `arm64` + `x86_64`), web static, built-ins bundled with the app. +See **"Phase 5 — Runtime dylib plugins"** below. Everything under "Phase 4 history" +further down is DONE reference material. --- @@ -46,10 +48,9 @@ state moved onto the `Workbench` struct; doc-collection + folder/settings/etc. r > state struct on `Editor` (`pixelart_state`, `workbench`) for lifecycle — the same arrangement > for both. All three build configs are green. > -> **Next big rock (not started):** runtime dylib plugins ("one source / two link modes" — -> dynamic desktop, static web). Optional polish first: route the few remaining -> `editor.workbench.` / `editor.pixelart_state.` direct reaches through -> accessors/vtable (a "Stage E" for workbench), and consider symmetry cleanups in `fizzy.zig`. +> **Next big rock:** Phase 5 runtime dylib plugins — see **"Phase 5 — Runtime dylib plugins"** +> above. Optional polish first (5a): break workbench→pixelart compile-time link and route +> remaining `editor.workbench.*` field pokes (workbench Stage E). All three build configs are green: @@ -64,6 +65,185 @@ the sandbox (network/file access). --- +## Phase 5 — Runtime dylib plugins (NEXT — not started) + +### Goal + +**One source, two link modes:** each plugin compiles from the same Zig sources, but the +link mode depends on the target: + +| Target | Link mode | Loader | +|--------|-----------|--------| +| macOS / Linux / Windows (`arm64` + `x86_64`) | **Dynamic** — plugin is a `.dylib` / `.so` / `.dll` | Host `dlopen`s at startup (built-ins) or on demand (3rd-party) | +| Web (`wasm32`) | **Static** — plugin is a Zig module linked into the exe | No runtime loader; same as today | + +Phase 4 proved the **vtable + `Host` registry boundary** is the right seam. Phase 5 makes +that boundary cross a real dynamic-library load on desktop without changing plugin logic. + +### Product decisions (locked for this phase) + +- **Built-in plugins always ship with Fizzy.** Pixelart, workbench, and future built-ins + (e.g. textedit) live in this repo under `src/plugins/`. We are **not** planning a + "shell-only" Fizzy distribution stripped of plugins. +- **Built-in dylibs are bundled, not separately versioned.** The release artifact is one + Velopack/update unit: the exe plus its built-in plugin dylibs at matching versions. + Velopack does **not** sign or distribute each plugin independently; plugin dylibs ride + inside the same app package the exe does. +- **3rd-party plugins are a later concern, but the architecture must allow them.** An + external Zig project should eventually be able to `@import` a published Fizzy plugin SDK, + write dvui-driven UI through the same `Plugin` vtable, build a dylib, and have Fizzy + load it at runtime — registering menus, sidebar views, bottom views, and doc handlers + through the same `Host` registries built-ins use today. A plugin store + hot-load path + is out of scope for the first Phase-5 milestones but should not be designed away. +- **Reference plugins to demonstrate complexity:** + - **pixelart** — full editor plugin: docs, save/dirty, explorer panes, bottom panel, + dialogs, pack jobs; consumes **workbench-api** for tabs/splits (inter-plugin service). + - **textedit** (future built-in) — lighter editor plugin for `.txt` / `.json` / `.atlas` + etc., coexisting in tabs beside pixel-art docs (see "Multi-plugin readiness"). + - **workbench** — infrastructure plugin (file tree, workspaces); likely stays a + built-in static or early-loaded dylib since it owns the center layout. + +### Dylib mechanism — Option 2: context injection (validated) + +The `spikes/shared-globals` spike ruled out **Mechanism A** (one shared `libdvui` / +`rdynamic` symbol interposition — globals are not auto-shared across the dylib boundary on +macOS two-level namespace, and the same applies on Linux/Windows). + +**Mechanism B (context injection) is the chosen approach:** + +- Host and plugin each compile their **own copy** of `dvui` + `sdk` + `core` (same pinned + Zig + source versions → identical struct layouts). +- Host owns the live `dvui.Window`, arena, backend, and GPU path. +- Before calling into a plugin's draw/tick hooks, the host **injects** the plugin-side + dvui globals (`current_window` per frame; `io` / `ft2lib` / `debug` at init — all + `pub var`, no dvui patch needed) with pointers into the host's live state. +- Cross-boundary vtable types (`Plugin`, `DocHandle`, `Host`, `EditorAPI`, workbench-api + `Api`, …) are normal Zig structs, not strict C-ABI — host and plugin are pinned to the + same SDK build. Only the **dlopen entry symbols** need `callconv(.c)`. +- Load-time **ABI version gate** rejects mismatched plugin builds before any vtable call. + +See `spikes/shared-globals/README.md` and `spikes/shared-globals/build.zig` for the +minimal host+plugin dylib harness. + +### What already exists (Phase 4 carry-over) + +| Piece | Location | Phase-5 role | +|-------|----------|--------------| +| Plugin vtable | `src/sdk/Plugin.zig` | Same shape static or dylib; hooks already optional fn pointers | +| Host registries | `src/sdk/Host.zig` | Menus / sidebar / bottom / center / settings — hot-load target | +| EditorAPI | `src/sdk/EditorAPI.zig` | Shell reach-through; plugins never import `fizzy.zig` | +| Globals injection | `src/plugins/*/src/Globals.zig` | Pattern for post-`dlopen` pointer wiring | +| Inter-plugin service | `Workbench.Api` in `src/plugins/workbench/src/Workbench.zig` | pixelart → workbench without compile-time coupling (goal) | +| Static registration | `Editor.postInit` | `workbench_mod.plugin.register` + `pixelart.plugin.register` — replace with loader on native | + +**No dylib build targets yet** — `build.zig` has no `addLibrary(.linkage = .dynamic)`. +Plugins are still compile-time modules on all targets. + +### Remaining Phase-4 polish (do before or alongside Phase-5a) + +These are not blockers for a spike, but should be cleared so built-in and 3rd-party +plugins share the same rules: + +1. **Break workbench → pixelart compile-time link (blocker for independent dylibs).** + - `build.zig` `wireWorkbenchModule` adds `pixelart` as a module dep. + - `workbench/src/files.zig` reads `pixelart.Globals.state.colors.palette` for file-row + tinting — the only live cross-plugin import in the workbench tree. + - Fix: register a file-row color hook via **workbench-api** (or a small `Host` callback + registry) that pixelart contributes during `register()`; drop the `pixelart` import + from the workbench module. + +2. **Workbench "Stage E" — route shell `editor.workbench.*` field pokes.** + Pixelart Stage E is done (`pixelart_state` is lifecycle-only in `App.zig`). Workbench + still has ~24 direct `editor.workbench.` reaches in `Editor.zig` plus a few in + `Explorer.zig`, `Keybinds.zig`, `WebFileIo.zig`, `singleton_native.zig` (mostly + `open_workspace_grouping` — callers should use `editor.currentGroupingID()` instead). + Extend `EditorAPI` / thin `Editor` delegators so the shell never names workbench internals. + +3. **Minor hygiene** (non-blocking): `web_main.zig` force-imports `pixelart.widgets.FileWidget` + for wasm link; `fizzy.zig` globals (`app`, `editor`, `packer`) shrink as the loader owns + more lifecycle. + +### Phase-5 implementation plan (incremental; all three configs green after each step) + +Each step ends with `zig build`, `zig build check-web`, `zig build test`. + +#### 5a — Pre-dylib decoupling (Phase-4 tail) + +| Step | Work | Done when | +|------|------|-----------| +| **5a.1** | Break workbench→pixelart link (palette row color via workbench-api hook; remove `pixelart` from `wireWorkbenchModule`) | `grep pixelart src/plugins/workbench` → 0; all configs green | +| **5a.2** | Workbench Stage E: route `editor.workbench.*` / `fizzy.editor.workbench.*` through EditorAPI | `grep 'editor\.workbench\.' src/` → lifecycle + delegators only | + +#### 5b — Dylib scaffolding (native only; web unchanged) + +| Step | Work | Done when | +|------|------|-----------| +| **5b.1** | SDK **export surface** — `fizzy_plugin_abi_version()` + `fizzy_plugin_register(*Host)` (`callconv(.c)`); document ABI version constant | Spike + one plugin export compile | +| **5b.2** | **`build.zig` dual link** — add `addLibrary(.dynamic)` target for one plugin (start with pixelart or a minimal `plugins/hello` example); web root keeps static `@import("pixelart")` | Native builds `.dylib`/`.so`/`.dll` beside exe; web still static | +| **5b.3** | **Host loader module** — `std.DynLib` open, ABI gate, resolve entry, call `register`; wire `Globals` after load | Loader unit test or dev-only flag loads a dylib and registers one sidebar view | +| **5b.4** | **Dvui context injection** in shell frame loop — set plugin-side globals before plugin draw/tick (per spike Mechanism B) | Plugin draw mutates host `Window` in a loaded dylib (manual or integration test) | + +Build all six native release triples (`x86_64`/`arm64` × macOS/Linux/Windows) once 5b.2 +lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is the same. + +#### 5c — Built-in plugins as bundled dylibs (desktop) + +| Step | Work | Done when | +|------|------|-----------| +| **5c.1** | Built-in pixelart dylib loaded by host on native; static on web | App opens `.fiz` files via loaded dylib on macOS; web unchanged | +| **5c.2** | Built-in workbench dylib (or keep static until pixelart path is stable — workbench owns center layout) | Tabs/splits work from loaded workbench | +| **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | Release package contains exe + `pixelart.{dylib,so,dll}` etc.; single update channel | + +Built-ins can remain **statically linked during 5b** and flip to dylib in 5c — the +`register()` path is identical either way. + +#### 5d — Reference plugins + 3rd-party path (later milestones) + +| Step | Work | Notes | +|------|------|-------| +| **5d.1** | **textedit** built-in plugin | Exercises multi-editor tabs, `fileTypePriority`, `registerBottomView`; forces "New > kind" chooser | +| **5d.2** | **Published plugin SDK** (`fizzy-plugin-sdk` or similar) | External Zig project: import SDK + dvui, implement vtable, `zig build` → dylib | +| **5d.3** | **User plugin directory** + discovery | Scan `~/.fizzy/plugins/` (or platform equivalent); load + ABI-gate | +| **5d.4** | **Hot load** + plugin store | Reload dylib, refresh Host registries; trust/signing model TBD | + +### 3rd-party / distribution considerations (figure out later, don't block 5a–5c) + +- **Trust:** built-ins are co-signed with the app; 3rd-party plugins need a separate policy + (user opt-in, hash allowlist, dev-mode only, etc.) — not decided yet. +- **Velopack:** app updates replace the whole `zig-out` tree including built-in dylibs; no + per-plugin update channel for built-ins. +- **Version skew:** ABI gate + documented "built with Fizzy X.Y" requirement for 3rd-party + dylibs; plugin store would pin compatible versions. +- **Hot load:** `Host` registries already support append; unload needs vtable `deinit` + + registry removal + no dangling `DocHandle.owner` — design when approaching 5d.4. + +### Phase-5 sanity greps (add to the checklist) + +``` +# no cross-plugin compile-time imports (after 5a.1) +grep -rn '@import("pixelart")' src/plugins/workbench → 0 +grep -rn 'pixelart\.' src/plugins/workbench → 0 + +# shell workbench field pokes routed (after 5a.2) +grep -rn 'editor\.workbench\.' src/ → lifecycle/delegators only +grep -rn 'fizzy\.editor\.workbench\.' src/ → 0 + +# dylib entry exists (after 5b.1) +grep -rn 'fizzy_plugin_' src/sdk src/plugins → export symbols present + +# web stays static (always) +grep -rn 'DynLib\|dlopen' src/ → 0 on web code paths +``` + +### Where to begin (next session) + +**Start with 5a.1** — break the workbench→pixelart compile-time link. It is one focused +change (palette row color hook + `build.zig` dep removal) and is the last hard coupling +between plugins. Then **5a.2** (workbench Stage E), then **5b.1** (export surface + +promote the spike pattern into the main tree). + +--- + ## Plugin directory layout (convention) Every plugin follows the same shape: @@ -268,23 +448,21 @@ src/web_main.zig → FileWidget.zig force-import (wasm link — mi --- -## Stage D — remaining work (start here) +## Stage D — remaining work — DONE (historical) -1. **Route any straggler shell path imports** of pixel-art files through `pixelart_mod` - or `@import("pixelart")` (mostly done; `process_assets.zig` stays separate). +All items below were completed in Stage D/E/W. Kept for archaeology only. -2. **Optional:** wire `b.addModule("workbench", …)` the same way. - -3. **Stage E cleanup:** shell `Editor.zig` still uses `fizzy.pixelart.*` extensively — - shrink as plugin vtable / EditorAPI surface grows. +1. ~~Route straggler shell path imports through `pixelart_mod` / `@import("pixelart")`.~~ DONE +2. ~~Wire `b.addModule("workbench", …)`.~~ DONE (Stage W5) +3. ~~Stage E cleanup in shell `Editor.zig`.~~ DONE (pixelart); workbench Stage E → Phase 5a.2 Do **not** re-introduce a duplicate `@import("plugins/pixelart/module.zig")` from both -`App.zig` and `fizzy.zig` via a third path; always go through `fizzy.pixelart_mod` in -app code until the build module is fully wired. +`App.zig` and `fizzy.zig` via a third path; shell code uses `@import("pixelart")` / +`@import("workbench")` build modules. --- -## Stage E — strip pixel-art names from shell hubs (in progress) +## Stage E — strip pixel-art names from shell hubs — COMPLETE **Done this session:** - **`Editor.pixelart_state`** — shell reaches plugin state through the editor, not scattered `fizzy.pixelart.*` (53 → 0 direct field accesses in shell code; `fizzy.pixelart` global remains only in `App.zig` lifecycle). @@ -436,10 +614,7 @@ End-state achieved. Verified this session: - **Plugin** consumes `core.Atlas`/`core.Sprite` for its own rendering (composites, reflections, `water_surface`) and builds its own packed `internal/Atlas.zig` at pack time. - **Neither side reaches the other's atlas** — `grep 'editor.atlas|fizzy.atlas' src/plugins/pixelart/src` → 0. - -Residual: `workbench/files.zig` + `workbench/Workspace.zig` draw the logo via -`fizzy.editor.atlas` — that's the workbench plugin still routing through `fizzy.editor` -(a separate "workbench off the app hub" concern), not a sprite/atlas-in-core gap. +- Workbench draws the logo via `Globals.host.uiAtlas()` (not `fizzy.editor.atlas`). --- @@ -461,6 +636,8 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl | Path | Role | |------|------| | `HANDOFF.md` | This file | +| `spikes/shared-globals/` | Dylib + dvui context-injection spike (Mechanism B) | +| `src/sdk/Plugin.zig` | Plugin vtable; dylib entry wraps `register()` | | `src/plugins/pixelart/module.zig` | Pixel-art build module root | | `src/plugins/pixelart/pixelart.zig` | Pixel-art intra-plugin hub | | `src/plugins/pixelart/src/` | Pixel-art implementation tree | @@ -477,21 +654,24 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl ## State of the tree -**Committed** — Phase-4 is committed through the workbench lift (latest: `stage w4` + -follow-up). The compile-time modular-separation phase is complete; working tree is clean -apart from in-flight HANDOFF/cleanup edits. +**Phase 4 committed** through the workbench lift (`stage w4` + follow-up). **Phase 5 +documented; implementation not started.** -Sanity greps (verified 2026-06-19): +Sanity greps (verified 2026-06-19; Phase-5 targets in **"Phase 5 sanity greps"** above): ``` -# pixelart — fully decoupled -grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live +# pixelart — fully decoupled from fizzy +grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live (comments only) grep -rn '@import.*fizzy' src/plugins/pixelart → 0 -# workbench — fully decoupled (Stage W) +# workbench — decoupled from fizzy; one cross-plugin link remains (Phase 5a.1) grep -rn 'fizzy\.' src/plugins/workbench/src → comments only, 0 live +grep -rn '@import("pixelart")' src/plugins/workbench → 1 (files.zig — fix in 5a.1) grep -rn '@import("workbench")' src/editor src/App.zig → module import (no path imports) +# shell workbench field pokes (Phase 5a.2) +grep -rn 'editor\.workbench\.' src/editor src/backend → ~24 (route through EditorAPI) + # shell imports plugins only via build modules; only build-time exception: grep -rn 'plugins/.*/src' src/ *.zig (excl. src/plugins) → process_assets.zig → Atlas.zig ``` From aae2667d05496c1162c3873aa878244c96215e9d Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 11:26:33 -0500 Subject: [PATCH 33/49] Phase 5 part a1 --- HANDOFF.md | 53 ++++++++++++++++++++------- build.zig | 11 ++---- src/plugins/pixelart/src/plugin.zig | 8 ++++ src/plugins/workbench/src/Globals.zig | 2 +- src/plugins/workbench/src/files.zig | 5 +-- src/sdk/Host.zig | 24 ++++++++++++ 6 files changed, 78 insertions(+), 25 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index fb0d8d46..f0f3deb5 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -148,9 +148,10 @@ plugins share the same rules: - `build.zig` `wireWorkbenchModule` adds `pixelart` as a module dep. - `workbench/src/files.zig` reads `pixelart.Globals.state.colors.palette` for file-row tinting — the only live cross-plugin import in the workbench tree. - - Fix: register a file-row color hook via **workbench-api** (or a small `Host` callback - registry) that pixelart contributes during `register()`; drop the `pixelart` import - from the workbench module. + - Fix: register a file-row fill-color hook on **`Host`** (`registerFileRowFillColor`) that + pixelart contributes during `register()`; drop the `pixelart` import from the workbench + module. (Host registry chosen over workbench-api to avoid service init ordering and a + pixelart→workbench compile-time dep.) 2. **Workbench "Stage E" — route shell `editor.workbench.*` field pokes.** Pixelart Stage E is done (`pixelart_state` is lifecycle-only in `App.zig`). Workbench @@ -171,7 +172,7 @@ Each step ends with `zig build`, `zig build check-web`, `zig build test`. | Step | Work | Done when | |------|------|-----------| -| **5a.1** | Break workbench→pixelart link (palette row color via workbench-api hook; remove `pixelart` from `wireWorkbenchModule`) | `grep pixelart src/plugins/workbench` → 0; all configs green | +| **5a.1** | Break workbench→pixelart link (`Host.registerFileRowFillColor`; remove `pixelart` from `wireWorkbenchModule`) | `grep pixelart src/plugins/workbench` → 0; all configs green | | **5a.2** | Workbench Stage E: route `editor.workbench.*` / `fizzy.editor.workbench.*` through EditorAPI | `grep 'editor\.workbench\.' src/` → lifecycle + delegators only | #### 5b — Dylib scaffolding (native only; web unchanged) @@ -235,12 +236,38 @@ grep -rn 'fizzy_plugin_' src/sdk src/plugins → export symbols pres grep -rn 'DynLib\|dlopen' src/ → 0 on web code paths ``` +### On-disk layout (locked) + +Fizzy already separates **install dir** from **user config** (`core/paths.zig` → +`configFolder()`; `App.zig` chdirs to the executable dir on native). Phase 5 keeps that +split and adds two plugin locations: + +| Kind | Path | Writable | Updated by | +|------|------|----------|------------| +| **Built-in dylibs** | `/plugins/.{dylib,so,dll}` | No (install tree) | Velopack / app update (same unit as exe) | +| **User / 3rd-party dylibs** | `/plugins//plugin.{dylib,so,dll}` | Yes | User / future plugin store | +| **Plugin settings** | `/settings.json` → `"plugins": { … }` | Yes | App (already via `Host.plugin_settings`) | + +`` is where the binary lives (and where the app chdirs on launch). `` +is the OS user config dir + `fizzy/` (e.g. `~/Library/Application Support/fizzy`, +`~/.config/fizzy`) — **not** beside the exe. + +**Loader search order (native):** + +1. Built-ins — fixed list from `{exe_dir}/plugins/.` +2. User plugins — scan `{config_folder}/plugins/*/plugin.` +3. Dev override — env var e.g. `FIZZY_PLUGIN_PATH` (optional, for local dylib hacking) + +Web: no loader; plugins stay statically linked into the wasm binary. + +Built-in dylibs ship inside the same Velopack package as the exe (no per-plugin signing or +update channel). User plugins survive app updates because they live under config, not install. + +Repo source tree `src/plugins/` is **build layout only** — unrelated to these runtime paths. + ### Where to begin (next session) -**Start with 5a.1** — break the workbench→pixelart compile-time link. It is one focused -change (palette row color hook + `build.zig` dep removal) and is the last hard coupling -between plugins. Then **5a.2** (workbench Stage E), then **5b.1** (export surface + -promote the spike pattern into the main tree). +**5a.1** — done. **Next: 5a.2** (workbench Stage E), then **5b.1** (export surface). --- @@ -655,21 +682,21 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl ## State of the tree **Phase 4 committed** through the workbench lift (`stage w4` + follow-up). **Phase 5 -documented; implementation not started.** +documented; 5a.1 complete** (workbench no longer compile-time imports pixelart). -Sanity greps (verified 2026-06-19; Phase-5 targets in **"Phase 5 sanity greps"** above): +Sanity greps (Phase-5 targets in **"Phase 5 sanity greps"** above): ``` # pixelart — fully decoupled from fizzy grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live (comments only) grep -rn '@import.*fizzy' src/plugins/pixelart → 0 -# workbench — decoupled from fizzy; one cross-plugin link remains (Phase 5a.1) +# workbench — decoupled from fizzy and pixelart (5a.1 done) grep -rn 'fizzy\.' src/plugins/workbench/src → comments only, 0 live -grep -rn '@import("pixelart")' src/plugins/workbench → 1 (files.zig — fix in 5a.1) +grep -rn 'pixelart' src/plugins/workbench → 0 grep -rn '@import("workbench")' src/editor src/App.zig → module import (no path imports) -# shell workbench field pokes (Phase 5a.2) +# shell workbench field pokes (Phase 5a.2 — next) grep -rn 'editor\.workbench\.' src/editor src/backend → ~24 (route through EditorAPI) # shell imports plugins only via build modules; only build-time exception: diff --git a/build.zig b/build.zig index dabd2f9c..087716e5 100644 --- a/build.zig +++ b/build.zig @@ -412,7 +412,7 @@ pub fn build(b: *std.Build) !void { }); web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module); - const pixelart_module_web = wirePixelartModule(b, web_target, optimize, .{ + _ = wirePixelartModule(b, web_target, optimize, .{ .dvui = dvui_web_dep.module("dvui_web"), .core = core_module_web, .sdk = sdk_module_web, @@ -428,7 +428,6 @@ pub fn build(b: *std.Build) !void { .core = core_module_web, .sdk = sdk_module_web, .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null, - .pixelart = pixelart_module_web, .backend = null, }, web_exe.root_module); @@ -868,7 +867,7 @@ pub fn build(b: *std.Build) !void { } fizzy_test_module.addImport("core", core_module_test); const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module); - const pixelart_module_test = wirePixelartModule(b, target, optimize, .{ + _ = wirePixelartModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), .core = core_module_test, .sdk = sdk_module_test, @@ -884,7 +883,6 @@ pub fn build(b: *std.Build) !void { .core = core_module_test, .sdk = sdk_module_test, .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null, - .pixelart = pixelart_module_test, .backend = dvui_testing_dep.module("testing"), }, fizzy_test_module); @@ -1218,7 +1216,7 @@ fn addFizzyExecutableForTarget( core_module.addImport("icons", dep.module("icons")); icons_module = dep.module("icons"); } - const pixelart_module = wirePixelartModule(b, resolved_target, optimize, .{ + _ = wirePixelartModule(b, resolved_target, optimize, .{ .dvui = dvui_dep.module("dvui_sdl3"), .core = core_module, .sdk = sdk_module, @@ -1234,7 +1232,6 @@ fn addFizzyExecutableForTarget( .core = core_module, .sdk = sdk_module, .icons = icons_module, - .pixelart = pixelart_module, .backend = dvui_dep.module("sdl3"), }, exe.root_module); @@ -1335,7 +1332,6 @@ const WorkbenchModuleDeps = struct { core: *std.Build.Module, sdk: *std.Build.Module, icons: ?*std.Build.Module, - pixelart: *std.Build.Module, backend: ?*std.Build.Module, }; @@ -1357,7 +1353,6 @@ fn wireWorkbenchModule( workbench_module.addImport("dvui", deps.dvui); workbench_module.addImport("core", deps.core); workbench_module.addImport("sdk", deps.sdk); - workbench_module.addImport("pixelart", deps.pixelart); if (deps.icons) |icons| workbench_module.addImport("icons", icons); if (deps.backend) |backend| workbench_module.addImport("backend", backend); consumer.addImport("workbench", workbench_module); diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig index 196e4d98..26db1cad 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -326,6 +326,7 @@ pub fn register(host: *sdk.Host) !void { // these before State.init, but register re-syncs after postInit ordering). plugin.state = @ptrCast(@alignCast(Globals.state)); try host.registerPlugin(&plugin); + try host.registerFileRowFillColor(.{ .color = &fileRowFillColor }); try host.registerSidebarView(.{ .id = view_tools, .owner = &plugin, @@ -367,6 +368,13 @@ pub fn pluginPtr() *sdk.Plugin { return &plugin; } +fn fileRowFillColor(_: ?*anyopaque, color_index: usize) ?dvui.Color { + if (Globals.state.colors.palette) |*palette| { + return palette.getDVUIColor(color_index); + } + return null; +} + fn drawTools(_: ?*anyopaque) anyerror!void { try Globals.state.tools_pane.draw(); } diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig index 8ec402f9..7dd20dd3 100644 --- a/src/plugins/workbench/src/Globals.zig +++ b/src/plugins/workbench/src/Globals.zig @@ -2,7 +2,7 @@ //! //! The shell sets these once during `App` startup so workbench code can reach the //! app allocator and the Host (EditorAPI surface) without importing `fizzy.zig`. -//! Mirrors `plugins/pixelart/src/Globals.zig`. +//! Mirrors the pixel-art plugin's `Globals.zig` injection pattern. const std = @import("std"); const wb_mod = @import("../workbench.zig"); const sdk = wb_mod.sdk; diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index 5a94574f..5ecee49b 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -2,7 +2,6 @@ const std = @import("std"); const builtin = @import("builtin"); const wb = @import("../workbench.zig"); const Globals = @import("Globals.zig"); -const pixelart = @import("pixelart"); const dvui = wb.dvui; const wdvui = wb.wdvui; const icons = @import("icons"); @@ -479,8 +478,8 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u try visible_file_rows_order.append(Globals.allocator(), .{ .id = inner_id_extra.*, .path = abs_path }); var color = dvui.themeGet().color(.control, .fill); - if (pixelart.Globals.state.colors.palette) |*palette| { - color = palette.getDVUIColor(color_id.*); + if (Globals.host.fileRowFillColor(color_id.*)) |tint| { + color = tint; } const padding = dvui.Rect.all(2); diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index c2c97cc9..e227e8f6 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -25,6 +25,14 @@ pub const SettingsSection = regions.SettingsSection; /// settings.json and never interprets them. pub const PluginSettings = std.StringArrayHashMapUnmanaged([]const u8); +/// Optional tint for a workbench file-tree row background. `color_index` is the row's +/// stable index during the current tree draw (workbench increments per file). Return +/// null to defer to the next resolver or the theme default. +pub const FileRowFillColor = struct { + ctx: ?*anyopaque = null, + color: *const fn (ctx: ?*anyopaque, color_index: usize) ?dvui.Color, +}; + allocator: std.mem.Allocator, /// All registered plugins (static today; runtime-loaded dylibs in Phase 4). @@ -42,6 +50,9 @@ shell_api: ?EditorAPI = null, /// Opaque per-plugin settings store (see `PluginSettings`). plugin_settings: PluginSettings = .empty, +/// File-tree row fill tints (workbench asks the Host; editor plugins register). +file_row_fill_colors: std.ArrayListUnmanaged(FileRowFillColor) = .empty, + // ---- shell region registries (Phase 2) ------------------------------------- // The shell iterates these instead of hardcoded enums/switches. Items keep their // registration order, which is the order they appear in the UI. @@ -74,6 +85,7 @@ pub fn deinit(self: *Host) void { self.center_providers.deinit(self.allocator); self.menus.deinit(self.allocator); self.settings_sections.deinit(self.allocator); + self.file_row_fill_colors.deinit(self.allocator); { var it = self.plugin_settings.iterator(); while (it.next()) |e| { @@ -372,6 +384,18 @@ pub fn registerPlugin(self: *Host, plugin: *Plugin) !void { try self.plugins.append(self.allocator, plugin); } +pub fn registerFileRowFillColor(self: *Host, resolver: FileRowFillColor) !void { + try self.file_row_fill_colors.append(self.allocator, resolver); +} + +/// First non-null tint from registered resolvers, or null for the workbench theme default. +pub fn fileRowFillColor(self: *Host, color_index: usize) ?dvui.Color { + for (self.file_row_fill_colors.items) |resolver| { + if (resolver.color(resolver.ctx, color_index)) |color| return color; + } + return null; +} + pub fn registerService(self: *Host, name: []const u8, service: *anyopaque) !void { try self.services.put(self.allocator, name, service); } From 4caec7665f00bc31c25a160e0c4083a502feb804 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 12:02:40 -0500 Subject: [PATCH 34/49] Phase 5 part a2 --- HANDOFF.md | 11 ++--- src/backend/singleton_native.zig | 2 +- src/editor/Editor.zig | 53 +++++++++++++++---------- src/editor/Keybinds.zig | 2 +- src/editor/WebFileIo.zig | 2 +- src/editor/explorer/Explorer.zig | 3 +- src/plugins/workbench/src/Workbench.zig | 24 +++++++++++ 7 files changed, 65 insertions(+), 32 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index f0f3deb5..cd609f78 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these ### Where to begin (next session) -**5a.1** — done. **Next: 5a.2** (workbench Stage E), then **5b.1** (export surface). +**5a.1–5a.2** — done. **Next: 5b.1** (SDK export surface + promote dylib spike). --- @@ -681,8 +681,8 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl ## State of the tree -**Phase 4 committed** through the workbench lift (`stage w4` + follow-up). **Phase 5 -documented; 5a.1 complete** (workbench no longer compile-time imports pixelart). +**Phase 4 committed** through the workbench lift (`stage w4` + follow-up). **Phase 5a +(5a.1–5a.2) complete** — plugins decoupled; shell workbench field pokes routed. Sanity greps (Phase-5 targets in **"Phase 5 sanity greps"** above): @@ -696,8 +696,9 @@ grep -rn 'fizzy\.' src/plugins/workbench/src → comments only, 0 live grep -rn 'pixelart' src/plugins/workbench → 0 grep -rn '@import("workbench")' src/editor src/App.zig → module import (no path imports) -# shell workbench field pokes (Phase 5a.2 — next) -grep -rn 'editor\.workbench\.' src/editor src/backend → ~24 (route through EditorAPI) +# shell workbench field pokes routed (5a.2 done) +grep -rn 'fizzy\.editor\.workbench\.' src/ → 0 +grep -rn 'editor\.workbench\.' src/ → lifecycle + Editor delegators only (Editor.zig, App.zig Globals inject) # shell imports plugins only via build modules; only build-time exception: grep -rn 'plugins/.*/src' src/ *.zig (excl. src/plugins) → process_assets.zig → Atlas.zig diff --git a/src/backend/singleton_native.zig b/src/backend/singleton_native.zig index 749cd16e..7e7d6044 100644 --- a/src/backend/singleton_native.zig +++ b/src/backend/singleton_native.zig @@ -197,7 +197,7 @@ fn dispatchPath(path: []const u8) !void { return err; }; file.close(io); - _ = try fizzy.editor.openFilePath(path, fizzy.editor.workbench.open_workspace_grouping); + _ = try fizzy.editor.openFilePath(path, fizzy.editor.currentGroupingID()); } /// Walk upward from `file_path`'s parent directory, returning the first diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index c1fb56c1..d5a9a6f6 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -833,6 +833,20 @@ pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { return editor.workbench.activeDoc(); } +pub fn clearFileTreeDataId(editor: *Editor) void { + editor.workbench.clearFileTreeDataId(); +} + +/// Files sidebar inactive — drop tree dvui stash and tab-drag state. +pub fn resetFileTreeWhenFilesHidden(editor: *Editor) void { + editor.clearFileTreeDataId(); + editor.clearFileTreeTabDragDropState(); +} + +pub fn clearAllWorkspaceCenter(editor: *Editor) void { + editor.workbench.clearAllWorkspaceCenter(); +} + /// Workbench routing helpers (type-agnostic; dispatch through `doc.owner`). pub fn docGrouping(_: *Editor, doc: sdk.DocHandle) u64 { return doc.owner.documentGrouping(doc); @@ -1521,9 +1535,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } else { // Explorer peek/collapse hides the workspace subtree, so `drawWorkspaces` does not // run and `workspace.center` would otherwise stay latched from a prior panel animation. - for (editor.workbench.workspaces.values()) |*ws| { - ws.center = false; - } + editor.clearAllWorkspaceCenter(); } { // Radial Menu (pixel-art plugin) @@ -1630,7 +1642,7 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA .filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" }, })) |files| { for (files) |file| { - _ = editor.openFilePath(file, editor.workbench.open_workspace_grouping) catch { + _ = editor.openFilePath(file, editor.currentGroupingID()) catch { std.log.err("Failed to open file: {s}", .{file}); }; } @@ -2588,16 +2600,14 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void { const doc = editor.docAt(index) orelse return; const grouping = editor.docGrouping(doc); - if (editor.workbench.workspaces.getPtr(grouping)) |workspace| { - if (workspace.open_file_index == index) { - for (editor.open_files.values(), 0..) |d, i| { - if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) { - workspace.open_file_index = i; - break; - } - } + const replacement_index: ?usize = blk: { + for (editor.open_files.values(), 0..) |d, i| { + if (i == index) continue; + if (editor.docGrouping(d) == grouping) break :blk i; } - } + break :blk null; + }; + editor.workbench.adjustOpenFileIndexAfterClose(grouping, index, replacement_index); editor.closeDocumentResources(doc); editor.open_files.orderedRemoveAt(index); @@ -2605,18 +2615,17 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void { pub fn rawCloseFileID(editor: *Editor, id: u64) !void { const doc = editor.open_files.get(id) orelse return; + const index = editor.open_files.getIndex(id) orelse return; const grouping = editor.docGrouping(doc); - if (editor.workbench.workspaces.getPtr(grouping)) |workspace| { - if (workspace.open_file_index == editor.open_files.getIndex(doc.id)) { - for (editor.open_files.values(), 0..) |d, i| { - if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) { - workspace.open_file_index = i; - break; - } - } + const replacement_index: ?usize = blk: { + for (editor.open_files.values(), 0..) |d, i| { + if (i == index) continue; + if (editor.docGrouping(d) == grouping) break :blk i; } - } + break :blk null; + }; + editor.workbench.adjustOpenFileIndexAfterClose(grouping, index, replacement_index); editor.closeDocumentResources(doc); _ = editor.open_files.orderedRemove(id); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 64824f99..39a8bee6 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -63,7 +63,7 @@ pub fn tick() !void { .{ .title = "Open Files...", .filter_description = ".fiz, .pixi, .png, .jpg, .jpeg", .filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" } }, )) |files| { for (files) |file| { - _ = fizzy.editor.openFilePath(file, fizzy.editor.workbench.open_workspace_grouping) catch { + _ = fizzy.editor.openFilePath(file, fizzy.editor.currentGroupingID()) catch { std.log.err("Failed to open file: {s}", .{file}); }; } diff --git a/src/editor/WebFileIo.zig b/src/editor/WebFileIo.zig index eaac597c..29c21f0b 100644 --- a/src/editor/WebFileIo.zig +++ b/src/editor/WebFileIo.zig @@ -46,7 +46,7 @@ pub fn showOpenFileDialog( ) void { if (comptime builtin.target.cpu.arch != .wasm32) return; open_callback = cb; - open_grouping = fizzy.editor.workbench.open_workspace_grouping; + open_grouping = fizzy.editor.currentGroupingID(); open_picker_id = dvui.Id.extendId(null, @src(), 0); dvui.dialogWasmFileOpenMultiple(open_picker_id.?, .{ .accept = open_accept }); } diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index ae597de7..689308b3 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -109,8 +109,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { }); if (!fizzy.editor.host.isActiveSidebarView(workbench.plugin.view_files)) { - fizzy.editor.workbench.file_tree_data_id = null; - fizzy.editor.workbench.clearFileTreeTabDragDropState(); + fizzy.editor.resetFileTreeWhenFilesHidden(); } if (fizzy.editor.host.activeSidebarView()) |view| { diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index 4a402049..917ed2ec 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -77,6 +77,30 @@ pub fn clearFileTreeTabDragDropState(self: *Workbench) void { } } +pub fn clearFileTreeDataId(self: *Workbench) void { + self.file_tree_data_id = null; +} + +/// Explorer peek/collapse hides the workspace subtree; clear latched center flags. +pub fn clearAllWorkspaceCenter(self: *Workbench) void { + for (self.workspaces.values()) |*ws| { + ws.center = false; + } +} + +/// When the open doc at `closed_index` closes, pick another tab in the same workspace. +pub fn adjustOpenFileIndexAfterClose( + self: *Workbench, + grouping: u64, + closed_index: usize, + replacement_index: ?usize, +) void { + const workspace = self.workspaces.getPtr(grouping) orelse return; + if (workspace.open_file_index == closed_index) { + if (replacement_index) |idx| workspace.open_file_index = idx; + } +} + pub fn rebuildWorkspaces(self: *Workbench) !void { return workbench_layout.rebuildWorkspaces(self); } From ce6b4bbcc9583022260f1ee34120ddba93c86940 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 12:04:51 -0500 Subject: [PATCH 35/49] Phase 5 part b1 --- HANDOFF.md | 6 ++- build.zig | 80 ++++++++++++++++++++++++++++++---- src/plugins/pixelart/dylib.zig | 16 +++++++ src/sdk/dylib.zig | 34 +++++++++++++++ src/sdk/sdk.zig | 3 ++ tests/root.zig | 1 + 6 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 src/plugins/pixelart/dylib.zig create mode 100644 src/sdk/dylib.zig diff --git a/HANDOFF.md b/HANDOFF.md index cd609f78..35426f69 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -179,7 +179,7 @@ Each step ends with `zig build`, `zig build check-web`, `zig build test`. | Step | Work | Done when | |------|------|-----------| -| **5b.1** | SDK **export surface** — `fizzy_plugin_abi_version()` + `fizzy_plugin_register(*Host)` (`callconv(.c)`); document ABI version constant | Spike + one plugin export compile | +| **5b.1** | SDK **export surface** — `src/sdk/dylib.zig` (`abi_version`, `RegisterStatus`, symbol names); `src/plugins/pixelart/dylib.zig` exports `fizzy_plugin_abi_version` / `fizzy_plugin_register`; `zig build pixelart-dylib` | ✅ Done | | **5b.2** | **`build.zig` dual link** — add `addLibrary(.dynamic)` target for one plugin (start with pixelart or a minimal `plugins/hello` example); web root keeps static `@import("pixelart")` | Native builds `.dylib`/`.so`/`.dll` beside exe; web still static | | **5b.3** | **Host loader module** — `std.DynLib` open, ABI gate, resolve entry, call `register`; wire `Globals` after load | Loader unit test or dev-only flag loads a dylib and registers one sidebar view | | **5b.4** | **Dvui context injection** in shell frame loop — set plugin-side globals before plugin draw/tick (per spike Mechanism B) | Plugin draw mutates host `Window` in a loaded dylib (manual or integration test) | @@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these ### Where to begin (next session) -**5a.1–5a.2** — done. **Next: 5b.1** (SDK export surface + promote dylib spike). +**5a.1–5a.2** — done. **5b.1** — done (`sdk/dylib.zig`, `pixelart/dylib.zig`, `zig build pixelart-dylib`). **Next: 5b.2** (formalize dual-link in build; loader stub 5b.3). --- @@ -664,6 +664,8 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl |------|------| | `HANDOFF.md` | This file | | `spikes/shared-globals/` | Dylib + dvui context-injection spike (Mechanism B) | +| `src/sdk/dylib.zig` | Dylib ABI version + entry symbol names (`fizzy_plugin_*`) | +| `src/plugins/pixelart/dylib.zig` | Pixelart dynamic-library root (exports only) | | `src/sdk/Plugin.zig` | Plugin vtable; dylib entry wraps `register()` | | `src/plugins/pixelart/module.zig` | Pixel-art build module root | | `src/plugins/pixelart/pixelart.zig` | Pixel-art intra-plugin hub | diff --git a/build.zig b/build.zig index 087716e5..ec9953e7 100644 --- a/build.zig +++ b/build.zig @@ -528,6 +528,18 @@ pub fn build(b: *std.Build) !void { b.getInstallStep().dependOn(&install_artifact.step); } + if (main_fizzy.pixelart_dylib) |pixelart_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_pixelart_dylib = b.addInstallArtifact(pixelart_dylib, .{ + .dest_dir = .{ .override = plugins_install_dir }, + }); + const pixelart_dylib_step = b.step( + "pixelart-dylib", + "Build the pixelart plugin as a dynamic library into zig-out//plugins/ (native only)", + ); + pixelart_dylib_step.dependOn(&install_pixelart_dylib.step); + } + const package_step = b.step("package", "Velopack release artifacts (strip + vpk); not part of install or run"); // The default native target on a Windows host resolves to x86_64-windows-gnu, // for which `velopack_supported_for_target` is false — exe_for_package falls @@ -781,6 +793,7 @@ pub fn build(b: *std.Build) !void { .{ "fizzy-grid-validate", "src/plugins/pixelart/src/internal/grid_layout_validate.zig" }, .{ "fizzy-animation", "src/plugins/pixelart/src/Animation.zig" }, .{ "fizzy-window-layout", "src/backend/window_layout.zig" }, + .{ "fizzy-plugin-dylib", "src/sdk/dylib.zig" }, }) |entry| { tests_module.addAnonymousImport(entry[0], .{ .root_source_file = b.path(entry[1]), @@ -1111,6 +1124,8 @@ const FizzyExecutable = struct { zstbi_module: *std.Build.Module, msf_gif_module: *std.Build.Module, known_folders: *std.Build.Module, + /// Native-only; `null` on wasm targets. + pixelart_dylib: ?*std.Build.Step.Compile = null, }; fn addFizzyExecutableForTarget( @@ -1235,6 +1250,20 @@ fn addFizzyExecutableForTarget( .backend = dvui_dep.module("sdl3"), }, exe.root_module); + const pixelart_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + break :blk addPixelartDylib(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + .assets = assets_module, + .zip = zip_pkg.module, + .zstbi = zstbi_module, + .msf_gif = msf_gif_module, + .icons = icons_module, + .backend = dvui_dep.module("sdl3"), + }); + } else null; + const singleton_app_dep = b.dependency("dvui_singleton_app", .{ .target = resolved_target, .optimize = optimize, @@ -1294,6 +1323,7 @@ fn addFizzyExecutableForTarget( .zstbi_module = zstbi_module, .msf_gif_module = msf_gif_module, .known_folders = known_folders, + .pixelart_dylib = pixelart_dylib, }; } @@ -1359,6 +1389,46 @@ fn wireWorkbenchModule( } /// Pixel-art plugin (`src/plugins/pixelart/module.zig`). +fn applyPixelartModuleImports(module: *std.Build.Module, deps: PixelartModuleDeps) void { + module.addImport("dvui", deps.dvui); + module.addImport("core", deps.core); + module.addImport("sdk", deps.sdk); + module.addImport("assets", deps.assets); + module.addImport("zip", deps.zip); + module.addImport("zstbi", deps.zstbi); + module.addImport("msf_gif", deps.msf_gif); + if (deps.icons) |icons| module.addImport("icons", icons); + if (deps.backend) |backend| module.addImport("backend", backend); +} + +/// Native dynamic library for the pixel-art plugin (`src/plugins/pixelart/dylib.zig`). +fn addPixelartDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + deps: PixelartModuleDeps, +) *std.Build.Step.Compile { + const dylib_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/plugins/pixelart/dylib.zig"), + .link_libc = true, + }); + applyPixelartModuleImports(dylib_module, deps); + const lib = b.addLibrary(.{ + .name = "pixelart", + .linkage = .dynamic, + .root_module = dylib_module, + }); + // Resolve dvui/sdk symbols from the host at load time (Mechanism B). + lib.linker_allow_shlib_undefined = true; + lib.root_module.export_symbol_names = &[_][]const u8{ + "fizzy_plugin_abi_version", + "fizzy_plugin_register", + }; + return lib; +} + fn wirePixelartModule( b: *std.Build, target: std.Build.ResolvedTarget, @@ -1373,15 +1443,7 @@ fn wirePixelartModule( .link_libc = target.result.cpu.arch != .wasm32, .single_threaded = target.result.cpu.arch == .wasm32, }); - pixelart_module.addImport("dvui", deps.dvui); - pixelart_module.addImport("core", deps.core); - pixelart_module.addImport("sdk", deps.sdk); - pixelart_module.addImport("assets", deps.assets); - pixelart_module.addImport("zip", deps.zip); - pixelart_module.addImport("zstbi", deps.zstbi); - pixelart_module.addImport("msf_gif", deps.msf_gif); - if (deps.icons) |icons| pixelart_module.addImport("icons", icons); - if (deps.backend) |backend| pixelart_module.addImport("backend", backend); + applyPixelartModuleImports(pixelart_module, deps); consumer.addImport("pixelart", pixelart_module); return pixelart_module; } diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig new file mode 100644 index 00000000..ea949913 --- /dev/null +++ b/src/plugins/pixelart/dylib.zig @@ -0,0 +1,16 @@ +//! Dynamic-library root for the pixel-art plugin (Phase 5b). +//! +//! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use +//! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported. +const sdk = @import("sdk"); +const plugin = @import("src/plugin.zig"); + +export fn fizzy_plugin_abi_version() callconv(.c) u32 { + return sdk.dylib.abi_version; +} + +export fn fizzy_plugin_register(host: ?*sdk.Host) callconv(.c) u32 { + if (host == null) return @intFromEnum(sdk.dylib.RegisterStatus.err_null_host); + plugin.register(host.?) catch return @intFromEnum(sdk.dylib.RegisterStatus.err_register); + return @intFromEnum(sdk.dylib.RegisterStatus.ok); +} diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig new file mode 100644 index 00000000..79e8bf1e --- /dev/null +++ b/src/sdk/dylib.zig @@ -0,0 +1,34 @@ +//! Runtime dynamic-library contract for Fizzy plugins (Phase 5b). +//! +//! Host and plugin each compile their own copy of `dvui` + `sdk` + `core` (Mechanism B: +//! context injection — see `spikes/shared-globals/README.md`). Cross-boundary vtables use +//! normal Zig layouts pinned to the same Fizzy/SDK build. Only the `dlopen` entry symbols +//! below use C calling convention. +//! +//! **Bump `abi_version` when any of these change:** `Host`, `Plugin`, `DocHandle`, +//! `EditorAPI` layouts, or the semantics/signature of an entry symbol. +pub const abi_version: u32 = 1; + +/// `std.DynLib.lookup` names for the host loader (5b.3+). +pub const symbol_abi_version = "fizzy_plugin_abi_version"; +pub const symbol_register = "fizzy_plugin_register"; + +/// Returned by `fizzy_plugin_register`. Stable unsigned values for C callers. +pub const RegisterStatus = enum(u32) { + ok = 0, + err_register = 1, + err_null_host = 2, + /// Reserved for the host loader when `fizzy_plugin_abi_version()` != `abi_version`. + err_abi_mismatch = 3, +}; + +pub fn abiMatches(plugin_abi: u32) bool { + return plugin_abi == abi_version; +} + +test "plugin ABI version is locked" { + const std = @import("std"); + try std.testing.expect(abi_version == 1); + try std.testing.expect(abiMatches(abi_version)); + try std.testing.expect(!abiMatches(abi_version + 1)); +} diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index aae47821..222fe73c 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -27,3 +27,6 @@ pub const UiAtlasView = EditorAPI.UiAtlasView; pub const WorkbenchPane = @import("WorkbenchPane.zig"); pub const WorkbenchPaneView = WorkbenchPane.WorkbenchPaneView; pub const pane_layout = @import("pane_layout.zig"); + +/// Runtime dylib entry contract (`fizzy_plugin_abi_version` / `fizzy_plugin_register`). +pub const dylib = @import("dylib.zig"); diff --git a/tests/root.zig b/tests/root.zig index 54386d37..1606de7c 100644 --- a/tests/root.zig +++ b/tests/root.zig @@ -16,4 +16,5 @@ comptime { _ = @import("fizzy-grid-validate"); _ = @import("fizzy-animation"); _ = @import("fizzy-window-layout"); + _ = @import("fizzy-plugin-dylib"); } From 78b772289c01a64bacdff5b79ef365b058445ccb Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 12:08:22 -0500 Subject: [PATCH 36/49] 5b.3 --- HANDOFF.md | 7 +- build.zig | 41 ++++++++++++ src/editor/Editor.zig | 53 ++++++++++++++- src/editor/PluginLoader.zig | 99 +++++++++++++++++++++++++++++ src/editor/PluginLoader_stub.zig | 16 +++++ src/plugins/pixelart/dylib.zig | 10 +++ src/sdk/Host.zig | 26 ++++++++ src/sdk/dvui_context.zig | 44 +++++++++++++ src/sdk/dylib.zig | 2 + src/sdk/sdk.zig | 2 + tests/plugin_loader_integration.zig | 26 ++++++++ 11 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 src/editor/PluginLoader.zig create mode 100644 src/editor/PluginLoader_stub.zig create mode 100644 src/sdk/dvui_context.zig create mode 100644 tests/plugin_loader_integration.zig diff --git a/HANDOFF.md b/HANDOFF.md index 35426f69..96431ea8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -181,8 +181,8 @@ Each step ends with `zig build`, `zig build check-web`, `zig build test`. |------|------|-----------| | **5b.1** | SDK **export surface** — `src/sdk/dylib.zig` (`abi_version`, `RegisterStatus`, symbol names); `src/plugins/pixelart/dylib.zig` exports `fizzy_plugin_abi_version` / `fizzy_plugin_register`; `zig build pixelart-dylib` | ✅ Done | | **5b.2** | **`build.zig` dual link** — add `addLibrary(.dynamic)` target for one plugin (start with pixelart or a minimal `plugins/hello` example); web root keeps static `@import("pixelart")` | Native builds `.dylib`/`.so`/`.dll` beside exe; web still static | -| **5b.3** | **Host loader module** — `std.DynLib` open, ABI gate, resolve entry, call `register`; wire `Globals` after load | Loader unit test or dev-only flag loads a dylib and registers one sidebar view | -| **5b.4** | **Dvui context injection** in shell frame loop — set plugin-side globals before plugin draw/tick (per spike Mechanism B) | Plugin draw mutates host `Window` in a loaded dylib (manual or integration test) | +| **5b.3** | **Host loader** — `src/editor/PluginLoader.zig`; `Host.pluginById`; `-Dload-pixelart-dylib` / `FIZZY_LOAD_PIXELART_DYLIB` / `FIZZY_PLUGIN_PATH`; `zig build test-plugin-loader` | ✅ Done | +| **5b.4** | **Dvui context injection** — `sdk/dvui_context.zig`, `fizzy_plugin_set_dvui_context`, `Host.syncPluginDvuiContext` in frame loop | ✅ Done | Build all six native release triples (`x86_64`/`arm64` × macOS/Linux/Windows) once 5b.2 lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is the same. @@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these ### Where to begin (next session) -**5a.1–5a.2** — done. **5b.1** — done (`sdk/dylib.zig`, `pixelart/dylib.zig`, `zig build pixelart-dylib`). **Next: 5b.2** (formalize dual-link in build; loader stub 5b.3). +**5a.1–5a.2** — done. **5b.1–5b.4** — done (dylib export, loader, dvui context injection). **Next: 5c.1** (load built-in pixelart dylib by default on native; route Editor delegators through `host.pluginById`). --- @@ -664,6 +664,7 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl |------|------| | `HANDOFF.md` | This file | | `spikes/shared-globals/` | Dylib + dvui context-injection spike (Mechanism B) | +| `src/sdk/dvui_context.zig` | Mechanism B — inject host dvui globals into plugin dylib copy | | `src/sdk/dylib.zig` | Dylib ABI version + entry symbol names (`fizzy_plugin_*`) | | `src/plugins/pixelart/dylib.zig` | Pixelart dynamic-library root (exports only) | | `src/sdk/Plugin.zig` | Plugin vtable; dylib entry wraps `register()` | diff --git a/build.zig b/build.zig index ec9953e7..3a1775eb 100644 --- a/build.zig +++ b/build.zig @@ -239,6 +239,12 @@ pub fn build(b: *std.Build) !void { build_opts.addOption([]const u8, "app_repo_url", app_repo_url); build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); build_opts.addOption(bool, "velopack_enabled", velopack_enabled); + const load_pixelart_dylib = b.option( + bool, + "load-pixelart-dylib", + "Load pixelart from plugins/libpixelart.{dylib,so,dll} instead of static register (native dev)", + ) orelse false; + build_opts.addOption(bool, "load_pixelart_dylib", load_pixelart_dylib); const step = b.step("update", "update git dependencies"); step.makeFn = update_step; @@ -538,6 +544,38 @@ pub fn build(b: *std.Build) !void { "Build the pixelart plugin as a dynamic library into zig-out//plugins/ (native only)", ); pixelart_dylib_step.dependOn(&install_pixelart_dylib.step); + + const plugin_loader_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/editor/PluginLoader.zig"), + }); + plugin_loader_module.addImport("sdk", main_fizzy.sdk_module); + + const plugin_loader_test_opts = b.addOptions(); + plugin_loader_test_opts.addOptionPath("pixelart_dylib", pixelart_dylib.getEmittedBin()); + + const plugin_loader_test_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/plugin_loader_integration.zig"), + }); + plugin_loader_test_module.addImport("sdk", main_fizzy.sdk_module); + plugin_loader_test_module.addImport("plugin_loader", plugin_loader_module); + plugin_loader_test_module.addOptions("plugin_loader_test_opts", plugin_loader_test_opts); + + const plugin_loader_tests = b.addTest(.{ + .name = "plugin-loader-tests", + .root_module = plugin_loader_test_module, + }); + const run_plugin_loader_tests = b.addRunArtifact(plugin_loader_tests); + run_plugin_loader_tests.step.dependOn(&pixelart_dylib.step); + + const test_plugin_loader_step = b.step( + "test-plugin-loader", + "Build pixelart dylib and run dlopen/register integration test", + ); + test_plugin_loader_step.dependOn(&run_plugin_loader_tests.step); } const package_step = b.step("package", "Velopack release artifacts (strip + vpk); not part of install or run"); @@ -1124,6 +1162,7 @@ const FizzyExecutable = struct { zstbi_module: *std.Build.Module, msf_gif_module: *std.Build.Module, known_folders: *std.Build.Module, + sdk_module: *std.Build.Module, /// Native-only; `null` on wasm targets. pixelart_dylib: ?*std.Build.Step.Compile = null, }; @@ -1323,6 +1362,7 @@ fn addFizzyExecutableForTarget( .zstbi_module = zstbi_module, .msf_gif_module = msf_gif_module, .known_folders = known_folders, + .sdk_module = sdk_module, .pixelart_dylib = pixelart_dylib, }; } @@ -1425,6 +1465,7 @@ fn addPixelartDylib( lib.root_module.export_symbol_names = &[_][]const u8{ "fizzy_plugin_abi_version", "fizzy_plugin_register", + "fizzy_plugin_set_dvui_context", }; return lib; } diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index d5a9a6f6..3c1de856 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -13,6 +13,8 @@ const comfortaa_bold_ttf = assets.files.fonts.@"Comfortaa-Bold.ttf"; const plus_jakarta_sans_ttf = assets.files.fonts.@"PlusJakartaSans-Regular.ttf"; const plus_jakarta_sans_bold_ttf = assets.files.fonts.@"PlusJakartaSans-Bold.ttf"; +const build_opts = @import("build_opts"); + const fizzy = @import("../fizzy.zig"); const pixelart = @import("pixelart"); const dvui = @import("dvui"); @@ -28,6 +30,10 @@ pub const Dialogs = @import("dialogs/Dialogs.zig"); pub const Keybinds = @import("Keybinds.zig"); const workbench_mod = @import("workbench"); +const PluginLoader = if (builtin.target.cpu.arch == .wasm32) + @import("PluginLoader_stub.zig") +else + @import("PluginLoader.zig"); pub const Workspace = workbench_mod.Workspace; pub const Explorer = @import("explorer/Explorer.zig"); @@ -63,6 +69,9 @@ pixelart_state: *pixelart.State, /// File-management workbench (per-branch explorer decorations, …) workbench: Workbench, +/// Keeps plugin dylibs mapped while their vtables are live (Phase 5b.3+; native only). +loaded_plugin_libs: std.ArrayListUnmanaged(PluginLoader.LoadedLib) = .empty, + settings: Settings = undefined, recents: Recents = undefined, @@ -443,6 +452,35 @@ pub fn init( /// Stable shell-builtin contribution id. pub const view_settings = "shell.settings"; +fn loadPixelartFromDylibEnabled() bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + if (comptime build_opts.load_pixelart_dylib) return true; + if (std.process.getEnvVar("FIZZY_LOAD_PIXELART_DYLIB")) |v| { + return v.len > 0 and v[0] != '0'; + } + return false; +} + +/// Load `{exe_dir}/plugins/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry. +pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const path = try PluginLoader.resolvePluginPath(fizzy.app.allocator, exe_dir, "pixelart"); + errdefer fizzy.app.allocator.free(path); + const loaded = try PluginLoader.loadAndRegister(&editor.host, path); + try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded); + editor.host.installPluginDvuiContext(loaded.set_dvui_context); +} + +fn unloadPluginLibs(editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + editor.host.plugin_set_dvui_context = null; + for (editor.loaded_plugin_libs.items) |*entry| { + entry.lib.close(); + fizzy.app.allocator.free(entry.path); + } + editor.loaded_plugin_libs.deinit(fizzy.app.allocator); +} + pub fn postInit(editor: *Editor) !void { // Install the shell's read/utility surface so plugins reach shared shell state // (per-frame arena, project folder, content opacity, settings dirty-mark) through @@ -463,9 +501,15 @@ pub fn postInit(editor: *Editor) !void { // hardcoding panes. Web-safe — the draw fns reach the same inline code the // editor tick already runs on wasm. Order = sidebar order. try workbench_mod.plugin.register(&editor.host); -const pixelart_plugin = pixelart.plugin; - try pixelart_plugin.register(&editor.host); - try pixelart_plugin.pluginPtr().initPlugin(); + if (loadPixelartFromDylibEnabled()) { + try editor.loadPixelartDylib(fizzy.app.root_path); + const pa = editor.host.pluginById("pixelart") orelse return error.MissingPlugin; + try pa.initPlugin(); + } else { + const pixelart_plugin = pixelart.plugin; + try pixelart_plugin.register(&editor.host); + try pixelart_plugin.pluginPtr().initPlugin(); + } // Shell built-in: Settings (owner = null; not a plugin). try editor.host.registerSidebarView(.{ @@ -487,6 +531,7 @@ const pixelart_plugin = pixelart.plugin; // keybind map. The shell already registered its global/navigation/region binds // in `Keybinds.register` (during `init`, before this runs), so the two halves // are disjoint — no `putNoClobber` clash. Runs on all targets (web included). + editor.host.syncPluginDvuiContext(); const window = dvui.currentWindow(); for (editor.host.plugins.items) |plugin| try plugin.contributeKeybinds(window); @@ -1134,6 +1179,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.setTitlebarColor(); editor.setWindowStyle(); + editor.host.syncPluginDvuiContext(); for (editor.host.plugins.items) |plugin| plugin.beginFrame(); if (fizzy.perf.record) fizzy.perf.beginFrame(); defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog(); @@ -2682,6 +2728,7 @@ pub fn deinit(editor: *Editor) !void { editor.explorer.deinit(); editor.workbench.deinitWorkspaces(); + editor.unloadPluginLibs(); editor.host.deinit(); editor.workbench.deinit(); diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig new file mode 100644 index 00000000..4852ce8d --- /dev/null +++ b/src/editor/PluginLoader.zig @@ -0,0 +1,99 @@ +//! Native runtime loader for Fizzy plugin dylibs (Phase 5b.3). +//! +//! Opens a prebuilt plugin library, checks the SDK ABI version, and calls +//! `fizzy_plugin_register`. The returned `std.DynLib` must stay open for the +//! app's lifetime — vtable hooks live in the dylib image. +//! +//! **Native targets only.** Wasm imports `PluginLoader_stub.zig` instead. +const std = @import("std"); +const builtin = @import("builtin"); + +const sdk = @import("sdk"); +const Host = sdk.Host; +const dylib_api = sdk.dylib; +const dvui_context = sdk.dvui_context; + +pub const LoadError = error{ + DylibOpenFailed, + AbiSymbolMissing, + RegisterSymbolMissing, + SetDvuiContextSymbolMissing, + AbiMismatch, + RegisterRejected, +}; + +pub const LoadedLib = struct { + lib: std.DynLib, + path: []const u8, + set_dvui_context: dvui_context.SetContextFn, +}; + +/// `{exe_dir}/plugins/{pluginFilename(name)}` +pub fn builtinPluginPath( + allocator: std.mem.Allocator, + exe_dir: []const u8, + name: []const u8, +) ![]const u8 { + const file_name = switch (builtin.os.tag) { + .windows => try std.fmt.allocPrint(allocator, "{s}.dll", .{name}), + .macos => try std.fmt.allocPrint(allocator, "lib{s}.dylib", .{name}), + else => try std.fmt.allocPrint(allocator, "lib{s}.so", .{name}), + }; + defer allocator.free(file_name); + return std.fs.path.join(allocator, &.{ exe_dir, "plugins", file_name }); +} + +/// Resolve a plugin dylib path: `FIZZY_PLUGIN_PATH` when set, else the built-in layout above. +pub fn resolvePluginPath( + allocator: std.mem.Allocator, + exe_dir: []const u8, + builtin_name: []const u8, +) ![]const u8 { + if (std.process.getEnvVarOwned(allocator, "FIZZY_PLUGIN_PATH")) |override| { + return override; + } else |_| {} + return builtinPluginPath(allocator, exe_dir, builtin_name); +} + +pub fn loadAndRegister(host: *Host, path: []const u8) LoadError!LoadedLib { + var lib = std.DynLib.open(path) catch return error.DylibOpenFailed; + errdefer lib.close(); + + const abi_fn = lib.lookup( + *const fn () callconv(.c) u32, + dylib_api.symbol_abi_version, + ) orelse return error.AbiSymbolMissing; + if (!dylib_api.abiMatches(abi_fn())) return error.AbiMismatch; + + const reg_fn = lib.lookup( + *const fn (?*Host) callconv(.c) u32, + dylib_api.symbol_register, + ) orelse return error.RegisterSymbolMissing; + const status: dylib_api.RegisterStatus = @enumFromInt(reg_fn(host)); + switch (status) { + .ok => {}, + .err_abi_mismatch => return error.AbiMismatch, + else => return error.RegisterRejected, + } + + const set_ctx = lib.lookup( + dvui_context.SetContextFn, + dylib_api.symbol_set_dvui_context, + ) orelse return error.SetDvuiContextSymbolMissing; + + return .{ + .lib = lib, + .path = path, + .set_dvui_context = set_ctx, + }; +} + +test "builtin plugin path joins exe_dir/plugins" { + const path = try builtinPluginPath(std.testing.allocator, "/app", "pixelart"); + defer std.testing.allocator.free(path); + switch (builtin.os.tag) { + .windows => try std.testing.expectEqualStrings("/app/plugins/pixelart.dll", path), + .macos => try std.testing.expectEqualStrings("/app/plugins/libpixelart.dylib", path), + else => try std.testing.expectEqualStrings("/app/plugins/libpixelart.so", path), + } +} diff --git a/src/editor/PluginLoader_stub.zig b/src/editor/PluginLoader_stub.zig new file mode 100644 index 00000000..753211c9 --- /dev/null +++ b/src/editor/PluginLoader_stub.zig @@ -0,0 +1,16 @@ +//! Wasm stub — dynamic plugin loading is native-only. +const std = @import("std"); + +pub const LoadError = error{Unsupported}; + +pub const LoadedLib = struct { + path: []const u8, +}; + +pub fn resolvePluginPath(_: std.mem.Allocator, _: []const u8, _: []const u8) ![]const u8 { + return error.Unsupported; +} + +pub fn loadAndRegister(_: anytype, _: []const u8) LoadError!void { + return error.Unsupported; +} diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig index ea949913..dba5be2a 100644 --- a/src/plugins/pixelart/dylib.zig +++ b/src/plugins/pixelart/dylib.zig @@ -3,6 +3,7 @@ //! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use //! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported. const sdk = @import("sdk"); +const dvui = @import("dvui"); const plugin = @import("src/plugin.zig"); export fn fizzy_plugin_abi_version() callconv(.c) u32 { @@ -14,3 +15,12 @@ export fn fizzy_plugin_register(host: ?*sdk.Host) callconv(.c) u32 { plugin.register(host.?) catch return @intFromEnum(sdk.dylib.RegisterStatus.err_register); return @intFromEnum(sdk.dylib.RegisterStatus.ok); } + +export fn fizzy_plugin_set_dvui_context( + window: ?*dvui.Window, + io: ?*anyopaque, + ft2lib: ?*anyopaque, + debug: ?*dvui.Debug, +) callconv(.c) void { + sdk.dvui_context.inject(window, io, ft2lib, debug); +} diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index e227e8f6..aab6385c 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -8,6 +8,7 @@ const std = @import("std"); const dvui = @import("dvui"); const Plugin = @import("Plugin.zig"); +const dvui_context = @import("dvui_context.zig"); const regions = @import("regions.zig"); const EditorAPI = @import("EditorAPI.zig"); const DocHandle = @import("DocHandle.zig"); @@ -33,6 +34,9 @@ pub const FileRowFillColor = struct { color: *const fn (ctx: ?*anyopaque, color_index: usize) ?dvui.Color, }; +/// Mechanism B: setter from a loaded plugin dylib; null when all plugins are static. +plugin_set_dvui_context: ?dvui_context.SetContextFn = null, + allocator: std.mem.Allocator, /// All registered plugins (static today; runtime-loaded dylibs in Phase 4). @@ -103,6 +107,20 @@ pub fn installShell(self: *Host, api: EditorAPI) void { self.shell_api = api; } +/// Wire a loaded plugin dylib's dvui globals to the host (Mechanism B). Called once +/// after `dlopen` + `fizzy_plugin_register`; also primes `io` / `ft2lib` / `debug`. +pub fn installPluginDvuiContext(self: *Host, setter: dvui_context.SetContextFn) void { + self.plugin_set_dvui_context = setter; + dvui_context.syncHostIntoPlugin(setter); +} + +/// Re-push host dvui pointers into the loaded plugin image. Call at the top of each +/// frame before plugin draw/tick (updates `current_window` every frame). +pub fn syncPluginDvuiContext(self: *Host) void { + const setter = self.plugin_set_dvui_context orelse return; + dvui_context.syncHostIntoPlugin(setter); +} + /// Per-frame arena allocator (reset every frame; do not free). Asserts the shell is installed. pub fn arena(self: *Host) std.mem.Allocator { return self.shell_api.?.arena(); @@ -384,6 +402,14 @@ pub fn registerPlugin(self: *Host, plugin: *Plugin) !void { try self.plugins.append(self.allocator, plugin); } +/// Lookup a registered plugin by stable id (`"pixelart"`, `"workbench"`, …). +pub fn pluginById(self: *Host, id: []const u8) ?*Plugin { + for (self.plugins.items) |plugin| { + if (std.mem.eql(u8, plugin.id, id)) return plugin; + } + return null; +} + pub fn registerFileRowFillColor(self: *Host, resolver: FileRowFillColor) !void { try self.file_row_fill_colors.append(self.allocator, resolver); } diff --git a/src/sdk/dvui_context.zig b/src/sdk/dvui_context.zig new file mode 100644 index 00000000..37e92f0a --- /dev/null +++ b/src/sdk/dvui_context.zig @@ -0,0 +1,44 @@ +//! Mechanism B: wire the plugin dylib's dvui globals to the host's live state. +//! +//! Host and plugin each compile their own `dvui` copy; before plugin draw/tick the host +//! calls the plugin's `fizzy_plugin_set_dvui_context` export (see `dylib.zig`). +const dvui = @import("dvui"); + +/// C ABI setter type shared by host loader and plugin dylib export. +pub const SetContextFn = *const fn ( + window: ?*dvui.Window, + io: ?*anyopaque, + ft2lib: ?*anyopaque, + debug: ?*dvui.Debug, +) callconv(.c) void; + +/// Set this compilation unit's dvui globals from host-owned pointers. +pub fn inject( + window: ?*dvui.Window, + io: ?*anyopaque, + ft2lib: ?*anyopaque, + debug: ?*dvui.Debug, +) void { + if (window) |w| dvui.current_window = w; + if (io) |i| { + const io_ptr: *@TypeOf(dvui.io) = @ptrCast(@alignCast(i)); + dvui.io = io_ptr.*; + } + if (comptime dvui.useFreeType) { + if (ft2lib) |ft| { + const ft_ptr: *@TypeOf(dvui.ft2lib) = @ptrCast(@alignCast(ft)); + dvui.ft2lib = ft_ptr.*; + } + } + if (debug) |d| dvui.debug = d.*; +} + +/// Push the host exe's current dvui state into a loaded plugin image. +pub fn syncHostIntoPlugin(setter: SetContextFn) void { + setter( + dvui.current_window, + @ptrCast(&dvui.io), + if (comptime dvui.useFreeType) @ptrCast(&dvui.ft2lib) else null, + &dvui.debug, + ); +} diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig index 79e8bf1e..f9baea3d 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -12,6 +12,8 @@ pub const abi_version: u32 = 1; /// `std.DynLib.lookup` names for the host loader (5b.3+). pub const symbol_abi_version = "fizzy_plugin_abi_version"; pub const symbol_register = "fizzy_plugin_register"; +/// Mechanism B — host calls each frame (and once at init) before plugin draw/tick. +pub const symbol_set_dvui_context = "fizzy_plugin_set_dvui_context"; /// Returned by `fizzy_plugin_register`. Stable unsigned values for C callers. pub const RegisterStatus = enum(u32) { diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index 222fe73c..10e3ff8e 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -30,3 +30,5 @@ pub const pane_layout = @import("pane_layout.zig"); /// Runtime dylib entry contract (`fizzy_plugin_abi_version` / `fizzy_plugin_register`). pub const dylib = @import("dylib.zig"); +/// Dvui global injection for loaded plugin images (Mechanism B). +pub const dvui_context = @import("dvui_context.zig"); diff --git a/tests/plugin_loader_integration.zig b/tests/plugin_loader_integration.zig new file mode 100644 index 00000000..4a33f1b2 --- /dev/null +++ b/tests/plugin_loader_integration.zig @@ -0,0 +1,26 @@ +//! Integration test: dlopen the pixelart dylib and register into a Host. +const std = @import("std"); +const builtin = @import("builtin"); + +const sdk = @import("sdk"); +const PluginLoader = @import("plugin_loader"); +const test_opts = @import("plugin_loader_test_opts"); + +test "load pixelart dylib and register" { + if (comptime builtin.target.cpu.arch == .wasm32) return error.SkipZigTest; + + var host = sdk.Host.init(std.testing.allocator); + defer host.deinit(); + + const before = host.plugins.items.len; + var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib); + defer loaded.lib.close(); + + try std.testing.expect(host.plugins.items.len == before + 1); + const pa = host.pluginById("pixelart") orelse return error.TestExpectedEqual; + try std.testing.expectEqualStrings("pixelart", pa.id); + try std.testing.expect(host.sidebar_views.items.len >= 3); + + // Mechanism B: context setter is required and callable (no window needed for init io/debug). + loaded.set_dvui_context(null, null, null, null); +} From 448462ad40e0c2e57fe1a802ef4b0872c0c0ee49 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 12:16:42 -0500 Subject: [PATCH 37/49] 5c.1 --- HANDOFF.md | 6 +- build.zig | 26 ++++++-- src/App.zig | 1 + src/editor/Editor.zig | 89 ++++++++++++++++------------ src/editor/PluginLoader.zig | 48 ++++++++++++--- src/plugins/pixelart/dylib.zig | 13 ++++ src/plugins/pixelart/src/Globals.zig | 11 ++++ src/sdk/Host.zig | 33 ++++++++--- src/sdk/dylib.zig | 9 +++ tests/plugin_loader_integration.zig | 11 +++- 10 files changed, 186 insertions(+), 61 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 96431ea8..00ca4bcc 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -181,7 +181,7 @@ Each step ends with `zig build`, `zig build check-web`, `zig build test`. |------|------|-----------| | **5b.1** | SDK **export surface** — `src/sdk/dylib.zig` (`abi_version`, `RegisterStatus`, symbol names); `src/plugins/pixelart/dylib.zig` exports `fizzy_plugin_abi_version` / `fizzy_plugin_register`; `zig build pixelart-dylib` | ✅ Done | | **5b.2** | **`build.zig` dual link** — add `addLibrary(.dynamic)` target for one plugin (start with pixelart or a minimal `plugins/hello` example); web root keeps static `@import("pixelart")` | Native builds `.dylib`/`.so`/`.dll` beside exe; web still static | -| **5b.3** | **Host loader** — `src/editor/PluginLoader.zig`; `Host.pluginById`; `-Dload-pixelart-dylib` / `FIZZY_LOAD_PIXELART_DYLIB` / `FIZZY_PLUGIN_PATH`; `zig build test-plugin-loader` | ✅ Done | +| **5b.3** | **Host loader** — `src/editor/PluginLoader.zig`; `Host.pluginById`; `FIZZY_PLUGIN_PATH`; `-Dstatic-pixelart` / `FIZZY_STATIC_PIXELART`; `zig build test-plugin-loader` | ✅ Done | | **5b.4** | **Dvui context injection** — `sdk/dvui_context.zig`, `fizzy_plugin_set_dvui_context`, `Host.syncPluginDvuiContext` in frame loop | ✅ Done | Build all six native release triples (`x86_64`/`arm64` × macOS/Linux/Windows) once 5b.2 @@ -191,7 +191,7 @@ lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is | Step | Work | Done when | |------|------|-----------| -| **5c.1** | Built-in pixelart dylib loaded by host on native; static on web | App opens `.fiz` files via loaded dylib on macOS; web unchanged | +| **5c.1** | Built-in pixelart dylib loaded by host on native; static on web; Editor routes via `pixelartPlugin()` / `host.pluginById` | ✅ Done | | **5c.2** | Built-in workbench dylib (or keep static until pixelart path is stable — workbench owns center layout) | Tabs/splits work from loaded workbench | | **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | Release package contains exe + `pixelart.{dylib,so,dll}` etc.; single update channel | @@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these ### Where to begin (next session) -**5a.1–5a.2** — done. **5b.1–5b.4** — done (dylib export, loader, dvui context injection). **Next: 5c.1** (load built-in pixelart dylib by default on native; route Editor delegators through `host.pluginById`). +**5c.1** — done (native default dylib load + Globals injection + `pixelartPlugin()` routing). **Next: 5c.2** (workbench dylib) or **5c.3** (Velopack bundle polish). --- diff --git a/build.zig b/build.zig index 3a1775eb..ba72c31a 100644 --- a/build.zig +++ b/build.zig @@ -239,12 +239,12 @@ pub fn build(b: *std.Build) !void { build_opts.addOption([]const u8, "app_repo_url", app_repo_url); build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); build_opts.addOption(bool, "velopack_enabled", velopack_enabled); - const load_pixelart_dylib = b.option( + const static_pixelart = b.option( bool, - "load-pixelart-dylib", - "Load pixelart from plugins/libpixelart.{dylib,so,dll} instead of static register (native dev)", + "static-pixelart", + "Keep pixelart statically registered on native (skip built-in dylib load)", ) orelse false; - build_opts.addOption(bool, "load_pixelart_dylib", load_pixelart_dylib); + build_opts.addOption(bool, "static_pixelart", static_pixelart); const step = b.step("update", "update git dependencies"); step.makeFn = update_step; @@ -521,6 +521,13 @@ pub fn build(b: *std.Build) !void { if (no_emit) { b.getInstallStep().dependOn(&exe.step); + if (main_fizzy.pixelart_dylib) |pixelart_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_pixelart_dylib = b.addInstallArtifact(pixelart_dylib, .{ + .dest_dir = .{ .override = plugins_install_dir }, + }); + b.getInstallStep().dependOn(&install_pixelart_dylib.step); + } } else { const install_artifact = b.addInstallArtifact(exe, .{ .dest_dir = .{ .override = zig_out_install_dir }, @@ -532,6 +539,15 @@ pub fn build(b: *std.Build) !void { run_cmd.step.dependOn(&install_artifact.step); run_step.dependOn(&run_cmd.step); b.getInstallStep().dependOn(&install_artifact.step); + + if (main_fizzy.pixelart_dylib) |pixelart_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_pixelart_dylib = b.addInstallArtifact(pixelart_dylib, .{ + .dest_dir = .{ .override = plugins_install_dir }, + }); + b.getInstallStep().dependOn(&install_pixelart_dylib.step); + run_cmd.step.dependOn(&install_pixelart_dylib.step); + } } if (main_fizzy.pixelart_dylib) |pixelart_dylib| { @@ -539,6 +555,7 @@ pub fn build(b: *std.Build) !void { const install_pixelart_dylib = b.addInstallArtifact(pixelart_dylib, .{ .dest_dir = .{ .override = plugins_install_dir }, }); + const pixelart_dylib_step = b.step( "pixelart-dylib", "Build the pixelart plugin as a dynamic library into zig-out//plugins/ (native only)", @@ -1466,6 +1483,7 @@ fn addPixelartDylib( "fizzy_plugin_abi_version", "fizzy_plugin_register", "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_globals", }; return lib; } diff --git a/src/App.zig b/src/App.zig index f957caab..db20e8c2 100644 --- a/src/App.zig +++ b/src/App.zig @@ -194,6 +194,7 @@ pub fn AppInit(win: *dvui.Window) !void { fizzy.packer.* = Packer.init(allocator) catch unreachable; pixelart.Globals.packer = fizzy.packer; + fizzy.editor.syncLoadedPixelartGlobals(); // Hand the window to the listener thread and queue our own argv so the // first frame opens any files / project folder supplied on the command line. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 3c1de856..9ebaaeb6 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -454,11 +454,27 @@ pub const view_settings = "shell.settings"; fn loadPixelartFromDylibEnabled() bool { if (comptime builtin.target.cpu.arch == .wasm32) return false; - if (comptime build_opts.load_pixelart_dylib) return true; - if (std.process.getEnvVar("FIZZY_LOAD_PIXELART_DYLIB")) |v| { - return v.len > 0 and v[0] != '0'; - } - return false; + if (comptime build_opts.static_pixelart) return false; + if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_PIXELART")) |v| { + defer fizzy.app.allocator.free(v); + return v.len == 0 or v[0] == '0'; + } else |_| {} + return true; +} + +/// Registered pixelart plugin (dylib or static). Panics if missing after `postInit`. +pub fn pixelartPlugin(editor: *Editor) *sdk.Plugin { + return editor.host.pluginById("pixelart") orelse @panic("pixelart plugin not registered"); +} + +/// Re-inject host-owned Globals into a loaded pixelart dylib (e.g. after `Packer` init). +pub fn syncLoadedPixelartGlobals(editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + editor.host.syncPluginGlobals( + &fizzy.app.allocator, + @ptrCast(editor.pixelart_state), + @ptrCast(fizzy.packer), + ); } /// Load `{exe_dir}/plugins/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry. @@ -466,14 +482,19 @@ pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void { if (comptime builtin.target.cpu.arch == .wasm32) return; const path = try PluginLoader.resolvePluginPath(fizzy.app.allocator, exe_dir, "pixelart"); errdefer fizzy.app.allocator.free(path); - const loaded = try PluginLoader.loadAndRegister(&editor.host, path); + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, .{ + .gpa = &fizzy.app.allocator, + .state = @ptrCast(editor.pixelart_state), + .packer = null, + }); try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded); - editor.host.installPluginDvuiContext(loaded.set_dvui_context); + editor.host.installPluginDylibHooks(loaded.set_globals, loaded.set_dvui_context); } fn unloadPluginLibs(editor: *Editor) void { if (comptime builtin.target.cpu.arch == .wasm32) return; editor.host.plugin_set_dvui_context = null; + editor.host.plugin_set_globals = null; for (editor.loaded_plugin_libs.items) |*entry| { entry.lib.close(); fizzy.app.allocator.free(entry.path); @@ -502,13 +523,14 @@ pub fn postInit(editor: *Editor) !void { // editor tick already runs on wasm. Order = sidebar order. try workbench_mod.plugin.register(&editor.host); if (loadPixelartFromDylibEnabled()) { - try editor.loadPixelartDylib(fizzy.app.root_path); - const pa = editor.host.pluginById("pixelart") orelse return error.MissingPlugin; - try pa.initPlugin(); + editor.loadPixelartDylib(fizzy.app.root_path) catch |err| { + dvui.log.warn("pixelart dylib load failed ({s}); falling back to static plugin", .{@errorName(err)}); + try pixelart.plugin.register(&editor.host); + }; + try pixelartPlugin(editor).initPlugin(); } else { - const pixelart_plugin = pixelart.plugin; - try pixelart_plugin.register(&editor.host); - try pixelart_plugin.pluginPtr().initPlugin(); + try pixelart.plugin.register(&editor.host); + try pixelartPlugin(editor).initPlugin(); } // Shell built-in: Settings (owner = null; not a plugin). @@ -1585,7 +1607,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } { // Radial Menu (pixel-art plugin) - const pa = pixelart.plugin.pluginPtr(); + const pa = pixelartPlugin(editor); try pa.tickKeybinds(); Keybinds.tick() catch { dvui.log.err("Failed to tick hotkeys", .{}); @@ -1628,7 +1650,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { }; if (comptime builtin.target.cpu.arch == .wasm32) { - pixelart.plugin.pluginPtr().runPackWorkers(); + pixelartPlugin(editor).runPackWorkers(); } _ = editor.arena.reset(.retain_capacity); @@ -1944,21 +1966,21 @@ pub fn close(app: *App, editor: *Editor) void { pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { if (editor.folder) |folder| { editor.ignore.deinit(fizzy.app.allocator); - pixelart.plugin.pluginPtr().persistProjectFolder(); + pixelartPlugin(editor).persistProjectFolder(); fizzy.app.allocator.free(folder); } editor.folder = try fizzy.app.allocator.dupe(u8, path); try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); editor.host.setActiveSidebarView(workbench_mod.plugin.view_files); - pixelart.plugin.pluginPtr().reloadProjectFolder(fizzy.app.allocator); + pixelartPlugin(editor).reloadProjectFolder(fizzy.app.allocator); editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); } pub fn closeProjectFolder(editor: *Editor) void { if (editor.folder) |folder| { editor.ignore.deinit(fizzy.app.allocator); - pixelart.plugin.pluginPtr().persistProjectFolder(); + pixelartPlugin(editor).persistProjectFolder(); fizzy.app.allocator.free(folder); editor.folder = null; } @@ -2144,20 +2166,17 @@ pub fn processLoadingJobs(editor: *Editor) void { /// Kick off an async project-pack via the pixel-art plugin vtable. pub fn startPackProject(editor: *Editor) !void { - _ = editor; - try pixelart.plugin.pluginPtr().startPackProject(); + try pixelartPlugin(editor).startPackProject(); } /// True while a pack is queued, running, or finished but not yet installed. -pub fn isPackingActive(editor: *const Editor) bool { - _ = editor; - return pixelart.plugin.pluginPtr().isPackingActive(); +pub fn isPackingActive(editor: *Editor) bool { + return pixelartPlugin(editor).isPackingActive(); } /// Per-frame pack-job sweep (delegates to the pixel-art plugin). pub fn processPackJob(editor: *Editor) void { - _ = editor; - pixelart.plugin.pluginPtr().tickPackJobs(); + pixelartPlugin(editor).tickPackJobs(); } pub fn activeWorkspaceCanvasRectPhysical(editor: *Editor) ?dvui.Rect.Physical { @@ -2354,7 +2373,7 @@ pub fn newFile(editor: *Editor, path: []const u8, grid: sdk.EditorAPI.NewDocGrid return error.FileAlreadyExists; } - const owner = pixelart.plugin.pluginPtr(); + const owner = pixelartPlugin(editor); const staging = try owner.allocDocumentBuffer(fizzy.app.allocator); defer fizzy.app.allocator.free(staging.backing); @@ -2412,34 +2431,28 @@ pub fn forceCloseFile(editor: *Editor, index: usize) !void { } pub fn accept(editor: *Editor) !void { - _ = editor; - pixelart.plugin.pluginPtr().acceptEdit(); + pixelartPlugin(editor).acceptEdit(); } pub fn cancel(editor: *Editor) !void { - _ = editor; - pixelart.plugin.pluginPtr().cancelEdit(); + pixelartPlugin(editor).cancelEdit(); } pub fn copy(editor: *Editor) !void { - _ = editor; - try pixelart.plugin.pluginPtr().copy(); + try pixelartPlugin(editor).copy(); } pub fn paste(editor: *Editor) !void { - _ = editor; - try pixelart.plugin.pluginPtr().paste(); + try pixelartPlugin(editor).paste(); } pub fn deleteSelectedContents(editor: *Editor) void { - _ = editor; - pixelart.plugin.pluginPtr().deleteSelection(); + pixelartPlugin(editor).deleteSelection(); } /// Begins a transform operation on the currently active file. pub fn transform(editor: *Editor) !void { - _ = editor; - try pixelart.plugin.pluginPtr().transform(); + try pixelartPlugin(editor).transform(); } /// Performs a save operation on the currently open file. diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig index 4852ce8d..ed518497 100644 --- a/src/editor/PluginLoader.zig +++ b/src/editor/PluginLoader.zig @@ -17,6 +17,7 @@ pub const LoadError = error{ DylibOpenFailed, AbiSymbolMissing, RegisterSymbolMissing, + SetGlobalsSymbolMissing, SetDvuiContextSymbolMissing, AbiMismatch, RegisterRejected, @@ -25,9 +26,17 @@ pub const LoadError = error{ pub const LoadedLib = struct { lib: std.DynLib, path: []const u8, + set_globals: dylib_api.SetGlobalsFn, set_dvui_context: dvui_context.SetContextFn, }; +/// Host-owned pointers injected into the plugin image immediately before `register`. +pub const PreRegister = struct { + gpa: ?*const std.mem.Allocator = null, + state: ?*anyopaque = null, + packer: ?*anyopaque = null, +}; + /// `{exe_dir}/plugins/{pluginFilename(name)}` pub fn builtinPluginPath( allocator: std.mem.Allocator, @@ -49,13 +58,23 @@ pub fn resolvePluginPath( exe_dir: []const u8, builtin_name: []const u8, ) ![]const u8 { - if (std.process.getEnvVarOwned(allocator, "FIZZY_PLUGIN_PATH")) |override| { + if (std.process.Environ.getAlloc(nativeEnviron(), allocator, "FIZZY_PLUGIN_PATH")) |override| { return override; } else |_| {} return builtinPluginPath(allocator, exe_dir, builtin_name); } -pub fn loadAndRegister(host: *Host, path: []const u8) LoadError!LoadedLib { +fn nativeEnviron() std.process.Environ { + if (builtin.os.tag == .windows) { + return .{ .block = .global }; + } + var n: usize = 0; + while (std.c.environ[n] != null) : (n += 1) {} + const slice: [:null]const ?[*:0]const u8 = @as([*:null]const ?[*:0]const u8, @ptrCast(std.c.environ))[0..n :null]; + return .{ .block = .{ .slice = slice } }; +} + +pub fn loadAndRegister(host: *Host, path: []const u8, pre: ?PreRegister) LoadError!LoadedLib { var lib = std.DynLib.open(path) catch return error.DylibOpenFailed; errdefer lib.close(); @@ -65,10 +84,29 @@ pub fn loadAndRegister(host: *Host, path: []const u8) LoadError!LoadedLib { ) orelse return error.AbiSymbolMissing; if (!dylib_api.abiMatches(abi_fn())) return error.AbiMismatch; + const set_globals = lib.lookup( + dylib_api.SetGlobalsFn, + dylib_api.symbol_set_globals, + ) orelse return error.SetGlobalsSymbolMissing; + const reg_fn = lib.lookup( *const fn (?*Host) callconv(.c) u32, dylib_api.symbol_register, ) orelse return error.RegisterSymbolMissing; + + const set_ctx = lib.lookup( + dvui_context.SetContextFn, + dylib_api.symbol_set_dvui_context, + ) orelse return error.SetDvuiContextSymbolMissing; + + if (pre) |inject| { + set_globals( + if (inject.gpa) |gpa| @ptrCast(gpa) else null, + inject.state, + inject.packer, + ); + } + const status: dylib_api.RegisterStatus = @enumFromInt(reg_fn(host)); switch (status) { .ok => {}, @@ -76,14 +114,10 @@ pub fn loadAndRegister(host: *Host, path: []const u8) LoadError!LoadedLib { else => return error.RegisterRejected, } - const set_ctx = lib.lookup( - dvui_context.SetContextFn, - dylib_api.symbol_set_dvui_context, - ) orelse return error.SetDvuiContextSymbolMissing; - return .{ .lib = lib, .path = path, + .set_globals = set_globals, .set_dvui_context = set_ctx, }; } diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig index dba5be2a..6b42e888 100644 --- a/src/plugins/pixelart/dylib.zig +++ b/src/plugins/pixelart/dylib.zig @@ -24,3 +24,16 @@ export fn fizzy_plugin_set_dvui_context( ) callconv(.c) void { sdk.dvui_context.inject(window, io, ft2lib, debug); } + +export fn fizzy_plugin_set_globals( + gpa: ?*const anyopaque, + state: ?*anyopaque, + packer: ?*anyopaque, +) callconv(.c) void { + const Globals = @import("src/Globals.zig"); + Globals.installRuntime( + if (gpa) |p| @ptrCast(@alignCast(p)) else null, + if (state) |p| @ptrCast(@alignCast(p)) else null, + if (packer) |p| @ptrCast(@alignCast(p)) else null, + ); +} diff --git a/src/plugins/pixelart/src/Globals.zig b/src/plugins/pixelart/src/Globals.zig index 16ce8f0d..e64ca5b2 100644 --- a/src/plugins/pixelart/src/Globals.zig +++ b/src/plugins/pixelart/src/Globals.zig @@ -13,3 +13,14 @@ pub var packer: *Packer = undefined; pub fn allocator() std.mem.Allocator { return gpa; } + +/// Mechanism B: host calls `fizzy_plugin_set_globals` on the dylib image before `register`. +pub fn installRuntime( + gpa_ptr: ?*const std.mem.Allocator, + state_ptr: ?*State, + packer_ptr: ?*Packer, +) void { + if (gpa_ptr) |a| gpa = a.*; + if (state_ptr) |s| state = s; + if (packer_ptr) |p| packer = p; +} diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index aab6385c..165ddb6f 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -9,6 +9,7 @@ const std = @import("std"); const dvui = @import("dvui"); const Plugin = @import("Plugin.zig"); const dvui_context = @import("dvui_context.zig"); +const dylib_api = @import("dylib.zig"); const regions = @import("regions.zig"); const EditorAPI = @import("EditorAPI.zig"); const DocHandle = @import("DocHandle.zig"); @@ -36,6 +37,8 @@ pub const FileRowFillColor = struct { /// Mechanism B: setter from a loaded plugin dylib; null when all plugins are static. plugin_set_dvui_context: ?dvui_context.SetContextFn = null, +/// Host-owned Globals injection into a loaded plugin image (pixelart today). +plugin_set_globals: ?dylib_api.SetGlobalsFn = null, allocator: std.mem.Allocator, @@ -107,13 +110,6 @@ pub fn installShell(self: *Host, api: EditorAPI) void { self.shell_api = api; } -/// Wire a loaded plugin dylib's dvui globals to the host (Mechanism B). Called once -/// after `dlopen` + `fizzy_plugin_register`; also primes `io` / `ft2lib` / `debug`. -pub fn installPluginDvuiContext(self: *Host, setter: dvui_context.SetContextFn) void { - self.plugin_set_dvui_context = setter; - dvui_context.syncHostIntoPlugin(setter); -} - /// Re-push host dvui pointers into the loaded plugin image. Call at the top of each /// frame before plugin draw/tick (updates `current_window` every frame). pub fn syncPluginDvuiContext(self: *Host) void { @@ -121,6 +117,29 @@ pub fn syncPluginDvuiContext(self: *Host) void { dvui_context.syncHostIntoPlugin(setter); } +/// Re-push host-owned pixelart Globals (`gpa`, `state`, `packer`) into the dylib. +pub fn syncPluginGlobals( + self: *Host, + gpa: *const std.mem.Allocator, + state: *anyopaque, + packer: ?*anyopaque, +) void { + const setter = self.plugin_set_globals orelse return; + setter(@ptrCast(gpa), state, packer); +} + +/// Wire a loaded plugin dylib's dvui globals to the host (Mechanism B). Called once +/// after `dlopen` + `fizzy_plugin_register`; also primes `io` / `ft2lib` / `debug`. +pub fn installPluginDylibHooks( + self: *Host, + set_globals: dylib_api.SetGlobalsFn, + set_dvui_context: dvui_context.SetContextFn, +) void { + self.plugin_set_globals = set_globals; + self.plugin_set_dvui_context = set_dvui_context; + dvui_context.syncHostIntoPlugin(set_dvui_context); +} + /// Per-frame arena allocator (reset every frame; do not free). Asserts the shell is installed. pub fn arena(self: *Host) std.mem.Allocator { return self.shell_api.?.arena(); diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig index f9baea3d..3cc30203 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -14,6 +14,15 @@ pub const symbol_abi_version = "fizzy_plugin_abi_version"; pub const symbol_register = "fizzy_plugin_register"; /// Mechanism B — host calls each frame (and once at init) before plugin draw/tick. pub const symbol_set_dvui_context = "fizzy_plugin_set_dvui_context"; +/// Host-owned pixelart `Globals` (allocator, state, packer) injected before `register`. +pub const symbol_set_globals = "fizzy_plugin_set_globals"; + +/// C ABI — wire plugin-side `Globals` to host-owned pointers (pixelart today). +pub const SetGlobalsFn = *const fn ( + gpa: ?*const anyopaque, + state: ?*anyopaque, + packer: ?*anyopaque, +) callconv(.c) void; /// Returned by `fizzy_plugin_register`. Stable unsigned values for C callers. pub const RegisterStatus = enum(u32) { diff --git a/tests/plugin_loader_integration.zig b/tests/plugin_loader_integration.zig index 4a33f1b2..3dbfa75c 100644 --- a/tests/plugin_loader_integration.zig +++ b/tests/plugin_loader_integration.zig @@ -12,8 +12,15 @@ test "load pixelart dylib and register" { var host = sdk.Host.init(std.testing.allocator); defer host.deinit(); + // Stand-in for app-owned `pixelart.State` — register only stores the pointer. + var state_buf: [8192]u8 align(16) = undefined; + const before = host.plugins.items.len; - var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib); + var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib, .{ + .gpa = &std.testing.allocator, + .state = &state_buf, + .packer = null, + }); defer loaded.lib.close(); try std.testing.expect(host.plugins.items.len == before + 1); @@ -21,6 +28,6 @@ test "load pixelart dylib and register" { try std.testing.expectEqualStrings("pixelart", pa.id); try std.testing.expect(host.sidebar_views.items.len >= 3); - // Mechanism B: context setter is required and callable (no window needed for init io/debug). loaded.set_dvui_context(null, null, null, null); + loaded.set_globals(@ptrCast(&std.testing.allocator), &state_buf, null); } From fd15a23baa2487da6eea406eef820484ba270aca Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 12:23:00 -0500 Subject: [PATCH 38/49] 5c.2 --- HANDOFF.md | 5 +- build.zig | 88 +++++++++++++++++++++++++-- src/editor/Editor.zig | 85 ++++++++++++++++++++++---- src/editor/PluginLoader.zig | 10 ++- src/editor/explorer/Explorer.zig | 2 +- src/plugins/workbench/dylib.zig | 40 ++++++++++++ src/plugins/workbench/src/Globals.zig | 11 ++++ src/sdk/Host.zig | 2 + tests/plugin_loader_integration.zig | 2 +- 9 files changed, 223 insertions(+), 22 deletions(-) create mode 100644 src/plugins/workbench/dylib.zig diff --git a/HANDOFF.md b/HANDOFF.md index 00ca4bcc..71b5744f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -192,7 +192,7 @@ lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is | Step | Work | Done when | |------|------|-----------| | **5c.1** | Built-in pixelart dylib loaded by host on native; static on web; Editor routes via `pixelartPlugin()` / `host.pluginById` | ✅ Done | -| **5c.2** | Built-in workbench dylib (or keep static until pixelart path is stable — workbench owns center layout) | Tabs/splits work from loaded workbench | +| **5c.2** | Built-in workbench dylib loaded by host on native; `workbenchPlugin()` / `workbench_files_view` routing | ✅ Done | | **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | Release package contains exe + `pixelart.{dylib,so,dll}` etc.; single update channel | Built-ins can remain **statically linked during 5b** and flip to dylib in 5c — the @@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these ### Where to begin (next session) -**5c.1** — done (native default dylib load + Globals injection + `pixelartPlugin()` routing). **Next: 5c.2** (workbench dylib) or **5c.3** (Velopack bundle polish). +**5c.1–5c.2** — done (pixelart + workbench built-in dylibs on native). **Next: 5c.3** (Velopack bundle polish) or **5d**. --- @@ -667,6 +667,7 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl | `src/sdk/dvui_context.zig` | Mechanism B — inject host dvui globals into plugin dylib copy | | `src/sdk/dylib.zig` | Dylib ABI version + entry symbol names (`fizzy_plugin_*`) | | `src/plugins/pixelart/dylib.zig` | Pixelart dynamic-library root (exports only) | +| `src/plugins/workbench/dylib.zig` | Workbench dynamic-library root (exports only) | | `src/sdk/Plugin.zig` | Plugin vtable; dylib entry wraps `register()` | | `src/plugins/pixelart/module.zig` | Pixel-art build module root | | `src/plugins/pixelart/pixelart.zig` | Pixel-art intra-plugin hub | diff --git a/build.zig b/build.zig index ba72c31a..140f5f8a 100644 --- a/build.zig +++ b/build.zig @@ -245,6 +245,12 @@ pub fn build(b: *std.Build) !void { "Keep pixelart statically registered on native (skip built-in dylib load)", ) orelse false; build_opts.addOption(bool, "static_pixelart", static_pixelart); + const static_workbench = b.option( + bool, + "static-workbench", + "Keep workbench statically registered on native (skip built-in dylib load)", + ) orelse false; + build_opts.addOption(bool, "static_workbench", static_workbench); const step = b.step("update", "update git dependencies"); step.makeFn = update_step; @@ -528,6 +534,13 @@ pub fn build(b: *std.Build) !void { }); b.getInstallStep().dependOn(&install_pixelart_dylib.step); } + if (main_fizzy.workbench_dylib) |workbench_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_workbench_dylib = b.addInstallArtifact(workbench_dylib, .{ + .dest_dir = .{ .override = plugins_install_dir }, + }); + b.getInstallStep().dependOn(&install_workbench_dylib.step); + } } else { const install_artifact = b.addInstallArtifact(exe, .{ .dest_dir = .{ .override = zig_out_install_dir }, @@ -548,6 +561,26 @@ pub fn build(b: *std.Build) !void { b.getInstallStep().dependOn(&install_pixelart_dylib.step); run_cmd.step.dependOn(&install_pixelart_dylib.step); } + if (main_fizzy.workbench_dylib) |workbench_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_workbench_dylib = b.addInstallArtifact(workbench_dylib, .{ + .dest_dir = .{ .override = plugins_install_dir }, + }); + b.getInstallStep().dependOn(&install_workbench_dylib.step); + run_cmd.step.dependOn(&install_workbench_dylib.step); + } + } + + if (main_fizzy.workbench_dylib) |workbench_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_workbench_dylib = b.addInstallArtifact(workbench_dylib, .{ + .dest_dir = .{ .override = plugins_install_dir }, + }); + const workbench_dylib_step = b.step( + "workbench-dylib", + "Build the workbench plugin as a dynamic library into zig-out//plugins/ (native only)", + ); + workbench_dylib_step.dependOn(&install_workbench_dylib.step); } if (main_fizzy.pixelart_dylib) |pixelart_dylib| { @@ -1182,6 +1215,7 @@ const FizzyExecutable = struct { sdk_module: *std.Build.Module, /// Native-only; `null` on wasm targets. pixelart_dylib: ?*std.Build.Step.Compile = null, + workbench_dylib: ?*std.Build.Step.Compile = null, }; fn addFizzyExecutableForTarget( @@ -1320,6 +1354,16 @@ fn addFizzyExecutableForTarget( }); } else null; + const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + break :blk addWorkbenchDylib(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + .icons = icons_module, + .backend = dvui_dep.module("sdl3"), + }); + } else null; + const singleton_app_dep = b.dependency("dvui_singleton_app", .{ .target = resolved_target, .optimize = optimize, @@ -1381,6 +1425,7 @@ fn addFizzyExecutableForTarget( .known_folders = known_folders, .sdk_module = sdk_module, .pixelart_dylib = pixelart_dylib, + .workbench_dylib = workbench_dylib, }; } @@ -1423,6 +1468,14 @@ const WorkbenchModuleDeps = struct { }; /// Workbench plugin (`src/plugins/workbench/module.zig`). +fn applyWorkbenchModuleImports(module: *std.Build.Module, deps: WorkbenchModuleDeps) void { + module.addImport("dvui", deps.dvui); + module.addImport("core", deps.core); + module.addImport("sdk", deps.sdk); + if (deps.icons) |icons| module.addImport("icons", icons); + if (deps.backend) |backend| module.addImport("backend", backend); +} + fn wireWorkbenchModule( b: *std.Build, target: std.Build.ResolvedTarget, @@ -1437,14 +1490,39 @@ fn wireWorkbenchModule( .link_libc = target.result.cpu.arch != .wasm32, .single_threaded = target.result.cpu.arch == .wasm32, }); - workbench_module.addImport("dvui", deps.dvui); - workbench_module.addImport("core", deps.core); - workbench_module.addImport("sdk", deps.sdk); - if (deps.icons) |icons| workbench_module.addImport("icons", icons); - if (deps.backend) |backend| workbench_module.addImport("backend", backend); + applyWorkbenchModuleImports(workbench_module, deps); consumer.addImport("workbench", workbench_module); } +/// Native dynamic library for the workbench plugin (`src/plugins/workbench/dylib.zig`). +fn addWorkbenchDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + deps: WorkbenchModuleDeps, +) *std.Build.Step.Compile { + const dylib_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/plugins/workbench/dylib.zig"), + .link_libc = true, + }); + applyWorkbenchModuleImports(dylib_module, deps); + const lib = b.addLibrary(.{ + .name = "workbench", + .linkage = .dynamic, + .root_module = dylib_module, + }); + lib.linker_allow_shlib_undefined = true; + lib.root_module.export_symbol_names = &[_][]const u8{ + "fizzy_plugin_abi_version", + "fizzy_plugin_register", + "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_globals", + }; + return lib; +} + /// Pixel-art plugin (`src/plugins/pixelart/module.zig`). fn applyPixelartModuleImports(module: *std.Build.Module, deps: PixelartModuleDeps) void { module.addImport("dvui", deps.dvui); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 9ebaaeb6..b7881fda 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -462,19 +462,73 @@ fn loadPixelartFromDylibEnabled() bool { return true; } +fn loadWorkbenchFromDylibEnabled() bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + if (comptime build_opts.static_workbench) return false; + if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_WORKBENCH")) |v| { + defer fizzy.app.allocator.free(v); + return v.len == 0 or v[0] == '0'; + } else |_| {} + return true; +} + +/// Stable workbench sidebar view id (matches `workbench.plugin.view_files`). +pub const workbench_files_view = workbench_mod.plugin.view_files; + +/// Registered workbench plugin (dylib or static). Panics if missing after `postInit`. +pub fn workbenchPlugin(editor: *Editor) *sdk.Plugin { + return editor.host.pluginById("workbench") orelse @panic("workbench plugin not registered"); +} + /// Registered pixelart plugin (dylib or static). Panics if missing after `postInit`. pub fn pixelartPlugin(editor: *Editor) *sdk.Plugin { return editor.host.pluginById("pixelart") orelse @panic("pixelart plugin not registered"); } +/// Mechanism B: push host dvui state into every loaded plugin dylib image. +pub fn syncLoadedPluginDvuiContexts(editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + for (editor.loaded_plugin_libs.items) |loaded| { + sdk.dvui_context.syncHostIntoPlugin(loaded.set_dvui_context); + } +} + +fn syncLoadedPluginGlobals(editor: *Editor, plugin_id: []const u8, arg_b: *anyopaque, arg_c: ?*anyopaque) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + for (editor.loaded_plugin_libs.items) |loaded| { + if (!std.mem.eql(u8, loaded.plugin_id, plugin_id)) continue; + loaded.set_globals(@ptrCast(&fizzy.app.allocator), arg_b, arg_c); + } +} + /// Re-inject host-owned Globals into a loaded pixelart dylib (e.g. after `Packer` init). pub fn syncLoadedPixelartGlobals(editor: *Editor) void { + syncLoadedPluginGlobals(editor, "pixelart", @ptrCast(editor.pixelart_state), @ptrCast(fizzy.packer)); +} + +/// Re-inject host-owned Globals into a loaded workbench dylib. +pub fn syncLoadedWorkbenchGlobals(editor: *Editor) void { + syncLoadedPluginGlobals(editor, "workbench", @ptrCast(&editor.host), @ptrCast(&editor.workbench)); +} + +fn appendLoadedPluginLib(editor: *Editor, loaded: PluginLoader.LoadedLib) !void { + try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded); + editor.host.plugin_set_globals = loaded.set_globals; + editor.host.plugin_set_dvui_context = loaded.set_dvui_context; +} + +/// Load `{exe_dir}/plugins/libworkbench.*` and register via dylib entry. +pub fn loadWorkbenchDylib(editor: *Editor, exe_dir: []const u8) !void { if (comptime builtin.target.cpu.arch == .wasm32) return; - editor.host.syncPluginGlobals( - &fizzy.app.allocator, - @ptrCast(editor.pixelart_state), - @ptrCast(fizzy.packer), - ); + const path = try PluginLoader.builtinPluginPath(fizzy.app.allocator, exe_dir, "workbench"); + errdefer fizzy.app.allocator.free(path); + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "workbench", .{ + .gpa = &fizzy.app.allocator, + .state = @ptrCast(&editor.host), + .packer = @ptrCast(&editor.workbench), + }); + try appendLoadedPluginLib(editor, loaded); + syncLoadedPluginDvuiContexts(editor); } /// Load `{exe_dir}/plugins/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry. @@ -482,13 +536,13 @@ pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void { if (comptime builtin.target.cpu.arch == .wasm32) return; const path = try PluginLoader.resolvePluginPath(fizzy.app.allocator, exe_dir, "pixelart"); errdefer fizzy.app.allocator.free(path); - const loaded = try PluginLoader.loadAndRegister(&editor.host, path, .{ + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "pixelart", .{ .gpa = &fizzy.app.allocator, .state = @ptrCast(editor.pixelart_state), .packer = null, }); - try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded); - editor.host.installPluginDylibHooks(loaded.set_globals, loaded.set_dvui_context); + try appendLoadedPluginLib(editor, loaded); + syncLoadedPluginDvuiContexts(editor); } fn unloadPluginLibs(editor: *Editor) void { @@ -521,7 +575,14 @@ pub fn postInit(editor: *Editor) !void { // near-empty shell's content: it iterates the Host registries rather than // hardcoding panes. Web-safe — the draw fns reach the same inline code the // editor tick already runs on wasm. Order = sidebar order. - try workbench_mod.plugin.register(&editor.host); + if (loadWorkbenchFromDylibEnabled()) { + editor.loadWorkbenchDylib(fizzy.app.root_path) catch |err| { + dvui.log.warn("workbench dylib load failed ({s}); falling back to static plugin", .{@errorName(err)}); + try workbench_mod.plugin.register(&editor.host); + }; + } else { + try workbench_mod.plugin.register(&editor.host); + } if (loadPixelartFromDylibEnabled()) { editor.loadPixelartDylib(fizzy.app.root_path) catch |err| { dvui.log.warn("pixelart dylib load failed ({s}); falling back to static plugin", .{@errorName(err)}); @@ -553,7 +614,7 @@ pub fn postInit(editor: *Editor) !void { // keybind map. The shell already registered its global/navigation/region binds // in `Keybinds.register` (during `init`, before this runs), so the two halves // are disjoint — no `putNoClobber` clash. Runs on all targets (web included). - editor.host.syncPluginDvuiContext(); + syncLoadedPluginDvuiContexts(editor); const window = dvui.currentWindow(); for (editor.host.plugins.items) |plugin| try plugin.contributeKeybinds(window); @@ -1201,7 +1262,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.setTitlebarColor(); editor.setWindowStyle(); - editor.host.syncPluginDvuiContext(); + syncLoadedPluginDvuiContexts(editor); for (editor.host.plugins.items) |plugin| plugin.beginFrame(); if (fizzy.perf.record) fizzy.perf.beginFrame(); defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog(); @@ -1971,7 +2032,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { } editor.folder = try fizzy.app.allocator.dupe(u8, path); try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); - editor.host.setActiveSidebarView(workbench_mod.plugin.view_files); + editor.host.setActiveSidebarView(workbench_files_view); pixelartPlugin(editor).reloadProjectFolder(fizzy.app.allocator); editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig index ed518497..90c93a49 100644 --- a/src/editor/PluginLoader.zig +++ b/src/editor/PluginLoader.zig @@ -26,6 +26,8 @@ pub const LoadError = error{ pub const LoadedLib = struct { lib: std.DynLib, path: []const u8, + /// Built-in plugin id (`"pixelart"`, `"workbench"`, …). + plugin_id: []const u8, set_globals: dylib_api.SetGlobalsFn, set_dvui_context: dvui_context.SetContextFn, }; @@ -74,7 +76,12 @@ fn nativeEnviron() std.process.Environ { return .{ .block = .{ .slice = slice } }; } -pub fn loadAndRegister(host: *Host, path: []const u8, pre: ?PreRegister) LoadError!LoadedLib { +pub fn loadAndRegister( + host: *Host, + path: []const u8, + plugin_id: []const u8, + pre: ?PreRegister, +) LoadError!LoadedLib { var lib = std.DynLib.open(path) catch return error.DylibOpenFailed; errdefer lib.close(); @@ -117,6 +124,7 @@ pub fn loadAndRegister(host: *Host, path: []const u8, pre: ?PreRegister) LoadErr return .{ .lib = lib, .path = path, + .plugin_id = plugin_id, .set_globals = set_globals, .set_dvui_context = set_ctx, }; diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 689308b3..3d754170 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -108,7 +108,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { .background = false, }); - if (!fizzy.editor.host.isActiveSidebarView(workbench.plugin.view_files)) { + if (!fizzy.editor.host.isActiveSidebarView(fizzy.Editor.workbench_files_view)) { fizzy.editor.resetFileTreeWhenFilesHidden(); } diff --git a/src/plugins/workbench/dylib.zig b/src/plugins/workbench/dylib.zig new file mode 100644 index 00000000..e26529d6 --- /dev/null +++ b/src/plugins/workbench/dylib.zig @@ -0,0 +1,40 @@ +//! Dynamic-library root for the workbench plugin (Phase 5c). +//! +//! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use +//! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported. +const sdk = @import("sdk"); +const dvui = @import("dvui"); +const plugin = @import("src/plugin.zig"); + +export fn fizzy_plugin_abi_version() callconv(.c) u32 { + return sdk.dylib.abi_version; +} + +export fn fizzy_plugin_register(host: ?*sdk.Host) callconv(.c) u32 { + if (host == null) return @intFromEnum(sdk.dylib.RegisterStatus.err_null_host); + plugin.register(host.?) catch return @intFromEnum(sdk.dylib.RegisterStatus.err_register); + return @intFromEnum(sdk.dylib.RegisterStatus.ok); +} + +export fn fizzy_plugin_set_dvui_context( + window: ?*dvui.Window, + io: ?*anyopaque, + ft2lib: ?*anyopaque, + debug: ?*dvui.Debug, +) callconv(.c) void { + sdk.dvui_context.inject(window, io, ft2lib, debug); +} + +/// Workbench convention: `gpa`, `host`, `workbench` (see `Globals.installRuntime`). +export fn fizzy_plugin_set_globals( + gpa: ?*const anyopaque, + host: ?*anyopaque, + workbench: ?*anyopaque, +) callconv(.c) void { + const Globals = @import("src/Globals.zig"); + Globals.installRuntime( + if (gpa) |p| @ptrCast(@alignCast(p)) else null, + if (host) |p| @ptrCast(@alignCast(p)) else null, + if (workbench) |p| @ptrCast(@alignCast(p)) else null, + ); +} diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig index 7dd20dd3..dfae2380 100644 --- a/src/plugins/workbench/src/Globals.zig +++ b/src/plugins/workbench/src/Globals.zig @@ -15,3 +15,14 @@ pub var workbench: *Workbench = undefined; pub fn allocator() std.mem.Allocator { return gpa; } + +/// Mechanism B: host calls `fizzy_plugin_set_globals` on the dylib image before `register`. +pub fn installRuntime( + gpa_ptr: ?*const std.mem.Allocator, + host_ptr: ?*sdk.Host, + workbench_ptr: ?*Workbench, +) void { + if (gpa_ptr) |a| gpa = a.*; + if (host_ptr) |h| host = h; + if (workbench_ptr) |w| workbench = w; +} diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 165ddb6f..d276a765 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -36,8 +36,10 @@ pub const FileRowFillColor = struct { }; /// Mechanism B: setter from a loaded plugin dylib; null when all plugins are static. +/// Deprecated: prefer `Editor.syncLoadedPluginDvuiContexts` when multiple dylibs are loaded. plugin_set_dvui_context: ?dvui_context.SetContextFn = null, /// Host-owned Globals injection into a loaded plugin image (pixelart today). +/// Deprecated: prefer per-lib `LoadedLib.set_globals` when multiple dylibs are loaded. plugin_set_globals: ?dylib_api.SetGlobalsFn = null, allocator: std.mem.Allocator, diff --git a/tests/plugin_loader_integration.zig b/tests/plugin_loader_integration.zig index 3dbfa75c..7dbbf195 100644 --- a/tests/plugin_loader_integration.zig +++ b/tests/plugin_loader_integration.zig @@ -16,7 +16,7 @@ test "load pixelart dylib and register" { var state_buf: [8192]u8 align(16) = undefined; const before = host.plugins.items.len; - var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib, .{ + var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib, "pixelart", .{ .gpa = &std.testing.allocator, .state = &state_buf, .packer = null, From 0fb1f765c6ffe9e0e5fabe643efcfa300e36bae0 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 19 Jun 2026 12:26:50 -0500 Subject: [PATCH 39/49] 5c.3 --- HANDOFF.md | 4 +- build.zig | 72 +++++- docs/PLUGINS.md | 233 ++++++++++++++++++ src/App.zig | 2 +- src/editor/Editor.zig | 19 +- src/editor/PluginLoader.zig | 2 +- src/plugins/pixelart/dylib.zig | 2 +- src/plugins/pixelart/module.zig | 2 +- src/plugins/pixelart/pixelart.zig | 2 +- src/plugins/pixelart/src/CanvasData.zig | 4 +- src/plugins/pixelart/src/Docs.zig | 2 +- src/plugins/pixelart/src/Globals.zig | 4 +- src/plugins/pixelart/src/State.zig | 4 +- src/plugins/pixelart/src/plugin.zig | 15 +- src/plugins/workbench/dylib.zig | 2 +- src/plugins/workbench/src/Globals.zig | 4 +- src/plugins/workbench/src/Workbench.zig | 13 +- .../workbench/src/workbench_layout.zig | 2 +- src/sdk/DocHandle.zig | 3 - src/sdk/Host.zig | 56 +---- src/sdk/Plugin.zig | 2 +- src/sdk/dvui_context.zig | 2 +- src/sdk/dylib.zig | 14 +- src/sdk/sdk.zig | 10 +- 24 files changed, 352 insertions(+), 123 deletions(-) create mode 100644 docs/PLUGINS.md diff --git a/HANDOFF.md b/HANDOFF.md index 71b5744f..45952f43 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -193,7 +193,7 @@ lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is |------|------|-----------| | **5c.1** | Built-in pixelart dylib loaded by host on native; static on web; Editor routes via `pixelartPlugin()` / `host.pluginById` | ✅ Done | | **5c.2** | Built-in workbench dylib loaded by host on native; `workbenchPlugin()` / `workbench_files_view` routing | ✅ Done | -| **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | Release package contains exe + `pixelart.{dylib,so,dll}` etc.; single update channel | +| **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | ✅ Done | Built-ins can remain **statically linked during 5b** and flip to dylib in 5c — the `register()` path is identical either way. @@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these ### Where to begin (next session) -**5c.1–5c.2** — done (pixelart + workbench built-in dylibs on native). **Next: 5c.3** (Velopack bundle polish) or **5d**. +**5c.1–5c.3** — done (built-in dylibs on native + Velopack packDir includes `plugins/`). **Next: 5d**. --- diff --git a/build.zig b/build.zig index 140f5f8a..64296b79 100644 --- a/build.zig +++ b/build.zig @@ -513,17 +513,19 @@ pub fn build(b: *std.Build) !void { const msf_gif_module = main_fizzy.msf_gif_module; const known_folders = main_fizzy.known_folders; - const exe_for_package: *std.Build.Step.Compile = package_blk: { - if (velopack_enabled) break :package_blk exe; - if (!velopack_supported_for_target) break :package_blk exe; + const package_fizzy: FizzyExecutable = package_blk: { + if (velopack_enabled) break :package_blk main_fizzy; + if (!velopack_supported_for_target) break :package_blk main_fizzy; const pack_opts = b.addOptions(); pack_opts.addOption([]const u8, "app_version", app_version); pack_opts.addOption([]const u8, "app_repo_url", app_repo_url); pack_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); pack_opts.addOption(bool, "velopack_enabled", true); - const pack_fizzy = try addFizzyExecutableForTarget(b, target, optimize, accesskit, pack_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, true); - break :package_blk pack_fizzy.exe; + pack_opts.addOption(bool, "static_pixelart", static_pixelart); + pack_opts.addOption(bool, "static_workbench", static_workbench); + break :package_blk try addFizzyExecutableForTarget(b, target, optimize, accesskit, pack_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, true); }; + const exe_for_package = package_fizzy.exe; if (no_emit) { b.getInstallStep().dependOn(&exe.step); @@ -716,8 +718,20 @@ pub fn build(b: *std.Build) !void { // the full install path, which produced `.zig-cache\o\\C:\...` // on Windows (BadPathName). const vpk_pkg_out_dir = vpk_pkg_sh.addOutputDirectoryArg("desktop"); + // Stage exe + built-in plugin dylibs under zig-out//.pack-input/ + // so vpk ships plugins/ next to the main binary. + const pack_input_subdir = b.fmt("{s}/.pack-input", .{zig_out_subdir}); + const pack_plugins_subdir = b.fmt("{s}/.pack-input/plugins", .{zig_out_subdir}); + const pack_stage_tail = addVelopackPackDirInstall( + b, + exe_for_package, + package_fizzy, + pack_input_subdir, + pack_plugins_subdir, + &strip_release_sh.step, + ); vpk_pkg_sh.addArg("--packDir"); - vpk_pkg_sh.addDirectoryArg(exe_for_package.getEmittedBin().dirname()); + vpk_pkg_sh.addArg(b.getInstallPath(.{ .custom = pack_input_subdir }, "")); switch (target.result.os.tag) { .windows => { // Sets the installer's icon and the Start Menu shortcut icon. The @@ -773,7 +787,7 @@ pub fn build(b: *std.Build) !void { try velopack.attachMksquashfsToVpkRun(b, vpk_pkg_sh, target); //vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step); - vpk_pkg_sh.step.dependOn(&strip_release_sh.step); + vpk_pkg_sh.step.dependOn(pack_stage_tail); const build_package_install = b.addInstallDirectory(.{ .source_dir = vpk_pkg_out_dir, @@ -847,9 +861,8 @@ pub fn build(b: *std.Build) !void { // modules under test, so it compiles in well under a second // and never needs dvui/SDL/assets. // - // 2. Integration tests (added in Phase 2 of the testing plan) - // will use dvui's testing backend and exercise real fizzy - // drawing functions in a headless Window. + // 2. Integration tests use dvui's testing backend and exercise + // real fizzy drawing functions in a headless Window. // // Both share the same `zig build test` and `zig build check` // entry points. @@ -1207,6 +1220,43 @@ fn applyMsvcIncludesToReachableTranslateC( } } +/// Install stripped exe + built-in plugin dylibs for `vpk pack --packDir`. +fn addVelopackPackDirInstall( + b: *std.Build, + exe: *std.Build.Step.Compile, + fizzy: FizzyExecutable, + pack_input_subdir: []const u8, + pack_plugins_subdir: []const u8, + after_step: *std.Build.Step, +) *std.Build.Step { + const pack_exe_install_dir: std.Build.InstallDir = .{ .custom = pack_input_subdir }; + const pack_plugins_install_dir: std.Build.InstallDir = .{ .custom = pack_plugins_subdir }; + + const install_pack_exe = b.addInstallArtifact(exe, .{ + .dest_dir = .{ .override = pack_exe_install_dir }, + }); + install_pack_exe.step.dependOn(after_step); + + var tail: *std.Build.Step = &install_pack_exe.step; + + if (fizzy.pixelart_dylib) |dylib| { + const install_pixelart = b.addInstallArtifact(dylib, .{ + .dest_dir = .{ .override = pack_plugins_install_dir }, + }); + install_pixelart.step.dependOn(tail); + tail = &install_pixelart.step; + } + if (fizzy.workbench_dylib) |dylib| { + const install_workbench = b.addInstallArtifact(dylib, .{ + .dest_dir = .{ .override = pack_plugins_install_dir }, + }); + install_workbench.step.dependOn(tail); + tail = &install_workbench.step; + } + + return tail; +} + const FizzyExecutable = struct { exe: *std.Build.Step.Compile, zstbi_module: *std.Build.Module, @@ -1555,7 +1605,7 @@ fn addPixelartDylib( .linkage = .dynamic, .root_module = dylib_module, }); - // Resolve dvui/sdk symbols from the host at load time (Mechanism B). + // Resolve dvui/sdk symbols from the host at load time. lib.linker_allow_shlib_undefined = true; lib.root_module.export_symbol_names = &[_][]const u8{ "fizzy_plugin_abi_version", diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md new file mode 100644 index 00000000..2b1c86dc --- /dev/null +++ b/docs/PLUGINS.md @@ -0,0 +1,233 @@ +# Fizzy Plugin System + +Fizzy is a near-empty **shell** that owns a window, a menu/sidebar/panel layout, and a +document model — but no features of its own. Everything the user sees (the pixel-art editor, +the file explorer, tabs/splits) is contributed by **plugins** that register against a stable +SDK. The same plugin source compiles two ways: statically into the app, or as a runtime +dynamic library. + +--- + +## 1. General structure + +``` + ┌─────────────────────────────────────────────────────────┐ + │ Shell (Editor) │ + │ window · frame loop · menu/sidebar/panel layout · docs │ + │ │ + │ ┌──────────────┐ ┌──────────────────────────┐ │ + │ │ Host │◄──────►│ EditorAPI │ │ + │ │ registries │ reach │ (shell read/util surface │ │ + │ │ + services │ back │ arena, folder, docs, …) │ │ + │ └──────┬───────┘ └──────────────────────────┘ │ + └──────────┼──────────────────────────────────────────────┘ + │ register(host) + vtable calls + ┌──────────┴───────────────┐ ┌────────────────────────┐ + │ workbench plugin │ │ pixelart plugin │ + │ file tree · tabs/splits │ │ canvas editor │ + └──────────────────────────┘ └────────────────────────┘ + plugins never import each other — they meet only at the SDK +``` + +The SDK (`src/sdk/`) is the entire contract between shell and plugins: + +| Type | Role | +|------|------| +| `Host` | What the shell hands every plugin. Holds the **registries** (the shell iterates these instead of hardcoding panes) + a **service locator** for inter-plugin APIs. | +| `Plugin` | A plugin's identity + **vtable** of optional hooks. The shell calls these; a plugin implements only what it needs. | +| `DocHandle` | Opaque handle to an open document: `{ ptr, id, owner: *Plugin }`. The shell stores these per tab and **routes every document operation to `owner`** — it never inspects `ptr`. | +| `EditorAPI` | The shell's read/utility surface a plugin reaches back through (`arena`, `folder`, open-doc collection, save dialogs, …). Reached via `Host`. | +| `regions` | The contribution structs a plugin registers: `SidebarView`, `BottomView`, `CenterProvider`, `MenuContribution`, `SettingsSection`. | +| `dylib` / `dvui_context` | The C-ABI entry contract + dvui-context injection used when a plugin is loaded as a runtime library. | + +**The shell owns no features.** Each frame it iterates the Host registries and draws whatever +plugins contributed. Adding a pane, panel tab, menu, document type, or settings section is a +`Host.register*` call from inside a plugin's `register` — never a shell edit. + +### Two link modes (one source) + +| Mode | Who | Targets | How it registers | +|------|-----|---------|------------------| +| **Static** | Built-in plugins (pixelart, workbench, …) — always shipped with the app | all, incl. web | shell calls `plugin.register(&host)` directly at startup | +| **Dynamic** | Third-party plugins | desktop only (no dlopen on web) | shell `dlopen`s the library and calls its `fizzy_plugin_register` C entry, which calls the same `register(&host)` | + +Built-in plugins live in this repo and ship inside the signed app bundle; they are never +distributed or versioned separately. The dynamic path exists so an external Zig project can +depend on the SDK, implement the same `Plugin` interface, and ship a loadable library. + +--- + +## 2. Anatomy of a plugin + +### Directory layout + +``` +src/plugins// + module.zig # static build root — what the shell imports as @import("") + dylib.zig # dynamic build root — exports the C entry symbols only + .zig # intra-plugin hub: re-exports sdk/core/dvui + shared types + src/ + plugin.zig # register(host) + the vtable + draw entry points + Globals.zig # runtime-injected pointers (allocator, host, plugin state) + State.zig # the plugin's own runtime state (whatever it needs) + … # implementation +``` + +Files inside `src/**` import the hub (`../.zig`) for `sdk`/`core`/`dvui`, **never** +`fizzy.zig`. That import-discipline is what lets the plugin compile as a standalone library. + +### The `register(host)` entry — the one required surface + +`register` wires the plugin into the shell. A minimal plugin just registers itself; a +real one adds contributions: + +```zig +pub fn register(host: *sdk.Host) !void { + plugin.state = …; // adopt the plugin's runtime state + try host.registerPlugin(&plugin); // identity + vtable + try host.registerSidebarView(.{ … }); // a left-rail pane + try host.registerBottomView(.{ … }); // a bottom-panel tab + try host.registerSettingsSection(.{ … }); + // …whatever else it contributes +} +``` + +`Host.register*` methods: `registerPlugin`, `registerSidebarView`, `registerBottomView`, +`registerCenterProvider`, `registerMenu`, `registerSettingsSection`, `registerService`, +`registerFileRowFillColor`. Each takes a struct with a stable, namespaced `id`, the owning +`*Plugin`, and a `draw`/resolver fn. The shell renders the set (and shows a **tab strip** +automatically when more than one plugin contributes to a region). + +### The `Plugin` vtable — optional hooks the shell calls + +Every field is an optional fn pointer taking the plugin's opaque `state`. Group by purpose: + +- **Lifecycle** — `deinit`, `initPlugin`. +- **Document ownership** — `fileTypePriority(ext)` (claim file extensions), `loadDocument` / + `loadDocumentFromBytes` / `createDocument`, `saveDocument`, `closeDocument`, `isDirty`, + `undo`/`redo`/`canUndo`/`canRedo`, plus opaque document-buffer management for the async + load path. +- **Document metadata at the workbench boundary** — `bindDocumentToPane`, `documentGrouping`, + `documentPath`, `setDocumentPath`, dirty/save indicators. These keep `DocHandle` opaque so + the file-management plugin never sees a plugin-specific type. +- **Rendering** — `drawDocument(doc)` (the document's content in a tab/pane), + `drawDocumentInfobar(doc)`. +- **Per-frame** — `beginFrame`, `tickKeybinds`, `tickOpenDocuments`, … (the shell calls these + for every plugin each frame). +- **Contributions** — `contributeMenu`, `contributeKeybinds`. +- **Dialogs** — `requestNewDocumentDialog`, `requestGridLayoutDialog`, + `requestFlatRasterSaveWarning` (the shell dispatches; the plugin owns the dialog). + +A file-management plugin (workbench) implements none of the document hooks. An editor plugin +(pixelart) implements the document + rendering hooks but contributes no file tree. + +### Reaching the shell: `Globals` injection + +Plugin code can't import the shell, so the shell **injects pointers** into the plugin once at +startup (`Globals.gpa`, `Globals.host`, and the plugin's own `state`). Plugin code then uses +`Globals.host.` to read shell state (open folder, active doc, arena allocator) and +`Globals.state` for its own data. In a dynamic build the host pushes these across the library +boundary via the `fizzy_plugin_set_globals` C export. + +### Building as a dynamic library + +`dylib.zig` exports the C entry symbols the loader looks up (`src/sdk/dylib.zig`): + +- `fizzy_plugin_abi_version` → must equal the host's `dylib.abi_version` or the load is rejected. +- `fizzy_plugin_register(*Host)` → calls the plugin's `register`. +- `fizzy_plugin_set_globals` / `fizzy_plugin_set_dvui_context` → host injects allocator/state + and its live dvui context into the plugin image (host and plugin each compile their own + `dvui`/`sdk`/`core`; the host's pointers are pushed in before draw/tick each frame). + +Bump `abi_version` whenever the `Host`/`Plugin`/`DocHandle`/`EditorAPI` layouts or an entry +symbol's meaning change. + +--- + +## 3. How pixelart flows — and uses workbench + +**The crucial property: pixelart and workbench do not import each other.** They collaborate +entirely through the SDK. `grep` confirms zero cross-imports in either `src/` tree. + +### What each contributes + +`pixelart.register` (`src/plugins/pixelart/src/plugin.zig`): +- Claims its file types via the `fileTypePriority` vtable hook (`.fiz`, `.png`, …). +- `registerSidebarView` ×3 — **Tools**, **Sprites**, **Project**. (Project also sets + `draw_workspace`, letting it take over the center pane to show the packed atlas.) +- `registerBottomView` — the **Sprites** panel tab. +- `registerSettingsSection` — "Pixel Art". +- `registerFileRowFillColor` — a resolver the file tree calls to tint pixel-art file rows. +- Implements the document + rendering vtable hooks (load/save/undo/`drawDocument`/…). + +`workbench.register`: +- `registerSidebarView` — the **Files** tree. +- `registerCenterProvider` — owns the entire center region: the tabs/splits + canvas layout. +- `registerService("workbench", …)` — the file-management API (see below). + +### Opening and drawing a pixel-art document + +``` +user double-clicks foo.fiz in workbench's Files tree + │ + ▼ +host.pluginForExtension(".fiz") ──► pixelart (highest fileTypePriority) + │ + ▼ +pixelart.loadDocument(path) ──► builds its File, returns an opaque buffer + │ + ▼ +shell inserts DocHandle{ id, ptr=File, owner=pixelart } into Editor.open_files + │ + ▼ +workbench (center provider) draws a tab for it, and to render the body calls + doc.owner.drawDocument(doc) // Workspace.zig + │ + ▼ +pixelart draws its canvas inside the workbench tab/split +``` + +Every later action follows the same rule — the shell and workbench only ever call +`doc.owner.(doc)`. Save, dirty-dot, undo/redo, grouping, path, and the infobar status +all route to pixelart because it is the `owner`; workbench never knows it's a pixel-art file. +Reordering a tab is the one mutation of document order, done through `EditorAPI.swapDocs`. + +### The `workbench-api` service (inter-plugin file management) + +Workbench registers a service (`Workbench.Api`, key `"workbench"`) so any plugin can drive the +file explorer without importing workbench: + +```zig +const api: *Workbench.Api = @ptrCast(@alignCast(host.getService(Workbench.Api.service_name).?)); +_ = try api.open(path, api.currentGrouping()); // open a file into the focused tab group +``` + +Its vtable covers open/close/save, listing open docs by path/index (no plugin type crosses the +boundary), file-tree ops (create/rename/delete/move), and `registerBranchDecorator` for drawing +a per-row icon (the built-in "unsaved" dot is one). Pixelart doesn't need it today, but it's the +sanctioned way a second editor plugin would place documents into tabs and decorate file rows. + +### Why this is the model to copy + +A new editor plugin (e.g. textedit) drops in with **no shell or workbench changes**: register +its file types, implement the document + `drawDocument` hooks, and optionally contribute +sidebar/bottom/settings panes. Its documents then coexist in the same tabs/splits beside +pixel-art documents, because the whole system is keyed on `DocHandle.owner` and the Host +registries — not on any plugin knowing about another. + +--- + +### Key files + +| Path | Role | +|------|------| +| `src/sdk/sdk.zig` | SDK entry — re-exports everything below | +| `src/sdk/Host.zig` | Registries + service locator + `register*` methods | +| `src/sdk/Plugin.zig` | Plugin identity + the vtable of hooks | +| `src/sdk/DocHandle.zig` | Opaque document handle (`owner`-routed) | +| `src/sdk/EditorAPI.zig` | Shell read/utility surface plugins reach back through | +| `src/sdk/regions.zig` | Sidebar/bottom/center/menu/settings contribution structs | +| `src/sdk/dylib.zig`, `dvui_context.zig` | Runtime-library C entry contract + dvui injection | +| `src/plugins/pixelart/` | Reference editor plugin (owns documents, renders canvas) | +| `src/plugins/workbench/` | Reference file-management plugin (tree + tabs/splits + service) | +| `src/editor/Editor.zig` | The shell: frame loop, `postInit` plugin registration, dylib loading | diff --git a/src/App.zig b/src/App.zig index db20e8c2..ec80bcf2 100644 --- a/src/App.zig +++ b/src/App.zig @@ -168,7 +168,7 @@ pub fn AppInit(win: *dvui.Window) !void { fizzy.editor = try allocator.create(Editor); fizzy.editor.* = Editor.init(fizzy.app) catch unreachable; - // Workbench plugin runtime injection (Stage W): host + allocator, so workbench code + // Workbench plugin runtime injection: host + allocator, so workbench code // reaches the EditorAPI surface without importing `fizzy.zig`. Mirrors pixelart.Globals. WorkbenchGlobals.gpa = allocator; WorkbenchGlobals.host = &fizzy.editor.host; diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index b7881fda..71eb5936 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -47,8 +47,8 @@ pub const FileLoadJob = workbench_mod.FileLoadJob; pub const sdk = fizzy.sdk; pub const Host = sdk.Host; -/// Workbench (Phase 1): file-management home — currently the per-branch -/// decoration registry for the explorer; grows to own files + tabs/splits. +/// Workbench: the file-management home — file tree, open/load flow, and the +/// workspace/tabs/splits system, plus the per-branch explorer decoration registry. pub const Workbench = workbench_mod.Workbench; /// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels. @@ -69,7 +69,7 @@ pixelart_state: *pixelart.State, /// File-management workbench (per-branch explorer decorations, …) workbench: Workbench, -/// Keeps plugin dylibs mapped while their vtables are live (Phase 5b.3+; native only). +/// Keeps plugin dylibs mapped while their vtables are live (native only). loaded_plugin_libs: std.ArrayListUnmanaged(PluginLoader.LoadedLib) = .empty, settings: Settings = undefined, @@ -80,7 +80,6 @@ panel: *Panel, last_titlebar_color: dvui.Color, -/// Workspaces stored by their grouping ID (owned by `workbench`, Stage W2). sidebar: Sidebar, infobar: Infobar, @@ -485,7 +484,7 @@ pub fn pixelartPlugin(editor: *Editor) *sdk.Plugin { return editor.host.pluginById("pixelart") orelse @panic("pixelart plugin not registered"); } -/// Mechanism B: push host dvui state into every loaded plugin dylib image. +/// Push host dvui state into every loaded plugin dylib image. pub fn syncLoadedPluginDvuiContexts(editor: *Editor) void { if (comptime builtin.target.cpu.arch == .wasm32) return; for (editor.loaded_plugin_libs.items) |loaded| { @@ -513,8 +512,6 @@ pub fn syncLoadedWorkbenchGlobals(editor: *Editor) void { fn appendLoadedPluginLib(editor: *Editor, loaded: PluginLoader.LoadedLib) !void { try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded); - editor.host.plugin_set_globals = loaded.set_globals; - editor.host.plugin_set_dvui_context = loaded.set_dvui_context; } /// Load `{exe_dir}/plugins/libworkbench.*` and register via dylib entry. @@ -547,8 +544,6 @@ pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void { fn unloadPluginLibs(editor: *Editor) void { if (comptime builtin.target.cpu.arch == .wasm32) return; - editor.host.plugin_set_dvui_context = null; - editor.host.plugin_set_globals = null; for (editor.loaded_plugin_libs.items) |*entry| { entry.lib.close(); fizzy.app.allocator.free(entry.path); @@ -602,9 +597,9 @@ pub fn postInit(editor: *Editor) !void { .draw = drawSettingsPane, }); - // Menu bar contributions (non-macOS in-app bar). The draw code still lives in - // the shell's `Menu.zig`; Phase 3 moves the File/Edit bodies into the workbench - // / pixel-art plugins, which will then self-register. Order = bar order. + // Menu bar contributions (non-macOS in-app bar). The File/Edit draw bodies still live + // in the shell's `Menu.zig`; a later step could move them into the workbench / pixel-art + // plugins so those self-register. Order = bar order. try editor.host.registerMenu(.{ .id = "workbench.menu.file", .draw = Menu.drawFileMenu }); try editor.host.registerMenu(.{ .id = "pixelart.menu.edit", .draw = Menu.drawEditMenu }); try editor.host.registerMenu(.{ .id = "shell.menu.view", .draw = Menu.drawViewMenu }); diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig index 90c93a49..cd6df986 100644 --- a/src/editor/PluginLoader.zig +++ b/src/editor/PluginLoader.zig @@ -1,4 +1,4 @@ -//! Native runtime loader for Fizzy plugin dylibs (Phase 5b.3). +//! Native runtime loader for Fizzy plugin dylibs. //! //! Opens a prebuilt plugin library, checks the SDK ABI version, and calls //! `fizzy_plugin_register`. The returned `std.DynLib` must stay open for the diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig index 6b42e888..e84cada3 100644 --- a/src/plugins/pixelart/dylib.zig +++ b/src/plugins/pixelart/dylib.zig @@ -1,4 +1,4 @@ -//! Dynamic-library root for the pixel-art plugin (Phase 5b). +//! Dynamic-library root for the pixel-art plugin. //! //! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use //! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported. diff --git a/src/plugins/pixelart/module.zig b/src/plugins/pixelart/module.zig index 348139ab..a7e17fc2 100644 --- a/src/plugins/pixelart/module.zig +++ b/src/plugins/pixelart/module.zig @@ -1,4 +1,4 @@ -//! Pixel-art plugin compile-time module root (Phase 4 Stage D). +//! Pixel-art plugin compile-time module root. //! //! Wired in `build.zig` as `b.addModule("pixelart", .{ .root_source_file = "module.zig" })`. //! Shell code imports this as `@import("pixelart")`. Plugin files inside `src/` import diff --git a/src/plugins/pixelart/pixelart.zig b/src/plugins/pixelart/pixelart.zig index 7bb86e8c..817a67cb 100644 --- a/src/plugins/pixelart/pixelart.zig +++ b/src/plugins/pixelart/pixelart.zig @@ -1,4 +1,4 @@ -//! Intra-plugin import hub for the pixel-art plugin (Phase 4 Stage D). +//! Intra-plugin import hub for the pixel-art plugin. //! //! Files inside `src/plugins/pixelart/src/**` import this as `../pixelart.zig` (or //! `../../pixelart.zig` from nested dirs) instead of `fizzy.zig` for sdk/core/Globals diff --git a/src/plugins/pixelart/src/CanvasData.zig b/src/plugins/pixelart/src/CanvasData.zig index 2c13e4a9..0d869641 100644 --- a/src/plugins/pixelart/src/CanvasData.zig +++ b/src/plugins/pixelart/src/CanvasData.zig @@ -56,8 +56,8 @@ pub fn init(grouping: u64) CanvasData { } /// The drag names are intentionally not freed here: `init` may have fallen back to a static -/// string literal on (effectively impossible) OOM, and freeing a literal is UB. This matches -/// the pre-relocation behavior where the names lived on `Workspace` and were never freed. +/// string literal on (effectively impossible) OOM, and freeing a literal is UB. The names are +/// short-lived and never freed. pub fn deinit(_: *CanvasData) void {} /// Per-pane chrome for `grouping`, lazily allocated on first document draw. diff --git a/src/plugins/pixelart/src/Docs.zig b/src/plugins/pixelart/src/Docs.zig index 7ce735de..c40ec736 100644 --- a/src/plugins/pixelart/src/Docs.zig +++ b/src/plugins/pixelart/src/Docs.zig @@ -1,4 +1,4 @@ -//! Open-document registry for the pixel-art plugin (Phase 4 docs/tabs inversion). +//! Open-document registry for the pixel-art plugin. //! //! The shell stores opaque `DocHandle`s in `Editor.open_files`; this map owns the //! concrete `Internal.File` values their `ptr` fields point at. diff --git a/src/plugins/pixelart/src/Globals.zig b/src/plugins/pixelart/src/Globals.zig index e64ca5b2..924ae05e 100644 --- a/src/plugins/pixelart/src/Globals.zig +++ b/src/plugins/pixelart/src/Globals.zig @@ -1,4 +1,4 @@ -//! Runtime injection points for the pixel-art plugin (Phase 4 Stage D). +//! Runtime injection points for the pixel-art plugin. //! //! The shell sets these once during `App` startup so plugin code can reach the //! app allocator and singletons without importing `fizzy.zig`. @@ -14,7 +14,7 @@ pub fn allocator() std.mem.Allocator { return gpa; } -/// Mechanism B: host calls `fizzy_plugin_set_globals` on the dylib image before `register`. +/// For a loaded dylib build, the host calls `fizzy_plugin_set_globals` on the image before `register`. pub fn installRuntime( gpa_ptr: ?*const std.mem.Allocator, state_ptr: ?*State, diff --git a/src/plugins/pixelart/src/State.zig b/src/plugins/pixelart/src/State.zig index 79d6290b..039b1144 100644 --- a/src/plugins/pixelart/src/State.zig +++ b/src/plugins/pixelart/src/State.zig @@ -1,4 +1,4 @@ -//! Pixel-art plugin runtime state (Phase 4 Stage B/D). +//! Pixel-art plugin runtime state. //! //! Owns the pixel-art-specific editor state that used to live as top-level fields //! on `src/editor/Editor.zig`: the active tools, color/palette state, the open @@ -44,7 +44,7 @@ settings: Settings = .{}, tools: Tools, colors: Colors = .{}, -/// Explorer sidebar panes (lifted off the shell `Explorer` in Phase 4 Stage C). The "tools" +/// Explorer sidebar panes. The "tools" /// view (layers + palette) and the "sprites" view (animations/frames) are pixel-art-specific /// UI state; the shell only routes the registered sidebar view's `draw` to them. tools_pane: ToolsPane = .{}, diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig index 26db1cad..2b77a2df 100644 --- a/src/plugins/pixelart/src/plugin.zig +++ b/src/plugins/pixelart/src/plugin.zig @@ -1,7 +1,6 @@ -//! The pixel-art editor plugin. Phase 2 thin shim — the pixel-art stack still -//! lives inline under `src/editor/` (Phase 3 relocates it whole behind this -//! plugin). For now its contributions point at the existing draw entry points -//! through the `Globals` injection. Registered from `Editor.postInit`. +//! The pixel-art editor plugin: registration + draw entry points. Its contributions +//! reach the plugin's state through the `Globals` injection. Registered from +//! `Editor.postInit`. const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); @@ -126,9 +125,8 @@ fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 { return null; } -/// Load `path` into the shell-owned `*Internal.File` at `out_doc`. Runs on the shell's -/// load worker thread; `File.fromPath` is the pixel-art loader (still resident in the -/// editor tree, relocated whole into this plugin in Phase 3b/3c). +/// Load `path` into the plugin-owned `*Internal.File` at `out_doc`. Runs on the shell's +/// load worker thread; `File.fromPath` is the pixel-art loader. fn loadDocument(_: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void { // Web loads via bytes only (`loadDocumentFromBytes`); the comptime guard keeps the // disk-reading `File.fromPath` path (Dir.cwd / posix.AT) out of the wasm binary. @@ -172,8 +170,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { // Grid (column/row) reorder is driven by the rulers and consumed by `FileWidget`; commit // the pending reorder and clear the per-frame drag indices after the whole document (incl. - // the file widget) has drawn. Registered first so they run last, matching the order the - // workbench `Workspace.draw` used before this view was relocated here. + // the file widget) has drawn. Registered first so they run last. defer chrome.columns_drag_index = null; defer chrome.rows_drag_index = null; defer chrome.processColumnReorder(file); diff --git a/src/plugins/workbench/dylib.zig b/src/plugins/workbench/dylib.zig index e26529d6..552d9b46 100644 --- a/src/plugins/workbench/dylib.zig +++ b/src/plugins/workbench/dylib.zig @@ -1,4 +1,4 @@ -//! Dynamic-library root for the workbench plugin (Phase 5c). +//! Dynamic-library root for the workbench plugin. //! //! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use //! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported. diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig index dfae2380..af11cc62 100644 --- a/src/plugins/workbench/src/Globals.zig +++ b/src/plugins/workbench/src/Globals.zig @@ -1,4 +1,4 @@ -//! Runtime injection points for the workbench plugin (Stage W). +//! Runtime injection points for the workbench plugin. //! //! The shell sets these once during `App` startup so workbench code can reach the //! app allocator and the Host (EditorAPI surface) without importing `fizzy.zig`. @@ -16,7 +16,7 @@ pub fn allocator() std.mem.Allocator { return gpa; } -/// Mechanism B: host calls `fizzy_plugin_set_globals` on the dylib image before `register`. +/// For a loaded dylib build, the host calls `fizzy_plugin_set_globals` on the image before `register`. pub fn installRuntime( gpa_ptr: ?*const std.mem.Allocator, host_ptr: ?*sdk.Host, diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index 917ed2ec..0b5081c7 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -1,9 +1,8 @@ -//! The Workbench is the file-management home of the editor. Its module now owns -//! the file tree (`files.zig`), the open/load flow (`FileLoadJob.zig`), and the -//! workspace/tabs/splits system (`Workspace.zig`); in a later phase it becomes a -//! standalone plugin. It exposes its capabilities to other plugins through the -//! `workbench-api` Host service (`Workbench.Api`) so they never reach into the -//! `fizzy.editor` globals. +//! The Workbench is the file-management home of the editor. This plugin owns the +//! file tree (`files.zig`), the open/load flow (`FileLoadJob.zig`), and the +//! workspace/tabs/splits system (`Workspace.zig`). It exposes its capabilities to +//! other plugins through the `workbench-api` Host service (`Workbench.Api`) so they +//! never reach into the editor globals. //! //! Per-branch decorations let any plugin draw a right-justified icon on a file row //! (e.g. the built-in "unsaved" dot). Decorators run inside the row's hbox after @@ -30,7 +29,7 @@ pub const BranchDecorator = struct { allocator: std.mem.Allocator, decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty, -/// Workspaces keyed by tab-grouping id (Stage W2: owned here, not on the shell Editor). +/// Workspaces keyed by tab-grouping id (owned here, not on the shell Editor). workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, open_workspace_grouping: u64 = 0, grouping_id_counter: u64 = 0, diff --git a/src/plugins/workbench/src/workbench_layout.zig b/src/plugins/workbench/src/workbench_layout.zig index c785bce8..8d1104b0 100644 --- a/src/plugins/workbench/src/workbench_layout.zig +++ b/src/plugins/workbench/src/workbench_layout.zig @@ -1,4 +1,4 @@ -//! Workspace map maintenance + recursive split drawing (Stage W2). +//! Workspace map maintenance + recursive split drawing. const std = @import("std"); const dvui = @import("dvui"); const wbench = @import("../workbench.zig"); diff --git a/src/sdk/DocHandle.zig b/src/sdk/DocHandle.zig index edbc2e35..5af748ab 100644 --- a/src/sdk/DocHandle.zig +++ b/src/sdk/DocHandle.zig @@ -2,9 +2,6 @@ //! and never inspects `ptr` — it only routes operations to `owner` (the plugin //! that opened the document and knows how to render/save/undo it). For pixel art //! `ptr` is a `*pixelart.internal.File`; a text plugin would point it at its own type. -//! -//! Phase 0: defined but not yet produced/consumed anywhere (see the modular-editor -//! plan). Wired into the open/render/save path in Phase 3. const Plugin = @import("Plugin.zig"); pub const DocHandle = @This(); diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index d276a765..7f89686e 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -1,15 +1,10 @@ //! The services the shell exposes to plugins, and the registries it owns. Plugins -//! receive a `*Host` instead of reaching into editor globals. Today the Host is -//! embedded in `Editor`; as the shell shrinks (Phases 1-3) more of the editor's -//! responsibilities move behind it. -//! -//! Phase 0: holds the plugin registry + service locator. Nothing is registered -//! yet — the existing pixel-art code still uses globals directly. +//! receive a `*Host` instead of reaching into editor globals; it holds the plugin +//! registry, the shell region registries, and a service locator. The Host is +//! embedded in `Editor`. const std = @import("std"); const dvui = @import("dvui"); const Plugin = @import("Plugin.zig"); -const dvui_context = @import("dvui_context.zig"); -const dylib_api = @import("dylib.zig"); const regions = @import("regions.zig"); const EditorAPI = @import("EditorAPI.zig"); const DocHandle = @import("DocHandle.zig"); @@ -35,16 +30,9 @@ pub const FileRowFillColor = struct { color: *const fn (ctx: ?*anyopaque, color_index: usize) ?dvui.Color, }; -/// Mechanism B: setter from a loaded plugin dylib; null when all plugins are static. -/// Deprecated: prefer `Editor.syncLoadedPluginDvuiContexts` when multiple dylibs are loaded. -plugin_set_dvui_context: ?dvui_context.SetContextFn = null, -/// Host-owned Globals injection into a loaded plugin image (pixelart today). -/// Deprecated: prefer per-lib `LoadedLib.set_globals` when multiple dylibs are loaded. -plugin_set_globals: ?dylib_api.SetGlobalsFn = null, - allocator: std.mem.Allocator, -/// All registered plugins (static today; runtime-loaded dylibs in Phase 4). +/// All registered plugins (statically compiled in, or loaded from a runtime dylib). plugins: std.ArrayListUnmanaged(*Plugin) = .empty, /// Service locator for inter-plugin APIs: name -> opaque service vtable. E.g. the @@ -62,7 +50,7 @@ plugin_settings: PluginSettings = .empty, /// File-tree row fill tints (workbench asks the Host; editor plugins register). file_row_fill_colors: std.ArrayListUnmanaged(FileRowFillColor) = .empty, -// ---- shell region registries (Phase 2) ------------------------------------- +// ---- shell region registries ----------------------------------------------- // The shell iterates these instead of hardcoded enums/switches. Items keep their // registration order, which is the order they appear in the UI. @@ -112,36 +100,6 @@ pub fn installShell(self: *Host, api: EditorAPI) void { self.shell_api = api; } -/// Re-push host dvui pointers into the loaded plugin image. Call at the top of each -/// frame before plugin draw/tick (updates `current_window` every frame). -pub fn syncPluginDvuiContext(self: *Host) void { - const setter = self.plugin_set_dvui_context orelse return; - dvui_context.syncHostIntoPlugin(setter); -} - -/// Re-push host-owned pixelart Globals (`gpa`, `state`, `packer`) into the dylib. -pub fn syncPluginGlobals( - self: *Host, - gpa: *const std.mem.Allocator, - state: *anyopaque, - packer: ?*anyopaque, -) void { - const setter = self.plugin_set_globals orelse return; - setter(@ptrCast(gpa), state, packer); -} - -/// Wire a loaded plugin dylib's dvui globals to the host (Mechanism B). Called once -/// after `dlopen` + `fizzy_plugin_register`; also primes `io` / `ft2lib` / `debug`. -pub fn installPluginDylibHooks( - self: *Host, - set_globals: dylib_api.SetGlobalsFn, - set_dvui_context: dvui_context.SetContextFn, -) void { - self.plugin_set_globals = set_globals; - self.plugin_set_dvui_context = set_dvui_context; - dvui_context.syncHostIntoPlugin(set_dvui_context); -} - /// Per-frame arena allocator (reset every frame; do not free). Asserts the shell is installed. pub fn arena(self: *Host) std.mem.Allocator { return self.shell_api.?.arena(); @@ -532,7 +490,7 @@ pub fn activeCenter(self: *Host) ?*CenterProvider { } /// The registered plugin with the highest priority (lowest value) for `ext`, or -/// null if none claims it. Used in Phase 3 to route file opens to the right plugin. +/// null if none claims it. Routes file opens to the right plugin. pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin { var best: ?*Plugin = null; var best_priority: u8 = 255; @@ -550,7 +508,7 @@ pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin { /// Open a "new document" dialog. `parent_path` (when set) targets an on-disk folder; `id_extra` /// disambiguates launches from distinct explorer rows. Dispatches to the first plugin that /// provides a new-document dialog. -/// TODO(multi-plugin): with >1 editor plugin, present a typed "New > " chooser instead of +/// TODO: with more than one editor plugin, present a typed "New > " chooser instead of /// picking the first provider. pub fn requestNewDocument(self: *Host, parent_path: ?[]const u8, id_extra: usize) void { for (self.plugins.items) |plugin| { diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index a2cf5eaf..f5c8b9b5 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -95,7 +95,7 @@ pub const VTable = struct { /// Open the owner's "new document" dialog. Not doc-scoped — the host dispatches to a plugin /// that provides one (see `Host.requestNewDocument`). `parent_path` (when set) creates the /// document on disk in that folder; `id_extra` disambiguates per-explorer-row launches. - /// TODO(multi-plugin): with >1 editor plugin this becomes a typed "New > " chooser. + /// TODO: with more than one editor plugin this becomes a typed "New > " chooser. requestNewDocumentDialog: ?*const fn (state: *anyopaque, parent_path: ?[]const u8, id_extra: usize) void = null, /// Open the owner's grid-layout dialog for `doc` (pixel-art specific; the shell only /// resolves the active doc and dispatches here so it never names the plugin's dialog). diff --git a/src/sdk/dvui_context.zig b/src/sdk/dvui_context.zig index 37e92f0a..f13ad8f9 100644 --- a/src/sdk/dvui_context.zig +++ b/src/sdk/dvui_context.zig @@ -1,4 +1,4 @@ -//! Mechanism B: wire the plugin dylib's dvui globals to the host's live state. +//! Wire a loaded plugin dylib's dvui globals to the host's live state. //! //! Host and plugin each compile their own `dvui` copy; before plugin draw/tick the host //! calls the plugin's `fizzy_plugin_set_dvui_context` export (see `dylib.zig`). diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig index 3cc30203..7be08236 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -1,18 +1,18 @@ -//! Runtime dynamic-library contract for Fizzy plugins (Phase 5b). +//! Runtime dynamic-library contract for Fizzy plugins. //! -//! Host and plugin each compile their own copy of `dvui` + `sdk` + `core` (Mechanism B: -//! context injection — see `spikes/shared-globals/README.md`). Cross-boundary vtables use -//! normal Zig layouts pinned to the same Fizzy/SDK build. Only the `dlopen` entry symbols -//! below use C calling convention. +//! Host and plugin each compile their own copy of `dvui` + `sdk` + `core`; the host injects +//! its live dvui context into the plugin image (see `dvui_context.zig`). Cross-boundary +//! vtables use normal Zig layouts pinned to the same Fizzy/SDK build. Only the `dlopen` entry +//! symbols below use C calling convention. //! //! **Bump `abi_version` when any of these change:** `Host`, `Plugin`, `DocHandle`, //! `EditorAPI` layouts, or the semantics/signature of an entry symbol. pub const abi_version: u32 = 1; -/// `std.DynLib.lookup` names for the host loader (5b.3+). +/// `std.DynLib.lookup` names for the host loader. pub const symbol_abi_version = "fizzy_plugin_abi_version"; pub const symbol_register = "fizzy_plugin_register"; -/// Mechanism B — host calls each frame (and once at init) before plugin draw/tick. +/// Host calls each frame (and once at init) before plugin draw/tick. pub const symbol_set_dvui_context = "fizzy_plugin_set_dvui_context"; /// Host-owned pixelart `Globals` (allocator, state, packer) injected before `register`. pub const symbol_set_globals = "fizzy_plugin_set_globals"; diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index 10e3ff8e..355cc260 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -1,9 +1,9 @@ //! Fizzy plugin SDK — the surface a plugin module depends on. //! -//! Phase 0 of the modular-editor plan: type definitions + registries only. -//! Nothing routes through these yet; the shell still drives pixel art directly. -//! Subsequent phases move file management, the workspace/tabs system, and the -//! pixel-art editor behind this boundary, ending with runtime dylib loading. +//! A plugin receives a `*Host` and registers its menus, panes, document types, and +//! settings through these types instead of reaching into editor globals. File +//! management, the workspace/tabs system, and the editors (pixel art, …) all live +//! behind this boundary, which also supports loading plugins as runtime dylibs. pub const Host = @import("Host.zig"); pub const Plugin = @import("Plugin.zig"); pub const DocHandle = @import("DocHandle.zig"); @@ -30,5 +30,5 @@ pub const pane_layout = @import("pane_layout.zig"); /// Runtime dylib entry contract (`fizzy_plugin_abi_version` / `fizzy_plugin_register`). pub const dylib = @import("dylib.zig"); -/// Dvui global injection for loaded plugin images (Mechanism B). +/// Dvui global injection for loaded plugin images. pub const dvui_context = @import("dvui_context.zig"); From 95353f65324de84e94b05659d57ac5f5a3277629 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 09:59:30 -0500 Subject: [PATCH 40/49] fix dylib link --- build.zig.zon | 6 +- docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md | 299 +++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md diff --git a/build.zig.zon b/build.zig.zon index faf88e34..f6c3e553 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -27,9 +27,9 @@ .lazy = true, }, .dvui = .{ - .url = "https://github.com/foxnne/dvui-dev/archive/2f81423945d7076796023a7802f2680226dd9bd4.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmdw09wCp9ts4oaBV7Rkn7YuMKxDiaCLaweO-HPuS", - //.path = "../dvui-dev", + //.url = "https://github.com/foxnne/dvui-dev/archive/2f81423945d7076796023a7802f2680226dd9bd4.tar.gz", + //.hash = "dvui-0.5.0-dev-AQFJmdw09wCp9ts4oaBV7Rkn7YuMKxDiaCLaweO-HPuS", + .path = "../dvui-dev", }, .assetpack = .{ .url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz", diff --git a/docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md b/docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md new file mode 100644 index 00000000..c877c7cd --- /dev/null +++ b/docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md @@ -0,0 +1,299 @@ +# Handoff: plugin render bridge (keep SDL/GPU in the shell) + +> **Goal:** make Fizzy's runtime-loaded plugin **dylibs render correctly** without each one +> linking its own copy of SDL. Today every plugin dylib bakes in its own dvui SDL backend + +> its own SDL, which produces `SDL_RenderGeometryRaw ... Parameter 'renderer' is invalid` on +> every plugin draw (only shell-owned UI renders). The fix is a **forwarding/proxy dvui +> backend**: the plugin's dvui turns widgets into draw calls that are forwarded, through an +> injected C-ABI function table, to the **host's** real backend. SDL/GPU stay entirely in the +> shell; plugins link zero SDL. +> +> This work spans **two repos**: +> - **`dvui-dev`** (the `foxnne/dvui-dev` fork — checked out locally at `dev/dvui-dev`): add a +> `proxy` backend + expose a `dvui_proxy` module. **This is the part to do first.** +> - **`fizzy`**: define the bridge table, implement host-side thunks, inject the table into each +> loaded dylib, and switch plugin dylibs to import `dvui_proxy`. (Outlined here; do after dvui.) + +Until this lands, plugins work in **static** mode (`FIZZY_STATIC_WORKBENCH=1 +FIZZY_STATIC_PIXELART=1 ./fizzy`), where they share the shell's dvui/SDL directly. + +--- + +## 1. Why this is needed (root cause) + +dvui binds its backend **at compile time**. In `dvui-dev/src/Backend.zig`: + +```zig +const Implementation = @import("backend"); // chosen when the dvui module is built +impl: *Implementation, +pub fn drawClippedTriangles(self: Backend, ...) { try self.impl.drawClippedTriangles(...); } +``` + +`self.impl.drawClippedTriangles(...)` is a **static call** into whichever backend the dvui +module was compiled with. Fizzy builds each plugin dylib against `dvui_sdl3`, so the dylib +contains its own copy of dvui's SDL backend (`sdl.drawClippedTriangles`) **and statically links +SDL** (confirmed: `nm libworkbench.dylib` shows `_SDL_RenderGeometryRaw` defined in `__TEXT`). + +The host injects its live `current_window` into each plugin (see +`fizzy/src/sdk/dvui_context.zig`), so the plugin's dvui has the host's window — which holds the +host's SDL **renderer pointer**. But the *code* that consumes it is the plugin's own SDL backend +calling the plugin's own SDL. Passing the host's renderer handle to the plugin's separate SDL +runtime → "renderer is invalid", every frame, for every plugin draw. + +Static plugins render fine because they're compiled into the exe and share the one true SDL. + +**Conclusion:** plugins don't need SDL. They need a backend that converts dvui draw calls into +calls back to the host. That backend is the deliverable. + +--- + +## 2. Architecture + +``` + plugin dylib (its own dvui, NO SDL) host exe (the one real dvui + SDL) + ┌───────────────────────────────┐ ┌──────────────────────────────────┐ + │ widgets (textEntry, box, …) │ │ real dvui_sdl3 backend (SDL) │ + │ │ dvui immediate mode │ │ drawClippedTriangles → SDL │ + │ ▼ │ C-ABI │ textureCreate → SDL_Texture │ + │ proxy backend Implementation │ ─────────► │ … │ + │ drawClippedTriangles(...) ─── calls ───────►│ thunk → host_window.backend.draw… │ + │ textureCreate(...) ─── table ────────►│ thunk → host backend.textureCreate│ + └───────────────────────────────┘ (RenderBridge)└──────────────────────────────────┘ +``` + +- The plugin's dvui is compiled with a **`proxy` backend** instead of `sdl3`. +- The proxy backend's methods forward to a **`RenderBridge`** — a struct of + `*const fn(...) callconv(.c)` pointers the host fills in and injects into the plugin (exactly + like the existing `fizzy_plugin_set_dvui_context` mechanism). +- The host implements each bridge fn as a thin thunk over its **real** `dvui.Backend` + (the SDL one). All GPU/SDL state and calls stay in the host process's one SDL runtime. +- **Textures cross the boundary as opaque handles.** `dvui.Texture` is + `{ ptr: *anyopaque, width, height, interpolation }`; `ptr` is the host backend's texture + (e.g. `SDL_Texture*`). The proxy never interprets it — it just hands it back to the host on + `drawClippedTriangles`. `dvui.Texture`/`Texture.Target` layout is identical in host and plugin + because both compile the same dvui source. + +### Key design insight — the proxy backend is **stateless** + +The host injects its own `current_window` into the plugin, so the plugin's +`current_window.backend.impl` actually points at the **host's** backend instance, reinterpreted +through the plugin's `Implementation = ProxyBackend` type. That's fine **as long as the proxy's +methods never dereference `self`/the Context pointer** — they must forward to a **module-global +`RenderBridge`** set at injection time. Write every proxy method to ignore its receiver and use +the global table. (`begin`/`end`/`renderPresent` are driven by the host's dvui on the host's +window and generally won't be invoked from the plugin; implement them as no-ops or forwards.) + +--- + +## 3. Part 1 — Changes in `dvui-dev` (do this first) + +### 3a. Add the proxy backend: `src/backends/proxy.zig` + +**Template:** copy the structure of `src/backends/testing.zig` — it is a complete, non-SDL +backend that already implements the entire interface headlessly. The proxy is the same shape, +but its rendering/size/clipboard methods forward to the injected `RenderBridge` instead of +no-op/test-buffer behavior. + +The backend must implement **the same method set as `testing.zig`** (that set is authoritative — +it's every method `Backend.zig` calls on `self.impl`). For reference, the methods and how each +should behave in the proxy: + +| Method | Proxy behavior | +|--------|----------------| +| `pub const kind` | add a new `dvui.enums.Backend` tag, e.g. `.proxy` (see 3c) | +| `pub const Context = *ProxyBackend` | a tiny struct; methods ignore it (stateless) | +| `init` / `deinit` | trivial; `init` returns an empty `ProxyBackend` | +| **`drawClippedTriangles(texture, vtx, idx, clipr)`** | **forward to bridge** (the core render op) | +| **`textureCreate(pixels, opts) → Texture`** | **forward**; wrap returned host `ptr` in `dvui.Texture` | +| **`textureUpdateSubRect(texture, pixels, x,y,w,h)`** | **forward** | +| **`textureDestroy(texture)`** | **forward** | +| **`textureCreateTarget(opts) → TextureTarget`** | **forward** | +| **`textureReadTarget(target, pixels_out)`** | **forward** | +| **`textureDestroyTarget(target)`** | **forward** | +| **`textureFromTarget` / `textureFromTargetTemp` / `textureClearTarget`** | **forward** | +| **`renderTarget(?target)`** | **forward** | +| `pixelSize` / `windowSize` / `contentScale` | **forward** (host owns the window) | +| `clipboardText` / `clipboardTextSet` / `openURL` | **forward** (host owns the OS) | +| `setCursor` / `textInputRect` | forward or no-op (cosmetic) | +| `preferredColorScheme` / `prefersReducedMotion` | forward or sensible default | +| `nanoTime` / `sleep` | local is fine (`std.time`) — no need to forward | +| `begin` / `end` / `renderPresent` / `refresh` | no-op or forward; host drives the frame | +| `accessKitInitInBegin` / `accessKitShouldInitialize` / `native` | match `testing.zig` (likely off/no-op) | +| `backend(self) → dvui.Backend` | `return Backend.init(self)` (mirror testing) | + +> Confirm the exact list against the installed dvui by reading `testing.zig`'s `pub fn`s plus +> `grep -oE 'self\.impl\.[a-zA-Z_]+' src/Backend.zig`. If the interface gains/loses a method in a +> future dvui bump, the proxy must track it (a missing method is a compile error — good). + +### 3b. The `RenderBridge` table + +Define the C-ABI table the proxy forwards through. Put it where both the dvui backend and the +host can reference the **same definition** — simplest is a small file in the proxy backend, e.g. +`src/backends/proxy_bridge.zig`, exporting the struct type and a module-global setter: + +```zig +// src/backends/proxy_bridge.zig (illustrative — match real dvui types/signatures) +const dvui = @import("dvui"); + +pub const RenderBridge = extern struct { + ctx: ?*anyopaque, // host-side backend handle, passed back to every fn + + draw_clipped_triangles: *const fn (ctx: ?*anyopaque, texture_ptr: ?*anyopaque, + vtx: [*]const dvui.Vertex, vtx_len: usize, + idx: [*]const dvui.Vertex.Index, idx_len: usize, + clip: ?*const dvui.Rect.Physical) callconv(.c) void, + + texture_create: *const fn (ctx: ?*anyopaque, pixels: [*]const u8, + width: u32, height: u32, interpolation: u8) callconv(.c) ?*anyopaque, + texture_update_sub_rect: *const fn (ctx: ?*anyopaque, texture_ptr: ?*anyopaque, + pixels: [*]const u8, x: u32, y: u32, w: u32, h: u32) callconv(.c) void, + texture_destroy: *const fn (ctx: ?*anyopaque, texture_ptr: ?*anyopaque) callconv(.c) void, + + texture_create_target: *const fn (ctx: ?*anyopaque, width: u32, height: u32, + interpolation: u8) callconv(.c) ?*anyopaque, + texture_read_target: *const fn (ctx: ?*anyopaque, target_ptr: ?*anyopaque, + pixels_out: [*]u8) callconv(.c) bool, // false = error + texture_destroy_target: *const fn (ctx: ?*anyopaque, target_ptr: ?*anyopaque) callconv(.c) void, + render_target: *const fn (ctx: ?*anyopaque, target_ptr: ?*anyopaque) callconv(.c) void, + + pixel_size_w: ... , pixel_size_h: ... , // or one fn returning a small struct + // clipboard_text / clipboard_text_set / open_url / content_scale / window_size … as needed +}; + +/// Module-global, set once by the host via the dylib's C entry (see fizzy Part 2). +pub var bridge: ?*const RenderBridge = null; +``` + +Notes: +- Use plain `extern`/C-ABI scalar params (slices → `ptr,len`; enums → `u8`). The proxy methods + marshal dvui types into these calls. +- Texture handles: `dvui.Texture.ptr` ⇄ the host's `?*anyopaque`. `textureCreate` returns the + host pointer; the proxy builds `dvui.Texture{ .ptr = host_ptr, .width=…, .height=…, + .interpolation=… }`. `drawClippedTriangles`/destroy pass `texture.ptr` back. +- Error mapping: render ops that can fail (`textureCreate`, `textureReadTarget`) signal failure + via null/bool; the proxy converts to dvui's `TextureError`. + +### 3c. Register `.proxy` as a backend and expose a `dvui_proxy` module + +1. Add a `proxy` variant to the build `Backend` enum and to `dvui.enums.Backend` (mirror how + `testing`/`sdl3` are listed). +2. In `build.zig`'s `buildBackend`, add a `.proxy =>` arm that mirrors the **`.testing`** arm: + + ```zig + .proxy => { + dvui_opts.setDefaults(.{ .libc = true, .freetype = true, .stb_image = true, .tree_sitter = true }); + const proxy_mod = b.addModule("proxy", .{ + .root_source_file = b.path("src/backends/proxy.zig"), + .target = target, .optimize = optimize, + }); + const dvui_proxy = addDvuiModule("dvui_proxy", dvui_opts); + linkBackend(dvui_proxy, proxy_mod); // <-- the supported custom-backend hook + }, + ``` + `linkBackend(dvui_mod, backend_mod)` (build.zig:1002) does `dvui_mod.addImport("backend", backend_mod)` + — this is the *intended* extension point (`build.zig:375` even documents it). +3. Make sure the `dvui_proxy` and `proxy` modules are reachable to consumers via + `dvui_dep.module("dvui_proxy")` (and the bridge type, if it lives in `proxy_bridge.zig`, + via a module too). Crucially: the proxy backend **must not link SDL** — it links nothing + platform-specific (no `linkLibrary(SDL3)`), so a dylib built against `dvui_proxy` has **zero + SDL**. That's the whole point. + +**Acceptance for Part 1:** a throwaway exe/lib that imports `dvui_proxy` compiles and contains +**no** SDL symbols (`nm | grep SDL` → empty), and the proxy backend implements the full +`Implementation` interface (no missing-method compile errors when used as a dvui backend). + +--- + +## 4. Part 2 — Changes in `fizzy` (after dvui exposes `dvui_proxy`) + +1. **SDK bridge + injection symbol.** Mirror the existing dvui-context plumbing: + - `src/sdk/dvui_context.zig` already injects window/io/ft2lib/debug via the C export + `fizzy_plugin_set_dvui_context` (declared in `src/sdk/dylib.zig`, called from + `Editor.syncLoadedPluginDvuiContexts`). Add a sibling: a `fizzy_plugin_set_render_bridge` + C export (symbol name listed in `dylib.zig`, exported by each plugin's `dylib.zig`) that + stores the `*const RenderBridge` into the proxy backend's global `bridge`. + - The `RenderBridge` type comes from dvui's `proxy_bridge.zig` (single source of truth) — the + SDK and host reference the same type. + +2. **Host thunks.** In the shell, implement a `RenderBridge` whose `ctx` is the host and whose + fns call the host's real `dvui.Backend` (the SDL one for native). e.g. + `draw_clipped_triangles` → reconstruct slices/`Texture` and call + `host_window.backend.drawClippedTriangles(...)`. Build this once; the host's backend instance + is stable, so the bridge can be **injected once at load** (no per-frame push needed, unlike + `current_window`). + +3. **Inject at load.** In `Editor.loadWorkbenchDylib` / `loadPixelartDylib` (and the generic + loader), after `installRuntime`/`set_dvui_context`, look up and call the dylib's + `fizzy_plugin_set_render_bridge` with `&host_bridge`. Store nothing per-frame. + - `PluginLoader.LoadedLib` (in `src/editor/PluginLoader.zig`) currently holds `set_globals` + and `set_dvui_context`; add `set_render_bridge` alongside. + +4. **Build wiring.** Switch the **plugin dylib** modules from `dvui_sdl3` → `dvui_proxy`: + - In `build.zig`, `addWorkbenchDylib` / `addPixelartDylib` (and a future `addCodeDylib`) pass + `.dvui = dvui_dep.module("dvui_proxy")` instead of `dvui_sdl3`. The **static** module + wiring (`wireWorkbenchModule` etc., used for the in-exe fallback and web) keeps `dvui_sdl3` + / the normal dvui — only the **dylib** roots change. + - The dylib now links no SDL; keep `linker_allow_shlib_undefined = true` so the remaining + dvui/sdk/core symbols still resolve from the host at load. + - `core` also re-exports dvui (`core.dvui`); make sure the dylib's `core` is built against the + same `dvui_proxy` so there's one dvui flavor inside the dylib. + +5. **Texture/format sanity.** Confirm `dvui.Texture`/`Texture.Target`/`Vertex`/`Rect.Physical` + have identical layout in the host's `dvui_sdl3` and the plugin's `dvui_proxy` (same dvui + source + same relevant build options → they will, but the interpolation enum and any + `default_options` that affect struct layout must match). + +--- + +## 5. Verification + +- `nm zig-out//plugins/libworkbench.dylib | grep -i SDL` → **empty** (no SDL in the dylib). +- `otool -L` (macOS) on the dylib → no SDL; only libSystem/libobjc + `@rpath/...`. +- Run **dylib mode** (the default — no `FIZZY_STATIC_*`): the file tree, canvas, and pixel-art + panes render correctly (no `renderer is invalid` spam). +- Open a `.zig`/`.json` with the **code** plugin and a pixel-art file side by side; both render. +- `zig build test` still green (static/testing path unaffected). + +--- + +## 6. Reference (exact, from the pinned dvui) + +- dvui fork: `foxnne/dvui-dev`; pinned in `fizzy/build.zig.zon` (`dvui-0.5.0-dev-…`); vendored copy + for reading at `fizzy/zig-pkg/dvui-0.5.0-dev-AQFJmdw09w…/`. +- Backend interface & dispatch: `src/Backend.zig` (note `render_backend.kind == .default` → all + rendering goes through `self.impl`, i.e. the proxy). +- Complete backend template: `src/backends/testing.zig`. +- Custom-backend hook: `linkBackend(dvui_mod, backend_mod)` at `build.zig:1002`; usage documented + at `build.zig:375`; `.testing` arm (the pattern to copy) around `build.zig:395–417`. +- Types crossing the boundary: `src/Texture.zig` — `Texture { ptr: *anyopaque, width: u32, + height: u32, interpolation }`, `Texture.Target { ptr, width, height, interpolation }`, + `CreateOptions { width, height, interpolation = .linear }`; `dvui.Vertex`, `dvui.Vertex.Index`, + `dvui.Rect.Physical`. + +### Fizzy-side files to mirror/extend +| File | Role | +|------|------| +| `src/sdk/dylib.zig` | C entry symbol names + `abi_version` (bump it when adding `set_render_bridge`) | +| `src/sdk/dvui_context.zig` | existing per-image dvui injection — pattern to copy for the bridge | +| `src/plugins//dylib.zig` | each plugin's C exports (`fizzy_plugin_set_dvui_context`, …) — add the bridge setter | +| `src/editor/PluginLoader.zig` | `LoadedLib` (add `set_render_bridge`) + symbol lookup at load | +| `src/editor/Editor.zig` | `loadWorkbenchDylib`/`loadPixelartDylib`, `syncLoadedPluginDvuiContexts` | +| `build.zig` | `addWorkbenchDylib`/`addPixelartDylib` → switch dylib `dvui` dep to `dvui_proxy` | + +--- + +## 7. Notes / decisions for the implementer + +- **Do dvui Part 1 fully first** and prove "import `dvui_proxy` ⇒ no SDL symbols" before touching + fizzy. That de-risks the whole effort. +- **Stateless proxy is mandatory** (see §2 insight): methods must use the module-global bridge, + never `self`, because the injected `current_window.backend.impl` actually points at the host's + backend instance. +- **One SDL, in the host, forever** — this is also exactly what a **third-party** plugin needs: it + will import the Fizzy SDK + `dvui_proxy` and draw, never touching SDL/GPU libraries. +- Keep **static mode** working throughout (it's the fallback and the test path); only the dylib + build flavor changes. +- If a clean proxy backend proves hard to land quickly, a stopgap that *shares one SDL* (host + exports SDL; dylib built `-undefined dynamic_lookup` with SDL not statically linked, or a shared + `libSDL3.dylib`) would also fix rendering — but it keeps SDL in the plugin's build graph and is + worse for the third-party SDK story. The proxy backend is the real answer. From 0cec1f0262e777ebe446bbb656e62ddcd0fd9fe8 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 10:22:38 -0500 Subject: [PATCH 41/49] fix dylib part 2 --- build.zig | 76 ++++- src/editor/Editor.zig | 10 + src/editor/PluginLoader.zig | 8 + src/plugins/pixelart/dylib.zig | 4 + src/plugins/pixelart/src/Globals.zig | 6 +- .../pixelart/src/widgets/FileWidget.zig | 1 - src/plugins/workbench/dylib.zig | 4 + src/plugins/workbench/src/Globals.zig | 6 +- src/sdk/dylib.zig | 6 +- src/sdk/render_bridge.zig | 263 ++++++++++++++++++ src/sdk/sdk.zig | 2 + 11 files changed, 368 insertions(+), 18 deletions(-) create mode 100644 src/sdk/render_bridge.zig diff --git a/build.zig b/build.zig index 64296b79..49a2f6c1 100644 --- a/build.zig +++ b/build.zig @@ -300,6 +300,7 @@ pub fn build(b: *std.Build) !void { .backend = .web, .freetype = false, }); + const dvui_web_proxy_bridge = addProxyBridgeModule(b, web_target, optimize, dvui_web_dep, dvui_web_dep.module("dvui_web")); const web_exe = b.addExecutable(.{ .name = "web", @@ -370,7 +371,7 @@ pub fn build(b: *std.Build) !void { core_module_web.addImport("icons", dep.module("icons")); } web_exe.root_module.addImport("core", core_module_web); - const sdk_module_web = wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), web_exe.root_module); + const sdk_module_web = wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), dvui_web_proxy_bridge, web_exe.root_module); // Three editor files have `const sdl3 = @import("backend").c;` at file // scope. After refactoring all `sdl3.SDL_DialogFileFilter` references @@ -944,6 +945,7 @@ pub fn build(b: *std.Build) !void { .backend = .testing, .accesskit = accesskit, }); + const dvui_test_proxy_bridge = addProxyBridgeModule(b, target, optimize, dvui_testing_dep, dvui_testing_dep.module("dvui_testing")); // Build a module rooted at `src/fizzy.zig` carrying all the same // imports the production exe carries. Because fizzy.zig's transitive @@ -980,7 +982,7 @@ pub fn build(b: *std.Build) !void { core_module_test.addImport("icons", dep.module("icons")); } fizzy_test_module.addImport("core", core_module_test); - const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module); + const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), dvui_test_proxy_bridge, fizzy_test_module); _ = wirePixelartModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), .core = core_module_test, @@ -1293,6 +1295,16 @@ fn addFizzyExecutableForTarget( else b.dependency("dvui", .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit }); + const dvui_proxy_dep = b.dependency("dvui", .{ + .target = resolved_target, + .optimize = optimize, + .backend = .proxy, + .accesskit = .off, + }); + const dvui_proxy_mod = dvui_proxy_dep.module("dvui_proxy"); + const proxy_bridge_host_mod = addProxyBridgeModule(b, resolved_target, optimize, dvui_dep, dvui_dep.module("dvui_sdl3")); + const proxy_bridge_plugin_mod = dvui_proxy_dep.module("proxy_bridge"); + const zstbi_lib = b.addLibrary(.{ .name = "zstbi", .root_module = b.addModule("zstbi", .{ @@ -1364,13 +1376,25 @@ fn addFizzyExecutableForTarget( core_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); core_module.addImport("known-folders", known_folders); exe.root_module.addImport("core", core_module); - const sdk_module = wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module); + var icons_module: ?*std.Build.Module = null; if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| { exe.root_module.addImport("icons", dep.module("icons")); core_module.addImport("icons", dep.module("icons")); icons_module = dep.module("icons"); } + + const core_proxy_module = b.createModule(.{ + .target = resolved_target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + }); + core_proxy_module.addImport("dvui", dvui_proxy_mod); + core_proxy_module.addImport("known-folders", known_folders); + if (icons_module) |icons| core_proxy_module.addImport("icons", icons); + + const sdk_module = wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), proxy_bridge_host_mod, exe.root_module); + const sdk_proxy_module = wireSdkModule(b, resolved_target, optimize, dvui_proxy_mod, proxy_bridge_plugin_mod, null); _ = wirePixelartModule(b, resolved_target, optimize, .{ .dvui = dvui_dep.module("dvui_sdl3"), .core = core_module, @@ -1392,25 +1416,27 @@ fn addFizzyExecutableForTarget( const pixelart_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { break :blk addPixelartDylib(b, resolved_target, optimize, .{ - .dvui = dvui_dep.module("dvui_sdl3"), - .core = core_module, - .sdk = sdk_module, + .dvui = dvui_proxy_mod, + .core = core_proxy_module, + .sdk = sdk_proxy_module, + .proxy_bridge = proxy_bridge_plugin_mod, .assets = assets_module, .zip = zip_pkg.module, .zstbi = zstbi_module, .msf_gif = msf_gif_module, .icons = icons_module, - .backend = dvui_dep.module("sdl3"), + .backend = null, }); } else null; const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { break :blk addWorkbenchDylib(b, resolved_target, optimize, .{ - .dvui = dvui_dep.module("dvui_sdl3"), - .core = core_module, - .sdk = sdk_module, + .dvui = dvui_proxy_mod, + .core = core_proxy_module, + .sdk = sdk_proxy_module, + .proxy_bridge = proxy_bridge_plugin_mod, .icons = icons_module, - .backend = dvui_dep.module("sdl3"), + .backend = null, }); } else null; @@ -1480,12 +1506,29 @@ fn addFizzyExecutableForTarget( } /// Plugin SDK (`src/sdk/sdk.zig`). Depends only on `dvui`. +fn addProxyBridgeModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + dvui_dep: *std.Build.Dependency, + dvui_module: *std.Build.Module, +) *std.Build.Module { + const mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = dvui_dep.path("src/backends/proxy_bridge.zig"), + }); + mod.addImport("dvui", dvui_module); + return mod; +} + fn wireSdkModule( b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, dvui_module: *std.Build.Module, - consumer: *std.Build.Module, + proxy_bridge_module: *std.Build.Module, + consumer: ?*std.Build.Module, ) *std.Build.Module { const sdk_module = b.createModule(.{ .target = target, @@ -1493,7 +1536,8 @@ fn wireSdkModule( .root_source_file = b.path("src/sdk/sdk.zig"), }); sdk_module.addImport("dvui", dvui_module); - consumer.addImport("sdk", sdk_module); + sdk_module.addImport("proxy_bridge", proxy_bridge_module); + if (consumer) |c| c.addImport("sdk", sdk_module); return sdk_module; } @@ -1501,6 +1545,7 @@ const PixelartModuleDeps = struct { dvui: *std.Build.Module, core: *std.Build.Module, sdk: *std.Build.Module, + proxy_bridge: ?*std.Build.Module = null, assets: *std.Build.Module, zip: *std.Build.Module, zstbi: *std.Build.Module, @@ -1513,6 +1558,7 @@ const WorkbenchModuleDeps = struct { dvui: *std.Build.Module, core: *std.Build.Module, sdk: *std.Build.Module, + proxy_bridge: ?*std.Build.Module = null, icons: ?*std.Build.Module, backend: ?*std.Build.Module, }; @@ -1522,6 +1568,7 @@ fn applyWorkbenchModuleImports(module: *std.Build.Module, deps: WorkbenchModuleD module.addImport("dvui", deps.dvui); module.addImport("core", deps.core); module.addImport("sdk", deps.sdk); + if (deps.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); if (deps.icons) |icons| module.addImport("icons", icons); if (deps.backend) |backend| module.addImport("backend", backend); } @@ -1568,6 +1615,7 @@ fn addWorkbenchDylib( "fizzy_plugin_abi_version", "fizzy_plugin_register", "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_render_bridge", "fizzy_plugin_set_globals", }; return lib; @@ -1578,6 +1626,7 @@ fn applyPixelartModuleImports(module: *std.Build.Module, deps: PixelartModuleDep module.addImport("dvui", deps.dvui); module.addImport("core", deps.core); module.addImport("sdk", deps.sdk); + if (deps.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); module.addImport("assets", deps.assets); module.addImport("zip", deps.zip); module.addImport("zstbi", deps.zstbi); @@ -1611,6 +1660,7 @@ fn addPixelartDylib( "fizzy_plugin_abi_version", "fizzy_plugin_register", "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_render_bridge", "fizzy_plugin_set_globals", }; return lib; diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 71eb5936..d2d8861f 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -492,6 +492,14 @@ pub fn syncLoadedPluginDvuiContexts(editor: *Editor) void { } } +/// Inject the host render bridge into every loaded plugin dylib (proxy backend). +pub fn syncLoadedPluginRenderBridge(editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + for (editor.loaded_plugin_libs.items) |loaded| { + sdk.render_bridge.syncHostIntoPlugin(loaded.set_render_bridge); + } +} + fn syncLoadedPluginGlobals(editor: *Editor, plugin_id: []const u8, arg_b: *anyopaque, arg_c: ?*anyopaque) void { if (comptime builtin.target.cpu.arch == .wasm32) return; for (editor.loaded_plugin_libs.items) |loaded| { @@ -526,6 +534,7 @@ pub fn loadWorkbenchDylib(editor: *Editor, exe_dir: []const u8) !void { }); try appendLoadedPluginLib(editor, loaded); syncLoadedPluginDvuiContexts(editor); + syncLoadedPluginRenderBridge(editor); } /// Load `{exe_dir}/plugins/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry. @@ -540,6 +549,7 @@ pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void { }); try appendLoadedPluginLib(editor, loaded); syncLoadedPluginDvuiContexts(editor); + syncLoadedPluginRenderBridge(editor); } fn unloadPluginLibs(editor: *Editor) void { diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig index cd6df986..9afc7645 100644 --- a/src/editor/PluginLoader.zig +++ b/src/editor/PluginLoader.zig @@ -19,6 +19,7 @@ pub const LoadError = error{ RegisterSymbolMissing, SetGlobalsSymbolMissing, SetDvuiContextSymbolMissing, + SetRenderBridgeSymbolMissing, AbiMismatch, RegisterRejected, }; @@ -30,6 +31,7 @@ pub const LoadedLib = struct { plugin_id: []const u8, set_globals: dylib_api.SetGlobalsFn, set_dvui_context: dvui_context.SetContextFn, + set_render_bridge: sdk.render_bridge.SetRenderBridgeFn, }; /// Host-owned pointers injected into the plugin image immediately before `register`. @@ -106,6 +108,11 @@ pub fn loadAndRegister( dylib_api.symbol_set_dvui_context, ) orelse return error.SetDvuiContextSymbolMissing; + const set_bridge = lib.lookup( + sdk.render_bridge.SetRenderBridgeFn, + dylib_api.symbol_set_render_bridge, + ) orelse return error.SetRenderBridgeSymbolMissing; + if (pre) |inject| { set_globals( if (inject.gpa) |gpa| @ptrCast(gpa) else null, @@ -127,6 +134,7 @@ pub fn loadAndRegister( .plugin_id = plugin_id, .set_globals = set_globals, .set_dvui_context = set_ctx, + .set_render_bridge = set_bridge, }; } diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig index e84cada3..a09430aa 100644 --- a/src/plugins/pixelart/dylib.zig +++ b/src/plugins/pixelart/dylib.zig @@ -25,6 +25,10 @@ export fn fizzy_plugin_set_dvui_context( sdk.dvui_context.inject(window, io, ft2lib, debug); } +export fn fizzy_plugin_set_render_bridge(bridge: ?*const @import("proxy_bridge").RenderBridge) callconv(.c) void { + @import("proxy_bridge").setBridge(bridge); +} + export fn fizzy_plugin_set_globals( gpa: ?*const anyopaque, state: ?*anyopaque, diff --git a/src/plugins/pixelart/src/Globals.zig b/src/plugins/pixelart/src/Globals.zig index 924ae05e..3c4de8a8 100644 --- a/src/plugins/pixelart/src/Globals.zig +++ b/src/plugins/pixelart/src/Globals.zig @@ -5,6 +5,7 @@ const std = @import("std"); const State = @import("State.zig"); const Packer = @import("Packer.zig"); +const core = @import("core"); pub var gpa: std.mem.Allocator = undefined; pub var state: *State = undefined; @@ -20,7 +21,10 @@ pub fn installRuntime( state_ptr: ?*State, packer_ptr: ?*Packer, ) void { - if (gpa_ptr) |a| gpa = a.*; + if (gpa_ptr) |a| { + gpa = a.*; + core.gpa = a.*; + } if (state_ptr) |s| state = s; if (packer_ptr) |p| packer = p; } diff --git a/src/plugins/pixelart/src/widgets/FileWidget.zig b/src/plugins/pixelart/src/widgets/FileWidget.zig index 6aa49d85..af56d986 100644 --- a/src/plugins/pixelart/src/widgets/FileWidget.zig +++ b/src/plugins/pixelart/src/widgets/FileWidget.zig @@ -2,7 +2,6 @@ const std = @import("std"); const math = std.math; const dvui = @import("dvui"); const builtin = @import("builtin"); -const sdl3 = @import("backend").c; const Options = dvui.Options; const Rect = dvui.Rect; diff --git a/src/plugins/workbench/dylib.zig b/src/plugins/workbench/dylib.zig index 552d9b46..da517a17 100644 --- a/src/plugins/workbench/dylib.zig +++ b/src/plugins/workbench/dylib.zig @@ -25,6 +25,10 @@ export fn fizzy_plugin_set_dvui_context( sdk.dvui_context.inject(window, io, ft2lib, debug); } +export fn fizzy_plugin_set_render_bridge(bridge: ?*const @import("proxy_bridge").RenderBridge) callconv(.c) void { + @import("proxy_bridge").setBridge(bridge); +} + /// Workbench convention: `gpa`, `host`, `workbench` (see `Globals.installRuntime`). export fn fizzy_plugin_set_globals( gpa: ?*const anyopaque, diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig index af11cc62..77353152 100644 --- a/src/plugins/workbench/src/Globals.zig +++ b/src/plugins/workbench/src/Globals.zig @@ -7,6 +7,7 @@ const std = @import("std"); const wb_mod = @import("../workbench.zig"); const sdk = wb_mod.sdk; const Workbench = @import("Workbench.zig"); +const core = @import("core"); pub var gpa: std.mem.Allocator = undefined; pub var host: *sdk.Host = undefined; @@ -22,7 +23,10 @@ pub fn installRuntime( host_ptr: ?*sdk.Host, workbench_ptr: ?*Workbench, ) void { - if (gpa_ptr) |a| gpa = a.*; + if (gpa_ptr) |a| { + gpa = a.*; + core.gpa = a.*; + } if (host_ptr) |h| host = h; if (workbench_ptr) |w| workbench = w; } diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig index 7be08236..a5ae9f2f 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -7,13 +7,15 @@ //! //! **Bump `abi_version` when any of these change:** `Host`, `Plugin`, `DocHandle`, //! `EditorAPI` layouts, or the semantics/signature of an entry symbol. -pub const abi_version: u32 = 1; +pub const abi_version: u32 = 2; /// `std.DynLib.lookup` names for the host loader. pub const symbol_abi_version = "fizzy_plugin_abi_version"; pub const symbol_register = "fizzy_plugin_register"; /// Host calls each frame (and once at init) before plugin draw/tick. pub const symbol_set_dvui_context = "fizzy_plugin_set_dvui_context"; +/// Host calls once at load so plugin proxy backend forwards draws to the shell SDL backend. +pub const symbol_set_render_bridge = "fizzy_plugin_set_render_bridge"; /// Host-owned pixelart `Globals` (allocator, state, packer) injected before `register`. pub const symbol_set_globals = "fizzy_plugin_set_globals"; @@ -39,7 +41,7 @@ pub fn abiMatches(plugin_abi: u32) bool { test "plugin ABI version is locked" { const std = @import("std"); - try std.testing.expect(abi_version == 1); + try std.testing.expect(abi_version == 2); try std.testing.expect(abiMatches(abi_version)); try std.testing.expect(!abiMatches(abi_version + 1)); } diff --git a/src/sdk/render_bridge.zig b/src/sdk/render_bridge.zig new file mode 100644 index 00000000..f552d424 --- /dev/null +++ b/src/sdk/render_bridge.zig @@ -0,0 +1,263 @@ +//! Host-side thunks for the dvui proxy render bridge. +//! +//! Loaded plugin dylibs draw through `proxy_bridge.RenderBridge` into the shell's real +//! SDL backend. `ctx` is the host `dvui.Window` pointer (stable for the session). +const std = @import("std"); +const dvui = @import("dvui"); +const proxy_bridge = @import("proxy_bridge"); + +pub const SetRenderBridgeFn = *const fn (?*const proxy_bridge.RenderBridge) callconv(.c) void; + +var table: proxy_bridge.RenderBridge = undefined; +var table_ready = false; + +fn emptyTextureDesc() proxy_bridge.TextureDesc { + return std.mem.zeroes(proxy_bridge.TextureDesc); +} + +fn windowFromCtx(ctx: ?*anyopaque) *dvui.Window { + return @ptrCast(@alignCast(ctx orelse @panic("render bridge ctx is null"))); +} + +fn textureFromDesc(desc: *const proxy_bridge.TextureDesc) !dvui.Texture { + return proxy_bridge.textureFromDesc(desc.*); +} + +fn targetFromDesc(desc: *const proxy_bridge.TextureDesc) !dvui.TextureTarget { + return proxy_bridge.targetFromDesc(desc.*); +} + +fn clipFromDesc(has_clip: u8, clip: proxy_bridge.ClipRect) ?dvui.Rect.Physical { + if (has_clip == 0) return null; + return .{ .x = clip.x, .y = clip.y, .w = clip.w, .h = clip.h }; +} + +fn drawClippedTriangles( + ctx: ?*anyopaque, + texture: ?*const proxy_bridge.TextureDesc, + vtx: [*]const dvui.Vertex, + vtx_len: usize, + idx: [*]const dvui.Vertex.Index, + idx_len: usize, + has_clip: u8, + clip: proxy_bridge.ClipRect, +) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex: ?dvui.Texture = if (texture) |desc| textureFromDesc(desc) catch return 0 else null; + win.backend.drawClippedTriangles( + tex, + vtx[0..vtx_len], + idx[0..idx_len], + clipFromDesc(has_clip, clip), + ) catch return 0; + return 1; +} + +fn textureCreate( + ctx: ?*anyopaque, + pixels: [*]const u8, + options: proxy_bridge.CreateOptions, +) callconv(.c) proxy_bridge.TextureDesc { + const win = windowFromCtx(ctx); + const created = win.backend.textureCreate(pixels, .{ + .width = options.width, + .height = options.height, + .format = @enumFromInt(options.format), + .interpolation = @enumFromInt(options.interpolation), + .wrap_u = @enumFromInt(options.wrap_u), + .wrap_v = @enumFromInt(options.wrap_v), + }) catch return emptyTextureDesc(); + return proxy_bridge.textureDescFrom(created); +} + +fn textureUpdate( + ctx: ?*anyopaque, + texture: *const proxy_bridge.TextureDesc, + pixels: [*]const u8, +) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex = textureFromDesc(texture) catch return 0; + win.backend.textureUpdate(tex, pixels) catch return 0; + return 1; +} + +fn textureUpdateSubRect( + ctx: ?*anyopaque, + texture: *const proxy_bridge.TextureDesc, + pixels: [*]const u8, + x: u32, + y: u32, + w: u32, + h: u32, +) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex = textureFromDesc(texture) catch return 0; + win.backend.textureUpdateSubRect(tex, pixels, x, y, w, h) catch return 0; + return 1; +} + +fn textureDestroy(ctx: ?*anyopaque, texture: *const proxy_bridge.TextureDesc) callconv(.c) void { + const win = windowFromCtx(ctx); + const tex = textureFromDesc(texture) catch return; + win.backend.textureDestroy(tex); +} + +fn textureCreateTarget(ctx: ?*anyopaque, options: proxy_bridge.CreateOptions) callconv(.c) proxy_bridge.TextureDesc { + const win = windowFromCtx(ctx); + const target = win.backend.textureCreateTarget(.{ + .width = options.width, + .height = options.height, + .format = @enumFromInt(options.format), + .interpolation = @enumFromInt(options.interpolation), + .wrap_u = @enumFromInt(options.wrap_u), + .wrap_v = @enumFromInt(options.wrap_v), + }) catch return emptyTextureDesc(); + return proxy_bridge.textureDescFromTarget(target); +} + +fn textureReadTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc, pixels_out: [*]u8) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return 0; + win.backend.textureReadTarget(tex_target, pixels_out) catch return 0; + return 1; +} + +fn textureDestroyTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) void { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return; + win.backend.textureDestroyTarget(tex_target); +} + +fn textureClearTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) void { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return; + win.backend.textureClearTarget(tex_target); +} + +fn textureFromTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) proxy_bridge.TextureDesc { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return emptyTextureDesc(); + const tex = win.backend.textureFromTarget(tex_target) catch return emptyTextureDesc(); + return proxy_bridge.textureDescFrom(tex); +} + +fn textureFromTargetTemp(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) proxy_bridge.TextureDesc { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return emptyTextureDesc(); + const tex = win.backend.textureFromTargetTemp(tex_target) catch return emptyTextureDesc(); + return proxy_bridge.textureDescFrom(tex); +} + +fn renderTarget(ctx: ?*anyopaque, target: ?*const proxy_bridge.TextureDesc) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex_target: ?dvui.TextureTarget = if (target) |desc| targetFromDesc(desc) catch return 0 else null; + win.backend.renderTarget(tex_target) catch return 0; + return 1; +} + +fn pixelSize(ctx: ?*anyopaque) callconv(.c) proxy_bridge.SizePair { + const win = windowFromCtx(ctx); + const size = win.backend.pixelSize(); + return .{ .w = size.w, .h = size.h }; +} + +fn windowSize(ctx: ?*anyopaque) callconv(.c) proxy_bridge.SizePair { + const win = windowFromCtx(ctx); + const size = win.backend.windowSize(); + return .{ .w = size.w, .h = size.h }; +} + +fn contentScale(ctx: ?*anyopaque) callconv(.c) f32 { + const win = windowFromCtx(ctx); + return win.backend.contentScale(); +} + +threadlocal var clipboard_scratch: [8192]u8 = undefined; + +fn clipboardText(ctx: ?*anyopaque) callconv(.c) proxy_bridge.TextSlice { + const win = windowFromCtx(ctx); + const text = win.backend.clipboardText() catch return .{ .ptr = &.{}, .len = 0 }; + const len = @min(text.len, clipboard_scratch.len); + @memcpy(clipboard_scratch[0..len], text[0..len]); + return .{ .ptr = clipboard_scratch[0..len].ptr, .len = len }; +} + +fn clipboardTextSet(ctx: ?*anyopaque, text: [*]const u8, text_len: usize) callconv(.c) u8 { + const win = windowFromCtx(ctx); + win.backend.clipboardTextSet(text[0..text_len]) catch return 0; + return 1; +} + +fn openURL(ctx: ?*anyopaque, url: [*]const u8, url_len: usize, new_window: u8) callconv(.c) u8 { + const win = windowFromCtx(ctx); + win.backend.openURL(url[0..url_len], new_window != 0) catch return 0; + return 1; +} + +fn setCursor(ctx: ?*anyopaque, cursor: u8) callconv(.c) void { + const win = windowFromCtx(ctx); + win.backend.setCursor(@enumFromInt(cursor)); +} + +fn textInputRect(ctx: ?*anyopaque, has_rect: u8, rect: proxy_bridge.ClipRect) callconv(.c) void { + const win = windowFromCtx(ctx); + const natural: ?dvui.Rect.Natural = if (has_rect != 0) + .{ .x = rect.x, .y = rect.y, .w = rect.w, .h = rect.h } + else + null; + win.backend.textInputRect(natural); +} + +fn preferredColorScheme(ctx: ?*anyopaque) callconv(.c) i8 { + const win = windowFromCtx(ctx); + const scheme = win.backend.preferredColorScheme(); + if (scheme) |s| { + return switch (s) { + .light => 0, + .dark => 1, + }; + } + return -1; +} + +fn prefersReducedMotion(ctx: ?*anyopaque) callconv(.c) u8 { + const win = windowFromCtx(ctx); + return @intFromBool(win.backend.prefersReducedMotion()); +} + +fn ensureTable() void { + if (table_ready) return; + table = .{ + .ctx = null, + .draw_clipped_triangles = drawClippedTriangles, + .texture_create = textureCreate, + .texture_update = textureUpdate, + .texture_update_sub_rect = textureUpdateSubRect, + .texture_destroy = textureDestroy, + .texture_create_target = textureCreateTarget, + .texture_read_target = textureReadTarget, + .texture_destroy_target = textureDestroyTarget, + .texture_clear_target = textureClearTarget, + .texture_from_target = textureFromTarget, + .texture_from_target_temp = textureFromTargetTemp, + .render_target = renderTarget, + .pixel_size = pixelSize, + .window_size = windowSize, + .content_scale = contentScale, + .clipboard_text = clipboardText, + .clipboard_text_set = clipboardTextSet, + .open_url = openURL, + .set_cursor = setCursor, + .text_input_rect = textInputRect, + .preferred_color_scheme = preferredColorScheme, + .prefers_reduced_motion = prefersReducedMotion, + }; + table_ready = true; +} + +/// Push the host render bridge table into a loaded plugin dylib (once at load). +pub fn syncHostIntoPlugin(setter: SetRenderBridgeFn) void { + ensureTable(); + table.ctx = @ptrCast(dvui.current_window); + setter(&table); +} diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index 355cc260..302e70e4 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -32,3 +32,5 @@ pub const pane_layout = @import("pane_layout.zig"); pub const dylib = @import("dylib.zig"); /// Dvui global injection for loaded plugin images. pub const dvui_context = @import("dvui_context.zig"); +/// Host thunks that forward plugin proxy draws to the shell backend. +pub const render_bridge = @import("render_bridge.zig"); From ed6a5a83faadf1ea921c0f8570ae157cc6218e12 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 09:11:16 -0500 Subject: [PATCH 42/49] Begin adding code plugin --- build.zig | 46 +++++++ src/App.zig | 8 ++ src/editor/Editor.zig | 6 + src/plugins/code/code.zig | 13 ++ src/plugins/code/dylib.zig | 40 ++++++ src/plugins/code/module.zig | 10 ++ src/plugins/code/src/Document.zig | 64 ++++++++++ src/plugins/code/src/Globals.zig | 28 ++++ src/plugins/code/src/State.zig | 32 +++++ src/plugins/code/src/plugin.zig | 206 ++++++++++++++++++++++++++++++ 10 files changed, 453 insertions(+) create mode 100644 src/plugins/code/code.zig create mode 100644 src/plugins/code/dylib.zig create mode 100644 src/plugins/code/module.zig create mode 100644 src/plugins/code/src/Document.zig create mode 100644 src/plugins/code/src/Globals.zig create mode 100644 src/plugins/code/src/State.zig create mode 100644 src/plugins/code/src/plugin.zig diff --git a/build.zig b/build.zig index 49a2f6c1..feda858c 100644 --- a/build.zig +++ b/build.zig @@ -443,6 +443,11 @@ pub fn build(b: *std.Build) !void { .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null, .backend = null, }, web_exe.root_module); + wireCodeModule(b, web_target, optimize, .{ + .dvui = dvui_web_dep.module("dvui_web"), + .core = core_module_web, + .sdk = sdk_module_web, + }, web_exe.root_module); const web_install_dir: std.Build.InstallDir = .{ .custom = "web" }; const install_wasm = b.addInstallArtifact(web_exe, .{ @@ -1001,6 +1006,11 @@ pub fn build(b: *std.Build) !void { .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null, .backend = dvui_testing_dep.module("testing"), }, fizzy_test_module); + wireCodeModule(b, target, optimize, .{ + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + }, fizzy_test_module); if (target.result.os.tag == .macos) { if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| { @@ -1413,6 +1423,11 @@ fn addFizzyExecutableForTarget( .icons = icons_module, .backend = dvui_dep.module("sdl3"), }, exe.root_module); + wireCodeModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + }, exe.root_module); const pixelart_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { break :blk addPixelartDylib(b, resolved_target, optimize, .{ @@ -1591,6 +1606,37 @@ fn wireWorkbenchModule( consumer.addImport("workbench", workbench_module); } +const CodeModuleDeps = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, +}; + +/// Code plugin (`src/plugins/code/module.zig`). +fn applyCodeModuleImports(module: *std.Build.Module, deps: CodeModuleDeps) void { + module.addImport("dvui", deps.dvui); + module.addImport("core", deps.core); + module.addImport("sdk", deps.sdk); +} + +fn wireCodeModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + deps: CodeModuleDeps, + consumer: *std.Build.Module, +) void { + const code_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/plugins/code/module.zig"), + .link_libc = target.result.cpu.arch != .wasm32, + .single_threaded = target.result.cpu.arch == .wasm32, + }); + applyCodeModuleImports(code_module, deps); + consumer.addImport("code", code_module); +} + /// Native dynamic library for the workbench plugin (`src/plugins/workbench/dylib.zig`). fn addWorkbenchDylib( b: *std.Build, diff --git a/src/App.zig b/src/App.zig index ec80bcf2..8782c644 100644 --- a/src/App.zig +++ b/src/App.zig @@ -10,7 +10,9 @@ const icon = assets.files.@"icon.png"; const fizzy = @import("fizzy.zig"); const workbench = @import("workbench"); const pixelart = @import("pixelart"); +const code = @import("code"); const WorkbenchGlobals = workbench.Globals; +const CodeGlobals = code.Globals; const auto_update = @import("backend/auto_update.zig"); const update_notify = @import("backend/update_notify.zig"); const singleton = @import("backend/singleton.zig"); @@ -174,6 +176,12 @@ pub fn AppInit(win: *dvui.Window) !void { WorkbenchGlobals.host = &fizzy.editor.host; WorkbenchGlobals.workbench = &fizzy.editor.workbench; + // Code plugin runtime injection: host + allocator + its open-document registry, + // which lives on `Editor.code`. The plugin's `register` adopts it as its `state`. + CodeGlobals.gpa = allocator; + CodeGlobals.host = &fizzy.editor.host; + CodeGlobals.state = &fizzy.editor.code; + // Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created // before `postInit` so the pixel-art plugin's `register` can adopt it as its // `state`. Owned on `Editor`; torn down in `AppDeinit`. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index d2d8861f..59e0110c 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -30,6 +30,7 @@ pub const Dialogs = @import("dialogs/Dialogs.zig"); pub const Keybinds = @import("Keybinds.zig"); const workbench_mod = @import("workbench"); +const code_mod = @import("code"); const PluginLoader = if (builtin.target.cpu.arch == .wasm32) @import("PluginLoader_stub.zig") else @@ -69,6 +70,10 @@ pixelart_state: *pixelart.State, /// File-management workbench (per-branch explorer decorations, …) workbench: Workbench, +/// Code plugin runtime state (open text documents). Owned here; `code.Globals.state` +/// points at it. Torn down via the plugin's `deinit` vtable hook. +code: code_mod.State = .{}, + /// Keeps plugin dylibs mapped while their vtables are live (native only). loaded_plugin_libs: std.ArrayListUnmanaged(PluginLoader.LoadedLib) = .empty, @@ -598,6 +603,7 @@ pub fn postInit(editor: *Editor) !void { try pixelart.plugin.register(&editor.host); try pixelartPlugin(editor).initPlugin(); } + try code_mod.plugin.register(&editor.host); // Shell built-in: Settings (owner = null; not a plugin). try editor.host.registerSidebarView(.{ diff --git a/src/plugins/code/code.zig b/src/plugins/code/code.zig new file mode 100644 index 00000000..a753e49a --- /dev/null +++ b/src/plugins/code/code.zig @@ -0,0 +1,13 @@ +//! Intra-plugin import hub for the code plugin. +//! +//! Files inside `src/plugins/code/src/**` import this as `../code.zig` (or +//! `../../code.zig` from nested dirs). The compile-time module root is `module.zig`. +const std = @import("std"); + +pub const sdk = @import("sdk"); +pub const core = @import("core"); +pub const dvui = @import("dvui"); + +pub const Globals = @import("src/Globals.zig"); +pub const State = @import("src/State.zig"); +pub const Document = @import("src/Document.zig"); diff --git a/src/plugins/code/dylib.zig b/src/plugins/code/dylib.zig new file mode 100644 index 00000000..99691a33 --- /dev/null +++ b/src/plugins/code/dylib.zig @@ -0,0 +1,40 @@ +//! Dynamic-library root for the code plugin. +//! +//! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use +//! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported. +const sdk = @import("sdk"); +const dvui = @import("dvui"); +const plugin = @import("src/plugin.zig"); + +export fn fizzy_plugin_abi_version() callconv(.c) u32 { + return sdk.dylib.abi_version; +} + +export fn fizzy_plugin_register(host: ?*sdk.Host) callconv(.c) u32 { + if (host == null) return @intFromEnum(sdk.dylib.RegisterStatus.err_null_host); + plugin.register(host.?) catch return @intFromEnum(sdk.dylib.RegisterStatus.err_register); + return @intFromEnum(sdk.dylib.RegisterStatus.ok); +} + +export fn fizzy_plugin_set_dvui_context( + window: ?*dvui.Window, + io: ?*anyopaque, + ft2lib: ?*anyopaque, + debug: ?*dvui.Debug, +) callconv(.c) void { + sdk.dvui_context.inject(window, io, ft2lib, debug); +} + +/// Code convention: `gpa`, `host`, `state` (see `Globals.installRuntime`). +export fn fizzy_plugin_set_globals( + gpa: ?*const anyopaque, + host: ?*anyopaque, + state: ?*anyopaque, +) callconv(.c) void { + const Globals = @import("src/Globals.zig"); + Globals.installRuntime( + if (gpa) |p| @ptrCast(@alignCast(p)) else null, + if (host) |p| @ptrCast(@alignCast(p)) else null, + if (state) |p| @ptrCast(@alignCast(p)) else null, + ); +} diff --git a/src/plugins/code/module.zig b/src/plugins/code/module.zig new file mode 100644 index 00000000..55f18f33 --- /dev/null +++ b/src/plugins/code/module.zig @@ -0,0 +1,10 @@ +//! Code plugin compile-time module root. +//! +//! Wired in `build.zig` via `wireCodeModule` (`b.addModule("code", …)`) for the native, +//! web, and test roots. Shell code imports this as `@import("code")`. Plugin files inside +//! `src/` import `../code.zig` for shared sdk/core access. +pub const code = @import("code.zig"); +pub const plugin = @import("src/plugin.zig"); +pub const State = @import("src/State.zig"); +pub const Document = @import("src/Document.zig"); +pub const Globals = @import("src/Globals.zig"); diff --git a/src/plugins/code/src/Document.zig b/src/plugins/code/src/Document.zig new file mode 100644 index 00000000..19d70a88 --- /dev/null +++ b/src/plugins/code/src/Document.zig @@ -0,0 +1,64 @@ +//! A single open text document: its path, contents, and grouping. The contents are kept +//! in an `ArrayList(u8)` so the editing widget can grow/shrink it in place; the shell stores +//! only an opaque `DocHandle` whose `id` maps back to the registered `Document`. +const std = @import("std"); +const builtin = @import("builtin"); +const code = @import("../code.zig"); +const dvui = code.dvui; +const Globals = code.Globals; + +const is_wasm = builtin.target.cpu.arch == .wasm32; + +const Document = @This(); + +/// Shell document id (monotonic, allocated from the host). +id: u64, +/// Absolute path on disk, heap-owned. +path: []u8, +/// Tab grouping (which split/tab group this document lives in). +grouping: u64 = 0, +/// File contents. The text-editing widget reads from and writes back to `items`. +text: std.ArrayList(u8) = .empty, +/// Unsaved-edits flag, set when the editing widget reports a change. +dirty: bool = false, + +/// 64 MiB — generous for source files; guards against opening something huge by mistake. +const max_file_bytes: usize = 64 * 1024 * 1024; + +/// Build a document from in-memory bytes (browser file picker, or after reading from disk). +pub fn fromBytes(path: []const u8, bytes: []const u8) !Document { + const gpa = Globals.allocator(); + var text: std.ArrayList(u8) = .empty; + errdefer text.deinit(gpa); + try text.appendSlice(gpa, bytes); + const path_copy = try gpa.dupe(u8, path); + errdefer gpa.free(path_copy); + return .{ + .id = Globals.host.allocDocId(), + .path = path_copy, + .text = text, + }; +} + +/// Build a document by reading `path` from disk. Runs on the shell's load worker thread. +/// Web has no filesystem; documents there are opened from bytes (`fromBytes`) instead. +pub fn fromPath(path: []const u8) !Document { + if (comptime is_wasm) return error.Unsupported; + const gpa = Globals.allocator(); + const bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, path, gpa, .limited(max_file_bytes)); + defer gpa.free(bytes); + return fromBytes(path, bytes); +} + +pub fn deinit(self: *Document) void { + const gpa = Globals.allocator(); + gpa.free(self.path); + self.text.deinit(gpa); +} + +/// Write the current contents back to `path`. +pub fn save(self: *Document) !void { + if (comptime is_wasm) return error.Unsupported; + try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = self.path, .data = self.text.items }); + self.dirty = false; +} diff --git a/src/plugins/code/src/Globals.zig b/src/plugins/code/src/Globals.zig new file mode 100644 index 00000000..2ed70f21 --- /dev/null +++ b/src/plugins/code/src/Globals.zig @@ -0,0 +1,28 @@ +//! Runtime injection points for the code plugin. +//! +//! The shell sets these once during `App` startup so plugin code can reach the +//! app allocator, the Host (EditorAPI surface), and the plugin's own state without +//! importing `fizzy.zig`. Mirrors the pixel-art plugin's `Globals.zig` injection pattern. +const std = @import("std"); +const code = @import("../code.zig"); +const sdk = code.sdk; +const State = @import("State.zig"); + +pub var gpa: std.mem.Allocator = undefined; +pub var host: *sdk.Host = undefined; +pub var state: *State = undefined; + +pub fn allocator() std.mem.Allocator { + return gpa; +} + +/// For a loaded dylib build, the host calls `fizzy_plugin_set_globals` on the image before `register`. +pub fn installRuntime( + gpa_ptr: ?*const std.mem.Allocator, + host_ptr: ?*sdk.Host, + state_ptr: ?*State, +) void { + if (gpa_ptr) |a| gpa = a.*; + if (host_ptr) |h| host = h; + if (state_ptr) |s| state = s; +} diff --git a/src/plugins/code/src/State.zig b/src/plugins/code/src/State.zig new file mode 100644 index 00000000..709cac28 --- /dev/null +++ b/src/plugins/code/src/State.zig @@ -0,0 +1,32 @@ +//! Code plugin runtime state: the registry of open text documents. +//! +//! The shell stores opaque `DocHandle`s in `Editor.open_files`; this map owns the +//! concrete `Document` values their `id`s map back to. +const std = @import("std"); +const code = @import("../code.zig"); +const sdk = code.sdk; +const Document = @import("Document.zig"); + +const State = @This(); + +docs: std.AutoArrayHashMapUnmanaged(u64, Document) = .empty, + +pub fn deinit(self: *State, allocator: std.mem.Allocator) void { + for (self.docs.values()) |*doc| doc.deinit(); + self.docs.deinit(allocator); +} + +pub fn docById(self: *State, id: u64) ?*Document { + return self.docs.getPtr(id); +} + +pub fn docFrom(self: *State, doc: sdk.DocHandle) ?*Document { + return self.docs.getPtr(doc.id); +} + +pub fn docByPath(self: *State, path: []const u8) ?*Document { + for (self.docs.values()) |*doc| { + if (std.mem.eql(u8, doc.path, path)) return doc; + } + return null; +} diff --git a/src/plugins/code/src/plugin.zig b/src/plugins/code/src/plugin.zig new file mode 100644 index 00000000..b429327d --- /dev/null +++ b/src/plugins/code/src/plugin.zig @@ -0,0 +1,206 @@ +//! The code editor plugin: owns text documents (`.zig`/`.json`/…) and renders them as +//! editable, monospace tabs. Registration + the document vtable. Registered from +//! `Editor.postInit`; document state lives in `State.docs`. +const std = @import("std"); +const code = @import("../code.zig"); +const sdk = code.sdk; +const dvui = code.dvui; +const Globals = code.Globals; +const State = code.State; +const Document = code.Document; +const DocHandle = sdk.DocHandle; + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "code", + .display_name = "Code", +}; + +const vtable: sdk.Plugin.VTable = .{ + .deinit = deinit, + .fileTypePriority = fileTypePriority, + // document staging buffer (shell allocates, plugin fills, then registers) + .documentStackSize = documentStackSize, + .documentStackAlign = documentStackAlign, + .loadDocument = loadDocument, + .loadDocumentFromBytes = loadDocumentFromBytes, + .setDocumentGroupingOnBuffer = setDocumentGroupingOnBuffer, + .documentIdFromBuffer = documentIdFromBuffer, + .deinitDocumentBuffer = deinitDocumentBuffer, + // open-document registry + .registerOpenDocument = registerOpenDocument, + .documentPtr = documentPtr, + .documentByPath = documentByPath, + .unregisterDocument = unregisterDocument, + // document metadata (shell/workbench routing) + .documentGrouping = documentGrouping, + .setDocumentGrouping = setDocumentGrouping, + .documentPath = documentPath, + .setDocumentPath = setDocumentPath, + .bindDocumentToPane = bindDocumentToPane, + .documentHasNativeExtension = documentHasNativeExtension, + .documentHasRecognizedSaveExtension = documentHasRecognizedSaveExtension, + // rendering + lifecycle + .drawDocument = drawDocument, + .closeDocument = closeDocument, + .isDirty = isDirty, + .saveDocument = saveDocument, + // text saves are small and synchronous, so the async path just saves in place + .saveDocumentAsync = saveDocument, + .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename, +}; + +pub fn register(host: *sdk.Host) !void { + // Adopt the app-owned state as this plugin's vtable `state` (mirrors pixelart). + plugin.state = @ptrCast(Globals.state); + try host.registerPlugin(&plugin); +} + +/// Stable `*Plugin` for constructing `DocHandle.owner` fields / lookups. +pub fn pluginPtr() *sdk.Plugin { + return &plugin; +} + +fn deinit(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + st.deinit(Globals.allocator()); +} + +// ---- file type ownership ----------------------------------------------------- + +/// Text/source extensions this plugin opens. Lower priority value wins; pixel-art +/// owns image/`.fiz` extensions, so there is no overlap. +const text_extensions = [_][]const u8{ + ".zig", ".zon", ".json", ".txt", ".md", ".toml", ".yaml", ".yml", + ".glsl", ".c", ".h", ".cpp", ".hpp", ".js", ".ts", ".css", + ".html", ".xml", ".sh", ".py", ".lua", +}; + +fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 { + for (text_extensions) |e| { + if (std.ascii.eqlIgnoreCase(ext, e)) return 50; + } + return null; +} + +// ---- document staging buffer ------------------------------------------------- + +fn documentStackSize(_: *anyopaque) usize { + return @sizeOf(Document); +} +fn documentStackAlign(_: *anyopaque) usize { + return @alignOf(Document); +} +fn loadDocument(_: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void { + docBuf(out_doc).* = try Document.fromPath(path); +} +fn loadDocumentFromBytes(_: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void { + docBuf(out_doc).* = try Document.fromBytes(path, bytes); +} +fn setDocumentGroupingOnBuffer(_: *anyopaque, doc: *anyopaque, grouping: u64) void { + docBuf(doc).grouping = grouping; +} +fn documentIdFromBuffer(_: *anyopaque, doc: *anyopaque) u64 { + return docBuf(doc).id; +} +fn deinitDocumentBuffer(_: *anyopaque, doc: *anyopaque) void { + docBuf(doc).deinit(); +} + +// ---- open-document registry -------------------------------------------------- + +fn registerOpenDocument(state: *anyopaque, file: *anyopaque) anyerror!*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + const doc = docBuf(file); + try st.docs.put(Globals.allocator(), doc.id, doc.*); + return st.docs.getPtr(doc.id).?; +} +fn documentPtr(state: *anyopaque, id: u64) ?*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + return st.docById(id); +} +fn documentByPath(state: *anyopaque, path: []const u8) ?*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + return st.docByPath(path); +} +fn unregisterDocument(state: *anyopaque, id: u64) void { + const st: *State = @ptrCast(@alignCast(state)); + _ = st.docs.swapRemove(id); +} + +// ---- document metadata ------------------------------------------------------- + +fn documentGrouping(_: *anyopaque, handle: DocHandle) u64 { + return (docFrom(handle) orelse return 0).grouping; +} +fn setDocumentGrouping(_: *anyopaque, handle: DocHandle, grouping: u64) void { + (docFrom(handle) orelse return).grouping = grouping; +} +fn documentPath(_: *anyopaque, handle: DocHandle) []const u8 { + return (docFrom(handle) orelse return "").path; +} +fn setDocumentPath(_: *anyopaque, handle: DocHandle, path: []const u8) anyerror!void { + const doc = docFrom(handle) orelse return error.DocumentNotFound; + const gpa = Globals.allocator(); + const new_path = try gpa.dupe(u8, path); + gpa.free(doc.path); + doc.path = new_path; +} +fn bindDocumentToPane(_: *anyopaque, _: DocHandle, _: dvui.Id, _: *anyopaque, _: bool) void { + // Text editing needs no pane/canvas binding; the text widget manages its own state. +} +fn documentHasNativeExtension(_: *anyopaque, _: DocHandle) bool { + return true; +} +fn documentHasRecognizedSaveExtension(_: *anyopaque, _: DocHandle) bool { + return true; // a text document always saves in place over its own file +} + +// ---- rendering + lifecycle --------------------------------------------------- + +fn drawDocument(_: *anyopaque, handle: DocHandle) anyerror!void { + const doc = docFrom(handle) orelse return; + const gpa = Globals.allocator(); + + var te = dvui.textEntry(@src(), .{ + .multiline = true, + .break_lines = false, + .text = .{ .array_list = .{ .backing = &doc.text, .allocator = gpa, .limit = max_text_bytes } }, + }, .{ + .expand = .both, + .font = dvui.Font.theme(.mono), + // Key the widget by document id so its cursor/scroll follow the document across + // tab switches within a pane, not the pane slot. + .id_extra = @intCast(handle.id), + .background = false, + }); + defer te.deinit(); + + if (te.text_changed) doc.dirty = true; +} + +fn closeDocument(_: *anyopaque, handle: DocHandle) void { + (docFrom(handle) orelse return).deinit(); +} +fn isDirty(_: *anyopaque, handle: DocHandle) bool { + return (docFrom(handle) orelse return false).dirty; +} +fn saveDocument(_: *anyopaque, handle: DocHandle) anyerror!void { + try (docFrom(handle) orelse return).save(); +} +fn documentDefaultSaveAsFilename(_: *anyopaque, handle: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 { + const doc = docFrom(handle) orelse return error.DocumentNotFound; + return allocator.dupe(u8, std.fs.path.basename(doc.path)); +} + +// ---- helpers ----------------------------------------------------------------- + +const max_text_bytes: usize = 64 * 1024 * 1024; + +fn docBuf(buf: *anyopaque) *Document { + return @ptrCast(@alignCast(buf)); +} +fn docFrom(handle: DocHandle) ?*Document { + return Globals.state.docById(handle.id); +} From 014a1604576c644eebbd1cd03bbc218bbd548dc6 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 10:36:09 -0500 Subject: [PATCH 43/49] Fix extension filter --- src/plugins/code/src/Globals.zig | 6 +++++- src/plugins/code/src/plugin.zig | 2 +- src/plugins/pixelart/src/Docs.zig | 2 +- src/plugins/workbench/src/files.zig | 20 ++++++-------------- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/plugins/code/src/Globals.zig b/src/plugins/code/src/Globals.zig index 2ed70f21..5a95fbf0 100644 --- a/src/plugins/code/src/Globals.zig +++ b/src/plugins/code/src/Globals.zig @@ -6,6 +6,7 @@ const std = @import("std"); const code = @import("../code.zig"); const sdk = code.sdk; +const core = code.core; const State = @import("State.zig"); pub var gpa: std.mem.Allocator = undefined; @@ -22,7 +23,10 @@ pub fn installRuntime( host_ptr: ?*sdk.Host, state_ptr: ?*State, ) void { - if (gpa_ptr) |a| gpa = a.*; + if (gpa_ptr) |a| { + gpa = a.*; + core.gpa = a.*; + } if (host_ptr) |h| host = h; if (state_ptr) |s| state = s; } diff --git a/src/plugins/code/src/plugin.zig b/src/plugins/code/src/plugin.zig index b429327d..62a9d7ee 100644 --- a/src/plugins/code/src/plugin.zig +++ b/src/plugins/code/src/plugin.zig @@ -72,7 +72,7 @@ fn deinit(state: *anyopaque) void { /// Text/source extensions this plugin opens. Lower priority value wins; pixel-art /// owns image/`.fiz` extensions, so there is no overlap. const text_extensions = [_][]const u8{ - ".zig", ".zon", ".json", ".txt", ".md", ".toml", ".yaml", ".yml", + ".zig", ".zon", ".json", ".atlas", ".txt", ".md", ".toml", ".yaml", ".yml", ".glsl", ".c", ".h", ".cpp", ".hpp", ".js", ".ts", ".css", ".html", ".xml", ".sh", ".py", ".lua", }; diff --git a/src/plugins/pixelart/src/Docs.zig b/src/plugins/pixelart/src/Docs.zig index c40ec736..7a351b04 100644 --- a/src/plugins/pixelart/src/Docs.zig +++ b/src/plugins/pixelart/src/Docs.zig @@ -18,7 +18,7 @@ pub fn fileFrom(self: *Docs, doc: sdk.DocHandle) *Internal.File { pub fn activeFile(self: *Docs, host: *sdk.Host) ?*Internal.File { const doc = host.activeDoc() orelse return null; - return self.fileFrom(doc); + return self.fileById(doc.id); } pub fn fileById(self: *Docs, id: u64) ?*Internal.File { diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index 5ecee49b..26c45b7e 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -802,15 +802,10 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u if (branch.button.clicked()) { const mode = detectClickMode(branch.button.data().borderRectScale().r); applyFileClick(inner_id_extra.*, abs_path, mode); - if (mode == .replace) { - switch (ext) { - .fizzy, .png, .jpg => { - _ = Globals.host.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| { - dvui.log.err("{any}: {s}", .{ err, abs_path }); - }; - }, - else => {}, - } + if (mode == .replace and openablePath(abs_path)) { + _ = Globals.host.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| { + dvui.log.err("{any}: {s}", .{ err, abs_path }); + }; } } }, @@ -1060,13 +1055,10 @@ fn pathIsDirAbsolute(abs: []const u8) bool { return true; } -/// Same file kinds as primary-click open in the tree (not directories). +/// True when some registered plugin claims this file extension (not directories). fn openablePath(abs_path: []const u8) bool { if (pathIsDirAbsolute(abs_path)) return false; - return switch (extension(abs_path)) { - .fizzy, .png, .jpg => true, - else => false, - }; + return Globals.host.pluginForExtension(std.fs.path.extension(abs_path)) != null; } fn appendOpenableFilesInTree(arena: std.mem.Allocator, root_abs: []const u8, out: *std.ArrayListUnmanaged([]const u8)) !void { From ac0c4f15499fc6851cb370c0cbfd5af68f6c36d2 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 22 Jun 2026 10:45:28 -0500 Subject: [PATCH 44/49] Improve code editor --- spikes/ts_highlight_test | Bin 0 -> 988480 bytes src/core/dvui.zig | 12 +- src/core/widgets/TextEntryWidget.zig | 1846 +++++++++++++++++ src/editor/Menu.zig | 2 +- src/editor/Settings.zig | 2 +- src/plugins/code/code.zig | 2 + src/plugins/code/queries/json.scm | 16 + src/plugins/code/queries/zig.scm | 315 +++ src/plugins/code/src/CodeEditor.zig | 126 ++ src/plugins/code/src/Document.zig | 10 +- src/plugins/code/src/SyntaxHighlight.zig | 159 ++ src/plugins/code/src/plugin.zig | 42 +- src/plugins/pixelart/src/Tools.zig | 2 +- src/plugins/pixelart/src/dialogs/Export.zig | 2 +- src/plugins/pixelart/src/dialogs/NewFile.zig | 2 +- src/plugins/pixelart/src/explorer/sprites.zig | 9 +- src/plugins/pixelart/src/infobar_status.zig | 2 +- .../pixelart/src/widgets/FileWidget.zig | 2 +- src/plugins/workbench/src/Workbench.zig | 1 + src/plugins/workbench/src/Workspace.zig | 2 +- src/plugins/workbench/src/files.zig | 15 +- src/sdk/Host.zig | 5 +- src/sdk/Plugin.zig | 11 +- 23 files changed, 2521 insertions(+), 64 deletions(-) create mode 100755 spikes/ts_highlight_test create mode 100644 src/core/widgets/TextEntryWidget.zig create mode 100644 src/plugins/code/queries/json.scm create mode 100644 src/plugins/code/queries/zig.scm create mode 100644 src/plugins/code/src/CodeEditor.zig create mode 100644 src/plugins/code/src/SyntaxHighlight.zig diff --git a/spikes/ts_highlight_test b/spikes/ts_highlight_test new file mode 100755 index 0000000000000000000000000000000000000000..374e8764b99b49c6e43acff00464dbb219eb5a15 GIT binary patch literal 988480 zcmc${3!GF}mG6J5iw;!~!aKZa2ovcJMvNrVZQxQ(8v&yxfh2_ROi%-&Mg}$X#Y~!1 z)WkR=Wf-I4Xg3|lYNBHtxj@JQ^XKzv>YRP{S$plZ*Ly$C5C8Mme>^`3Vgdi^_#4OHP4z+W!C)3wK`@2C<@{~j zcx~<8Tt(&7oBk@I6tG}#*Nosb<_3LkHhybs4fh`dr%4dpDJwJ_};he zeD7e@T6mwNvfxeM5VuOYSHCL%h6Np!Yc4i!-0|M`-MXVvR15FX&2fX*;X0}ND7=J& z2Mz7t#*LeIy!TJHzWcVyeRY2?e_IHzu{Erq`{=(YY?TA=_IK|X>~Jl9oBle4_xj65 z7#Q7GLySTfy!XBPj;-&0>&C6`e*5hPzZ$=Xemctf`|4rgd%Ca1DGE{JS~NJXZ@s>C zW7{=vS>;Ce0)rEzd~G+r?p`?RPq4@Ew{hcbw-5GG_d~-Q0Z{q$&W-1yd8-g`^h24IO%ua$n#|+Er!NSW@%*Mk7X({g8w4XMyH@YZxUIg3BZ)BN}C_|y6C+Z;vl{`8D4@b68gQ7X8A&#Lwv z?|s|5UbXa9;I;Kus}#I~AHnZ@5c&bK$=!=y`ZvSfANr3Af{vM1bk6?st~r@s@5BU9yjW5L&SofsbMNz|vxK`dqU z`r|>SF&31&V!`vXQ?JOp7Z~q1IH~gKT}yjtCvzyi@h7$I_0f*n>;v|@RDbwjyG?bj z-QlV7k+ZeSdx<-f`)b?29#}UTyqDhowXS{jv#Q^;KUBX}ud3Z=UXgh%Fq(&IHy%4$ zXb8%E(}MD`NkRGe_@G>DfObLfsL__|;^?3Z6HG`1(Ds%KGrG5Dqf_N3=q>sdr$yf3 zx(NL$@64W@(Y+rld#9dvCPm)ix(JO2-L1bj&!^2vC|lZmFDed=S8{v{@+^8M4o_8#|qt@5#yPYTO72O%EloC%FV$4Yo% zoc^+>8k$w+aZl6-W7U@*3=B+4)YLOPPHk*PIgC5KtF5O(o3{F+f4F^o&*@zgdQQjd zGYQdiw)hQtr)QQr(RO?+Q(PA0 z?Y=%$#{Vc4+g@KP&ZGV{seEy5kVk7y5d0o+)VM$9aT5%U!|@iZ9kFNJtdQ*PCw5Q@R8uFKh~$GBKR$E`0W681F&`P@#}3Z6_Lr}T;5;c@Qcu? zf}h?Ier>>D+zp1u4_#m|;4Q&813%y!emtw-w+dME!m+r>^FA~cul0j_lkr`0+bpLC z3s=*|67k9OQZb#%XX7(+UFmhD{c(6D|az_lNoHD)gl->DAYY2bS^_^yTC!gopu z8iMas@LdDGH>7C*njYC5& zOB>Ra(xI{?J=o^6wfPa+l-(|%KTq#^J?(n^Uq`#c(f?<*+eKUZu?f}=_KosJ>^cu2KkewWl+?kCCwr}-v;>+rT?fqPP8&YE{o)7lSt=NjXIJBoJ-4i@pF)^?@iPlu9u|6oz z@%l&aI{AJq?ra;nP4#3?i@>mJ*(U0mPN6<}gnp;Wv*GDFbnw?+9qbhigy%_qw?K>j zc;Y}4GO;xon4V5z;|lRZi)Y0;7RxVbOolf7T;n&@cxD;4sm}Rk;+OOA1!Lw5cG);F z{`B4KbbUyJ?(@RFn13j`)CZRp+_;EuG*)U?G&{ik8+lK52>Ojwc_Z}FyYg?Me5UJJ z=;T;yYiSR%rhat76T#tDY~F^_dnnUbbiza8AJN+55cMN&bz@|G4?4!FJzZzhGjiEp z{;-{T*UJ{UhzV72VqrhGIylxR$~^7{3tSV2!}0cT493`MS{)yXF4P&4E@K^Cy!^o< zzO7k|z33txRK({hAS0qnANqorV{bn)SirZGf7pnhScQwpPIB8BbfG=LFG>&0kat+PA^1a4aFCI;`@=Jb+8>8HJ!NM$7$Vyg%5IdYhFG`7e2^-e30TkmE!|ksq$&q)0@%t zYfFXr@XY)EIPjpJqYKf2t$DR;{$T`ukB4t?@EHuwRN$`x{>`PLwTm6McBcUUCg86z z`0E9q=fYjjg}cVd{}rHFAM{y6Ukmf;*Zfu+zeF*bR@t*XgQZ_yr+Tl+>AlluOE6$7MkI!k%fBg>w4+_5U zD9*?BI$uXVdl5WTK5*Bt7U2VLTh_)z z+PIK5Zor398#m^PBWWY;+87zOp?Cj>ZwHu{hcGRktH7lE5ra7am=l3{eW|!hFmK2e ze^38+Ihems8Q$bQCJ2viduKfFOyHeWrQ$ZdbA7J(6k`L-efE4D&&Tt8WvRGX&sXJ& z-vj2h6nr_hRD4Rl&$wJ?`a0^Vj>^ZBir-WD*xdf~wWaR(b*02G=yMk`nU0lQ`9{k3QQljZKen;8e0+0j`4<|` z)xcWIv(?mF3#_jK>l?sY%RAzOZ}3h;CPdSE`k4?P;)euo>{Y&8xLpIhRlvIjcx}Aj z4ZQn-M?B*7LGi=HmAtF|OJ9247L5M@r)TkH^-SY)f_rOge55VPFI8L1XzOa)T1Hz} z($)jCwTrfP)U?IB))xA`(iSkDinLYyD*9?ysIPjQE*CzB=xf>GQ{;I+^`!4Bb%67A zSEqQ_>suO~e3(vI0&dbN>AHO15_E7IcuJQoq#PYp+)AKgQ9(d63 zRGn`6qdMq2D?_jJqhq`ud-pJB4{OS6{M$!@;6nMgk0=*X*Jl3hBb#?#nCX1d`M1E7 z-zdFR$%QPW?S=T%_-FmdzWRH_+c)ar&y6i&Bgjim(BIGfctNi}^W#O{pb&(5LF2g7 z%7c~Bd_v=ohnwCgVxN>J?3Vn@XG|ABpZU;gK6q?innxz{MQoMod|GwpQfD4@=2B-a zbyiVl6?N8v@71Aic9GYA^Hb!}sJl6ZPG%e$7>7A}kMc>BucCYb<1mSFm==!1&(OUW ziZ_AP4@~vH0#EOacJIvr-e};B2cFs)PPy6{54_>P8yUj;Ch#0`ZD zGPZ@0sXV?z=Jc*sV!p?+mRpSv#$EEQqwi<&q2x<~p}@^>YFOW>W6hZeta;>i0`(4jE;(Bc$yI6yloN1K$R&4Kv( z(ulD^IoTBC8@>{>B)=A9mc)YghJOfJ8m5H)akQUNIvqQzm^c#O#N)&_BZ*7vGSPQL zo5CFEH3ylXpY(BiKi5sVhBs|osa9nILQ?pIPRMW5@ypEsUg04-Mqc~bZj)V1|0?-4%$2E+nPTM+df9W zkBb-aKd+~(lRgF)AU9v29=_-4QCxQi?dm)6hs|kZDaU@;^>XAP+cqP2C-hex${!V7 zROen-=OEADr}6o6s=fc=RJ(Yk&+E}IP}Uumb+|Is-%5Sq@)p{!8cX=k#`0=t-3HCC z3G%}8^;R$9pHEXg{Bz{U@?Xf3;Qeeg{n;4y?Z>)aD|)Og&0?GjUk+LXYlf~L=9==i zstz%q%5Tv%F<`d$=A74uvxhtTqkas0PI9WL%y9=Crwn~AG2|=UdorP9W3kg z_KbRp_llD~H+^d4%c&OL8wZ^$dC{1ArvB8^hmKfi zj~I`ZdtF|-2cCehOS7gBKl8VCJ#=?=OMaQgSTUR*ZyVz_VjSW*i;d4C$2=PuMgG9? zq}RdUdjgsf>s9f0?=W)vV~Op?kwY0K9aDK%Ikv)rP<|>p23b0bj(H?_ex7v99@+I{ z7dpF+jyXWPCZD`(I>x~`L&uDFI%ZO+W28SC-!7hu@DlplcxfKIguI)cK;ES%BuicB z3F#h->E@|?I{ITR`eR+G&0tNX4zfx-r}!1w@O)XV(_|Cnb6g2u?{GQ|y(gSLdcJU) z0-UM9A@^!J?dDLY@m@AfT*rIa_!MxuiF&G|a`LmL(`CbiA*D?t?mQB{ugu@*2aOUOSI*zmRdC zgl!?#t{L~vG;vbf#GK-!sIHgaE1uS0cE^Ry1^Dpbf{D$-RlinWHVFIC!@FnYmF2Ij z@XFl{gS^t0|Qq!=uK- z@ThO^nUOX8X*p~7vyD3Nlkq3~RK=g9EEWubl%dBkny5A6izA3s2UeT=hh5U0nT4HFMZZ#LqKu@+{6 z-8UJJj%{oT`9ja5d`*7RV&E)1ZT<|p#&~R1%6QDvx(-`+$mwRAXH*;l{~15H@&EER z_zxW+9dN|iWZ|IuM*Kn5wfgke;uv`MMi;+i7u?Xi{8wR2QY(9UC%fauX86zh-b3nv zH~)~lnR0x{?CD*r(E~T)-w;P?Z9|**t$-{w!hD!*>%cei_fw04o11JqrC z%t-&OKwheR@;Oci&P5KW_sxNU2eI{r&%?B1FnHH=G0)GcixaP^=;Hma7}Ujo|AWwO zdtB5hOw*qa)aXy-)$}Lw8r7dm-zr!U{b{-wA7C@E z&^xvcz_mLPJ71CQWv+kA24(vUn-O<51GzVw!S7jZ26c_^zIyjE7Dm!53Mo=vQD==~vxHWI_Fm%0r*aDb-QF7(I6`A7&;zczV}$=&Wmd2JlZq zxz+lwa4tgfjZa&ykMO?c(N(sSer@t=J-By%jOBOwxpz2LWUito6#Mf0tn%`vL3vp^ zb5LHM|Gx8c)fVIBVR(6wtyBI=wDY+-wJCif8W~QEujEzv3Y95WGLUxqsyY`?rK>fM zR>1dMXX6%$x6NNB-kyi;=(>uI&MwHF$|hFqfh(`vzesz(f#;&_!QXA!uYf8#C zCr1udCxBln<=|797i3J(G5N&eV0)XNhg8|=5GHhyoR$;2p*J>K@@Kq8xztOQ-(+#E3;9&EwAH>fGPNIEl4qLChP|B1sMmSi*;RuN~dk&YVvRvetquwfE0UwFIrnODZ&u zj6FJba9ktheoTA4{{(*|ZoxM}cHF$}le$)$1#Cx!H(m_J`{={VfDz1&;1`eZ2K4XR z+^}Y};O{48c5999^vRiH$&Quq-}9T7PtNT9$57tH3tgKxg44#GtWDm_I)l8eC72E0 zOai8h(M;xu^ZIGKQMib<_2E7II<8T8sf^sR)d4@zxoRzBFEWTu?OPJGAA<+r^>%o( z+%zS`eRIP3E|eu^&_2&kaQ#M}+j^2U@VL{p335B+-qH8ieq>F&mQ2z%*YE*+9JCK0 z55`~E+%*hig~T5={ivV0 z_5RJVoN&SCg|5N&@B6Xi-q!!T(Av&g@M-2(jVE?Rr#7h!dw|}SP0mXvR(ODQBpRPv zUEb03V>@)v7_@KQ+0%<2<$0eQKj}U3w*IshSZhRT-`NrbR|vjhGvAIM&*N^4Wh(p&;C&m{7B_~-fbjy)1iP>VUa0sz5&aXPH|w&`)IF4W z-Lu=ZdGL6{&Q}Rvo*8|ep1IZKcm25S=lSu?L1wOWh~!bS$(Z#=WJU5Se=f0xxMw(Z z=A^Wq*yvYWFqlKi9^bH2wo*FV_>pV%3;mMMf)Bom(OS3-=Q@hRv&4&?l&gN{@r^sH z^iP!cYIJc_4<4TrWInESB=C>ZW%Pz{q9BICcZW~Bin21 zT&Uw|Tvg}$VV$w+r^Xq+WzMhMi@(oUi}#OA^Yi7?K=1r9)*714cyw}R%=gSM495t2FZwTWH2(~^F^|wb zK;PA_^?y#N13%*C4m37>^ygXXnxFGk{G5ffJKx4ToG)IOYH9Sb27EwY$Mu63a|Os= za@!_HOZMo5@)zklI>+XnSUXn1{rGp`LC;r;5oO0>CL?Oc`eJfiBR}e!WXt!(lrMMX zl8r6y+VJY#n5!D|v*7g;jTyWpo?_fD_kA@T2(6+0^;RYu(e&TvF^zv-ZO#qa)u$r- zV({UU{<+vP?4ic^fYT@9K^q&jO`TXfy2A0~yELxo^~A8?GS(=CcuJ?&MaI;~9zG>GOD>p6rX-t#+3LlS?jX5~}>|A3WADnl${Vv!r=`7Qa7NaJ&vChiy zlT5PBP5Y-FK}VN^;T=y(Uj(rwKhgX=@uRH`_BxDQitW!){3Rb%v7F{MFQf0OTkXR~ z=DHbglZ8q6SJ$A6u7SSf54~@tIql+u_~*1Oc_ru6a>VC-sb~Hc@0{Se%HI;N=(+qY z^HuP-&>!+a6Zl)^d+FNwT;?;VzI+!C&-pIqvm}ET^uEzMl+AJyUr~BCSGI{R{q|FPU`h}%Idh+ z#$gn5)zDRAP!(?uHLmb)R^w#jyP?#?7&f^vKJLai0be6G6+gk&(rR3>aT+t_gcR%9 z*h2rm#K%$l%DdLV4}bS~uvfAXg(3P3`UsJ6ZX3+YYP^fkQ1C0UOl=>?L+{@Ve{P09 z)8PLixO*Q*YYS}62W@onUKj6^n=sydoA7tMDf~b3a_~=Aj|VoLK7=|PnIvBo$=OiX zdsAuMHsx9FmorF(j^^=46RbarY-nUl$ zXnB+mgx?DAyNEyWgTYgMa8keR{ub~?hc*J!=)k?ojZcv~>DHn6RIpzIOi#NC4)o7t zQ+>H2)xPBq1DET*QWk~pe8IcDF4jBnVq&%%3*NW*k?|Fu@~p*hMn>&ku_Es@BE!N% z@|$otNsbLSU|1cV35Qzx7+!agD?t7#KJQR60^SuFISUVZS<-Ke`X9z_MftIM9ryU7 z%=_+9U)5)u?3JdWE3vZJ9{IRN!Z>yF@q@)oxS=3R_IcE`r%d(R7H(VoAV zKG(bTGO8boXNuS%JsUynnPktVyNXRu$F>*0@VTjartvIbKMu5Q z$lZtU-1N&=W$u*^{pYXB%mc>Bxk3Joj5U9MI&AEHpY|~N&}9>h1XaV92^ z|M3mU9kkr`j;2bDO~?^Cv~dl4o#>PLrS}Xc@f_l$~TJcvFlx;&6OFo*6u1TI9*{$!bUMiC5OcD}wbpV4*AQ zx#pM(E{EZ9N#yA>KDe7x9#Y42e22a;ey4Xu_%340r5}svLh)5UzfD~sT`PGt%EWuV zPSWq`mL1W18V_`|=QFJ>qo3ns)0Br%pLFlb4EL8{BMcwGb@)`xclQp)``VwRa||Rq z>Wg$w7T>55{bTr{Yqr$6eg5DZplhB%@Ba$eQT=okbn^8@2k9pLN;heJuGdf1ah>Eh z+_QHEf8(>+c?0v|+xh4Vj^D7pGj|0ozNYJ}_E7gtF}u?!zNKyxW{U3Cwu#$eM0-_lhWnuOS9k=*;Ua?K5KB9#zw!CgSX-**@Nf44ZVS* zcN)92j&eQvZ+$6=PU*qcNvCK}te4kokp^NO}G!eW}v1ZG$lgF`Dsw!hp$vV7#L2L_xnApl$mbfd4-qp!N^w{&qmwb>esNkXXGy{{CuBZyQ*i%K=i%i zP7YLNaCv^>0c@S@iR$AohPH00;V+&P{!zReKOfkza#h z?E(Ar%ugexf7EzX_t4Mq>Iv!S=N_$Y2KBNDQF3)ClW6GL&s=$XPk?d)hYU z#1nRo0z89_FuJO~=wSTHwdCan$qU!=$=rL)$1A>~Y`4wv+q=bpJmKMH+xNiPPT@N| zPasQ&9i6H3L?XkQL+qgD3p@<`*XlYiur?Wf*7^mjbCq~1?6+v3_eBGE-1yk8kxzU@ zi(@VCL%;TeV`y9Xl?)1J#vhz6JM4UX$xLEwu-*G`noqe8Ik39)xzbnhA2wET^4Z`E zkE%9YzoT?jovJe^oK0enc{t9Z{|or`!`-;&qjqV~rXGpxX;E%fHWe75y=-#!a!n}T z%1!ryH^-9=u9bmy4?A8L9TV5l9=}ajh5F(mr!Q2d=Och=@(fJLv!}mwHS`?I_~Z?a zr>`G#`T>3IxxM51)uejx;Ny{c#^>;y>WOC2K2rCj&jozqn>BVleh#{z=laAK;t$bh z4m25WxDumvtn3%wKF^LmL>9JN+(Eg=7h7erbT)kc6nu(t?=wZ_+G=!!bjn+xd6gdV zr z{^PfvHlRmilHbv(_Qp|Ro7~%&bB}*{_Hnq}#vx)0=nJ$mzb*(;gZ`8+r;mwYDflGF zCldUQ1~+I@>0erYSCV`VIzn)$|2TZ!G#kE*$=8L?>9?J=!Tz*P#=SHC8PUFz=L*9+!aZ#4Wm^XS>ufWfEub|cY6Bk=y%1SDH^Qk(`ND90)JAf|(~xsw@}jO8GtCc6o{~u?%Tu5i z<;Iinax_l2@rG8)Gl<8}h3j{~b#R^C5Mu`%#>RX-!KxacUX9NP_6)tq_@rl`cSZkL zrog@)jZa}($KpS8<0D^rsPTE4=YD)1;o9#zv%FwDmg#3~$fxA`85{XnG0vx%_*?45 zu}9((@2@yq zWf!n(_MX!n<-{m#dp+|g!1-Ullk7)w3vGk(NHD5eUJMu38D#yu>s*^h$on;Y=<9a+ zYy3c;24(Fm{rQz}e2M+JGtwX8SbJ}%{``7~{)m1Whs60|zbmqhtopO|OztGd(Anr2 zM^598PmtI3Y7;%C+}=NMT@}Mc`sdp+o#0{B$ov~4eL29jwG9tsp@qj$WAk^led!gx zVZOrT)$zpxf`MF$mWF#;`ErLl`($6`+n|p54#1Y4i?;RTzYjd9Gh_Ng{ZnqdbIhv# zAH{Ym55#)-mL#;B1J8(`SUWK;ft?!0Z#d5siOJgrb&P0fYxGt%GE|_dfw1=aP>5FnN_{vr{wAGG=WX$;S<|_$J(w9h2_C*FK&4 zk^HD5?&{5+X*XR2d-i7m5vlwKFVBUhd`LR}WTP?ZyV8NB#+T9@N^F{FRQzI*vaP7{6| zh_3I9ui`$;;~}F7>2l$}hIyqs>#CkPxiY__K)$mgFA+XBUEuidl<*mp<1kMq*bzPR zI`~U#Ryr4;zu*VfS~iP5z{%ShJ?b0p*2v0Tf(`G8@5o=HD+hfb`m`DRKZhkC%)|n5izp%6Hv}fPjq?EH)o$;rD6UH8$@n0;Gf3OzbB!0E= zP+nAZ6tn1gXZ*8@T_zR5NjLV`>&gvi z4gj3;{lr1?KaPW+)(>$09Lw-JRyMx~PWKv4gE3F_x`^RNMNji@Xh(R+H_;j@%};bP z=AH3Ludn~Bff&wJTZtD74WA&;_uyMlMxpRwWaDa40m3T;jv zmUS%t5c9*b6)XI?8?^7+D8%EL!h*1!!b0AWJf-V0h3H=XbUgD{;9j_rI5Ez%IUS1& zOSqofvG~u?3!CC@ZrHb?ou!`^SMyA5ssCz6{nr?v zFH9Hc+Gycmv2QzffH71%S_>lmeINNl`b?hc%xB%6)*jy)UHLjkFY{p|K34I!;ht}` zukjPD3hC8s!j^bXHdt(l2O&bApsfB0D1_|EaX=q#T9-S6RP z^nvCd+lO(EiOZ`?HkZT#)3fqJ8|#DeNXawhQF%@#LwQb4Vg2I_{$}%cakv)p6{0!1 z#rv_EBa3|yJ9WJ~Gju-iDQm20NADP&daHHxdT>?y!Pp>QsnZtfWaArj^3Bu{?z38B zr6u6A1bmi&5A#~KwoCLomyUbyLFu^2Ud3LWnLd%d<(#pRuFq8KIL=jBO&)F)`gnDy zuaCHM0Sep?yPffL_Cdaqj~}u-CN_Uo{1Cnq^{1XBXB3`S;o$Z2+k~EP!v8qqyb9_X zUttrh-VAbE+9P!h>uTr6wC)yuOO?NeZrS9{(z7-vGRNiiTl#x@AOB5j1rssGi1!6s z<&9R(x;|)W?Fl!vr8PHxZ6jq`XC-)+vv+MDV64OZdU*DW=DYish-WtB`qQkbA^z)0 zPcJ3Ng;kyXvIJe4<~+}|$_LMW`KEYhl6Ww(_j{T6l)c{%FwV=s7oIFWKn@h1G<;Wq z6LKNkf_n1i;HK+m=C`(o?N4#-PsJXt!5-esIgGK)r~WhKw?g_`?yQP$oBg&vVo&T> zHre&Hr25EsC+Eg=appiz`leDpxk>ih=la`xUnjY?Cg(cStEqQWiM%a+z0jSRFbN!P z1c%k7`_$Lbf)_dKa<2NyS(mHm>s;5@RbgLG(5K~$OOtqw{QYwHAK4;iO^y)Hi5A%% zH#AGv$uEfVa@5X$^?#dBkq>zH^sXCOn|A}bwKLE4#^xXJzNE=(99fJz9X=dA3_X18 zd22khCw^|g){kAr*b*be!AtuNgkKmd4BB(f@5Lu{YfR+7LT}$@*QuGrz-Q{y$J8!1 zK6`3IGizT=pK3kP@vFl%6pC5so2^Zf%^r`=kgc;h0-hPq5WiI9mHk$8z)f`%)GaIt z*!zTx_&p0MM@JfOQGU$}et!x1ANHVfW@JkgmiAW&pM&5dy+EJxhn${Jc?0r_&l2K6 zE<5_JjbUZKR>T*Ji%#KpFZyjYGVya+s521(aDX^EZJDbM|{GZkjp_u;=#1+ z$tZI6_~DWp;K>gato;ibngjVx__=}kO`lyP`=zx3A#UrMk2rrrYakA$r{{{@tmVXa zzY%{~bu3p+Sr7Lqzm6K6on5f8Im9!JLkv%H&GG}3XYO#en=?gne!lY-=PSJuxGERl zbB;t#<0U&lo+Nib<+AZhup7P&XE&@3?1tL-582-0bWWX`V}C zr}g_)@oRcG_G&uzY6jysJk#G@myyqHa)Zt?+f7U-9JCI9i|1kVOxUmL`Ilk9Cl3(L z2gB!a@bBea&As{f5q@*;hW>J3botbxLK%H%yeR#qGq!Fv`@erq>}Ype?C96$T zPh98VMdCQx=|=|egUxpf&&gw-CHQJgzU#)s%Et!mXVW=gL&Nzga2^ECZ=t{SS#2F= zpR$AXeGg0JV}lnsBl@hcbk?SPalx{^L2dD)U2Z%(b)Bv|`fuC^qrJ}g2{9wSGCp!= z{HKcwS9UDk2TeNDKGO3A)Pf0?`|*t5dQliu^Ig#21ReMN7V7S&aSmS zk0J+C?|pj1JL72xA78J2YJ585KXGyAPt9)K!~5Dhp>pq&8(wocdka3<&R8>7Rc`dY zfyM!zl}twCiZ{12fE(8;0iOS`qW%a;uC4t0aQ zk8W7Ud(z!oeZSqi-p96j#Q9qH@pbCzynvU2`wDPRI^0E1|MwwiC>!6(bCsRmwI2Jp z4p|-^t}{c=v2VTHH&^j&P(CjI0DmQc4^{MX#Mo%hyx{0}B5fJsdGDm0;#<#hTnu@!yiA3s$jiV}HYdCcIxR!*_-|b4ns=|%yYZWm@tGy@rf@*+#%Af; ztL4S#2vnp>`FksU3Yo!tBgiT^pVwg}cU!czY6aS(8K~-7D{Qde<8G z^CtMyofFnu<4>WxEB;jP%v?pMV>4tAe9lWcT{*99C8yKT3yM{gk5WvxKOG$H?hcNo z&>_Yxz#`+!Ia~$!F;MdA}JF`bxZ-77J{YN)G)QTmu z_O|zUJAqGm#POoynuPOVEeE0dD>N=I;?t)8-^k=2@l9>qkB2gO9Wr_@nM|M)<@d}* zc3-Yc{t0&M<;di%@P*FD{C_2rzAsuM`d^XB*Xh0gzhp8k{tVYE$lop?lNFyEKHZO8 zR_2qy2tdeXs4L4GM7NE zLHFXNLUbL%S>(v{M2!sH!rZ%${WyEVY;;xZ_fq5}KD1wK{dDbFKapAa=Ck4D+PRae zd~kZ?QTf55E%r6kd+5q8=GE1Q+Ood_`KS`%`$F3LJqR&x}%G+Q3p?j zF?MJCpBKp=?2JEv-qxJJ-bL42sqjpwAE({t6luPwqtPy{P|`?zHmEy!I^7? zt2-7Kc5r=7$KsFZ8RzWtEr|+D%U#|ztVQ1%Am$8xF?|DoI%W^&GCz-BV!AF`{^77( z`~oi2b+Pm=VC{N+%MrIO-0%{ejM0E&Eu)=a@|=<3E32_2Boy z9n`x6J&Ro_I2st-I~>eiVcz#*aLKYxBJ4Z<1b&3|9iK!x?*w$6<@%^Po$<^fJy%XX zw7+AT<(E}p#DOu#!I&Guc!%>nlg>8O!Z~+xJ*@ch%aN#2!iT+(7{>2Y|7jmTXf%M2sb#D8m>j1^X(gAVwO;t?%e_f9xkM?gK zR*9?6s!!U&`6cnN{I}`wu+O!xXd}ml+!ABh8RR|q*F));N0=`)9h9ExbddDQUdBx@ z|8kLZ&)=e3vg!EI>Co`tsj?i?BO>7-*P?3`z+bO zbSUq$ppjw_$@_2U2Qn1$s&8wR)0OB_wU4}CK$IS@2?I4PjuBd z=1m_i8=MY!C-rqdkWT)$b-;v|s003M`BBXY z@eSQw%nM<2<+Di#D5mUcV@`bm{)FX7S;uTS(!X*xWm@yW9g|Hyv!~WJXGezoc<|J; zX1?Q-k^eEu$)D(8c~Z(ZQr78ms($Q!{>J14zWnQdLkG0s8!&&| zF*Zjs(S%HFaz5Z<+8cx&+8=`rf5fS0{OLN6>+FGYLd7|ZWn1j% zMkl)k?nQgqq*+O)Z}$;Td^q4wYfeIZd`|P<+?W_2IlsvG4j%101@G{Vc-nl^(RD4w z(Mwy3KK>Rw@jU+UacqwHrIE5N&JI20+ECpJ&pIE=^Z2*b*6@%|J#IQHQT!s@L?6`^ zeX^(4lbc~Z5IQdOm&iNeFG;4ZM^*!GXP~d@g}U+=rYpsFQF@9mLtb13FRtuijNN|K zv-9GM@sD^?{PW&go@AVS9t1!8Ko_|bV6p!~b49F)k?pME#jZ5*CNXC7w$ z1#jLp$eRW;y`l85p5a4_H+|U!^9S3}_su+ydAsa+a<1B07%*qEq13l9RsLiIzq9h@ zj6{`Js2+K0#ZbXS$bhYpRz{Np73tbD)%w^EAD6fa~b@i9;Cd zc_II!`EkuL>ASn{Jce#k>`Q;`ySw!1O_XU}n`B413_F7v{ib<+a;4U$_fKuEoi;uE z1mEt}JCY6ksqcz0phY>f86jP&*7>I+`?z%0P(5~ob$YRU$CKJeAev||!ax67u?S~P zqk~xckZ*|9Gat&n(|Y%NXB~ZGT>HjnVs#x)-c7yNYdk2^cxJnUqXB31D7P-T=uH1| zQDXLZ_SAN;W-eSmY41~}^_q5lOI=xgI2fP7c(9)6gJBz%>zNbFXr57P*ZLYb?$VkYgMmw|tCz|8erg4}hD>Pw$HCGwU5(k1e`tZv}HndF58r z&!$n~_YDdWL%*3a8%e2CFhcUH`7yw1NZ&%SfSzRKQN#(D%}UsT;lomn+?#2>!h z#!+ETu<8tu^x&Rp@hyL>31KdS9C+|daC;D-H-~jB-ru`17mIZ~`MAk_%JYezE(V zf?ffB4F6R8c?>^yA9x?bKM?$&HORy`2j&yevfY&NotzA1R_8GH^mplb9V}|8{sLBj{HZ)f z`IW4jFg{T|#%{=ZlwaoRSzJQ9(l^Y3**f4x*7R99?V^X-V-#7BQX>z-6W{J@YV#`o zBtKd+%(c@8OUw7T>%^T)E8i^9d(t~4p6OlPCmyeP-g;r?`RMa+EiK>c`fcyiZ~ew7 zKTSQ!n9i}39+nR9aD(=x<9hn0Lp0-7-9 zj~cJ2ZSL(1I_i$q8B{LUn1C+uknOe7S=XFDYIaKR3GRCwz5)2kmyXso`d8LREoI%B z+dmeegY=B(EFF2o$$`-wxt2}wW0)9Q^Bfu98hIYfPMIx>A+vhF?&4HAkL(_73)iw~ z?UCtgY_EI(e|}MIdGs5foA{n?F#WZeTl8Jsb+64FTiCI3GuN9x@Y>9GA05O^=WPg1 z5*-!rEd#Y5#bZpft4p1P=C{G=7x;6kXZln9pOPxy>(0uN zKA(WkWTTn)4DFDsb1wYvZg%*OS(y2>!(TjPbfJCD)X&dqe@BIP1zY%PKmRWsz5(>* zj7l39@p*!4kB{g3DxQznB>7BgPx|YK<30bb^o{JJAP5e6Dr9(yx3U)^xqekLE*|BU$d^LdN)spfp8e{KV|#<1-g6Ga5ay z`SK~5>l}^LufwblWM075UYR|BKWD@5b=Z|3jt*^vcv$OhbtYZol$6dvKnJr{m-YqY z4Q~kc>bnKunpvIk!S{6AFF{|1G9|cLdy5Qh?;jQBXRHjkvQsN~*2FrC6~NP3>6&Y| zHQg77@AQoD?`&w!4$db&DgRRbh3eY;J@wC6zf2wDIqZqfUD7v7P4?PYgTZ@E)&}1o z^XDa{*Oz*DrgZ@F<@t7Ti>JGCn_5TvQs}*Zxuf^%r)K{Bt071~R~4Cd1E*MTYg6|-Y*i4}oXphioTLYkm6QS#YNOu)|p~j@JDs&a$xq zX2WjcJN+td(y!ts{VHzaS22%bAI%2@TZxG%V@-U<&H$elJ;&HHY0zDN?Ds*0P?`{wT=pZM&y?>xYdsGP;C=ak!+UrerxT&sK``QOnx zF5U|FA3}4(<>HRT(K^v{qVTEAUL^iukRPlK?*IL7LmA~h-xw<`6ulV7yxI5SI#np za#4(&9ht~4tvX9m&l9%{+EwcZWmA7;bL-Yu4@dRP-U7q>sn*wg9ln0VzP8oqE_ZKr zvCD!XAG$~%8k6u8JY=y?TCvaIxxw;t+{NMJ+o(P5wulQpZZF2q${@B~y*s84; z^T)n;MaTzH9T)Y-G>&H5@#n<5KjnL*iW5BShk3pQKT>A|VUNddg%(lYFF{}XphXeh zS6r#{Uv-A2Iqi_DB5M z%6afgJ-(#>HaTmk@#l%JZZ1i;e&QM8U+4SSyXeVi-Sd21%DZ?+b?SKUe5c!s!0!|+ z^3z>yT7&bT%f)|k(OP(3^&38={1U(9S?rhmjNbVpan2@xE+O|idl|efc2qsdY}V^& z?sYzDyn}PHWcr7jy?m%XsMy#Ep3_J5_Hubh%O%Eb9=;l(>r>?SisLp?DxTgG!cw(pd%8$&>P07?Hc8BrM zFQ#NZ;S z-V!=DI;D+A+W58LosQeC!@dR5S>%K9t%_}evp!e6 zA2|1iV>^$rJsBT$U>@UpGJgJnZHrSMpNBoUWE5jgUa)4&_uTWIX_@jPp$-`B#z3+z zJT&i9ho5m9_{{S2(L75;aM+>1@P?x>=?4&L!TulMTe(wX#^+rxe@y!TIlb`o*u~2@@EDtPOtQVK116V zBwNb|-F3j4-4mILOi+^nY0(H!7s)fw!tl{E!!-KK2m}2s)Gmm~bj44bX$6byEe>o95)UqY+&S7`= ziTgMY$nkm6mu-)2@AGBgBb(kavSa1J(8D}Ul}k5n)3N&jaNJGt)uVp;1bcVk=Vj;p!`??3uE@Z%W*eFT{!4+j}@4hx6V! zFRs(+G2Q>U+cQ=bqek$^&apGz@b%rCCvlMa9rATU=8u1ato!>z!!!S>dw5Z2>OVs{ z=jyMN-`?@&NWEvqWuB*ARhw;*JOKR}j3H~{Wn>+CTg=ItSH=ySu&_#>KN8$juW{R> zT>aAZHg0=1Z)wgvO`9ew;HB75a1^iUyTj~hG9D-}e&^yJmsdDf`+I^{Hdore^~%Ba z4@TP8*unGp5wzcQ=Yf5Rty>j)v{$!17(YjKt(`wa?xwE+S~Nfla>-|*#hze7O9hu> z5nMjwap7IV1vpV$lAQV3Pi~0$g*wISAtkJvpG^`PTPhC!ENPBP( zZ7CljJ~>yMTir*+xcG~0oHMatdb4~+>He;^bve!ZcE+dgjFx>(W#p^7lnYOJT`etS~??1%5iA`#I67Q|9 zdKVpJzDagRcy>{C$Li*U`;O8<$~-Rnfq8|&P&{>`!BF4i+u|QtemX(FfEUi!vBoQ5 z@%)<7y)Mo&Sm3oEoVvmLM%E!240LPgo3GP3nZ)aBO8<*9X8P{ZcR$H@ws9UMeOTFN z>raWL7#H5@0w=}hdUiF>ygs&ex?@N8kB=SIeEL0`(E~%xDT3qE#N!8dT-XfmyWH3o$3A5n@Z*rYTjWLeoLBlUij9~|8kS@UC1+W6nnX^uiSI~dFO=4 z{V47y1=s&fcHqIK6}#|Hx_@A4Wo24t<6^aYG{F9P!p#-a!=y2 z%n!e$F<)2u6**nuGfizQYvs&}v%_ty#Ka-o6H8j#6$2OfW^!ZfjCkmgdzvrHe9g7< zJ=c!f(iq;xx~qitOuBXc!bNW8wRlHe zOBPtvF}W!2&^DwrSUSl;27|Z1LK*i54Zp78ph-fcyGtjmRHc{;ruOsD?Gjrf0q7b;pgH$ zmASQ-h<<%z5%{wcn?i(R>~>)_5M*c_@+n z66%}q@Vp>d{?)DFIQf1H=0CiB=rR4kS;q7P{Zl_B!;C9=n29-Zh?B_a=>D=#4|46 z7>?1TX8D^o4$^<~Emq%ITpjZMvrDmGs~ztXA4$iJD}-|V;2-JBjvFgy+K6A#!H-tj zW`3!QveoF-gAljzbHI?lPcxK;`QeEc2B%^1^_wBhS@tVy4{9>q6 z_c$L`ur!}1dcWm=Sj>UF;tX}=tb{)_Qaid{;xyk9RalqEvurPkES$`bY{&~)0+ck-G81?L1W8V$F*nWI6`HI2ra4lXP>qx)E z{xSJz$Vr&?izV3b@i|w#J|9f0+-j-zNRA!Tj2XxL3~S&KwZolj>x zFi$w1S?65ivGi>&rh4_r%sX6v3-cHY(T;Y)aao(Yk62l;-H6$IBR1kUSwCit%v|GX zMf=c`^;pw#|K#$A25S^BEBX!C+$UJ`p>vaz8|y}vI+=s3^!>=l% zD^nC(vevBPdnrD-8UJ-nZUo;SKLfW{o}X#-xKYP&1LkuMw@Q1d^D{H4=jF@t1FU!b z5^F?N#{5b7VScy5FIwkg`2l1jF)?|6z&c>f+374*8y|SNCq9X_6r4+B`v`wl*&8Ds z3h6UF_eyX$czuO&4VDas5}{eBDGcmA^oJ6t3h6o+HM! z>tCkwC;j!$#e1&2FP!K3b^L+@m*RWB65SG=-?9GRb3tY+ZTUGfnDxRqu1h3g6G7KvL$XYWr~TmLvZ^A_4FwhitP zs?cWEn9MTQXZZzN96bwM|AMuyXVCJdF_|kP^}TJY!29Hw%mSW=YonblJ2WQqD#`@2 zi#4hV>~irM@^x;UR32m85bi5`c+q`hgC?ZNUu@gUEo>$1S! zX?ln?my~UI7r6G4`)gC~Z$tBPKi}J{HLI*&L~rbOI$Gy>D}N~(9!$Hnt!bSv25i~r z^Fy4p_J{R)xn;oC8AN^Xx~=P)gxtW_-M#Be54CM5{ZBm~3Ge&7)5y>_6P}NIovbo) zO)0-uTlahmuY=e(1s-NQp`E9Z`t(imlhOX_8s9i~x$&-}3;6A)yZ~)H%_^|eA4enn zT^2j*8}mhfrl9CD1P!v4yiQ1iGy46e5t-et-w#ImUA;~t?1TCq=7iYu0e#mI|5_Q> zDXr}%Cv+#UJkIQ~%2jsYrw2faTx z#ojmJQN2&V_(yPf7k#?@zG+T(QAYlb^>TFwlym>WdU#=|oICq?lmS zjCr*^!Sm-UCVoQeIF6lkc5L0KOw!TS_sPSM9kcxj#PsqP_>L()T(JH7eosSsef9nX z{1e-sKwX_v*mQ9ix3F*ZNqwuz&QYh1@f`k(;$+Sz7wl>{=;{}RLmh*IF86H>;yc~e zlrIT$NSB0ptaYU;b&vi}w9Y!o`R9xWIVEz_m--wT_cqVeyEeB1x+jv4Pu2XBjZ>N! zlfL&jz0k$I=%qZAe&g_m?C-b92@;PnU#0qdJGOTka?SOf_Kl+L>@~Z-0*^9se))<_ za&3K@o62W*Ol#IY!tNbwn>{aFd=5TX>10`LMR9-9$+O^kJ{6ycM_Kz4_ARdXJsdBy zp`ISK<1+dsj0<(F59GW!KR4W0G9N#a9K~y&tClt8EnZC4POuKk^JNiz==-3#OD}7J zeG7CyF&tS#PZs8p^B|w#eI=u}a6vX%2b23IG`WaB=~Bit%%dSoRzhLW8_OTLZ#iS6|bh}*TE0lT7kVf0S87gJ;Ssh{IBQ5Mt(%D31)Z|z&v84)pJ zA9Aa<7NS2+oTGY4@V8&qA45mw?CS39*~eV`#}|CTc-ZEzwI8WKz3P3P!quPcUUxwI zRUZOppU=}grgD1ncRS-xFPg=^0nzjT_bYy0hmS;F2|cAa>{wg%-v96F3>xH4ezVq# zYwu+tjxP<20&-ZTk9!B#LPXad93QUSm0MpA?#G9x;D5f`#~eV_dV=`io-j{y`)+I7 z%-loqnU*AecyxlQ|;4F^Mj0E zxqqIv4*B?(Ix%$r<+P`E>>b)M`thuRHVV)sx@My4Y{}jP>(a?X!>{|>X5_ll(@PJT z-ri8U*ZUsus_Br$_&Z*=`m)G=+8%WK5%^3r*IB;bBZpIw-RXQAX>KTkTJx#gVNp7S zv)W|Szr|WLe57)M9Qb2g4}2Nk-yF*i+{WM6x!%F=9sC{SI?eBg`TGvn5AgdSe?R29 zo8K?+_fxLF%J0|tdz$NBe!s)tvt0j(-=FgLTdt3*EXG;M8%p;32b3!< z{!Vq`?MpgGx-v&))o)X1pE>BGJfR5MML1z z##3BBjSd8F$>@)PEx8g8b2dr-Bjk@yAWzUXKhPa-x#Vp@hB@l?fiK5f3Jb$rz^ucE z76pep7OAhrg~BEL*4Hy}U~Wd2-zK?PTaw;H#>f4DdVR9b@KXRU8O>W`nY(Fg3C~U- zxBb9T`N*bJ`Ahs}p^b8DAI1KBaSi>r^uRvbYx&hW^iT3YAK!hu2-qZ$_7b<03%%dv zyeM*^2ie~=Hf%$*PRPFlUX!lTA<;ZVN%#R%14EC-zmCLFPKO@Xd+q6)`e?0 z{5^PIcG$_LagG1|QY|a?I zo9%deMTECk07p3<*31>5t=4E5AJZ?RAANi=`W@Tm=y#|g(;B58V=BJ%a}&x@7O$3U z@LllX?wby}pOR|No)@`K^4#BxzVO0J#pSVgI$ZY5%FG-B7r_Fba2%I`&$1Aod)+wL zJ@`;>d!M&cs&jFweM>B{&}bu^XOORkR_dQ%N}lzu`lvdUdcg8$k0tPlE9Z#$_wI6j zENhOUeO&6lYgAKS!^e*}K7M32e7yhN*7rv?zh`#l>4zeGtZ@KNWgM=g-{jYWn;eE@!g-Fz_I><_mA)k#d{jlnDY1Zsrb{Nz4$xRH|;trjXsYn zek0a0aFG0M_ZxD>&Cuy->V7Y1KgRl$mGG_S32c((|3!z!uUM{sY~xoPKRTNG??7TV zEjF-sH5Sm{dsoG038*ON7X&P z3VvrD-}4HHgYc2t8gklmXMRc&K4`N%-bnoRd=a0|_A8GH$7Dq%_Qt1CS$o5(Vw`$$ zu4f0^eeN$2L;q85e8@9&y^X1VF1Y@=_jQg@FUCFzy;J4SMDSHC!Fz5maEQC%9XR}l zg}<&3_a_oTxnVMA#JneHNxV16$c8q2AZXdNt;UBv(rIIMN64Yd-O7=ARv1^n3-+{rh@OOl@oK;l<0N<%uTbk?#+S&aNC9g}E-lSqPj> zE|(yl(VS$IcYelw)E{FVZ)JaPB8F`PMpHdF;tS{-2bFcc)U%jiB(`?6^f<8#b^Y^d zzi#Bw?2fvl%8eJF!pB+?lye(wJ2wC0D{zhL%+zGt0G$&O_43%`Dz zav<0q(e3ktYws&%?~85ka&m(YF;4Fs&^yS6{FZF*RAO|lKV|%5^z`=$v$=e`lxIWO zjOT;g1+uxOtL~m`y6XAOlSgMB?e@9~xGUsShcKFemw0p2{lo(8{(G03D*TTPYz_UT zI_3f9u_o5=;C-zH5R69QxF9Hl*Vx8S5lh9_l{j$tLFv8|=pKBJa%bDr+{3*$=N?W^ z-FdKk>Q3269}8D#@~+tS-P1!|A{bwJhjNhXN~?gSHM9rQQ%anz&H5O=yOy48dSH-V zlZdleGf4gYz2rK+y0%%q(L=;+e9MJ(ynNFnZQlU7Y<4Aw=EjGynCZm%i2br})g;Q>e9K1}naXxOmOr{7ZU@=3k6g0_XERW_u9Vl}2EX z9)>1c;HQc|i?3I)Px#dbL@W4SvMaxj^-JUA_gzSTG=BZ~+={zrp;xnQ^m(ktx9(^Q zGTVh*+V}%sJlP2BgKiC?*5dVmr|}jx;v%1)bLB6_w@Vuw-=@Z7&hvar-+Opp-_5bT zZn`#~MPqh`?^z`Ww|aR0HYW40`<)y(c;GmeoFX&~_s^MJs825f^X?F)`2oN5Frl~T zuCenY8oM$qnMq9=X5-P!|_ZZBi0aqVZGPyE*eAJ+|of8l@AVO_!Y-5UmF^SfI^ z*?gyXEmEh$%aJdO>^rK|L(cnrJ`XuX-o|BjOlaNiK z`>H;^M}2ht`e)#E(%vlEJNUr3W_t(wmyPqCG3tH~nd}%F+Ev{@!@aN56JJRjlsuX3 z9Sa?U`Gg(BIeQ1Xud|H$P$Qz$M$gawmLZ zeyiG2y&nT>xwE%V!K=+0C$%>P*@f?R`~9T6N#_jD)Fh3VF1mi28#}4p@r=jIWa%o&(ywR1zfLC) zweU%@a?6&IZimO9KV{#?KHL=FgNpw1-|K(znEI7H7QUP+cp2)Wtp8+wXFQZ63!`ZFG!dcTkUd_SQ-`i(3Nrb}{qJpCE`QK_w@92R+3v=$!X19XdZ zOZfhTKCN=J_?c@b18=Eo=NGocbL5f!3Qh{T_D`LVf94f(%*t=Rc1nZtoz~7X+u|PF^a_CykJVLIxd~uu>=M3qiR8(07?EB_eC!fG z9PUrKehLTG`6_eE!w0}?L%MZ%eY*7s|9f9%jmRreo+IYP7Z`&%->LV)Pep$Ci`3Wu zHMD_Gv9^0#PRai}^TNVceEDiZJP&wX>gSXp!cSOfNp7lT2 zR9JwF^iEH=hSPJcs~m4ao>>kp#k>S<{)9HSxHfO6&2W9LRb%Ea`aBQs7Ae#E(XRc$ zk^1q~_d~B%t0y|rf6=7}TE_jZhyg-qB^*0l|JQ)4-jTe?282`7G_$JIJ)*@Jf8#4 z=SJdbbf!E@u4zE|CFF>Cdb`uN(aR6dl^0Cn9O}-h6)wN90$bqLiG-uoP3-LjW*MFL zt#ov5=M3GiSJ2tPI#C}EBe&{@^#wUcwm+=jlE+TQz(*-dG0xhD?C(VX>>0BB@Bc^e z@EO#rqt79-Y40O*KJJtZVTT`<97>zK9U92RXdIda8|G7KMd_^KASnSG97!xqZ5yu0!jn8vF^i@74 zxEnkyCSYD7xcOid6Wkib1l;dX?sqZ?U6b8<0=wn(`VWYW?QS#fD2rn_{f0K{ZExz> zG2D7$f!@7Ut?bh*^Sj{_@)<#XIq+9GJC^hC60`_ecPRRtkQP(1i?k8bgZz@Gh0fkF zT1+jCvssI7@zK{MGw|a_`1)XK6nDh-dv9dFk0s)O;$COJ7e@Abym-a?7HbrP?fCVp zy{z?7cRc-*tchMVCQBO!uXeoupY-iq-hVyEeRB8XhaN1^J(*uwLY>}8*wIN%@jlsJ z-huwydllKsj-=nDuE{me)b1}OhtN>*nQ-b~*i`tU_%a>7OmB|;Kj#9)K4y!<`Dc0; zeF`Vg{siu~15Ul-$&ussG9B-~(sX=}^7p^`T7`}SC)V$~Bi}1Lig#nHviK;zWqV>f zTADwI%6VVyZ}sakUHv3(yIm~hVZC4ctJan9KX3y4l}FX*WsN~)E8tBZ^O7&aXWcsh z{U1)oU-v;%@hAC_41NxI5ZyJms%NoWeG}j3BoSrddcWAzx)~h5 zqBu*k%G|KY|7)6WI5~N(+EC(mu9DxtiRE=0c4ixTM4LaPy^!BYS!HZs`X86`u4J{# z`&CyziPu4=yZ#;FpOh*6Rt_^MFFHTU=BB0xHn!s~x~Sdhw3o#Wx&66Xuc9@}FSwkq z>|?mDDxb_nE`oLgQ-gwXlD}qtwQp);p*Q!^#oC_}euFr#Cm=5hUGHX&@E+!}T4xjU z%sC(b>HeR||5^6m`!bDfTZc|;yR*zeYkj_QpJ7jAlfOe>4bSu_crwQzJYN)^tVi!% zz#1Om`Oxo<=jFd6o}x*Tk7LDcRX4eq=R0TQpLnF)9tut@U+bMcTt7;D#vIE{UXFO4 zjL&=y*kUy5nf$X}eT&Nx+5Vo&cy5dst=Z%I<>~pmEAaKOk~nMsop`Ttf%aD^N4iF~ z;eF7k-?s^kHdnNH(llcJD~@3+XkGRv?02Ql{F@y=q0iC^Tt}mi&Nr*1kMvr6 zG8=|n|5t2m^4#u_IZv(EV~%Xp^I2xoZZGqk!#x%Ee6|HgG#621ezSK)(Aswq^V7Em z4a7i-!CF_4b6J6(t#*39;^v@x#WeixMLeI$H)8_vZ$&L1^4QThNBNcNMapX*yOn## z8T)KQ4>PT2P05U}vGUS*D5@9zm#s+tpH=Z+wj=p`X2t&*75}GK{8w%$dY|)IBe|E& zW8cO;uPe+)vbI03wRTz7K3V^ugXxIsYTU}Zzkx@`JC|5S{lpI)^mC5P%iCPMD^J?4c{+=sZhoHJ^;;SjZ@zt( z?m&C1Wy}2h=l{yvF6QiOi5E4$KN*@z)}YbfFb^oYCeP;8K1#zamCC=#9z52^Jgq&s z!rPz2cDyA)p7Ez!&Sm|N>nraMRiDSW2>35CS7_^|(gAA+6r&KQ zs?E`3;xgFH9`>7a^NSL&Gxg-cfTbLm)~>m8rFInQpLn**%dy%c&(_tmX@|(O zG8xA1`?%q|&|G*+?$|?8cmce1rq|hg9-9>$`Ryg;YZl7p>2uSLgz3}rS_@fxwVO|QM(V&?gLR9q0c>F50ux}8qAM!o*~XeSXN6NiTTW1m9|&t;R3Zw@#l@pPF(0YJ5eOvd9m} zj?c6_Sv|3TNc{u1&0MDEqAO+5-OVkoY+{s~QeJ9+buA@0_=lO1y=O0eM6-0XZ$Iig z^Wl;~N0)s`8BCWy8;dUUpL6ms@4WnqQR#ABi7vN8m)pzf;$sluROg=6tFO>by8CIT zyXPPSqJ_OrpFLf%!BITVv$DRY&MeJwv|P9BkjsG!Mm6n3^q&F!XGZj=ETX^s=8X8= zNTy~kGg}Q^O!lDTQ?k{!U8gfeY!0TAHjr=2b+@-fYbLZ_V)y@a@+~?10`i^SP+Bj+ zUJI?4_@?h0{g&OzOmlO9>MuM>&M7SL;}`XpXY;o5ocA=Zu6EVKCZX(I|-ENQSXwSyt`@X*`@%@FbR@ffwKo{-A`tXOrQ05Q8GocShmlB_Dl0KP+0Q4mz@`I-;M>S}R)vlDoEB?`(Es&ls-s>NDcaC%fxg zZ!w?Ik;oJ2j4MCCl3b^7%w*t`%kb02cYmIn(ULV6M(3|YK6bOq&5ZVJEIRLg-qHD6 z=jA_DLFf9_;H-8gR^Ok!4jMBzZ0n|=z4}QkV!C1d)HCL#>N}A06l-I!lbPzst|rf} zIzpektu>h!EZJLR=(82iBAxKI<;?C!$?Zz^=3a?CzD7Q8s#~wHj(y6}ey?~>bWf>l z%>MVd{@;6kexd8X_2&!f`#RR&+`@eQR6gs<it}yzCo5mHwO)Cpq z+6xWR_Xpcg1-O55W zIQY1ytL+Mt)o=stYyc*{;YlCsP`4AD)Ls!d)+RC`oOGY8lxW};-^n7e(WH;h>#830>Q@d~T zIKmUNf2?~Azn-zNi1;6VeN_~*VE^jJqhq!8+rH@5by?qd&+@p`yY~l?4YqmqzaF+mq^Y+_cAy>saVJ>P*xDC%8R~Vi(t{~nP=ZenIaXK`gJ_>Is z8^K%3V%|;~$KLCeg@H+qw>6HpHRZf5=2o>@J5yDm3Hl%;XTiWux4b@lp;oI_j3TLe>c;5C?r;@inhn~M*!CSp6x*9zAsQaUmd>SJ6 zssBMLusALpo>1cT@TDbQPm6e6{n3)&m);t$sTUsoK>oe|D;it%^7|>qeZexfp5j%W zpB4Fr;b+rEzwp^yp-8#t*FVR>WSwEQ_7j5xJTu$cQ9YRHSTl$%Xb^9A%`DZEEXvNC zAEu7Y@3(y*f3)QT`5)0Ity!$AT{Bo$n;kTM+5gpp^&P8$mpxX+`JA4v!|$dG!|$Xc zIeRx3@$|uTA$%|w+mIDbzf>k%>RoPM?TCH@l&{5(tjT`=%z=B-g#q9kyC+vzld7p1 zXiJ+N8EDHDYS6_scRj(mytnMAuLU3MoXODrw0FPSPS_Iahu~)T1IyZ$4uIRw;G=I_ zHcIWC2zHs=(O;80+KlB+eV5$1Ha&g9cc^o!i;bwmTEfWAmgnd81krfqu*T&5hs=NM zX*un~`MqCuaSF5&-xkZJ0>^YjaeK!E_-A&>NWMfjun9=d`zyv?Sy(g3hYt!nl?{HuRF8vPp^0XV@a<& z_MSkmYsaC}D+e`~ok{AoFOwbRcfFp=u8pDB{l4Aa3-fKRU6TdD8joH(I5y949c#9n zKJWDNU6OI?1htVqD+e;fzTyGOL+Y;9`@p%3?=OOnKilYj*1G$(Nl873NJFqx+&=&=GLAvZLDsVuI^nmx%yCbCVCq!*|xewo=jyqq?{0wc$fA-Us^1E6W@5dppUm6sCPG5F6 zF;99a=LjuqWPkCg`O~%6A3ST2n<8@Ked_?S<9#bUjeOyRa$gu4&xv1<4}X8r2B()! zjwefw&!#Lqn$2&Z&%<{_eF*PpF8HzX?yFs#nJ61m2Y>8zkg@W{{~YOp;+(oK5SIZ% zW99ITajoGSE2DVY<^INkdC-VnQ1H51@%?B;M$6H;Zx0p&JRRmc+t}8taGwPe}^xJUTdq^7uYzY{e8vDfg>My zDSf$g6q->sf@YM(G+P19fHAPb(d;4zdo{GZh<7Jq3$eAub*`Np?d0@3K{M)w;AJ!e zj_~j}#WWMXf+>8D1G9`~^wDTWTQSY_u4v}kifOi1?M3^xSGhIz)lX<(ZXppLT~^Y8nFg(?LFKf#}n+w&C8a8!vcxoK0e~%RAxGrbr%~ zt@U%Qvdfjvk~Es9x#egaYkOz+{mjolotOXl!wyI2EI5+4#r6EAtvKg?JM_7|G0M3g zrJv~Zhx}X#@5RsD{M60Pj?Pfu{d4B1!qhy=ZL6#Xcv~scyj8Wvti&i=HFrgMY3?eT zbBc8doQ#gp$M``%J6DFiD6tKE1)OCA-JX&ymHAutmKcs6XJ8a5 zi)|oh))av;&;##N z1`6Nfz$~+Y^wDe}ZN)ZF@5%?0duo5s~;x{pJHEb{~G zQ_y~ddj8kzoQzbqCTxq!Sd(eG6yA-zw9!}euKxoGJSjI>ZylKfsI)s(zi@b-x;(#o z6#mvXAUlGmdAX*5^Ay34v4V7^!^^06r*h^l!q=)|do;X_bFDrF&ilc0TZCuW!vB`0 zcr3RR`InxmOfb8=9Rg;Nve*u7zzzXpU<0_w4y{iWGS^UNJ@08h*=x-vPvIuuZ&F(c z`$L@&T+IIPzO^M=1up&ahr(a5gySf%%IpsPiQ?6S-O+m%uSRWnyAyt@sWi7h9qkoq zV$9~pYd?Q(e11CTRQme3@r{S~y$TD&TE zKC{u6`nDcV&wLKyj;dD1h$F^7{(Q^frO3|(^iA@!H0n>dg#SxMksr!N$PZ<){47L% zfHAPp$ZGxxkbf|^|V{$SzKQ}bC%=#EZ{=7E|+UEnWOAv zWp4g}$=uT|tV`PDWKR7S9Ko=-5O}e#nH*>hSmaMCV!zMIudZ-?0EX4Ww{!-}Y_8Yh zW}fSOd6Q4UN$x-K@qvF=&#Zr}xp492S-fj~Ow>8qz8>UEUe+|c)xuihvyY=+bq=Rk z$LQ}r%3iHl*$}#Q8lJ|I{gZ8b^P8^iL(B8y$Dp%fPTIc8*4$;Gbru@QKUbCFXe&#s zg$a`W+|}QbifC>!Oxb8MJnZBxmVMFM$<{6OS-D`1SB8h(df#E~^SGf~dU&wT<(3j< zWBKOIzjpoq^nb|zL*CgoWQ_B6h6aFO_rUUj&>O74aC{aCun?W+ze%E!X_pvUL)AR{m^i5#wTSLE};+(YwsaC_2F;#J4s?}`U z{uF+Ib~MiV2C&tpem{|FZG<0Y6RmyhEdAAZ+RC7#*;)AY7t&Ga>&Mm_Yp)e=Uuib` znDz7A)G^zm_Qo?7h4!PJ_gE=soByZd=a*9XX`VLp&D+UEip3nv`-m^K-$wSX?&jzm z_~8c~pB{uyh7))GxcD(llvVKOup7hMzUh8&i0oHY{@?aEx`0FGm*4@6m_O5_^Y4sC z^mq6k&J1e{qVefVw0{qE<#+E63OeHtd9pP+x@*zip-gt*B_9_8gFS;KzkWwxFb3{m zJy?pk@HPkgX6oI>yYk1=qx)!d=V*AR+DrI$>V)8=wu-6`g?+KK(U-jjcK?Wis5TU%q{UO#6)%iPvN&;ZRsMmCA{xi<8jXPoFaML5 z!C}`9HnJPPK76a=^Q|S@vDxntOO%bpXKpSRpAWY%7xpKf&)`~(?FfO9RlW`R(j4G# zUS*!)B;jsO!2N@<;NBeMe+pcq7qE?v^y~ZdS#xSX;+gV%|M&;!ICC9G^q)BUWqmW( z{u;7xfz6(rmetyO)8(qoPM7~En@>4jXzzx6dlzt!`N1$9=$!KCEOM=x zNK2-;e}J*-iQqR!@DI0)o1HH{81cj4W24vuQ3$vT>UeuW;`1e^vLA8m{VRKdl?&oN z?QXUGD|=cFFUbEO*=DWQ36}3)38yv|Mx||qEU>?~RdN7b3dKp$8Y6W7YROjy`wEQ; z)A@}LZ!J>q__V2x@$A3G>DQNL=Jz=~tqsveZEW4TOmNi(KCDnCdzHL*=WvQIUS{U) zBHxYN{4slU%4nQUoO7S%9L%1xy8H2&y357xgg}-@;p?8@2WQ8)@4M5{__A~Iw|N?; zHw{)dvF;Q&@;$N*^4aps(4|nUAkqWe9W=VJXT{oXJ#pJrpLcD~{=NLw ziMBK3D`>-Vh`M{-bRT+@ott0D*9qX`WcxV&cTX^GVchS2-!}ceFu^nRTX1~81J*p0 z@#}YK_s>f09=xkW@9AT;%eum{b`x@9d#NVjgKL~`fL7v3{nm))`}|t1it-8{u_yR# z0C`({wZP|WZUFxmmg;lpD{+${b&|jN}ur4tR3JCJML>Z75Nu0 z^$Epa`BVC!GeH#FBtlJ?ytBlYzfSgF z@&Ft!51E@;bD8Lq#_g5;JTC5|=qv9|31`8}>5iA|Yc&1&N<`a({;Y(G@)3RcQmHTf zyIf!D7Uloc^~LVwSdCr*N4a^^W!b?L?V;zg6I-1fwAd%Ik*&hb_@(#6FYx@aVyamV z&+8&QxtqakXl40Wc<$cq@Z_wCs}p$IJUj5?d3MIDnY$*eQ*7^P55CossT0#b$Ng!k zyq$TRq(64013%j--$=jA54ygL!1*VOVmUe3=5SuQIKR;2?EUmfyx;HNr_a;<``9-1 zS-AK{)eim zCpjlR-v(}l5BVGcbu#eB#$D(#@?^Ogy~FQT_!XTup0HJV-((8CQv6nC*DHC1U)zUY zQw-ICU()?+=Mg_Kzgm#2(WmnooxLm5A?o|RWb>0clqes;=kI(l=5zCZbhIRo_00!L z@s7zRx)Afp#h1cGK6(SVRqD%6U2K2ce$q#sJJOGC4HF~T`@}P52WCe4bN9-}zJLrtyHG3IK)F-#5vvFBd8J+ch zv$LNT2UXT{^S%~Sg3kc3{rbtUqHdPDey@Aza0uz2@$0xeXGlCPe1bE% z^M*cZ-@WdJ(w$C**C?X%;jvro^0ZC0l&BLeQUtF%v64f z>?eKmd9>lbq+D^x*Qv9g&o`js6MUZH_cMGy%lC794)D8&&x`#2KHo3%{R*ES^SPBa z$UP3bZz3jVB~wUdDIgw z|Lw;vFF>2m_}{fbo;#W!wmQ&@^Nt@jK1^)1T!MITBG_Jj0$@D?+_CC&{(h^;dveUF z{bSx1C(F^Jq<+rR8GSQOjrFr?sgvo?pPC=O-}xfZM79_>0cSq6f{$+x8JvtiZdIO4 zd6Lgn;_7UyXpvc-YmmQ=ZB61C`Q{P%k-o&3tZpkZK|Usm!%BCBDbJC-FHCXjz3wcI z137o6ZO)BnuZ#3*vp@H)qC80#vs*KMKc-7_-r22B|9-yU=%ThP?gEbY=iK>fCF4M!d5nm}hZLbgw7%t!##sWqdv2pDphEWPfIi=@epF^6@N z^C|H=;3}SVLYu#ZXDSzOWK&k07nmJi(G-2pL-qpBr|&}tdTVs|2WKu#+PS^jUZHMdyovcRIT7&G6>Fx0#ETH%s{cq%A;>j;BYD zpR698Ix&`)`5!6i(T7ISqbz!4b_V-xJWyQWbjj93Nnf%q*LF^&EmGcQJoY`soJW=Q z-R>x)|8{3y`YfY?;$88tAHC_N|3(M+crJM$-A#HZai^N`#L>QsXZ6x|e3)_s(fu9~ z4}|M`cYX}J=*C3Q(B~7#9TgZ8=)6da&CDlI=K7}`gZZ^$B43tqV_|at4I1AQ_ggNa z?!E|?d|%xIft{nAtZOjuZj9^+@>w}P<31X@vr)Jyw%+1%>~n=fIer>XNC)^H65RG2 z_Z8w#SUafmew|L1#*RK0A>7EZ4c09W6sKyOKUCKg*nUrstLh47DHl;Le|Fa(I2OTi zfEcDOF-BVD#z_7d^o+(&qN&fB&>tI<%dYR`j4MxXXk9r@O8T<@Pk7dm;MrsU|L}~p zUVBf9XR8xD`}q6dS&!}={s8wLabFzzfsazG_c(Wy6Hlcc*Bwfk4DlWx#zV($@@Ekd zFG&~Mv0Lrwzq(ku*q(mm;-PT>HIg;7@h%r|(=|bnW*N+gbY+wxW;coF!J!oDOHZ zoBn$E(r>{xy-hs#`1$s=PE|T^pllrWiLT+EbfmXE^5@miv?{tw3R-$Q_9*^Tw#o8O z;~NX}@HrYMe+}JiPrGNtdE}SlXM6RmZT6t-m~f~|1r56IR^JD5vpLrwI=iSdw~;(I z_uyt-TrerhL2>>jXJm1Hq${8A=0z*(kBAq>Eh*XmCtbYor6u`q-si?Sv{j8=6&ycj z#(l%Oo1m<{vEVj)xM!c6-|OLmtHA}1hZ_>VceZVa#_Ef09>U}?-%)$S<~oD4jOJcu zeVckuG1nb7Zd`+`RykQM&C!_+c{iH0@6y!ArVPpD=dPzQ89RBH2 z{Lt^?@V}p!pX2z9Z-s~WWt|U3{7^o(kr)H^lyJ1T2>+s(irjiMPM;R>+wSpO0p0bC zGV23%BnugzpIgz`kVS`z_?65lkuOyJEcJy?wdyDQQlHCnNN4qY-gsL>GW0R-iD6xk z<@Gs_FowCsol{v^|4ejPaOKY$-MtIG4~8f0>)dPM3LkVfsL5^&2Rd$$9KRX>it7n3VEDL6tZ>@kQ8|3qzoONw@j%Z6bj^@*K zZe;@hKi&?p{8Ubf*qBwVdwaH=o}V-79ExBDyE( z^uA*s=C)6YPNVQU+3x%1&pjvEZeqOBj(=1B!hBj2YeZZ;+MY%akrm`%%GILZapu8R zA$O8Jy`SZMy(2wH+7o2*aPa)O@<)f2ciT5@xAJa>TRwPRK6|hCN3=JGxE0x#j9^=2 zm(QzpJbfJatGcwlBbdCpD~RIqRIE!@pPaxDGGH=hV_yB|90lPP;HwQTzF9u|udxBW z6KG3g8`&|{9q;~Uu|N7harAdzK7}!-VD?4%pDOx1>D}SI;$6m#A;yZe%g;GH{YXgs z7LwQ6qWjrvBYYIQDW>Rnd-k#eM`uTIJ3c$pkZZ^|I~CELvh$!7a})L6PO|RFVu@+o z3&%UR@O%wE?iR+>^R64XICzgc-Y6GMI&&MkMUSu2r(e1;xZbUM$>=}1?xmoR$)~n= zI6tEI=28~7eI0bFa_5cm%+AR|=kATib7}{_(~~-)`<-9^ePn4#_6>>>5=WO8l_DAnb%iE{$RyF#dd`D!u856+&Gw| zJFbRr-N%h5bMstA6YDSxMju#`HzBM%m;Yg z1?6^ghu}t=iFltuc2#%y?;KoVR|GJgxc{NB1Wu<^SP7j^yd%z?098`+Z&4#o~MN zqjs(T$lRyUW4)vH>rAGqx7DHhURI=!>ldMi>(K3S!S;4iqjZk^lXn|OD--qrKD74JoNr$zSSYacY9WBHEC zcXbYZ+%|bUPlqksmlyF(_NMB{OCOS*cJ?QiK9V^Xea1Eh|ElrsxFg)kvOMTRj()Ur z`A@M&>PvVz=bMjrv7xKm+cqiBJ?&B5f7rSF!h?*Hc~)F-TE4hIYx1fLW^bB$1y}F8 zJC{G=>h;#R=bT*?{Iz@j?VZc#_~)DtCY*ZHJnwSPzqxbyIeM2)w zZtsEW278|4bl)4?^P6`r@8NmxaJHduaWW-* zebJXz8PB9|{VTHhqZeiKuP@E!yOB%d+p{0D^$ODMB5{0iQntb5pe>URuB1)L<~7Kh za9WJLToM$X_vblz*-TS+wX6GaC!3nX4KkdgM?AUB|mpI?axlj@A*Q)rpcFReieA}{OY^G zQ24IJJ@3n3ZT^L8YZE?1SQPXTo8L!SEECgl6C z;+(D1T|bT{czP0i+ZUGjHoual;v4W{zWoZm{ffRZHx$c_o7WmM&hTx(w`^q_;+umR z^X*uIZ^sgRW6!nGMD<7E-7L>LfB%Gd=g0lP8NoZj_Pq1s-#XcXE%^u}>1-8k9 zz32%4i^!gIsr~KYt5vSg@b}p-`J&^|_Z=NA9$_EoWzkq&d+$wlT=`gZsCv`UVda$k zok<*teRW2F-2w0#GWi-fojCR_>;HTYo7ndclCWj3)PDyvmdRIxp(15<%x`8|r`VYd zw*PSs>v!n0)hEs@z8cGGS)A#|7_&KBNB6g}7s=?j#f@?7Kl8AMf>n_{8eZLi&6uM5 z5NAq;u-}#oTS(r+%k+UY4f3bO1(pkm@>_xn9F^;Rc(f+ux$$lRK85$~H|rHVeEnA& z;;HZ;pW7;5XL%#$Kwt2>k$_!fezY@zYrNKyxulSHtc_^C%lcsdSFARC!KpfY&d~r| zJA+KrXZnLJjQSz^L+g8ko9x*z^$mu@Aq-B7-nnRX!@bUii>`4U>@POl?CQesYg9j7Cil(1 zaXR(WDf$1>R&HA|SE5(yJ216>DtsjvvUXnqXJjH=!MQIha;^9Jz9=Tw?1z;-=@1fJH32AcS(bnb@O@3 z=}0cz_-re8(OG#1@fP+#Yje6dBi_#U(0VNFk?>!kGk$;-x?HZ;wYt=~MsM#=1v`Zu zIp^Pp_wIDDtoh`lr{ou1&)wG!cROE1KH1qiqmh%>RoNXTml3|KZ|WJB{{>?ki%+K! zpRz70m6Pjw$~m9ERBrJRbo()JB=1R_2H zU?aAqK3k%BLKc3nLKeR3X$g;|yLGyMNPK&rzN7ztI~~Gz{AhQ%4vp$NHU&f0Uvwzr z&upb{;={c@zTXu6&!i$-81uY!Dzb38$wJ2CZ$4;hnJkE|oo{bkR_A0Xbnue<&6PJX zo}w#eyPgf~ES&He$LnW^lb?-jh@W>1DLYwzeK56K`J_E9GfvMxn52vRHE_&duZI@v ze+Qa-*iWCH-;spv{k4M``|EYIvyOJ4m6z9EzxPn((0)~FUgSf3Jz$tj2v^w;=&~5P z8XT)1t&y_3#*#9Ws4x1OF6^($&$*7A#^vB~dE*ea=UYa5(O0%Zw13g@LGgpffx3o+ z!!eVpGaQR5cYOEqAb81-cxAA``vGrHX+t@5cji%zY~oiqe+H`)>G$4p{_gYVc2|^- z8K+Hfd?!wuoaBx84jkkArO@Hhh#w>Ll{&KBAI0u+A815-@cZxdZ+LE1gDaaHwmF>J z%5jcmwW541oOfqRvikb2gboX5;26#;zvc&p^p?b-b;QQRA#(DC2YRCD!Cz+dxeI9s< zZg2h@Fom;Z78=#lw%|qbG6nlF)$E7K->Xdx89%lirR|$5+g2Ud_5;bb9nbzzZ6|nU zaS}Y!T)F5n8V-g>gs<`RONyW7Ha9SL6Zsj3R~*-jKE^DIYpPCj{KO`IHpx$K9~{is zJ}iZ|OCA55KTG*F<*JLWZ3(s*k80_Q(Y&vev)0&FlG{E?*rf@9<$|C0vOEF#As5`?{tLgl?Iu$ra=+IPabFM5mCs$zZ;zk7 z1O9d7gjIG0&zPHLeNAaUv-UeMZ(X)OwZ+BQ%$JY3ca=Elt>EuxX>P>!4fkKXGg{Bj zI)%4)MmikA4`NkYFPvQGsI}E~=eYi`rcwBnt!qvv_M@_9f;D`s)rt0QF!#rL4fY;1 z@jm)_(8)<z*JyFUEEL44VVp)3WjW{2_;{ttmim*ehf6I9dY;ZG?Zp?>l(G z@k4dZ7U;Rt$1bg9UR&BN^oTV(cs8! zDb{N(V9r%*p?Ws5){XUUZG3Z|)PbJp?lZejl=nRUH@iD4D$7UE>*)(({`a3%;{Pg7 zFJNM4xLb~Uf3&71eJ1m3>>hx2Fnet=7~1}wra3w~ib`*>z~lbIa_gE+S6z}yC*NDEo*z{BrX9cQV-m`Yeg+zH6@*Trf4V`a)a+&38 zkl#+9=NN8oNRGAQ`gf-qIESsF)5Y`Li8m-;dTe;tRpLKqoKMz&?vtp_eEf8Ld^9G! z2EJM!d!qGm)<@RI{i$%>K+L&8wk2B2x!?6ISr)I+ldKWb`17-yV;Q@ix#n4eddGCd z)sw!M&5R$qKKLTL)66;W;)QIm;vB^%4d}~FZVYU& z@UdZO4gXac>!0@EpS4VRqW{FXGM#VVxD5ZwTDlFa&$ymF(i5Wfkrn&TYz<0{_UPaX z@CW*S#l%)xhg%Pxi?Bn*boJx)*m-}3 z*F3G$NJZ;%1M)ty`@L!G?E-#}=QnnD+utf~q5s%;&0A*uycum+{Tz07x~q@9eUbXi zA+wGYJIlC6_WeWXSK#t_!lw>=PGlS1dgAS!>Dk@TNNX~q_S&$Oi9JRas6FP}J8#># ztkdTVnRAznFjrbo40zPr&WiF~?(R?HrP1KShEGa|0AFb7iV8-JcVoBZo4gO|(Ev_Vokwx!{lLF2Kb!Xx!o}@f;S1NHv0doe_ zF}pcQ{pOB!^bI{}m>{~jcec2*47H9!b*0mt=#}Y48XcFdbo9~hBu(^R@95+BLYz9g zTlYAK?vZ|1?4QiBUe?Ey=ddjnqVwa0I}cG((eHIS}qm^(Sut#i|Q=RVJRd-C}?eB&;9>b_y1q{Pn)ZpZ*J%J z4~SKwca4rU1_M|cA8GwrD4R~buRTS)hz_@7*Ja&EUPu}&7ey0G=at1~^P zyB#_1NHaEG3{8CugUsziANQv>4$hg%9$aL*z3oiu$-Z7csCAm1=?&fCHe}=i`b|H> zZBajk!vyZhFK4)&W_2YaLzb`&{T5=?pN^^c9{*A_oCMn+aCzCdjQ2Zx=D2wBhHll_8n3t0`A%BzbwhX7o$q8m z1N)8cv)_&wi`eOqi#fFqTY9nkP!xMePwXrX%KwhBk@Xo^!dY#;ZZRukYWb=(Fed{O zd6R7^gUSETIGBbvZFkbIh&S|GHsB@BAa=e@>#J?OV`}FQulPwcZnrZs(D^5M_C@N) z_fugTxXXA@<6i8-h)nbdq(cVP|nJ5rOj}hkIBJx zBL5vci|N`n4W=l&FxfxRp|TETA57L6-#E0zuLA}~!2gLn_kECj%%e>9&C8mtV?l@e zq389EA7yLxEBbLDH@ADlnGmA2@-qIMi>dNi`Mn$Z_*WcV2ar z-z}g2NPhF@eQbt|iAKQ5o>FlpL_KYhCwsxkv(7m~cG%M#<%-lVy{9tlbwTG1v8R)> zKN9;s5`D6LJos9CYP1LDcsOrth;iOb-pg>_vMhh0!&!FFa0ZUYS$vgjXixI-+TX|X zlg8n22?yogJPxOifrD(6WX{X0aQQFXA!~HPHVH@cJBm^0LvRjsr(E*)7cWNjiu@N0 ze9O@B`r*$D5kJP{{na0h>3DE?sSii*Ea$!8csj~fOaJAc<<}HX*ni0ac4McH1$`an z`OpJf>m0vFe+~t~x2@mGwH}$komP=8N9W4V(MZV0N%!l~QorV}DD~@`C)+Rlm&Hw! z!3|xis_c_{j^8)`6Zb3e9%qbfE8|Zhrz$zq-V>ekxBr>5mc4!fdo6n0*$Bsz`Li#I zdDY)o>eCM2r*x#Jf)n#fwpwxouR5Q(aoN!el*8CGc=iiHz5^MeUsJxQ^`!7rGKEd^ zJQ>QUFT9)Vqy0}w?h<>a6Z{bz#g9kR57x6fR5;O!Z#$KTLhS2eg)@@?Y)D@z^8)UpPGez)`dm#4B0 zLhGC9f5_P~DSTlj8_j(@!#U4-SN+o+N#-lZ?fl^v{z<-)HJtdGC-~lq&c%BJphIR_ z_Wt(tKVPgdXM6fz$S>|(?%`ylpNbvMiPoOk{tUsQe6;-;RcA-_h+iYx+WY1EGb(9Y z^+Ah+_Ox7aYW|0R;%!KTukFtOzfM0cfUd-K$lXSCfmpO{J?l7$^&iBZJSbe}elDD# zHN4Du@?W&p+70?(bPd;He_YIDYkhfU_Jq8+>Y}&R;bPuh(yVolABpHd8@ryrSo^xe z#pD`Y-m#xJq4y$Wk@56fnFw#&|BXC9lMNm|iX68?s}AI&pLTD#I;vwZ{Fs<_JZ{Ff zbC4VQl!E710>kq`{M-jkQ}FYJUy7gCM)6kL4D#*ncWy@acbx2mx4M4aiayeIz4bY} zZOa*M4Pixjhd(2Ox)!&Q@3gpWv1onryxe?liI!%K|^u6J@8$yaJSxrD3L*L(Pg zN7YTpJUEucr@|=<|KBH0{Rx~tdK{br73*r z|7LdPBy?f^d5(VnV{(4d2wbsMz|h!NXHQ9QMN3;3&$wznG82r)??a=@oIGm$Q`DFX z`x`EVc0Oi(``TsqdKu(-z`H)SZl$k+nFZD{zPI4d2AEq7$y3pn$y@MWf~EMnm+!Xk zMRrHOw?9i>&FPx8JJb3(--C2TAdA|AIw`0rr?|A z0MEyFGM=-T>tx~XPQd+)gA0AgM-W50_(kI}#RFx2y^Z`*4Srg5b@YU1+uM?{(Du@v z0Z&(r)9T&_jeShFFFhCgN4%OA9XVsp@oSr8gEBt{JaYWNzMe1! z{#NPze*gZ(EAsPyhxe;EKib9t-&v8L;@^j^QEZWjp@{*kPduy7q6_qQtbWZ0Zdz-} zt`73Q-Qj(Sg9~kZ%o4&c=|Bd3@ATscy8&PcMo-4OgH$JLAmv7Spo+%DYy+BTZ z@2YbehtAWUCCU}2RWbh4xcPN(Ag;qFa*iiFoAhR6H_GLwbT*RBIpofY&Q3vxkJIPF z!Pys!mxnbLty*QYJlw*#?m*mU^5nz~UAY^(tHH(HZEy2v!wa3CT!`M2z>?ykDg_TKWQS zb}Zp7Pnm7&jJ8kpGLPIvA_FTZ~e zf6W+Vd(p{dWgE7COz*q=R=xZIa=hBfwqX1UAN0IG*IIS65&r+;WLYxqKbeX?nEc20 z9VGH(xst72Rd&`1IcIFE=3>6tskK|0OVxOSbAj=3iW8Z~k)0(k9#}3v`98QGyUF!+ z2fSdc0h+xtv46s6}zg=`+>hN z0spMy!1rflpD>Xe-&O1`sfP88mpkZ!9|F<+Y38(-dPX;0-3`}XnIW6nh+-rpR(Z!-71f8Wyv zIHFAv-8bGUHo>;o*aUs~F7f6T zO}@$ds)Np(e?D29_xV1bT9tpO66XXwa5w*}Jh0Y|$R|$LxL)HlY}z3=uV~Nsf7XAG z##Z{Tahk_1Aa*F5ERZa&tHJO(*Jlao3pZ3mFBXa4$iKPdAG0H$Gx@rn_WLF zM*5M_W3uM5%V3qyiXsgn1xj0X`h~te_%i#8VxM4bfdL>-? z;9~${l%WyhRQT`5s{QePlPFfQ^^iQTt{Arpuld+m#c;&>oPX-_smT9*&`UljTp007 zLB|ER)lIlVV~i|(Aj9+tt~?cu_oCoADPduVW>pXp&eehu59wPBf4 z+}tI$BarF3q)@_Wj`*_pZM zpw4@(3&^pwVFQURZg#Q7PmznVbKH6E<#v%f%S(2_>?QL0catHU?_+1BLNJC@afpH_!&)v0y)q64{TOlWm; zH*{MaH}5l$=%>-s=mpK7c~|a{?rLbRJ1&5CKlYO`SLxh%i(^?6w9~CEapN@BYs~4E zjSzjZo+jkRmFuXh7*9VU8bx~uYLrJe8RNh49a^d%|L9~!^^$a43msKoADxq&!SCw) z`%lY0N#4}Q+F-kvxfRqCPV$kfuthO`irr@gg&0@HerjhiG>~7v4>*2a^oy+P6HOL< z=y4nKHZms6X5oJ}uw2e6za~`e(BEd{#@gifx{uRW%9SsxM<&#!>TDDZ$iqucH10GS zP{(KhZlb|Uo(8U5YvHUO?~O_;SMFu*cxUWez8pPOd^3YJdgwXp|27Ry({E?LG}cmn z2pk^9_DDy}K2*(YEc_+7m*;7Jhw<<0C7a#9!EE-^%F|wRTWK7c93N=xz6u*x#~gK; z|L-ZS%X%McZw?2u$CrG@#!~wi`u6F&wa-{6_Pyy~NO7S2nefnW$s9f}&Xw7iP<8Nw zWqGxV*b1Ae7-;Mq!J{P{58hD1@l=l^JW0|>{zv{L!+NR_dC%Xa{kKZ(H{V!l|IN>h z&ZD?};>r>2Cw)7zr#S5dao6GC;!|QC9Za-pC1oLpq z=TFH$>-z-XHP@|mUV@{wA@ZHW=!5iFyq4aS$$yTVDY6m5cg-1QZVrAO>#oYMJ<=zg z&!IZ{Ej^Y_mGY=j-ZflccM5*Q^jyDLM~;oK{08q8Y`r+|aZYG!vfS#+kCFT9WsV?* zo1{B^QeI%+(X)0~j`iq`J1%6+tm!;`^mwVC;M_pm@?i&;|Iat5{mePR_M3bxqW0>8 z?TsaWQpnsFY$xv##VUHfJ+mrpry*$(0=vmx?N zec}PnmDgl`_|F&8w+wBISC!A^xqctd#)QbD`YZoG5C0t`_K;ZLoSbg0#E<&yy^nE} zyb4F*r}t9sTXOF4d~bWigEG5ZVQ-RcW?i1lXhN(R;iz_^oO&6YKa3AzE&UJJ;B)e&ADf?ZnvP{NC;fL;& zv3KExY^BGE^?POq#4E|8WIOr1V!rxt@cPsk=go}!nFoKmmaS*QQ%AoQyZJRd_p&}%I4jmY#`hNLm962CJOfW_c$jmu^P@E9A{h8AV!ecJRQMCE zANMp^tubi!r8BiZ-e{96t;N`VYDpH1UZM@pjV`QRR4(fT`N5}yd#8zBPfMoff57Oq zNAhv#vyuIY=_RW2 z0D60Tk7=CVx@}POe?PD{NBA1-dlLAH&)7xN6~XkpkbY=g{a4{_S^QG5UN$_cv)+TD zCvuGqk6+r@(6yE|I>7rNd#b)v+c@+k;6KS2KidC!*5H$EHw?bX+OsFqH+H|dEhuO| z=wsA=zenpGPy7q>U%I~{Y5S)ym5EXdhDH`Vc_kcq4=wyf%%D{-dX74 zgN?1Z{lj}RpFeF~?<-Y1!UH^`OmU5^(R?QQZ#j(j(%aeBRe00KAMbF76f&qX#$@K> zU(eygFA%NSW4#SupPtRWr47VE8{NL8Go!uN=~?6ouOHmcdfjJ~`v8XMCVO5lxz<|H zr)c}B#)i5~lyhKjNJH4e`7f<=6`!5Yp09@DvyE0(ub8d%tSOxPw#dq#OP0@>lF7RA zXOiW!r*I#$)sNuLnxY&MZ70gkq%HUEuafW1oRXR8>On8gQe(e6-){u1>Ua1`iaQv= z?^FE#aZ|$p=j+x_1joum4_{qzY4r{e7}}z*a%&;zVBPm zNp_{z%{}!YH=X_xqiGWhn}VKIaBJh zQF#PoHt$#4`_cbd75~qS{~tq^N^oXQsh^pIGow`R?PR^_Q|fDz_0EXv4ZthGWBtUG z`qpVv>gyvshA&UG+FhL!ciQ=NRnr>sZ|8;52(3M&Bg!MPPKg9CL9&dEI_LzEJ)|?JZ=D zH}#EHQ;|_(0P}~(q16h}m-r!y`;Ie@kG&7E)8?zNJzM>{1(nIZ^1T{ADxbVM8x*`> z*7%H=FWP$wFBR)44i|0_e-ihQR>V@egIsZ@>UFei+L7Al?BKd!2YIpv=Pw#o`LhS8 zlM(%%EzQL+&+&Bd@br?Ooa5rCH>csy2IpG`KKGjXOPgVDfJ(hO+R~Mi+$Zv5i{)aul zeM^JFFD}6UY{D+eZm}Qu7s%)vo3NGWoa~hLxXMm-rRU%iHsTZ52fIM!vj&T6RX)4> z(Y6h=v2n1-+zIDlHmrb_E3!dD-FVty?b%{m*BkBskPa!oE}CRoWpC%4ul|GfV z$~Z%-VCCl_|7p(mzz>fl%U^=0iqqb^b1mmKxI5T#%FSn3lU0=;sq?eRXKx$~&S@;% zAU_9vyu9|(N0V3RM4e@fi$5Zspr=1nAF9Q3-nIMGcwhNZboGbWRL2uIm%=6da{1?-T;`!6_qC^_H`TJFo6JIFZqbp5}JagF{9 z4~>N%=CpnFDav6b;(Fngj%=R#rTQ^mZPLSu+){tr8-y}r_IPaDp@ zWo_H+9S7Rn{d{el`;m*{{bqmO6EyUF2wfjUSCa*Dn0^h}-+?dXzXpu(#(;Juh_>kXmd;6ViS(&3PG8v6wRgXS(9o@JCzq`%*-Mxle zg{~~|xS=b&H!5z_G2E6!xV0Ofv`2nJ2{&wvr$vmL$|`YF*(kVub~L&XkDrik)G^!^ zqbm!Q@7yrB54l3$m?wyA8oEHdP|9Q5PuC)uMdy^tS#g6}QpN9A~1a;~wN+K|quUF={qpM}nF z*Wz?5<9)>-T|GD0Sh>s1@A-N6$9Y~Ehd3Q9rz`g4gnXxt^&dMSeR7=27%Uk(fV z5W%`0`M!R19#O|&VMlx&#Y#G(EIT!xeV?bZ?{gZqYP@{k)0x^RUW@Jnn=#m-y@Tm%m#r$Tq2CkCo1u7V zkMauri(Fpe%QNzS^Jy0^r8$!W`#<+W`Q&Rkr_p`ay6;`=y;XSwaVOuApJzPg;+j&~ z^ij%Y#AOC!IqOVw(;1_376`CXUubOYWxlPw?To<#xih*cE9ZsR7wW2=vGhf{!kt}1 z!{2bR@;4OAMDNI^Rn@t?{d#;W&n$;>x%B*6ov%~4*Tv(#moqmGPQ=gVQ_K4|!{Qk6 zUiJQE)Ozak%(z}iTXpf>WSpI%xQusSJaVbKJ-%jvd#~A$e-uI zyXdgqy^eK|^4q#QwnP59jqyI`u%LI{I@48Wz}97hA)WK8eEmYRmAp?3qy8#JFgRcH zvB50h%r=`;#CBuXyNfN%A7>TMoT;X2{$&VPFvon05n zyL^p50~#4{{t+3W{3Unx>kDpND7d$F4ey;dF3QK~|3jRY*zWI%S=U%tm5%0_^z1)q z&8K@7o$PtYCi&CqyRx(5cT&|g9)I=0f7HfZv|;s@yMCf` zD*u@8e}gMWpH;pB{CgAlUkUwga(5wgwtaFisNCU~r29>y+5*|hGh4ET)-BDSHJ^Zfr zilR4y|}Hq|B*pHbxws~Et=L?cqvT|?yl-t)#$Uq zH@z##t?B(aYM=Z=b;&jp|1pn=?ZQqQj?{_g28gLNj++Us^?q<4`xyBDLI3}O=s#oD zQS5hVY%2O_{I+`3dL>?{UW_L(dpbVL7kdDY?sa-&b;PrA8OzHGr@NVZwsP!-)?d}S z+>o9LKjx(wvo{WLSzokHs|H%=d^>NmsK+A2htWTyX&ru8`4_#jq@vu*p}t)nEshP~ z?=j*!vtuk4WxnGY_6J{0%rV}bgJ@$m_U|I=)44~}-&Zw>*rEwvNtyEPYgV(S3w~xk zR)2p;{G<8)jK$6?N$&vPs+|`ep9Ql+FJOf1a64 zl+E*>G`tBX2~T;$XkS-(92K2!6EZGc<@|rRfjsmq^F0k2a>|O8vC|F6XtZ8|cv5GF zu@+M6lM5=hen2zDW#Iz(NX;X$Um-1D$b2C*b9s+Z`Sc0+r1jXnY2`i=dlkxaAMv}D z`Oabw>ja3MEa!PqDd(xReZ&X(0b&qh>+XTYL1Ewm)_8*>XT#PUdppP<14qtuE6k%$ zoKZXY&A*7&hie@bB(W&XAcsi&^L{Zi9hI*;(o;zWA$yi`UcHL%^d&(dT9+q zl+ReIH6r8INp9kG3v-|`|3zb9l-4brw2Yi`k}?v<;-wtL1o%ETXw}{9A-vJ}6D8Z_1tjP^ZTOzhU?tM);Q--%10z&+q=hweN6@I2gZQy=s33SrB9v?Ck1cZ|7&c5=xb?_guvwa5|TR-(zKITa82#14)B4q>e zrT8pzd)vqXC|7RwG31$#@oYYhXQs;w%_dC=na>LsGk#>u`muM3G0-vnm)>hXp2i^L z{f=dwe&VJ=G0V9%v9*yf7Xuq;AA+HtLlRvz2vGs zp?oCY#Q0;{iHYhD_U64iFC)L`#>Z*d?KC;_fHT`!@5MR4>c7@e9jyUAeARv1f2{Qm z$n4{s{aeJ2YRqc6c%T0#KP~%nHn=E%808BSd(qOR+&unaPo_5FhwQA+%bPt%-ps~s zz?UzikIetRo?|}&aZ0!_>Z9g&C1Xn;k{$Ku8KoZYL#DLmJK9Tu-Gsk3_Pc-_ehwRl zpBMi}&DS&bvwS_a`$YNr3D9E&@w3ZqEeoA(CI7xG)2jK_S?4phTc+4TXZA+p`!gt? z9>=W}x%!wM$=IS}i(;C}T)m3}pta4NqSNpL|6bG>t_FX|x}lLad_X({Z`lCV=Y06J zt5Ttj`$oLeGjhF}%_X6e8Dh)x^x(PHAvu3-BXr=#M%&u#rXd!hXt&J&bxp?+m< zE2NL$#RnY8acRvSdcFr6U~7t{-?ABHb3Hko*)@sW&1u*!&g@DvHwo``-++8(z`AVV z(06N4sKOrfUla`eP`L0+_Jes#VqtP>Goi(jU`TPj#`Vg}t^Qxbdk2;vE9g|;t*l$S zD9Q`SCtD8A!T-A?yj%hvd4m{U+YN&k3f>Cf<$x3AlMRmgWcYxC=$f7G$HozkkJHX- zp4Ds45Zsgty9^uI&b*T40ZrDAU$;qhUH(-%q5Blnzi&Q@F0a>E@`l03`3?Uc?_CcL z#LADmxRy9{iq4Q<434VTu6Nc&^m)X;qw!wi9kY+Tqx0p}&bOcB9eju0{~LZ=JJ>P3 zLw?x&gKtM;b=B+CJ8PqM9`o-ohn9TDw{iTSQAh0&V_`q8?-s8#w`+GvwB1k+ z4;yBAQ2J?SI*kU8ItCA0RR*t3_fI6`nwZ(-nz(azQVy}*v0S55u^f_1dWbxR>_8EH z>mw)KubgyRaIY(q>r4_J{y2t*J&oa!pSoM{u$!zkwEUEEQ}OyC(H2_BU;Yo*N9Zmb#{{#2Fi z>0I`yC>N|Y53P@6(&Heygi-rf*?!UmnS3vPPy0z1@t)#~R4se#puuj=Y&(at;@eGy z0q!kEU!oXm6LF1?vACnSsAuFIEXL9^$>b*VhFBzga6&`K`(<_Rr))x#Q@_y+}BCmSP`PNstJI%ql+kAf_e|pr$FWtKHEIiuC*o`$++|WGC7$2x?9hKkK8;2_uK)f_EqG7__pY`XgSzvUGa1R*UP~_ay1@)GqO%1< zXx5pYLC$tPIooRouc?BE_zvaZJ2*SMi+o-2Md{J{?&3qKg8W)%+cd^^&AfL8@UEfW zCkJ&uXJ^~#b2{y<8w_6-Ew1hk_tV#x@nz6C+~34qf~ms2 z;=B6i`mx#Hxko=De5W{kr_%0KzyzN0X0qeW6xzL#cCQ*-KtI~sPNU9b+Pz|sJM(Dw zO7hOM3*W+pRotscyX(dgt2GbxEp8qPpQU^*<+o9u8&~L`+dOo1LGutnq|u~zdKGj0 zRpdaLhl&RReC*0Xu`N|#3^7zhriyLgj!cP0)-PzJeqHJN1+T@|oad|h>dN}uez)-c zOB?QWYwpB{qIkFv+a;Kjz)dh0fb%49o(j(NKb$~0{0*mq^8|3N;iEj#LdC$?nlv~M zH!;rvj%jdS2hOho`<>KK@i}mKJ6-5~r)j9~-KHVYIs8>>NOBP#O&9uq)iiYU?WUp0 z(4hAXU>)Px8%;yu*Mp((l^k?R704|Pu~u#M^%UNA5)&A{25^V7+9}1 z4gFAKrI&&A3eR4q-c!KZpDTndz

zpk>&?JJ3?Lz<+vIP#1mGy8O3vSI%EjuV-b$ zs?#FfcpW`5db|i+(c>X-c@bQ`&wJwe0m{YmHgGurEZSzF*<_YvUSPz)d)PQ!w^I+r3eoT9+b^!teiww|9?|yQuR1yQedx zCj*8gKD4&T`*W(Q``evK2=4PdfAp)r-}+XaI(6#Q zsZ-~is?yk7h|fl2Z{cY^4LlaskQY3p7isJ(??UXYDqk!8e5!k%cQ*guc}J6dC*~-B z&HQ$I#_pS^-KSPB9A@vVXB0E|amCxkPo1$)@izKiQ~GY-EOs#R{j|#OiXn90OK=rO zSZgp#X^JJROjA5zWg4-B$D|QA=+lH_?U^MUTzn&Ogl9WA`aW#z!eK_&;+pNukxAkS z6;s4LcZD20ApWpB1MlG-nwy^tnrr=>R~j_0hvw>g>B-99!+D7JYmC58*ysv9iWQ;tdGNj*2`jKYFHDlyn z#>noL!f#mv^xOsPd-!%2`8EP;c8akA?2VKWO@2WcXp&0+^8(U6Uaw=U<2~O zN&bac7xZY(JD2=%>A4^mBWdxlulbO{*nFE`zvV;&tvkZ|Ef*DiN4e3!WDKjWV0#Jp ztfDQ=VZ3)#lzGLK+2H5Ze-wT}29H7p(>J->%rytjXMKDgP(Rzg(5=xfZxv zJ={$m?xCt|-zMO0X({{@aHYEnx1MW&y%E^gv=kQZN7g(upZN%wnlIK+R`W$SFxLRH z)x%usVLk@TrNCU#Qur(|dlms}3EvixuQi5DrtJ6VOZqhQ-Gb0Zk+gLojk$|HF+D}= z&wA*^7<5aFQ*<{b>}QG@ABii`t(kHHa^jdi~8{JSYaf0T?ZXBoOKoS zU^|OoDD;HqLOg$dUd+yR?WI2Hp&jp|eM4D0)S^=lC*JG9SoR-DQ(eYO@{KW`N1#j1 z=Z;(H^@_ji=VC68A+aR76mRSMZMTfEObLY_~fX|gSOU(8}X zTl=PC7aA2mn1@E0?(tpu1bZH^nFX8vGsw6+w9(x#vd>9=awa!=6Y*oIm0d-fey#Ap zHbsBPrXOufln&FNgU-Kn_Me6LmO?yW$zB^Rdkh`G!{~tRtBelRYjmiC4l|*{ct?jP zz3oQ0n;!!D2yRG5QT@*cYU4nJs80|Iq40H2PU<)bA0oX{JgxGUQEZJkdXl zr>0~(c2&jC#$!!M@h<5#8fWsKdHbE_M&VcVFI3n`wg0_~_3t+CFXr6I{`1JMGSJ<{ z;UQaZEBZHmn%2aA9jyJ#T2D&`H+UIM*>al!{N`e()?kC+{EM>(u|bf>Xh}Qv2&Ui5 zB;L6l96N*Whj;vDW?wTrUHyj7uNbpW^AqmkC4E1}hI;~=+iv*$KJK2nKQ*E4f!Pxp zA7rfupY><_8L+bL=$g9Pv{({m0bwN9aRj!rtU{&3DMR_scl+Bd<@eV7|o>O2Q|JmHWi1fXKQzzcvG=;EBe_-{ZzxAacPYut%WmN#x?3bE3=i(W1o-r zFUuD6_MPsm8RC4h+Y&C`gt6LoAlQ&}N21ox$PBYjQD)am_&#WZ=I6&fT?MNOSj4C~ zo_2G!l{5V2XpFJu*P5ETENvckJ>Y=nuuLmBwqDh^ew35@nJrf}CW9ZGF{RkLV`%r% z)b!cVPIlzv{wdsDs_}=d*`F<;u^7rJJ)elB>*LrMqV(^dnm(ffhH%r*?01KWt0>-S zyt;os{&(n9I>%>(YgazRE=1}qtLM~d>HogE64wZRqTjjA*|Xp7^TcD;o-5VQ^auLh zd4?0uJ;*cpw1nT*d!ae+Mo-DB)VMa2SE;Mowxi4GUMJ=IRwwZ{r+C^P569+d>4TLx z(*N*+#UBXHZNQo0<4nrwyL@o^uAi3vMg>gmTR*9?ADNc^Qbk#{^Q6n)IxYQ$LCOop zrNRLoTj0+aK8D7#N;IA(8uM;6o&}9t$2uCfE^m8_(Rhr}_-*h8vUGF9AiN;E;Gp~m zjvuMO@%7*)TEFSIv_1~3yQihsRlquyR>vv(@U-;ein3~N^e3F+v@v#u=f#edCf^iq zd6U-BsTlU2Qd%E-+YWWd)9=22;I4s9>{r|9X}D4TC1*>P{&Dq)VDqoAJ)`G_1ay%?OImTqA40J2TbR)+a4H#1gHtfq7dOGBT? z&yk*4bEGP)b4WwCZ_0&xJ@j6~emk{`KAGxuart|a4V345gU(&jyb65zrvmKc2;xGh z>$jRaku6a=?}5(0q20M_TbiCnpApSpPkTk{H&)O&dOv|+8+`StbZU6#Z-K8g(cyq! zA4KIynY=wPE&auHS^u;mzj=1`U&Wb@*WcJ{VuiH<8{^pj7c9yQp|Ju z`1D-z7%o2Fe}F&vjIWCMmW@x(uE@7iaq&oI&{fT5%76d z<+sGL;eCtVuPwd5L+@Kl?>EUFc75r6ufBJb-aC*vovATJ8dmfE2lz|zz%OlS&i?+| z=IrmbHfLYFzB&6sM|1X})UvL_=vlH|i>|s`=v?%H`fA2Y;&i(!-0VAv;d^bpnRwk^ zbmvdikI?ti-1C`wK6NklAO36i)=$*`u)XzDV+H!)(Wyfwq<^!v)Cc$Hh^H*dAjdC{ zA`h8Eoen`CP)8xj+<$9CR)|I+J;DQzPJQpB^bdfq{_}nH=+x#@(+5w?knCEYq*r#oR9&sIR!km zo<%+kZgV29@Rd);?)@h2M7rv})^TlN{LU5+hC7%$R&)hgSX){sM;iOWw?mV5bVbVP zoxS+RgBM-{lf>`*mwxs_9B-usTEzK358XTaY#hhItF|mJ2O!sDTw-omsO&P*7_c%X$kAsE( zuFJl6&<^mo@58sv{T1JXUpU7)zZtud&RyKs!LM6qqyFkb-4_(IJHFWK$o)h|w?=Ie zrTwFdA#!WTN&T8zSm&o(pp(&_=U#BzbxrKxmq_ajI{B8>jR&G~B0`7vLkAmY&_KMr zf%b=U_C7%Sg;&q0$KBmYqxlE71?V(7`+l;n`IOcwjDKGb_h)3Qp!vnr zMf>R+#xwOj%>T!zuXryg^);M1%<7v0EqbVL3iYWj&2|3#Y` zWQXp|sTmu~PM*kbDnI-zlbIjR5zSS<;2Yn%XT3{T&i*H|4*oS9Cc?|mzK8sw?Qz*h zs@cco=UVlLWRLQRhlFFmUobVELVdzl--c1|3~&@J<*S$nj`P4#urGE2y zEP+ld;EyHzQuHO?X4DWPaZ$|9^3r)5A-~Stu;;1R3h(jH$lLtA^jn=DchlXtBZk{} zYWWNB%u+oQ+v(H({`2(v06PBwI)AXWh(>49pHUj|9_ky${+2v&>YWV_+6^*!+Dm!hAe_+91U4Z#%MEp|}<8kfEe zPkoMdjnY_faS+m_^XPhZUI03J4K%d-uE#cK%WS8!HO7g3K%bA=PS-IO*b4x>i2Za9 zypzCwiY}OA-C{Z!dYt&!a8(})S9tn(JjIz1Hs=n?KaBsA<{$DJ|1hSkPgJ+oK-hDQ zMvPd)d#~1)q8PjPZNlN^um5%d|L(l;~56qZljt zFjhkxh4wvZt;HSWUBOmt^jYHQBV6k%#*y{smMa^X-=UxR9g1T%I@aH{8Xa+HIOF3= z{0>)e-&<)vN2PxA7oD|Hso$In{>%lY-|(-xJG*uF&fsTwNDeBG?6k9353(O1n;a53 z4^gs@Ju;FF5xKv+4E` z9pdurN$3zmy)3KsvJBb}-k0oz>-N4F^sb{@tDvX!sZw7O@Am(oFH3bx&(vKOve)Y# zB=v{x=RO59a9YsMfis?EV4Cs$%qtK67>m&i5351-o7e&k=jN-8l|d zdp+?&-ybT6Jt$2KY+A4sBfrh_r(hM=Q^1Py&j{lm*JhnHF(`j(9i{cnc*mcT(UOJJ zs9SanjpaF-uc|_e%+^w`e9d%$tl7Gu@59tJ=vqo^C|_4tOCkGhEp;b6m!bWF zAs#DVTSeq>glDuzr3YT=9T<3N>XZrTwqy9EBsV3qHTS-B0(m_JxuN}I$~S^GYb-H7 zou1BIt9XpcwAVH#pTe4AENhB!%p<30O;H>#nwN>ww|%?lI|X9&r7!H5x^_x>zHmkd zCq6Kn3APWOiP6l~;zO0rD&7~bOV2;fT5?Bh*<`IHcW5oS>rJ+n+@ZDPzzV;X6dd|5 zz|i;zW21(2?YFTpmcUMcKD?8@T6Y(+Ih>j7ar&)zC*Xum6X0nLRI$D-!)e+qk5l8+ z^v{onli-E*?IqCb68!nXIfZ@$4T#$!w&1nXW&U z^C4$ftN@>spF_91IrP=evz+q>!HUFQv&OO5Yt}f-tp(&7@oTgdx{CL6i~JnAh;^ai z$Jrnuej5Dpr0dM!PklmgBj;GyoXt5Fl8c5TvDAj+7sXJBhpA znK%IrnzuvV{_pJVD+pa-ldgq zYsiC+lj|mrbevJVi*_?3c>NW8sXNKB`Peu;^Hun=M)!QvhWcvig|?ETk{OZ{MwhSg zjLbgKTs?4=&DGhXTd!hWv>g5S6t^e+SbIpB?CR_|)*gcF^g8+#{WV%ovd<`aL&I_H zAm4)b2VKX)J14!4C9ky=UMj9*wf_^D`q$b)bpGh^>>p$CdsNy#-gY;9f&IgHbu_%% zQEpp!Gkhcd2>S8=)V8qbt?U{3>ud{~Wm}M)CR1UjsrPptT7SF-JB{U+jRw6o@AVk7 zWjMRd=3uu`A6Z`b!ul!hWTNJXUH=bk8nR(5{Y-`Io9<=bl33RI44py$&ek)CEn|8! z-;rZWB(F-gj3ejMAIOX{TZYBTVat%7hz-PS8Q4JTvGs@+WqJiRL8Fgs8LaP4G+swB zUS;cKea{_C6}F6uHLLWRXQjUaGdYOuA*%npgMBDvIQ<{df6DbSldH^mn(s`1VQ!av zvhj<3MlxJ{w8zJpn65|qc>D=#z5YyD7h6SM`85_G^Xg4!Bi72}9D!%>^UMjzqB7mi z=slPnr6Lxv>~0e2T*vA(PsMu7W^a1d)!Z$M{C=u+?d#L`FL!#D)-2MqEPhWiTYT0{ zEMGV~gnp7vA>BLvJpJYMipu_&EnOj!cLI9BlxiQ_CV1#87Dspzc=iQ6hn$_>ItZRo zU64Lv{-q7Vk#<=B@vL{*{e_e60^*QNcc%ieaJ0?tV10FcIkJzuqvT@?b`SZ;LOg@k zMaDO@Q};Z{FRZb0T>W>nW6UTCl2sb7F2UlVsOQx`sFWE6dr4fnEd zMIWb)b`~*lJAd0yt@gQdw=BQt!mo?|(AcEywe0Kf3b4Xim!!LOX^M3ZwjA-&1}`&h z?H+00dY_)6&(+3y^(pPv953G0?;G&7cqB7>qREea{0G0>3SvXGub{G?S-vbWP0nBU z_1MAviX-*0fYCDf;$HC58c=qSYrk?5Hn$4DU8ZhTBkebRS^oNf*E-+M8t@8jsp9#k zw9Uro>{X2m3eF}`=9i4fI`NA5MB~cFho_Zf@?gBd`sJlqeO-Dd|5_VW&DpkiH_uud zbuII2BaJh7BfzK}4;lk8_@r##dWZag)%ePZA7{+4-rEuEBNoH$?H2MsC>A^LvgY#R z;(4Hi=Mxb;KN;{89O3COkZjJ>SEMfW`kongD0wFl>7aPEZn8y5SHae72{dcm9x zu4Q-IVxM4swez6*Mlsi8Ys>_>j&CK|VmOqY33YfBb69*>Eqlq^G~dg=tobR}y>;jE z0QMiZH=^Ra-9$K3V^Y66+j?UE1FQ{APA2qETw5F*x`~6KJ(J^#Ls+SQbez@tNB8sp z$U65<89!!1+=<1ZFc&wx(aWq3#m{hlt@2gOfi|8RUtb(gea*gak+sh)4RauMKy!(f6R1a6e~EoK~i1HUL9N6&#@UiTt051Xcohjj9L0cST& z@pnbAChk8F)-ycotd9ox3m;hU52{P)5K7sDG3)x);e!xuVByPkK(@d(}xN1J2iLmlDuiU{50D`H)Y?n_)c zv|taEdxox>|N3bwcsiNT>fkDmqZMVap{Op!Zy7z92lCgDM*HS_n@6OdqB6?P9CveM zsUJB*!TH;%y8+yev9CmYoO$XBF`j|`;z!LHJu{mNPpY3noEH6hYozb{4cac4MQ(9*QP_m|D| zf4-;Lo|wBWKl4h!V_JwkcJq_LZct1y_3jM5DSfNJDa6mo9w6OEd{D$k`GpVl=`K~( z8Gb%Skn=ajcYTJlDvxfBoQZrKy-0pT)d9b|v4=hzbQ|GW71mthTfY4(>p{k{)7PZa zYTSC>w)T_9(t!6O_-W26*M&;DyvEqEx)5^}{E(Z@ygprR1)kP}7kT_H;{CLKCrfzF zljpc|&*f*QcY0lJ+M?3j^Uf>1E+_bm_54NKET&BR)QlF-#+r!TZ_lYN#>1Dwc?x<* z?w)L&yF#7JOZCUp*_@prSj5scun$V>4AcLFlg5Z-kmDPBxAheKEnOzBwY%`!*xWva zzO!|_KX+L+g)qiGa;PMC{Q6^Lj<#7pJGSt&vvpCqT??6uJn72U#j<&eE#dx*I_X%Y`!n$MJxv_r-XXN<7T^o^ ziFB?XUm=|<5|bFwv!gMImFq*n)f!QK`(*G5@_xKEqvfEMbe$XY0qj4Q3!kt?)tM1%{dhGyf#)gB%OjbW z!Z*e6`8nS4o?jn`m&AL0t+DhW)`p+PrU@^x*7x#mD7*vzNbWx2Wkrs(Byu+s6uzYU9-O5YGQAaMwT+tO;YJHz3tvr@EazA`T&IMoJ-^*{t zd6JBc;{Fuzd@cd}gw=0yAlYEDyIuN#)@2EB(H>7nPu=Gljj1Tvu(Zw;pUPis@x%Bw zWBB+Df1?d>X=i<$`t;6P%HD|uR$JK{xmfYQdM2LI{a2HhcjcRg3>1xD@Bhz6Hpssl zXU%;~tVgO8b85Dd1nYwCNnN`;CUxBh?S8{JwD{s0t+h1d0|Ja>+CdryJPCZwdwg2ylv$_&ratqcjLt3 zRhVP@SP$LtMcE6S{YZTg@M2A7utPkA`Ushk-Ag&$IUeKAaZ4j#NQ;wJSw6>|ftt^& zuB*96`%nMiaVU=cFh9f32fSVlQEh zaofe2&KpI`dZQ(0I@jnf%1CT^gqPKqy6?sKmT{rB8vnu{j346HJ%8z4{H1sC7ym{6 z;#uj&PvkRx;y=nyA7-qS@j2_*VjP^tBl%5s5bGcKb1M93d|KoF>zJdIXU3N*;x;OH zQ*A)6{i59f=bi;_>+&K<8r2e=@F-C4Y{U& z3OLx=pc-eSiQh_R2}Wp}D4k7dbgV9&O`x&gHxvDTI_r1lH0c3(bWrnI&V;V)^UqCh zh8EC6|LPb1)#k6Wmm#WhI{?X0xX7fgm)E@cYBimUU7Wv?-Ul_PcaX^9J_3!R`ZZ3S7m}BuX z{patVryrnCeLPk;9X=_u#|$!-1WW5x$yUl_#Y;)rYVwVAyNefmdlT>^u{Nr!hdwXz zrP}0SR9&~nWK8!G^P3jR#!$C-?RCU@_`8vyna)4eT#-qzza1XwOnm;r&cqikv@~d} z`MopoInwUAP&}OLz;*~8PkhSl>(H65>l6={kQ{J!#joodv{G#SReXD{JC=S9-W*`< zG9bSbaAF?kIPKGVs!H$tCwTw%KMdUU8)%_(J`MpR$ZC}v0$qN)JC>e%y?7FOlh4+a z(CrV5Rq?Rqq~Gb8vU)zEF$_;0-TEH<$Nucuh&<`+CVsJdP1l{!tUSJ}d~UTk$JCmx zKOmcSY7aziOnQ^i*|kaiB^e|>(HLyhxs^P(8r;a7opL+8 zeO}3Jo6lUD`RMZg4g!ne0@iWXW>0Y|McVqZ@yzs%<4vD_s&(6p^sg5OeHuLIQ;UT| zpNBTdfvo~`#l4L#n)Gm5R28j>P=qcH~yl%}NHfJN0 zUZQ-0BYCBm60^53p2Au`O6v!ai$}Lc?x9RY^gR00Y$IwncnkJ%>HhI^igceg#?$?U z`ZS~APU}m`8r?mNKnJa9D*GU`^XJbp-uxOv>*u8TU7ep#@vTwZfOmPZ9^uQ{{$%mJ zG2r{sIqA!fhp+bygnko!HBSqNnD4vLZ=>nABr)eYqeD2$PyFhB$*2yYKJe#gcQ6-j znT(Cr#!vRZDc%;W_c-lrh;6>X&xhjC`nlFuBkJd(WAiROadL}%i);Gl5;LWJYzC{z z`4?GRWZ@IPht|Q&KYK5<_UC`{-Sb}Lj1ll@U86npW-k%!8~naJ!5;Qyr_0y3fJ+?x zllrZ1qTi_BzBNU@oy(j-p5*=D#{6jKNHK?nvNe>^d@u~VuK2;uhew{x4>m8p$>u9N zK#@;5w}pOkZ9ta6$7W*+_1L)HDO_qc_IaJf=m-wv9Z`RO2|wW#%|Ab^OI7bWbPI6R zci|}6cX+m?8P4LD1=8<`qpzRHxMZCox@awkt&Fo&VrOti;ONYjHI4L#;V4)}&xz=~ zEkpW~(6jzh-xlhqFUHV7GiYPD?Qw1Ci0DYsZ+n4x@t3?mh&JPgv~i<8toyjroAjLG zSw2!d58Li^WBGJchiG5_Wrs)EUEY2^F*){!`Ne%vdE{Ds*3&cV$`$93NSVohsA^K# z$>b@1?GJ zQ96&OVd{6~w;#6tR5xbfi>Bn6v61jelW2GVf2QuUHu?Zt_0jiP?68}^A8_&QRNGXp z=DH!H4xpISbF!Yfi=F6ry@IIG_J_m7+H>M`0FZ);oxdNli(DDkf7s``@g zs?Dl1IguFUqp{80!EU@Fnu!+H9`5}<5#0UoVI6HO(zymYdz!j()HNbDQtdVw@Avtf z2#*)Scs~vv!r$Zre37S(>-`)ezlrKh5+|toBv--@y{4oWy%wIsPZMc>!DR9kXK{D> zJtjlsUw&w3e@DD7vG{ZFA*%OKmLnRgvyPvGsGgc+Iw(7T4Ul?{DF;+JHl7 zkN(Mz&uH?JqQ2LCtp2MzpTn4vtP$zPOX^{n|rKYZhT2xh3JP5rO)r*~?-b>PkEzM-Zo+Me_OS`^V0=?l}} zfYE^N8C#xb&H6=ttNZ^MA72vv>NZbf^~F?huBzJh^ameeuHs$ut?Va)Q4b8w&FY(a zY+=MBl=lg=ebw`k$YZoZxAyYBa4~fpW<5PHGnUO?3Bz@y46X!J*k!C zC7vv-lfWZ+DfUipce7ZuZ_Y0)DC#JDk0;W(l-AZf|K0oodz%EOR@gP!)wRv^b(KP29H;!qeQE`ADhBSefDO?=Hp(64rv+A zD-ex8CS6eZzB-@t`d0Tp@(c17&+$7+TF;KOX_jl#{?Mjb&4nLzcv@r2J8qkXoR^;D z+jL&BO%DI-z;!Kn?qiRq`tc2{FKCa`ucf0D&P!+bruYGU|H_XmzBin2-zWL+FTw|9 zR!(HSl%_iGT;3!pXgfov->KBmd3|mJba*%hi5|9 z#PWXml8wK_%XN;v8v=dnxa$-89-fXI5^v9RG=@(L_0_S$F+6?FThrfqkK^f|W8boI z=K17-zaYaytcXvW1nsTOsa0M7T&r`E)F)H|Oq=Be zXXT0GBinUoS=;W875%@sK{jU2%k=3=+eNv*oJw6G?w9X_(Wt(752+vLW*7A199y#L zpB89??e4A6M)z|CTy}!PxuO?w5k@a;+TzLM((CTGrrSKdK3PGpE9n0>(AU;CCo}eu z;g(nZINag-3UM?x_L)O#!#KhIc-(Qa?1J>=9-j|*eAMP&^Xz2tB=7e-!C7e?rTKI> zdWPBiTPL;kLBHn=M|{EYGi{wI{W!_|v%cE#uD!G7l&xNRz|L?^H~C`KI`eZ){}+D_ z&BH!=>`EqsnIki;t1T{2Fl1lqQrq#1VQVydtZcTI8*i-c-)VWB9k+_S*TNf9LY%EH zzmqlx{N(dcyrKBH+~--E@5=9MIrU)fA?69jSgyMKOq8Cl;WxP^p6&ZiRlDwnh|yoY z-}es6XjjONHRxdf{#uxnWK&wn-5=* z{?(<}-}(MBegm~-;OntMa{?V(Wf*t8*R*6uGVa2j8}U=_A!wW647U~F6v?;cApP*5 zZ0FTYIq+xBMfbu^F}}KKU^Kr`{J6(qWSihc_Tk+lc&pgs7SGn&H+L^I@j2Y{p>Hd> zPcEJvn9PrJJb`|~P|Zjo_i@6eHb!e0?>tgX=T1=;_VqcMG>r}f>Q)@sAQf~)>}A8ams5T3JV;D1ri9{=QsrA@c``A7Zp(<^3rzk&HyYijgN^thT~IcRC^yo@o% zx&6|8urXe4?OpSZrcBbuAp%3=igU#Zd7klSw}=mY5yX;+w&*V|J2f+ zH(u~|x8En4a^v6Gv_8Z-04wlWGxKPtZ)fiJsisVbrB~g9^vilbe;NML!I-zKk0kE~ z!B=-d{}8-P4l{opSLWp!)1!S~y)ldn`a(1bKheD2%LnPiIq|&eJUG7zz8{%`*Y763 z`XhIX_;f7icignCDc$>S z$78Q>){xpF{3d!H%&lqRUVs>K2G~d9?Y&!@3m@`)82If)@~Z9YeLt+SrrapVKzQF86smUhWDqm_;6F@8*sg;vX;)$;!D zSVv~&IEfgtq%72S{-MglP)3F|MLDmgEf*oqtLMy z_iN8@Cb6VTdwz41k&m7xx$p2UI!qnKnG7x2-+re#`#iG2Xvvu6Y+3BgBl@||2H#HU zj-~uHNpt#Tx<7GQ@$Az}eub=A!`NTLSVBHN3QrC^i_KLuMNZ{5I{9S% zIyCP3a8wmOKklli<-P@I#GMT6b8X4y5>?s00rY@GOW|SK)$`Kk@iJ7SHzn1pn|anhU?X8GQME2IEiv+tJB;MB8WQH{BX+nxrRz zm%qc|y+6YHL6=dT_|DruOZn!g><3rrI^p|Qt}m^X3{T&GLGFgJW;{fjetVbWp*(bY zVbnXD4uhAK1MZQf&4nZX9xEJqjPnV1(+B@fA3Q_aB7WZ?eK+ZM#Iw(Rr@8Q(?==@@ zGVb0OuN|g&;f=%)f1KxYs$)&uQ9X>a@|v{18OHqCa}j-Z5q$@*sejh{xX_-j6Xz{n zOp(qyKeit~3GEJZ??u=m4l}3b`KEgmE|wjoeay(@M0d|nXrtkOY02hb@8}`*<4byo z*&OCkk7!R@b9LC!sDI!Vuiw`-7e*m>*~3$i{UMI6A)J4VtzpMacfKvX`V#tb6F!NJ z-p)4%8MAR4Hu8L6>#8qR)|T{VZ*9=s_5Ma|Ux7FEer{}YXRy`l{ruSG=Aeh@eRF(s559xH5uekZ{HARs zdoFFLA@1zfS{M6$-0!b*-`D*QU>rEj!Fccg1ct|B-v0!~sSXd@-|$z`V}Zv*caa{K zW}c>b>NpI|!#STvpgDd@?SE;qxW2$U^aJyH(~0sI`Ftb&itSQocdygj?%RRfve<4r zYkb?b#q{k)?&7ukFSM^fe13q~8^-Bg#mngY*xvE*#6HGDH@|(P&EmfUx!MX3uRu0= z+g8~a$h|H7g^Qg&^cd$B7V!eU;)xWm!&Gf@RE_<1`L z`6YL?2FAWp_%VJ0;h02LsDAx|O<4L=G)?&iwY7K4Hc%03b7)eVVr}-l%syOfgl|ql zYx-ReyC?%Q$Gb92%|;1&6s$9W!8 zo1QPAfAL=!O4~-mtKwtYD7s~6_rJ+b`tqvCIrfhB>RZwNT4=BS(>|Q~xb!W)liud* zD2siG+F!6+*co8N4({4LsqMZyCN&DTJB+4oz0N-IMz!1S2)|=>qxw<4AmzJ^`9(f{ z(l&N_`A)1(;{wk=h6g;dle{~bE8)StJ-e9)X=4UhabSI;Mr}sF_$Bib<4o~ys>^sJ zK|9uA@9SROCAieH_`cn%+jc>#1ZB_op`+WbXSru0QfHDf@+B!YlK-q|`^(?ZSNQ!5 zhoR7y{#2~qh95Y-EsJ;TDDATe`=HSsC7;-jKzxv%C5Osmq;|wEyfD2}@!R-(s;bTB zV`t8^EsVrv(Jui;)aPTk&T_c^nsfGYvy85_iYpWT8))NNuluP#T#V!D{s(B6_ThFW z*k_+$&r^*X8%96!Y0N4fVj(!IzJ>UPi-k_y6*qdn63Hm0kb`b*MjfsOT;G{UbIW& z&|)>fTm1>1&iB=^a&X@l@U=g);cVysMX!09dFzOs@e98Qha6)$_`V(jkBi$k_8(>} z?&bL~y3F40Rb7YCd3uIBADGIN?W0NQ%;Qm@V)Rzjb2U z14|}0KA4)w*dq1~e$^g5`cQoCZQQH7c2QRv{?r}lJE76OZuTGh?|<`s(mUMQ{MOeg z_P2I8f7P^EP6p}>`;Zpp)ot3Vf9Hki8+1=Z*VuH`wc8eVXs`Y+FHHYnf#cP^iIsK+ zevOY&Qv6zH;3H-pu2BHHX~^R#(kZu+AYv=N@rXR%_q9DVNc^ce$vpa(P(ErOn)SfgV5Q&k7g zQ~T*7!O5h?ckM^cbJq!epr2jX(@~Yizt28e;x)lN-f~9zeDe2BW~|pC16xYyynW_?p2@y#H=L3F3-&&>$8@?y&4ty>7wmIEpJUF@JbwY}s z&@#~H%N|BjdI@t$4{+Wc<{e~YZZvz}MEEiK_H|>K1QxYOYqg&0EX5rZcX49?^LLq|sNskKl`<|CXb(_B=#- zYE>gVZ2m-S5AAuri)WY{P4;mXoWEypB0ANix706pak7~uoZ(UQmoe^9wWFumvo)_=o+J01TH?dzM44vAi)`TOZzdik#i=1rh9o1|x-s+R&ZS&c~+ME?`Rm7kK z+>U@xNOS0{Dpo}{SElAO31dtZRL0huO$iRQ~ak^Yz7BcBxQ8L4N%AYO(&QkSzn zS+Zv!<r2v!|=+d&c+7U6dCs zU+?>R5A=+FpZV{azueiz`%)9V?67(!R zx*qeV-Cv`)CV0OGzQ39Hiq0Buudd_F0zd96;Z@!2;oY4`=RG`?{}R4s>`mDmWi#I9 z4vp;`2gUZ}$dl(csD8uvR6Dg^(_Za?3p^iRz1gZa8;~)yrT&Jn=AK_j`t_dTtb{}S=J|zP_#FTH6yiUE-c7pXefT~W`~*+F$@-hC z?4GlL|4!0I@LjZ%ERE(#30Pt6qW#_)KkAng@%^;hjPIXneRNKG zb>Nku=xFeMd<1>VI5V95xO@@Wb98IzEZ{P~bVu@Ft~z?Y(j@#XZ&exhh*uza>d2SH>{YdP)_+ywXu(m6e+vuyTD7KM@G`l zQ(8i4%lb28o!vq42ERZjo-)>rqkY}(%op8EY1UR=n#e(|v{G1HOqb_C0oH4!bkFJ7sM z!7KY>*_Ps3yp-?t>Mzz1JGD;iK)1)Iqj^I<9nNqq;%h#7UI6UJ01@o*NP^4p^f%5*n5iilzaa!dUVK_N$K2J z?&yM7cBd}4aZrW6@GI;iYdt;-R0ny?9%z=zTrj7G!PoJYK%mpdQv5*!I6-<5oP%TH_(gtM6p*In4cTGqAnX zS05AiwBx2<4oQFXomzuj)&F^KCo|lEnJcbeTwf_Yj*QfQ72nsPKUx~k+q4hsgA>v> z0!w)4jtl)8PVlG662`+^`opCyr+?3I^IPKbE@Cy%SzSFVK7)2N688--R z=6Ko^=>lw{3$XV=7xBj>31aMu_P@c~mWlRD#k$gbCOy^Z5xzaseS11niG%y_d+zIy z@00$cA2lxJ&r_U5{z1kSeEfT@Np_=iKG=*MZDhaBT$DKAnjn2Q!J&`z2e{_uGab2NLh?f8ZzRJ$J?gEjjt&`I$~^vMQq z&k#NF)4O?U06oXYE78xF7ilRw0BinS1fN1nbEbMybBvV&dyh}WgCgVjPCqY3tc z!yU8_7_x=MvGx2LI+VUC&Dshc&jy+6+mK*P5{KaM0@q4CuCnf3uI@*r$Gwnpb?Z#e zJaucIAN*)`wT034Y3$F86|C(iQO6UJI-c=$C}vkNN77~D)UgWPO8X;OhxQ9nwd4AyYP>*PSf9t0V9p2=IVq_F&c<4e;P!1M7}JW3^qjyY_SRuESe2j^c#rcUy`~Sw*)k|HH zHPJEk^Pj>p$$*8R(85)Nr~5oO}32$@k?Nnbg)>@uF{DUqicQrxf>ifnW{*FX*8;^4S;!zRsl1tw}X$ zK1HsKtRFkNN%>_fI&hq`JI;Wn10Bh7zpeIG4W-UH_A&xPwmQ@wmsyEgZ*N4mJrDq?T3Glo)=SyxSN+*jxCA?xryuhg2ZcS#4p7q$~0 z0`{KbSptWFExKlWxqaQSwU5cZCq|cK-}mP``Lj2% zx?lU~CHG3}p3i$5x7HCJ*Tkx>7qO{!9va_f_T}TrqDXyJ>_<1BLoS(q)Yqr8>uf9{ z(*_p7tH`Dg<74c(#PiM~ z>3TOlO#fhf`~sawbW;8LDbMT79$lhkrfyB+eZc7Bn{>gS|C^t0gsbLOX!A{Y-Nx2D zPn&tNpJLO3HuFPlO(g9^eLroS?~BKWrZ0{31#ugWHj*EazBb&D8%g?E`2BOhPx*x( zIQY(0Fip=3WD&!yk#yilUC|82al05Qo!+OAE6mwjjZvk%dGIZC|kj2Qc!R*9ExE5yN_b+xkdO{(2X^Ad%e9#V~e@N=9Fr{^=_e$ihTk6w1^J%KFCfWQ0*0SC#KC=Lza zpuAUIM_Ku`6O%PB`TKu3r;B-=_zgSLpECj!FL=K7Be(#^&I-X#Do11c^8QfaOA1W&vDJ{d@*y3r$1w{K3+8$Z!X*(J^iI?2i zylBrr!rxC>!ApWCejnhj8Ooac|1;xUY1h6|!AIStedjyj9ofS*PPOk`a%DkO)({JN z3F~F)7$%DrLJRWwHrbw^_iwR#rmarJB*fb1vj(F5S$v4>HPLxZhiJq87mZdFZv>BMy$=miul9)?VXxAl^`bYZUgdes^)lW#*Pxvk%yx}o z_W$t=FVWZ3WqNug?Tqgw%aB*MdO4)CY^*QEdsjHV(;8hdow8xu-Fk{!C7=3I=gk$* zr%1WA1!Jknc!BuX(7&t`o5TzCq`7!-{Pm|14^CXIXs_=oQ^QjNM8f^=p=Z2uYQD%Q_$U>r#|`Uvp|`X)bB9sCg2cWC?$){3HmzA-P` z{g1C;|1iCaevnS0_&&)a?m^)!y6D*nw3Rx;*_$z)LzW=!47w@CY6|Z$&U=+lvM1r5 zi*)KP$oM)PuCtM?P zAf8#d15#`$3%f1+aFvEI==GhyGc-jNgB>nq=ltv9siQSgV*f6|M! zb{_K)&Cl|s`aIYd6+`%^tv(NH`{Cr-+rgenvr(?J{WF94FVLYMz3H5J=_{dEC-6SV zS(xJKN;-U4?WB&|)J}Yb!s(--o!B1!s&;nBe;C>@#@g`RVjGtA|5`HIe2jq>M)o&IF+%~wQ2Yn%1w=u$iJ%c%`Jify>O|30;|<4j9`K=r21>MGHNba1#S zX^Gr4cl$~X~(Rg9=$0y zFa6`UyZ(RSlY{sFOSDIL{G;I~|J|zot${8n`S4ylUAWT%x#Rj)@nwR4v2SP3CB%r) z&YmSMeJyqMJi^!y`4>5T{p}(DqUOS4vPfX z%)Xtw9sK}%cr&tNUErlbcr8;QJ94QAO)qe?`>((EYii=_YD6P^07W{^arAm2~I%kN@mf$A7fP|Bc7t{c~Rn{p%vQVTV#*e>&iXJ!+5e$A(*AA z9`djT>JGWNTh*&|u^2?@0yz|&J7vBJZbz}#{XL;HvXe%NO>1a zva+imT9>Uc=|^;suG|X7c=w8~e8Sx^4nMQLDwX@ye&}~mdDm5mL| z&-vN$Cia!3Iq$E1U|zgwU|y-roz%@el8Ef+@e_oyJVu}Y>8|-{W^uy+& zU&i5rUo3koaFj=D-sYmuw)B0UwXqa0WBVc!>{*zNzB!Y6m$-U!)9)P@T{o}yYrJ}B zrkHuv1yS!bT?_l8rNI}9wLFAw^&ED`TlBpa8FDIn?}EKa>u1j4yTRMtEBi#}iU-=N zj$O2QlYDZ;`XjmyeW+MRo$)As(vLk{MSI{sv2IRAbtCc{-)&hHU9UftoQh73&K6y- z&xC)`d;NO76uUIgUEVBfoO3=&C7V zzo>6#(Pt07vX!Rd>3f+{-0DT%+MW^lY)|#`knHiy zL)u>z)-kqDg+}Y}uWFq-|87sC|2QT6RZkp>1)gYO%=LKd3=_N?fw4? z?PHG0Q(p0xL%qxvJm@?^vf;U4YwqwogDjj%e+n1%ul5QE-v-jO-_d9i;SI&5=)1;y z99ekDGtiH@D9Ts&9a_zqJkXguvhimhAGk|)VwL+AJ`&Y6Fx|I*x?6jN*zFsG|DhuN zgmk#a=&+-eIPG&h9fXg?X9LIX85=$bj>xxw;|_4N{!o6=3jBTstu)>=-il z0abkSF_`DdM^|R2kF1|fu8xL}s7o?MaqXv3SI?;Ov;oP+d93T_ac`EFk+O$coD6vl zH{LVIs3@#A1FJJtp4R&<$t%);W%hDpH{hB z2=m-N>|ly-)q1^boz7aDy$=0-sjB`%-Ld}XQt^JX(d@?F5bA7gZn`zthbF_@j0=;O zem)Vw{?Y5py;)%4Y&B$};Y({4QVqR$uPzlw0qg?Hq$hT~(fk zpo<86JNIKIR zKd2at1Cjm5y1#cZtb+OY9sqx`%pypT`5!&|V;wY1;9 ziSk1^vtuad!i4h&c+T@(^{*@Pk7VCc=YwUgvi#_Dorz`rZz4`i`iuEsnLA#D9&3kt zzNFov74uP{W<;!zzX$(C1MM7vt?10=!nycs^!O*^aW`2+$LlSd8(nC&R*cW%YNcakTYZ1hq0`GH};Pg zph1t;P65_N;t!!?AM2kaeIps)yboQ1@7jZvGw6<{aoel-e8X0RX4dU z=qWScm(K1EeKtP8svdAW!v8{VRWN zX19Ody|Qgj_e$W^Y~=hJ>wjwt<8f!*sNzE2fv*v->+l9C0+1&G%Z}X)Q zy*1WLJSXWnWF>KK?brg^2k^z`wm#l&Yv$`o+eq4`SUWmep$}R0No18^maR)K^5X?N zf$k$PTG4)uA<^rrq#+YWN`D-{N3ZX!In2M%5O@0%tHPb#73s(5xSubL->bPPqyu|; zYvZZuElmzD>$mIX7xKVi2j+!6+|wc-$4+>wZ)S5l=Tc^$8_ybKPP5IS><=CZZi|QV z|9-}5Ptd_BZ!!RUe@#6(N@nW2(V6#R8R1|$`J}FqHco;q zDsn$%L?5@ce(U76U8%`!Myu8}Jg;dxl!~QWKS(?%^b(Dbb2{5U%be_Zv6i_G-x_r} zeVK38xA1HXdY0+Lk@^(ppuLfruLdHrUOp_v)2OX|C4N^O1?h#>pU&Ua&YAHx_E>K@ zToUjTb_tbT=Vg#|O2db;qKEdt`?2 z+1!Ft)*f{Hqg$)E$0(MTKTr0sVVm(c@?Q_E(<`^m;D<>jL)ULn_Z z#BLfRz2vE>cTY;sU+LHG{bSN|!J|kAwF5ZdyWR9Z!K8hmt`ot$I>7w+*mQk>Nm-*! zfXR5&Sb!&+SZB-E+q5!)t`C*=_NIwy-eGy!b zUJ#M%wBN4}x64m1-V?6sv$qIOd-rz=N5=dwz_aWw%an^5P=4|8U_O46iO8w{oBa8n z{{eqK4_yN71HDhkpV)AXkFY}(`H*$uu)v?ubd84#%IuFjVnfCi`Lk+yi9bIb`bT^q z{sfNkCo)lX)PS3fu@k_Y7GT1k+XBqMp8;m1&n3^qpR3sa6YY1i-D>Wmz2eU@nVpjU zn?(Mg|I)XL1J^kwFDqul$HLp(-aWbNFubf-Jhe&ZCn+Dg0_%R##xkF9w@q_#j`wLd zbIzWhdjg!F0!L*(LVRF=r@Pgl#}T{Z4!Xr|h_PN+-M_1QRo5x-ap&%dZR*?De(aF& zw&F^WarV$335^j&propB{wn zACJ&IS0~>KG4tdLw9D7I`B5>^vc2{4-iIw>#UAF!In0rBY_2~y=mjdTJdXq8R?&yP z-J&`o^i_vL{ z>VK5F@u9N*XDtzIE_w!+_Xb?x0pS#1E`B3kI@_G>sG^+3yO#4p^GWoFcmeulb4$_T z(P<^mh3gxsPc(TeIB_?G*%AY7(d~@h*cwapHr=S^h~&G`3_KO@q&t&W2*2ry$1TTi z%*ojM0%gU6hjY@lCu zzBP8I;3_V(C)`g@o!I8GN^iI>j?V?#Q_!vWX5-|mUbp3&>JxvE2i^8Kv3STR<&$o7 z?NK-80uI|EI6PkEV|fR`0sJoab(QcF9)sbRJr=+EKf8GO6X9sO96DQ``s?FvE|s1r zycdFZS=os0F1bm}pXmvbjXCMQ;t|jLmGbXjL<_#32h5;nYwqR_*e3Cf?rpQSDi-fl z$B+6JVCE7r?l|MT)8K~_k(;#?`WoO$D36qN2L>PPCGfu z9wU>Z8yIIreIHzO*6D({JOA^-LCUW?iSij={+vO|Uv?7Z>wWnNgOqQ1mGYW<@xj}< zp`q>impl?(%-L-)`1LN7wi-!TdGVphBln2j#larzuo=k zfx9Fl#zo7qkgmCLYLJ)rnxD}6D#v$&3q52f5nYVeJp9nd#rmO_>{O=jLo3!9 zhNIqNH^6`VSCM`n?YW=&Ti|K*23rgN+q0#6!If7!+tM9=?Fo$@qU;9B7Uj&I7`WLu zZ5JNpXJxWB0heCpTj9n!FAF`-Q$F*3Vry9&Y-RF=)?gb9d-wE?ZLlZEO1%@0(##(3 zjVjNaZMMY#5BmW$Dz_~HuQ>ncJMiFtHy`O8cs=x`=4SEPQvUrI&R)**0@|*=yo~4f z@UuE=WAJ%wgz9=Sz^Vlnb#+GHsS7>L)ur#$rTIjDhfs%nn4ykFp3jfGpU1P+$2@sV zeazcs_354ZwuSojPJL^6SKLjgPj}yj`XrCn`}T=HZ}rb-@vJ?JR=4IWwUxHzLt7HA zF124iP_-w()N_C%-ADZ_y6T@;jMDv*gUrEw@QCTa$SLV#pL+n=Cm%Zfo{9ClaiHfz zliSq)lGnCYr#}KO7+?9dapTR$)JxcOL69 zz73hQ0}Ss!s7EGC2orWf#)9gjcYZaqqFF^8E?U281r#KERk2 z-A$K7COi>r&(dSY#?pC>U)|a2_Y@ZA8}_?Me{LYpVe&Lo+nMd{!Cz(YG`9g?d+CdI zrpo?Iv1h%`^?jz!>jt6+_L+;l{j4m#LT4~Kt(;V((~Vb^==8=)Isw0uPCM~0d?-RE z(L(j9581Qb=Im$XcHvi>qoxEJY7UZ|4|YJkL*pqGb5tjD*hT6C#<1p(cZFxxhPPJu z!oaJ{1}=C?t3h^aUr)W4%h9<(cfSWY%f6a3q?=n^wGsH5bAVF@UuhNagY2FNjPNWy zGr*U>?Ti4QxhNOb{F-9~A9!INSnqKa-l4qa1kDBEJ;C#!b3(_f>k37`q3?$&QQZMN zwgo?=zH9#DeW9;U|3)MFps0iVAJ7?Q1GnC{`^`Kr@XxYM%m{VTFGaeAegWUxm_IeX zM31SFvXgioAIkF0^nURY^e^&Hpg(=k5S{mZ|K@>R<6)~m0$wLQM|VVYe%8r5u|e*_ zc4#vG%PsA}-k02f%KApdSlgTOa8;|<4 zdsKcQG)&e!IZn17!N^Z14`;-g4XDn~wbW~MM(nUAJ071>lpXUw=4HouQ`3{)5}9ky zMb`(;chLoO_9tgJIXm+5SBH6ZfEk;Xu8G2A4P*9$zY@Pcp6l>C%sS3!>~Z{_;rNi@ z7}iJ3W$oAu+hrdn-n?CO_)^f%s9${}+KO(XXXu;zwI;AWVIDmR`c2#J>G$3@q<`q~ z3%IGz{~G$u4=|@pPiLbrML*`dzZSm-0{xi7qBtIxeseti@STf3r}_AO)ek?~xeb-L zJmJ?SmFdy-W+e|+eY`}&>!R(YfB#w(c#~?CZq75GR%g|1&{+%Bj8X7tII}rBW6HLtr!Y>n zPWAhs3$pE%+4ayv6sO#~h&{a6^bXacJ1IsP+c)R9v}?U8TTs&JHHDm>(FxJH3(V;j zOUB+^r#q?KT)r_sgmvy=z7_8=9F1*YG-EK#g*>Yc@e#U*t@-MpiPe81IHMe#PQO&W?rWe~r z-%RSp{?Gtzlh^IJ$8^m=6X}Z7D_+Xc2a-3%In%!<<3l!oAo%_x<WZ*%MhRwj^IoMSKALg!)+4)eKvy*;4Qkv?>UEkb%QBe4U+~0xwAJ)((F8i}_571mj1hCcY!ZXz3-Vs=P`*n; zz7fq$hlkU)vFu|4p9bFPi(!MoZa#@JmGwy9F?@iVyl$wS$x^I`cXScrHjZt_*(X`9 zvOejbR$sNJ6aRz3KkYdAD&ZYZ|2(>NAHGz{pXA=E(>Tiyx;DO9afkaek0;jrxJ*XT zck(&((C-=_7Bd2^EoOv&*`vdLkpJ~_^TU~MzEWO=m>aEwwGUo<*n8lKUSRCN7c5z* zm=M|E~V-; zuh6&n{#g1m*t7R_kMsC*4+-hPR&wJuCz~_f<>|-F-`f(>A$M5(3VU#{S-;fEIpLp} z6>Qev4*mB4M|)4~&J*e}zY%SJGUyT^4ISO^kZpvqaiaQe{G6}v&LQc|(fVrC@3aLN z4Zw?YzGx>p`WxUAJ8Qdpc^9(B>D}au(nNR2K1#mFxvx1xKK0LsqV|;vdR!gW>B1k{ zy7+9Kp|5Q7uZ4coK3}5WT;CqWU-sa`Ri6bq>VAbe^qpWk{cBv;&zZaK0%!4}ex0=A zr?ev(Yi`f%9^0n(|Iqso-aFj;Q+huY8PSB!(dyE^r?ez_5@U%Y;QT+%o0r{PcX7U2 zf9(2#$NxX7)29{tV|BmzEIh2Bmn*hjcWia#W` zmKEDQNqnd_QD+#NCmIv;zv%lQb4vQ*H-&W^eE`0KQv(fj24SY-EMlCvOJxK1z3*O5 z+(_c!w)Z)Ipw3@@%lkP4X^oE)NL)$G^vJf1@^y@C+qauv_sB-hgMc=R8}rPe7c++@ z*NtU98#W`uH>E>gws%N(sIjzt&yb$FvMrZzevM4W6`TQlWt+xm@&^sX=Z!=@EEa8> zZuPz-duRU4d0(8pGp`8`)e+|J8e4}fZnXK^@NjjCp7PawkA52|`qy3AcAyvwPPg>mK3wma`y|Q?;(w`N!~W#|Y%|WySt$qMY?9Y2w=_1K;vq>t-h&Z%gYql&6#2E=+Q53x)e~r8?c>`X>S%x#s`rUY!}u&uKN0UfvCH%B`-Y~E zzA?-nfesFD@viQe7rmV98_{Lw?xGX!=~&#Ry4MB$wga6pqT}s>UxI$e+VgVZ{SNMZ z597ej)`hpY8@ubvKf?dvoK9=L9a+v%!_W54iS>?{{u`HAgzb`iQjTX}0Ik`Gxde)7%v9RoL(8p!0OWo%-S>JGRnB zFpq7G6=cU2emfIa_4jPAve@S*lG~pCJpCj+Y%cL@x;x9py=1{ebm~Ru_%$)c>f$zg z)*8n5NiyDOGSJdbb8}F#WyD+RPjh!94XmkZ(wz~)DcIk4dR$G9=legXPyW=>zF?^9 zpO7BPu@3WdJaEoNW>oZx`cCDGeOC>gif7|i**Zt|zyL2#Uh&s9kB4v+P4u%oHLU%D zY!U9f=jp$}%2a8*;yP26w1iI&;9VVJ7#7xwJzI`WX`i)%OB**nS?) zuweaTV^F${Vj+WG+DE=cOQ zH;pd*RO>m(^moq*vdrm1f)n&0t20I4jV0c?vx#1&)z^w(}7{GzeIce2DTC(a;;l$5xbSmZ}ocU7HkJwO80Drc~)uBc~Pnc(gqxFxkC(weuTJmW`)-CaLA9zhV+3}XuHPKfc0g*Rc)m$D`@wM5^XP_yvis|wA~YIS-cl% zyM!{7&o40;wYfze?o?>8h&0)J#CL+fl)OuoFG5%H=w4~nBN$ef$EDB4Cv+7o;W!Aa zGMdsRqbYS1X{vJKd0$7ML2gY2P2%p9s(N(SKX! z{_gTP3)z=KjK%ume7;7yNTKo$;N)`lZ_)<&3auXUJdR!zZ119n+NYR9=}YgWKb$>2 z;#a4B;&*hWW`P)@)A3Ik3URmDa1F+lY6N8t3yK6`Bdu92z>N~b>=`ON+K$rHgCJ6KmZPB?G$IDv@ zcM5rJEQ?N(GoxeKdo-Vh{=7NTpFBtVGt{Rz)Dz{etTRD=ofl#}Hj9|C;JZj2yWdVR zsQ;V2FM*G;IRBp{Bv}qGM8!K0kmW{1LEn7<^O$V=6T zAYzA}SKL}7UWs6?iuR4rzDAGF2MnEOCA_E(YG3vbz(x6dWpjih*LAQ_m&fOWCh7Ch z2K#*58CfZ#O2FuTl=i#PS&c0?oAQ1o=Cq;nG>=SYc6nZ!`qL9I9;C7z&XbkEMoN^u zGCuS-*$(v)^c%V=ZIb6XD39tQKj7>Qg3sc$nk~I`{Ms9K3%|kkd?}CV@DA^>KLz`5 zFbW<79P1H~0Ip`hcW?VQ4lV)9q{LB`}SHeJU z(w}I*bSd{^@K+>v#tS;N%tH(1!H>;lItboE$5WaP_zB0BXuZx)1I6u(kE9FuQB1rJ z<;*oCXT21Br+dU@nR3to{_~vL(>mr(8hr)%QmnH*QnBoO%(EZydoCy2LOp+{wou0@ zxZA|jpIGcqt`TD_%j*$o=2Kr5t9Nt95D#3U#t_qYX~&1puA%nXzSNwmv=3)2L3i26 z{6(0Lf=*-pTj~Ybn0$XAYz%fH>3|JD_KT+R9%A~N>>Rp36mZfGz57MHxIL(G8?<}- z_f6b(gG^DE)OCCt0b?=MhwK8{VN+86TE3N_NA@p9-jN*pKsV$#0roi*I!@(|&Q};$4!nNX?lqt^bHW=kGNH|zk~PW2c%DiozQojX*@~31hAMF zS3Jjl8}%EwrG`V#u}+-AnrG<5etqf81MX@W#{1$!%XWn=_8aT|-4}nG`80l3f6F-| z$c=ms$zucd1W>)E7$`|46diZsVFIwT-gCuo9%~R7kMw+vh{DOSIuMd0DH#m2A z;Rdxg3G+%=M_PwF6KJkw1Nn8p!?#F1z*@*G(i!rVbXIZwRMCD3VlncSDd>NjzO(sB ztj!%yItn;3pa^F-R2@Z=Mc?1uf<6GglI(@b*v^g@VQ!ju_bKjCle$jzKqj!;rjOL3f84`#MVKjH>#6#=GDv zpF@^sN2L6KC&msj(Im$bN0B^G$E_tepMV%{U&w>*Wu({~cKr~wLAZxeCbW$7tMAzG zzG4~el{dc=9l{>Ccl7#}(ml~G?^MJ(Jk7l&aF>ebJ&nEee%NYlbF^2U?kSdQuaF_` zH`=`0Why>@2YnySI}L{~y%+yp-qYPD^4*>S`^2$dWhA~S4gDj%A{`VMSFG!eaR%;F zp>vn?jpa+3r&E<&=$z}zu-mULG`~CHvjN;2p1^&qNuKuuKSGOWeHDJARq6ISng+Aa zr2RL~5}#J6*aY-rZr=5abDeu%yzhN;+xLr=d%3#z{3WMMrTL)#2elu|v=Ba|d#Imu zkL}d(ZJX2d6P~(@f2DQr@g3Von{|Wds-J)z!e2UbPJ6&^Y2OU|49k-D?Mj=4Kc;gq zy8poUWDb%>|w4%R8X)SNH1NoSt6AkNXgd2{Fm^zAa%#9RY>8O0GXy%u+b z##6@@&i5+mZzryWzo%FgF%Hq(o8l#>?rI&gzzz_{Jwo^_!MYiI8eqi&ih&Ws({E=z z5qmOFpXzt1PT4kiNx1!mc?4O7sBXfW>Ml`rpGS3w%)=++*b?a8P7b(l4 zcG{ayK1Gy*ulSex8HOF8;}Pk*Kwo%r$)VKmtY3k?xka?UTO-e(*BT&iy9cK>@wNT*LmG`hf4$f6{t`6hRJ!6p)@_i+^ z=TOKy4y3*gR(g23=IN8~+V6rmU#}s^dmD&n@X=)J9sF7So-WTO$~mU{YnJzE?`!f5 zdMxjP0M9~H5AH~MO2-e`)PIyw|9=iJWMb4${F8b@bRt#?4~9OydoI?h_g{Jd^AUd| zPu>ghG;ED|OSqAJkgZ5P8K7vsUHc}IE7e2$`DxrE$3N6BgFmvLLmV>uQuk~Wo%g9j zoxBI~eBicj#Tt2s5Veu^j1R_r=J zQZE1O4(+SBJldu?DEO*n0&?_>Z7^5Gd$DM|O8d>=2W3nb+C|Ah;!eM9I<&Js-*x5Q z?MIbfOnu^~HIa(;OU$-Q(KgYKI)+jVT+(w`XgALuONk%Zjj{t{E^R8t(S%Q>yc12v zO61STSIhlzMB7>Vn^^F%$)eC9q!VO^=6m=^_r10wxlVyH5?=Xa=bLJXYu)=~d@`k& z_f7b5;PnXozVGMm_odLT8sj35>ZSa5=We++Nii1I$ev<->AKy-~BYXw=dTArP-<{)I(7C85`{!qH1_7|NZ^|mW#J`*=2y zF9xn39?5&(^&UOfz-A>-_+ zxc?k&?MMC_^HUTjQOvro1mE4kycPBP{-VtBuJa7!W-GF_Q(Cc05 z_QaeOV0vQTz&d=lr-HuW1AD@n`DW6EuP!|H?k&$0sk>~`$d~$08e_vpVSi6mW*%>H z<#*d4Ex(iZYroClvDzn8wYn#GEcY`&ei+}*eu(?+&p~o zW~|M~y8&>W7txq@esm%!&8_?s;k<6b(9DQMs4JK&Y%Eqv9zfNO(peF)wz z0B^5=|D`=MakNeMhy3$C#8G<#UZ`)zti$hOtQ!!YsSfH71Z3{vIrc$)bzvXub;Y-W zO2x|10Oc2z-`Q)^`h&pp6=!eSa1g!6-EFqmxMgqsO$My*>$X#XPuae61X#MGhQ2-Y zPBr`m^lTkq-^OpmgZI$6VDbs;Z1FS7*6Oks)TsMOFW2)WR7b0RXN})c7BQJ@_ufb8 zJ|N?pgu_8&NtOIw1imGR*j3?l8{lZpmcIo+<4(+v02cEkCx}Nrm`b?n?>eBLEz&Ut z(Y6ohk>9*1q4DZ$c~36g^Pf5l{qaG{mI&v*fTQ_D{_cHZ1-`RHdR~I>xIicAE&#I) z>@!yL6lmkYI`9v1Jk|36expx>-3d(b%l&8zXg-?nnjoB-;B(QRN%)2MO)rwA%511KARGK zceOX3qqQMA_pQe*2i~e>D`R8KcVQ0T6!@|4LkCt~_VU2?$EjY>8@g!94@h65gd^mY zFy_7K?6jxcrvux6>MM7KE*Baq7G-R+M`imhzOoT6J9sN)`&YKF^p&Ocd`auUTPZuD zvi%ZY*r*E0uS~6$TbycuEy|;+^t*~C-jN#0E=z%@kd%~A)fd7^8F?=wMOH#wZ z4;q(JJV!Q)wI;fUi|XD$Hc0OQEA0T^fxz78X6z$*By}oahTw0C$A;ry`0q@%Py!y& zIw{3KUtQ?lS$Z*SM@;FV>|!d}g}zI&M4h#K^0oaY;2y>abboOO_{y;lVn~w9-Orpm zVE}B4=kEzl<`;o4G*$%+=0+qe!K^|ZG=>P_E(f|Bl5h`|7H^EKE}Hcr@WS}))8jXe z!u+t z%Frvjx6_!P$FN88JU3u^qAzn`aDEAK`7)}T@7txZZ>T45MLT~(dsIgl_Xv?pUerED zu7kmjerx`HiCFZM{yPjiNf7@~?*m*%nW|%i#zofgzA>k)RgFB6R({+0WxsrD(3G1xq>$GL+gEbw-Nrp{ZcANFqgw;Fw71r z??TRd!p)26yg29Kjw7cWct~|FL|!&LHvRq#dhergR0bL&n#O62 zt4W8J#ZjDR(2>x2I4_VB9c{Y&a9ub13(4o3-Fpus8jil-?WYTq8_qd)xyJi~VF&O* zjvd_E-);whaqNI#3_B=Adqw1%dfWE}fL$=AA zE%?S6U)*8G_L2gRsoi!9e2n1@8p7IFe9Hnqo8b+*lG^T;5j^qZ4cFEqn&xKCTkqz@ zv@c(PJko{GR&*hx?Vw86<*}F3iWZ^ookjY}_?$*?K0h$Rf`3BeKg|MP!SEKECTl!G z7MjK*FB_VM>AJQ?Q-uY`&wFbf{n+JgSo5}@1^z>Z|CbBTJPTRi-_8L4CHAzLbb9(l z^l4Y=cu?B9&35+;(2rg3_G2-vPk%wXe%U^h0nb-E8P7J2=YtkJugHLBqLcAVXgt4b z!LvF8p5N$XJd+yF^DKBCk^#?Ios4Hn<2l}f=MIeL|GIawNq>oZNZD5>@huec)d%?S zP8Tb-;d~gu?`3IkV=wI&p7OQ#oZDVlm+xgMzaHgH+xFn_mK(1|bRC<&>}ZoOXR5;z z^QJ6yJQ=W#n8xD|mO6eKuny+u^Oibp_SI2nU&lWAb_*^SbTTd>&D)DDxSY}1xM&-h zW5H!yC*zXPe3@v$WtUFIC8hbYhXt2Udcqf2#?gHMYu;<>8AoGYg2oRqnqNC`FZ=ib zFf@K}V2ts@zpy`@;xRdYN8<;~=WXlwp^eA?s9VMqTZtJ{I%a&8;cYQUj$`R3P^Uiz zxh4&te+F1{KRM2@yYm%tPE7N;XdgSD0pswQU<^Lrh`J@80TUuVV=i-B`JB*vzKr=6 zkk3mQ-p1!Vjnk^ohM(`_!S^tYy)a)z^G;a5UMAP%7fuj7qpzOKWYQ5)aP78 zxKQ3kcOEIM`=iAcJWuqs7Y>N$5kbolEG=c$b6-ELk|=S}B4qrYu~?ht*) zaGpV5NYi(?g}&W=tNBG(jyl7R&FDn zcW`^Aerh~#<~$27KeFJmFh^X%nrD|->O9j|XEL1}eDmQ-vM;;K9B+ zQvq=q611ETmx%U*hgopx>#LLWE{HGa;VTylSnl7YUSH6buAGmCP%ov|y@&?NGju+o z^GYoA{mIu}B3*kvxxeTumke0$m%ehTbmfSKjjo?>(=@#Lc}IH>>%8Ako)6F4eC-jR ztn%~mGp2RoVN0D?`Ra@WtTUx`{#K^Ju=k{<;RZ{4i+t_H0=5^{G!_vpCQ2pJC0<6FJY|XGE7f)`I80K0Jx{ zR-N$a(oVi|sdVLhx)jlPR$6fR5AK`{>-i3f%ZXkqF1|Wry3QRfb-wSbllU8?&ZMpr zm)vx)Biw`N!ISu#woc0$^m|mNj|PhEsV{!Z(%yr<_F|dZGjt-PX-Hb?yv0{%Te>=Z za>rpB5ABI`<$Uzfn1|@Q(}K%#U!BQ-btZM4D=l@-_tlw7SErA@vwY>kSVyrNX zU%6z!a+7`K!kF8&wnzLlV*Qkk*=G{%lxK{qVi zXW$aixD4Su3oaEFTt3G=mZmSr$QPr%1o;op-rG_q&Y61ZjHKhWPqwf7%Ebbfd)`;B zEnvCTzH*6l1LOT3{w3E{92unME@U=s0#o++!C@=WRg#(s*END5O&GWvt-+k86 zj{YadI%AMI`OrtHPHGQ!%RV8Y^M1~G+vsE3bbYtz`XZ*yrtvYuOdoTztJe`-*OeAt z)MUd8%f7fJOs{2MTodww=vzqRSF7vt*r#Q$-`N&A4)oCxNvB^VH?v>FngPl57^Va3 z!z|M_op(6rUGC;ZblxGH_k^1l(|Kb!&)`i6bMe&PP|n*8-`-{aL%99WttYJO*$=Qm z_?Xgs>`Qg~{LqFXuYbsvKVSV*>%4_O|K_{^w0sEIY-o9#>h{s{aE`P@H1B`S_!_jZ zT%O^)BYkpts-s+1`)Dcj9wG9zq&qiQc(N!Po)~nq9)FAJ4zPdh0!#Zdb8Me&CFT!zDbR5|A08OX&m}k zaCjGccFedTD9_lIKL3aI3jyrqBfy%rUub(tXu4k_9DP3IP9NQ=zVGe`sz z#{6@KF~m0Rfk|roe{8`YcUXAj7TPsE{)NPCi3K;hd(zY?`fZC5yOOtjM(Q{V{y3ZN z#Xk*iDF*b!D#loXtmtr0D}d3({vu zHw_fxb);~m5X+HXMw)^P46Z?X5vgJ@>O{I1={=-eGu}ImX8BH2ZP^8 z*NqoqONsX!GkqH~*YldRXE4xt&2N=%j6{p&c}Jp)IB%}^ zlinY?WM8?@QJy8nT!?cn6EJ$8qk=K^Inp=ep!VvU?X=Gk_d?Ji*pq?a+S8b~nc3xacQ*ghN9=;c) z>U_)1OX|F~HLUSj;7aN$!Leg)U{f}6*&>~c$8kD)HpH`Cb5=9?wWMvN5JvGaUx z?^|wr91EPsc?O;_UG6LkF4r+##+au~!=Gq@zmnk%StK<4k=(W+i=@t*$aw~jQabM- z&NIhodj5Qb1&0NU!*lN3aahAwa9zK3^CCKLPtN3H&vt5%Icbu`hRgXDS8h#_-rOb_Xc%9?}&NFm5tk+Q9MV_=ttoc#=!*RQ; z%ZP&`8vYL!ymn{2?sV~rY53n*;CnH=L3f*mf0A*1!>uc!^Hy8x`g|8{`^NY#sp0SC zx=g*%d3RdsdXMWe zo!7#7Z@J|XISTk6`2>w3tQcbkU)ou#gX&P!VAD(AWkc_cOb;}-ZYcGmW8$S9@Z@3+AJo#73A z4`DwF@#zi={JRWq@I0*HuLHcF=MkOP&UvQZ>O4BHDa*ZK@Jr{du;B1C<6y`(q2ZTs zU9Y%xC3RktrLOz9E<;u+4PVQ38Fm=Leh1>s97|m{aa~6L6V~uEE$}ND-rz|@!yjXT zzl`AxKE*Wrp%(b13~%_PHVr@00$&raG081y7`JNH@I`GnyOn@(zY+j&iV3*1A0 zGR@qQ91j8KAaT>>7QB*-mtlV?jn_pM_{SOE@RhvBc@f~H{u(s${_gWR&yY(*@3W}k zJi|s~I&ZcGhZ`9OgZFJ3{$vaM)eLXooY3$`Ti{m!-qgJie3eZnB&~)|PwF~HTk33J z9IkSGW=g|XTHxn0{N*luNUy2d&TF2|@CFaV8ooE`_xqKI&g)@mdn(su$SS7cH(Abj z9m4QN+ie>DBMba+hBx#iq2bqA;P+>EqyD6Zf87GVE5jT3r!;)h0$%3Y^UAJ56ifH&VE%4Veyn$;> z!%w%sU%~JrUrWBL@`&R%Fo>Y)+$ zq;%e{oM+(2bJKk|?-iF;o>#V=*F4&XJB=m60rqB$XWWf`B%<#H`tUy;=c9%(yrC~K z4gaSG7~a4?so@{9!2bvLgGqT9JWpx(`z-LE zFuXBN3+1?=ZZ&mk;6+NqmssGBV)*rLd{66x#Ou#3XW7OB-t>9on}Ya_u&-PQHWZ}XUcPeSfaUry zU50*zHC<0x=n}qmXkIG_e*eHdU{aoj{YG>TdF zS6kp;VtB*O6B>Sn1^#JOcmw~i zhL`6xeQWdIXZYvb`Xd_tI7|KCW_Y9in1(;h0)GL+oBY@Cqb=|afH&BpVaU@E$|Twd`iQAWjV(+iQx@@9@o(Cyr1Y3HvN z_@suv(E|S`hBy3wO2c1mfq#kN4SYhoE4i((z(39KhW&>%{1U)R+6-S6(Rodly6!_= z9v))dEC>&8WgOaF-HGXTPPer4LxwkEzBUbiyaoPphBs_Jq2Uj=z+c4hM&FUt@MA6T zix}SEVM@avV1bV^{97))p*@t0_OigA#qh?sC#>Ojw7{QefsbhTVhj9{3~$(aOvC@% zavtnphJWA1zfHq`V1XaX@K?F;2@U@j3;ezeZ}^g=hL`6xeSN?#fcNNKz?h_j+cA85 zO1HDxg4gG`w@ccCfmf)%lH0u&_zxN0(2KB!UuA)Ro8b*xk7)R7Eby-}yitEl!?#)B zf5q@d{cRe4xdr|)hBwAC2@P*MuX!KC8*)x+_*zT-w==v^e@erjX@S3%;Z6SUsrWzL z0)K@CKCI!VSm3|O@MbKh;m29vTNvK3%b11_Tj1vd-qh2!fHCG-{&HAX4&ql&VEl}} zpiSc^EchM4@CH2z4gZT6Q;8PlYtp$E>z?=Mv1dRQ6;C2kULwhMb zdD_xW5yKmH6xQ$$Tj2kN`?~yneni9HV}akm@J4?U)9^pGz`w=th90(Q`0rcbUtxH& zf79^aw!lBf@P-{FHT(q@czonp(QDwJ((nxy`2PjGDYpdXWP|AIE!@rW6yDc#O6OFML*o?l-=`z!sT^P2v-z&Sp=Xbmz5 zUNg8I!ykurJ3TDzO!c)B4rq@DTiS`}c0RD28ym{-roYtif3d*t%kZXNYWP#%MoY-wjc!y9~x zX!w0B@aOpOiUhRJ6S*CO{+Mp3*wW6CzIIXp{p~^Aj$zkrx}B8eEF0aY=eMJThF@!e z-<#o$elw}zU$(&S#PAQfc9hca&syM182&jIJ~UA2$!ZJy=eV!SkAGOh-)n*Y(09LA z80)Y$y^?hrx*gGVUW+Z7Wi*5{2MM`!y0~~1%4UB zoBmeAkFvlo1ia}_LPd7D`}tbsD@SM7Z2ICaH`7;+&MO5eH3)tRUzH+gE@%-<6<=O(4d&*ZX5)jXyqnzJ&g$60Ri&^-8m#>{rzhFZh`;8cOO?GVEpy5uUsTezv}PX6B?K6 zEx5diIwic(wym{P&JNU;@J%J}<1{KeoV+VEBhz z{flV$^%nRFhBxquY4|rR@ck_CZ5sXs3w(&-4gMrF{1X=VFMW4}B?9`SPkiMf0sFXh zC@1aPpg*bc`=JHD*BSl|m%k|uf4K!d$?%5WhQdnT7g^vRXLy5OVGX~?0)Ic?P5Vm* z^p&^y$|cH!>H9VQa>8Ct^7JB?M-ffK6blXC^wk*&7(XrYmkVgKbf2EY?@kxLn8vTa z1-~1~t zU+&_Q((o@@;CnN?VN;=@O6S*D;J5hh*h&S|#f`plv4G{?^_5En^c8RT$|VE(reC3) z)Hj2FVa<=_7JfX&@He~sj%fHM3;cZyZ}^y)hOf22-_G#YyY;te_%i|Tw~d6(n`xv(-;SHTjX!u`Q;L9xVNe%y)1^z#DuMnL@G4M%g`1>sIpD?`XbA~Iq-EM(@ zhvAL2h_Hsg)&l=~hBxYuX!t8E@V^GUspoA0<@PwYW7tznx3kF7&ixGkimR_}8a`@) zzr&ALK%e;|U%5!YIkGlixmZ9uynyL4>^-6B8f~Gg!PibAAivMEw3F2B>}F|amam;y zz&`z0ZpY}OQ@WimKI&-q6B*u+YiNYh=f7LvaaEbJ7ehb88vb1iJg!so>SsVYaE+X& zTryxRgzNA6g%B%-ibP*ZiGS`7*M@K4{KJKB(LLc+ItL>p3%a+E>380mf_e4|f9Lbxvu z=~<-DkOuX{9dSsXAx-Ip`|XfcA>sSjLg4;Ax{L2Rq$JWNq%iK`!&wor5-Ewa2?=Mb z#7d+!NI2Ii;z)NOZ9)p;jy}4l?+&Ebk$QlraY(gD*C4G!+Ibhyh?GEj6KN3c@SBBn z8PbDD?;-7jyZrc0zYv%|0V#%b9a0i$6H*v=`K?4+gY+p<1@7^yN4g2=MWip0BDl+M zDN+LIO{AT1k6#4oVx;?#Qb_%ApI-!NDN+LIO{5U+^_zecL%I$riL?o64DR)IxgGlco^}{`Z(~&MidJyS7 zq<(`SE2QN}_ad!D>N6PaAk`z?g!CfPmq_CdKs!j+AU%up8PcF3Xb0&sqz92cLfR*c zcSu(vJ&yDd(mq4cHqvsW2a(=G>NgB+BP~awy9d`I^%;(~k?N7|Kw68`Zv@IBEl0W+ zX)RKpk$^+0N4f*)b)-I{@D3@CbO+K}q&}nZ4k?aw2hv)kK4b6>sUGPjq!*FCL>h4* zzBlS2KeEy}KO{Dvg)*9t^fFQ? z0(_BTNY^1Hk?`SU5uOJBgVct!2I*6zisSJPX(dt;X%o_z6M!$$bx291O-SMCc!#v* znc`Qzy0F*XU#%#{ost)yk~%^>^5ir7uMVS%(B$3Q6D9j^syu$nwX0U_w<)pW+)b&S z#O9aRZn<`SiMVIoPE$62_k=Ci%J+MS&2*P#YF8n@{rnZkd}r4wn^Oe0$CS*>6(?>K7mF(LMnG4tTkL`)7b(wPN2*t5(d}L~!f(7MqU(J{#rxIh+20 zf7kClW%D~nOnGEgiGuq8?-C_*HVp;*hDxznl#2Fi2(A*}pI)&K>YBajHss%qI^QQ) z{Czk6#yu}r>|Z1frMB*)_x}{Hq=tyiPfp!ubprQ$riSBp$v&%p1UlaxGG+67!>4S% z6=kWdcRg?4Y04`B7d-aAA(T+HM zbGTG&mb9+z-`;>WW!wlp?}W0eO3vMME9$xp{KMbi!#SH)*H9a?H$6&!Bfsok`up5X zZ$lO&A8K>eGgYgQ=N&apm~3>`QjtCTuZW&eBOV`<|Xkh*UEN4_pP@Vy>i>BMX$X1P1I8+ z#?$+IO3vPtc;@WYJxjrV$UQXl?i)qXn#rq5uAL-G>EG)ntt$D$q;)05?dvKp#@}yF zS~uk4$vaF!9nb8(TIS(?-F18DyuB};ygTwXKyTK4Q$Wt=ZsInm?xF!x9(m`WqW1D8 z;0bxBW=whHBk=hH$UF7TDUaO!%;~FpADSF1o;M%9RLD zYH6JtZ;nQ*Iu`a6ZK$blt>t>FYU}3Bi#A6aYNBYrHo8<+T$c`}#b~Y`=(Sb@`*`)- z`e;?OA>O=9@JpcEvTWhp#(LmMXpTHQ-nbyzP*u~|+7K7VoN~&HQ$&^er&=^eTUr-J ztD35tTk0C-3;Gv4nFnrEMQiKgq8Cvo-!`j%6iBm;CkLq3woU;P9tYhfM=A!lR*b|$ zG(?whYt4-{(Uz8q#Sl+jW5dM3tt0riB1pbziC4#?69?ChsAz6nBHz?B*5hB%tCz|D zt>9-!AuuVb%6WASbuBRnmXu&rQ@mN!Ha1AbSkxMAURDKqg#6`YTUa;KR2>Jy8(6~N z!GTg2?AacQ;?>RZs+M@PsU!0AmaEKEree6ax%B0>+6r_<5nLOskH({BtFDG?s%om6 z;;qe56sT^9*UhVgfDH{-)pUfZn%CG|Raa})thv{b0Ub+sY@w+cYF-2FX^}PSx&l@2 zlbTtkrM|8vI;wHryq0L(BS~&eimW3x&|`>m?y`8)gN?)1XhUs=+&Z#UsYzAMjg9fB zhfB(Bc{QqPVRcgno*T6^G&WS#H`Y|wlM_%9F>>L7;6me3sC~84L6ZfZ_N0O;c620K zt1kplU0+`Ze`ZwUYBp)9b7@u? zheb85%`H&U=+daA-#9?B5gmtVoc^NlYzyIYqg5KSmX4^^N9UoNQYPfE)ZoSWlKken z`LT{*{^lB@OXE<5cumY~XN<`}$H7TY>on-EsX4l^adEU`T^?mfQ3JX}>qfM|0wtf8i{CGHlIRLOXx3SkH09|`2XC_pjzq!qNO(ez=8dc4zB;+OOj6_Y1d9Ozcr9Ia_=u0`0kaA7q{7-#Wak zUDi;&u&$;GeML>Qmf|(HK&-l@s=9V@HD01R8!g%

X_2>er02AO;|{rn;d@HRXt%Y{|u=sk*hr5HY&E>Y8}nV(%+uer_Sz zF``_p`%1;uf`}!DUmb64AvHXej0z9@c`~(Rl&GnXRwH(-o8MgB6swTBTybG9fo^s| zZC&%^fu~F#cvvqHt(_mOI0pll#wAquWOUQfIWka}C7`DmtW?!D#;asKLRuR^Rw2GV zrzKY16qRL0RMc0`jn+>dI1r^hKqC*Ykbe(usyIg=lx}REjL4+|Lkxna7<^u1LtJId zt#6Hvs8|xEu4eLqWALA@Hx92AuWOn-(6#o72=05i4uSp@y|lee#J5;l4doB3E=+Sz z87e!$UXC|vfixRnM4%ia08}ydOhM!r>hE#7b?{=<@kTe+GyqkD2Q03G1B5jb+KzvA z*`axM#6Y1q+0+PbSHO+TuWOh*#$l=E)xg2E14V_y3U;nU`3UgBU`+?k^y=t$P^@;~ z9H;=J;}KL#d|7>Ta&0w@L@IgsFJ*N6dx&Zyezc=agws=5YnLU}Y@q&XU|u4@ny3M1H&hc`#(si>mj z@QN|k_X8#)!$7PVqrPfzMYc)9V1Ff5Rj16DF{`Rd$jK7&F4~&Jhze~@^*|yH0pX2T zgYiN+ocUl&!-xt*1WW2_hBvoYW!ff*fH{$}PZZl-C(aVF=~+Lccvy&GNL! zziXouVYG~FYyeDs>%s>8wxtHIG0MSP%$~@X%>b>Ak3@-hOundZY+O(+{S=MdWPPF+ zDsM)&5pAAWF=TMf5Qkz|tV29wJ-8Jfi@iSOlG6uYC7>Qo%x{c0QUtQNuCWzrk8u)d zGVzL{GF80-^;!)Zr|cTEK07+@3~0iw{t@y%NHUCV1 z;nDO=b$}jdF1j+O8f9Jnwiu4QV!AxigjSEu*JWgbkNq?p116bTz!XM(EzM1k*hZoc zfoLk`!R5dk#~Uj!xW!O99<3OBL2HFnlr(_NQ5&dtzOuU3Gq^XWBOR(t_rtE2`bo?% za5rR1g%w3Bktr9aO06#z5aF~yAvATNG%-m`_c%$9=xx;)+SfroHh8C5ni_G)a^7mq zaj}yo1^=fpG#Q|@-E73F^dP}R*K)Ka5HE@HXWl0@o4=Uz0H!iGN5M8zeV=D;_mqowT zm=SM8EoKF4&=QRaD;&Q`Idh|!*^5?rFr!v6k4@Q4GzLwV>3Ls8Lo#=U*&ZnxH50h* z2?P{FjYRQdVO@)y635SZ&DHZ^#v~u=BhBi)6W9XLs6sO;T}9R47U}JY0$-Mk0XjN4 z-+;bzp6=HPo`_PlxS)vpFd?UV8$C@=lXUPj3+rfbu0&WLUFwY2S(4tlaq8e8zPkAh z#PsOGruZ_agp!ugC{@QDO~&d-qcLqlgX{SX2tJ@F+zSd+g1;T)srrf3>jR81fxFeK z3@#o{Y#(i|tF8wiDruM>k5x>1(%S zOeIv$ZETLChpo{14+W1=lz~9MVHvML__`Bk9^I(N@Mn5>q1=ow7G+0*OtKFUR9{a^ zFw#yOD;QCM*)@!H9IMdaF^s+N-kqOtg@-r^;*d)t-m<7G&V`Uo@tw5H>gIY(#hVp) z%Q=8c>slm(9BevZXRuuR3B2KeolLE9Uf5BHp{6`EtH@b90q_9&%OAheJhxnLG1?;a z1DnkHlzjC-lXu}6yelCKF1+|S!;3_aR)(AgClMp70c)HhG zlfAg(+SEjc$r^Y{Oq5|Lz)U9lVV7zum($M}$YSxemR2=M`4JY^y0bOxB5E6}U?Ax4 z;|R*&8_+YLm!erqk4Z9jWEiUqSx)5ujCpA^JGUAusF;h?Y&4m}Hj6_R#H>aHwr=5E0w3CiAku%9;xQulexXLMK6^ytDnA4o}mMb+I z^%Wd>I8CEt2t^ZoYIG5#7PAnwlw&neA=%3~lF^$0oN<|*hHP8ZOC9D?Yt{Lfwjv`k zc~e&_X}7G%8l2ZaWD=~?w)!zbk=48EPH!5b^AT6pR>`rF$5uTf11}giqk0;>=K*0@ z6eAhT%zFkH=~iByK*XjK%Di0=t5Lhzj88hU^%Au$dts}wmdd72%dk*AqQ@f*XHSS$ z2VUGYR&5Qr$W0(>VR0*Z?25#wv8n}A&)yZlg>bzcmhFuq%m)LBaJZ^!ZfhOpeH#$R zH6fx14+tCi0jnH3q5}QV5fxQc)h$xY&G81*Jg>E(rmAX0#X#vmN7W1*QE}jbW5(k} z_Du{NS~0OAytonSJO<4jz|P1^A4U9NY|A+g;&X^s(BU<(rQ|>&%9c2Oi{Q zWY9LU@(8+v#4^Z$VI8uz zf=l=SsbVd4Eu*j{0^v3b#1Nayt*yhFbZT6(E9Au@m|8GHr&;@@GGGM%;P_G!9C?apF*B~{Uz_-qZZ_mcg)N2g6n%&lBTXUN3(wa5Tjpj~B9 zW$@rL@^Jn$U7%niNjGWATy39ZdzTX&PD$6(HAR~dTrI5PlBOVhiX_)9uo`2Lu&d56 zc+8lkvsG@z24%Jk`NgBzdRX>3hD7<54QDx*W%Ae&m+7)htX#@QvAJe26i;kTLA7P0 zrH=gB&(lnSgKEcMmn-npjEIt=V}I1{u}vAmI~D5<+L#TXl|{Kmj0tPW31=gU+z7}R z)Wb8%QTo=_SP77|qhn#rX+-0gv{jo_-Ab{%VRG5a6SGQ3Yt@1QLRqy<$Sj>vb8I^W z4Z9`Bqjs9<`FJzrkrg{UG)tLKx&=02`?x~qrql1OV95?h`l_^8k%E$y8H2+r$#O4y zbz2@d8lh}2Mx#y5jo3KQg0&unI4vGv zucq{?Tv~C--S;5(Hz>F&TH+%r^q(}DAsvCd5JsF-%r=|@hV zcC;8Wd(03#8t4(HM>8I@Ujj=KKAljM8#`M3`B+h)gD|$WU9;z8uVCHspgI=c0Sw6( zW)gQuEltz`ibg0g)}CoiN*ft1R9#RvU*N64;0QR?(dh;;LNv9`6);+KE7;#1ufxpM zL@`-lo&;-6_=n~pMLdS7`dYQ?{xC6YvKTfPY58O^da}T;;rO5a!HbdjA3sOp=cGxL z@Z<2qCyN6ni*qLn{6Xu+aj_UX3iWQE2F&@%Z9nrGL>Oy{SX9F{s8ORviB?+bq+YqD zsTxFGC@#W^Yt4cv)`jBDm{}K%P1SIQps97CXlz@oZIb%Fh2QQfjoEN-mF z>?}&1Bj$*ahl>H?8)76#7Y7K`iM0YTd9oM)+69OwMu>BfAsd}E2^=BLh~Z*5{vQm+ zfnj2_7%YYj18c!qv3xn2S=LY^s+Uyb#1l>V)HQ%sy?qn=qFNyrxdZ|~v<%46PI;!O>cBw3DLOj*5Ovw>Hw21LbX3@&9q#S!WpwZ9} zJXvwkAGumwrJAj&f=`Dc*HmHmw)!EVs_5(--qtr_|28u9zx0p!%6MJd*h)O)i9|w2 zt*ssCyff@-3SqSnW+qx1Tbr?fGmn%-@&=3bSQlLwrG3`e)eO@^hf`CJomooFv>Ezd z%0+&ZlathBKXr0B*(_OQds{``oTPwhN~+&et(?4+Z_yLQ8zqa;+d^+(_O!o2|5TB&&C^SmNc^}LW~>vP z0;dh#H1Ke%Q3tFPlypjFo_e9{AWgziNC*@&A9EXPmqA#|U}&{M9@LV4Od?Odj3Nxz zpMkfoL(`_dm^uXVpAH@56C&GctuYrF}ul>3tckcIN^x(3dXzub(P`o_6Z(Luja zJJD_Ysc17^V2T}{m?$v8S{raM!hA6oF^u<(qaL2h;7QG5(y5v+Tn#dcvC}mHmH+j= zA%|qXkzUz+LH)m3FZpWog>u`LH#Ic8tkT)^2f;`uX#!(wJslXSk}k<;hn&2064I`@ zN04AbhKcH@+$#>xqM+o-WBF4VqB2nVLhU6q8jx{`i3uDHb+q#IumMMiKw+cJHROPd z47oAZd_jGz`FAyDrj{*~n@eSv4;K%mm-}v-+2xq8jNfvsCKS%bYsYwrB(o_;ar_xj zT>mRIB@~NN_k&2wH5%nA+rE7%|F{vpKJzl>I+ zx7TY}QW#1)@~6@5bnIZT3xRe9A1Lg-gNwWCYMdgD|HnR0U5g@+|FZgO2iEXr&O7a; zoWBiU!j;l1_?oE9gN1w@f(bj;VQlJ%2a;mrJ*ir#LtMN$r#H)6T@`~nmB`{ zj=j=|Z0V>mVlZc^-%%5Y93I_q@lNNhrkJqfY+hWb07A%FtH!zK!>5bJ1qg}NQ4%3f zSz=!l#f?xv)YTHNXsU0;sE;<%cN6ATXq$UV*l!pstFGhE`m3m>7K;mPcDg&ud(Y zQ!t}zql-t+tGnQWWuse~Yq<328mwuIrkRYoXv=8SGODF!p=h3~4y0E|y{f2=V^cgf zv)A?#QwEP3)`GQB`lpwm|F#(a$rpIwo(|_f&ZZai+sW9bze3 z2ABO!6}y!eiE>ee=Qy!zc{xCeaj{ky!==Lz-d|U;o0uRD6~~BU#c?7cri&TkWO0f( zRm>8ni8I8R;w*8t94(wH7Ks~5D)6E39mI}eC$Y2GMf4TBihg1@vAftq^cQ=Iy~N&P zADo04FQ$lT;&^d_I8mG=W{T6rY*8iV3phWNSJ+jS76EcFp5w)EJg11McrFsh;MpvW z!?Q)qD=8BH7B%I?vV1i@U~s5|&5Y8WLDz(gn4efWK-0T*tSiBjapFiH-t$ErXbX;U zRC0SEo)OWA=QPoZ=S;B-&(p<)c+U4x)dK2>>viQNpyoJme+hmm2`s{u?aW0l^$W_& zB;_olE6PHeQl_I$%rD<1fVBE$L0vjpW|S5~77<5M`Jw5=(!Fw!ULCYDxV+L)#5!k_ z%FSgxB()^9%u>0rY`+{RV2OV{H1Da>Ts80OU{`=Jt*QH4x2EoqoT=-d zh4&BATVI4e8*_c0NF+b~+b#GIql(6;&pr~7Mz9|H>)4j~SQ@*|ZHw)+O%X0Jf`KIuaf>F4>8~10ubGEg7 z%Wp-&t;jm=HTQkc?unhY5p#Dp#MWpi_tB6K?7t3v?8cn!pr@A2-q7(O z+5)bZ)-W3DU7v^n(E2svTI@%mb*iw~vvha4TWD&=T{rH2p*^5KTikr z)mz+*=MeE?agn%7Tv57*l=z|8kGY`nYG;9ZI2-7Q_p zI~$I78aVDLalE}tab)=_Up!P|H)K25r{=|LBREwq{_c!Mx^DkmskVo^z^TRJ53tjn zi~5xI2X^DLvdvzwh2lWgIT#iWA#FApoZN;*O#_}D=w@f@< zl5KSJXvyHf(ajJ5)E{=AK7mH3lA zE{~Svug_y_-rugU*%mqFk2h>@YL9=q-Ti-sX5log+*y~4S11b*y;REB#lNdxokqwW z%Ury|wqnV8ro6(>vmWz2P?}G#z&+Z{(0Xr=X8QhY`?3@?VO$nH+Vxm>EmV)j6yBa! z3O~q_!dnVRAJ zVaGU4#lq=gN{)Wrm^ZT~&A{3e#h_D-GjAuB?i(mh=@@6yEGN$>cf@>aID0XjQM!x9 zEYpk@?Iy`J+9YgwzePuEw{`q6qqGG0PsK>aHWD!{-%GcD^l>8M@8A^H;S^%J2tW6+ zRQ_n$BMVxlK|Z{1siS;?Q^Pi4lfw*Hu96a+VcVJ{_Rb1>_w*XCS#+c#m^Ej{Yes4B z4mPaDyW3{Th35Tlvf%v-rMdFHFj34$+ffw5aq%a>sq06}9Cf_49rVk8&U?Si)V;eM zP`AEd)NR-fsQX*qsVlTU`#1}K_Te`Bv;1n{r&;>5^#vT2vB&;bUbXNaSyH&MfE2P9 z*_>Aja~pSlo_9O*_D5YkPs63N?e#b334}ykOLI zZ_V6cJCI%Pg2`@|?SQ(S3P#;-+W~dE7L2;?t(p672eR8c@3Q0AuoAubz_hnzsWln3 zs`eS;zH+fiMt>qiI}3{OoF+ne&J=y{JYDRJC*MX@7E-rR?TCMAb?iURK7w8$-bX;I zE@gsN)Q%KALhj8}UwZZt{MWaS;NOAv5d@>~b{Wl>6lSKaFDT@@JAzP1t1gA2kYkq2 zGE{3a!6{U$r$J~lB}S_*icY%mEFbaXKjTIJWNxecFEJnlfwBGg;b7 zZkj}EK>6i0uK{JIPU(>HV+wQjT&};$tk>z*fO1{0pYN;z1;N~lvlr9gYe2c;dOG|* zuK@+YHoLyVT{p4yBBmem((Ug#XbmVE3V97E2t`5r!Tiy(M^>~*A$vM zT9N4r>bkdPs#)w3QZlY$aDpD-CHx&dQG8aM=LV< zl^u^RD-b^&)#Yc=O7vXO4{|u4*N*c+#o$hmI}x^i5}f%maJv|FGd6>^D{KzXI+M%! z&R}v3F>V*Z~0i=(##)j2eSJ? z!DM&+c0k>=1*7iP?Laec$-8FqY;4CfQ~5dhbu;7<^yJr#W!rlCNUcuhtJe-AZp!AA zIIUe4`jq(Hd9|@&SyFgs0V(A8u%jlf#jY~jT{OAGhr!Qynyawx%`O}tW?sEe@l~Nm znG`|gca#~7Iu&0PnmUT0x`H|tUlp1t~^TjJ)0t^ATu@{XF!6j_Nsg3 zg3D-xj4y((h|&CdBlTPRK~#1P8Ge&X$?c&JDlmhBKJn{p5^(snRUh4+wNbA z^N+`qImqUTJj#Y`=2to3@mvA$_+1Wo{I&piypaPQuN44~FLTh-{}ez^|C$3H?-c-# z4|2dGRRBCD=g{XKR)9YD#2oNAp#XSXn}falumJXQZw@#7-jl~0e%YV?I0rqwDUW)} zH!-U|cbR7udUV!3@YwSG1MT&gg44o$m)lI77FKuW(#`?q%MCg3<+_6LBZ{}HEr(&MX-T$y9 zYkZ3H%%3wn`0S9iymrnrKGhz1tTO=+9Yd&R?*P zHi!<>e&2L4yB8mdOp0V0dXnMQZl7^wI#34(D=dC znS-;$Y(gG%#)}V)A6$Ii$Wm%==E0`C{lSJTG5A{n_2%O&arrQ>xOnA6<0@B9Te8Hg zIPZEx<0==Q9kSHpo%4W?S8qqNBi}L78dV6l{8;m`ENt9J1<;Pgn3qU^tY8SLv{G^b^o4`8_s$U$!V=TUCn zev;;5-G1`9Ec?l19{NeTnbm z9Pp?s03Jm-$Yo0b@OV6j{j`r3U_b3KIq+j@0rd2`9QbiV0r;_J4tlyr9`(Z;n{CKC z9{O7zW#h%=(=j&gT6#ulo_$rzl-%%@UWEyyC+0 z`FRd@{6GQh_;KmgCWu3Enlsow#z*sNJIs%+#)EZ4t$R(*dK`XO0mk8{}upNJv!+E`b7<8f*Zec#Li^nGXKfX8VCz~h`8@Hndgc+}*8M|B?Y z@cNS0tbIvK9(@VxYHJR@z7TjU%K?ui1>na;IpA?&0q_`_10F;2h{qnDy&mbl&Zus6 znt&a5<>DwjBjRX0u_pu1nc^foPZy`)$zNfP=&vxJtiQrM3Ey6xAdV9!i|Ni+n9Ix5 zjaBsJ-eZlgFdrT&MHJ6BtxO4($$0)WafUckoFyiPb_p=v?UMynY6lBr>TP{S>CT{d zLdFrriKY7n8iQBj&R72}_VWevKgjK*v%ukUaW0+_aUP!2#5_D_iu3V2T{Pf1U#J`5 zYc>DtHUH0a_#bol{}jG}MQ8Nr4*0nS|IhaDf3}bRrw8JHHs55;tVuIUi$QZl1ktGA zT6ALRZdu5T{dp5OoZDBEE5YfYvox7bKk+w(gJ_G=3Z)OM2d?xpOL<0VxzzjP!1?K7 z2JH64(tQHaK>B3v)tV3O%I15v={!oI^dRV$d6n&Eb0=duO4!rbeBun)BxyVG-0O+_ ze(=Q7y|U0?S0d~ggJ_Ufcf1-C)H4Rrd%w=mSDove?>Yy;G_yBk-3j6qgL^{qAwj8g zZ6F9W>AZTO=-53l9n&Bewt;P-g#D4NUovgLt8@Ooh-@IJ1Y8TK6a2mAAQafVXlATt zl=kxZ&QrvxV%9c!Q0{@6ySxV~wDf;tc)-kjeqkG>ALL$L71?Hr5dD5Rlk|hE)O3sz zv-?taF1?P)^US>t@jMqQ_y*Y^78fMSjl&N33krU(etl^Pyux63Dw}r+?%#t&y7{JT zaR$n6=@QC<*h!F);ZjMNvL$~sPn6Mq>?4IODheLg2OAycTV@wzklCGOovDrdtqUcy zASX9E`^?5(dSt$|qzD$8FMqHc5-1kmz;m!30WZUelwL)C-_RKzfhi0kgX?j&rdUMr z9g=df5YLEc#B-Wx#dD@uhUe+xLOkb-i$Pm&ajDd_&x(u0BSKkHO?k1r(XIvm(x{=X zoM&2=;FpR#7M1sKZosR?$p?3KWkI=jc4dBfklB@ND7+<03NPC(D9pEqxLi^dd~W93 zn59vD*!JFEeJ@MOt|%B~*=XfY;2pBDyOrC8R%S!tEm>0d{jVK`cVlL_QJs8}MrkHNd8f@q*^rPtl7n7LoI z@0QA?r=5wJewIZLtb_En!M#E6ZmD7W7Er^wPF6%!_w#YzDWC@CJASw=L;OH{5IQe@ z$ZB06%k{WBB$Zvg;+SvQ{Z9tjb?#mS?=1Y^0n; zzUK$sQdZY?OR8=upavFtzV2p7glF_Kk1K90%Wb}{>-^nL1=O~D#{h{8F~Co-Yp?U- zw|rA}PX@|v`C3zUZwAU}r)B5KESs2neU>rzPbHOI9dmb`7umIdUL+g2ZOl?`_y2z` zw?fVzdB;i5KtsB6w-Vb%=g*hY3`wEZjo3EwMb*z>55?j^JO_)1@vPH(vk%9Pv#!~j z-F0%K+51Att?Q_wGyL6;szR<}cAc!qI~Q73oxf&5dnOA#51uc77<_jC&4v|PR-I23 z&4zV9s%SQ>`%y)+Vcm}^nhooIRMBi$p{eS8&;C%iT;Zhs4&AT6qIthUYhdU1S2PdN z{irHr9LRI$L1dNgmXl0(gP3#YnRmm}+<7i{gK&J_`TmaX<0_PA;2Gv1atU((!fH9w zWshe&_m%>dSH38-(`4JhOZ`)!yhFa_^=Jlp(OL)5@%6kfx~dJvs?9h@x|zc={11o7RDd5-Ub-Mz(qYUkU- zn+3B6rm6E|)<6AUoRCAD!1C&R9ek@`I+%^m__~e>J6~RJOZ%!U_!vH29~#8Fub1Ba zUFqH5$8)^yL#M*W_vjKI-<@^QOItd*1^?cNihcfO^vwghEcWrzmN{1ay7`cN&q#j= ziSb>n{##l%;HxFYoircKGP_jXN1HDX`7x*~)_!Dy-0hg(xxno`R{VP>_0#$C9~%w- z@h8ZuYsOAxdR|)H$oNm-G*0Y_dwq*BuIq|B8QC_JRpg7;|1x;}+wFwcpF6zn-9`6b zc3n)pxnQ>1bu@j^4QcwS8`89;U^IDsSNDp=y3VeOdj8*qS zU6$e*)-mQq=tsL|KC(O8SvIrrNbl{!hXj$=4pLq_8Fset z|3i80QgC@)nb)!Ru2O1hpV4WMSFit@!wdZVmtg&w_bl#iJ-cp%>cymg7h=NmWBD2@ z?*%O`6Z_zKr0(PW-^{Yz=b7&>Wpz$3v7eM%xKqAeswEGTzSVLf+=v z8V5>i9Mr|u=+)f=y3pHsF{$f9NqaE~cOg&mt#iYq&W-3|o%2$ac^>6Eh_-@#RmY=>(kBl2N^ewfzI+Ox?j4Zw1sxYcM82yzGS=MZDuP9!eRR< zim{#8*JtAwo(g=A(l}@ipP}$;PUmaPvM$sZukKcNAtqi)r}GQJcWjK8{n~`>s$bi7 zswPURCUr4YUadK`tm{TM`PQ1lC4Z;j$#bV&F&_5v_oyz6Hocgf(S?%sVsi9$!;@pX z5EE}KRjB?xA}OrvlzEeUQ*^vx$z9RY=bN7C-He`-x*0tugPyL8Ec2~zr*t!VW_2@q zPVZ*+b7mLIrV64)Uq(X7tSKX7t3m z89iOSI>Y@>kkdGs&rzKZ$rOuvJj?YiitgYK8oHUjHG!V))SotYGx@|pPj?zSFYada zEbV6WTmX8ylRsDvdb%t^!#Za0(9m7u39`;C12x9@c`dRBHbdcF^Owx9lI+xO&L1A5B3(dmgFZCCQy z_H~ZyKu>q-PjBdE^0^80bf^C3$K6cdR&_IaZtG_B+|kYK=YP5xJ&A6nZ})UF`P|#h z==mw=+5Y3}ATw`j*N58kZ$FFD8K*V!JwCm^o9Wxnx*0tWb~Ab&?q>9??q>8n+Rf;B zyqnSU%Wg)`nr=qV)1YVjiFdYrzUtX-CZFfK89m7^rf2sozAG0b%I>_PvX}`%#kluB;#u>uT-lqb>f|@H{=li+Glbm+%aUm+|Z=Ud6MQcoWY7 z;!k)UAWp#@DYuI@JZ}}R>PBxcJxZ}im3=Vb<mBpP*!!cnvMD5&yySDG^1>Pm7Om zPhc_j+4ctY>B_z$OMNa&&61_AD(6z%qQbZ%uAida{lz5`<4+{Uw@Hk-++&hbv)n&q ziO(dZ{Q&!zsKE0Haas?eG<`YMk}7Am=Pef;&)YqC1}`@Q_aeG@rYrYvN#&uEzGEeQ zsvJu}X_L~yO@Qqs-Uk0SiOJyq?c)1*x|+y5N!QkYWLxLSw(5|E3?TixtXW5V^ZzbS1GBq?DU&ewIxsfA z_6H>M^+F$C6^(lVqL&zpx^5TGoa}(HzeFOR=D@1cJ71vio||+UMHS||M^5b zkEg4f704+Q1M#dBgYcXp{sB$;m7s4yBpsQXI(`74CWtVehl(Ep>c8lF%>F}ZkX?7o zdQ=U=WNoS*rf_dy;I*qo(8N!~M7(z~AAyo(Vica0Vho;B#75-5Aie@lzqVqoS{!R> z(Zp*U@&}6Zz_mNX_auFcmugY9HD0#ci2N7C?%=fBqJmQS6982#Dgaepq@dFCYCn8& zW0Tknc*V_$y-LyV3_;I%2zpG$LCH%=YLdiZ6l(vK7ziAaPT9k)Wjj(?BdPp}g~}-s z^P^-7AA+ytMPC5QrBdN>w1hfVLVX6PvZ5CO&9B2uP`<7WXV^hiEp9CUpv&EV({{`Ud7O%O2<}BUz(Kt+zZl|G7cs` zmECx4JzZqT6rLg5E(Sh37QKz{D$%#t_#2(GkykFx2HmO$vbA|GV8Y^Q$$2g}SC%`^ zDaY^U$@jI+ds}@mqdr^y`7(c!Y_C+*y0=LoS1&ykKlxD9_5>)z;2 z?GKpdyOCEe?&?6Z+2&6GJjqDo+_RrxDb;wY@H}`U9So;xLpca%lgf%=t5( zVX*)?B~Fgw%3H|kEq?Cd%2L!_CzeC2ibZOB~YliYt>wstz&ak+0|eXyj5E$M3f z4f$FcjRGHU1A%+`;AJ-Iwf$=W`| zbAnK^=Q4dH1|Q3O#ZAWeZY#zsCG9pVGx568gO~b_``^)qpB9y~QRYndP-*hG3OPL; zZT+XzR+`yi8dNR+GU{g zPJAU>@M_K$`L4J(>7mjOrfLc zwe=QHSSafwbDs0$jF1xDQR3m%X7#O+on=lvaC3d&cv+^erHqQG`pKN-C{t48mBsG8 zl`Qz}ZGh=$7mAMlsHL}f!6OGZE@qy(^BT<4KHz_)*h}Kz_4i?k_dYV`Vzg|x+-EV) zaCvH4)e9)&=3FLgv)O`*Q};8tz__V06*9-$d#Id&GDrD;E;C5JI{?p0p}YjserE^I zt-iw)mL-M(TUTak|A6w`!;o+H+`pF;vUG03Umc}0Le@SC&#*WT5U!scC3}Z4GT-aZ z))@W9L7vtx02eF8!Lp2(?nC6eL-DK>5wzoadi8x&^<7hCd(4r;q=nI#hPgLc)^dcb zWgcpAd;cS4&e3>=h0;|nqk8{iWxf|DrI``r^cIhM>}agihT~q{%=RNe1|E+ zs1>snz{`cXRN_E0v}*LBWF7O!`Z9^bw>>zt;jba$yLh_1Rb}fuWv>LpK=C~Z&21?D z^!D&)rG)#w2ksjDRW5#nr>o=q-3sR6FQs0%cA@It)l>I%vhEvX-E0RpX**z?ek@^D z;dz9^i`(S8J7hVA`ycr(;dytDe0Q(s-B0Da`|)(^`w8Y_A4-RU+QqBD_4hpZI zNnge9o=2&2k;K!r9c6dtNW0@Ue=XbKzh1;&M>@Luk|*z286hxa#Piq7fUI=Nz3M6V zI{pefdB6Ak^`@uXpYWGE{#)yT{0sgX=+v^#1NjdADtGeU!(VmIUw_44ZtE#exmV?P zjG3YjJdhvZFSor<@Rv*9ryj^j9!~rNc`gV4AA8pUC^gY_Z<0;07ZgFU@B~FcL_|eI zL{!Ayv4bM^-u1H=?7gGdKm;4gFW9m7-g~2{*n6-4xtX0!*-bW^E$qI%JMT1eIz6AolwgKP5@Oh;#67Y2h_*9=!-|NK!=N5;L$1j!iTQYF26MSB+mk#*4z~^0` znkUe?EQ@pA9b6~yjF!VWuh!k*^Ga8}ADznzff7~>_*R0?d(tbz=jFKyd|s{9|Gv(1 zwZOU6;q$Ixjeu{>fN!mUZ*BOz64nWvTQA^SKj7OS;M*wR+XOx@&&>k9Edsu+0=_K* zcXyk>xozR|?$Y*wb2|onI|qEj;PdXwt^wa}@Ok%o_keFt_`GXSUnfYV&>qw~3HTJ)2A#aeft4|s zKL%JJLv$kWJ+O;S^b*kDAsP!T*oNpxU>30b97In8tItU^0a#=%qSJscfgR>1dIIP@ z57Ge(&P#L(@B^^xd_=DT>&#E|f56fU5M2a(2@GD4XgtuaEzw@UWWZjCXglBr;5(r2 z!bGP6Zvrjthz@>)3fOr?+VcmY^w6{01LoR@ zXe(e8@Db2uW8?!&0saKm-h}8(;1ytzO>y6WvA~zW%9{}#4LkzOu{rVqE(hKNmfV79 zcisBXBh^4On(-q62|DfnR|&wn6=X#{g#t(MG_9z+1rL+v1)8 zOnQfH`)+JpwKS-T*r6NHh!>2Yd;vxD(N#z`ek~z&bnQ zT7j2?G4-pd=IR)3pfir2F$T5$^k9}-T*ob$9)3E0-pihcEj}n zcLGa{K$*aYzQFS>QRK``+LQ@F}p>K12@#E9^^j zCGaJ%(|+JK(0_mA1GGN?*8=or+{C9eg}bzz=8*({=h6?t3&WV0CYN(=se(e zV8mg#hd{5x@tlA*M}T|4$G}EM65R!KI*RC6;4@&$qw#EkuKxok0s9zSCvYFov5&LX-IXnQu%@xYhBcIO~Iu)?``)8D0~WuC=xpF;VA#dD_rPkGfLp+Pm*U<6Ujo}-M)VZ0 z^5sO=19M%0{{!F=V8v08E5I~hqbtE-V9Be9&INu2b{|dj8nE`&M7IMSt|2-d_#PN` zEy@HIybe4C9tFBx57`C011vEH&mVXZXge0y2|Nb$xB=G(+yitPhvxyj1oXWT|8v0O zz>4F+E8rtw!wHZh!0SMVo6u$ePXR05jQ@4uUtrH$kO#2Ztwh%X&TaVr2BrdCCgOU4 zpMjlk2d97)?!f&7{swlRgzErS`9JUh$lQs047>rXIT`m8_#D_`3Z4bf=`LIs@IA2o z-MEjya`)hW5BMF}^z}LW*Pa+@STHqnz1K>Ac zp{Iy?0viK+0jC1j0rvrK0zUxrK8^bY^aZv6_5h9o&H=6i{ttK(cpvx%_zzg{8MIG8 zA7Eo(7vNCfG+-2PGcXl+8JGe50?hF&QAc1!U;wZYFcjDuI2Je`7z0cJo(A3mz5sp) z=6((w1$qE$0-FN60tW&o0OteO0=EJ80?z<%0Mmf)0eT+y8t4S91gs5g0qhDK44ed9 z2wV?L0;U2l03QHf1AhZ^yntr|EC;L(YyfNz>kSUIg9)W&u9~#!L9$2RZ`F0=X1NJMB3BXdo3P4|AePDB7XJ8-TNZ@4Pd|)(i6L1&s z81ORi4)7`PBk&(E&#Od>0?Puuf&Rc?U~^yxU<9xqa0GBXa0YMza0M_1xD~hyco29J zco}#XmtfHA82NnXB0J;LbfPTOrU~^yxU<7aga1?Mda1L+@FdDcKxC6Kk zcpP{UcmsGJm;rnNd=LBv{0rD`;64Hi0gC}Gz;ZxOU^SpWupY26uobWauq&_^a3F95 za2#+da5iueFbcR17!OPYrT`BBj{(mDuK;fY9|4~NUjsh@e*(sv_+JC&2NnUA0J;F( zft7*2z}moIU^8F{urn|M*cUhiI2t$+I2||-xD*%-j0J86CIR;V4*^dCF95Ft?*Y?+ z&w=lNUx9ys%v*RaKwDr@pcBv)SP@tiSQA(m*a+AX*dEvg*b_JaI2c3*p=%aJL6K0E+^PA$)Nhm%wo;oaf$Z z7hqX{!G53P=d^s}vN)XcnGgBQpLO$bE?^#nJHWhfBOciWZ!2fZ0tZwG8)n7&Lb|`<8lr2R&R7iffKN`~b zy>N%(=e)Vxav`~d`I+QSmFhs|>M}}ASWstL?stxMp?M5SLciMyY#Mg`V#(c-Vv>)wH2hf3Z5FL#9 zz(esZrNgmm^GM7zAC0-@W9V2qj*h1j=tMe+PNq}nR2oUA(dl#sok?fW*>nz_OXtz~ zbOBvR7tzIZ30+E;(dBdn-gmf?uA69_tKmqMPX!x|MFD ziF7;NL6hkJ=uVnUQ|K}|={|IN9>6AFLMmkGW}>23BAgw&56U zj5&-sjk%1ujd_fDjrolEjRlMajT^v_8)!k}Zyf(dD*gpo!whQUGKgK4%P4NSIn;5( zGuiX{$gvBtW?8UiS(i24UDk9Du%=H0YgW%PiMhqR@-wPm1Z#$aJIo(u&@M2FHT#1# z`?{<-7_R-mo&&+9{ahD^?3>3L=FXYG*(;SeM`$!_-pR6t z8T2_A#ZavI&}GfnE^FQcYo>u`?}Be1xU89;WzA3E-+RQY`5LZiZu*}P<8Q2GU%>d7 z+8Y1R!p1u|_cvI(sG-aG2Qj~<7UMtaYluTL^^L*EYeQorV`F0zV^d=@V{>B*n5~ShjcpLNt+Ac4 zy|Dw%^Kqy#%-F@))fjH$TkHvVTEV;pN7XB=;wV4P^2WSnfAVw`G>G)^;4H_kB5G|n>4HqJ55HO@26 zH!d(PG%hkOHZCzPH7+wQH?A;78CM!t8KaG>jcbf+jq8l-jWNbp;|61#aicNbm|)yw z+-%%p+-lrrOf+sc?l2~y23*2zZVC4o_Zs&Z_ZtrwQ;i3Whm41fM~p{}$BcUrTU`lX z#R10i*}9&TjsJQ!#TlsEL#W-usLfE+{$teWT9kiNUU_^yoNu^1E{#k28+T2WWtgUE znd;u0WXxgCY0hPyVa#LlUAWB6m#>1af%6RK_n3SCRZd6Z?w;h{J+7Uu+3m>Vj?mrU zoF)~?i=Wj=#unLnc=_FC>}Uq>ZSi_>UPqXP>UJOg*G$)%=%)89H&3nvy&HK#z%?He>=7Z)#=ELSA=A-6g=HuoQ=9A`A=F{dg=CkH= z=JVzY=8NV_=F8?Q=Bwsw=IiDg=9}hQ=G*2lde?l<97-RUADSPTADh$6>E;Y`rum8a zsri{X%lzE@!u-S`@#EpK(Rx?3w)J**Y2p4LiMFKcD1x3!A3s4wTZQuE%Bdw#Xqpkm0$5_W&$63c)Cs-$1Cs`+3r&y<2Bdyb{ z)2%bCGp)0%v#oQibFK5N^Q{Z43$2T+i>*tnORdYS%dIP{QP!2#Rn}*7TI)LN zdTWd|*1EwOhqL`qLVx7k7gw?Yu683_@46`S2-JLI)O{ez+yqx}BCcRFT+>>(rZsV8 zTcA!S;3^lz-P#nj>xKHgfP1qBYWs@ys`Z-nI!e93eFE>GmhV~bBPV_eALDrp#vN<# z`aW~-Hs9soJsyZ!{Os1ryR(0z)Zl&HF_(I-jF1<$){X1UBM zcp{7D-R%=`g~Rhg`57#V`!EbopmUZ%ms%&#>fpsX;KYTlR<|3TY)^1u7>%}u(%E| z4hN&I0`vJ9UE^}|B=Br3Lb)DilKL-N8gUd)hk_OT@buQSUdXKB{)c$~DQ{*5y3b-I zJdJg6-#*NIl=(O_Ei*kcBQrDeN#@hcXPGOl&oet_pY;0K|04a=Hp3I=|0#ZQeZZ|D zxbys+R2= zI@LbWI?X=aKEpoKKFdDaKF2=SJ`es2>`U#-?91&d>``!EWsgR>YwT<7 z>+I|8G4S1Bk3;NB;JV4a**?v_)xOQ1h&=B=ImaR$$GrnN9%J8Y-{+p4YAGlkJru~Wisr{Kf%l_Q{!v50!%KqB^#{Sm+4!$4kAMKy)pY31Z{tcLJ|7rha|84(c z|7-t;uA=QKm7U(sD$c6TYEB<#b*HbhhSSek)9LT5 z!Fkbn$$8m%#d+0v z&3WBVm$obfr=1g~HI5VA3oKKz4oLNZ!h4Uq1eeHbX zeCvGYeDD0={OJ7T{OtVV{ObIM@IRbCoxc$KALn1^KMWKYZOk^aaP~XLY4e9O2YgPO zKb?8n%-iN&_`XEBpP?QzoOW#%Y4Z(Acpdq+!`auICEL7&n!M*M-R4EqbDlOcQQC6I z?<3Ua3G@OcqQ@XVxdV-M2YO?~ta`tNs+YAd=e03fvk=Dgp2XM_k2<}Av59xk%YPN) z2rpn%>J4`c>RtSYzKrpy1&w_%%EqH3(=q<^7e<%<#>ms3=-=Ohp8Tg6k2)5kMYAy8 z_6J6#reTa@CdQ^_U<~Rej5XX!*JFf?N5}5QD8@Y)z2MQZ_c5Y272^Z9VGQCYNCSRn zrCAJ*swAXF5U_kC%QpdU&`yNaiVs>y-%nV{? z6xf=EVBB&$jCS*HC)XYddlz6hFa%@Xd%)~fve^g!uNM7^R(yF};g+y52MzmGmp04O zQs|W^(-J|XUhYI|Z`l3Slq+)J-&A`kUy7yBvxze!-MHtYud2%nA6w8f;(S}IH=xP3 zSkGY!6YK19@#o5nL=TheAOCA-9RP+L;6^$KBf5vd)fqS-f#F0)0L&1T1L^~ppCLza zB>Z|I=iWHCDvtjH*l31u+G8>2aH4DY441BL6<66Ds*Q3VfH{g&F@}5w<|fX;yu@K( z$N?@xhJXi(As4v$g&A@YW-rn*gs&+0TOs>&i!)?RmlvzS?h|0h(O`s^XB<2DoMuS- z+!Obny>fXB;app+=WV8l7i%z5Zs;q~D?8|FXI%n@T;c{YLoS1h8FEPi!-+IQR1VCL zOClI@85nX27;+gHa+zy1LpZI+5I)DHYg@%tHiv4X+?RwHav2zMNghKuzj|lLu~laX zuWd-fkSoBDEBIk!RuQYi0A|P)2@Im;7@~4uhFlTBkSoEEE5MK|!H_FmqZz_!J%;c( zE?wIyuCh5)8|A(t#E>h&kSp>S!ui!ZLl&z#L-={6VaRAOWHcvs8FCGd%#hIu45!gD zMCHH?86ClpYrv4vV8}IK$ThCf4B@mML--t*u5A@p*&M2ka*qx%%frf_+h&Y8G|D;gjW$1Gn_^z>qOu$QajXhHzSsA$(4) zVNkY;t88A`pp9~07h=d5Fyy*ChHy^x&X7KJ%a9wukQ?}6y9~JzM`p+k2@I#v3{g2S zLvDy*$cdSZx@%Lp=fcsMn;j|t@xUW)5 zUna~d#Sl*GF@*anrSxTj45|OV zOcX;V)-6N4z6>*@)V_>nNK{|OV~D>m!|dRnVhE@87{Yy(Qu;DMhSYywCW;|<)Gb5s zP8&2s{@sCjzQb^w?D~q2yK}U=fP1i7o`0On1Hfdge|{J%<{!rl<~#vEKe&%t(HSdC2L=eJ1MhDdscTeBtK(m8gbaQg}Y;7}AF6fnR&W8xKh`BEo4Fg^cKEERk)ecubYYQ@r&dO9W(yn%i9} z8>TeX7K3F^XJZ+ot7}S?As_S0JW>1x!rHv zI?aOpdG0=-$~0Gs^&t~8$w$mywiib!UT=3Y)`v{-NiD8Jp=h{-fn z%@8Gp;uzwMsfHQCX+4INDno({;W5>GhVZr2%cy%4L)uiGAsumN%40}B$dFPQ(jWf^ zEJLCgG9deZ;$J~jh#>=wb@7fEn^GAv*w_&7LT-w8Be%qRk@e4z5w7+z6!x%y9$dLMPWuh3; zzv>KG1ot_e44H^gZvNc?@TmJ__P8V8@iV*}%ir7OT*8{FHy*;H?l-z~yYb^8rH;A> zd%HKHFLOg)ZHHt2|A$pTTP`v>w9an(>B9)G~y}Mfn#kL%cE7 zungg}o(w5;m7d<3OhRj)Y8N$yetqd8>iCr1O^D;_?6klb=Y5X#T zbBUHAJTG$%WXLr|M$Af`mkG*{Yal~L=gAPhmU@>V-Rf9|OvHZ}|G1T58KM{xFF*J! z|NIQ$kZ6Xm3>gE4j48s9Qe{YxA!ERh>+%@FwXJuC{FQ1vq?A#2eqQNXnTc5E%fEks zD*?rj;%k{X&3}L%Q{`O34Dm+HcqQ|dV91pP8RCtY@k-@VRx$@i%&r7OuE=8u=T|Qb zi5fBc(5m*BDnGAu4DrT8cx9i~L*lhmKFdEn9-@5tdWbh-=B*D2>mi)h8xJW}Qw@%X z@cNK^J%nppFM3GSc*vYp9}nT@l}-=w7{cqvHA9l=A(|nqgYYk`sd^0Y)`x@{!f8E* zl&XgW8N%yB@)^R{Qtu2|x*-@+dS6B}L}?{)42kc{cnm334+%1){`)dfGNf|@Fr=Ts zZz*XRl1vZLy~97-KA^J}VwM$9}-HGag*(^N~NSMbv(q+5Y|Pa84^Dp;xVLDJtWAG`X3L8V#t?u%n;p|>4&|JOYQAyJtV5P>**o! zyLK;t+l|sg)~w@EcaI^Z_jWZyR67-?smAwqJ%*Ip$^;ow|GnKPhRjjd z3{gFigV0Kp+Lt*3X*#-Npm5;FId%4}F+0i)<-QE>L8Nwv;4;*&FAl2gE3w0j-uI!j zG1cG>GpaAcdk}d$%&7c^qO4tj;Q;SJRBt=XoRxwh=VK>auXhr3qp0q@U}n{61!$4B5#op;|KJdvjB4?xPtp&eZ?OE!Kb7&DiqC5@Qf=&nyY-%7qt^>Ld^(X|_ieV&uFQM@&&4a5_sGhQy|c&tvd6s0p@0MN z<%>fQ_i%jMK%Y7W-@DikUyL{fUtTy9Uv@Yj-?g|DUpu%8F|Glw2X4s49uL>eu8+Un za3{d;qLuc$3*UEm0AH(k)Of;p8mXTHUNl~D&FgM%Z@Konus;Ak23|2{7@xv?Ub6Yh z_}0KJ^ZlY_Nb6yU?#on0h6FX$d>JCGOyO3h=WLWAKEmfY+gU#t%#b-Nwx8+`fmWtx zwnR@4sZ}de&HYsQqI$y&cY4Eh9c;CqYVU^IPnGK*|MR~uBQm6Tk0!q_6Wz*`rm4KSi&xX-dwWKe^l*%(i^bpmfOxD{~J#l_AtJJL?Qt96A zFCs(San2S?`Z6+N#{c`3(nGdv8X5AN$Pkes{I7$b$Pm5@<;jqpIScnUq-kV`XsV@X zs@rRV(4eMa$REY9rY>ub-j&{~x#y>o7fp2Cz?)=i6<~-*7t}8!wFr=;7 z)@)~XFguz{X5+V*UCh6Xu4Xs0hq;p3+g#1;n?1wd-5CI^0}RTB5p4k1#;$KOb4zm@ zV9VUOU|G?=?aUp`q2{jU2y;(!AEe$NILJKIJObuuH@9P5`vll010#V$%`?oiVa_Yr zTxecms(Be6Q>8XCrW*ZZL=RE-raV1lyW9<4t!Y}B%T1a6XHBHV>d`Q*OphE-^Bpp# zT6`|XAEVOqGCSrT$xd}HLn=478|=&I5&zaUxBGn)eJfLqx(h@6k|NrzWymYSkeu|3 zb~Nwzc8Qj0(>&kX6&VsOG4ju78S8{|qsWkGV&u*=GebTS8It2cw4<3B(n(}UG%<2#nwcT*hz!Z`AllK) z4EbFc5>1TUnPz54due5IJcxEQGeZ^=hC~x1ccz&c@}0CYIUYninwcSg2t%TYkvr4O z40%}?lH)O*NVrxiiho zkZ*(`IUYninwcTr3PYlakvr4O46#KI$?+iC(aa2KBMgZqM(#{AGh`uQNR9{5j%H@a z>rJPJFb`_8^MCW~>;6~n3sp(JK(ub1?=T|_$$!tkP>6o_-;=D-`gj$Pkesu3i%SR;DLIRu+Z`Lt+`SR^2jWlzElO zV55)hDsEI7m2Q-~@3Bhfg@;{U{(Odr!R4^ae~!ZngPW z-2M=q-V5{BirpIiE7hrz9Xao2V+hg2Ecvg;J>=hHbx362j9B;3eYv0x@5 z9;YX%(0;08&2i><^Ct5alfTIPqMP;rn)?dzU?i z>__A9UowNHmc)>e?z26}Zl5bpeY3=~Td;39*Pz8(4yMI=t^!|mpFnrIwNJ{BDTwVd z#8@JCGgyZB_jNDCkh{%$AwxJxUz%dJ827_HwPG@4OXSkfaE`EWz@Y&E<-*G$&kb87HDkk zj5MvxXe)^fsaz`~dPsZ=!)MEHWx{&Mfi#}(f)1UAA-g8hLwZ;}Dx`-zlU9b5tcUnD z)fV@Es#BdPrdzQcMrg?Fj23(%Vhm>#clm z_kT2+?xuHA^>(*Q)Z5)JvtNaJyA$X+m)}YCkV^GsUO=hUk|9&fmqb&AIH*itMp~I# z=po@&=19o!=c!AoR_1_2txRD(q?A@h_jaW(QwxTK`!a{o)$|yBm`YQEH@HN&KL&CjXq8l-j^>r#4vVWr9Za1r2g?hUa_;%u# zREE4(0U7dIm1PLmvA!4*mLZ4Iq3&HzBSX%tnha5O;)?lYNU02YLo+1RcnFu?m)^j< z47;k;m*G0rH$x7lgZTzfn(>gm6KSfPdwweuuc@j!aY{c!q?M_KrkdZ%+=SH=zo%+t z_DIyqbg{b3rdH;y3doSRsw_jej`b}=4xvN%Up18sIjm|jMAeDw<(DC)wleQjfFbWx znIT-q`ew*Xb0+GTjv)tCjUlQ|!jR&vN;pROGk0N4wH$^V)hG<1(pC_uHD=7uzVuF& z`!bCqL(=qRE~;8zrmzgD-I!`KkB5ZEREch&XXxWpn(A4J7*aV+RTvU(Q}X@gw=($* zxt5-yHmOz+ot=mwvv0&Kr>ROSQ^q<~t%sy(WyU6A2)F0?<00{*?uA>K+Rg3yTbb&P znDO1Iuf9xpZkOnGdWpKFn%g}zk)~R>m5CoQH9M8WqZ5n-k0I> z`_g-6b>ElaI@Z_wGU2|=v2?8aDAK$yGcwT%BF?_l_hnR_IHmu68Id9RdPg{Xu48?-GRM#{+zh1B zLvBjM5YD~SRz}r{Q@&+=WKJ_b!hIy-<21M!AHiMBOharHpJQ?kDxC^1WOM$W@k-KV z>vNnR*CrTW)z6E=u5gTCn0H;OhU{k)PN#E@J92qwn49d^CVRblJS6EV#2L8P<-Yqr zqsp`YT*vwvF$?P?oPGY7YO&s~suiyf`2@RbeS-T)#K+IzVtfL3 zG4q+bFPBQEa!~11cp;nf_l#GPE?b}DlDRg)_^N(h9Cn3c1jD@R;=H(a3Wd|@oa2sM zUbD%N&&@B*&vEC7`1mDUjL+dNX1+vh6`x~r4l11rFJyE6p7Bc3W$SaCAJ--rU)9fx z!>(|QV3>DZs)p=m6i%mejyrOB&BhS_{vqlqCv9cE#=S1LmHE2Ltqj+(-dhC&_^ zdsVTOQMD>>72>xQV92*sW(e1@-WhV5`;VE1AxBq{A*xp8F+^mDlDTXr-(ezT_$Pi)3>}JSuRg@u>V958nl__}zk+;*`_f=k}%5|*wR%Vn-v@|P-##C(u5q2le z(!ZRyA#yUL42FDNWrj44rdn?d@np!4=FjGj_)jC^%OWB|}}Qj#bK$%aietr0?AQ zi+jy|nR3QM{;l$O2-mUR$3xC{|GCm=s?{71QMD>>JY-6iAsx$TW%vt3e1odB(;e5b z-dmYdT!y4+WorKgBDboEcgy4tzLh&3LRNK0%(#yA&XBX++n#1ThhGGH)^a;@-UI-k)mqb~*F_7bG(OI;%q>TaT&EW6ftRfb=bH`empq zEl=I42ldS6(hJAlz^Xu>Y#8r_NK}1>T;P^B6!tE_a9{|Hpo?I_``8ut$L(}?mP51@ zz8lA^|1wC4B@<6taF`no&0zIU&xHK z-puIIqmTZ#Aw-6h#}K~D#TimahCD&O_5+PFAN1eP5;=8PZl{2(?}rqIYmfy;3-tmz9Z)H-$e34lgq9sN2 zv3wa)h#_~@~zBgvU80|We9W3o!c#WB{Qeg>qDyhMirM-T^Yh9crv8;%-zoJT@KFe zzCc}5eZ}mMMDOhu?#mS0*Iiv#aGk0!q>$7IhLtZv3NfVKzjLR@RD~fyHWczzhavU; zHC16qAubdSs}4h6rWI4QGDjwAWeWFYlD9I8Kp$Sj)r|Sr0j>^i$RhB?8;+;qb3D~< zu}g((TZJoM@En&!q|&OgbsCjReqR3c%Iy#FQhP2go%2?ua4i|Z(sb-3M=mdWsp`U- zz+TK++*-`NXg)3u*Wzx-V(`Tqj;G>tJawzsrNXtX!j&(0j!Pm^X;s-ejmjlIFMoRF z_J??>Jr|eGd8<;mmW*I&I(CvHmzTX%bzx0m>sF?mF;ya2r`megm}!R8c1)E>y$wG|H2UeTKD>%+nqh@R5e57jVe}UmQ1zAY-pl2 zW_M@quFx7YX=MucaQ&g>_jU`nGQX#4W%fwa%5<^1l-bIN3@N@oRmUiQKh;8O-SNeW zR9|G?A(0HZAag;PGDI`vj%?2@>0XGgbVS$B|L*?-)XADm57NW*C_PS3($m>Id(qOC ze&=p%?p?FzaaOf5U94B>b$S)1)Zd%*HoZ&l(}(miO-JIHz^63JHD9=y)$+aF@tJX1 zGa=%Aw!7V({qBH0$#v=LUBn*mOEEqZZ^mWLaNc%q$xO3!dEw&(u!8f}_Dk;Et`}=M zQqD-waeoVOG(&2;)?M@vzoyz4Yu!Z;@oOA~-PO`V>U#x|$Ple%6hBr=4_P)ex3kr`LcQI}^ktS4 z8B!A&(yN9tMC&1yVMsS&NKF_rJ9@j7VaN)?5MhWO~wclqTM0NpScr;6zbO&4lBR6TbLnjQuTJv zPQ;LIR<|;HyTXvdY$z03Eru+ViXj&#Vo2rIm`N*Bh!erEYB6NZR1CQ;5ktx!F%uaQ zWI>#-S`2B7{ZwoF&Yjj&<-J|*8g@$cM%Dg_-l!_9ha`Waib!NgoYV-OtCkFDjGgX8 zh6LGA$X7l?3ccA)b^h+Zo|lm|X0va7$bzZXhwPkaeaP6%*fQ6L=vGE#NFk{a46Bw5 z>6S_lIW&FY|S( zQTP25tspAgmr1janaGeJ8w&Z#XGmd9wVwA6@p`+$kV2dYhEWjH%APG1dD1 z&Rtp=BKx{4$vMHduY2)5nS(M!<-qp2RhA*om9ek8mLZkt%kUSMWOs;h*}WM&2546! z?hc`4$m-eMB&w^2)c%{@wbVn3%aC3L_H}3Wq*=-Q0(N)xzV;AwdEe}Q(tgs;|7Le7 zW2#z))LIWwUv+=c{pY&P96$qU08FXBb!iX{rVVLh+LSg&;+eqg+hJy@+>S3%Z*~{n zgNPbp4^d|0I!g34rn+f=2ud$7~uUVV9*s%D6^GV$!-v*k0S@H=wmVrr7Xdue+Mt7r! z(bMQ<^fp#S>Me1$k1+&tVhHthbE~i3?l*3oX2Jd(_{a6}1-+9gy_K1mNz&Wp+*_=d zLnCIT%>IiEDK0y7jQWxxmnC}VuJ9L#(u{|s8B@Ir{0#268#Sit$q?Cbmq_op`$A5J ze2G1F`M+KL;`iI-eSJAzt#{njnrbO~GV|Q7pCK*q`$ye-@`d2%Z)H4&a4XYnBWA(A z%!@gOJXDgVsu;3+qVW*E{}VFLl(8qXZe?n{lDRp@Lkh2CPN%8XdL^^W%V=#Q`dGdm zQptIl^R3qt%*(uIzgOnGjFus~l_{k!Q~C;`+K-3SvM)0x;;ZgNGqOE6e=GA-u2+Y- zq!fLbQbx?43iV}5(Nr}md}vN>LImVK_q>dS}>%NeVM}&jhGeQaW~BhA{h^<1w$%19&%Ko z@sKN=D=IV|BI`qH!I0qkkRx;RGPgFsd%M!g)PfQ#f+J@0=SIvHOu>*^jk;?+L^Rb}Fr<>2 z>cNRL)upYa%hW?OLn_nCH21FE#n*=fTbZMBz1=i#L%f@4w@hxyWPOOYTPE)?v$eaw z%n;haZRTtFR%UUAR8ofQn@ENf{=Q6_G1bDz#aoqdjAHXLhZuU_ z+bB&nYF_546eDJDTJI!Sh4`WUVS!bMT{9!C<$ODqxc^KAtaj+S=~hPckT4(e{lyqk zTN$!XA{nxtwO*Neh#n7-w;}YXNA$7Scu1-5%eL(11fqGtcoXsSCWn*G1g zxv{|P|7XxtKQB>J6^4ZQkngV^L(+_g99?6E$lPu|Bf=r&Go+GpyH_Wg+b#dCOs$9f zlkE+cvRmeVNaNov^8xt%yJZ?#O|0;4na?Bki*Bi#S+>!}uwh(p3D%s(+{V1d{KkUD zLPk5Jo{6-x@B1>v`!em_nV7b)7Xp^f&CC2~rRmGe%ze*hoco1N&z_L|N|yT0&4kR; zW$czI42jlE!)JSN3m>&l8 zcFQb9hLp3fyB-gbnY-}lN4~$hw>}xNK#H~Q zwUQyam63Uwe9bf*Qog2I$$6P`63xpr+FEy!A)XBR&L>0KiVVq)n6++(6kn%WNf~l> zA{o-n>Q?4DRo%)Q#N@`WvpOWQk3g)Bv_vj$3tawhb{~~JC-WmI^pO2CNn}XjRwm6iyQQ}q z?z!aqi}hv3l=LM;e{c8WIvz38GDP%{d`5&riZP_PrW({kHp%HB>({X!q8U;f86w}g z<91ZHGMnZYl4d3I!iiQgb9v>=+<9+hiVO)$jC_BkWyoeZhHTUTGDH}X&xmkHr5Un$ zjv-srF++53S7z?QecOD0vEFWNXYO`MG;{Y@=COvFxf4CauZJ9+8xOfXl?+Mp1)|F7 zAu=8kZd3C8<+n1G91l4p(RfJVH>zYjWC->lirZo4BD#q38Bi>w7(+@M5Ao03O{?Sa z5Ya>O^^K^I`qD#oO{9km$_y$?582k*-rCk(ddbJ_;bLqHcQM25n~Kjd*{9NNk6b*X z!j+%%_l#E(yZq-kKMwJSaXP%PJGlDtkbV-h!clJ`%g*An(N6bVI zDb~j9Ma5c~;$y0n)I$zUq=$5|x)hlGPosy(ct|Z65*&5kDmNaoO{%_3l9@XebCsF> zm+_D&?ZXSNFFj;OFFv@*4jA(d=pj!4wX6#hn4npUPZGk2|f6=G?< zUH@k$KB?p0uGUm*!w?x$WxYVp?e3NvQ@uNt9wK9^d^h8N&Ai6!$V9D7;crx>X=S7@ zQwu#L*q7Nl*O%EYl?E^x0DE17Hk1tO6ljV42mO(a7Ke?26P3=xJj8bgjx#E`<@hDgJZ+Vpl? z_jt(oh_@krbTv}8gL@Ept;{(oTA5Pb{eQNMckZ;N+Pc0#G<(*E6rZ_^`T|j!weIIz z?X0$(6X^NlKzA?3F+>Bm$X;f<<(*?vSReVP796W5m+kj>NU%M3RPy_LDE zwXQM9SQo~VyUARGjSY>BjZKZsjV+C>k-FadG9%pfW+?1oz=5uhue5E7QTK_7^bpRy z#dR~yCquWp;;X zZ|vsIx|T8?l4fpqcefO-x0>7C6MmW7Jv7Crd#&eoWyjrGXsVUmE%W+ByJZ%hxsy@% z*)uOw+Nk?axi*H#Od&%`nYk-x=YKtOR~tQ~Ip=o2aJj&CP!Hj@R{i#_GD9jcw_6*A z$Xa)1gkI}DCN~c?yvhs-kEu#4GkaQ@sP!RVx-x|AC>hcS>qBZILz=Ud`O0Mo+l|Z+ zS%p}OzDy<8y8mCInY)9ngDdpze{E!l=plYR^dLPQBbo(L~&!}+a=lnh6mBcRpInIwm{9&BV?^kYrh|bvy z^R7$Pko}BcPC8wZBj=sHRCQrZVYkU%<=WXx?_9!FyWfT=XRsH!TF8)7L{qIpP1XGeg4IGJWlYuGTdXyW zskSy5a=OTnx|ShliVSIWGNcmYA!m!GTBfF2&*LGjt(7@fTA4a*WzH8FQl<>)*UD$^ zyuQqZqKAkcqUnI6Uh96b$dEE+NImNzmC#f*X=D{5uP;lp#_Y~Ie&xLvr&nvKOMa!;Eg7iPF4tcS)l6Ww?yWymMF13L<@_qKBl_ zLv9m2L}ZBjZxp46Oc90%Lt+_nv*;nhkXVLnCkzpW#4_YwX=Q{Vu?%@e7$OXbWyrD8 z$_PVZ8FIWZL>LmwkQ0OooGJ_vhQu;tbzz7wB$gp(3PXe;u?(3iy5Mf9xL!L^}%DidSV(0%$t;?|r`EuBN9OWL_b(K}js5ER}nq9}tF3!(y z+}ncuxs0oDhD%Uk973eha!QqI6k>46+Gf8t9IB0SbB=zaa@Qd`Z_bb18eVBSX3`_q zGFN?l8Lj>(>s0kB)Z)kLty3K(t&FrXuB?e#L3D#KL>LmwkXwZz!jM>o>>vyghQu=D z$rOE=QohJ6UsK(fHl@w+47LQeCjOS{5Zb|gCOf(IP}sWw!+{|*f-Zt7u9=2oR8Lb) z^ESjAHRLmwkgbFv!jM>o+$IbWhQu=DVHpn*hQu=DX<>*kB$gr12}6V- zu?(3gdWbM2mLX}@smk20drOO0>(U?^OdC?8&+Xpd0If`WLp_YnMV^WO^rA@9(O4oE z6rS5%BAaI~#L(ZUy4%#z`@+%U##qZ*#^`D+1EbF+JuYu_H+mR7jb27?V^yT)+5bL< zn*HzV=2l;`|KGTEng#oF*QKxX`V_6q#7y$F?k~GvQ`IxiBdy7qk=7L7m-Tc#!pC-4 zxGgK0!>d4|{q?mzWSs1l876cAe`{%FHccTzL=P!Ih`gZI!jN5rAvrxn_GB)!C-dY6 z&{Snl=4kyxpQ)Z6a-T3nWQf~BMy*2JTv{1nNGwB66ov>xVi~f9Fhm#<%a9d?A;OSY zhFmTT5r)JvWIthuFeH{CX9z=tA+Zd(Mi?RtiDigCBsBLDgjVMrl{Tq~`Nv@)@+%bp7HA;V{_|NpU2r3-56c0SLt)H`5L3anF|o*C~;F&@4+GtP~BOJM>+S9Wz8$GVAq0#gElnK{U9ITNzgBvyv(C=3yX#4_XnVTdp!mLU^TjJnrK z50UYZdKeE`$*$iKGu_*jcmH*tHu_k8Z#U}Q|MgO|GV<zXe-&ca24INp%apQCm3zDXb*e4!``4)!>g{?A;r&!!bX(`!%mFlz z2Edg13y+x1Li&2g&&y~%L}bY9lp+60DC2R_hom15c|~N1 z$dFhW@~SXI7!u2n*MuR$kXVMiE({Td#4_ZKI%bH>%jj8`;>YUE%N$qIh}lSY7sFb= zl__JY<@aT3JEnS`jH$YEIo@h5>qFj?Rwk#1$j<+TcK%QEM%4|nuY0`saL=#FSRW#KNTcZ?qeKsJ|NG;u)}n`e zRmXaW%*zy)9Xdw!-iG*A7?RUey?5^9+Yp|7{7x7m3~}iYwF>cjVTdp!mLb2?F+*h3 zJ=~{__E)dBdvXJey2}b8t&c_@tEZ{9m$mK%S{d0L!qZgKtaaZ*#zVXt80l&);~^ay zK!!*w6WzM#Gu3Nl7OP{1h^88?nTF3)PgA|6j^|~BAz>aw`>V&0mxLiXO;u+93(fv_ zYygJH*F&OZhdxuj42k-B$gmXSA@cQ*$PSF}L~B_eGQI(1h+ZEe--gfw42jw; z^ATZ)=pnA;i(<%4b<7aGK19n9*>P8oe&ipEjfd2Bt^0#Ars~r-qMWT|Otp)Qm`N)W z+sZuD0IiISy63kl;gDjjO#0m+mK7NyG9*@pyeKjx#}HYkTIlN`Uo^lPGZ|COAO8r4 zls_I4HKs~3rkax>GA~nTUgm)m45`(-|1!55mK4$c@?}WWh}oJAzz}I=qM4!3l+Tc; zR;F8KDR)m0HSV(%^wv0|_79nqX>W~;FfCS#o1eB{cE3^O#h#oQX-&yQjWT)R?U0L> zA;J*NgXm*@F{IXWyTXuYX6Q53V~Cxil@W$`vM$=SywTn0Ve~Y58NH2F4IPciC#d5w3e5=}Lq5#f;X84@K!=4${MA`A)hA>Us; zhRojp43SnQpAq4Z@);7<$~={#mAOD#nFJl0*3y?T8bF3fD-)I&`Tp`{NR%F82}7i} z8{6CcFU7o!jHxDQWT>^YGV?Zo3=vH=zf}o`l;6rkX{wV_XsQjfKEzle_s`w}S7%y= zy3+F0oqABu?3rFT_6Al33XQr?H3!f@8UR!3uh3hW^*k>V#gIo*$dG+XnEiiFX8#*} z=5DzZ3=usfi9Bg7ddT0Rsm8W4GPnC_3Oz(*NHfWhXGMmH3~>n`HDdO>Fhm#<%aE6Z zA;OSYhP*5c5r)Jv(=!k*SL z9`e4(kl0p6M%~klx*seuq_EfsgtitL@Q9Kh;T^t81l0)Bvw!;WfYes2JVq#iL7O|=3e zW&;{PQx%4UwT^s$`FcpynCiX4kX$Pxz1>2+-8&k9A<~!0ml@%Z@?}Vr9`dK?AtFOu zO*N{OnVLd|Tq*DE2FBM5ds@q=`!fw7L!^}nw<-Dl>a{ZeGyp@SFO$!Ra7g(KiE3re zOVP^IdS0fo47t{ddgo61GKKmw$21*7qTk9Ch7@ARRjrvJvI?;RW2$LZ5dE*lD~KxF z%7_dp-1myoL#}H886tg|uwI()FJDuQ8ZnzE1w-Vm%)j&VPOb^n-^e8<}Pm+<{r`^B9Orh`GZD!d< z8^ealWtGq|r!luNuQ9)|ps|qA4yk7%-KR9mHD9=S-0top{|(&V0Y3n<=qLIGCj2GL z;{Ni#mD%2{Oyrqqj1viVP7M z;x_eBddMu1A;OSYhI}pz5r)Jvq`R~-!jM>otRf5%hQu=DD`{nfA+ZekS{Nb>iDk$~ z!VqCdEJMB#h6qDq8SLmwkne;c!jM>od@l?UhQu=D2VsaXB$gpR2}6V-u?+cD z7$OXbWytTs5Mf9xL;etk2t#5S@~1FF7!u2nzl0&ekXVNND-02a#4_YRVTdp!mLZ0$ zAQFbeGQ<>y2t#5Sk`aaoLt+_X*D*udxHTs_D0}RjJtjFEfk+)=cI`eYn~DFu-9md1 zZDSozC(`jSrT$K)Q|UB1gU+IJ=sYCe64;uCxMl}8vz=UfDC}K;;lL0YK^MW~@BUCM zr2O3>)^y+2T@7}hz_OBm#a@V$YP<)L$dFn@4GDH|MI~j7MFeIm`iXK9guZJAf01Qd3hs0>A zJzXu4sFsY)v|J6oCmrC>52$K4ZT)V+8kL#;(q zU8wM-OV!=EeMI}Bz!o>guGYH7AY)w^RfpvEU}HmLV`Ece zb7MInDhhr%9~yAOP|eNya}IkANIc3%wbQZ+d<(wdSf zexLpOsY)v|J6oAFyJenH;~jTJhSY)~Q8FaWj=Qy%A;OT^$&l3>pp_AZ%ua^%6^7*c zGNP#x5;GpsGuz=VHXbt7tmLlU z^*kODB|~mcAwy0qVLT+fYj>LQ5RoCXQ-%y^02v|-nVk$-y8##?nrbZ=64lB)m115d zV<+EXCfv%T`HGpykXkS#N>gqFdITEuECs}ScE86q+yR))+c3=xLJGGu;Xh%h9U zA?pZ3gdwpESx^`v42fmPLc$PXNGwAZ5rzmuVj0pw7$OXbWyqq!5Mf9xLlzT;2t#5S z(oq;942fmP5)HtR)az8`ZHNkdEAx>QZ$q3fZ$r5Mb@5hfnYmlCHOmnB-fjhC$kl0N zNR4;8laorNL7kXVLvsbhvr%yt&;$Q~zWk4X-9 zA<{i`UoPqca80HM>0x@59;YYi>FgQ*yZ>WzdjLL&E$>+~v2slPYrZF-m9 zrw{34nvTRXflq0cYrb$ZyWK7C8@Rs%egJ0CPxK4SZzY>Q=r6L|T!YrF*<%~j? zqAQd=zJ)k#vTo5sW~UyKW~aMJC5(rZvsk|Dy7 zdSJ-nb==AbL+XJcOVlw#v>uXrUPi`LD=?;de~LG%HmULcA;OSaXsVUnVWzfUF%yQ& zPKKoW&RuQxAkuosKUr0}l<|=NkS1k?zd7@f>k-2?#ETR;KEQ&O7 z-^yGfo2P#~q|g_L*0Po{x*E&CB;ln$&hkcgqleMc=wP9ak8Gj4e^b8g|lFP z9=O&GQ!r#=Ci$4^%N6)mrpS=lIi`Ak3WkUb@ym)RcWaR$BT_J=)-ps|nOew@s8*(L z129BdnOZO;s<(S(3Wn6Wl@W%_PKJyYhUDIc=#x_^c*Lxq>+4UI{LbChG{iMKxOwm7 z+CyRQ0t^R+&t#EoF_&nwD&cTe#1Pk|DP=07FC%3G*S} zUp_;k^pL|-Fr?O+sO}nt!0R`GWjwi98$gviE3rW3qzzYnmyM&+(Ubl#jFyEVMhbj+kju4V3G^+mV(<4x8r?nU1O z`!;}&w;-&TxyfR~@o$H%ez(J|!VB5A;M{F)8Wo#UtF)YlN^u)vaLL+czcw7IjdF92 zexq{NAv$l)kKGzxX*y=oBiAx_vHGH0{c#fRA7A7o*vt?<7GnpWpX3^jf2W(C^SBdk z6&|)Z9;dkt;F$blmrBcds1(eAQWz2}N9C@=bl#jFyEVMhbj+kju4S(J>LQxJo&p)n zmpKJCw=#Sz*23`lDX!u8cf091kGtVk;bEKOahg#8$K)ToR9enMrQlYilvXBKj>=t! z>AX2Vc58U0>6l56T+3Yb)kQRceXn)Dd(rp8o(k~seuNb>_gZW?{zI_U?;*HVc-ZE6 z_v74DH;szTsa0CeL#3FC7+kWp*{=KlXV zHpjaK=WcV;sMwrZrR6+SicSDKEQ^vJc$U97(7R)1XFTFkv@ zJ}wRygJ?1M;tj`A@l|@Z8H*zq&v2~5VJfx1Y@LeB<=U|AH-5iz`$Ke0FU-rE^HQ}_ zHT2@>RLPE<&b>YAvYN!+%G$!cXg+QQ7lUXE_~H%6Qt?%Kwi#O?7te64!eJ`4zigd~ z%jMdz?KghEa{EJcOfSsKoAXk&Q#JJB=v2v$oX)*H>av=|?qDtAUNj#&z{Mb11ipB~ zu~dAOo^3`4cB+P69GxoJk<+=i zM_pEv*xOs%x);sI?crh&Z3|z#;aDoZO3yZ9d*tF7j#W5JrS_MtQ*pUm8@Bz%?^kYr zh>q!nd3keQs&=Y|UL2h&*^$$^w?|!8li2%Od%G9CFKi8>y%8F3`1YvyDm~kbeJ#IH zH_;!$X>_Pc!D;sPrQ&iq#2>~v`2EW557A|KVfwn%b*Qpb4ZS!zRk98>DVq-YWTcAFRv68$|b35 zQa0z#;42QMOX}lvC08+AQOIH_v7lgX7-=T4+!`+X`M+r>%^pZAyLm7+pbZl2992G=u~E~$^xm0ZPel^e|e zhV>RM`7PLd^t*~(h)Tou8`+!2F3!(yR4%Fv&Yeid_WM*iwu_Y-KJPEfD@BE>+&r6I z46bJ|T~Z&XE4hl{DmR?}9qT>!qThkdE`2O^AskOZrRVr6{X3RtR4!gzj^mxxWxt0y zu-nV2Sn2w_KQFHo70P+4Iw_lTS2YaQA(%&;kJBNOruuzgeS}N?2sR)6u41=ArD6Mn z>`h}A=jS&n7gYx5PNZY|eJUN>#YzpI_m}0BqC!<}p3N=>*E5(dsgKi@T*Yve8_Yk= znt@B60h^D0SFsCGY1p2Yy=m;?{QO4cqRQahiF9ngPo-nKSgGOj{<6GMRH(|$v)RSq zdIr-a^>MnAs~E0wgZV$PKEow{2Ahw5SFsCGY1sZGd(+s(`T32?MU}z16Y1D~pGwDe zu~Nh5{bhNjs8E%gXS0jJ^$ey<>f>}JS20}W2J?SzeThr{5;h zOa2x%AN{Uk7oyUz{dM-Hv5WKb8gFkMm~rz^RN;VL(nUq;N5j+Sw} zW*jjSJtV0P!tq285j`YcBN07B^pJRsq#5-P8BaZL4h>rW`>f5PUY-&IWI zR%zJ&J*(cci}Ujvm5VBab0^ZV{XUhB?P8^d&-=^rN>QOIH_v7lgX7-=T4+!`+X`M+r>%^pZAyLm7+pb zZl2992G=u~E~$^xm0ZPel^e`23`xokj@OI~p^TX!GGX)4?$6XZi&k0rsLH5T+(B>G*yZU&E}>{AlDBc*E5(dsgKi%4B`KkB+tkD-&1m2 zN$;@yRyld{Us{bw&$;@4nds|O&WQ|$U^<^Bd z8P}M}8&yf)rs889Ij>q`{&5ag?ly^Y(Dy3#V$&vVS5fYC8wPO#tYYZ@)-|0 zwhB=`m5%LVrH0RkOY^Qq*Grc`#4ZM(38qWx<8;l&ka;rm;hxP0n~#21_*sPgDh=E7 zxG6d9JTP9k&Xdo0(6LpB@~L!ehf68u50~a$jjoq2frwoUJ`+rr)W_+BA>MP1e>&b> z)z4KJ5`V`U^Xvkdw)n4V3!9IASF!6+Y1m%CP048&fbqh0o_xlGj;%tJPo-nKSgGOj z;nKXT(e=_L5V4EFXM*XH`Z!&)$&iIJi{PFu0-KM1SNK_k{VEOH3%e;f?ZPl#xXzQ$ zc+jy`i1Mj)Y==uJ<`0+VU5&1nE`f+$3_cS~m(<7UnvEeHGK=A!Ee4y9epj()qtdY5 z!A;3&JHUA1I!`|1LC01h%BRw?U98mb`EY68)#!TZ60m2yp^ceR_6L#~EVd1+haqRQYH zL^`(Lr_!-qtkm#%U5+}cJaw4L&9m9X;Ckvfr5!n4vuUc+@HVP{2gDifE`j0aP{^-h zvaNO-RC^3^etx4;dbpc04 z2OV35D4$BlcCk{!=fkCWSEK8tOCVwwgUxBt*~R(!jXuLU zE9lsMpGwDeu~Nh5{blJAbePJ`v)RSqdg?f(9XVaIF+^lYQu)F0no)+Vm{|$`+bhB5 zqu&+&Zw>oZ8n#z-Q*zoBVZ3miC!g`4W2+G5Q|Z_amr~3hF3r0dT`ye%5xW?CCYUa% zkJB}q4EY*+K>GLU{?^^ky4Z8!^C~9WYF})%8#d?XH!3G@KW*8yJITGRuQ)4b|8K*` zVzpd3!-i<(%qrO&h*p7Hg@^6cvT-?%&Y4pypGwEJK9}?uEQf2uP)6s?d9hoAYpmmx zcI0&B%8;K+?$IZ`7uAZldiJS=kHyMc1L-);>Y4u89EjMpdd6>5E-Idi-#;@TvyPi$ z^~@UCwEc19e7u~Bm9EeG%koN5q5YA+s*|!gcU40!Esf!HvlT$lD2ej;AzRv9*4 zIzGcWsPM4O;R>o0j?E#;w++hX^1X9>KFKdwjxI%o>byB0c584+!E{M|oUYOgNph95 zH_pA;OAa54)#_f04bi>UL%Eyw5Zo#}Y;!yXFE)oLpGwDeu~Nh5gXQQ_RH)9I)395E zYaC3M)W_*cu41^#4d>q$|6TkGA600yy={gKr`tZ81JU+ytMIU`^5^(oY|p3Cu^nAr z;WNQuM!VsMRv>5}?5UGp(y$Lv!H9}7PbFLcKY8=@UEJ7;qs+8J&Y9=182 zf)|@ZluxB&+si+hD_D*$MTP3TIVW~&aE*iMlKMDZ^D$&t_Nj!A#h%cx3>%_hnO$?w zbyv7mc-ZE63SMjuQ9hN9?P8^d&j-uVrKnJyH>Y8@2G=;4E~$^xH6KHE%RZIxG5jpT z{@pTch<3~Do_nsl!>z)@Hpf%&VsnV{sdQ|IODX0LmZM8ip*nBQgWVcj<6yd^K2Fzs z4EcZdt_IGI>Fl4GJ9BsQ#rh6OB#0!Mv{Y#+ElOLhsHiCFW36P9EJ=3lZb(SmRY7P} z5G;ZqGzfwq2!hbO1SwHbq0y!q8bq6tYSjDu=Q+=unP)e-yL;32_1>BJoq0a~&-47x zoOABnxp(*8JHpP2mWAeKM1b9x5y7ZwRs+|=r^;~HC)Bw9Nrv{_rc^yqU`}GlV+*~v~Y;hirnx&AP<`EY5 zN^R7DE)6|S=e5;BUg@onv36F*26d4pcA>c$8(=p^@(_;-?AJ&5xiJf(;bE(exKYPp zz}vE=d4z?%QX6%kO9N9nudNpHN^gbK)7W9tlD!)~)(6;;*Jvvk(?~k|^^xl%-bFi$ z^KjfSU+YMkM_Aa))!Y#p8hV`0YpaF4(pw>C*jdrC^%-&AGXm_!oDqz-6^t2AI{Wnz zaok)qJZy0uj;?;&v^0;fuvcoM5_DFn1>#Bp=c@UX>sIBJ$ccA7_6*ekVB1G+TyIGxv43wfotLVj*%Max2S^YZ|^ zF+UF`#q&CeboT2b;<&kJc-Z1R91F2(Jk294?3LQcfi4X_PUp4NLSE^ukn`=VXsPEy z+vf+^jX6J<9M9`y(%G+%h~ws>;bDvOaMUb?>@<(CuvcoM26SoYaXPQ97V=7Og(*=ZhOVXxFi4d~L)<8)qI zE##Hn3R!4RS<6CmvoMFMnoo?91$x{jy+Mq={YVL@o=k8zXs$M+NrlBmCT$1<~-Z zHI2AY$6>(RvZZ;1g}qW6b)ZWFQ#!A$7V=7Og(CpcG$FJ?}m@p1lW-` z-BzG)_#vJB`pESW@1mW>c{pyEuXQBNBP{IYYVHUP4Lwfhwbepi>8+6K?W}0o`iwa5 z^#OMJ&Zl68tzgUy(%G+%h~ws>;bDvOaCG(Crlom=g}qW6m7q&QkJEW=wUAeOE953S zD_Rzsp_>Bi#@rOljOTSG>Fn1>#Bp=c@UX>sI2K~nc$!C8*ekV>16>+=oX%^jg}l;R zA-CFD(NfQawr>rv8*^(gE1uU`q_bZi5y#C%!^0Nm;iy>(*=ZhOVXxFi4d~L)<8)qI zE##Hn3c20Rik5}u=Jo))F}DY^<9VG;I{Wnzaok)qJZy0uj)hn?p5_r2_DXH!K$nId zr}NrsA+PjS$X#|;wA6EFn1>#Bp=c@UX>sIBJ$ccA7_6*ekVB z1G+TyIGxv43wfotLhiM*qGh4Exi`RW%)PAbdD$Sb`S@_?NcE%jVz8}Z!c#q$d7fgrhamW!p&O-4BUh~wOOB%!-X znr%aNlFPBM7d63$TpXo=DV^6=3wfotLLQDr&=@T@G$-OmJh%C_f-%q@4w5@ZyI8~k zhaZx2=aYo)DhW#=ndEXT>_tuP6CY8Xd&qrA=eET{Ug@on$D-My&pj(yXl{Tb=doac z%`^tuV?lD~Djdi03lcbI;0P~{ZDCEuB)J?5d%2oBLPG;nII0u_%Fa29EIJxN*LgXp+mZu$Qa3BQ!KH zrSsZqA+PjS$g_4sWXW+PmEF3w$ z?MKJN^|(jux+lAV>8f~Aorc3Wl%u`dO`wH1tk_EM1CxeCVt ztMXhlylnlJYM_q8+QN>QB$s1huhd2ybZKBp=e5;B-lkk3oi}AMoaVn}TW051%eIgq zXIbz&TfrD;%Yx+2RX7eYp_6Hg}Y+AB+<45HDAy{cE(043G_8$Vbb3Vs0 zhl_@nt(8;*bsV;FUWYm+Xv`o!sf}pp(!eA!?UTqWy%q8{jU6^E*}L&0a^4PB*$T!$ zdpk((oX>I0;iBPXYZcW%9fxh4*P%%+$HHEzjcDl7z?9BwtA)JMTOse-`PI_R!iF{E zyc?{x73e$LBm3RJ?VQVTh~*gP<=oX&19e=b4RfXu#{`WT#3!|p0bLrHB&K~5d8M~P z-lws{rX_nfenigu!5Uk^7-;VY$({2#jyYU3ylkzZ8mQy2jq^G*$>mtsE42{~T^g9u zd2O|jS9&YtuXcX5bhEHw4LN@e*4hfjK>KTu+&P!yfN_lTa_(BHfjX|zhB?znlFPBM zS85{zx->AQ^V(`5uk=>PM|OU-OwUB?7IHoc*4YZiK>H|2?wrSQz&OTvId>h^Kpj`9 zb-tF~B$s1huhd2sbZKBp=e5;BUg@onPwf0^*>W~odXV!;u-;ZM2HGb+RmD-4fE)7iSytZ1%E43BUnMMu` zTj#KAtaT0x?VQ7Tu7YR|Wleax5(ifnKM?Q&aVSTz?GVv`GM45MmZX;}!Lyk?yCm9v zy|h-ywz&!#H8gA$;g+#h5f<7xhx1$o(HzQ}@N7vOTv_};zzf8o9L2UnL<7oLnnzfY zUakbs*7od@X#4e2TOr%gsG(uoF5EuWwhIgGoWpspf@lt9O?b8^4z4VIAm9b!P>y2T zA)*0gEX^Y6AD8|JNj55AR$nN3pVgK;U;a9@F zY5$r%_7C?B_lx%3!*5Uo_T6Ydz#h{)2T>jAiy#Mw-wwY^{!O)Qk_YCd>P)-pjOb9; zzei(-#@k_5`(9XhmmI7Rseml!PipXEg&Y%8 zY=y`eb!>$kPIH6C+YwefJS@CR4pxX%K$i0-HTbbYjtGyiwHX~4Wq=omLph3VhlmE0 zu_OndiS9wRLS&3OwnC1ixk1BrWO!7p9T^tdIfwIH1<@SJn(!P&99&uaK)?&cp&Z4w zLqr40Sei#zl3uQq^c1Ie=u%xA<$pE%Xqp=|Y)6O3#M;qep`CL$&s7l3p{xndF~q@@ z#Sa9$Kpe_ZY&%3Wpp2z?geB?aN=Z*~da13D<7jTsupJj3A8W^jg?7&2JXb+9hq5L- z#}fxv7C#X10&yruvF#AifHIcm5tgKvD7}+puAvh~!*)%sIo7Vp71}w6^IQed z9Lk#TG!q9`7C#X10&yruvF#AifHIcm5tgKvD2`>UEAlzYK9L^1XxL5+D`V}% zu+YvqoaZWt=1|szr;<3hviO037l=bSifxC829&WhkFX@YTq)@(PA|0;BJX5j)QW${ z%)Jw)XF+(fcg*z2{9m`L=+TRYttuQAYgJ*PopU(PRS?agtO?IB;^4~S2LfIo4&^Ae z9U>Y~#?m~(lJs(=q^CH&)UF}bG&gA2s>6}7Rvi}FIfwIH1<@SJn(&My4z4VIAm9b! zP>y2TA)*0gEX^Y&Bd8#HWV!rE9H6BgPzhx1$o(HzQ}@YE6q zR~A1I@B(otN3rb?(SS0R<`I^pmn$Vb#p$KCLQbc-LBn=>*br-{hlO^|;XGGCG>5V# zJPpLbmBkMPyg(evQEWRzG@y*7d4whD&YXxM%d{w&sh z5*FGyhx1$o(HzQ}@cfK8xU%?xfES2EIf`wEhz69gG>@<(y<92jDNZl76><*E4H~v{ z!gFKooUqW&Ih^Mzh~`k%gy&r1;L73$0$v~v*lY zH)z<-3onSZ^TI+q=Ww2@Aeuv26P^o*gDZ<42zY@wl%v>oh-g3=OY;az(#w^Sp5pXU zTOk+H+@N8*F#JWVT^JVHIfwIH1<@SJn(+LBIJmO-fq)l?Lph3VhlmE0u{4jcB)wcI z=_yVxwH0zP%?%p1i^EG|?c%V|&N-atDv0J#)`aI0;^4~S2LfIo4&^Ae9U>Y~#?m~( zlJs(=q^CH&)KWh~7jEJ-g{N_vXZOKpW*O>=|BTW_mfUGTFIJHNl zKM?Q&aVSTz?GVv`GM45MmZX;}B|XLIrM5!uq`5)Ec4v5Ztlb$F+Bt{wTm{h_%9`-p zO&nZV{6N49#GxF;wnIb%%2=95Sdw0@l=Kv*m)Z)shvo(i+dbiZv35^bXy+Wxa}`8$ zC~LxVA8~MH@dE)b5QlOU+YS*8C}U|JVM%(qQqohLUTQ1kewrIJ-g;Z@{&wD}N`?DF z(WIhC_;;m-*D{EbxPg)OAR(LTwTx62=Il;YMxtYPst?iJpz$Uv!rF$vgD6`evKm^GpN|!S*D|~Y_#E6f z%vyVr_tBQ`Ld@<%Hnx5`wnCn!S1)MTo(_KWh~7jEJ-g{N_vXZOYQxT|Dm}-l?upm z{-g%~uGDa+%C#9C7-fK$?^K_&D9h*Q8&=!YA2#&*O2FFZqTqjAHEoC z&xeI}&fz>)K{SW5COj_^2Uiw95by$VC`YmF5Yd1#mgW(bq?aotJ;mvzb`80K<_3*7 zc~5oI|Bu;kh(oR++5ecyc>iZ*$F3nS)6Ah^dpUe1)?N+^?VQ7Tu7YR|WleZqAr7uA zejwll;!uua+aaO>Wh~7jEJ-g{N_vXZOYIu+D$NZVwpYW~V(rzi(9SuW=PHQiP}YR! zHR9mP;s*j=AP(gywjClGP{z_c!jklIrKG1gz0_97>ohlLyvei7>+Sr@om6-|6iq6M zgkPRz5G8Q~Bkc`BH`QP6q_Qw)cd9ZH9eb8}m>v>n*nS_*x7x$G!aL{Sh*-9q=1@VH zXMS$Jtu21!f)|KGIf`wEhz69gG*9wu1mQ|z6))|n7GLOdQsFIn^rG=5*O0f`c@2>Y zZ-t^sMUn8!H3U%-H!#xvxCyTzQdyX@Ylw_Q@z;<)W!DfH%#FxWyN3Lk9=&M1$u;E9 z?YxFag+GU)Nkx(H%QXa15;ri?-r0oL5UDK8*)>E)qGQ*PztG&E@%En8{t_16B?s@R zN(E#&e^P^ARtVQ-bYPSLUcOU(&oZ-ouLJL?>K3ByL~`Eu^-o?o_3+FlW~g8HtWv zL&|7w(0G#-Qr6BDA{EN=qDe)O@XHE8l*A1Tp>?9RsaA+o7Upb)$VhZ-h2UA+J#)(n z$?ijBR9ni=@3TzDR!A3m^rG=5E2K+1uOU*QOI|doC=!0Th9FAf28PhOQQK745UDK8 z*)>E)qGKy$bDA48-eiSr-p&;w6*kX{CKW}(FDnF55;rh}wgt6KwL+w_FlQ@7MxtXY zWGk8*G~Q%|Y}L*cA{DmEizXFC!Y?ZXQ4%*WgtiT}O|?R#vM^^WL`I@xD+JeMcU_hh zl0D1FsJ4`)b|11Gtq?S9+vT^9we9kScFy5ES3xv~vL-y+69-ooKM?Q&aVSTz?GVv` zGM45MmZX;}B|XLIpWhYIvGeXhbA!g4Ttj-a^I1kJ^vH`Q6-B}?&oYRTxPc+GUeq?# zvy4<0=ImKUMxtZSGQDYT(6IH+?-Xmj^M!WK;XGGCG>5V#JUbBwR~A1I@B(otN3rb? z(SS0R<`I^pmn$Vb#p$JX4cUd}1`XRT`CVgemwcg}b2!gc5Y3^i3D2&?!Ii}i1iU~T z%28}PL^Pm`rFn!U>E%jEPjPywt&sjSH)z=U=XZ;>{`o>X=Ww2@Aeuv26Q13OgDZ<4 z2zY@wl%v>oh-g3=OY;az(#w^Sp5pXUTOk8!ZqTp|%zrV~2IdRxoWpspf@lt9O?bXY z99&uaK)?&cp&Z4wLqr40Sei#zl3uQq^c1I;+6vj7<^~Ph?)fjr+V1&6JLhnot00;~ zSreWw69-ooKM?Q&aVSTz?GVv`GM45MmZX;}B|XLIrM5!8LUV(L?JN1cW9=*XLObVh zo~t05Ls=7^y@`V>iysJhfjE?-*mj6$Kp9K(2usq-m6D#~^io?PU!%D}<85E7eJx*j zmmK`ByHr4y^Cva<@oIP9{Jyp}qXVN{@B(otN3rb?(SS0RY zH)z=Q%YP%*_RAOAIfwIH1<@SJn(%ysIJmO-fq)l?Lph3VhlmE0u{4jcB)wcI=_yVx zwQI-$G&gA24#*!AYX{^D?VQ7Tu7YR|WleYvA`Y%Bejwll;!uua+aaO>Wh~7jEJ-g{ zN_vXZOKpW5Oml;V?cn^kW9{I4p`CL$&s7l3p{xndw~2!*iysJhfjE?-*mj6$Kp9K( z2usq-m6D#~^io?P-=(=h!}i_$_hRk4`9eGAaGt9mnnPI=p6?L{R~A1I@B(otN3rb? z(SS0R<`I^pmn$Vb#p$KCLJp(3LBn=f{_t2kEMI8n9L{qUL~|%>!gDxraAolW0WT1T zaunMR5e+C~X&zxodbv{4Q=DFEE93~88#HW3)K{SW5COk(H2Uiw9 z5by$VC`YmF5Yd1#mgW(bq?aotJ;mvzwnC1gxk1BrRQ~8#J1Spj=N!&+6-09=Yr=Ch zad2hv0|74(hjJ9#4iOC~V`&~?NqV_b(o>vXYAfUznj181$K;QTwPW&ycFy5ES3xv~ zvL-yo5eHWmKM?Q&aVSTz?GVv`GM45MmZX;}B|XLIrM5zjr@2AHc6|QCSUWynXy+Wx za}`8$C~LxVB5`nK@dE)b5QlOU+YS*8C}U|JVM%(qQqohLUTQ0(lI8{tTV=i~)++Ob zcFy5ES3xv~vL-xL#KD!t4+OkG9LiB_J47^~jHP*mCF$i#Nl$TlsjZM)K{SW5COoGT2Uiw95by$VC`YmF5Yd1#mgW(bq?aot zJ;mvzwn7?cZqTqbE%jEPjPywt&p>6ZqTruo&QO!ot-bVa}MXZ3ZglbHR1UQad2hv0|74(hjJ9# z4iOC~V`&~?NqV_b(o>vXYAfVtG&gA2ewIHc)_#^Rv~v#UxeB5=lr`Zwhd8*h_~LObVho~t05Ls=7^^N52h ziysJhfjE?-*mj6$Kp9K(2usq-m6D#~^io?P7tq|G@g|?jyr7+*%9IKh74nOAt`Mp4i@a!3Q6&7bLJ%c! z14C#RQ`=N4L@En&wnAhiI<`VCp}9fhO;*Sy?OY*J;gY;)Qc)!QvO*9gaRWnWmr~nQ zD?};_bGAZcBs#W2uBCJ|-eiSbYj!I5jjED^PeVwBYZJ8`4h<2qLJ%bx1EbvQ2;EdG zL@I+7Ted=Y9E|DM3b~vfy=c723c0+U*AS_2d0sTBC=z~IA&8Q=fg!XjsBNkhB9(zxRJb8gOKP%2gsc!mNyfks+KtpU)e4cy%$q&S$T)QLS>{@L^rG>0 zozEFPc;oc_(qm3PF^_4Gf{(O>I-H5UDK8*$R=7=-3Lmhvo(i+dcXFV(p%Mp`CL$ z&s7l3p{xndeZ;|)#Sa9$Kpe_ZY&%3Wpp2z?geB?aN=Z*~da35V#JP#5FR~A1I@B(otN3rb?(SS0R<`I^pmn$Vb#p$KCLejs3{@?jk z_lNS?ueukvYX6`5R_(tt`*C{oqG5YH|3s`ko-ed>4(GWF zqB)c`;dz2MxU%?xfES2EIf`wEhz69gG>@<(y<92jDNZl7YsgbHH)y;)ZMCQJiwf?N zW6YwwRCp@Z9LgFZ7EyaT|1`z8IL>{}B0!m$CEGZrHK6QC^MFD6A_!L!t9V&zE97|^ zQ#9UQwA%Ce!n@=kUn)4wp{xndi}@FAZSe!a3IXC!j$+#(q5)+r%_A&HFIP%>iqlJN zh5Ux*2939ut@fLI;azf&FBP2TP}YR!<^0RGw)lZyg#d9VN3rb?(SS0R<`I^pmn$Vb z#p$KCLTczcPSJRK#jcQ=a9O_4E;+_5%S(ltP&9|K2LCdu{!0E8igt0FixmO{JM*$+ z+aaO>Wh~7D2HgX$l#VOLDzz1I4$T}IZ?D@Ga!&YWzR)f?#=M!A3g?8PIg~Z{-=ylV z=U=C27st6+AwaM*FH5!^A{tP}(mY_$J>W{|xMHkQTOn06b7;K1Wmia5xH4a8mmFhO z=A}YaD4IiAgMTGee=GkMMY}l8#R>s}oq1WZ?GVv`GM456gYE%WO2-vrmD&pVGmR-4 zZ|_*`&-uc;Wh~7jEJ-g{N_vXZOKpYx zh2{p0xA&~}mwe$}a*!_-oaRv0gy+5dd$zXtfjmbIh(kGwZHI^kl(96Auq3@)Dd{Or zFSQkN4V^F=Z_QS_CRccu9OO#{r#X~0;c3n_+uGs>f)xV9p&Z4wLqr40Sei#zl3uQq z^c1JZgH>Fa&rS9RG&g9xeQ320@@ot3l4H!;yj1ug)*Q+jBGyv-F#jRNxH!(m3IT$h znOU;!5Yd1#mgWJ2^hFS^Bv$dV)K=L_$WgM6vrG>5V#Jpai5!`2o* z5Uda&4&^Ae9U>Y~#?m~(lJs(=q^CH&)KQPX`_yXx%opAz2l-OLX%1ygcs|X4 zYHN!h2v!IXhjJ9#4iOC~V`&~?NqV_b(o>vXYAfU@nj19UKC{|UdB3dCE;+{dWl{m( zw(R^#4Sv7On9uT`rM{DybFo5zU}s*IY&%3Wpo}Fsm_he|E2ZO#u}W=)gfwetyp>rk zEGxWA4)Udf(;Uj0@RZTUwZ#tvD+CBOMp?2Qxg$h0pxA_0*n~xFNUoIh6sMQk3h6}i zgT`AIt92?Xyh{%9rGnEO%9`+WDeGcuiysJ92oQ&I6x$9F4Jc!29$`s(xl+?0zqO8y^ImT3!Nd&?cz8WD+CC3=4HvYLqr40 zSdxPobPu>vI<6S2)K|c7Wh~7D2HgX$l#VOLDzz1|3ymonZ@XG; zm$JgU#GxF;wnIb%%2=95Sdw0@l=Kv*m)Z*HPxFJu z+iq6tUpA=VE;+^wDw7KRW6hzgAz~1<-O6^O7#GL6SRp{LGc!xJ9U>Y~#?m}skiH1Q zmBcDumf8wwpt(WgZJgB#eIGM$$w9tYPID-0!o%OB?BZC%3X$(yh6mrc%t!h5WzLfr z@d~f735(c}*>^H0N2C4v1iC*!<860)4Ve(`SypJ59Aow@lL`|;(HzPe{CiUM-OF~T zXcx!1SRp{LGcQZF9U>Y~#?m}s&^_Qv>9}I7QoDwnNHd4V+unACoEYv?R%n+TWA-VN z3MYo5Ig~Z{_o3>0m+ei_E{=1tLV#drUY2Y-L^Pm`rFp=hd%%^_am84rwnFx!F-7C; z8&=z|tne;5$d?LEb0}-V^Nq4^*xKR;f)xV9p&Z4wLqr40Sei#zl3uQq^c1I;+6p;< z<_C?pgRFKyS>auBkS`US=1|sz=b*BKY;Exa!3qK5P>y2TA)*0gEX^Yh9ZbfM91{mTWsj zG@y*7dBC81z?IT*#aN}bLSCksL*wmxc7?ng4lXOSOO7#v%cR1~p=b_e4gSGY{d;BK zqi7e$xmY1Uurn`9wjClGP{z_cV9-6_O6j;_tWsMcN6?s}@ph!ujwmaejr#OKpe_ZY&%3Wpp2z?geB?aN=Z*~da13DqiBB6cstr^N0k-cB?tLZ z!D$XxPPDbf4+JX&h(kGwZHI^kl(96Auq3@)Dd{OrFSQjSqvcse z&fgOU`JsyKnQNj*2X7v!&VC}g?{)SPWk==s5LH^C@F$i1Jz727hbY{PQub>q9^U|- zj918_!rdq z{ybWeQBagQN!h<=I}Z2<*LSv>|LxTHY@_n$DqQout@yvB@^4lBzcN+N^IlQszaOv1 zxWC^?<+qIAHigq)JJovD>)~is|4S-f+q)}#4`oMY;_`yvc8D!ZHFpRM?|QTS%czEj2bR^zj4fCSh7nQX_7 z*Hpc?)ckZ(QUOm+K3|08qY)4f<{Ue2I`xu2EtL&{*|BqAn zP72>z*~hE++Z5hcozFMb`5&n4gOvR;*NePRcjS7pePV=1ogmHgeT&e2FKI3P)W25! zbRXHOpYCT{_0xH^>Zkiz_^DxY{h|BXR{eB8OMbbYNPNqF(|s-Sxon~?-QTwAr~BMi z{dB+Es-NzATlLd?wdy}`1AdynR^w?NTlLfZbgO>4uWr>(_t)@K!zSYu-DkHNPxsrc z#?yUwtA4s4Xw^^m-L2N&kpHF)#MAwGt99r;y;VQmuea)_`}S7-bpPI}pYG#Z_0#=) ztA4t#Z`Dut+wfCMk3Zelx9;D-S-`}d=_J_NZSPyiciue|OdOgr; z9eQ2RYCOF@Abme}<8foOh|CukYg5*H!WB>#F$mbyfWOx+;EsT@^pQuh42g_4QEV_4QEv z`g$mSdY##7eSJNYczrz-zrG%dUtjkM`StZs;`Q}V{Q7z*etkU@zrG%dUtbT!udj#V z*VjYw>+7NT_4P;m^u7nipIUl->g%M$)9dV3{Q5d6{tfk$-Y;o&o#^YN z)X~>T@u#no^eup?>!6qW6^{cChl%ZYd=&1dZTg*$DAHY5_3PM=)qUpQl>L^n|ElgI zFG%&5zGFvyJ z_3_E7=pk=C?{IvK@1y#Eg|aV+BIE9voT@iY;q}U{Q}*e~{-Zj-vlV_8+qMH<+3P7> z+w{IpI=+*N7hCFeR=Be18|PI2&P(yrw*jU3@~WP)=~v-Z{*~$ca+R-aV}7afFH-gt zWnZoAi?XFOBMx=ye9l+(-&XHKt>E}L zzezn_f2Hc35=FY}#b{-pqvm(5ve&2P&zSW4N}l&Gu4nrb+wpj$-^ccR6)$!i{|j|K zRjR*dDqFwrF-_%UPG#$m)CgD6i%PjOxa$b<9AEp^y>xc{1>Eb&%aKM zud=;`sqyv1j?Z@xpI>Z$RoSO2`;3(Bd2>~LYukFBuW)}2$H%y}Wj#oIjNixcF>Ym7P=TMeH~p^?R%OWXJdog!f9|))qWO{vd~oEqD&`iSrjb#*x3d z%gX4JX+6*^V7C{AI6QR z;`5d5Jx2Z6+Ks>1WLlq<1W(Z@g%r!?G&Eu5Afu9+|72JZ|%rS zasC{ITRX<@QMk2Z{9c7yTkyO(Kd}YRJxp3(cYb2W_kIt5J6!A-$NX=b zz^yHKIX}MQ`eF+nC|v9qpBZU!eqe3Ea|#z*a9`nK3mz(5Y{7#kBhB$!JH{U+t$%#} z_{MEOF5cpC{Jd1W7g$^HP}f_Ut|z#}cX}k!Vm`42FHhI=#1=eM{SjO6GF4w}!2^Yh zEqEuzFSg)$l`pp7!BdeIpP$%*m#ciS1rHUjZI9lEq%B_WVvECUo4Wt^{E7$>SZw$H zCgb#&h~7_(`|Z7{c&sgj%s&~ow&(vQ6>rQpqo*RN*V6tm_baX!sQd#sKl0*mpsM#W z=Ucob|3NDMTPpuxWgnvAtsVDI_vhOx-}1%ry1(0|`b(c>Og&EM|AABUX3PmG+snO6 zXS%(ccd;#>ms>)3_XKWj!Nd2Xk&^CtV#oML3b(f4`9BlBx2rF<;Ni`Lf6?J$3tk5N zs}2`Cg?~-qWD6eE`;x=O7QAd$y8mLw`0Wa}c8uSlaBB;mL;tsO{9+64tMLH1>F_%elCTkvw#f3XG6y+HZN$EURgFIW8+TkzcP6u-3vFIVeVY>U%9ZKTEPp&aXR zA2*(2OTAFl6I<}$Im++s^2Lt%-b=kN>IHWFL}x+cFLoUNfr`iaNY=M@%&*2HNRJ17 z{w{TW_5$SZmROGiqe?=raW7Q;6==JX;no(svs!;*3m&M) zo7gd~t`~u|V_ZGn0(HIcU!?v;f7<1KiY@tFRR6>lJW#mUf`?D1&tGi81GRp{7To^} z<#%Rg?~liWDA~C_Y-2rxOzU!sr!lC+r+{Q){gP?|KoeUwPXA>m2d4BU(C3*jrnf+e!)-Uo^P*r zaeU866I0`!{~XnK*F#!MDn5;S`Pu3IifsuzU(J`;(gE`n*N@{rPS^9TEqGo%{=^nM z_-oYvXi!Lt|E?l_iIcy#Sif~s#*FmyoBt>BMQRmD>v((L-y&YW9thFjJ>278Y&oCsk5OeA4^M2t{SPC&#rcU};^aK6 zKfb;Ns=nBg7pU>Hb_+gxU2So``g$9zOrMX~!dIsHW9`=YzT*CWNzO0gOI*MG4Xi)$ z3$Nr`zl;z4-dpSAYhQ=7IA3{vB6V|EuRYxSiY<7!f&PhK;-r7#k9w7OJlWT2E&3yV z9UrapFSZ;%>zDKQd^I0pOTTgow|0y9iq`$tTJnis_s`y^w>Tet|DIR;VoU$>SigzK zm)L^m-iny5N!JT&3!Yc&RcuKx|4QW>^HPM!_{Fw8KVz0C+}bhz8^&XMsj^>D`M*{6 zYbxH_krzdE{nu5#<%{EYQ~1Hku2S|z%1%FD(BDWEK1SJ_Df{(kBxF3j;6}2O?=M(8 z%0j69KfBnr|7RE5_W$gx?RnSJV>9{_SHxq??;}L`j9DHnWoPdl#fj|&$nO(BzK!`w zD&NZi-!6fVOX2i?LOB$S*SUQkGxAep%;Td@^wy>Ar``pT=Ub!fQ&qgR<9N+?n##9)alGwMY=0%v+<1LW z*A&k3 zGf(APJ3haM7?16TmHmja=c{^;D*G`NZ|%qn63zFx%C~%RyzNhH=Ty9XT^Hl&*V)EA ztK#_?$30%``|@%AyU|EEe5lHwq3pG4eSS&h?-AMVdCtBrR_~`9b8}SJ#o5=(>V0%$ z4pIDHReS@KJxJN#QTg9h_A+H}r|fR(eC^*Iq9{I99NqYRS@Eae2Q&unt0d$9src;s zjB)*4Rs3GcK0@VBPuZULhKk4gY)SqV%6=xDUxxb;x}S*qlm5GcF+W!0^LJ%49?!G9 zPj26Tc78kmE#7yw_d_xNAHsD1Z&A<3LsI=W=CtU(OZw0EPrkbkllP(Z`_0k)9^Hpo zUeDL}tA{I|>(qV2E^1syru*UR>;5gO-f=10%VQk+#Qibm3U%H+Qt`$NPw^S^^%Tza z=zcZw(s~F{pYAKgj`$LGv<@6U=O@?2VX6AYT&?8bAFn6>78?Iu=zSx8-$d$q0sP5+Pf$E#(mcTjsd@Br`u=XJTJP7Y^8#OD z+;XtpxEgbb%6~f5AI}d`HvZQOn>!!fpMR-(l`8*aWnZb{KT-Jk%J$RaJMzh$Z({f?{Z|F4z(n6l4T z_4ZJFdnp^g5t=;z7gW7ZlzpkHzf|F`^7+T-J5ZfZUh#dR`typaceSebqQWmw_Zi^?xo`FAS(3AG*{SLbt)vR_vDJ=J*d?`+BSfc}?J;rO-0eb-ym}1@sz^vQ}#B>rq4^K#$SIg+o}}q`8OgwaeYbuor<2*Qt|Xz0A(*y_72K! zPGMgy@8$*1B0UkGw(0Y2ici0+|5vGaFQ@ZAP1pDF{#CcQ|6Y(jA1}g_=l8D4*Y*FVa8)mtwvBl|9Ut77 z?tdP3Vm$@S=kD|Jc4ijx=MSvq`RsRbeE)9DG!M_X@_6R^$Ud=6pLdNa2u}ZZle(UB zeDePBl2kmuzHqM_==Dbmr`Nse`ahQIM`w-0?P@+Zh1ZMT*QcAin0GjzS8O+> z))~Ez6P=3V;rnJ|ZcVLUW9n6Z`2B49oK5u4KEmhu_I>V{@B1o`-$$nPsm{Z`zZ~Z^ zs(v4cNll#B2`Stw`yYj;KaW9wCsXV5PwDGH`L9*}A5*w7Ka2R>ILR5`6Fo4*@_g3B zrq5)kd97CCKS%W|{r@QRo#~1f55s=cS9*VhG~wa<`F`&G2A@CgPJwEi*)Ms<+@Q|8 zKH_uukW~M?vKuMhT_@=`?o|Ansd!@^RQ9XN{!x_g#+Bc;-BZPF3^vpu)FI;hul3!WXIfuc-b#rs8)_)uX?^DEk;S&OcE0 zy(+%HvTsoRDO32g=z8nsf!_}>=2CSXcvJBntL*F4c?GHU?v?4s*>6+h;`w~vf=>E9#QA}8`ZBZ)b;n^6z)auHze2BdR6}cRsR(p&!`Fx>(%(4s`BTm z{2GNnqi|o@4=cM|@qJy{m#g}nRsK`TK3m1Vq44Jvz9NNtIend(s^a%ajW_)UvcjK8 z*Q2Mj9R`%-Db%%bpNadfGx^5Y>K;hp~_({q>OP$XXs=vQZ-QUvp?Wp?u ztN05Qet@!vDgN)M{L2*IZK-@O)UU59RlW6Uyhf+;>9eaU9-pI1UOx{|_HioyE6Tn` z#g9~cmnnNc6@R%p-|r~U=Ly{ktwz&-3(n92pkqZ?LkztMZ2^`$%Q~P}$Q}y`vO9RM|%> z`$x+DnzFA`^$%C}^~%0M+5Oaf{6f_~QQ`fS{cUZl{701COsM}`Jd+d zca2+CchikPuc%bdqL0yqm;P55Tk^P}${fRJGCt{VncMbAKCkS!sYW0<7n`c)f4&Z% z1@pG|jFP!+>3MtO+fwsC6Vu+2`Oow!WBI&{z<+Dy|F?Mm9rN=4>0F8#pKKh88NDnw z8-Z*DvJuEe;Q#9g@bhC^jxAq1Cq4gP$0s|l|C=MwmKB@DWFwG`KsEyZz7a@118#^V zdP_TX;K-CKVYIB105^fUarTx3?Tt@XXn8K57f)NxA{kSRzNyBfm_Gd9%-#6NaGy6G z^Y24DHe%eLWY3dzl4#QZe_v0IbMowxXVfz0KY3m)+1ujTP!HR3UM*ubG%_vy$-X7^ zw)7_%lgv%(ZI5Z0mGu0#^kn}xq;uakgeLvTvq<`r`o^mzDRt46J!!i z>c!OLT&~u!K5v{oL$eX+*a$@bT->qL&v901p7rN=!m_S!bOd%XhtOw*Yv^|YPo&Q* z4>tYH>GazKd(-cUe3gEC^8ho@97DfR+M9m==sV_1W;p%++zIsE%4e9bn?7bg`c|#| z%@55t%ueQ;<_L-&W)3tzGsn~M1o{Pm6U{;9By*HGn|_UDB1Qeg>`T8<{cVbFoiok% z-4=Ws<|cDz^Qvygd6jSEybg}WvY$CjjpH}%7#>RJG=jMDsWJQpWu?cCIe6Um zkH@WwK45tYeRJ_J`XA%rCNR||q-uGZy)rY}l$){iYpb>N%K>$!i>aqy&}g7<^Jt`B zBpgS-5_^WJFlXXdi)c0Ao);h8WU+`KOsuWq0*69<`Kh|`Il19>z_`ash@KQi7l`XJMClKUJ({$r^x$I`hSXT0IZ88h?*;~!c{wN5tv++k#o zH2%_4O)&g4<2Q~mX5Cohm!D2!bGq^7kQ`ob{Jv+Mc3yeAT5*oKlXndwpKc*TpX;4F!iCQd;a({>3eC;^vsg+UTzS5 z0pFyb((h)|7yZn;-1COg7qpykh3Cz>!ZYKid;ZWFo>_Sdecu3m@x{bjJ+GN$-#a|- zz&kv@`93eV`aaK_GS@SG9`L+F=*wQ$KH_=D&iBmv`JTUev1hu!=y{`G^i16n&!4>9 zGfS6K?i*yk<@uxj3Op6??qpL+`ZNh zX4U!roN>OH_fy|n@>Aao{e_5D6d+~Wr`?(P|@T?r&-bD zTgjg@ui&5GG!;$J*VL1YzAl?2{uhyc4XUZ!%1%~xwz7+rU9Ie9{5gz@rX8)_+sZyx(!Vy+ zc5)N?h40PAcw7S)ZG-4!Z=nIfZ&je5IJfm@V?-*=c%LhqL_gNR~gXVfoB0oIm?^mRmdCszK;O8*+3r@WP2tlZqnEv?+f%I;R~XywjU z?q=nlR_<-(z0F+zYt}x%%7d-^p_N0e{E?L>S~=Xx)2yts@(e2{T6w;ezqE3imDgE0 z!^&H%yv@qlR^DsnTq_^2a=w+%SoxBbuUq+hD_2?hu9d5;{J_c&t^Avnf46d-m7iGo zsg>TfJZ^!NWma~wva6MwTe*dmTUoh-mA$O&V`X0}`&qf0m4mF@-O4?!+{?cDgl@(TQV`UF3`&il6$^ljmvhvGTe$~qTtUScZ zA6WS#D=V$6w(>M9$6I-Um6urAY~}S<-eTo!E9YAIxRuXYxy;JdR(@uscO9R1Co8wH za)6clTKNMjPg1hM+EcB(#ma}QTxjJBR=#WH8Y^?xGyk?$_P26hE03`9I4eh6*<|Hq zR^DXg{Z>9_<#H?Ew=#DF^Ho^c!^-|v?rr5kRvu|(rIn{!Il;=wR$gi4%~n2O*`AsXoZ{_h;o@V8_R$gl54OZT6<%3o}Y30jSuC(&+R(8FK`FFMQt5$x;%3)TX zVdVu@UTx()Rz7Rx8&-Z~rGGQm-^|MHR_sAi7vdYS{t(<1%udJMJ}Zi^c-(I-WtEkqtQ=!yqm^e`*<|JUR`PW(?oOs;Ban?iHUilQWFwG` zKsEx|2xKFWjX*X6*$8AKkc~h#0@(;;Ban?iHUilQWFwG`KsEx|2xKFWjX*X6*$8AK zkc~h#0@(;;Ban?iHUilQWFwG`KsEx|2xKFWjX*X6*$8AKkc~h#0@(;;Ban?iHUdSD z0DdC~pHnZ7KGf1hNsxMj#u3Yy`3q z$VMO=foue_5y(a$8-Z*DvJuEeARB>f1hNsxMj#u3Yy`3q$VMO=foue_5y(a$8-Z*D zvJuEeARB>f1hNsxMj#u3Yy`3q$VMO=foue_5y(a$8-Z*DvJuEeARB>f1hNsxMj#u3 zYy`3q$VQ+|BhZJUMp0WsZD(r9-J*1jE8Ox#J2?C_w9K@UhF?G?Z?l@ zds|W)K<(?)4yAT1wNt5`LG3bX^QbMS_CB?LQS0g%ZxFQ~P&<{{S=26~)=ceIY7bL; zmf9+6>!@w*8*fKydr~`u+E8kzP^+W%Q)*MF6P07CMvtx?W-2Qy8ybgK*VosX$`N(- zHDen`n98bCYU>+KW8OY zBdTgf8=|NmJ8TS5jHw<&ZMT{%!azEVT4moLp7a1CA2ZMXH;`KN}&^xn*!K7d>pwbw`%m*abuuG z9g6xqIwG-L4b|1u!x4?uqv?cbB&z64?SRpQdzEJ47f903n!qfZT~6wlQI*x>tA~wi ztgakI;nnq~Q)5FV___sV+7sO&vIHudZ*X9!971)iY}vN7alq`v+$7 ztS%HWxoKZIHb=*%=s1;*Ca_aqk(?DsasP!{pP@Un=&ZUp>B&R#6r8c`3!sJ(bXP=*j96 z=`i&~iaVuoLS1!(`Pi?cwHc}FDRL=-rqt45b|oEFjG@D-(~Swa&sXK*4o}aGscx(q zj^Vj67oEbbIn*(Cnq=jIOS%9@|(y!F-aV^>CR-Q#bua9M{amap^g9_3?*|9ygpux4vrZ zNE(r*kS6i`Fffawkf~QAaM>?$Tto$Q_}w8rs?7ZqKj#{t^f;lF_(<4TMc0IvLzA~> zwKf<*%cEhG`6M8GN;syf{xs^_aC38r`E4|}g$?F4Dl?t>6qxl*p2sWWnmmo!1e%9s zXVGE#I65>>B%(9Ty!`k^^C-b{0k1pDvs3Zb_Foid@)&g8RYycm|1;gMQ7yt-i+oq82L3f`ogm9Nmbnw6xj zcm&yNC;4=od=Xt7{8J{-N{VL2OfRpir3byWZYrmlh#p8+mSa(xS>=J5c?mMt(^a0v z@pkkM`DQt1nm;WWp;EgwsZY*qQ`<{#u={}z&_zJsJ;qiRMEH5U7(zCHz@7G zozyvc3|-WjE;RFiE_sMM#B1}W&h*q%TW@9&J~bMvmG=RUoKJQh-&kK|77;f2LF7)p zAIF7J1kS{)>^zRH#_x7ExoY!5=P@-64K-s&nm0R-HESqoe$<^s??;__ji!W$_v$Wz znR_8le914>jNH{FIU{qs$jIH$#m#Uu7Up3pGW`}*p8qk9>mS8&-UrnA=;ATFvZ00^ zz|@<6bg8SN%er04pLG%cd0nyO|JH>rY~~lFM^j^7?NV*-?@Dv>IO?sNkHMg2Z(i&g zmw1KX1)l+KqGx;K^Q21I_q&R=7DemL>s{-s&!{w?P}clt9WAA!T}Ly!Mdxd7rzfPz zbKs^2IbGGd(T%`J^8|U<+yQT+`E@tG5WPyi1$UCKE3cv{<%y9oPnTDl&&o&Cn7?!y zSzk4VE(w)pYUfc^bk)17Gp>D~b&emcmv=<+kHb~w8J38BrGY2C zJbIwk)-}=r; zUx)0;A5oq^tg5bY9Cgb4d5f|1Y}R1jqkyKj5ioBR0{FxxZ%OwB)#Ha%)1_xjRpYQx zm7{CMP?vtWWniX!faodj1YTuoejXrfDb=6{!XI&*xiF;Tg7a~oWNFQtTSnE)$}RCg zTDBz~NbgeK$_0pRUW2^Ff5UOvTJ$Dy<-KyN2<|ISvXjIKRn=sHkcY5umw za5HU7+!&hqdedd>fgGkG8I9h;AXw`CdKt$>zrk_A zi&P_h3^tkxW@zHJhq7)4{o)@LY zJ=@}eU1^@$)|5xRq+9pO5%fxhuDf(+yTvf`*4D$#_1n<%a%GF;d$*xrTdC4KyA2*> zA8k`-CU4u&XqInFHz-@u9mwj<>4Zi`FBI@}72i?4xLx92ed%^NGqX3&yuQ9_LZx|a zyXXP;DY=_Aqcd#2F}nA@4_A$q&2*Pdw5XGioQ| z-0g8~K)KP4=ECh8DyxQ{QAIWIG)1D4pM`Its98%uz4k5?Tzhxa-C>P2XH=U-+mEUm zjd_1z`?{)e4Wulk@Fjl&=i=)SKK&}Bu6YNKAsWwfcfc)fG^r_;3wNM|JCU&D0vxBl zhZ3{SfxhAsN)Bie&DSwm(DpGhf1WuCCH7-l3remvr+!d6)bd z9hmo*s0MY>+_gikS-V5}if$(L7)N)l7xkcC(AWIYZSFK27k-Lfterz=OfQ4#X<)|T z`D=oi+ar2B%FX@5J?LfPDRfa3$ATUdS#6%7dQ0Ap&g3`I8WXxZtHC`EPOrYI!ThloYFC?gdeM`L zd7+nmW_zO-aeqK%R=kV?)6N6ioMk9OS44CBj`SSTVCL*di?fz4firicOQm^WM|!wS zLFC#ykhOYY89l$BZhl8$ctTscqYP=ou&S|RX+rpg)QTPH^g;S%eZ0|aAaUYtk`}#DP-}Z@m7QHKk*HC6! z_eQ)2WS;Li?vzG)lc>@(?L?aVs+saBrh3}zIIdn%MjUurFtc{yd*Hivp}sDHJ9_mscbDiIPmfvi zG$D(BOUutZN7|xSqjmK=vg3$%kozb27yl{gKc_E|uY-U2+Y$P461|~s^Z=l{`>XrX zJ$g;;xQ6&u!!49K{VnPZz0Q@9GB@oqq6ROv_}2UPyF^pR#s|BMjou=$FFG#hTW22Z zYhMYte!fgqXFY?SEPFaSPQ$QHx*5l*@6kA5B}ve_|YB8^ScISX|xEMXMtkz?Km#E4acU) zv-)D(Kdh&>%joKG3yw3MpciU;&w}+eRio*lYAzhmSYJEATrz+bd4w;GaJq7t z+Xlo@cMqWJCcSw_tMc3dxS_scK!dq|0D3aM(yZw(S30wZ>a4j9SCT1rgL8Itcd_bj zx~hbDOR&(Hk6<=I7lT%$l9}itt9i z=(UQON2It!(DODuCsv+SGtwMOPh-E?%>yVNy#<5zpr z{nh2;=jB!eqo;ezDrWdixfS8M+~o4*UY~ho>%1oa(C!t7RP^nB!UAum*Zq(~x=-;} z`O_(F=oEkOY;U>W?A5LIrg_c&pqXBu8Q!3IL7#n=d8>0Pyyo0gZ;#2|g4`a{ya7|f z8T3~A6mNcRP&4K99W=x1K47so$LqV)o7jE-i9HV;HIuASeGWZgrgy@jv=6BpGI;Qz z`_HDdc|@=_T<%ZJ*Uj<=uki;rl}+`g25UVWNAF*E$bzy%4q2Z+Wbm@w;GwH?Lx--- z4V^)KU+vHK4xHz2z08~MPxe>mPM8^1tnvDGpP%c~w{PQKgC=_w1Lpb5wrKXI`is5U z-ej+_dy_YDg|~RCwOcLjw7eXy11Hg8k~eDB<`r|i;S0S1eb#<;q2G6zzt*32(jMKr zci(3z1KOzBC3HlWKP#1ll>D`=4J+a zEcFKO(M;zve&Skxe6u$_H#axeTb!Gnn^She#95Toaoj228m|Isic;XU&;yyFNJthT%rh4-`&84}R>#g@^2Q*2!ljn4u z>@5!_1*4j&N0Yq4Q)mRb&+{fu@%tXzv**@Z_vzWQ`+%jnNx6C9UVYXE%jl0ReQ8N7 z^VfA*=dJBA$6raT^Mg6vn#!l`a6(i0vBwT-Ja9^||59(Iw}J+G{Qg5nP4f1yYu;>n zxV(I|H#Il8^OW+sB?J$d9ClxkTUpjP)7yV;eqPWVtj{g=Cob?N=ceXXmmfm?_eKwG z&h;5GYSqps@6qHf307f<7I=+og9W*T{+j%h+`QZzn#@UF>I3&@4be7pS2sivAw|BtF67%+5!HS zMe)D4cAd4~wf1n5z}H!OQct$GAc_72JF>ltwO96G`|H+TzBAi{1@Fi9vDRMDpY4&> zUSaJC)@~li_(j&9I*9EXt-bZ0Y~OF~Xtr$MzqreaJW2{><954`O@EUUqyB zX1kxY#~;G>{?@Me4%??#dxo`7xAqciH(7i6_c;D4YX{$F`&MfY_yOCGSbMIums-1S z2;(0Jek9u%cZ~m`L)qTO+Uu;{-`b;p#Q4{&J;~bNxAyf zly*|mM2n_Gduk7gA|xwBLy=?_6;f14Au^((ttg5jB$1Ik=f2N%e^&W@|Ih1rJ$|qI z_w9YI>x}#Cai8m>;DF_h6tBS?09*sS9XQMp%Z~y}t;L*A`Co^*0k|Ca6R^Dtmj3~6 zcgHM>7U0DDXnSH-0lw*jc{#8mI#EJuY!1MFL70Pq`O$F}Ax{Ek3&UJM$v0wt3_Jw< z8rUET%YOn}#$Xmh^_ocEgYB5LfgN{Zb^*S;7jp=3T|DNUz}>*7fcp+%`4x(jF+Tv7 zO~d>V_$DxiG)~XJX)K=s9DfG0GI07?%yz)SS(yER$6UZ116)yx`517*9n3|*oewZS z0cLxK`4zAb@JHZs;Ge*<&$0dl)GrYE*$pfOEL4Z(n!t*{tANjZ#&S2xKRWD3!LV%w zcJIf09JuK#<`Upu8Fapm^q&H!&%oRb{6iKq=M0>_gn5{!0@uyQtO;DBh}i`A!9vVV z!12nMBPsuCm=6P6Yhq3Z=GMhr0Nk_$b1CpFJjQJ37`v%Mzz(UcOuK;^*!TbQYYCGmO;342I zz>=|8&MAk}>%J57RN&%0m{ozr4q!F`HaLuVEpQS#JSECM7`PWW4R`<@nG*71N{)_* z2(AVe%fj3P9EFZo2sxWPPM>ftW^v$~=un1`F923Vr)LDO0FDLr0j>i+0xU~>8-nz6 zfLno!fQ!+Y8zFxV`~&zaun9VVBIJB%TqWAu8Q@vKZ0OjFkm~_k0mfw{FX-vZWHg!v_Kx;ExM;PS%$q58#C#Aq4EO?Y6>ur=58ygrzqQ!^FmNvLbTrQ-(p&6= z%-X=&M=?793!lIo4r~vc3><|Hnuzc&11~v=`6+NNa1Sv0Sc}x>Rl?~@J&k!f za51nJF!vcOw+0RZ4gzkvisie3HLhVk2kZ^}7`Pj_lhQB2`r{Vj^kiPgED78Tya+h< z29`Sl8{EVk0bC561gubo<@vz9<(MA>E8fA}3!Hfm^8{s_9)(KG3c#HYFdG9WJ;dw< ztnvu+HsE65bYO+YSe_5e{{-^`;FzbF-vSSz<0PVdxm9p_`p~%<8=u9042zoB(_RxB~bpaFf+oGQMiy0T;}jl-w2bFJM`B z%z~;oJq})&rvh*B!K?wC0BjE2y%EdZf%|}?frB<<`4QkD;4I*`dIDs6OMvg6#ry;~ z_X6e@z=idg-vQ5Rz&r>X4?GU-I}rKH)`;bzz|O!51dl`W3U)y;`T%pz4|umW z*53q$cK}C#{xEPMgeN@~$KUCT^%a48{V=Zp&fI|68`u;z z6e7N8O5X)@I&k}1%-4X!wqtGv=H7`JeXKyf|CwEw`M4lItT0anmbAgF2<*5Tvo`Qh z5M~G9gfProfXBpOP6wU^=^?%WAnKdF9hMV^`2=SIe*#v3{XS0AcMNuK{aAc|HQR27U$1 zP23$p`S%&P56b&5a5?Iz3Aq3dexGvG5feNe_(v*cEnwjk%;vz{Aa@2f*n{PPz>>!> z#{f$`z?=%~3|s=7T8ZV2z?KmHS6~H*e+(~9Zw1I_01q9&{uco&fP5ve-$N{458MYF z2W+q#%aeh}%*EqNKClYx=RE{=2Yv?}1p9k@d^mlM+SvbmVD3ej&4Jlq|IQOwLj}tZ z0LKHLp;#TuD}k*+-U*zogylbhWnD3g^5gU@(Z{R|?Cy+tC9s$V=3w9|P0Z=Q_F9-9 z0Q*7wJ(L{w^8^KOd}koM1;G4YaQ-d<&Q`$TIRJMrz)YMg5$(%lCgw!o*x8tG06&<8 zxdpf!^uGh!Kf>{i5yat1LU~C7*U4f3i-Bw8F|PwQ1^sZ~RM1ZZ=0^3BXfM}*dr?0@ za4qne@tE6z*+AY8oSJ~+<3f7G`*x;beGy>ycr2d>JSG9lb$}o2!tyo1VqutrfGt5D zM{x?4Cj$?`{%Rqx!EP*X1fG?R6C%`jEaD;F=xS|6$++*#A8ToE?MZ*MXg*G1magp2zXM0j?>){0F!=9P9H>!09)I z{a`s@MdBVEDo-8Y3fNy>4IGw=<-Wj{p_t==vmyV|fgeD4g}_1}uLACC!~T1KwKFgi zcLIs}A$1(H81N5}D*-E@KiDsQ z13Zw79REjPN2qTc!Z>~DSy(;|*z_>wg}~nDFdG8z&cfk40_WOec_45T?7trX=AMYx zPwBu?u)muRT*HIq)xgDUn128l@?)Md8K<{c5VJP0DLdxXz?qzwBY+)YJUIv)KaA6V ziSiHo*EPUNuz&pv*sl-k%ZWgJ>%?pUEC%|4zz-n2RN$(9tbZLi6Sxc5aR|%VMRE95 zzcJ4QcKnLj7C0T!69qi$4VE7QZi4!F7T6vww21n58#qA?>o)?&PQda$U~4EZ;zBOb zzQlyFToBlFGUhqJH6YgqW&^eX?!AxehbwRttfw~vXF6l~0pNCsKM&YoK9*Mk_szrH zPBDbXHU+1*4tOdsKb#jV0S*H829^Sj2UY>j1GWZ!4&0Z70njIxs`3i6*83!AX89SeJ~a5xL^V&S7Ke3pf;v2ZC1SFmsu3)ix6GYfaHa5oEo zVc{Pv{FjBfB$)G$pM@u}us93PVBvWztj@x^ENsle7A$PX!cHvg&B8$}tc&&^RM9gZ zJ&NctK#v@Hh3V$(p6CVKSIL&UFu9@aDvav~k*nCk!cv=Hf#LbuIFUITY> zf_vcTJ{0=g5nlksU+Loe-{3-g2204v4=5SFmn6JXpJ!5+^ckG+HGKvL>aIV76Z*;& z-~UH$|Myu?gI^$$6DRr-I)@dv2?Lj!%z|39SU_r`>r_K7OvC2J-DEvYnB`1+Me z3x6|?ua%4Q66tS5Tv5cAQ0PjyYb4(Z_gl37hb5<2q=XuE#h(@$!?h9mRelyJ zgU3;HsO#Je)sjpbi-uh>Bp)U3_x{=AH zXCVqHfUpHXQ(>yuWTF^ekgmqy$XfVV^na)Vqbf39Fs3DnYE3vjMCXnDQdjmF6p@V4 zZUD1ngg(BGLw~JNy`rn58~zm`DH{WK^&k@# zg054Oms1EixQy0?f{3XoDK~uKo*@7#Z$W|l51Ap14ye~5ZVms-oo+yYAz;!!^YwH_ zDf1;iWR8gbPV`<7((tdbfyoqNLW;e1~ZDBp&Bvyb06K}z=$M} zx9~@G9a-K?5ir^@7~!9)iA+Ao>v`~|WE~kP#aI4E1jl3$V1$&Y1uWwdQ^R=px#rhA3(JRbWB^C6}-WN3(Ol;D+v0hLF`CbQmok(A5}9AM3e~ zw0=O>X1FC!cO+hmx-Cz4B}^DFM2{^RP&e=yvJb~f%veS>lt2#<)gc#bAA!jYOeHY6 zfz<@M+h{kh=~8@upUzZO!bu}%5M(`KvaLW@Cu3uH)u6y2qLf%%=rs`sOK8z&WOO&m z_5wrrfna!n!HIb&WQ>ZixIkA#CM*~&EHDa~Y%MSfSgbBEib(qlbO)k7{_m_s?}e_| zGWcLJ!$5auwv<4+!~OOVx9u5)xLvu?t2%fBcjA8fNOi_4&!i9CKdY^Uks+Yj9cg`m zQGyI6B8v)4E_l!#;fCfacz`AL0f_s#^!80TjBap&G4Oxv6u~|Z74koN97_DpvV(Nc zOcOB1j9Ld{#RXNGE56>%>H(DyvUW*ySPc41W*=CEM6+Y^5k#1J7^Un;cUqH$;90Mp zGlm|5X2gU!2xd3(>N%qmQMnn`U{t~xrnNMuk#-`8fXK3??U!M~ldaJm!Hxp9e?$)e zywIeSjRLe4LRfS7mm*%Q!fKu=atMjOn*C=Ov@#gYKhd<29SGL=8A^!J7irpnzQP;f zjg&Jip8r#UdF4DJDu$r&Ku%1`|2t5coe9?0BGboU1>!&CbVCrMDl%D+_)m@h2n;Q0 z2)lKo216`1nXFy>pS1oX!V$(USOcZ)Co>ic)kGQHaMI~r2BVC=q#NN%7|)=^$y(uP zy(#JKA3Y!`8*L#>3Yb=gqzhBxN9$ZjExLINTIy&{OqMnXF}a|mVnX}c^iGLYLhG97 zYf#c+FtV2u;Y3--paqEyOOUoH$jxu^+=GgbK}eaH05ce)`KMG+yQNvq_zykO_{RUz zA;KgJkM(7!$o?4>LqRk6W!~#z5YX!ngCpv+2z$${hLssjAG4S;W;S7rY&f%6O~x}f z!X`5|z)Vq#C7=9jMb{PyngOz6Lh>5h6Ji-PuWr~@Y{Grj5u*r;!F;k|Q(K!>^ zy2U3_$eas<31yn0Wk%b}Gz-pfIz^dm##uqP>Gysy;n$dr;rD;^R@Ua&f zojqN`VcL&hg~;Mr7F{ zFVt+ACV<@!29J0fc!VO1Pi&}RkqPrBjs$7JxRVn+8p3jgLBWL_n#sydO%1{V!;mSm zP{J?`(I{xm-4E^66V|X9-H6oxIb==sINdaVhGEA$P^)S&q&m0qSX+I#abSLcs2)!cFY*1W^!TNGpGEqZp%~dtP5zEu5R!l##9f~GHA0qhV>#f zr)4m1OC%e=2J>D#uAK}9%V>(^Sq8n<&?*fM1!W^K0wGI`R<@Dx4LRZf$>N}brkX6pBch_F1^+@Y@8S6NrtQp&1i4>q_4*M@7>bP+3TX`%4T?eNozQd!Hz|<1AUk)F$fO|bP@*1)>0PPdm>ii7 z07fQ)b~eD|iRmK?965d!*yf;Z?@~F7th5uWVk&$;A7uZIzPKLgOeJ-s1ZoLxJznVb zN2;LFhZ^3AJqxT#OdTjsC?%959y{?XphJ7Y;2nLC8yPg?RGQfn?jU>uPzMs|g%XL5 zPI09eErqB{^zaV~Lpw-#LPu}p3`~m)yOH+pseHjsWS2|4OAzk$P?Uatu4smc-C(VUvgOi-g^eMo-BC3ebv}euZ`A&%13=2}zc!btK>+Pz`vjmEafwsD z$Vy1gK53hY;DL0cPp>Es$ciE|sEH;9p)Q`xL6PGVVQiBa7ip;{rvj+MMzw{o_RBaY zK)W$$vWR-q0GNI-Ny){>sGD_gD9@xFgz}Tj5tzG>^IC?Ip-p_qV!l{eH}jdjMt7zXQZ7HzZ22@S}&Un4V#(GN?! z)XvQaJ)%<}ewV~))P{(wvI{CSs#^~YLceZ;DmCs+@j!+%0rjKAB7k}!s2?;LnK%?1 zQ#^S7?;c3+fQg0#Yli?dY49hEl`_rL85Pj^m=Bu26UM5El?Tk_kp`+ZgmugCFi#Eg zjp?kUhQc4|=8D{$EUc{1fx@zYFeNoogY3vyG7>M_7%CP~spxzBL>JF6?PHzQ(UycL z0BR0HOo=?`n^u%Fxu9a2@G7s5*{HopE1@@=Z@!7#hw3QHMhe zAFN3s>J6(Kc^FR3#2CtgsLZgIWQZHOAEsmyrN*)>b@%b~^gw%`EbmC1hTJ3l%AvgS0|Ro%DG`p!p?E2^A;QM6_ID7^^5D<&Ws4;Mjo@QD3)!3Ui?7 z+crqW`t=R8OG7r7QMO=++{H}^GMTKGOfKYNj~vcn(LrmD z)bx~b)q%!PKl0bpMics?EiZh0Jem|%dZUSm$r^egGI4m?i#sf0wT!=cAm(=DvI(_1 zAGAOtrfB5G4mcB)7YoQABcZ9&7ky0O;-ME!5Q*(Lrg({@1bBF%ejfcy8S#T$s7UA; z;6%J43LJmEjF)(LodjRk{_E|CRXZ`F<5b|q3=tLR(tL2VjM6hwVT zK;?@^2+DqcG=D+MR}?u_3KTohb~Q%NEoxCj(+4%;P#2{GZLK;kCG85zyB6ei_sB5hI$8vqn`p}oQyDsi`qEq4Lyjib3|*PFXUa^g2R1~ z^>IoEwOwkBPfT8@brCu7qh`Z!S`4Ez>nwp-4us*K1tDGwO=+l|9b75t8+srm+G{AD zkfOI_aD>jAY%+|&5HkdnYBX^~9Trpcun0h3KZoEU11~pWl|d}A(Vzy~Pt^2_7+ql` zMO7Fdz_am;B{yY}2-&%b3)iOkw1PZlF!`W#d4`YdFyVkeMO$*rSK|*T*LKyh zKm6ldCka~YsJOk+3fzxag`=`VzfHqGUb*S}hwIlw3q*bw^DF*X{W*7Ij<}iHFbS_FF~fF0mRjZNv6bn?3iB#GiS>wuxtD z*5YkT&-LDt{k*bU&4JhSTUho(-E^rXIu$LF`#CC(-1h1SH_JLL!?kNW*ZQ)AVdavB zN0(G;#P;vBDn4|ibAjpH&^6mkjJB*W8#=> z+09*@&)NGf-)=Y|w~#GcvENYU@n-(rTc>=Qkr3(kVZWaO-~CL#qOIxGWp6!d+9QK^ z%+jgtQSY}7`=b$1u<`v%&q;W^Y&mK< zI}X%pmL(d=xE{T*ucPhYQsMLCKK%I3v8n5G-lgX$vn8^0GlByu#~07PB_*dEx~agn z^}f5rPjjuk(}TBh+l2aaH{I=jRlUf@QL}Erlj$Dz3B8%xvO>0Vk4~?4<~(!0bhGz~ z^J=N=vJysVQy{`8Fn4&F_Uo<|*y+O+)jLcGvYT!(*n^TgALp1g?G1 zcwfSo8le$gF!1Hgt%}ZOS1Y&g&!@ijUbu9j3IuI9s@VH17E zo0Z;Ju(RYRhl}2`*_wx=0vn3B4+cNUiEpx+h7$t zt*}jtv)69=)uW4+?G%Xi*XLXx7W7QY&{@%|=;{6|o3`2*3eMkm-p9=({mA2U@7DIH zo=-B~?=|uM+j7~#=XDt&U>XAuUh+J_KAcAMbfX;ues%FC%e7%k@cNFuXcW}Wcz-ni*2&; zSHuJ@Z~X0GZ!0bc(BCm{*UU5{N%cpXE)$KtL#hMK7u4+E(chJ_z59i(#j~!QgVGxZ z>{bTY@cfl9iOuC4{B`fS{*lm>Jni95`8-3kV6E!jeGx{f|ZLtqr z8)w!sm*ee^sYStVJjq8dBwq@C|A{|^Z%Y8j*hGc2{^>7fviXS1ABs3W<5cH;?f7S= zVYk`@e#rWGdF>T`?48@^Zlom~W_@l~$u7&LiuejCD8Rx{_@jtP}%;$Ig zq-ifB!}31|T@srzaDM9%jo*qZ*YC?plJ^l-Dos1zH)l(i$fU334j(?>+LqjZe7oWF z2_d_m&3N(Raje(7Z83{E0u%l&4IE6$ROOAnctGT_y6dYd!>5sIGQa-XCPx^)9`CrN zfG0OILgY`Po0{yx?T%ab1ov}E&X_j0LT-Wb$zThPT$wA)k9XZg%wi%>lD*izb+{N9^DH zRll>dFUp12K|TCe%AG3~Cv3KN>3eoIJ}<0yTJrpZ(|UvOuTz#uw@dMFeZ5Bd-Inq1 zwpI7TP?l*X)J+-pU zGrvf3|BJ%Z{23Sj=!zYRI`Q@sPe@t2RJfzU9i^jEV_F7d8pjLmaZP%c@jhh@|MSbM z`4A1?xod>6=NZ&#? z{nPA~-8a1rbv|2_!yQrA`X%M1ppeK>t6dW+WtV1Nn;%>vJ?XB?m<#8|RVFkPm|OeZ zSugwWQrsJ{Qc2z#0mnN})b`7?-s&E@VQpu{?s7Cl($c3~X8n4OdmAH{KKJUpU3)7g zfj1|c@8#>E+vx#f1EMqC){oVZka%)KP4DTu;rGRlS4!Mo>w2V;{p6dUThDx|2voIm zJF9rL)K_WwBi%KhvV9L+o%-#|grJnTJI{LBv}Xs67YvnpY9YGUYI(tCzWpM{>h_jz zj`tTgSLQBX^7q~p&ZDW-y!GxzFT_hKViGpw?4R^waH-u-t&X$19U2$szN;{<)phT! z*d*dePKGvzf64hYN&h(JTCuT$LO!Ew1K+=nACE_IEn_|S7P zG@Ctjf5aqPJDu6$nld&e1@&94)PL^cj}zspZC$~kRv!90e|_QY1d8{>_n&ss;Xr;niVPo>R*6 zI@bBkTdhrTJGlheCD(MUE4f{od^Xp4uZC@ySN+n`Di01}&P)9(-up_^0sUfF!ON;iX_ZCnNakmJLQYf1XYuEyS8_9LD{eyimE_s54Lmko5P zPBs47b=9gq^)lZttILPAt=VS{L~(U4f3H4CXwR%QtE|*=-W%w}ZTsS!Ej82Si|!NA zjeP=unKZ6p+Kfz-V^IQ}Yr1}O%q_f|?akS}>c!Qg0iJ69vV1}I zt(TUX)RpgUyYc;8kgt$shu*&8EsC#o9w%Lyx!!5t{KM!|Hog|058s4MoX$+i9hCW* z@aBHu(y{FR-}l$8yqndsJiNzG^UbBm9WSiTc<%L`dQI+KLXrg6-}fdh-QDX_q|OS4 ztu$!TS|b%<@jiHUONo}PnacwqUrDwa{!g<~RiAkusdX`U$Sn}vD)e#c9Z&K6wa1>v z93OjQ`lR}GZ9KL8_ny356lUYqqUC>B)Ty*6ZTVhffxm%go7CqnxMq2%_j$*@H%(Tz zW`8>v+gqcQc68#D_O657P1~2%%sI1sL)`5@mHFad*BtixyJ$gvcy{*Nw)Vi*v2v#r zJFez+t#mpxbmmUfR*mFC4kEXgIhU8XsTyBz{*)0q!P?oep}B7Ppu9_p+j8jxI=y4p zMG9}8&K;8f=LOp?t#9jF6zwC;49pb*kB)nLWlzTM+)d+kIQx6~geJ!xvCi{s8~>ow zIX^?SjXlKo_YR4DY$iKjj}bSRQ?IdPlX-Tr?8Sq@Z4;ginfrDUQb zOmo(I_s}hB+r6l8ZoMx*&wJ+ewKeQcd61-5k?nBkf%?@;^MqVvH*+O?$-n!Nz1(n{ zjrg3R-yhrt{4dV_u;^;|qL67zL>5*^)Hq+2wU+yk87#kZZ&_H_FXnr&rbMRlUViP6 z#o9zZsWuUh_uucw$9RjB_*P|Q-0l03u*voqC!67Q{RP8Do-G%BMI)+R8=DvSC7M(Q z+gxtaOLcrx_?114f3ltTkY2>R6$#6x-1FS;zv+6-linrD-};iqn0|Hhart(zvT14B z6CtPW#jhM@v+Ju=z8^5`n}2>q_(XlvmC~km$CuQu(o1aZncN>Y=4j!}1=?oAaclzV zl7)R)X|ryn>aLjI!!_>nx%n5`tK2?^y2*zMam8)9(NwZ{=KjaO>-X55+j{HvSGB%} zkNy^PO`Q5S!1ql_aQNP@J!n9{sYzz<1zLeYqpI_?pR6M7tW>be-Mf#=Va5 zy!Jv%`k;~Ltszl%`EjT9ji1jhTDwV5rbcAT@bCJQQkyg?9OhfPe|T^)gk4ti`fXV? z-NDu0$9?E$3*%NOT9?%yd0Qv$Uh>RobCwhdo1Ij8pRwdmPpfp?sY_vlJ;}~Dq+d$B zovA8*D3z<}!^@40=R$==v^TZi*?2r@MMK7^A`XX@Ig83B7ELec*!X49Jev-OiN7N? zws@)DFXg-0p{Qc$kYZ?Fc_~)$W?5jO%vOoqooxXvC$0=MMe>=qT!`%Li}|7FS+_f9 z-Vuc+BcO`;Ejf;Lc!!Zj*C0Dv_9i7;Z~MP?!ChOAyGN2rk~xw``|IB*yyQU zk<~Q`K`Wmd-Wj7I|F%pjp&@ZwMv?#5q~CvpZx`*{mH+GWoUXAKHSPpI-r=o(^xcbC zWkGY{D2@YtkB1Goel!S%Ypqyz*GpX3s4Vn(Re*EZVe!*9Z)zL2SH86wdb)ez8HKeu zKXZCyJ2|hdT53LH^QK0tX)Q;WnO_&OUK)DdC^*nnx3wld!+X=*^oS>_Gjsh1esA43 z{IqsrR_LQ+4s&wu4ZL3TO8J(gbx-tLi(TqvuPfCw52$OtUaP6j<~Vm{Xv)h=yG392 z%IVt5wal%`sAoHzA6Z*zx?ng%=0?Z8m16JrCVW_N|Mht1&sQ}bZChZ>UD_MfTlD9o zZPw?*o(enW1c;8|PMmd}tt@kwfQQlXZSy7$e@Tnjx%Za1>Ml>&+SB|;>L=g2lWpg) z)3!`!a8Z50X6vf+M_c0*LJV&>1m(S%aWCPozvSa@TkMTzH!=Pyd5smS()W-VSjmI7IZFjng?|pFV-NI6-ZW`<>6b@4VTR{rcFq<#ze}HG9hr zAF4~py6Ch^B4G8)dt$2(`adbOQc%1zex<8KPC;Ap#-1HD{h#jcdi}=d{d+Zwoo}L-4Ju(_}se9seSv^&zt{Z^VJFG`nBtE zj&-RWpV6QC<$spW3;g?AaBYV1n%-p|K_65P3(K?((n#4OKB-~n@&!LA{&F3d?=MKy7#wB+U%@UY|Zoi#?SqC zv8yZJdQ_)%%;wdB7G57ykH5Z_7t@+#qF??QRN$XlU~}3tK2(*rDc~Y+B)|DI&*?3( zX@PkX?*%F@aE;r$B6f1u45g-ViBHbXFgr7D?2jEYqpsfa)wll6uWOcIEu*|@TJoVX z`AVnUME64Zm+Pk4da;XWMQ>A6;+2jsi929yK735z__?AVm#!97H=PVhaJ@T0lUGD6 zBWqXUb+-p=pOk>FUfX!7PR6DL;<7fTrx;a@s(T*DjJMRyCTvmZQsZl}Ns>2rnb z9KQ^AD+xcGHmTKRTH00>Z}4E2+4Vu%BbC5J@apfiLu-5M8D=ZotWVj@@PIc=ZE%^_6-sRtmHWplex^2QO zo7S?)Jj=e-+sS+0-sBy$M9I$GD&R!;dE2_p=|Kx_f3wnfqdT`KPS5_N#LYGJe^0U} zTb?WUEax=skRXTA%)WXlUjC`;p1zSkyTf|rz{j$LL<{Fu#Y`@TB`@Q|}6_IT(GU@}B;yl&>;_idnYha{^TzLVv6Z zv(`~E$knxdKmWY)DozcJxijmpRoiA;tX0gqFQ1~}JT*_?r~I1Rq4t;f-ptHZG~HAu zQS-1r$%`lVLZO9EY377Afu%J+v#&bLcj`K`@z}g?v1}_ROPY#GR^Qxwb>0ilg2N|n zRK*2Iq>R6)bKh59yJWM~hnDt56;FOS+^M#@bVOR`;m_D5pWM_wf8~3??N$|H#?8f} zQl&nLhE8FEst~YUO4S=QQ?hcLC5v8-n9f@ zzN2+SbeC2K@5+c%?-sTmI~Q}nH(Yzcw4FN^XK(kZnElMzYiVEUvo943(u#lP<;bG_hZMV6#Ut(R8`M@QD(IlrwNzTn?O zcVB+Y`+T!%mjcu#-qm8M)DTwljI zKEo=}V9FFxoxpos0h-3fKaIRKTLn6n>Is9cn!ND^dwc~9RN9YOc>dV*^6qf33?&2zj{d(Y{{O|L zH7COTXa0GuqJ)G~$tj2SpXOe###R@{Hyo7o+&<>!l+RDA-VEMOSrMy0p+IPbY%3>+ zgh&02p8?ymt~%aMAK-B2cw<}D+$9;5F1UTm#afYEgMNwbANuMJBJw zv#A>^|6q5-T=9!D;{O;q96PYv%zdz}F#p6N-%qp6W(AdSK3}CBJY(N-uX%G*?E1s3 z56A^nbOpcH3s=r-n16EIO?{2JT7z33TvPI{ru2PinQ}`snCWy7IvaM{EjF~*Sc6G<~#=BOD5>Z;tmi~7uyj6sC zKW>fM_0HH{LsUYfXl-rB&d5^_uX^4cU%Ye6Q71DWqiWxh_Ss)!|Gbq8Jh@KhIQQ7s zJURuZEwWZ{B-nSUfu^dUFb`U0g$wFU*l zAdQLAo=+M~rbfPgYQATD`iwNu!9zDUFOzUk9TT4OQ8&>lC%9HMWbV5SJ_(ZIi)X%g zcyRc_lz7>^g>U(Eq%8uXX30&=OE@5~qW|QvKG$>Fb$$zCqyuLN^!K|fO>7t09c%n> zf>ihs>+r_ESuL(^l6!AQ^r~4Y3*I`-QMO#Od0ei|GTRg9*)Kfb#vw54d;68|o3{1+ zJ{0V)l`h$7=XrhdCG(;du65abGt0M36pUYUzW2-Jpl~Po;@S!O?-2-3pO2LRMM0EQ^qw zy7nC3Gj-+OuAxnP$2NV~!E@O~hHqRz@1355@?7Clw`$wPziE>(ttzB@4$a z*x7uc|4^9vu{oZ~?_=+L=|6kCElMrSAhGlN={2RNL)B$@WdjnIr*;o7i9T54J3XcO z!WeI@ug~Q*dGn2vhLv~CIh}T_PTEyx&5MlK`XRMhueY2vFR9S;NOOLYzwT~)XGq1h zZVu;bUwWtT-7Hq-J-O}*r+iDe%_1Rjt%X;9nT3WlKKfQ5l(#kaihbby9?$YIjkUGw zIV|kQ{*5Y(+O)H!bf3u{g{=RQ^A3C29Owv$y82mJEYf%R1uwnOXOHy=iiHCD)S|T4E_%VW-Ra}U&O5p;#xf~i275IfuKPbt{{I^OfBAn=du8v` zC7Un5nU<8-TqEDOzU}pSh--m+$3@Zm9_GQ#>{2*G&>1kTGeE zpU&~UXMH+{?cX--`zl9#pFcfxcG^;{$NNokhk}X=V-`wJoL9JcX`SQ!x=)RYMUxY3 zHLMn~u^GFYTo4Ujc1=)y((X2`*IHX9_hsCPDQ>;6>B-t}OKnYGt{Hq&vd2>U&hPF1 zcS|2{%D&~~F=5u;GU0E{hxkgSXAVTHi?QhL^YK4-IOBwxjha5M*srAzZ_5=|f9?zr 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 e3bdd38ffb941d84133f0f4e193f68f7471e0900 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 begin work towards plugin store chunks 1 + 2 begin chunk 3 chunk 4 chunk 5 chunk 6 chunk 7 separate pixi from fizzy entirely Phase b1 b1b begin store_text_sidebar plan Phase 0 Phase 1-2 Phase 3-4 --- HANDOFF.md | 3 +- PIXI_EXTRACTION_PLAN.md | 252 + PLUGINS_PLAN.md | 529 + STORE_TEXT_SIDEBAR_PLAN.md | 724 ++ assets/.fizproject | 1 - assets/fizzy.atlas | 1 - assets/fizzy.png | Bin 5429 -> 0 bytes assets/palettes/apollo.hex | 46 - assets/palettes/downgraded-32.hex | 32 - assets/palettes/eighexplore.hex | 32 - assets/palettes/endesga-16.hex | 16 - assets/palettes/endesga-32.hex | 32 - assets/palettes/fizzy.hex | 10 - assets/palettes/journey.hex | 64 - assets/palettes/lospec500.hex | 42 - assets/palettes/pear36.hex | 36 - assets/palettes/pico-8.hex | 16 - assets/palettes/resurrect-64.hex | 64 - assets/palettes/sweetie-16.hex | 16 - assets/src/cursors.pixi | Bin 2409 -> 0 bytes assets/src/misc.pixi | Bin 1452 -> 0 bytes build.zig | 1732 +-- build.zig.zon | 24 +- build/app.zig | 527 + build/common.zig | 125 + build/exe.zig | 246 + build/markdown.zig | 34 + build/msvc.zig | 107 + build/package.zig | 261 + build/plugins.zig | 9 + build/sdk.zig | 38 + build/web.zig | 175 + docs/PLUGINS.md | 564 +- docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md | 299 - docs/PLUGIN_ROUGH_EDGES.md | 232 + plugin_sdk.zig | 314 + process_assets.zig | 158 - readme.md | 17 +- src/App.zig | 59 +- src/backend/plugin_store/compat.zig | 104 + src/backend/plugin_store/download.zig | 84 + src/backend/plugin_store/registry.zig | 131 + src/backend/plugin_store/store.zig | 99 + src/core/core.zig | 4 - src/core/dvui.zig | 33 + src/core/generated/atlas.zig | 74 - src/core/paths.zig | 46 +- src/editor/Editor.zig | 808 +- src/editor/InstalledPlugins.zig | 25 + src/editor/Menu.zig | 61 +- src/editor/PluginLoader.zig | 206 +- src/editor/PluginLoader_stub.zig | 15 +- src/editor/PluginStore.zig | 846 ++ src/editor/Settings.zig | 14 +- src/editor/Sidebar.zig | 63 +- 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/editor/readme.zig | 214 + src/fizzy.zig | 12 +- src/markdown/markdown.zig | 128 + src/markdown/md/cmark_headers.h | 14 + src/markdown/md/cmark_parse.zig | 114 + src/markdown/md/render_ast.zig | 909 ++ src/plugins/code/code.zig | 15 - src/plugins/code/dylib.zig | 40 - src/plugins/code/module.zig | 10 - src/plugins/code/src/Globals.zig | 32 - src/plugins/example/build.zig | 20 + 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/module.zig | 58 - src/plugins/pixelart/pixelart.zig | 55 - src/plugins/pixelart/src/Animation.zig | 145 - src/plugins/pixelart/src/Atlas.zig | 112 - src/plugins/pixelart/src/CanvasData.zig | 1271 -- src/plugins/pixelart/src/Colors.zig | 11 - src/plugins/pixelart/src/Docs.zig | 37 - src/plugins/pixelart/src/File.zig | 108 - src/plugins/pixelart/src/Globals.zig | 30 - src/plugins/pixelart/src/LDTKTileset.zig | 16 - src/plugins/pixelart/src/Layer.zig | 11 - src/plugins/pixelart/src/PackJob.zig | 738 -- src/plugins/pixelart/src/Packer.zig | 389 - src/plugins/pixelart/src/Project.zig | 117 - src/plugins/pixelart/src/Settings.zig | 212 - src/plugins/pixelart/src/Sprite.zig | 1 - src/plugins/pixelart/src/State.zig | 143 - src/plugins/pixelart/src/Tools.zig | 448 - src/plugins/pixelart/src/Transform.zig | 281 - .../pixelart/src/algorithms/algorithms.zig | 2 - .../pixelart/src/algorithms/brezenham.zig | 44 - .../pixelart/src/algorithms/reduce.zig | 452 - src/plugins/pixelart/src/clipboard.zig | 220 - .../src/deps/msf_gif/fizzy_msf_gif_wasm.c | 55 - .../pixelart/src/deps/msf_gif/msf_gif.c | 3 - .../pixelart/src/deps/msf_gif/msf_gif.h | 735 -- .../pixelart/src/deps/msf_gif/msf_gif.zig | 87 - .../src/deps/msf_gif/wasm_shim/string.h | 7 - .../pixelart/src/deps/stbi/fizzy_stbi_libc.c | 39 - .../src/deps/stbi/stb_image_resize2.h | 10651 ---------------- .../pixelart/src/deps/stbi/stb_rect_pack.h | 628 - src/plugins/pixelart/src/deps/stbi/zstbi.c | 34 - src/plugins/pixelart/src/deps/stbi/zstbi.zig | 95 - src/plugins/pixelart/src/deps/zip/build.zig | 41 - .../pixelart/src/deps/zip/fizzy_zip_libc.c | 65 - .../pixelart/src/deps/zip/fizzy_zip_strings.c | 34 - .../pixelart/src/deps/zip/fizzy_zip_wasm.h | 42 - src/plugins/pixelart/src/deps/zip/src/miniz.h | 10145 --------------- src/plugins/pixelart/src/deps/zip/src/zip.c | 1913 --- src/plugins/pixelart/src/deps/zip/src/zip.h | 477 - src/plugins/pixelart/src/deps/zip/zip.zig | 62 - src/plugins/pixelart/src/dialogs/Export.zig | 1040 -- .../src/dialogs/FlatRasterSaveWarning.zig | 171 - .../pixelart/src/dialogs/GridLayout.zig | 1762 --- src/plugins/pixelart/src/dialogs/NewFile.zig | 247 - .../pixelart/src/dialogs/dimensions_label.zig | 73 - src/plugins/pixelart/src/doc_bridge.zig | 93 - src/plugins/pixelart/src/doc_lifecycle.zig | 155 - src/plugins/pixelart/src/docs_registry.zig | 32 - src/plugins/pixelart/src/explorer/project.zig | 530 - src/plugins/pixelart/src/explorer/sprites.zig | 2499 ---- src/plugins/pixelart/src/explorer/tools.zig | 1649 --- src/plugins/pixelart/src/infobar_status.zig | 83 - .../pixelart/src/internal/Animation.zig | 140 - src/plugins/pixelart/src/internal/Atlas.zig | 110 - src/plugins/pixelart/src/internal/Buffers.zig | 133 - src/plugins/pixelart/src/internal/File.zig | 3860 ------ src/plugins/pixelart/src/internal/History.zig | 965 -- src/plugins/pixelart/src/internal/Layer.zig | 484 - src/plugins/pixelart/src/internal/Palette.zig | 52 - src/plugins/pixelart/src/internal/Sprite.zig | 1 - .../src/internal/grid_layout_validate.zig | 70 - .../pixelart/src/internal/layer_order.zig | 115 - .../pixelart/src/internal/palette_parse.zig | 112 - src/plugins/pixelart/src/keybind_ticks.zig | 82 - src/plugins/pixelart/src/pack_project.zig | 236 - src/plugins/pixelart/src/panel/sprites.zig | 1329 -- src/plugins/pixelart/src/plugin.zig | 685 - src/plugins/pixelart/src/radial_menu.zig | 238 - src/plugins/pixelart/src/render.zig | 918 -- src/plugins/pixelart/src/sprite_render.zig | 694 - src/plugins/pixelart/src/transform_op.zig | 123 - src/plugins/pixelart/src/web_file_io.zig | 30 - .../pixelart/src/widgets/CanvasBridge.zig | 24 - .../pixelart/src/widgets/FileWidget.zig | 6013 --------- .../pixelart/src/widgets/ImageWidget.zig | 478 - src/plugins/shared/build/helpers.zig | 93 + src/plugins/text/build.zig | 20 + src/plugins/text/build.zig.zon | 20 + src/plugins/{code => text}/queries/zig.scm | 0 src/plugins/text/root.zig | 7 + src/plugins/{code => text}/src/CodeEditor.zig | 2 +- src/plugins/{code => text}/src/Document.zig | 14 +- src/plugins/{code => text}/src/State.zig | 8 +- .../{code => text}/src/SyntaxHighlight.zig | 4 +- src/plugins/{code => text}/src/plugin.zig | 80 +- .../src/widgets/TextEntryWidget.zig | 4 +- .../src/widgets/TreeSitterQueryPredicates.zig | 4 +- src/plugins/text/static/integration.zig | 59 + src/plugins/text/text.zig | 22 + 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 | 369 +- src/plugins/workbench/src/files.zig | 139 +- 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 | 20 +- src/sdk/EditorAPI.zig | 68 +- src/sdk/Host.zig | 411 +- src/sdk/Plugin.zig | 225 +- src/sdk/document.zig | 47 + src/sdk/dylib.zig | 282 +- 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 | 43 +- src/sdk/services/workbench.zig | 78 + src/sdk/version.zig | 82 + src/web_main.zig | 26 - tests/fizzy_shim.zig | 15 +- tests/integration.zig | 50 +- tests/plugin_loader_integration.zig | 33 - tests/root.zig | 6 +- 202 files changed, 10774 insertions(+), 59319 deletions(-) create mode 100644 PIXI_EXTRACTION_PLAN.md create mode 100644 PLUGINS_PLAN.md create mode 100644 STORE_TEXT_SIDEBAR_PLAN.md delete mode 100644 assets/.fizproject delete mode 100644 assets/fizzy.atlas delete mode 100644 assets/fizzy.png delete mode 100644 assets/palettes/apollo.hex delete mode 100644 assets/palettes/downgraded-32.hex delete mode 100644 assets/palettes/eighexplore.hex delete mode 100644 assets/palettes/endesga-16.hex delete mode 100644 assets/palettes/endesga-32.hex delete mode 100644 assets/palettes/fizzy.hex delete mode 100644 assets/palettes/journey.hex delete mode 100644 assets/palettes/lospec500.hex delete mode 100644 assets/palettes/pear36.hex delete mode 100644 assets/palettes/pico-8.hex delete mode 100644 assets/palettes/resurrect-64.hex delete mode 100644 assets/palettes/sweetie-16.hex delete mode 100644 assets/src/cursors.pixi delete mode 100644 assets/src/misc.pixi create mode 100644 build/app.zig create mode 100644 build/common.zig create mode 100644 build/exe.zig create mode 100644 build/markdown.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 delete mode 100644 process_assets.zig create mode 100644 src/backend/plugin_store/compat.zig create mode 100644 src/backend/plugin_store/download.zig create mode 100644 src/backend/plugin_store/registry.zig create mode 100644 src/backend/plugin_store/store.zig delete mode 100644 src/core/generated/atlas.zig create mode 100644 src/editor/InstalledPlugins.zig create mode 100644 src/editor/PluginStore.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/editor/readme.zig create mode 100644 src/markdown/markdown.zig create mode 100644 src/markdown/md/cmark_headers.h create mode 100644 src/markdown/md/cmark_parse.zig create mode 100644 src/markdown/md/render_ast.zig delete mode 100644 src/plugins/code/code.zig delete mode 100644 src/plugins/code/dylib.zig delete mode 100644 src/plugins/code/module.zig delete mode 100644 src/plugins/code/src/Globals.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/module.zig delete mode 100644 src/plugins/pixelart/pixelart.zig delete mode 100644 src/plugins/pixelart/src/Animation.zig delete mode 100644 src/plugins/pixelart/src/Atlas.zig delete mode 100644 src/plugins/pixelart/src/CanvasData.zig delete mode 100644 src/plugins/pixelart/src/Colors.zig delete mode 100644 src/plugins/pixelart/src/Docs.zig delete mode 100644 src/plugins/pixelart/src/File.zig delete mode 100644 src/plugins/pixelart/src/Globals.zig delete mode 100644 src/plugins/pixelart/src/LDTKTileset.zig delete mode 100644 src/plugins/pixelart/src/Layer.zig delete mode 100644 src/plugins/pixelart/src/PackJob.zig delete mode 100644 src/plugins/pixelart/src/Packer.zig delete mode 100644 src/plugins/pixelart/src/Project.zig delete mode 100644 src/plugins/pixelart/src/Settings.zig delete mode 100644 src/plugins/pixelart/src/Sprite.zig delete mode 100644 src/plugins/pixelart/src/State.zig delete mode 100644 src/plugins/pixelart/src/Tools.zig delete mode 100644 src/plugins/pixelart/src/Transform.zig delete mode 100644 src/plugins/pixelart/src/algorithms/algorithms.zig delete mode 100644 src/plugins/pixelart/src/algorithms/brezenham.zig delete mode 100644 src/plugins/pixelart/src/algorithms/reduce.zig delete mode 100644 src/plugins/pixelart/src/clipboard.zig delete mode 100644 src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c delete mode 100644 src/plugins/pixelart/src/deps/msf_gif/msf_gif.c delete mode 100644 src/plugins/pixelart/src/deps/msf_gif/msf_gif.h delete mode 100644 src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig delete mode 100644 src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h delete mode 100644 src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c delete mode 100644 src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h delete mode 100644 src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h delete mode 100644 src/plugins/pixelart/src/deps/stbi/zstbi.c delete mode 100644 src/plugins/pixelart/src/deps/stbi/zstbi.zig delete mode 100644 src/plugins/pixelart/src/deps/zip/build.zig delete mode 100644 src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c delete mode 100644 src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c delete mode 100644 src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h delete mode 100644 src/plugins/pixelart/src/deps/zip/src/miniz.h delete mode 100644 src/plugins/pixelart/src/deps/zip/src/zip.c delete mode 100644 src/plugins/pixelart/src/deps/zip/src/zip.h delete mode 100644 src/plugins/pixelart/src/deps/zip/zip.zig delete mode 100644 src/plugins/pixelart/src/dialogs/Export.zig delete mode 100644 src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig delete mode 100644 src/plugins/pixelart/src/dialogs/GridLayout.zig delete mode 100644 src/plugins/pixelart/src/dialogs/NewFile.zig delete mode 100644 src/plugins/pixelart/src/dialogs/dimensions_label.zig delete mode 100644 src/plugins/pixelart/src/doc_bridge.zig delete mode 100644 src/plugins/pixelart/src/doc_lifecycle.zig delete mode 100644 src/plugins/pixelart/src/docs_registry.zig delete mode 100644 src/plugins/pixelart/src/explorer/project.zig delete mode 100644 src/plugins/pixelart/src/explorer/sprites.zig delete mode 100644 src/plugins/pixelart/src/explorer/tools.zig delete mode 100644 src/plugins/pixelart/src/infobar_status.zig delete mode 100644 src/plugins/pixelart/src/internal/Animation.zig delete mode 100644 src/plugins/pixelart/src/internal/Atlas.zig delete mode 100644 src/plugins/pixelart/src/internal/Buffers.zig delete mode 100644 src/plugins/pixelart/src/internal/File.zig delete mode 100644 src/plugins/pixelart/src/internal/History.zig delete mode 100644 src/plugins/pixelart/src/internal/Layer.zig delete mode 100644 src/plugins/pixelart/src/internal/Palette.zig delete mode 100644 src/plugins/pixelart/src/internal/Sprite.zig delete mode 100644 src/plugins/pixelart/src/internal/grid_layout_validate.zig delete mode 100644 src/plugins/pixelart/src/internal/layer_order.zig delete mode 100644 src/plugins/pixelart/src/internal/palette_parse.zig delete mode 100644 src/plugins/pixelart/src/keybind_ticks.zig delete mode 100644 src/plugins/pixelart/src/pack_project.zig delete mode 100644 src/plugins/pixelart/src/panel/sprites.zig delete mode 100644 src/plugins/pixelart/src/plugin.zig delete mode 100644 src/plugins/pixelart/src/radial_menu.zig delete mode 100644 src/plugins/pixelart/src/render.zig delete mode 100644 src/plugins/pixelart/src/sprite_render.zig delete mode 100644 src/plugins/pixelart/src/transform_op.zig delete mode 100644 src/plugins/pixelart/src/web_file_io.zig delete mode 100644 src/plugins/pixelart/src/widgets/CanvasBridge.zig delete mode 100644 src/plugins/pixelart/src/widgets/FileWidget.zig delete mode 100644 src/plugins/pixelart/src/widgets/ImageWidget.zig create mode 100644 src/plugins/shared/build/helpers.zig create mode 100644 src/plugins/text/build.zig create mode 100644 src/plugins/text/build.zig.zon rename src/plugins/{code => text}/queries/zig.scm (100%) create mode 100644 src/plugins/text/root.zig rename src/plugins/{code => text}/src/CodeEditor.zig (99%) rename src/plugins/{code => text}/src/Document.zig (91%) rename src/plugins/{code => text}/src/State.zig (71%) rename src/plugins/{code => text}/src/SyntaxHighlight.zig (98%) rename src/plugins/{code => text}/src/plugin.zig (72%) rename src/plugins/{code => text}/src/widgets/TextEntryWidget.zig (99%) rename src/plugins/{code => text}/src/widgets/TreeSitterQueryPredicates.zig (98%) create mode 100644 src/plugins/text/static/integration.zig create mode 100644 src/plugins/text/text.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 delete mode 100644 tests/plugin_loader_integration.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/PIXI_EXTRACTION_PLAN.md b/PIXI_EXTRACTION_PLAN.md new file mode 100644 index 00000000..0e2fed00 --- /dev/null +++ b/PIXI_EXTRACTION_PLAN.md @@ -0,0 +1,252 @@ +# Pixi Extraction & Shell Genericization — Plan & Agent Handbook + +> Living doc for extracting the pixel-art editor (`pixi`) out of the Fizzy repo into a +> store-installable external plugin, stripping all pixi-specific functionality from the shell, +> and reducing the bundled built-ins to **workbench + example + text** (text = renamed `code`). +> Update the Status boxes as chunks land. Companion to `PLUGINS_PLAN.md` (the store). + +## 0. Context & end state + +Today the shell is hard-coupled to pixi: it holds `editor.pixi_state: *pixi.State`, `@import("pixi")`, +a special packer injection, and the build system embeds pixi's C deps. The shell also owns a +spritesheet (`fizzy.atlas`) served via `host.uiAtlas()`, used by pixi (tool cursors) **and** +workbench (the fizzy logo). File-type icons are a hardcoded `switch (ext)` in workbench that knows +pixel-art + code extensions. + +**End state:** pixi is an external repo (`~/dev/fizzyedit/pixi`), installed via the store like +markdown. The shell bundles only **workbench, example, text**, has **no** `uiAtlas`/sprite concept, +and no built-in knows about another's file types. `core/` (Atlas/Sprite/math/gfx/fs/paths) stays as +shared infra — external plugins use it via the `fizzy` SDK dep. + +### Confirmed decisions +1. **Remove `host.uiAtlas()` from the SDK entirely.** pixi ships its own tool atlas; the logo moves + into workbench's own assets; `editor.atlas` is deleted. File icons become a **plugin draw hook** + (the owning plugin draws the icon for the file types it claims; workbench draws a generic default). +2. **Decouple in place, then extract.** Strip every shell↔pixi coupling while pixi still lives in + `src/plugins/pixi` (trivially testable), then physically move it. +3. **`code` → `text`** — keep built-in, generic text editor. + +--- + +## 1. Work breakdown + +`[ ]` todo · `[~]` in progress · `[x]` done · **Dep:** prerequisite chunks + +### Phase A — decouple in place (pixi stays in `src/plugins/pixi`, gains zero shell refs) + +#### A1 — Genericize the homepage · Status: [x] +Removed the pixel-art "New File" button from `workbench/src/Workspace.zig` `drawHomePage` (it +dispatched `requestNewDocument` → pixi). New File still reachable via menu + file-tree. Builds green. +Future: a hook for plugins to contribute homepage entries / their own homepage (not now). + +#### A2 — File-type icon draw hook · Status: [x] **Dep:** none +> **Done.** Added a `Host.FileIcon` **draw** resolver (mirrors `FileRowFillColor`): a registry of +> `draw(ctx, ext, path, color) bool` drawers; `host.drawFileIcon` calls them in order, first +> `true` wins. Owner-scoped, so `unregisterPlugin` drops it (test extended). +> - `workbench/src/files.zig`: the per-extension icon `switch` + the `.fizzy`→`uiAtlas` logo branch +> are gone; it calls `host.drawFileIcon(...)` and only falls back to generic filesystem icons +> (`.pdf`→doc-text, archives→archive, else→archive) when no plugin handled it. One `uiAtlas` +> consumer removed. +> - `pixi/src/plugin.zig`: `registerFileIcon` → draws `entypo.brush` for `.fiz/.pixi`, +> `entypo.image` for flat images (reusing `Internal.File.isFizzyExtension`/`isFlatImageExtension`); +> defers otherwise. (`.fiz` still a glyph for now; A3 can swap it to pixi's own logo sprite.) +> - `code/src/plugin.zig`: `registerFileIcon` → draws `entypo.code` for a source/text extension set; +> defers binaries to the workbench default. Glyphs come from `dvui.entypo.*` (always wired) — no +> `icons` dep needed. +> - `openablePath` was already plugin-driven (`host.pluginForExtension`); the leftover `.fiz/.png/…` +> arms in workbench's `extension()`→`Extension` enum are now vestigial (icon switch falls them to +> `else`) — harmless, optional cleanup. +> - **SDK boundary change:** bumped `sdk_version` 0.5.0 → **0.6.0**, `recorded_abi_fingerprint = +> 0xd6a131bddefe4fe1`. (markdown will need a rebuild against ≥0.6.0.) +> +> **Accept:** ✅ build + `test` + `test-sdk-version` green; no plugin-specific icon literals left in +> workbench. (Visual confirmation of the rendered tree is a GUI check for the user.) + +#### A3 — Split the atlas + delete `uiAtlas` · Status: [x] **Dep:** A2 (icons no longer via atlas) +> **Done.** Simpler than feared: workbench used `uiAtlas` only once (the `logo_default` tab icon +> for `.fiz` files), and `fox_default` was unused — so workbench ends up needing **no atlas**. +> - **pixi** self-loads its tool/cursor spritesheet into `State.ui_atlas: core.Atlas` (from the +> `assets` module it already depends on, via `core.Atlas.loadSpritesFromBytes` + +> `core.image.fromImageFileBytes`); new `runtime.uiAtlas()` accessor; all ~28 +> `runtime.state().host.uiAtlas()` call sites in `Tools/radial_menu/explorer.tools/FileWidget` +> now read pixi's own atlas (which is `core.Sprite`-native — pixi already uses `core_sprite`). +> - **workbench**: the `is_fizzy_file`→logo tab icon now routes through the A2 `host.drawFileIcon` +> hook (pixi draws its `.fiz` glyph), with a generic file-glyph fallback; removed the +> `documentHasNativeExtension` coupling + `wb.atlas`/`wb.Sprite` use. `.fiz` files now show +> pixi's brush glyph in tree + tabs (consistent; the logo sprite is no longer used). +> - **Deleted**: `EditorAPI.{UiSprite,UiAtlasView,uiAtlas}` + the vtable field, `Host.uiAtlas`, +> `sdk.{UiSprite,UiAtlasView}`, `shellUiAtlas`, and `editor.atlas` (field/load/deinit). The shell +> no longer carries a sprite atlas. +> - **SDK boundary change:** `sdk_version` 0.6.0 → **0.7.0**, `recorded_abi_fingerprint = +> 0x1bb54eb7506cbd78`. +> +> **Accept:** ✅ `grep uiAtlas src/` finds only pixi's own `runtime.uiAtlas()`; build + `test` + +> `test-sdk-version` green. (`fizzy.atlas`/`fizzy.png` + `core/generated/atlas.zig` still live in +> the shared assets/core for now; they move into pixi in **B1**. Tool/cursor + tab/tree icon +> rendering is a GUI check for the user.) +- **pixi** ships its own tool spritesheet under `src/plugins/pixi/assets/` and loads/draws tools from + it (it already uses `core.Atlas`/`Sprite`); replace every `runtime.state().host.uiAtlas()` in + `pixi/src/{Tools,radial_menu,explorer/tools}.zig` with pixi's own atlas. +- **workbench** moves the fizzy **logo** into its own bundled asset + loads it directly; replace the + `host.uiAtlas()` logo read in `Workspace.zig:~261`. +- Delete `EditorAPI.{uiAtlas,UiAtlasView,UiSprite}`, `Host.uiAtlas`, `sdk.{UiSprite,UiAtlasView}`, + the `EditorAPI.VTable.uiAtlas` field + `shellUiAtlas`, and `editor.atlas` (field, load at + `Editor.zig:266`, deinit at `:3257`). **SDK boundary change → bump `sdk_version` + + `recorded_abi_fingerprint`** (and update markdown's pin note). **Accept:** `grep uiAtlas` is empty + in `src/`; `test-sdk-version` green; tools + logo render from their own assets. + +#### A4 — Remove shell↔pixi concrete coupling · Status: [x] **Dep:** A3 +> **Done.** Pixi is now a normal generic-dylib plugin that owns its own state — the shell has zero +> pixi *source* coupling (only the bundled id string `"pixi"` remains, removed in B1). +> - **pixi** owns `State` + `Packer` as plugin-image statics, created in `register` (`State.init` +> + `Packer.init`, both context-free → safe before dvui injection; texture upload is lazy) and +> torn down in `pluginDeinit` (which also persists the `.fizproject`). `runtime.adoptState` +> replaces `adoptShellState`; `setPacker` removed. +> - **Deleted from the shell:** `Editor.{pixi_state, @import("pixi"), pixiPlugin, loadPixiDylib, +> loadPixiFromDylibEnabled, syncLoadedPixiGlobals}`, `fizzy.{pixi_mod, packer}`, and all of +> `App.zig`'s pixi allocation/`adoptShellState`/`setPacker`/`persistProject`/teardown. Pixi loads +> via a new generic `Editor.loadBundledPluginDylib(id)` (`arg_b=*Host, arg_c=null`), **no static +> fallback** (so web, which can't dlopen, simply has no pixi — consistent with extraction). +> - `FIZZY_STATIC_PIXI` path removed; `"pixi.menu.edit"` → `"shell.menu.edit"`. +> - **No SDK boundary change** (these are shell internals) → fingerprint unchanged (0.7.0). +> +> **Accept:** ✅ `grep` finds no `@import("pixi")`/`pixi.State`/`pixi_state`/`fizzy.packer` in the +> shell; build + `test` + `test-sdk-version` (which builds the pixi dylib) all green. +> +> **Shutdown-crash fix (A4 regression, found in GUI smoke test):** moving pixi's `State` free into +> `pluginDeinit` exposed a shell ordering bug — `Editor.deinit` ran the plugin-`deinit` loop +> *before* `workbench.deinitWorkspaces()`, which calls back into the owner via `removeCanvasPane` +> → use-after-free of the just-freed pixi state (`0xaa…`). Fixed by tearing down workspaces +> **before** the plugin-deinit loop (don't invoke plugin hooks after a plugin's `deinit`); removed +> the stale "App.AppDeinit frees pixi state" comment. Verified: clean `NSRunningApplication.terminate()` +> quit, no segfault, no leak reports. +> +> **Deferred (small, non-blocking):** `Editor.save_as_dialog_filters` still hardcodes +> `"fiz;pixi"`/png/jpg. Genericizing needs an owner-supplied-filters vtable hook (an SDK change); +> tracked as a follow-up — it doesn't block extraction (the dialog works as-is). Runtime GUI check +> (pixi loads as a dylib, tools/canvas render, `.fiz` open/save) is for the user. +Make pixi a normal generic-dylib plugin (owns its own state, like `example`/`markdown`): +- Pixi owns its `State` + `Packer` internally (module-level, set in `register`) instead of the shell + allocating them. Remove the special `arg_c = *Packer` / `arg_b = *State` injection → load via the + generic convention (`arg_b = *Host`, `arg_c = null`). +- Delete from `Editor.zig`/`App.zig`: `pixi_state` field, `@import("pixi")`, `loadPixiDylib`, + `loadPixiFromDylibEnabled`, `syncLoadedPixiGlobals`, `pixiPlugin()`, the `FIZZY_STATIC_PIXI` path, + and the `"pixi"` arm of `isBundledPluginId`/`registerMenu("pixi.menu.edit")`/save-dialog filters + that name `.fiz/.pixi`. The Edit-menu + New-File dialog dispatch already route by active-owner / + `requestNewDocument`, so they stay generic. +- Pixi loads through the same path as any user plugin (bundled dylib in `{exe}/plugins/pixi.` + during this phase). **Accept:** `grep -i pixi src/editor src/App.zig` finds nothing; app runs with + pixi loaded as a plain dylib; open/edit/save a `.fizzy` doc works. + +#### A5 — Verify fully decoupled · Status: [ ] **Dep:** A4 +`grep -rin pixi src/editor src/sdk src/App.zig` ⇒ empty. App builds + runs with pixi bundled via the +generic dylib path; `zig build test` + `test-sdk-version` green. Pixi is now a drop-in external +candidate. + +### Phase B — extract & publish + +#### B1 — Move pixi to `~/dev/fizzyedit/pixi` · Status: [x] **Dep:** A5 +> **B1a done (external pixi builds).** Copied the pixi tree + its assets (`fizzy.atlas`, +> `fizzy.png`, `assets/src/*.pixi`, `palettes/`, `.fizproject`) + the generated sprite table +> (`core/generated/atlas.zig` → `pixi/src/generated/atlas.zig`) into `~/dev/fizzyedit/pixi`. +> Adjusted: `pixi.zig` `atlas` now imports its own `src/generated/atlas.zig` (not `core.atlas`); +> `build.zig` packs its own `assets/`; `build.zig.zon` pins `fizzy` at `../../fizzy`; dropped the +> fizzy-internal `static/`. **`zig build` produces `pixi.dylib`, probed fingerprint +> `0x1bb54eb7506cbd78` = fizzy 0.7.0, id `pixi`** — a valid store plugin. fizzy untouched + still +> green. +> +> **B1b done (fizzy's build stripped of pixi).** Focused pass; `zig build` + `test` + +> `test-sdk-version` + `check-web` all green with only workbench/example/code bundled. +> 1. ✅ Deleted `src/plugins/pixi/` (it owned the vendored `zstbi`/`msf_gif`/`zip` C deps). +> 2. ✅ `build/plugins.zig`: dropped `pixi` import + `ZipPackage`. +> 3. ✅ `build/exe.zig`: removed `pixi_plugin`, the `zstbi`/`msf_gif`/`zip` exe imports + +> `addStaticModule(pixi)` + `pixi_dylib` + `linkZipNative` + the +> `zstbi_module`/`msf_gif_module`/`pixi_dylib`/`zip_pkg`/`process_assets_step`/`sdk_module` +> fields/params on `FizzyExecutable` + `addFizzyExecutableForTarget`. +> 4. ✅ `build/web.zig`: removed `pixi_plugin` + `zip`/`zstbi`/`msf_gif` wiring + `zip_pkg` param. +> 5. ✅ `build/app.zig`: removed `pixi_plugin`, `static-pixi` option, `zip_pkg`, the bundled-pixi +> installs, the `pixi-dylib` step; **dropped** `test-plugin-loader` (+ `tests/plugin_loader_integration.zig`, +> a pixi-specific dlopen test — belongs in pixi's repo now); removed the pixi pure-logic test +> entries; repointed `test-sdk-version` at the **workbench** dylib; removed `linkZipNative` and the +> **`process-assets` step**. +> 6. ✅ `tests/root.zig`: dropped the pixi pure-logic `@import`s. +> 7. ✅ `src/core/core.zig`: removed `pub const atlas` re-export; deleted `src/core/generated/`. +> Removed the dead `atlas`/`Sprite` re-exports in `workbench.zig` + the `atlas` one in `fizzy.zig` +> (`core.Atlas`/`core.Sprite` generic types stay — shared infra). Stripped pixi probes from +> `web_main.zig`. +> 8. ✅ fizzy `assets/`: deleted `fizzy.atlas`, `fizzy.png`, `assets/src/*.pixi`, `.fizproject`, and +> `assets/palettes/` (kept fonts/icon/fox/macos/windows). +> 9. ✅ `Editor`: removed the `loadBundledPluginDylib` helper + its `"pixi"` call; dropped the `"pixi"` +> arm from both `Editor.isBundledPluginId` **and** `PluginStore.isBundled` (so the store can manage +> pixi); deleted `process_assets.zig` (+ its `build.zig`/`build.zig.zon` references). +> 10. ✅ Verified green. +> +> **Deferred (unchanged from A4):** shell save/open dialog filters still hardcode `.fiz`/`.pixi`/png/jpg +> (`Editor.zig`, `Keybinds.zig`, `WebFileIo.zig`, `file_assoc.zig`) — needs the owner-supplied-filters +> vtable hook; doesn't block extraction. `Settings.zig`'s legacy-migration `"pixi"` key kept for +> backwards-compat. `PluginLoader.zig`'s path-format unit test still uses `"pixi"` as a sample string +> (pure string test, no dep). +- Move `src/plugins/pixi/*` → the external repo (canonical third-party shape, like markdown). Its + `build.zig` owns the C deps (`zstbi`, `msf_gif`, `zip`) + its atlas asset; pin `fizzy` to a git + url+hash. +- Remove pixi from `build/{plugins,exe,app,web}.zig`: the static integration import, `pixi_dylib`, + `static-pixi` option, bundled-plugin install, `pixi-dylib`/`test-plugin-loader` steps (repoint the + loader test at `example`), and the pure-logic test entries (`fizzy-layer-order`, `…palette-parse`, + `…reduce`, `…grid-validate`, `…animation`) — those move to pixi's own tests. +- **Accept:** fizzy builds with only workbench/example/text; `grep pixi build/ src/` empty. + +#### B2 — Publish pixi to the store · Status: [ ] **Dep:** B1, store live +Registry entry in `fizzyedit/plugins` + author `manifest.json` + `release.yml` (mirror markdown, +Chunk 7 of `PLUGINS_PLAN.md`). Pixi installs from the store on a matching SDK fingerprint. + +### Phase C — text editor (independent, can run any time) + +#### C1 — Rename `code` → `text`, generic built-in · Status: [ ] **Dep:** none +Rename `src/plugins/code` → `src/plugins/text`, module `code`→`text`, id `"code"`→`"text"`, settings +key, `FIZZY_STATIC_CODE`→`…TEXT`, build wiring in `build/{plugins,exe,app,web}.zig` + `Editor.zig` +(`loadCode*`, `codePlugin`, `isBundledPluginId`). Drop the stray pixi comment in `code/src/plugin.zig`. +Keep it the fallback editor (claims every extension at `file_type_fallback_priority`). **Accept:** +text editing works; `grep -i \\bcode\\b` shows no leftover plugin-id references; built-ins = +workbench/example/text. + +--- + +## 1b. Plugin install = `zig build install` + shared config-path resolver +`fizzy.plugin.install(b, lib, .{})` (build-side only — **no fingerprint bump**) now does both: +emits `zig-out/.` for packaging/store-CI **and** copies `.` into the fizzy +plugins dir the editor scans. Skips silently when no config home resolves (bare CI runner). One +call; `zig build install` is the canonical "test a plugin" command. + +**Single source of truth for the location:** added `core.paths.localConfigRoot(os, …)` (pure +per-OS rule: macOS `~/Library/Application Support`, Linux `$XDG_CONFIG_HOME`/`~/.config`, Windows +`%LOCALAPPDATA%`). Both the runtime loader (`paths.configRoot`) and the build installer +(`plugin_sdk.fizzyPluginsDir`) call it, so install + load can't drift. **Dropped the +`known-folders` dependency entirely** (was used only by `paths.zig`); removed its wiring from +`build.zig.zon`, `exe.zig`, `app.zig`, `web.zig`, `plugin_sdk.zig`. Docs updated. (pixi + markdown +currently installed in the dev plugins dir, ready for the GUI smoke test.) + +## 2. Risks & notes +- **A3 + A4 bump the ABI fingerprint** (SDK surface + boundary). Bundled built-ins rebuild; markdown + needs a rebuild against the new SDK before its store entry matches again. +- The shell allocates pixi's `State`/`Packer` in `App.zig` today — A4 must move that ownership into + pixi without breaking the save-queue worker (`pixi/src/internal/File.zig` `SaveQueue`). +- Keep `core/` in the editor — it's shared by workbench/text/shell and reached by external plugins + via the SDK dep. Do **not** move it with pixi. +- Save/open dialogs in the shell currently hardcode image filters (`fizzy;png;jpg;jpeg`) — generalize + or let the active plugin supply filters during A4. + +## 3. Verification per phase +- After each chunk: `zig build`, `zig build test`, `zig build test-sdk-version`. +- A5 / B1: run the app, open + edit + save a `.fizzy` doc (pixi as dylib), confirm tools/cursors/logo + render and the file tree shows icons. +- B2: store install of pixi via the local registry server (PLUGINS_PLAN §8 pattern). + +## 4. Status +- [x] A1 — Genericize homepage +- [x] A2 — File-type icon draw hook *(Host.FileIcon resolver; pixi/code draw their own; SDK 0.6.0, fp 0xd6a131bddefe4fe1)* +- [x] A3 — Split atlas + delete uiAtlas *(pixi self-loads its atlas; shell + SDK lose uiAtlas; SDK 0.7.0, fp 0x1bb54eb7506cbd78)* +- [x] A4 — Remove shell↔pixi coupling *(pixi owns State+Packer; generic dylib load; no @import("pixi") in shell)* +- [ ] A5 — Verify decoupled *(mostly done inline in A4; save-filter follow-up + GUI check remain)* +- [x] B1 — Move pixi to external repo *(B1a: external pixi builds + ABI-verified; B1b: fizzy build stripped — only workbench/example/code bundled; build/test/test-sdk-version/check-web green)* +- [ ] B2 — Publish pixi to store +- [ ] C1 — Rename code → text diff --git a/PLUGINS_PLAN.md b/PLUGINS_PLAN.md new file mode 100644 index 00000000..8d28b73b --- /dev/null +++ b/PLUGINS_PLAN.md @@ -0,0 +1,529 @@ +# Plugin Store — Implementation Plan & Agent Handbook + +> Living document for building Fizzy's **Plugin Store**. Self-contained: a cold agent should be +> able to read the relevant sections and execute a chunk without prior context. Update the +> **Status** checkboxes as chunks land. + +--- + +## 0. Orientation — what Fizzy is (read first) + +Fizzy is a **shell-first native editor** (Zig + [dvui]). The shell (`src/editor/`) owns only a +window, frame loop, menu/sidebar/panel layout, and a document model. **Every feature is a plugin** +that registers against a stable SDK (`src/sdk/`). The pixel-art editor (`pixi`), file explorer + +tabs/splits (`workbench`), and text editor (`code`) are all plugins. + +The same plugin source compiles **two ways**: statically into the app, or as a runtime native +**dylib** loaded via `dlopen`. Built-ins ship inside the signed app; third-party plugins ship as +loadable dylibs. + +### Key SDK types (`src/sdk/`) + +| Type | File | Role | +|------|------|------| +| `Host` | `Host.zig` | What the shell hands every plugin. Holds the **registries** (sidebar/bottom/center/menu/settings/commands) + a **service locator** + `register*` methods. Embedded in `Editor`. | +| `Plugin` | `Plugin.zig` | A plugin's identity (`id`, `display_name`) + a **vtable** of ~30 optional hooks. | +| `DocHandle` | `DocHandle.zig` | Opaque open-document handle `{ ptr, id, owner: *Plugin }`. The shell routes *every* doc op to `owner.(doc)` — it never inspects `ptr`. | +| `EditorAPI` | `EditorAPI.zig` | The shell's read/utility surface plugins reach back through (arena, folder, docs, dialogs). Reached via `Host`. | +| `regions` | `regions.zig` | Contribution structs: `SidebarView`, `BottomView`, `CenterProvider`, `MenuContribution`, `SettingsSection`, `Command`. | +| `dylib` / `dvui_context` / `render_bridge` | `dylib.zig`, … | The C-ABI entry contract + dvui/render injection used for runtime dylibs. | + +**The shell owns no features.** Each frame it iterates the `Host` registries and draws whatever +plugins contributed. Adding a pane/panel/menu/settings section is a `Host.register*` call from a +plugin's `register(host)` — never a shell edit. + +Authoritative reference: **`docs/PLUGINS.md`** (full architecture). Don't duplicate it — read it. + +### How a runtime plugin loads (today) + +`src/editor/PluginLoader.zig` → `loadAndRegister(host, path, expected_id, pre)`: +1. `dlopen` the dylib (`std.DynLib`; Windows wrapper included). +2. Read `fizzy_plugin_abi_fingerprint` — **hard reject** if it ≠ host's (`error.AbiMismatch`). +3. Read `min_sdk_version`, `plugin_version`, `plugin_id` C exports; check SDK version + id==filename. +4. Inject globals (allocator + `*Host`), then call `fizzy_plugin_register(host)`. +5. Caller syncs dvui context + render bridge, appends to `editor.loaded_plugin_libs`. + +Discovery: `Editor.loadUserPlugins(config_folder)` scans `{config}/plugins/*.{dylib|so|dll}`, where +each file's basename = its plugin id. Failures recorded in `editor.failed_user_plugins`. + +Install path layout (flat): `{config}/plugins/{id}.{ext}` — +macOS `~/Library/Application Support/fizzy/plugins/`, Linux `~/.config/fizzy/plugins/`, +Windows `%APPDATA%/fizzy/plugins/`. + +### The three versions (don't conflate) + +| Version | Owner | Gates plugin load? | +|---------|-------|--------------------| +| **App version** (`VERSION`, `build.zig.zon`) | Fizzy release | No | +| **SDK version** (`src/sdk/version.zig` `sdk_version`) | ABI contract | Soft (min_sdk_version) | +| **Plugin version** (`PluginManifest.version`) | plugin author | No | + +The **hard** gate is the **ABI fingerprint** (`src/sdk/dylib.zig` `abi_fingerprint`, +`src/sdk/fingerprint.zig`): a comptime structural hash over every boundary type. No semver +tolerance. A plugin binary is valid for exactly one `(zig version, dvui version, SDK contract)` +tuple. This is central to the whole distribution design — see §A below. + +--- + +## 1. Goal + +Add a **Plugin Store**: a sidebar icon above Settings opening an explorer tab of plugin **cards** +(available + installed, with versions + active state), with **live install / update / enable / +disable / unload** in-session, backed by a **decentralized** registry where authors host their own +binaries. + +### Confirmed decisions + +1. **Store is a shell built-in** (`src/editor/`, like `InstalledPlugins.zig`) — needs shell + internals not exposed to sandboxed plugins. Presents *as* a plugin (own icon + tab). +2. **Decentralized distribution, no child-CI triggering.** Authors build + host their own binaries; + the central `fizzyedit/plugins` repo is registration + index **aggregation** only. +3. **Full live load + unload now** — implement `Host.unregisterPlugin`. +4. **SDK hardening = cadence + pinning only** (no boundary refactor this round). + +--- + +## A. Resilience model (why fingerprint cadence matters) + +The fingerprint is a hard gate, and the dvui/zig coupling is **inherent**: a plugin links its own +dvui and operates on host-injected dvui globals (`dvui_context.zig` sets +`dvui.current_window = host_ptr`), so host + plugin must share the same dvui + compiler. We +**cannot** make a prebuilt native dylib survive a dvui/zig change. The lever is making fingerprint +bumps **rare and deliberate** so plugins rebuild only on intentional SDK bumps, not every release: + +- **Pin dvui + zig** to explicit versions; bump deliberately/batched. (Biggest lever — tracking + dvui-dev tip flips the fingerprint constantly.) +- **Decouple app-version from SDK-version**: a Fizzy release that doesn't touch zig/dvui/boundary + keeps the **same fingerprint**, so installed plugins stay valid across many app releases. +- The fingerprint stays the store's match key. Authors rebuild when the SDK version is announced to + bump; the store shows "needs a rebuild for Fizzy SDK x.y" otherwise. + +> Deferred (not this plan): freezing the dvui/zig value surface behind Fizzy-owned POD types. + +--- + +## 2. Work breakdown (chunks) + +Each chunk is sized for one agent, lists files, dependencies, and acceptance criteria. **Suggested +order top-to-bottom**, but parallelizable where noted. Mark Status as you go. + +### Legend +`[ ]` todo · `[~]` in progress · `[x]` done · **Dep:** prerequisite chunks + +--- + +### Chunk 1 — SDK/version cadence + lock test · Status: [x] +**Dep:** none. Small, isolated, safe to do first. + +> **Done.** dvui + zig were **already pinned** (dvui → `build.zig.zon` commit +> `3dec1c1b…`; zig `0.16.0` via `minimum_zig_version` + CI `ZIG_VERSION`). Added the +> cadence-policy doc comment to `src/sdk/version.zig`, a matching "Cadence" section to +> `docs/PLUGINS.md` § Compatibility, and a `version.zig` regression test +> ("abi fingerprint is decoupled from the app version"). `zig build test` + +> `test-sdk-version` green. + +Make fingerprint bumps deliberate and prove app-version is decoupled. +- Confirm `VERSION` / `build.zig.zon` app version is independent of `src/sdk/version.zig` + `sdk_version` + `recorded_abi_fingerprint`. Document the relationship in a comment. +- Pin dvui + zig to explicit versions in `build.zig.zon` (and toolchain config if any). If already + pinned, just record the pinned revision + note the "bump deliberately" policy in `docs/PLUGINS.md` + Compatibility section. +- Add a unit test asserting an app-version bump does **not** change `recorded_abi_fingerprint` + (e.g. a test that the fingerprint constant is computed only from SDK/dvui boundary types, not any + app-version input). Keep `zig build test-sdk-version` green. + +**Accept:** `zig build test` + `zig build test-sdk-version` pass; doc note added. + +--- + +### Chunk 2 — `Host.unregisterPlugin` + ownership gaps · Status: [x] +**Dep:** none (pure SDK). Parallel with Chunk 1. + +> **Done.** Implemented in `src/sdk/Host.zig`: +> - Added `owner: ?*Plugin` to `FileRowFillColor`; pixi sets it +> (`src/plugins/pixi/src/plugin.zig` `registerFileRowFillColor`). +> - Changed `services` to `StringHashMapUnmanaged(ServiceEntry{ ptr, owner })` — +> **`registerService` now takes a 3rd arg `owner: ?*Plugin`** (see ⚠ below). `getService` +> external behavior unchanged (returns `entry.ptr`). +> - `unregisterPlugin(plugin)` — generic `removeOwned` filter over every registry + +> service removal + plugin drop + active-id reset (`hasSidebarView`/`hasBottomView`/ +> `hasCenterProvider`), in that order (ids compared before any `dlclose`). +> - Unit test "unregisterPlugin removes a plugin's contributions, service, and resets active +> ids" (uses a victim + keeper plugin to prove scoping). Runs under `zig build test`. +> +> **⚠ Downstream impacts:** +> - **ABI fingerprint shifted** (adding `FileRowFillColor.owner`): `sdk_version` bumped +> **0.4.0 → 0.5.0**, `recorded_abi_fingerprint = 0x0146eaf7c2f9605a`. Bundled plugin dylibs +> rebuild automatically; any external plugin must rebuild against ≥0.5.0. +> - **`registerService` signature changed** to `(name, service, owner)`. Only in-tree caller +> updated (`Editor.zig:844`, passes the workbench plugin). Third-party plugins that register a +> service must add the `owner` arg. `docs/PLUGINS.md` mentions it only with `…`, so no doc edit +> needed, but call this out in any author migration notes when 0.5.0 ships. +> - Chunk 3's `unloadPlugin` calls `host.unregisterPlugin` **after** closing the plugin's docs + +> re-registering remaining keybinds, and **before** `lib.close()`. + +The teardown engine for live unload. In `src/sdk/Host.zig`: +- Add `owner: ?*Plugin` to `FileRowFillColor` (`Host.zig:31`); set it at the lone caller (pixi + `registerFileRowFillColor`). This shifts the ABI fingerprint → **bump `sdk_version` + + `recorded_abi_fingerprint`** in the same commit (Chunk 1 policy). +- Add internal service-ownership tracking: record `{name → *Plugin}` in `registerService` so a + plugin's services can be removed. +- Implement `pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void`: + - Retain-in-place filter each registry by `owner == plugin`: `sidebar_views`, `bottom_views`, + `center_providers`, `menus`, `menu_sections`, `settings_sections`, `commands`, + `file_row_fill_colors`; remove the plugin's services; remove it from `plugins`. + - Reset `active_sidebar_view` / `active_bottom_view` / `active_center` if they referenced a + removed id (use `firstVisibleSidebarView()` / first bottom / first center / null). These ids + point into dylib static memory — reset **before** `dlclose`. + +**Accept:** unit test (see §3) — register a fake plugin with one of each contribution + a service + +an active sidebar selection pointing at it; `unregisterPlugin`; assert every registry is cleared of +its entries, the service is gone, and active pointers reset. `test-sdk-version` updated + green. + +--- + +### Chunk 3 — Editor lifecycle orchestration · Status: [x] +**Dep:** Chunk 2. + +> **Done.** In `src/editor/Editor.zig` (+ `Settings.zig`): +> - `installAndLoadPlugin(id)` / `loadUserPluginById(id)` — load `{config}/plugins/{id}.{ext}` +> live via `PluginLoader.loadAndRegister` + the existing dvui/render-bridge sync + a keybind +> rebuild. No restart. +> - `unloadPlugin(id, force)` — collects + `rawCloseFileID`s the plugin's owned docs (dirty docs +> abort with `error.DirtyDocuments` unless `force`), `host.unregisterPlugin` → `plugin.deinit()` +> → `rebuildKeybinds` → `lib.close()` + free bookkeeping. **Ordering:** docs close → unregister +> (resets active ids) → deinit → keybind rebuild → dlclose. +> - `setPluginEnabled(id, enabled, force)` + `updatePlugin(id, force)`. +> - **Disabled set:** new `Settings.disabled_plugins: []const []const u8` (JSON array), backed at +> runtime by Editor-owned `disabled_plugin_ids` (`seedDisabledPlugins` re-points the settings +> slice at the owned list; `setDisabledPersisted` mutates + marks dirty). `loadUserPlugins` skips +> disabled ids; `seedDisabledPlugins` runs in `postInit` before the scan. +> - **Keybind teardown:** `rebuildKeybinds` clears `window.keybinds` and re-runs `Keybinds.register` +> + every surviving plugin's `contributeKeybinds` (drops the unloaded plugin's binds, whose key +> strings live in the about-to-`dlclose` image). Safe because the map is `StringHashMapUnmanaged` +> with POD values + literal keys. +> - **Scope guard:** `isBundledPluginId` (pixi/workbench/code) + `isUnloadablePlugin` (must be in +> `loaded_plugin_libs`). Bundled built-ins reject every mutating op with `error.NotUnloadable`. +> +> No unit test (these need dvui/dlopen + a live `Editor`); behavior is covered by the Chunk 5/7 +> E2E. `zig build` + `test` + `test-sdk-version` all green; no boundary change so the fingerprint +> is unchanged. +> +> **For Chunk 5 (UI):** the dirty-doc seam is `unloadPlugin(id, force=false)` → on +> `error.DirtyDocuments`, the store should prompt (reuse the save-confirmation dialog) and retry +> with `force=true` (or save first). `installAndLoadPlugin` assumes the file is already in the +> plugins dir — Chunk 4's `download` puts it there first. + +In `src/editor/Editor.zig`, wire the live operations (reuse existing helpers — don't duplicate): +- **`installAndLoadPlugin(id, path)`** — call `PluginLoader.loadAndRegister` + the existing + `syncLoadedPluginDvuiContexts` + `syncLoadedPluginRenderBridge` + `appendLoadedPluginLib` path + (already factored in `loadUserPlugins`). Loads live, no restart. +- **`unloadPlugin(id)`** — + 1. Close every `open_files` entry with `DocHandle.owner == plugin` via the existing close path / + workbench. Dirty docs → reuse `saveNeedsConfirmation` / `requestSaveConfirmation`; abort on + cancel. + 2. Re-register remaining plugins' keybinds (drop the unloaded plugin's `contributeKeybinds`), same + as startup. + 3. `host.unregisterPlugin(plugin)` → `plugin.vtable.deinit(plugin.state)` → find `LoadedLib`, + `lib.close()`, free `path`/`id`, remove from `loaded_plugin_libs`. +- **`setPluginEnabled(id, bool)`** — persist a `disabled_plugins: [][]const u8` set in + `src/editor/Settings.zig`; disable → `unloadPlugin` + add to set; enable → load live. Make + `loadUserPlugins` skip ids in the set at startup. +- **`updatePlugin(id, new_path)`** — `unloadPlugin` → swap file → load. +- **Scope guard:** only ids present in `loaded_plugin_libs` are unloadable; exclude bundled + built-ins (reuse `isBundled` from `InstalledPlugins.zig`). + +**Accept:** builds; manual unload of a loaded user plugin closes its docs + removes contributions +with no crash/leak under the debug allocator (covered in E2E, Chunk 7). + +--- + +### Chunk 4 — Store networking backend · Status: [x] +**Dep:** none for code; integrates in Chunk 6. Parallel with 1–3. + +> **Done.** New `src/backend/plugin_store/` (pure of dvui/globals — caller passes `allocator` + +> `std.Io`): +> - `registry.zig` — typed `Index`/`PluginEntry`/`Release`/`Download` (downloads are a dynamic +> object via `std.json.ArrayHashMap`), `parseIndex` (ignore-unknown-fields), `fetchIndex` +> (HTTPS GET → parse). +> - `compat.zig` — `hostKey()` → `os-arch`; `parseFingerprint`; `selectRelease(entry, host_fp, +> host_key)` = newest release matching `abi_fingerprint` **and** shipping the host arch. +> - `download.zig` — `sha256Hex`/`matchesSha256` + `download(url, expected_sha256, dest_path)`: +> GET to memory, verify SHA-256, atomic temp-file + `renameAbsolute` into the plugins dir. +> - `store.zig` — aggregator + `Status{ idle, fetching, ready, failed }` + threaded `Catalog` +> (owns the latest `Parsed(Index)`; `refresh` spawns a detached worker; `withIndex(ctx, f)` +> reads under the mutex). Per-plugin *install* status is UI-side (Chunk 5). +> - Wired into the pure-logic test target (`build/app.zig` list + `tests/root.zig`) as +> `fizzy-plugin-store`, so the whole module **compiles** under `zig build test` (validates the +> 0.16 HTTP/IO/Thread APIs) and its tests run. Tests: `parseIndex` ×2, `selectRelease` ×3 + +> `parseFingerprint`, `sha256`/`matchesSha256` ×2. +> +> **0.16 API notes for Chunk 5 (these are settled — reuse, don't re-derive):** +> - `var client: std.http.Client = .{ .allocator = a, .io = io };` then `client.fetch(.{ +> .location = .{ .url = url }, .response_writer = &aw.writer })`; `client.deinit()` (no args). +> TLS **auto-rescans system roots** — no manual `Certificate.Bundle` needed. +> - Response body → `var aw: std.Io.Writer.Allocating = .init(allocator); … aw.written()`. +> - File IO: `std.Io.Dir.cwd().writeFile(io, .{ .sub_path, .data })`; +> `std.Io.Dir.renameAbsolute(old, new, io)`. +> - `Catalog` API: `init(allocator, io, url)` / `refresh()` / `status()` / `withIndex(ctx, f)` / +> `deinit()`. The store will live in `Editor` (app-lifetime, outlives the detached worker). + +New `src/backend/plugin_store/` (off the UI thread; mirror `src/backend/update_notify.zig`'s +non-blocking pattern): +- **`registry.zig`** — `fetchIndex(url)`: `std.http.Client` TLS GET (`std.crypto.Certificate.Bundle` + roots), `std.json.parseFromSlice` (`ignore_unknown_fields = true`, like `src/core/Atlas.zig`) + into a typed `Registry { plugins: []PluginEntry }` with `releases[]` carrying + `version`, `min_sdk_version`, `abi_fingerprint`, `downloads: map`. +- **`compat.zig`** — `hostKey()` → `os-arch` from `builtin.target` (e.g. `macos-aarch64`); + `selectRelease(entry)` → newest release with `abi_fingerprint == sdk.dylib.abi_fingerprint` **and** + a `downloads[hostKey()]`. Returns null = incompatible. +- **`download.zig`** — `download(url, expected_sha256, dest_dir)`: GET to a temp file, verify + `std.crypto.hash.sha2.Sha256`, atomic-rename to `{config}/plugins/{id}.{ext}` (reuse + `PluginLoader.pluginExtension()` + the config path logic in `loadUserPlugins`). Reject + delete on + hash mismatch. +- A small worker + per-plugin status enum: `not_installed | downloading | installed | + update_available | incompatible | failed`. + +**Accept:** unit tests for `compat.selectRelease` (match / wrong-fingerprint / missing-arch / +newest-wins) and `download` SHA mismatch rejection (§3). No UI yet. + +--- + +### Chunk 5 — Store UI module · Status: [x] +**Dep:** Chunks 3 + 4 (calls their APIs). UI can be stubbed against fakes first. + +> **Done.** New `src/editor/PluginStore.zig`, wired into `Editor`: +> - Sidebar view `"shell.store"` (icon `dvui.entypo.shop`, title "Plugins") registered in +> `postInit` **immediately before** the Settings view → sits directly above the cog. +> - Cards via `dvui.groupBox` merging the fetched registry with locally-present plugins +> (bundled built-ins + sideloaded dylibs, drawn once) + a load-failures section. Buttons by +> state: Install / Disable+Uninstall(+Update when newer) / Enable+Uninstall / greyed +> "Needs a rebuild for Fizzy SDK x.y". Header shows host SDK+ABI + Refresh + a transient +> status line. +> - **Async install model:** Install/Update spawn a worker (`Job`) that runs `download.download` +> (network+fs, off the UI thread); `PluginStore.tick()` (called from `Editor.tick` **before** +> the host-registry loops) completes finished downloads by loading them live on the main +> thread (`installAndLoadPlugin`/`updatePlugin`). Dirty-doc disable/uninstall surfaces +> "save first" via the error seam. +> - **`InstalledPlugins.zig` folded** to a one-line pointer ("…in the Plugins tab"); `Editor` +> added `uninstallPlugin(id, force)` (unload + clear disabled flag + delete the dylib). +> +> **Threading model:** background work uses a real `std.Thread` worker (this Zig's `dvui.io` is a +> blocking GUI io, not a concurrent event loop, so `io.async` wouldn't run a fetch off the UI +> thread — every worker in the app uses `std.Thread.spawn`). Shared state is guarded with +> `std.Io.Mutex` + `dvui.io`, which **does** work across `std.Thread` boundaries — this is the +> codebase's standard pattern (see pixi `SaveQueue`: `std.Thread` worker + `std.Io.Mutex` + +> `std.Io.Condition`, joined on shutdown). So `Catalog` guards `parsed` with a `std.Io.Mutex`, +> frees the previous index on swap (no leak), exposes `acquire`/`release` (hold across the read), +> and `join`s the worker on `deinit`. +> +> *(Correction: an earlier draft of this note wrongly claimed `std.Io.Mutex` doesn't work with raw +> OS threads and used a lock-free atomic-pointer + leak-on-refresh hack instead. That conflated Io +> sync primitives with Io async tasks; the mutex version above replaced it.)* +> +> **0.16 API corrections made (note for later chunks):** `std.Io.Dir.createDirAbsolute(io, path, +> .default_dir)` (not `makeDirAbsolute`); `deleteFileAbsolute(io, path)`; theme fonts are +> `body/heading/title/mono` only (no `caption`). +> +> Verified: `zig build`, `zig build test`, `zig build test-sdk-version` all green. Interactive +> install/enable/disable/uninstall is the Chunk 7 E2E (native SDL app + local registry). + +New `src/editor/PluginStore.zig`, modeled on `InstalledPlugins.zig` (imports `dvui`, `sdk`, +`../fizzy.zig`): +- Register its `SidebarView` in `Editor.postInit` **immediately before** the `view_settings` + registration (order = sidebar order). Id `"shell.store"`, icon (entypo or a `lucide` TVG via the + `icons` pkg), title `"Plugins"`. +- `draw`: scroll area of **cards** via `dvui.groupBox` (pattern in `src/editor/explorer/settings.zig`) + merging the fetched registry with locally installed (`loaded_plugin_libs` + `failed_user_plugins` + + bundled `host.plugins`). Each card: name, description, author, installed vs available version, + active/disabled badge. Buttons by state: **Install** / **Disable**+**Uninstall**+**Update** / + **Enable** / **Reinstall**. Incompatible → greyed "needs a rebuild for Fizzy SDK x.y". Header: + host SDK + ABI (reuse `InstalledPlugins` formatting) + **Refresh**. +- Fold `InstalledPlugins.zig` into the store tab (or reduce its Settings section to a pointer). + +**Accept:** icon appears directly above Settings; tab renders cards; buttons call the Chunk 3/4 APIs. + +--- + +### Chunk 6 — Distribution repo `fizzyedit/plugins` (external) · Status: [x] +**Dep:** schema agreed (this doc). Independent of the app code; can proceed in parallel. + +> **Done** in `~/dev/fizzyedit/plugins` (origin `github.com/fizzyedit/plugins`). Builds nothing; +> aggregates only. Not committed (jj-managed; left to the user). +> - `scripts/aggregate.py` — stdlib-only aggregator: reads `registry/*.json`, fetches each +> `manifest_url`, validates, merges into `plugins/index.json`. **Malformed registry file → +> hard error (exit 1)** (reviewed PRs must be valid); **unreachable/bad manifest → skip + +> retain last-known-good** from the existing index. `--check` validates without writing; +> `file://` URLs work locally. All three paths tested green. +> - `registry/markdown.json` — example registration pointing at the author's `manifest_url` +> (the real `~/dev/fizzyedit/markdown` plugin, built against our SDK 0.5.0 / ABI +> `0x0146eaf7c2f9605a`). +> - `plugins/{index.json,CNAME,index.html}` — generated index + `plugins.fizzyed.it` domain + +> landing page (served via Pages). +> - `.github/workflows/aggregate.yml` — regen on `registry/**` change + 6-hourly cron + +> dispatch; commits index.json back (path filter excludes `plugins/**` so no re-trigger +> loop); uploads `plugins/` as the Pages artifact + deploys. +> - `.github/workflows/validate.yml` — PR check (`aggregate.py --check`). +> - `docs/manifest.example.json` + README publishing guide. +> +> **Current state:** `index.json` is intentionally empty — the markdown author hasn't published +> their `manifest.json` yet (the aggregator hit a real 404 and skipped gracefully). It populates +> once Chunk 7's author build action publishes the manifest. +> +> **Beyond the plan:** added `validate.yml` (PR validation) and `plugins/index.html` (landing). + +New repo (not in this tree): +- `registry/.json` — one-time author submission (PR): `{ id, name, author, homepage, tags, + manifest_url }`. +- `plugins/index.json` — CI-generated aggregate, served via GitHub Pages (`plugins.fizzyed.it`, + CNAME like the main site). +- `.github/workflows/aggregate.yml` — on schedule + `registry/*` change: fetch each `manifest_url`, + validate, merge into `index.json`, deploy to Pages. Skip unreachable manifests (retain + last-known-good). **Builds nothing; dispatches nothing.** + +**Accept:** a sample `registry/example.json` + a hand-written author `manifest.json` aggregate into a +valid `index.json` served by Pages. + +--- + +### Chunk 7 — Author build helper + E2E verification · Status: [x] +**Dep:** Chunks 3–6. + +> **Done.** Kept **self-contained in the markdown plugin repo** (`~/dev/fizzyedit/markdown`) +> rather than a separate `plugin-build-action` repo — simpler and immediately runnable for one +> author; the logic can be extracted to a reusable action when a second plugin needs it. +> - `scripts/make_manifest.py` — `probe` (dlopen a dylib via **ctypes**, read the +> `fizzy_plugin_*` C exports for fingerprint/id/versions, SHA-256 it → a fragment) + +> `assemble` (merge per-target fragments into one release, enforcing they share build identity; +> `--merge-into` preserves prior releases). Stdlib only. +> - `.github/workflows/release.yml` — on a `v*` tag: matrix of **native runners** per target +> (macos-14/13, ubuntu, windows) builds + probes its own binary (no cross-probe), uploads +> binaries to the GitHub Release, and a final job assembles + publishes `manifest.json` to the +> author's Pages (which the registry entry's `manifest_url` points at). +> *Prereq:* `build.zig.zon` must pin `fizzy` to a git url+hash (it's a local path today) and the +> matching Fizzy SDK commit must be on GitHub. +> +> **E2E verified locally (everything except the GUI click):** built `markdown.dylib` → probed +> (fingerprint `0x0146eaf7c2f9605a`, **matches the host**) → assembled `manifest.json` → ran the +> Chunk 6 aggregator against it over **real HTTP** → `index.json` populated → client fetched the +> index and **downloaded the binary + SHA-256 verified** (the app's exact verify step). The +> `assemble` mismatch guard and the aggregator's strict/last-known-good paths were also exercised. +> The only un-automatable step is clicking **Install** in the native SDL app — manual commands +> below. + +- (Optional) `fizzyedit/plugin-build-action` — reusable GitHub Action: on tag, build the 6 os-arch + targets against a given Fizzy SDK tag (reuse `release.yml` matrix shape), probe each binary's + `abi_fingerprint` C export (via `nm`/tiny loader), `sha256`, upload to the author's Release, + regenerate `manifest.json`. +- Run the full **Verification** flow (§4) end-to-end. + +**Accept:** §4 passes. + +--- + +## 3. Unit tests to add + +- `Host.unregisterPlugin` (Chunk 2): fake plugin with one of each contribution + a service + active + sidebar selection → unregister → every registry cleared of its entries, service gone, active + pointers reset. +- `compat.selectRelease` (Chunk 4): matching fingerprint+arch chosen; wrong fingerprint or missing + arch rejected; newest matching version wins. +- `download` (Chunk 4): SHA-256 mismatch rejects + leaves no file. +- `version.zig` (Chunk 1): app-version bump does not change `recorded_abi_fingerprint`; + `zig build test-sdk-version` green. + +Run: `zig build test` and `zig build test-sdk-version`. + +--- + +## 4. End-to-end verification + +1. Build the in-repo `example` plugin as a dylib (`cd src/plugins/example && zig build`). +2. Serve a hand-written `index.json` (real `abi_fingerprint` + `sha256`) over `localhost`. +3. `zig build run`; open the **Plugins** sidebar tab — confirm the icon sits directly above + Settings. +4. **Install** → downloads + loads live (Example's sidebar view appears, **no restart**). +5. **Disable** → its view disappears + it leaves `loaded_plugin_libs`; relaunch → stays unloaded + (persisted in `disabled_plugins`); **Enable** → loads live again. +6. Open a document owned by a loadable editor plugin, then **Uninstall** → tabs close (dirty → + confirm dialog), contributions vanish, no crash/leak under the debug allocator. +7. Point the client at the deployed `plugins.fizzyed.it/index.json` and repeat Install. +8. **Distribution:** dry-run the author action on one target for `example`; confirm the dylib's + probed `abi_fingerprint` matches the Fizzy build, `manifest.json` is valid, and `aggregate.yml` + merges it into `index.json`. + +--- + +## 5. Reuse map (don't rebuild) + +| Need | Use | +|------|-----| +| Load/register a dylib | `PluginLoader.loadAndRegister`, `resolvePluginPath`, `pluginExtension`, `probeVersionInfo` | +| Sync dvui/render into a loaded lib | `Editor.syncLoadedPluginDvuiContexts`, `syncLoadedPluginRenderBridge` | +| Track a loaded lib | `Editor.appendLoadedPluginLib`, `loaded_plugin_libs`, `failed_user_plugins` | +| Plugin lookup / routing | `Host.pluginById`, `DocHandle.owner` | +| Parse JSON | `std.json.parseFromSlice` (see `src/core/Atlas.zig`) | +| Non-blocking worker | pattern in `src/backend/update_notify.zig` | +| Card UI | `dvui.groupBox` (see `src/editor/explorer/settings.zig`) | +| Plugin inventory UI baseline | `src/editor/InstalledPlugins.zig` | +| Release build matrix (author action template) | `.github/workflows/release.yml` | + +--- + +## 6. Risks & notes + +- **ABI fingerprint shifts from Chunk 2** (adding `FileRowFillColor.owner`): expected — bump + `sdk_version` + `recorded_abi_fingerprint` together; rebuild bundled plugin dylibs. +- **Dangling `[]const u8` ids** after `dlclose`: the registry entries' `id`/`title` slices live in + the plugin's static memory. Safe only because `unregisterPlugin` removes them and resets active + pointers *before* `lib.close()`. Keep that ordering. +- **Dirty documents on unload/disable**: must honor the save-confirmation protocol and allow abort; + never silently drop unsaved work. +- **TLS root certs** for `std.http.Client`: use `std.crypto.Certificate.Bundle`; verify it loads + system roots on all three desktop targets. +- **wasm**: plugin dylib loading is native-only (`PluginLoader_stub.zig` on wasm). The store tab + should degrade gracefully / hide install controls on web. +- Bundled built-ins (`pixi`/`workbench`/`code`) are never store-managed — guard every mutating op. + +--- + +## 7. Status summary + +- [x] Chunk 1 — SDK/version cadence + lock test +- [x] Chunk 2 — `Host.unregisterPlugin` + ownership gaps *(→ SDK 0.5.0, fp `0x0146eaf7c2f9605a`; `registerService` gained `owner` arg)* +- [x] Chunk 3 — Editor lifecycle orchestration *(install/unload/enable/disable/update + disabled-set persistence + keybind rebuild)* +- [x] Chunk 4 — Store networking backend *(registry/compat/download/store; HTTPS fetch + sha256 + Catalog; tests green)* +- [x] Chunk 5 — Store UI module *(Plugins sidebar tab above Settings; cards; async install via worker+tick; Io.Mutex Catalog; InstalledPlugins folded to a pointer)* +- [x] Chunk 6 — Distribution repo `fizzyedit/plugins` *(aggregator + registry entry + Pages workflow + PR validation; tested; not committed)* +- [x] Chunk 7 — Author build helper + E2E verification *(markdown make_manifest.py + release.yml; full distribution chain verified over HTTP incl. sha256)* + +--- + +## 8. Manual GUI E2E (the one un-automatable step) + +The native SDL app can't be driven by the test harness, so the final install click is manual. +With the markdown plugin built (`cd ~/dev/fizzyedit/markdown && zig build`): + +```sh +# 1. Stage a local registry + binary served over HTTP. +ROOT=/tmp/fizzy_store; rm -rf $ROOT; mkdir -p $ROOT +cp ~/dev/fizzyedit/markdown/zig-out/markdown.dylib $ROOT/markdown-macos-aarch64.dylib +cd ~/dev/fizzyedit/markdown +python3 scripts/make_manifest.py probe $ROOT/markdown-macos-aarch64.dylib \ + --os-arch macos-aarch64 --url http://localhost:8099/markdown-macos-aarch64.dylib --out $ROOT/frag.json +python3 scripts/make_manifest.py assemble $ROOT/frag.json --published 2026-06-26 --out $ROOT/manifest.json +# hand-write index.json pointing at the manifest, or run the aggregator with a file:// registry entry +printf '{"schema":1,"plugins":[{"id":"markdown","name":"Markdown","releases":%s}]}' \ + "$(python3 -c "import json;print(json.dumps(json.load(open('$ROOT/manifest.json'))['releases']))")" > $ROOT/index.json +cd $ROOT && python3 -m http.server 8099 & + +# 2. Launch fizzy pointed at the local registry, with the bundled markdown disabled if present. +cd ~/dev/fizzy +FIZZY_PLUGIN_REGISTRY_URL=http://localhost:8099/index.json zig build run +``` + +Then: open the **Plugins** sidebar tab (bag icon, above Settings) → the markdown card shows +**Install** → click it → the worker downloads + verifies, and `PluginStore.tick` loads it live +(its menu/sidebar contributions appear with no restart). Verify **Disable** (relaunch → stays +unloaded), **Enable**, open a `.md` doc then **Uninstall** (tabs close, contributions vanish, no +crash under the debug allocator). diff --git a/STORE_TEXT_SIDEBAR_PLAN.md b/STORE_TEXT_SIDEBAR_PLAN.md new file mode 100644 index 00000000..7ea770eb --- /dev/null +++ b/STORE_TEXT_SIDEBAR_PLAN.md @@ -0,0 +1,724 @@ +# Plugin Store, Text Editor Platform & Sidebar Reorder — Plan & Agent Handbook + +> Living doc for reworking the plugin store into a **VSCode-extensions-style** experience (flat card +> list, per-plugin metadata, repo `README.md` rendering, logos), renaming `code` → `text` as the +> universal plain-text fallback, default-disabling the example plugin, extracting a shared SDK text +> editor, bringing the **markdown** plugin **in-tree as a built-in** (so the store can always render +> READMEs), adding a Host syntax-provider registry (zig plugin registers highlighters only), and +> making the sidebar icon rail reorderable with persisted order. +> Update the Status boxes as chunks land. Companion to `PLUGINS_PLAN.md` (the store) and +> `PIXI_EXTRACTION_PLAN.md` (pixi extraction — overlaps on the `code`→`text` rename as Phase C1). + +## 0. Context & end state + +Today the plugin store is a flat card stack whose order depends on registry JSON, registration +order, and filesystem iteration — not deterministic. Cards expand to reveal actions. The built-in +**code** plugin claims every file extension as a fallback but vendors its own `TextEntryWidget` and +tree-sitter stack; **markdown** and future **zig** plugins fork duplicate copies. The markdown plugin +lives in a separate repo (`~/dev/fizzyedit/markdown`). The sidebar icon rail follows registration +order with no reorder or persistence. The **example** plugin is always visible. + +**End state:** + +- Plugin store is a **flat, non-expanding card list** (VSCode-extensions feel), **A→Z** by display + name, with the same **Filter** row as the workbench file tree. Each card shows an optional **logo**, + a **title**, and a **short description**; the right edge holds a **fixed-width action area** with an + **enabled checkbox** + **uninstall (trash)** for installed plugins (and an Install affordance for + available ones). The reserved width never changes, so cards don't reflow on install/uninstall. +- **Hovering anywhere on a card** highlights the whole card, matching the file-tree row hover + (`color_fill_hover = themeGet().color(.control, .fill).opacity(0.5)`). +- **Selecting a card shows that plugin's `README.md`** rendered by the in-tree **markdown render + library** (read-only preview pane/tab) — a **direct render call**, not a document opened through a + plugin. README bytes are **fetched from the plugin's repository over HTTPS and cached locally**. +- Each plugin carries **repository link, author, version** (required) and an optional **logo**, plus + **store metadata**: when it was **installed** (local) and when it was **added to the store** + (registry). +- **Reusable engines live in the host as libraries:** `sdk/text` (`TextEditor` + `TextEntryWidget`) + and an in-tree **markdown render library** (`cmark` + `render_ast` + preview draw). First-party + text/markdown are **static-only** (no separately-buildable dylib). `example` + external + `pixi`/`ghostty`/`zig` are the third-party authoring references. +- **text** opens **any** file type as plain monospace + line numbers (VSCode-like baseline). +- The markdown render library is **native-only** (cmark needs libc; gated out of the wasm build — + text owns `.md` on web). The store is desktop-only anyway, so README rendering never runs on web. +- **zig** plugin registers syntax providers only (text remains document owner for `.zig`/`.zon`). +- Sidebar icons are **reorderable**; order persisted in settings. + +### Confirmed decisions + +1. **Store cards are a flat list, not a tree.** No per-card expander. The Phase 1 unified-model work + (dedup, A→Z, filter) is **kept**; the `TreeWidget`/`Branch`/expander layout is **replaced** by a + plain scrollable list of full-width card buttons (Phase 1R). +2. **Reuse lives in engines, not plugin wrappers.** Each "plugin" is an *engine* (the reusable + widget/render code) plus a thin *wrapper* (manifest + vtable that registers a document owner). All + reuse value is in the engines, so they move into shared host-side libraries: **`sdk/text`** + (`TextEditor` + `TextEntryWidget` + options) and a **markdown render library** (`cmark` parse + + `render_ast` + preview-draw). The wrappers stay thin (or disappear) — see #3, #4. +3. **README renders via a direct markdown-library call** — no document/plugin detour. The store + fetches the README, caches the bytes, and calls the markdown render library to draw a **read-only + preview** (its own pane/tab). No cache-`.md`-file + open-as-document dance, and no dependency on a + markdown *plugin* being registered. +4. **First-party text/markdown are static-only built-ins; drop the separately-buildable dylib.** + There is no real benefit to a dylib double-build for code that is always compiled in (the ABI + boundary is meaningless when statically linked). Keep **`example`** + the genuinely-external + `pixi`/`ghostty`/`zig` as the third-party authoring references. The markdown render code moves + **in-tree as a library** (native-only; gated out of the wasm build — cmark needs libc). A thin + `.md`-editing wrapper over the shared libs is optional/low-priority (Phase MD); README rendering + does **not** depend on it. +5. **README source = repository fetch + local cache.** Derive the raw README URL from the plugin's + `repository` link (GitHub: `raw.githubusercontent.com///HEAD/README.md`), fetch async + on a worker, cache to disk keyed by plugin id. Works for both available and installed plugins. +6. **Optional logo = remote URL, fetched + cached async** with a generic-icon placeholder fallback. +7. **Manifest gains `repository`/`author` (required) + `logo` (optional).** This is an SDK ABI change, + so it is **folded into the single ABI bump** (Phase 4a) together with the syntax registry and the + sidebar-reorder Host API — external plugins (pixi, ghostty) rebuild **once**. Until then, built-in + cards source repo/author from small in-tree descriptors and available cards from the registry index. +8. **Store metadata storage.** `installed_at` is local → a sidecar `plugin_meta.json` in the plugins + dir (id → install timestamp; built-ins use first-run time). `added`/`published` come from the + registry index (`Release.published`, plus a new `PluginEntry.added` field — registry JSON only, no + ABI impact). +9. **Syntax via registry, not document ownership.** Zig plugin registers `Host.registerSyntaxProvider` + for `.zig`/`.zon`; text plugin stays the document owner and calls `host.syntaxForPath(path)` when + drawing. (Not: zig plugin owning `.zig` docs at priority 50.) +10. **Markdown `.md` editing (if kept) owns `.md`/`.markdown`** at priority 50 for split-pane preview; + both the source pane (`sdk.text`) and the preview pane (markdown render lib) reuse the shared + engines — no forked `TextEntryWidget`, no duplicated renderer. +11. **Example disable = sidebar hidden**, not dylib unload (example is static built-in). + +--- + +## 1. Work breakdown + +`[ ]` todo · `[~]` in progress · `[x]` done · **Dep:** prerequisite chunks + +### Phase 0 — Lifecycle hardening (load/unload safety) · Status: [x] **Dep:** none (prereq for safe unload) + +**Goal:** Unloading a plugin while it has work in flight must never call into freed dylib memory. + +`FileLoadJob` (`src/plugins/workbench/src/FileLoadJob.zig`) holds a raw `owner: *sdk.Plugin` +resolved on the main thread before the worker spawns. `Editor.unloadPlugin` closes owned docs, +`unregisterPlugin`s, then `dlclose`s — but does **not** cancel in-flight load jobs. Opening a file +and immediately Disable/Uninstall-ing its owning plugin lets the worker call +`owner.loadDocument` / `deinitDocumentBuffer` into a `dlclose`d image → use-after-free / crash. + +- In `unloadPlugin`, before `unregisterPlugin`/`plugin.deinit`/`dlclose`, cancel or await every + `FileLoadJob` whose `owner == plugin` (mirror the existing `waitForPluginSaves` wait loop). +- This risk concentrates on unloadable specialized plugins (markdown / pixi / zig). `text` is + bundled / non-unloadable so it is not directly exposed, but Phase 2 makes `text` the universal + fallback owner for most files — keep the cancellation owner-scoped so unrelated jobs survive. + +**Accept:** disable/uninstall a plugin mid file-load → no crash, the partial open is dropped cleanly. + +### Phase 1 — Plugin store unified model + filter · Status: [x] **Dep:** none (UI-only) + +> **Layout superseded by Phase 1R.** The 1a unified `StoreEntry` model (dedup, A→Z sort) and the 1b +> filter row are **kept**. The 1c `TreeWidget` + `Branch` + expander layout (and the card-wrapping +> box work) is **replaced** by the flat card list in Phase 1R — the cards no longer expand. + +**Goal:** Deterministic A→Z listing, file-tree-style filter, one row per plugin. + +#### 1a — Unified display model +Replace four sequential draw loops in `src/editor/PluginStore.zig` with one merge pass: + +```zig +const StoreEntry = struct { + id: []const u8, // sort key tie-breaker + title: []const u8, // display name + kind: enum { registry, local, disabled, failed }, + // payload union for draw actions +}; +``` + +- Dedup rules unchanged: registry wins; skip local/disabled if id in catalog index. +- Sort: case-insensitive ASCII on `title`, tie-break on `id` (`std.sort.pdq`). +- Stable widget ids: `id_extra = hash(plugin_id)` — not loop index. + +> **Lifetime rule (must-have).** The merge, `std.sort.pdq`, and the draw loop must all run inside +> a single `catalog.acquire()` / `catalog.release()` scope, building the `StoreEntry` list into the +> dvui arena. `StoreEntry.title`/`id` are **borrowed** slices with three different lifetimes: +> registry strings (valid only under the catalog lock — the worker frees the arena on `refresh`), +> `plugin.display_name`/`plugin.id` (live in dylib static memory — valid only while the plugin is +> loaded), and `disabled_plugin_ids` (app-allocator-owned). Never retain these across the lock +> release or across `tick()`. `PluginStore.tick()` must keep running before the draw pass so +> `host.plugins` is never mutated mid-merge. + +> **Per-id dedup before `branch_id` (must-have).** Today the four loops use disjoint numeric ranges +> (`i`, `1000+i`, `3000+i`, `2000+i`) so widget ids never collide. With one `branch_id = hash(id)` +> the same id can surface from multiple sources (registry + disabled-on-disk, or failed + disabled), +> and two branches sharing a `branch_id` triggers a dvui id collision (warning + visual glitch). +> Collapse to exactly one row per id using explicit precedence — **loaded/local > registry > +> disabled > failed** — and fold `failed_user_plugins` into the same dedup pass. + +#### 1b — Filter row (mirror file tree) +Pattern from `src/plugins/workbench/src/files.zig`: + +- Horizontal box + lucide search icon + `dvui.textEntry(.{ .placeholder = "Filter..." })`. +- Match (case-insensitive): `id`, `name`/`display_name`, `description`, `author`, registry `tags`. +- Import tree via `const wdvui = fizzy.dvui`. + +#### 1c — Tree layout (SUPERSEDED by Phase 1R) + +The original top-level-`TreeWidget` layout shipped, then the design changed to a flat, non-expanding +card list. See Phase 1R. Keep async jobs, `pending_actions`, and `tick()` plumbing unchanged. + +**Accept (historical):** plugins always A→Z; filter like Files tab. + +--- + +### Phase 1R — Store card list redesign (VSCode-extensions style) · Status: [x] **Dep:** 1 (reuses unified model + filter) + +> **Implemented design (2026-06-30) — supersedes the sketch below.** Cards are now a flat list of +> `dvui.ButtonWidget` containers (no `TreeWidget`, no expander). Each card is a horizontal box: +> **(1)** a `gravity_y=0.5`, `gravity_x=0` 32×32 logo (generic `package` icon placeholder; real +> logos are Phase META); **(2)** an info column — large `Font.theme(.title)` plugin name over a dim +> `Font.theme(.mono)` `id · version · date` subtitle; **(3)** right-justified state controls. +> There is **no per-card README button** — clicking the card body selects the plugin (controls +> consume their own clicks, so a control click never selects). The store keeps a **single selected +> plugin** (the `readme.zig` `current` is the source of truth via `Readme.selectedId()`); selecting +> the already-selected card deselects it. While the **store tab is active and a plugin is selected**, +> `PluginStore.tick → syncReadmeCenter` swaps the Host **active center** to a new `shell.store.readme` +> center provider that renders the README (the previous center — normally the workbench — is restored +> on deselect / tab switch). This is the "selected_plugin → README in the **center pane**, like the +> project tab" behaviour, replacing the old in-sidebar README-with-back-button. +> +> **Controls by state** (`drawCardControls`): *available in store* → single install button +> (`arrow-down-to-line`); *installed (loaded dylib / disabled-on-disk / sideloaded)* → `Enabled` +> checkbox + `trash-2` uninstall; *static built-in (example)* → `Enabled` checkbox only (nothing to +> uninstall); *protected bundled fallback (text/workbench)* → a muted "Built-in" label (never +> disablable/uninstallable); *registry row with no compatible release / load failure* → muted +> "Needs rebuild" / "Failed". In-flight installs show "Installing…"; failed installs show a Retry icon. +> +> **Still TODO from the original 1R-c reserved-width rule:** the action area is *not* yet a constant +> reserved width, so cards can still jitter horizontally as state changes. Logos are placeholders. + +**Goal:** Replace the expanding tree rows with a **flat list of cards**; whole-card hover; a +fixed-width action area; selecting a card opens the plugin README (README open lands in Phase META). + +#### 1R-a — Card list (drop TreeWidget) + +Inside the existing `catalog.acquire()` scope + `StoreEntry` merge/sort/filter (Phase 1a/1b), draw a +plain vertical list — no `TreeWidget`, no `Branch`, no expander: + +```zig +for (entries.items) |entry| { + if (!matchesFilter(entry, filter_text)) continue; + // One clickable card; hover highlights the entire card like a file-tree row. + var card = dvui.button(@src(), ..., .{ + .id_extra = hashId(entry.id), + .expand = .horizontal, + .corner_radius = dvui.Rect.all(8), + .color_fill_hover = dvui.themeGet().color(.control, .fill).opacity(0.5), + .background = false, // idle: flat; hover/active fills (matches files.zig:519) + }); + defer card.deinit(); + // Left: optional logo image (or generic icon placeholder) + title + 1-line description. + // Right: FIXED-WIDTH action area (see 1R-c). + if (card.clicked()) selectPlugin(entry.id); // opens README tab (Phase META) +} +``` + +- Whole-card hover feedback uses the file-tree value `themeGet().color(.control, .fill).opacity(0.5)`. +- Stable widget id `id_extra = hashId(entry.id)` (unchanged from Phase 1). +- The selected card gets a persistent fill (selection state kept in a module var keyed by id). + +#### 1R-b — Card content + +- **Logo:** optional image at a fixed box (e.g. 32×32) on the left; generic lucide/entypo icon when + absent or not yet fetched (logo fetch in Phase META). +- **Title:** `display_name`/registry `name`, single line, ellipsized. +- **Description:** registry `description` (available) or manifest/short text (installed), 1 line, + ellipsized. Author + version shown as a small dim subtitle. + +#### 1R-c — Fixed-width action area (reserved, never reflows) + +Right-justified `dvui.box` of constant width regardless of plugin state, so the card shape is stable: + +- **Installed:** an **enabled checkbox** (toggles `setPluginEnabled`) + a **trash** uninstall button. +- **Available (not installed):** an **Install/Get** button occupying the same reserved width. +- **Fallback/built-in guards (from Phase 2a):** the active file-type fallback (`text`) and other + protected built-ins show **no** uninstall (and no disable) — render a disabled/placeholder glyph so + the reserved width is still consumed and alignment holds. +- Route actions through the existing `queueSetEnabled` / `queueUninstall` → `pending_actions` → + `tick()` path (no direct registry mutation during draw). + +> **Reserved-width rule (must-have).** Compute the action area from the **max** of all states (checkbox +> + trash) and always lay out that width, drawing placeholders where a control is absent. Never make the +> width state-dependent or the list will jitter horizontally as plugins install/uninstall/enable. + +**Accept:** flat A→Z cards; hover highlights the whole card; action area stays put across +install/uninstall/enable; clicking a card selects it (and, after Phase META, opens its README). + +--- + +### Phase 2 — code → text + example disabled by default · Status: [x] **Dep:** none (can parallel Phase 1) + +#### 2a — Rename `code` → `text` (PIXI plan C1) + +| Area | Changes | +|------|---------| +| Directory | `src/plugins/code/` → `src/plugins/text/` | +| Module hub | `code.zig` → `text.zig`; `@import("code")` → `@import("text")` | +| Plugin id | `"code"` → `"text"`; dylib `text.{dylib,so,dll}` | +| Build | `build/plugins.zig`, `build/exe.zig`, `build/app.zig`, `build/web.zig`: `code_plugin` → `text_plugin`, `-Dstatic-code` → `-Dstatic-text` | +| Editor | `Editor.zig`: `loadCodeDylib` → `loadTextDylib`, `isBundledPluginId` lists `"text"` | +| PluginStore | `PluginStore.zig` `isBundled`: `"text"` | + +Keep `sdk.Plugin.file_type_fallback_priority` constant name/value (100). + +> **Protect the universal fallback (must-have).** `text` must be added to **both** +> `Editor.isBundledPluginId` *and* `PluginStore.isBundled` when flipping from `code`. Missing either +> one lets the store render Disable/Uninstall on the text card; disabling the priority-100 fallback +> makes `openablePath` (`files.zig`) return false for **every** file, so the file tree silently opens +> nothing and the app looks broken. Add a hard guard: the active file-type fallback can never be +> disabled/uninstalled — omit its Disable/Uninstall buttons in the store and reject it in +> `setPluginEnabled`/`uninstallPlugin`. Verify every `code` reference flips together: +> `isBundledPluginId`, store `isBundled`, `loadCodeFromDylibEnabled`, `FIZZY_STATIC_CODE`, +> `-Dstatic-code`, and the dylib install path — a half-rename can leave a stale `code.dylib` +> loaded while the new checks look for `text`. + +#### 2b — Universal file open (verify, minimal work) + +Text plugin returns priority 100 for all extensions (including extensionless). With text loaded: + +- `openablePath` in `files.zig` → true for virtually every file. +- File tree name filter stays extension-agnostic. + +**Accept:** fresh install opens any file as plain text when no specialized plugin claims it. + +#### 2c — Example plugin disabled by default + +Example is static (not dylib-unloadable). Use sidebar visibility: + +1. Reuse `disabled_plugins` with static-plugin interpretation, or add dedicated list. +2. After `example_mod.plugin.register` in `Editor.postInit`: + +```zig +if (editor.isPluginDisabled("example")) + editor.host.setSidebarViewHidden("example.hello", true); +``` + +3. Plugin store Enable/Disable for example toggles persisted flag + `setSidebarViewHidden`. +4. Default settings / first-run: example disabled. + +> **Static-plugin disable wiring (must-have).** The store buttons route +> `queueSetEnabled` → `applySetEnabled` → `Editor.setPluginEnabled`, whose current `else` branch +> calls `unloadPlugin`. For a static plugin like `example` (registered unconditionally in `postInit`, +> never in `loaded_plugin_libs`) `unloadPlugin` cannot find it and errors — so today Disable would +> persist the id *and* fail without hiding anything. Add a dedicated static-plugin branch in +> `setPluginEnabled`: persist the disabled flag + `setSidebarViewHidden("example.hello", true/false)` +> instead of load/unload. Note `isBundledPluginId` only lists `workbench`/`code`(→`text`), so +> `example` is treated as static here — keep it out of that list but special-case it as +> hide-not-unload. +> +> Suppress the **Uninstall** button for static built-ins in `drawLocalCard` (there is no dylib file +> to delete; `uninstallPlugin` would try to unload + remove a nonexistent file). Apply the first-run +> hidden state immediately after `example_mod.plugin.register` in `postInit` (the `if +> (editor.isPluginDisabled("example")) editor.host.setSidebarViewHidden("example.hello", true)` shown +> above). When a hidden view is the active one, `Host.activeSidebarView` already falls back to the +> first visible view, so hiding the active example icon is safe. + +**Accept:** fresh install has no Example icon; enabling from store shows it. + +--- + +### Phase 3 — Extract reusable engines into the host (text + markdown render) · Status: [ ] **Dep:** 2a + +**Goal:** Put the reuse where it belongs — shared host-side libraries — so the thin wrappers (and the +store) consume one canonical copy each. Two engines: + +#### 3a — `sdk/text` (plain text editor engine) + +Add to `src/sdk/sdk.zig` exports: + +``` +sdk/text/ + TextEditor.zig // draw(backing, path, id_extra, gpa, options) -> bool changed + TextEntryWidget.zig // move from text plugin (one canonical copy) + Options.zig // chrome, line-number gutter, optional tree_sitter slot +``` + +**text plugin** (`src/plugins/text/src/TextEditor.zig`, renamed from CodeEditor) becomes a thin +delegator: line numbers + `TextEntryWidget` via `sdk.text`; remove the in-plugin `SyntaxHighlight.zig` +and tree-sitter deps; `drawDocument` calls +`sdk.text.TextEditor.draw(..., .{ .tree_sitter = host.syntaxForPath(path) })`. + +#### 3b — markdown render library (in-tree, native-only) — see Phase MD-lib + +The markdown rendering engine (`cmark` parse + `render_ast` + preview draw) moves in-tree as a +host-side library the **store calls directly** for READMEs (and a future `.md` wrapper reuses). This +is the immediate dependency for README rendering and is detailed in **Phase MD-lib** below; the ABI +note (3c) applies to `sdk/text` and the syntax types, not to the markdown render lib (it is a normal +in-tree module, no SDK boundary). + +#### 3c — ABI note + +Adding `sdk/text`, the manifest fields, the Host syntax registry, and the sidebar-reorder API all +**bump `sdk_version` + `recorded_abi_fingerprint`** — do them as **one** bump in Phase 4a. `sdk/text` +itself can land in Phase 3 if the bump is taken there, but to rebuild externals only once, prefer +batching the fingerprint change into Phase 4a (declare the `sdk/text` types in Phase 3 behind the +same single bump). + +> **Atomic bump (must-have).** Update `sdk_version` *and* `recorded_abi_fingerprint` in +> `src/sdk/version.zig` in the **same commit** that adds the new SDK types/Host hooks/manifest fields +> — the comptime guard in `version.zig` plus `zig build test-sdk-version` fail the host build +> otherwise. Until an external plugin is rebuilt against the new fingerprint it is rejected at load +> with `error.AbiMismatch` and surfaces as a failed / "Needs a rebuild" card. Markdown is now in-tree +> so it rebuilds automatically; pixi/ghostty (and the new zig plugin) must be rebuilt against the new +> fingerprint. + +**Accept:** text plugin plain-only; widget lives in SDK once. + +--- + +### Phase MD-lib — Markdown render library in-tree (native-only) · Status: [x] **Dep:** none (immediate; README enabler) + +**Goal:** Bring the markdown **rendering** engine in-tree as a host-side library the store calls +directly — *not* a document-owning plugin. This is the minimum needed to render READMEs. + +#### MD-lib-a — Move the render code in-tree + +From `~/dev/fizzyedit/markdown/src` take only the **render** path (no plugin/document/editor glue): + +``` +src/markdown/ (host-side library; @import-able by editor/store) + markdown.zig // lib hub: pub fn drawPreview(bytes, opts) / parse+render + md/ (cmark_parse.zig, render_ast.zig, cmark_headers.h) + MarkDownPreviewWidget.zig // read-only preview draw +``` + +- Expose a small API, e.g. `pub fn drawPreview(gpa, bytes: []const u8, id_extra, opts) !void` (parse + with cmark → `render_ast` → draw), plus a cached-parse variant if needed. +- **Leave behind** `plugin.zig`, `Document.zig`, `State.zig`, `MarkdownEditor.zig`, and the vendored + `widgets/TextEntryWidget.zig` — those belong to the optional editing wrapper (Phase MD-wrapper), + which reuses `sdk.text` for its source pane when/if we do it. + +#### MD-lib-b — cmark-gfm C dependency (native-only) + +- Add `cmark_gfm` to fizzy's `build.zig.zon`. Link `cmark-gfm` + `cmark-gfm-extensions` into the + module(s) that import `src/markdown` (native exe/app), with include paths for the dep's + `src`/`extensions` and `src/markdown/md`. + +> **Web/wasm (must-have).** cmark needs libc; the wasm build is `freestanding` + `link_libc = false`, +> so the markdown render lib is **native-only**. The store is desktop-only anyway. Gate `src/markdown` +> out of `build/web.zig` and behind `arch != .wasm32` at the import site (or provide a stub) so +> `zig build` / `check-web` stay green. `.md` files on web simply open via `text`. + +**Accept:** `src/markdown.drawPreview(bytes)` renders markdown into the current dvui parent on +native; `zig build` + `zig build check-web` both green; no document/plugin machinery involved. + +--- + +### Phase MD-wrapper — Optional thin `.md` editing plugin · Status: [ ] **Dep:** 3a, MD-lib (low priority) + +**Goal (optional):** Restore `.md` split source/preview *editing* as a thin static-only built-in that +reuses both shared engines — `sdk.text` (source pane) + `src/markdown` (preview). No vendored +`TextEntryWidget`, no separately-buildable dylib. Skip until editing (not just README viewing) is +wanted; READMEs do not need this. + +--- + +### Phase META — Plugin metadata + repo README + logos · Status: [~] **Dep:** 1R, MD-lib + +> **Partly done:** META-c (README fetch + cache-in-memory + direct `markdown.drawPreview` render) is +> implemented in `src/editor/readme.zig` and wired to card selection + the `shell.store.readme` center +> provider. **Pending:** META-a manifest/descriptor metadata (still using registry `homepage` as the +> repo URL — no `repository`/`logo`/`added` fields yet), META-b install/added date sidecar +> (`plugin_meta.json`; the card subtitle currently shows the registry release `published` date only), +> and META-d logos (cards draw a generic `package` placeholder). + +**Goal:** Per-plugin repository/author/version/logo + install/added dates; selecting a card renders +the repo `README.md` via the markdown render library; logos fetched + cached. + +#### META-a — Registry + descriptor metadata + +- `registry.PluginEntry`: add `repository: []const u8 = ""`, `logo: []const u8 = ""`, + `added: []const u8 = ""` (JSON only — `ignore_unknown_fields` already tolerates old indexes; **no + ABI impact**). Keep existing `author`/`homepage`/`description`/`tags`/`releases[].published`. +- Built-ins (no registry row): add a tiny `store_meta` descriptor in each + `static/integration.zig` (`repository`, `author`, optional `logo`) so their cards render full + metadata before the manifest carries it (Phase 4a). +- Card subtitle shows **author · version**; a detail line shows **repository**, **installed** date, + **added** date. + +> **Design note — exported identity (consider before committing to descriptors/registry index).** +> The same dylib **export mechanism** used elsewhere is the natural home for this metadata: rather than +> a registry index or in-tree `store_meta` descriptors, each plugin could **export its full identity** +> (`repository`, `author`, `version`, `logo`, …) as a queryable symbol — readable **without loading the +> plugin**. That would collapse the registry-index / in-tree-descriptor / manifest split into a single +> source of truth and is worth evaluating here before locking in the descriptor approach above. + +#### META-b — Install / added dates + +- `installed_at`: sidecar `plugin_meta.json` in the plugins dir (`id -> RFC3339 timestamp`). Written + when an install `Job` completes; built-ins stamped on first run if absent. Read at store draw. +- `added`: from `PluginEntry.added` (fallback: earliest `Release.published`). + +#### META-c — README fetch + cache + direct render (no document detour) + +- Derive raw URL from `repository` (GitHub → `raw.githubusercontent.com///HEAD/README.md`; + fall back to `main`/`master`). Non-GitHub hosts: best-effort or skip with a friendly message. +- Async worker (mirror `store.Catalog`): fetch bytes, cache to `/plugin-readme/.md`, expose + status (idle/fetching/ready/failed) + the in-memory bytes. +- On card select (Phase 1R `selectPlugin`): kick the fetch; when ready, render the bytes with + **`src/markdown.drawPreview(bytes)`** (Phase MD-lib) into a read-only preview surface — a dedicated + store detail pane/tab, **not** a document opened through a plugin. Show "Loading README…" / + "No README found" placeholders by status. + +> **Lifetime + threading (must-have).** README/logo workers are `std.Thread`s like `Catalog`; never +> touch dvui or `host.plugins` off-thread. Guard the bytes with a mutex (or hand-off atomic) and only +> read them on the UI thread while rendering. No document/tab lifecycle, no save path — it is a pure +> read-only render of fetched bytes. + +#### META-d — Logos + +- Optional `logo` URL (registry or built-in descriptor). Async fetch + cache to + `/plugin-logo/`; decode to a dvui image; draw in the 1R-b logo box. Generic icon + placeholder while fetching / on failure. + +**Accept:** cards show logo + author + version + repository + dates; clicking a card renders its +README via the markdown library; logos load lazily; everything degrades gracefully offline. + +--- + +### Phase 4 — Syntax provider registry + plugin refactors · Status: [ ] **Dep:** 3 + +#### 4a — Single atomic ABI bump (syntax registry + manifest fields + sidebar Host API) + +This is the **one** SDK fingerprint bump. It folds together every ABI-affecting change so external +plugins (pixi, ghostty) rebuild exactly once; the in-tree markdown built-in rebuilds automatically: + +**(i) Manifest schema** (`src/sdk/manifest.zig`) — add the store-required identity fields: + +```zig +pub const PluginManifest = struct { + id: []const u8, + name: []const u8, + version: std.SemanticVersion, + repository: []const u8, // required: source repo link (README source) + author: []const u8, // required + logo: []const u8 = "", // optional: logo image URL + min_sdk_version: std.SemanticVersion = version.sdk_version, +}; +``` + +Update every in-tree plugin manifest (workbench, text, example, markdown) + external (pixi, ghostty, +zig). Store cards prefer manifest metadata for installed plugins once this lands, falling back to the +in-tree `store_meta` descriptors / registry index. + +**(ii) Host syntax provider registry** (`src/sdk/Host.zig`): + +```zig +pub const SyntaxProvider = struct { + owner: ?*Plugin, + treeSitterForPath: *const fn (?*anyopaque, path: []const u8) ?sdk.text.TreeSitterOption, +}; + +syntax_providers: ArrayListUnmanaged(SyntaxProvider), + +pub fn registerSyntaxProvider(self: *Host, provider: SyntaxProvider) !void +pub fn syntaxForPath(self: *Host, path: []const u8) ?sdk.text.TreeSitterOption +``` + +Walk providers in registration order; first non-null wins. `unregisterPlugin` drops owned providers. + +**(iii) Sidebar reorder Host API** (the Phase 5 boundary types — declared here so the rail UI in +Phase 5 needs no further bump): `reorderSidebarView` / `applySidebarViewOrder` (see Phase 5a). + +> **No caching of `TreeSitterOption` (must-have).** The `TreeSitterOption` returned by +> `syntaxForPath` carries function pointers that live inside the provider's dylib (e.g. the zig +> plugin). The text editor / `TextEntryWidget` must **re-query** `host.syntaxForPath(path)` every +> frame and never stash the result across frames. `tick()` applies an unload before the draw pass, +> so within a frame it stays consistent — but a cached option from a previous frame would point into +> a `dlclose`d image after the zig plugin is unloaded → use-after-free. `unregisterPlugin` dropping +> owned providers (matched on `SyntaxProvider.owner`) covers the registry side; the no-cache rule +> covers the consumer side. + +#### 4b — Markdown plugin refactor — SUPERSEDED + +Markdown rendering is now an in-tree host library (Phase MD-lib), and the optional `.md` editing +wrapper (Phase MD-wrapper) reuses `sdk.text` + that lib. No external-repo edit or SDK-hash re-pin +needed. (Pixi/ghostty still rebuild against the new fingerprint — see 4a.) + +#### 4c — Zig plugin (`~/dev/fizzyedit/zig`) + +Scaffold third-party plugin (mirror markdown/pixi layout): + +- `register`: `host.registerSyntaxProvider` for `.zig` and `.zon`. +- Move/adapt `SyntaxHighlight.zig` + `queries/zig.scm` + tree-sitter deps from old code plugin. +- Does **not** implement document vtable — text remains owner. +- `zig build install` → dev plugins dir. + +**Accept:** the `.md` wrapper (if built) + zig share the SDK text engine; no duplicated +TextEntryWidget forks. + +--- + +### Phase 5 — Reorderable sidebar · Status: [ ] **Dep:** none (can parallel earlier phases) + +#### 5a — Host API (`src/sdk/Host.zig`) + +```zig +pub fn reorderSidebarView(self: *Host, from_index: usize, to_index: usize) void +pub fn applySidebarViewOrder(self: *Host, ids: []const []const u8) void +``` + +Mirror `Panel.swapBottomViews` pattern. + +#### 5b — Settings persistence (`src/editor/Settings.zig`) + +```zig +sidebar_view_order: []const []const u8 = &.{}, +``` + +After all plugins register in `postInit`, `applySidebarViewOrder` from saved ids (append new views). +On reorder, update list + `markSettingsDirty`. + +#### 5c — Sidebar UI (`src/editor/Sidebar.zig`) + +- Wrap icons in `dvui.reorder` / `Reorderable` (vertical), like `PanelWorkspace.zig`. +- Drag threshold so click-to-select still works. +- Hidden views excluded from rail. + +> **Id + index safety (must-have).** Today `Sidebar.zig` draws icons with `id_extra = index` (the +> position in `sidebar_views`, which still counts hidden entries). `Reorderable` needs a widget id +> that is stable across array position, so use `id_extra = hash(view.id)` (same fix as the store), +> otherwise the dragged icon's identity shifts mid-drag. Separately, `reorderBottomView` / +> `swapBottomViews` operate on **raw** indices into the full list, but the rail skips +> `view.hidden` entries — translate a "visible rail position" to its `sidebar_views` index before +> swapping, or the wrong entries move. `applySidebarViewOrder` must skip ids that are missing +> (plugin unloaded) or hidden when restoring the persisted order, and append any new views not in +> the saved list (per 5b). + +**Accept:** drag Plugins above Settings; order survives restart. + +--- + +## 2. Target architecture + +```mermaid +flowchart TB + subgraph store [Plugin Store UI] + Filter["Filter textEntry"] + Cards["Flat card list A-Z (logo + title + desc + reserved actions)"] + Meta["Metadata: repo / author / version / installed+added dates"] + Readme["Select card -> fetch repo README -> cache -> markdown.drawPreview()"] + Filter --> Cards + Cards --> Meta + Cards --> Readme + Readme --> MDLib["src/markdown render lib (cmark, native-only)"] + end + + subgraph routing [File open routing] + Open["openFilePath"] --> PFE["Host.pluginForExtension"] + PFE --> Spec["Specialized owner e.g. markdown prio 50"] + PFE --> Text["text plugin prio 100 fallback"] + end + + subgraph editor [Shared engines] + SDKText["sdk.text.TextEditor"] + TEW["TextEntryWidget single copy"] + SDKText --> TEW + Text --> SDKText + MDLibE["src/markdown render lib (cmark)"] + Store2["Plugin store README"] --> MDLibE + MDWrap["optional .md wrapper"] --> SDKText + MDWrap --> MDLibE + end + + subgraph syntax [Syntax providers] + ZigPlug["zig plugin"] --> Reg["Host.syntaxProviders"] + Reg --> SDKText + end + + subgraph sidebar [Sidebar rail] + Reorder["ReorderWidget vertical"] + Persist["settings.sidebar_view_order"] + Reorder --> Persist + end +``` + +--- + +## 3. Recommended execution order + +| Step | Phase | Risk | +|------|-------|------| +| 0 | Phase 0 — Lifecycle hardening (job cancel on unload) | Low; isolated, prereq for safe unload — **done** | +| 1 | Phase 1 — Store unified model + filter | Low; UI only — **done** | +| 2 | Phase 2a — code→text rename | Medium; wide mechanical diff — **done** | +| 3 | Phase 2b/c — example default off | Low — **done** | +| 4 | **Phase MD-lib** — Markdown render library in-tree (cmark dep, native-only/gated) | Medium; C dep + build wiring + wasm gate — **README enabler** | +| 5 | **Phase META** — repo README fetch + cache + direct render via MD-lib | Medium; threaded fetch/cache | +| 6 | Phase 1R — Store card list redesign (flat cards, hover, reserved actions, select→README) | Low; UI only | +| 7 | Phase 3a — `sdk/text` engine extraction | Medium; moves widget | +| 8 | Phase MD-wrapper — optional thin `.md` editing plugin over shared libs | Low; optional | +| 9 | Phase 4a — **Single ABI bump**: manifest fields + syntax registry + sidebar Host API | Medium; ABI (rebuild pixi/ghostty/zig once) | +| 10 | Phase 4c — zig plugin scaffold | Depends on 4a | +| 11 | Phase 5 — sidebar reorder UI | Depends on 4a (Host API); new persistence | + +README rendering lands first (MD-lib → META) without any ABI change — the markdown render lib is a +plain in-tree module, not an SDK boundary. Phase 1R wires card-select to the README pane. All +ABI-affecting changes still land together in Phase 4a so external plugins rebuild **once**. + +--- + +## 4. Verification + +- After each chunk: `zig build`, `zig build test`, `zig build test-sdk-version` +- Plugin store: mixed registry + sideloaded plugins sort A→Z; filter matches id/name/description +- **Cards:** flat list, no expanders; hovering anywhere on a card highlights the whole card; the + right action area stays a constant width across install/uninstall/enable (no horizontal jitter) +- **Card metadata:** each card shows logo (or placeholder) + title + 1-line description + author · + version; detail line shows repository, installed date, added date +- **README:** selecting a card opens its repo `README.md` as a markdown tab in the center; offline / + no-README shows a graceful placeholder, not a crash +- **Markdown render lib:** `src/markdown.drawPreview(bytes)` renders on native; gated out of wasm; + `zig build` + `zig build check-web` both green. (Optional `.md` editing wrapper reuses sdk.text + + this lib.) +- Fresh settings: example hidden; text opens `.foo`, extensionless, and binary paths as plain text +- With zig installed: `.zig`/`.zon` in text tabs show syntax; text still owns documents +- Sidebar: reorder persists; disabled example absent until enabled + +### Load/unload stability checks (must-pass) + +- Disable/uninstall a plugin **while one of its files is mid-load** → no crash; the partial open is + dropped (Phase 0 job cancellation). +- The `text` fallback cannot be disabled or uninstalled (no buttons; API rejects); opening any file + still works after toggling other plugins on/off. +- Reorder the sidebar **while a view is hidden** → the correct (visible) items move; order survives + restart; a saved order that references an unloaded/missing id loads cleanly. +- Rapid enable → disable → uninstall on the same id leaves exactly **one** stable store row (no + duplicate branches, no id-collision warning, no flicker). +- After the SDK fingerprint bump, an un-rebuilt external plugin shows as a single "Needs a rebuild" / + failed row and does not crash the store. + +--- + +## 5. Status summary + +- [x] Phase 0 — Lifecycle hardening (cancel in-flight FileLoadJobs on unload) +- [x] Phase 1 — Store unified model + filter + A→Z (layout superseded by 1R) +- [x] Phase 2a — code → text rename +- [x] Phase 2b/c — Universal open + example disabled by default +- [x] Phase MD-lib — Markdown render library in-tree (`src/markdown`, native-only) — **README enabler** +- [~] Phase META — README fetch/cache + direct render done (`readme.zig`); **logos, install/added dates, + and manifest `repository`/`author`/`logo` fields still pending** (repo URL still sourced from the + registry `homepage` field via `repoUrl`). +- [x] Phase 1R — Store card list redesign (flat cards, whole-card hover, state-based controls, + select→README **in the center pane**). See note below. +- [ ] Phase 3a — `sdk/text` engine extraction (thin text plugin) +- [ ] Phase MD-wrapper — optional thin `.md` editing plugin over shared libs +- [ ] Phase 4a — Single ABI bump: manifest fields + syntax registry + sidebar Host API +- [ ] ~~Phase 4b~~ — Superseded (markdown render is an in-tree lib; no external refactor) +- [ ] Phase 4c — Zig syntax plugin scaffold +- [ ] Phase 5 — Reorderable sidebar UI + +--- + +## 6. Out of scope (future) + +- Grouped sections (Installed / Available headers) within the flat card list +- Rich README features beyond what the markdown built-in renders (e.g. relative image rewriting to + absolute repo URLs is best-effort) +- Non-GitHub repository hosts get only best-effort raw-README derivation +- Dedicated file-type dropdown in file tree (name filter already shows all types once text is fallback) +- Ghostty/terminal plugin integration diff --git a/assets/.fizproject b/assets/.fizproject deleted file mode 100644 index fc780b0b..00000000 --- a/assets/.fizproject +++ /dev/null @@ -1 +0,0 @@ -{"packed_image_output":"/Users/foxnne/dev/proj/pixi/assets/fizzy.png","packed_atlas_output":"/Users/foxnne/dev/proj/pixi/assets/fizzy.atlas","pack_on_save":true} \ No newline at end of file diff --git a/assets/fizzy.atlas b/assets/fizzy.atlas deleted file mode 100644 index 91545d05..00000000 --- a/assets/fizzy.atlas +++ /dev/null @@ -1 +0,0 @@ -{"sprites":[{"origin":[0,22],"source":[240,0,14,18]},{"origin":[0,22],"source":[92,0,22,22]},{"origin":[0,22],"source":[48,0,22,22]},{"origin":[0,14],"source":[129,21,15,16]},{"origin":[-1,21],"source":[220,0,20,20]},{"origin":[-1,21],"source":[180,0,20,20]},{"origin":[-1,21],"source":[200,0,20,20]},{"origin":[0,21],"source":[138,0,21,21]},{"origin":[0,21],"source":[159,0,21,21]},{"origin":[0,21],"source":[114,0,24,21]},{"origin":[0,14],"source":[114,21,15,16]},{"origin":[0,14],"source":[207,20,24,17]},{"origin":[0,14],"source":[231,20,24,16]},{"origin":[0,22],"source":[70,0,22,22]},{"origin":[0,22],"source":[0,0,24,22]},{"origin":[0,22],"source":[24,0,24,22]},{"origin":[3,20],"source":[180,20,27,18]},{"origin":[-10,13],"source":[144,21,3,4]}],"animations":[{"name":"cursor_default","frames":[{"sprite_index":0,"ms":1000}]},{"name":"pencil_default","frames":[{"sprite_index":1,"ms":1000}]},{"name":"eraser_default","frames":[{"sprite_index":2,"ms":1000}]},{"name":"bucket_default","frames":[{"sprite_index":3,"ms":1000}]},{"name":"box_selection_default","frames":[{"sprite_index":4,"ms":1000}]},{"name":"box_selection_add_default","frames":[{"sprite_index":5,"ms":1000}]},{"name":"box_selection_rem_default","frames":[{"sprite_index":6,"ms":1000}]},{"name":"color_selection_default","frames":[{"sprite_index":10,"ms":1000}]},{"name":"color_selection_add_default","frames":[{"sprite_index":11,"ms":1000}]},{"name":"color_selection_rem_default","frames":[{"sprite_index":12,"ms":1000}]},{"name":"pixel_selection_default","frames":[{"sprite_index":13,"ms":1000}]},{"name":"pixel_selection_add_default","frames":[{"sprite_index":14,"ms":1000}]},{"name":"pixel_selection_rem_default","frames":[{"sprite_index":15,"ms":1000}]},{"name":"fox_default","frames":[{"sprite_index":16,"ms":125}]},{"name":"logo_default","frames":[{"sprite_index":17,"ms":1000}]}]} \ No newline at end of file diff --git a/assets/fizzy.png b/assets/fizzy.png deleted file mode 100644 index 55631d1d79498f1c51105b47eff680f3e01147b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5429 zcmeI0`#;nD8^=GJ*%r&lu<4GG!xWkg)lF^Wd|ddVal1E%h00-lLvHl3Ia923=M`ayszv1)AhLC&+B@>-q+)u&+u_wucE60 z0Kj^8Hzz*;fURv|02;aGLb;8cHMcIoZQo%4&^G?DVD5fK3jlz7;Evji728Aw3GkT(X_J2EYDI_d&h-n9S@E zxys?LzaBit2*L9l0{y_UL!7gU6B1uD(o+;Tf1&Pd5-ZD<^)0nStq? z0fb*2thr3YMN?;t_85P-v$xj~S@zYiV5|7VwgMGkrWdUe9jgOhp(w%{P&>|cKC2oe zzOp-=S&?mMBgzh(&a4*T8!^%B-Ii3gjlwM!RyBDT?0#TZa>WsR4+nNx@kS)^O;`-3 z(AU5IGn6Y67Gb$|f3>UZc&79r3ab0uaQb#kj3sx_zLZK6e}EErPsYbz3Gh~tR60j3 zX_=Mi;$zU|_Rf~_`V??!!=vAtp=Gh!=!4{WDm zsB{lcuudBw8IBfQ!uu($Ogl)UuRct8Bf}zNqcWJ4#nk1E-!|J3hPH&#rMyYc!3Ct& z!%EIkDo7p|1xVz?NA4`QOZF=XBK@drwkMlKS+X-_nb}WpYUwv=D^MyF~#}sU?S6#qv?x zny~U3-T6MT9Ey8z-Mm1waaRO*U<~JujD<1Fd@q#~KM5V|JSa3tQFol)Eqy@)DYu1bzgM%XHuxA;%Iac!14Nc|qCrE)SnUL3bs7KIa-=n~DBVjHB=dcZ=`aG_iw z07Ph|FDkH3KA1(Jky;*8mi9^bH? zANy(7AGVmU@3FDUP_lBudd#bXup&;z`?pzv(-cOz{){J+lfjjkmdf0%Gy@km-#4*s z5e$*K#x`{Qir!Hn%k`D+Phs|V5nnt6F0fz1m(2QC$Px6nSrbx!kykJb&Na%Bz4ohfh6vdm_MNFqal|Z)^2X!X- zP4$4x@|p=Le?+gI(?hZ6=J0L4=6`?_D`6_zXHJ7ta23xD1_5P6tk<8rE#ZNs_LmYOUT)2n9;m>+Y(H5Z{j8FQHV?aEK4@crrvlMvhI8H|*382G$84W+h+fi$Vl`HEE*Yk=NGq00`^@AqLW?%Y5w0VL?n z>rtpQFPiau-x<>HldULE|I78fgF2*BJztsg|c~%yuu&jEw2*< zfRZw(fnwni;HU)4rEE4=>Q3r2JN=PGY&%*Qc3ac~4=4e9RE>P|W#sLK%{oASH&Yin zB!;}*g^ob0J;4YY(&!X-3j6HE@ai>Ie796)R({@T9d_lo8x9vpJp9`FRoDFJ8L-!J zG1J+oDFIQQX<;FHCGcAw9yA#nT;q7{Cb5nDuVLY-ElAJpd%EpQX#MVQND%;J(GuS= zLq5o6e#GN5o7N(%I6dLAI=+{ZEwaaHWg=?zkIr#n0%ZLiovmph zF@557Fa}d|yTfT!z8|sz0;?6yw5~~GuYWaMgSe`c^{Yi_1MaaTN4De$)>CT zri~6elN>Pk_M!B01+qqmU;P<}WEimi$={(>@c43mTBqG3i(f{3VKnusf{0XW{K2DyOD+zWc0V(iKqAt#TLH5?CvMI8`|)Za=^dKA!VSB)?7S`mhy}TxmG2J6sdw!yeb29Y~m& zLgh$Kcp$&8+za-Ha~a(~MAp9q63*7Ct@1vOIG$w!%!NHTgylUA;Q!R37+@T1xhN2JJ5#Lj?&VrHCc7-;O@OI0mDhx&Q z=onPh;MTHTM-i0_o2EQoDrq!fBKKw93M z40CfAuVBJwn5!wZRw>|yJL=XVs|)XiO>908pWpWH1J? z19D-7K8`aT*B*(2k>a9bzDL8l$Z2ccp?pE-&wdB*j9_t90h$9@8$8}x>Kp%8Zn##X zIz4VlL{Uh{5mi2LEjZ+b6^W?`vbV5J8RDtqY#|tJMuH7##|+4!i}bQEKNVkQG4lNI99PXh>xx;%Fx3s~9VE|r!i`{}5qAId$O{X7z-9h~mmL=E^ zPq?`W_Y(3du9i8ZXAF4(?G^fgm9>>J{dGhg+$cr=j6R;=(k(>8g2Q{qFxgSzWv=Wt zjU_{^l8m9G(BQ%1%a4k^KeANm-id>Xfw71Cf#WOuN#ucfQ7?V@J27zxAU3K3;rv@L zsK{5mO~{Mtmp#Hzv3u0;_qGq^hM~Kds&j?cOI~Bs|5mb*+@Eb8&ZdhRfvl*E;PqMd zt3{JpxlbI4R=-V!+L$l%lhh5LGQ{Z_!HzCZ?SlF7&u5b{|2d^ijdqW=>a|{>fYNUc z`TgdtMvgRbOgsG#aGnE-n)i+R*2Dngsf9@RcL*IgQ-Odf>@-VbncH*s}&Q*G^*3NQUj(W&U#L!=Bxq{0+-& zWuX1KwTmi{Gp$T=FDf;eIfp+fofoG#yoWJiHGah9_7fN*saoPQb{fEcw?HE-$0eMLAtR%})kD`{3s__$dWHmGGw$ j{#3&Ms}c^GAb^>()&JNbDvqrEg97f(K2BF1S-k%N0ql`H diff --git a/assets/palettes/apollo.hex b/assets/palettes/apollo.hex deleted file mode 100644 index 3d00dac9..00000000 --- a/assets/palettes/apollo.hex +++ /dev/null @@ -1,46 +0,0 @@ -172038 -253a5e -3c5e8b -4f8fba -73bed3 -a4dddb -19332d -25562e -468232 -75a743 -a8ca58 -d0da91 -4d2b32 -7a4841 -ad7757 -c09473 -d7b594 -e7d5b3 -341c27 -602c2c -884b2b -be772b -de9e41 -e8c170 -241527 -411d31 -752438 -a53030 -cf573c -da863e -1e1d39 -402751 -7a367b -a23e8c -c65197 -df84a5 -090a14 -10141f -151d28 -202e37 -394a50 -577277 -819796 -a8b5b2 -c7cfcc -ebede9 diff --git a/assets/palettes/downgraded-32.hex b/assets/palettes/downgraded-32.hex deleted file mode 100644 index 0f317e5f..00000000 --- a/assets/palettes/downgraded-32.hex +++ /dev/null @@ -1,32 +0,0 @@ -7b334c -a14d55 -c77369 -e3a084 -f2cb9b -d37b86 -af5d8b -804085 -5b3374 -412051 -5c486a -887d8d -b8b4b2 -dcdac9 -ffffe0 -b6f5db -89d9d9 -72b6cf -5c8ba8 -4e6679 -464969 -44355d -3d003d -621748 -942c4b -c7424f -e06b51 -f2a561 -fcef8d -b1d480 -80b878 -658d78 diff --git a/assets/palettes/eighexplore.hex b/assets/palettes/eighexplore.hex deleted file mode 100644 index 84a225a5..00000000 --- a/assets/palettes/eighexplore.hex +++ /dev/null @@ -1,32 +0,0 @@ -582838 -882838 -a83848 -c85868 -d86858 -e88858 -d8b868 -d8c8a8 -284848 -386858 -689858 -a8b858 -486878 -7898a8 -a8a8b8 -d8d8d8 -382848 -584878 -686898 -6888b8 -583858 -884868 -c86888 -c89898 -381828 -884848 -986868 -b88888 -281828 -483848 -785868 -a89898 diff --git a/assets/palettes/endesga-16.hex b/assets/palettes/endesga-16.hex deleted file mode 100644 index 2f93eb90..00000000 --- a/assets/palettes/endesga-16.hex +++ /dev/null @@ -1,16 +0,0 @@ -e4a672 -b86f50 -743f39 -3f2832 -9e2835 -e53b44 -fb922b -ffe762 -63c64d -327345 -193d3f -4f6781 -afbfd2 -ffffff -2ce8f4 -0484d1 diff --git a/assets/palettes/endesga-32.hex b/assets/palettes/endesga-32.hex deleted file mode 100644 index 42bf9a92..00000000 --- a/assets/palettes/endesga-32.hex +++ /dev/null @@ -1,32 +0,0 @@ -be4a2f -d77643 -ead4aa -e4a672 -b86f50 -733e39 -3e2731 -a22633 -e43b44 -f77622 -feae34 -fee761 -63c74d -3e8948 -265c42 -193c3e -124e89 -0099db -2ce8f5 -ffffff -c0cbdc -8b9bb4 -5a6988 -3a4466 -262b44 -181425 -ff0044 -68386c -b55088 -f6757a -e8b796 -c28569 diff --git a/assets/palettes/fizzy.hex b/assets/palettes/fizzy.hex deleted file mode 100644 index 906a2bf0..00000000 --- a/assets/palettes/fizzy.hex +++ /dev/null @@ -1,10 +0,0 @@ -5d275d -b13e53 -ef7d57 -ffcd75 -a7f070 -38b764 -257179 -29366f -3b5dc9 -41a6f6 diff --git a/assets/palettes/journey.hex b/assets/palettes/journey.hex deleted file mode 100644 index 0b1acf7d..00000000 --- a/assets/palettes/journey.hex +++ /dev/null @@ -1,64 +0,0 @@ -050914 -110524 -3b063a -691749 -9c3247 -d46453 -f5a15d -ffcf8e -ff7a7d -ff417d -d61a88 -94007a -42004e -220029 -100726 -25082c -3d1132 -73263d -bd4035 -ed7b39 -ffb84a -fff540 -c6d831 -77b02a -429058 -2c645e -153c4a -052137 -0e0421 -0c0b42 -032769 -144491 -488bd4 -78d7ff -b0fff1 -faffff -c7d4e1 -928fb8 -5b537d -392946 -24142c -0e0f2c -132243 -1a466b -10908e -28c074 -3dff6e -f8ffb8 -f0c297 -cf968c -8f5765 -52294b -0f022e -35003b -64004c -9b0e3e -d41e3c -ed4c40 -ff9757 -d4662f -9c341a -691b22 -450c28 -2d002e diff --git a/assets/palettes/lospec500.hex b/assets/palettes/lospec500.hex deleted file mode 100644 index 7c5f7617..00000000 --- a/assets/palettes/lospec500.hex +++ /dev/null @@ -1,42 +0,0 @@ -10121c -2c1e31 -6b2643 -ac2847 -ec273f -94493a -de5d3a -e98537 -f3a833 -4d3533 -6e4c30 -a26d3f -ce9248 -dab163 -e8d282 -f7f3b7 -1e4044 -006554 -26854c -5ab552 -9de64e -008b8b -62a477 -a6cb96 -d3eed3 -3e3b65 -3859b3 -3388de -36c5f4 -6dead6 -5e5b8c -8c78a5 -b0a7b8 -deceed -9a4d76 -c878af -cc99ff -fa6e79 -ffa2ac -ffd1d5 -f6e8e0 -ffffff diff --git a/assets/palettes/pear36.hex b/assets/palettes/pear36.hex deleted file mode 100644 index 7bcaf73b..00000000 --- a/assets/palettes/pear36.hex +++ /dev/null @@ -1,36 +0,0 @@ -5e315b -8c3f5d -ba6156 -f2a65e -ffe478 -cfff70 -8fde5d -3ca370 -3d6e70 -323e4f -322947 -473b78 -4b5bab -4da6ff -66ffe3 -ffffeb -c2c2d1 -7e7e8f -606070 -43434f -272736 -3e2347 -57294b -964253 -e36956 -ffb570 -ff9166 -eb564b -b0305c -73275c -422445 -5a265e -80366b -bd4882 -ff6b97 -ffb5b5 diff --git a/assets/palettes/pico-8.hex b/assets/palettes/pico-8.hex deleted file mode 100644 index a8dbfeae..00000000 --- a/assets/palettes/pico-8.hex +++ /dev/null @@ -1,16 +0,0 @@ -000000 -1D2B53 -7E2553 -008751 -AB5236 -5F574F -C2C3C7 -FFF1E8 -FF004D -FFA300 -FFEC27 -00E436 -29ADFF -83769C -FF77A8 -FFCCAA diff --git a/assets/palettes/resurrect-64.hex b/assets/palettes/resurrect-64.hex deleted file mode 100644 index e02f18f5..00000000 --- a/assets/palettes/resurrect-64.hex +++ /dev/null @@ -1,64 +0,0 @@ -2e222f -3e3546 -625565 -966c6c -ab947a -694f62 -7f708a -9babb2 -c7dcd0 -ffffff -6e2727 -b33831 -ea4f36 -f57d4a -ae2334 -e83b3b -fb6b1d -f79617 -f9c22b -7a3045 -9e4539 -cd683d -e6904e -fbb954 -4c3e24 -676633 -a2a947 -d5e04b -fbff86 -165a4c -239063 -1ebc73 -91db69 -cddf6c -313638 -374e4a -547e64 -92a984 -b2ba90 -0b5e65 -0b8a8f -0eaf9b -30e1b9 -8ff8e2 -323353 -484a77 -4d65b4 -4d9be6 -8fd3ff -45293f -6b3e75 -905ea9 -a884f3 -eaaded -753c54 -a24b6f -cf657f -ed8099 -831c5d -c32454 -f04f78 -f68181 -fca790 -fdcbb0 diff --git a/assets/palettes/sweetie-16.hex b/assets/palettes/sweetie-16.hex deleted file mode 100644 index 759c118c..00000000 --- a/assets/palettes/sweetie-16.hex +++ /dev/null @@ -1,16 +0,0 @@ -1a1c2c -5d275d -b13e53 -ef7d57 -ffcd75 -a7f070 -38b764 -257179 -29366f -3b5dc9 -41a6f6 -73eff7 -f4f4f4 -94b0c2 -566c86 -333c57 diff --git a/assets/src/cursors.pixi b/assets/src/cursors.pixi deleted file mode 100644 index 4531d80d0b8c81c033b11c27a44230ee36941e10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2409 zcmZ`*d0dj&8pb8DaV?vsOr=$vYi4TZ3Y67YQc2mEnafy)7UqVQib$rVHy27P%`wT+ zX;H%^)Uq_ItC0$>xB`_Lq5;V&f_zt7rkOjx`~2~p_x#TDzUMpVJ@4;%QAiEVWgu;B zZ4h&Rtsm$w&UZsP z%Xkhl=LRcfOL;r&Os|bm;M8ll>lqCWj(1|+EG;5Bg$AdKOEi6p`4O4t-ZjGY3aYeA zCrj2>3R1&bc6r`nRAa8C*e-4;Z>(>2e9*UR3w*|FYvP0b7THk5!wtX>l;RQAw&|_U zi<>nBC1CZCcdr7pI)?SZ`C}~?B+1CMx$JXx-fx?F@}-UZ{Ae+KJ%og%-w!+$^ft11-$z zYiWm^g~j^eD@;7f1$hqO*T+CVMie;hHd|CRK-=!^CA(W%8x^$FxYg%P?)oc*7j{z* zIjoG@werT#S;se`->h{)-Y_Fb9|Bq^Bv?DFGIu&?;kM_|qV;7&{oh3C%_l1O1Ue`- zEM`+!Q2dGL1y9~hSjL83%Pv}qL(*5HG55@k!&YnGULCrn`n=Y$+vX2vz`B`wTkHm6 zmMm%}{kHyM($2|WemNhymV)E6&N_M>JsaNdPU`WOz)|A80#-uVq%{5y1#P5pjiHO%6!u;aC(aV`i)2v7LuB!O}u&V!X1& zZu)Uv0Mnm&N>+S?rY3K~&%Q67lXT!3#E_O%Mjo;Y9%y>R_UOPQ&Tty94cC=mf}`gF z1^|zm>?nPtP$?$UcGTpj1V6_`k{&%Phg_a!J(YXB@~+Fl*M&a|w3h|Q2QIoj6&>A; zVGkkJjlCq`qHkpiG8zzO#wPceb<&wqfWU{J8GKI?oRN^kq8@S+q~Jf;Pt;5mC+*SIO z_u8(Au&Uup8X?i7TXT1rP(9kq+B^V_0ED!_?mE`woHx0F%L|onP}Rh|4dZLn*Y@={ zIC4@HV$3;M5nwe7{nsf)D7B71dZ;M@7fy&kzebEBJi)X{IHa99pV_v z8Thf;-r*Qvs6B`9hQQIbv}sUi;+ek}DN6YmJiPV;0tWqvaA+-(+lwdG@vK!*E&<_P zw{POm)I2zrucIHR7yWURAPyFHeB@MB&%0aG_2TOht;#~c+Ub_ zO#u~MCW8napR#noK4v(i2%F7)cf(9XgleziIJT7W?Mxb^5dNAPd)Y~b&nj`A%NY;p zeC^QkTA6Y0xoxxkcwbZwnKLq_q~la96OM6;FYCATF|Gpa%S6OlzV?F&0ID)gV%;%X zUq14&x2|95u2=ml`cVcN3JE)DQ=8tmj3RjB`Pfqwv&%agu_xk^44I1EZKyRo# z)ewSG)DsSGMoSaj(hZ(SB{1ETB{du)x5U?djpK6-Xgc)x8kuPy$!dA#4Oo-rN|LHD z=aIDE4EQJy-dG|XSBVlx<2GAxNsFIIDH^ULcE{l>GyHfOCZ3ogqmH7$^OO5*(F?1TpI7q7CIhoKB+@o!yC8w~t4*nXJcNQdoTvXO zlD=0S?AJZ(&TES@mk>ww*fKZ+O-HoGQi!L0uFOXrn3(OSkTGT)1V$0kqMW*=n1ENq z>uZsiK$mU(fU$mo69UY1mo0HYjh^IU!T5?sKf5G4(c3T8M`QW~2R(<(=UOa@7(Q&**0EL~yu2#W>JvO~# zx$#s}s1vwc0#9co_WBQ9&#q}zVvk!;^M>-wCH!&tNecg@KX6^?b-dEY#dg!o$_G;{ zVZLtxG%}P7iNFT9E`g3YKP8ry%Acs*+!=*H72A>$@t6e@ zVHj+jDmC+k8G(OCm13NpnuVcg?AE6904!L+dM8CVz!GAlAu5=#>GvWoNb7=e<#0jK+#4S4>D&o5rE zX4TXmQ@IqG8TS+kO>w_1`sn`k32R-dS5$rWu`xcUQ*hO8!sYSu5mTh&@xk4keH?QyAO|h^&@yxAO8f3n_J8IF+zHwD^(t#r%S9IF#ox0^QJLlFWgX%7;`_FB9 zRYYUYt@^Xhe6zUQw~LbwYWUjKW)_rpO7B^4CjE9>&ieN|U#(sLY0tHG>6^Xp=Dzu& zuifQT^7R*cfHynG;jhp-a`fg zEs5nW%pO+`Hp*S%bPW)Yz0h@0#w{vWuE?)*?*GX;j+;7dgr_rsjc4e2|F`<8?dSVe zSC`(`{hVI%-{-1q#`^8glegWk@h#TfyY-yx+20}O_FU<=zVv^s&o1%(+n-OfvXy>b zbZ^fT$M1KW=bwDv`Fv&jokud~W_*8Jyuh+taDH)qq20XYmnLrgYZd$^VfFfZMcYrv z9+7*b#k@Nxj6vd}Jjk~}RTVBPA&y{RP=GLS`xcZKQc}|rOLIz){5#E%>yUv++kIV| z=JF35O`9*=JbUu)*@G87^B*eNHdyS~S;drYe*A>~pAS90Yt39|@8fUsT(b23{OMbi z+7cz&5+~fPc(G*holiThW&84sBz9i-|2uP9j@<|5?$Upg7&q_C+vLtBvDs7Uf_&W# z_Ak!zGZ}s)_rz}t*>pkfL_^(+wKldsPJQCeoaueOS&!D&2C_*c$6epIEaUF%GY|I6vNW-oe5K|6 zrcGay)fDSLy!*pZ8(j1IjNzTHf1OI-&#(XRf62euANpUO`+cx;p2Ta*dB5-c4n0?I zzk9~Njg8v-^KUFy-eb6YKlkcim;c`X%XmHJ`(Mk7>3fxLy^0fkCTOL6l(Tc%orVq`|f6b^4_x+5GT9KaY!N=J%}D;s$a zDDb!ho_DI2PWiugk{PeYg9Y9X=09ocO7B_vc*@2@G7pdU%iGue`SI~Fl(9TS^H9J1 z|MtJ%t+$+8SAfLv|e&$zY(BONgU(>J16f#obvt^%bbRDe;)1bCxrhbL791_n^JLjZZ8 zfoPc!T{ApXftpe6<^{SDBTb`ghkFmG9TemUFrS$LB?1Dx+1PZT(sInYaD^~EF#0Q0 fCKM_F)W;4I@?wQZfKot!H!B;6#{z^P^$@iHY*83p diff --git a/build.zig b/build.zig index feda858c..8bcfe976 100644 --- a/build.zig +++ b/build.zig @@ -1,139 +1,14 @@ 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 +17,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( - bool, - "static-pixelart", - "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( + const plugin_sdk = b.option( bool, - "static-workbench", - "Keep workbench 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_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, - }); - 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, + 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, }); - 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..0402d5b0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,8 +1,12 @@ .{ .paths = .{ "src", + "build", "build.zig", "build.zig.zon", + "update.zig", + "build", + "plugin_sdk.zig", "assets", "libs", }, @@ -27,18 +31,14 @@ .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", .hash = "assetpack-0.2.0-5DA2d1ZkAADJanNVdWrUBOGMhOzUENhrUiqXcHADxY2x", }, - .known_folders = .{ - .url = "https://github.com/ziglibs/known-folders/archive/d6d03830968cca6b7b9f24fd97ee348346a6905d.tar.gz", - .hash = "known_folders-0.0.0-Fy-PJk3KAACzUg2us_0JvQQmod1ZA8jBt7MuoKCihq88", - }, .velopack_zig = .{ .url = "https://github.com/graphl-tech/velopack-zig/archive/0c2f20635a97fde38cc8000e9fb1a75f891cf37d.tar.gz", .hash = "velopack_zig-0.0.1-LzeGcengAACU1f33uDAuLKlWXtek0KCC6i_b3XWeJMjd", @@ -47,5 +47,15 @@ .dvui_singleton_app = .{ .path = "libs/dvui-singleton-app", }, + // C library powering the in-tree markdown render engine (src/markdown). Native-only — + // the wasm build never links it (see build/markdown.zig). + .cmark_gfm = .{ + .url = "git+https://github.com/kristoff-it/cmark-gfm#cd0aba87cc89f0c8aa9393e59505db271ea30239", + .hash = "cmark_gfm-0.1.0-uQgTKymaFwDK0jVZszXfDtQtW6BnS44Bp0GS55TVBD4p", + }, + // 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..03476a7f --- /dev/null +++ b/build/app.zig @@ -0,0 +1,527 @@ +const std = @import("std"); + +const plugin = @import("../plugin_sdk.zig"); +const dvui = @import("dvui"); +const velopack = @import("velopack_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 workbench_plugin = plugins.workbench; + const text_plugin = plugins.text; + 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_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_text = b.option( + bool, + "static-text", + "Keep text plugin statically registered on native (skip built-in dylib load)", + ) orelse false; + build_opts.addOption(bool, "static_text", static_text); + 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 accesskit = b.option(dvui.AccesskitOptions, "accesskit", "Enable accesskit") orelse .off; + + const assetpack = @import("assetpack"); + const assets_module = assetpack.pack(b, b.path("assets"), .{}); + + // --------------------------------------------------------------- + // 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, assets_module); + + const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, target, optimize, accesskit, build_opts, workbench_opts, assets_module, macos_sdl_paths, velopack_enabled); + const exe = main_fizzy.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); + pack_opts.addOption(bool, "static_workbench", static_workbench); + pack_opts.addOption(bool, "static_text", static_text); + break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, target, optimize, accesskit, pack_opts, workbench_opts, assets_module, macos_sdl_paths, true); + }; + const exe_for_package = package_fizzy.exe; + + if (no_emit) { + b.getInstallStep().dependOn(&exe.step); + 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.text_dylib) |text_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), text_dylib, "text", 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.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.text_dylib) |text_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), text_dylib, "text", plugins_install_dir); + common.attachBuiltinPluginInstall(b, &run_cmd.step, text_dylib, "text", 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.text_dylib) |text_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_text = plugin.installBuiltinPlugin(b, text_dylib, "text", plugins_install_dir); + const text_dylib_step = b.step( + "text-dylib", + "Build the text plugin as a dynamic library into zig-out//plugins/ (native only)", + ); + text_dylib_step.dependOn(&install_text.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-layout-anchor", "src/core/math/layout_anchor.zig" }, + .{ "fizzy-window-layout", "src/backend/window_layout.zig" }, + .{ "fizzy-plugin-dylib", "src/sdk/dylib.zig" }, + .{ "fizzy-plugin-store", "src/backend/plugin_store/store.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.workbench_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`, 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.addOptions("build_opts", build_opts); + 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")); + 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 markdown_build = @import("markdown.zig"); + _ = markdown_build.addModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module); + + 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); + _ = 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); + _ = text_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; + if (velopack_enabled) { + try velopack.linkVelopack(b, integration_tests, .{ .target = target, .optimize = optimize }); + } + + 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..03088fee --- /dev/null +++ b/build/exe.zig @@ -0,0 +1,246 @@ +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 markdown = @import("markdown.zig"); + +const workbench_plugin = plugins.workbench; +const text_plugin = plugins.text; +const example_plugin = plugins.example; +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.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.text_dylib) |dylib| { + const install_text = plugin.installBuiltinPlugin(b, dylib, "text", pack_plugins_install_dir); + install_text.step.dependOn(tail); + tail = &install_text.step; + } + + return tail; +} + +pub const FizzyExecutable = struct { + exe: *std.Build.Step.Compile, + /// Native-only; `null` on wasm targets. + workbench_dylib: ?*std.Build.Step.Compile = null, + text_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, + assets_module: *std.Build.Module, + 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 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); + exe.root_module.addOptions("build_opts", build_opts); + + 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("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` and `icons`. + 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")); + 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); + if (icons_module) |icons| core_proxy_module.addImport("icons", icons); + + // In-tree markdown render engine (native-only; links cmark-gfm). The store renders plugin + // READMEs through this. Not wired on web (cmark needs libc) — see build/web.zig. + _ = markdown.addModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module); + + 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); + _ = 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); + _ = text_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 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 text_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + break :blk text_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; + if (velopack_enabled) { + try velopack.linkVelopack(b, exe, .{ .target = resolved_target, .optimize = optimize }); + } + + return .{ + .exe = exe, + .workbench_dylib = workbench_dylib, + .text_dylib = text_dylib, + }; +} diff --git a/build/markdown.zig b/build/markdown.zig new file mode 100644 index 00000000..3bca962b --- /dev/null +++ b/build/markdown.zig @@ -0,0 +1,34 @@ +//! Build wiring for the in-tree markdown render library (`src/markdown`). +//! +//! Native-only: the engine links the `cmark-gfm` C library, which needs libc and so cannot +//! build for the `wasm32-freestanding` web target. Callers wire this into the native exe and +//! the (native) integration-test module; the web build never imports `markdown`. +const std = @import("std"); + +/// Create the `markdown` module (rooted at `src/markdown/markdown.zig`), link the cmark-gfm C +/// library + extensions, set include paths, and import it into `consumer` as `"markdown"`. +pub fn addModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + dvui_module: *std.Build.Module, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/markdown/markdown.zig"), + .link_libc = true, + }); + mod.addImport("dvui", dvui_module); + + const cmark_gfm = b.dependency("cmark_gfm", .{ .target = target, .optimize = optimize }); + mod.linkLibrary(cmark_gfm.artifact("cmark-gfm")); + mod.linkLibrary(cmark_gfm.artifact("cmark-gfm-extensions")); + mod.addIncludePath(cmark_gfm.path("src")); + mod.addIncludePath(cmark_gfm.path("extensions")); + mod.addIncludePath(b.path("src/markdown/md")); + + consumer.addImport("markdown", mod); + return mod; +} 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..77c19e8c --- /dev/null +++ b/build/plugins.zig @@ -0,0 +1,9 @@ +//! 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 workbench = @import("../src/plugins/workbench/static/integration.zig"); +pub const text = @import("../src/plugins/text/static/integration.zig"); +pub const example = @import("../src/plugins/example/static/integration.zig"); 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..03fe0fd7 --- /dev/null +++ b/build/web.zig @@ -0,0 +1,175 @@ +const std = @import("std"); +const plugins = @import("plugins.zig"); +const sdk = @import("sdk.zig"); + +const workbench_plugin = plugins.workbench; +const text_plugin = plugins.text; +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, + 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); + + // 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")); + 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. + + _ = 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); + _ = text_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..8f298a2d 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,196 @@ 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 + … ``` -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. +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")); +} +``` -### The `register(host)` entry — the one required surface +| 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`: + +```zig +const lib = fizzy.plugin.create(b, .{ + .name = "", // = your manifest.id; the installed file is . + .target = target, + .optimize = optimize, +}); +lib.root_module.linkLibrary(…); +lib.root_module.addIncludePath(…); +fizzy.plugin.install(b, lib, .{}); +``` + +**To develop/test a plugin, run `zig build install`.** It builds the plugin for the current OS +and drops `.` straight into the fizzy plugins dir the editor scans — +`~/Library/Application Support/fizzy/plugins/` (macOS), `~/.config/fizzy/plugins/` (Linux), +`%APPDATA%/fizzy/plugins/` (Windows) — so it loads on the editor's next launch (no `--prefix`, +no `cp`). It also leaves `zig-out/.` for packaging / the store build action. (The +plugins-dir copy is skipped silently on a host with no resolvable config home, e.g. a bare CI +runner, so a packaging `zig build` never fails on it.) + +### 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 + +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 +263,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 +279,212 @@ 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` — builds for this OS and installs `.` into the fizzy plugins dir; + relaunch the editor to load it. + +`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 +570,142 @@ 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`). + +### Cadence: keep fingerprint bumps rare (so plugins rebuild rarely) + +A prebuilt plugin dylib is valid for exactly one `(zig version, dvui version, SDK contract)` +tuple — the coupling is inherent, because a plugin links its own `dvui` and operates on the host's +injected `dvui` globals (`dvui_context.zig`), so host and plugin must share the same `dvui` and the +same compiler. You cannot make a native dylib survive a `dvui`/zig change; the goal is to make those +changes **rare and deliberate** so plugins only rebuild on intentional SDK bumps, not every release: + +- **App version ≠ SDK version.** The app version (`VERSION` / `build.zig.zon`) ships often and is + *not* an input to the fingerprint. A Fizzy release that does not touch the boundary, the pinned + `dvui`, or the compiler keeps the **same fingerprint**, so already-installed plugins keep loading. +- **`dvui` and zig are pinned** (the `dvui` dependency in `build.zig.zon`; `ZIG_VERSION` in CI) and + bumped deliberately/batched. Tracking `dvui`-dev tip would flip the fingerprint constantly. +- The **store matches binaries on the fingerprint**. When it changes, that is the (announced) signal + for plugin authors to rebuild; until they do, the store shows their plugin as "needs a rebuild for + Fizzy SDK x.y" rather than offering an incompatible binary. + +> Possible later hardening (not done yet): freeze the small `dvui`/zig value surface that crosses the +> boundary behind Fizzy-owned POD types, so incidental `dvui` refactors can't move the fingerprint at +> all — only genuine SDK-contract changes would. + +### 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 for local development with: + +```sh +zig build install +# → installs markdown. into this OS's fizzy plugins dir, e.g. +# ~/Library/Application Support/fizzy/plugins/markdown.dylib (macOS) +``` + +### 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..c6ceb218 --- /dev/null +++ b/plugin_sdk.zig @@ -0,0 +1,314 @@ +//! 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"); + +/// Shared with the runtime loader so install + load locations never drift (see its doc comment). +const core_paths = @import("src/core/paths.zig"); + +/// 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, +}; + +/// Wire `zig build install` for a plugin: emit `zig-out/{name}.{ext}` (for packaging / store CI) +/// **and** drop `{name}.{ext}` into this OS's fizzy plugins dir, so the editor loads it on next +/// launch. `{name}` must equal the plugin's `manifest.id`. This is the canonical plugin-dev +/// command — `zig build install` is all an author needs. +/// +/// 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 name = opts.name orelse lib.name; + const dest = b.fmt("{s}.{s}", .{ name, pluginExt(lib.rootModuleTarget().os.tag) }); + + // zig-out/{name}.{ext} — packaging / store CI grabs this. + const install_step = b.addInstallArtifact(lib, .{ + .dest_dir = .{ .override = .prefix }, + .dest_sub_path = dest, + }); + b.getInstallStep().dependOn(&install_step.step); + + // {config}/fizzy/plugins/{name}.{ext} — so the running editor picks it up (dev convenience). + const dev = b.allocator.create(DevInstall) catch @panic("OOM"); + dev.* = .{ + .step = std.Build.Step.init(.{ + .id = .custom, + .name = b.fmt("install plugin '{s}' into the fizzy plugins dir", .{name}), + .owner = b, + .makeFn = DevInstall.make, + }), + .lib = lib, + .file_name = dest, + }; + dev.step.dependOn(&lib.step); + b.getInstallStep().dependOn(&dev.step); +} + +/// Platform extension for a dynamic plugin library, for the given target OS. +fn pluginExt(os_tag: std.Target.Os.Tag) []const u8 { + return switch (os_tag) { + .windows => "dll", + .macos => "dylib", + else => "so", + }; +} + +/// Resolve `{local_config}/fizzy/plugins` on the build host — exactly where the app scans for +/// user plugins. Must mirror `known-folders` `.local_configuration` (what the runtime loader +/// uses, see `src/core/paths.zig`) + `fizzy/plugins`: +/// macOS `~/Library/Application Support/fizzy/plugins` +/// Linux `$XDG_CONFIG_HOME/fizzy/plugins` (or `~/.config/fizzy/plugins`) +/// Windows `%LOCALAPPDATA%/fizzy/plugins` (FOLDERID_LocalAppData — *not* Roaming/`%APPDATA%`) +fn fizzyPluginsDir(b: *std.Build) ![]const u8 { + const env = &b.graph.environ_map; + const config_root = (try core_paths.localConfigRoot( + b.graph.host.result.os.tag, + b.allocator, + env.get("HOME"), + env.get("XDG_CONFIG_HOME"), + env.get("LOCALAPPDATA"), + )) orelse return error.NoConfigHome; + return std.fs.path.join(b.allocator, &.{ config_root, "fizzy", "plugins" }); +} + +/// Custom step: copy the built dylib into the host's fizzy plugins dir as `{id}.{ext}`. +const DevInstall = struct { + step: std.Build.Step, + lib: *std.Build.Step.Compile, + file_name: []const u8, + + fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void { + const self: *DevInstall = @fieldParentPtr("step", step); + const b = step.owner; + const io = b.graph.io; + + // Skip gracefully if the host has no resolvable config home (e.g. a bare CI runner) so a + // plain `zig build` for packaging never fails on the dev convenience. + const dir = fizzyPluginsDir(b) catch |err| { + std.log.warn("fizzy: skipping plugin dev install (no config home: {s})", .{@errorName(err)}); + return; + }; + // Create `{config}/fizzy` then `{config}/fizzy/plugins` (the config root already exists); + // "already exists" is fine. + const fizzy_dir = std.fs.path.dirname(dir).?; + std.Io.Dir.createDirAbsolute(io, fizzy_dir, .default_dir) catch {}; + std.Io.Dir.createDirAbsolute(io, dir, .default_dir) catch {}; + + // `getPath2` is relative to the build root (the runner's cwd); the dest is absolute. + const src = self.lib.getEmittedBin().getPath2(b, step); + const dest = try std.fs.path.join(b.allocator, &.{ dir, self.file_name }); + const data = try std.Io.Dir.cwd().readFileAlloc(io, src, b.allocator, .limited(512 * 1024 * 1024)); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = dest, .data = data }); + std.log.info("fizzy: installed plugin → {s}", .{dest}); + } +}; + +/// 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 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); + 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 deleted file mode 100644 index 505042f9..00000000 --- a/process_assets.zig +++ /dev/null @@ -1,158 +0,0 @@ -const std = @import("std"); -const path = std.fs.path; -const Step = std.Build.Step; -const Io = std.Io; - -const Atlas = @import("src/plugins/pixelart/src/Atlas.zig"); -const ProcessAssetsStep = @This(); - -step: Step, -builder: *std.Build, -assets_path: []const u8, -output_folder: []const u8, - -pub fn init(builder: *std.Build, comptime assets_path: []const u8, comptime output_folder: []const u8) !*ProcessAssetsStep { - const self = try builder.allocator.create(ProcessAssetsStep); - self.* = .{ - .step = Step.init(.{ .id = .custom, .name = "process-assets", .owner = builder, .makeFn = process }), - .builder = builder, - .assets_path = assets_path, - .output_folder = output_folder, - }; - - return self; -} - -fn process(step: *Step, options: Step.MakeOptions) anyerror!void { - const progress = options.progress_node.start("Processing assets...", 100); - defer progress.end(); - const self: *ProcessAssetsStep = @fieldParentPtr("step", step); - const root = self.assets_path; - const output_folder = self.output_folder; - try generate(self.builder.allocator, step.owner.graph.io, root, output_folder); -} - -pub fn generate(allocator: std.mem.Allocator, io: Io, assets_root: []const u8, output_folder: []const u8) !void { - var atlases: usize = 0; - - const cwd = Io.Dir.cwd(); - - var dir = cwd.openDir(io, assets_root, .{ .access_sub_paths = true }) catch |err| { - std.debug.print("Not a directory: {s}, err: {}\n", .{ assets_root, err }); - return; - }; - dir.close(io); - - const files = try getAllFiles(allocator, io, assets_root, true); - - if (files.len == 0) { - std.debug.print("No assets found!", .{}); - return; - } - - for (files) |file| { - const ext = std.fs.path.extension(file); - - if (std.mem.eql(u8, ext, "")) continue; - - const base = std.fs.path.basename(file); - const ext_ind = std.mem.lastIndexOf(u8, base, "."); - const name = base[0..ext_ind.?]; - - const path_fixed = try allocator.alloc(u8, file.len); - _ = std.mem.replace(u8, file, "\\", "/", path_fixed); - - const name_fixed = try allocator.alloc(u8, name.len); - _ = std.mem.replace(u8, name, "-", "_", name_fixed); - - if (!std.mem.eql(u8, ext, ".atlas")) continue; - atlases += 1; - - var allocating: Io.Writer.Allocating = .init(allocator); - defer allocating.deinit(); - const atlas_writer = &allocating.writer; - - try atlas_writer.writeAll("// This is a generated file, do not edit.\n\n"); - try atlas_writer.print("// Sprites \n\n", .{}); - - var atlas = try Atlas.loadFromFile(allocator, io, file); - - try atlas_writer.print("pub const sprites = struct {{\n", .{}); - - for (atlas.sprites, 0..) |_, sprite_index| { - const sprite_name = try atlas.spriteName(allocator, sprite_index); - try atlas_writer.print(" pub const {s} = {d};\n", .{ sprite_name, sprite_index }); - } - - try atlas_writer.print("}};\n\n", .{}); - try atlas_writer.print("// Animations \n\n", .{}); - - if (atlas.animations.len > 0) { - try atlas_writer.print("pub const animations = struct {{\n", .{}); - - for (atlas.animations) |animation| { - const animation_name = try allocator.alloc(u8, animation.name.len); - _ = std.mem.replace(u8, animation.name, " ", "_", animation_name); - _ = std.mem.replace(u8, animation_name, ".", "_", animation_name); - - try atlas_writer.print(" pub var {s} = [_]usize {{\n", .{animation_name}); - - for (animation.frames) |frame| { - try atlas_writer.print(" sprites.{s},\n", .{try atlas.spriteName(allocator, frame.sprite_index)}); - } - - try atlas_writer.print(" }};\n", .{}); - } - - try atlas_writer.print("}};\n", .{}); - } - - const atlas_path = if (atlases > 1) blk: { - const atlas_name = try std.fmt.allocPrint(allocator, "{s}.zig", .{name}); - break :blk try std.fs.path.join(allocator, &[_][]const u8{ output_folder, atlas_name }); - } else try std.fs.path.join(allocator, &[_][]const u8{ output_folder, "atlas.zig" }); - - try cwd.writeFile(io, .{ - .sub_path = atlas_path, - .data = allocating.written(), - }); - } -} - -fn getAllFiles(allocator: std.mem.Allocator, io: Io, root_directory: []const u8, recurse: bool) ![][:0]const u8 { - var list: std.ArrayList([:0]const u8) = .empty; - - const recursor = struct { - fn search(alloc: std.mem.Allocator, scan_io: Io, directory: []const u8, recursive: bool, filelist: *std.ArrayList([:0]const u8)) !void { - var dir = try Io.Dir.cwd().openDir(scan_io, directory, .{ .access_sub_paths = true, .iterate = true }); - defer dir.close(scan_io); - - var iter = dir.iterate(); - while (try iter.next(scan_io)) |entry| { - if (entry.kind == .file) { - const name_null_term = try std.mem.concat(alloc, u8, &[_][]const u8{ entry.name, "\x00" }); - const abs_path = try std.fs.path.join(alloc, &[_][]const u8{ directory, name_null_term }); - try filelist.append(alloc, abs_path[0 .. abs_path.len - 1 :0]); - } else if (entry.kind == .directory) { - if (!recursive) continue; - const abs_path = try std.fs.path.join(alloc, &[_][]const u8{ directory, entry.name }); - try search(alloc, scan_io, abs_path, recursive, filelist); - } - } - } - }.search; - - try recursor(allocator, io, root_directory, recurse, &list); - - std.mem.sort([:0]const u8, list.items, Context{}, compare); - - return try list.toOwnedSlice(allocator); -} - -const Context = struct {}; -fn compare(_: Context, a: [:0]const u8, b: [:0]const u8) bool { - const base_a = std.fs.path.basename(a); - const base_b = std.fs.path.basename(b); - - return std.mem.order(u8, base_a, base_b) == .lt; -} diff --git a/readme.md b/readme.md index 895d6c31..4ddd6f8d 100644 --- a/readme.md +++ b/readme.md @@ -6,7 +6,7 @@ ![buildworkflow](https://github.com/fizzyedit/fizzy/actions/workflows/ci.yml/badge.svg) # -**Fizzy** is a cross-platform open-source pixel art editor and animation editor written in [Zig](https://github.com/ziglang/zig). +**Fizzy** is a cross-platform open-source modular general editor written in [Zig](https://github.com/ziglang/zig). ### Try it in your browser [here](https://fizzyed.it/app/) @@ -17,21 +17,6 @@ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R5R4LL2PJ) -## Currently supported features -- [x] Typical pixel art operations. (draw, erase, dropper, bucket, selection, transformation, etc) -- [x] Tabs and splits, drag and drop to reorder and reconfigure -- [x] File explorer with search and drag and drop. -- [ ] Create animations and preview easily, edit directly on the preview. -- [ ] View previous and next frames of the animation. -- [ ] Set sprite origins for drawing sprites easily in game frameworks. -- [ ] Import and slice existing .png spritesheets. -- [x] Intuitive and customizeable user interface. -- [x] Sprite packing -- [ ] Theming -- [ ] Automatic packing and export on file save -- [x] Also a zig library offering modules for handling assets -- [ ] Export animations as .gifs - ## User Interface - The user interface is driven by [DVUI](https://github.com/david-vanderson/dvui). - The general layout takes many ideas from VSCode or IDE's, as well as general project setup using folders. diff --git a/src/App.zig b/src/App.zig index 8782c644..b043b924 100644 --- a/src/App.zig +++ b/src/App.zig @@ -9,10 +9,7 @@ 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 text = @import("text"); const auto_update = @import("backend/auto_update.zig"); const update_notify = @import("backend/update_notify.zig"); const singleton = @import("backend/singleton.zig"); @@ -20,7 +17,6 @@ const paths = fizzy.paths; const App = @This(); const Editor = fizzy.Editor; -const Packer = pixelart.Packer; // App fields allocator: std.mem.Allocator = undefined, @@ -63,7 +59,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,39 +173,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; - - // 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 - // packs the currently-open files instead of walking a project directory. - fizzy.packer = try allocator.create(Packer); - fizzy.packer.* = Packer.init(allocator) catch unreachable; + // Workbench shell-owned state: wire before plugin `register`. + workbench.runtime.setWorkbench(&fizzy.editor.workbench); - pixelart.Globals.packer = fizzy.packer; - fizzy.editor.syncLoadedPixelartGlobals(); + // Second-stage init that needs the editor at its final heap address (e.g. registering the + // workbench-api service whose `ctx` is this pointer). This loads the built-in plugins, + // including pixi as a generic dylib that owns its own state + atlas packer. + fizzy.editor.postInit() catch unreachable; // 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. @@ -249,13 +226,9 @@ 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. - pixelart.State.persistProject(fizzy.editor.pixelart_state); + // `editor.deinit` runs each plugin's `deinit` first (pixi's persists its `.fizproject` and + // frees its own state + packer while `editor.host`/folder are still live). 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); // 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/backend/plugin_store/compat.zig b/src/backend/plugin_store/compat.zig new file mode 100644 index 00000000..e1e846f0 --- /dev/null +++ b/src/backend/plugin_store/compat.zig @@ -0,0 +1,104 @@ +//! Compatibility matching between the running host and a registry plugin's releases. +//! +//! A prebuilt plugin dylib is valid only for one `(abi_fingerprint, os-arch)` pair (see +//! `docs/PLUGINS.md` § Compatibility), so selection is exact on the fingerprint + arch — not +//! a semver negotiation. Pure logic; fully unit-tested. +const std = @import("std"); +const builtin = @import("builtin"); +const registry = @import("registry.zig"); + +/// The host's `os-arch` key, matching the registry `downloads` object keys +/// (e.g. "macos-aarch64"). Comptime-known. +pub fn hostKey() []const u8 { + const os = switch (builtin.os.tag) { + .macos => "macos", + .linux => "linux", + .windows => "windows", + else => "unknown", + }; + const arch = switch (builtin.cpu.arch) { + .aarch64 => "aarch64", + .x86_64 => "x86_64", + else => "unknown", + }; + return os ++ "-" ++ arch; +} + +/// Parse a "0x…" (or bare hex/decimal) fingerprint string into a u64, or null if malformed. +pub fn parseFingerprint(s: []const u8) ?u64 { + const trimmed = std.mem.trim(u8, s, " \t\r\n"); + return std.fmt.parseInt(u64, trimmed, 0) catch null; +} + +/// The newest release of `entry` that is loadable on this host: its `abi_fingerprint` equals +/// `host_fp` **and** it ships a binary for `host_key`. Returns null when none qualifies (the +/// store shows "needs a rebuild for this Fizzy SDK"). +pub fn selectRelease( + entry: registry.PluginEntry, + host_fp: u64, + host_key: []const u8, +) ?registry.Release { + var best: ?registry.Release = null; + var best_ver: std.SemanticVersion = undefined; + for (entry.releases) |candidate| { + const fp = parseFingerprint(candidate.abi_fingerprint) orelse continue; + if (fp != host_fp) continue; + if (candidate.downloadFor(host_key) == null) continue; + const ver = std.SemanticVersion.parse(candidate.version) catch continue; + if (best == null or ver.order(best_ver) == .gt) { + best = candidate; + best_ver = ver; + } + } + return best; +} + +const testing = std.testing; + +/// Build a release whose `downloads` map (allocated with the testing allocator) has an entry +/// per `keys`. Returned by value; the map's backing memory stays alive until `freeRel`. +fn rel(version: []const u8, fp: []const u8, keys: []const []const u8) registry.Release { + var map: std.json.ArrayHashMap(registry.Download) = .{}; + for (keys) |k| { + map.map.put(testing.allocator, k, .{ .url = "u", .sha256 = "s" }) catch {}; + } + return .{ .version = version, .abi_fingerprint = fp, .downloads = map }; +} + +fn freeRel(r: registry.Release) void { + var m = r.downloads; + m.map.deinit(testing.allocator); +} + +test "selectRelease picks newest matching fingerprint + arch" { + const releases = [_]registry.Release{ + rel("1.0.0", "0x10", &.{"macos-aarch64"}), + rel("1.2.0", "0x10", &.{"macos-aarch64"}), + rel("1.3.0", "0x99", &.{"macos-aarch64"}), // wrong fingerprint + rel("1.1.0", "0x10", &.{"linux-x86_64"}), // wrong arch + }; + defer for (releases) |r| freeRel(r); + + const entry = registry.PluginEntry{ .id = "x", .releases = &releases }; + const picked = selectRelease(entry, 0x10, "macos-aarch64") orelse return error.NoMatch; + try testing.expectEqualStrings("1.2.0", picked.version); +} + +test "selectRelease returns null when fingerprint never matches" { + const releases = [_]registry.Release{rel("2.0.0", "0xdead", &.{"macos-aarch64"})}; + defer for (releases) |r| freeRel(r); + const entry = registry.PluginEntry{ .id = "x", .releases = &releases }; + try testing.expect(selectRelease(entry, 0x10, "macos-aarch64") == null); +} + +test "selectRelease returns null when arch is missing" { + const releases = [_]registry.Release{rel("2.0.0", "0x10", &.{"windows-x86_64"})}; + defer for (releases) |r| freeRel(r); + const entry = registry.PluginEntry{ .id = "x", .releases = &releases }; + try testing.expect(selectRelease(entry, 0x10, "macos-aarch64") == null); +} + +test "parseFingerprint handles 0x and whitespace" { + try testing.expectEqual(@as(?u64, 0x146eaf7c2f9605a), parseFingerprint(" 0x0146eaf7c2f9605a\n")); + try testing.expect(parseFingerprint("nothex") == null); +} diff --git a/src/backend/plugin_store/download.zig b/src/backend/plugin_store/download.zig new file mode 100644 index 00000000..4a6c3108 --- /dev/null +++ b/src/backend/plugin_store/download.zig @@ -0,0 +1,84 @@ +//! Download + SHA-256-verified install of a plugin binary into the plugins dir. +//! +//! The downloaded bytes are verified against the manifest's `sha256` before being written, and +//! the host's ABI fingerprint + id are re-checked at load time (`PluginLoader`). Hashing logic +//! is unit-tested; the network + filesystem half is exercised by the Chunk 5/7 E2E. +const std = @import("std"); + +const Sha256 = std.crypto.hash.sha2.Sha256; + +pub const Error = error{ HttpStatus, Sha256Mismatch }; + +/// Lowercase-hex SHA-256 of `data`. +pub fn sha256Hex(data: []const u8) [Sha256.digest_length * 2]u8 { + var digest: [Sha256.digest_length]u8 = undefined; + Sha256.hash(data, &digest, .{}); + var hex: [Sha256.digest_length * 2]u8 = undefined; + const charset = "0123456789abcdef"; + for (digest, 0..) |b, i| { + hex[i * 2] = charset[b >> 4]; + hex[i * 2 + 1] = charset[b & 0xf]; + } + return hex; +} + +/// True if `data`'s SHA-256 equals `expected_hex` (case-insensitive, surrounding whitespace +/// ignored). A malformed expectation (wrong length) is treated as a mismatch. +pub fn matchesSha256(data: []const u8, expected_hex: []const u8) bool { + const exp = std.mem.trim(u8, expected_hex, " \t\r\n"); + if (exp.len != Sha256.digest_length * 2) return false; + const actual = sha256Hex(data); + for (actual, 0..) |c, i| { + if (std.ascii.toLower(exp[i]) != c) return false; + } + return true; +} + +/// HTTPS GET `url` into memory, verify its SHA-256, then atomically install at `dest_path` +/// (absolute; e.g. `{config}/plugins/{id}.{ext}`) via a temp file + rename. Rejects and +/// installs nothing on a non-200 status or a hash mismatch. +pub fn download( + allocator: std.mem.Allocator, + io: std.Io, + url: []const u8, + expected_sha256: []const u8, + dest_path: []const u8, +) !void { + var client: std.http.Client = .{ .allocator = allocator, .io = io }; + defer client.deinit(); + + var body: std.Io.Writer.Allocating = .init(allocator); + defer body.deinit(); + + const result = try client.fetch(.{ + .location = .{ .url = url }, + .response_writer = &body.writer, + }); + if (result.status != .ok) return error.HttpStatus; + + const data = body.written(); + if (!matchesSha256(data, expected_sha256)) return error.Sha256Mismatch; + + // Write to a sibling temp file, then rename into place so a crash mid-write never leaves a + // half-written dylib at the load path. + const tmp_path = try std.fmt.allocPrint(allocator, "{s}.part", .{dest_path}); + defer allocator.free(tmp_path); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = tmp_path, .data = data }); + try std.Io.Dir.renameAbsolute(tmp_path, dest_path, io); +} + +const testing = std.testing; + +test "sha256Hex matches a known vector" { + // SHA-256("abc") + const want = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + try testing.expectEqualStrings(want, &sha256Hex("abc")); +} + +test "matchesSha256 accepts correct digest case-insensitively, rejects others" { + const want = "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"; + try testing.expect(matchesSha256("abc", want)); + try testing.expect(matchesSha256("abc", " ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad \n")); + try testing.expect(!matchesSha256("abcd", want)); // wrong data + try testing.expect(!matchesSha256("abc", "deadbeef")); // wrong length +} diff --git a/src/backend/plugin_store/registry.zig b/src/backend/plugin_store/registry.zig new file mode 100644 index 00000000..875ead16 --- /dev/null +++ b/src/backend/plugin_store/registry.zig @@ -0,0 +1,131 @@ +//! The plugin-store registry: the typed shape of the hosted `index.json` plus a fetch + +//! parse path. The index is aggregated from each author's manifest (see PLUGINS_PLAN.md +//! § B) and served read-only over HTTPS; this module never writes it. +//! +//! Pure of dvui/globals — callers pass `allocator` and a `std.Io`. The parse half is +//! unit-tested; the network half (`fetchIndex`) is exercised by the Chunk 5/7 E2E. +const std = @import("std"); + +/// One downloadable binary for a specific `os-arch` (e.g. "macos-aarch64"). `sha256` is the +/// lowercase hex digest the client verifies after download (see `download.zig`). +pub const Download = struct { + url: []const u8 = "", + sha256: []const u8 = "", +}; + +/// One published build of a plugin. A plugin version yields one `Release` per Fizzy SDK +/// build it was compiled against (distinct `abi_fingerprint`); the client picks the entry +/// whose fingerprint + arch match the running host (see `compat.selectRelease`). +pub const Release = struct { + version: []const u8 = "", + min_sdk_version: []const u8 = "", + /// "0x…" hex string; the hard compatibility key (matches `sdk.dylib.abi_fingerprint`). + abi_fingerprint: []const u8 = "", + fizzy_sdk_version: []const u8 = "", + published: []const u8 = "", + /// `os-arch` → binary. Dynamic JSON object, so parsed via `std.json.ArrayHashMap`. + downloads: std.json.ArrayHashMap(Download) = .{}, + + /// The binary for `os_arch` (e.g. `compat.hostKey()`), or null when this release has none. + pub fn downloadFor(self: Release, os_arch: []const u8) ?Download { + return self.downloads.map.get(os_arch); + } +}; + +pub const PluginEntry = struct { + id: []const u8 = "", + name: []const u8 = "", + description: []const u8 = "", + author: []const u8 = "", + homepage: []const u8 = "", + tags: []const []const u8 = &.{}, + releases: []const Release = &.{}, +}; + +pub const Index = struct { + schema: u32 = 0, + generated: []const u8 = "", + plugins: []const PluginEntry = &.{}, + + pub fn pluginById(self: Index, id: []const u8) ?PluginEntry { + for (self.plugins) |p| { + if (std.mem.eql(u8, p.id, id)) return p; + } + return null; + } +}; + +/// Parse an `index.json` document. Caller owns the returned `Parsed` and must `deinit` it; +/// every slice in the `Index` points into its arena. +pub fn parseIndex(allocator: std.mem.Allocator, bytes: []const u8) !std.json.Parsed(Index) { + return std.json.parseFromSlice(Index, allocator, bytes, .{ + .ignore_unknown_fields = true, + .allocate = .alloc_always, + }); +} + +/// HTTPS GET + parse the registry index. The client auto-rescans system root certs for TLS. +pub fn fetchIndex(allocator: std.mem.Allocator, io: std.Io, url: []const u8) !std.json.Parsed(Index) { + var client: std.http.Client = .{ .allocator = allocator, .io = io }; + defer client.deinit(); + + var body: std.Io.Writer.Allocating = .init(allocator); + defer body.deinit(); + + const result = try client.fetch(.{ + .location = .{ .url = url }, + .response_writer = &body.writer, + }); + if (result.status != .ok) return error.HttpStatus; + + return parseIndex(allocator, body.written()); +} + +test "parseIndex reads plugins, releases, and dynamic downloads" { + const json = + \\{ + \\ "schema": 1, + \\ "generated": "2026-06-25T00:00:00Z", + \\ "plugins": [{ + \\ "id": "markdown", "name": "Markdown Editor", + \\ "description": "Edit markdown", "author": "someone", + \\ "tags": ["editor"], + \\ "releases": [{ + \\ "version": "1.2.0", "min_sdk_version": "0.5.0", + \\ "abi_fingerprint": "0x0146eaf7c2f9605a", "fizzy_sdk_version": "0.5.0", + \\ "published": "2026-06-25", + \\ "downloads": { + \\ "macos-aarch64": { "url": "https://x/m.dylib", "sha256": "ab" }, + \\ "linux-x86_64": { "url": "https://x/m.so", "sha256": "cd" } + \\ } + \\ }] + \\ }] + \\} + ; + var parsed = try parseIndex(std.testing.allocator, json); + defer parsed.deinit(); + + const idx = parsed.value; + try std.testing.expectEqual(@as(u32, 1), idx.schema); + const entry = idx.pluginById("markdown") orelse return error.MissingPlugin; + try std.testing.expectEqualStrings("Markdown Editor", entry.name); + try std.testing.expectEqual(@as(usize, 1), entry.releases.len); + + const rel = entry.releases[0]; + try std.testing.expectEqualStrings("0x0146eaf7c2f9605a", rel.abi_fingerprint); + const mac = rel.downloadFor("macos-aarch64") orelse return error.MissingDownload; + try std.testing.expectEqualStrings("https://x/m.dylib", mac.url); + try std.testing.expect(rel.downloadFor("windows-x86_64") == null); +} + +test "parseIndex tolerates unknown fields and missing optionals" { + const json = + \\{ "schema": 2, "extra_top": true, + \\ "plugins": [{ "id": "bare", "surprise": 1 }] } + ; + var parsed = try parseIndex(std.testing.allocator, json); + defer parsed.deinit(); + const entry = parsed.value.pluginById("bare") orelse return error.MissingPlugin; + try std.testing.expectEqual(@as(usize, 0), entry.releases.len); + try std.testing.expectEqualStrings("", entry.name); +} diff --git a/src/backend/plugin_store/store.zig b/src/backend/plugin_store/store.zig new file mode 100644 index 00000000..6e349b44 --- /dev/null +++ b/src/backend/plugin_store/store.zig @@ -0,0 +1,99 @@ +//! Plugin-store backend: registry fetch + compatibility matching + verified download, plus a +//! small threaded `Catalog` that owns the latest parsed index. Pure of dvui/globals — the +//! caller supplies `allocator` + `std.Io` (the app passes `dvui.io`). The store UI (Chunk 5) +//! drives `Catalog` and tracks per-plugin install state on top of this. +const std = @import("std"); + +pub const registry = @import("registry.zig"); +pub const compat = @import("compat.zig"); +pub const download = @import("download.zig"); + +pub const Index = registry.Index; +pub const PluginEntry = registry.PluginEntry; +pub const Release = registry.Release; + +/// Lifecycle of the registry index fetch (not a per-plugin install state — that lives in the UI). +pub const Status = enum(u8) { idle, fetching, ready, failed }; + +const Parsed = std.json.Parsed(registry.Index); + +/// Owns the latest parsed `index.json`, refreshed off the UI thread by a real `std.Thread` +/// worker. Shared state (`parsed`) is guarded by a `std.Io.Mutex` — the codebase's pattern for +/// coordinating a `std.Thread` worker with the GUI thread (see pixi's `SaveQueue`): lock with +/// `dvui.io`, and `join` the worker on `deinit`. The owner must outlive any in-flight refresh (in +/// the app it is `Editor`-owned, app-lifetime). +/// +/// Read access goes through `acquire`/`release`: hold the lock across any read of the returned +/// `Index` so the worker can't free the arena underneath a reader. +pub const Catalog = struct { + allocator: std.mem.Allocator, + io: std.Io, + url: []const u8, + status_value: std.atomic.Value(u8) = .init(@intFromEnum(Status.idle)), + mutex: std.Io.Mutex = .init, + parsed: ?Parsed = null, + /// Handle to the most recent worker; joined on the next `refresh`/`deinit` so finished + /// threads are reclaimed and shutdown waits for any in-flight fetch. + worker_thread: ?std.Thread = null, + + pub fn init(allocator: std.mem.Allocator, io: std.Io, url: []const u8) Catalog { + return .{ .allocator = allocator, .io = io, .url = url }; + } + + pub fn deinit(self: *Catalog) void { + if (self.worker_thread) |t| { + t.join(); + self.worker_thread = null; + } + if (self.parsed) |*p| p.deinit(); + self.parsed = null; + } + + pub fn status(self: *Catalog) Status { + return @enumFromInt(self.status_value.load(.acquire)); + } + + /// Kick off a background refresh. No-op while one is already in flight. + pub fn refresh(self: *Catalog) void { + if (self.status() == .fetching) return; + if (self.worker_thread) |t| { // reclaim a previous, already-finished worker + t.join(); + self.worker_thread = null; + } + self.status_value.store(@intFromEnum(Status.fetching), .release); + self.worker_thread = std.Thread.spawn(.{}, worker, .{self}) catch { + self.status_value.store(@intFromEnum(Status.failed), .release); + return; + }; + } + + fn worker(self: *Catalog) void { + const fresh = registry.fetchIndex(self.allocator, self.io, self.url) catch { + self.status_value.store(@intFromEnum(Status.failed), .release); + return; + }; + self.mutex.lockUncancelable(self.io); + if (self.parsed) |*p| p.deinit(); // free the previous index; no leak + self.parsed = fresh; + self.mutex.unlock(self.io); + self.status_value.store(@intFromEnum(Status.ready), .release); + } + + /// Lock the catalog and return the parsed index (or null if none yet). The slices stay valid + /// until the matching `release` — hold the lock across any read of them. Pair with `release`. + pub fn acquire(self: *Catalog) ?registry.Index { + self.mutex.lockUncancelable(self.io); + return if (self.parsed) |p| p.value else null; + } + + pub fn release(self: *Catalog) void { + self.mutex.unlock(self.io); + } +}; + +test { + // Pull the building blocks' tests into the unit-test target. + _ = registry; + _ = compat; + _ = download; +} diff --git a/src/core/core.zig b/src/core/core.zig index 2fd4cd4b..728e03f5 100644 --- a/src/core/core.zig +++ b/src/core/core.zig @@ -28,10 +28,6 @@ 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"); 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/core/generated/atlas.zig b/src/core/generated/atlas.zig deleted file mode 100644 index 174bc9b5..00000000 --- a/src/core/generated/atlas.zig +++ /dev/null @@ -1,74 +0,0 @@ -// This is a generated file, do not edit. - -// Sprites - -pub const sprites = struct { - pub const cursor_default = 0; - pub const pencil_default = 1; - pub const eraser_default = 2; - pub const bucket_default = 3; - pub const box_selection_default = 4; - pub const box_selection_add_default = 5; - pub const box_selection_rem_default = 6; - pub const Sprite_7 = 7; - pub const Sprite_8 = 8; - pub const Sprite_9 = 9; - pub const color_selection_default = 10; - pub const color_selection_add_default = 11; - pub const color_selection_rem_default = 12; - pub const pixel_selection_default = 13; - pub const pixel_selection_add_default = 14; - pub const pixel_selection_rem_default = 15; - pub const fox_default = 16; - pub const logo_default = 17; -}; - -// Animations - -pub const animations = struct { - pub var cursor_default = [_]usize { - sprites.cursor_default, - }; - pub var pencil_default = [_]usize { - sprites.pencil_default, - }; - pub var eraser_default = [_]usize { - sprites.eraser_default, - }; - pub var bucket_default = [_]usize { - sprites.bucket_default, - }; - pub var box_selection_default = [_]usize { - sprites.box_selection_default, - }; - pub var box_selection_add_default = [_]usize { - sprites.box_selection_add_default, - }; - pub var box_selection_rem_default = [_]usize { - sprites.box_selection_rem_default, - }; - pub var color_selection_default = [_]usize { - sprites.color_selection_default, - }; - pub var color_selection_add_default = [_]usize { - sprites.color_selection_add_default, - }; - pub var color_selection_rem_default = [_]usize { - sprites.color_selection_rem_default, - }; - pub var pixel_selection_default = [_]usize { - sprites.pixel_selection_default, - }; - pub var pixel_selection_add_default = [_]usize { - sprites.pixel_selection_add_default, - }; - pub var pixel_selection_rem_default = [_]usize { - sprites.pixel_selection_rem_default, - }; - pub var fox_default = [_]usize { - sprites.fox_default, - }; - pub var logo_default = [_]usize { - sprites.logo_default, - }; -}; diff --git a/src/core/paths.zig b/src/core/paths.zig index 721da93f..1f09cabc 100644 --- a/src/core/paths.zig +++ b/src/core/paths.zig @@ -1,6 +1,33 @@ const std = @import("std"); const builtin = @import("builtin"); -const known_folders = @import("known-folders"); + +/// The OS "local configuration" root — fizzy's own canonical mapping (formerly `known-folders` +/// `.local_configuration`). **Single source of truth, shared by the runtime loader (`configRoot` +/// below) and the build-time plugin installer (`plugin_sdk.zig`'s `fizzyPluginsDir`)** so a +/// plugin's install location and the editor's load location can never drift apart. Pure: the +/// caller supplies the env values it read with its own env API. +/// macOS `{home}/Library/Application Support` +/// Linux `{xdg_config_home}` or `{home}/.config` +/// Windows `{local_app_data}` (FOLDERID_LocalAppData — *not* Roaming/`%APPDATA%`) +pub fn localConfigRoot( + os: std.Target.Os.Tag, + allocator: std.mem.Allocator, + home: ?[]const u8, + xdg_config_home: ?[]const u8, + local_app_data: ?[]const u8, +) !?[]const u8 { + return switch (os) { + .windows => local_app_data, + .macos => if (home) |h| + try std.fs.path.join(allocator, &.{ h, "Library", "Application Support" }) + else + null, + else => xdg_config_home orelse (if (home) |h| + try std.fs.path.join(allocator, &.{ h, ".config" }) + else + null), + }; +} pub fn configRoot( io: std.Io, @@ -8,10 +35,21 @@ pub fn configRoot( environ: std.process.Environ, fallback: []const u8, ) ![]const u8 { + _ = io; if (comptime builtin.target.cpu.arch == .wasm32) return fallback; - var environ_map = try environ.createMap(arena); - defer environ_map.deinit(); - return known_folders.getPath(io, arena, environ_map, .local_configuration) catch fallback orelse fallback; + const get = struct { + fn f(env: std.process.Environ, a: std.mem.Allocator, name: []const u8) ?[]const u8 { + return env.getAlloc(a, name) catch null; + } + }.f; + const root = (localConfigRoot( + builtin.target.os.tag, + arena, + get(environ, arena, "HOME"), + get(environ, arena, "XDG_CONFIG_HOME"), + get(environ, arena, "LOCALAPPDATA"), + ) catch fallback) orelse fallback; + return root; } pub fn configFolder( diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 59e0110c..77930bb8 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -16,7 +16,6 @@ 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"); const update_notify = @import("../backend/update_notify.zig"); @@ -30,11 +29,14 @@ pub const Dialogs = @import("dialogs/Dialogs.zig"); pub const Keybinds = @import("Keybinds.zig"); const workbench_mod = @import("workbench"); -const code_mod = @import("code"); +const text_mod = @import("text"); +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"); +const PluginStore = @import("PluginStore.zig"); pub const Workspace = workbench_mod.Workspace; pub const Explorer = @import("explorer/Explorer.zig"); @@ -59,24 +61,27 @@ arena: std.heap.ArenaAllocator, config_folder: []const u8, palette_folder: []const u8, -atlas: fizzy.core.Atlas, - /// Plugin registry + service locator exposed to plugins host: Host, -/// Pixel-art plugin runtime state (owned by App; wired into `Globals.state`). -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, +/// User-disabled plugin ids (store "disable"), each app-allocator-owned. This is the +/// authoritative runtime set; `settings.disabled_plugins` is pointed at `.items` for +/// persistence (see `seedDisabledPlugins` / `setDisabledPersisted`). Freed in `deinit`. +disabled_plugin_ids: std.ArrayListUnmanaged([]const u8) = .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, @@ -252,13 +257,8 @@ pub fn init( .infobar = try .init(), .arena = .init(std.heap.page_allocator), .last_titlebar_color = dvui.themeGet().color(.control, .fill), - .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, .host = .init(app.allocator), - .pixelart_state = undefined, .workbench = .init(app.allocator), }; @@ -436,9 +436,6 @@ pub fn init( editor.open_files = .empty; 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). - try Keybinds.register(); // Collect the initial settings json (shell fields + per-plugin blobs) for autosave dedup. @@ -456,20 +453,20 @@ pub fn init( /// Stable shell-builtin contribution id. pub const view_settings = "shell.settings"; -fn loadPixelartFromDylibEnabled() bool { +fn loadWorkbenchFromDylibEnabled() 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_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; } -fn loadWorkbenchFromDylibEnabled() bool { +fn loadTextFromDylibEnabled() 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| { + if (comptime build_opts.static_text) return false; + if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_TEXT")) |v| { defer fizzy.app.allocator.free(v); return v.len == 0 or v[0] == '0'; } else |_| {} @@ -484,9 +481,9 @@ 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"); +/// Registered text plugin (dylib or static). Panics if missing after `postInit`. +pub fn textPlugin(editor: *Editor) *sdk.Plugin { + return editor.host.pluginById("text") orelse @panic("text plugin not registered"); } /// Push host dvui state into every loaded plugin dylib image. @@ -513,60 +510,547 @@ 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-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); + 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/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry. -pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void { +/// Load `{exe_dir}/plugins/text.{ext}` and register via dylib entry. +pub fn loadTextDylib(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, "text"); errdefer fizzy.app.allocator.free(path); - const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "pixelart", .{ + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "text", .{ .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 ("workbench", "text") 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; + + // User-disabled plugins (store "disable") stay on disk but are not loaded. + if (editor.isPluginDisabled(plugin_id)) { + dvui.log.info("user plugin '{s}' is disabled; skipped", .{plugin_id}); + 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); + + for (editor.disabled_plugin_ids.items) |id| fizzy.app.allocator.free(id); + editor.disabled_plugin_ids.deinit(fizzy.app.allocator); +} + +// ---- runtime plugin lifecycle (store: install / enable / disable / update) --------- +// +// Only dylib-loaded *user* plugins are managed here. Bundled built-ins (pixi/workbench/ +// code) ship in the app and are never unloaded, even though they also appear in +// `loaded_plugin_libs` when loaded from their bundled dylibs. + +/// Built-in plugin ids that ship in the app and must never be store-managed. +fn isBundledPluginId(id: []const u8) bool { + return std.mem.eql(u8, id, "workbench") or + std.mem.eql(u8, id, "text"); +} + +/// Static built-ins that are compiled in and registered unconditionally in `postInit` (so they +/// never appear in `loaded_plugin_libs`). "Disabling" one hides its sidebar view rather than +/// unloading a dylib — there is none. They are not in `isBundledPluginId` because, unlike the +/// shell/workbench/text core, the user *can* toggle their visibility from the store. +pub fn isStaticHidePlugin(id: []const u8) bool { + return std.mem.eql(u8, id, "example"); +} + +/// Show or hide every sidebar view owned by `id`. Used to toggle static-plugin visibility +/// without load/unload. +fn setPluginViewsHidden(editor: *Editor, id: []const u8, hidden: bool) void { + for (editor.host.sidebar_views.items) |*view| { + const owner = view.owner orelse continue; + if (std.mem.eql(u8, owner.id, id)) view.hidden = hidden; + } +} + +/// True when `id` names a runtime-loaded user plugin that may be unloaded/disabled. +pub fn isUnloadablePlugin(editor: *Editor, id: []const u8) bool { + if (isBundledPluginId(id)) return false; + for (editor.loaded_plugin_libs.items) |loaded| { + if (std.mem.eql(u8, loaded.plugin_id, id)) return true; + } + return false; +} + +pub fn isPluginDisabled(editor: *Editor, id: []const u8) bool { + for (editor.disabled_plugin_ids.items) |d| { + if (std.mem.eql(u8, d, id)) return true; + } + return false; +} + +/// True when `id` looks like a real plugin id (ASCII identifier), not corrupted settings data. +fn isValidPluginId(id: []const u8) bool { + if (id.len == 0 or id.len > 64) return false; + if (!std.unicode.utf8ValidateSlice(id)) return false; + for (id) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') return false; + } + return true; +} + +/// Seed the runtime disabled set from the persisted `settings.disabled_plugins`, then +/// re-point `settings.disabled_plugins` at the owned list so future saves serialize it. +/// Call once after settings load, before `loadUserPlugins`. +fn seedDisabledPlugins(editor: *Editor) void { + var dropped_invalid = false; + for (editor.settings.disabled_plugins) |id| { + if (!isValidPluginId(id)) { + dropped_invalid = true; + dvui.log.warn("settings: dropping invalid disabled_plugins entry", .{}); + continue; + } + const dup = fizzy.app.allocator.dupe(u8, id) catch continue; + editor.disabled_plugin_ids.append(fizzy.app.allocator, dup) catch { + fizzy.app.allocator.free(dup); + }; + } + editor.settings.disabled_plugins = editor.disabled_plugin_ids.items; + if (dropped_invalid) editor.host.markSettingsDirty(); +} + +/// Add or remove `id` from the persisted disabled set and write it to disk **immediately**. +/// Re-points `settings.disabled_plugins` because the backing list may have reallocated. +/// +/// Enable/disable is a discrete, infrequent, important action, so it is flushed synchronously +/// rather than through the debounced autosave: the debounce + idle frames + a shutdown that may +/// never run `deinit` (fizzy ignores SIGTERM) previously let a toggle be lost if the app went idle +/// or quit within the autosave window. On wasm (no filesystem) we fall back to the in-memory dirty +/// flag. +fn setDisabledPersisted(editor: *Editor, id: []const u8, disabled: bool) !void { + if (disabled and !isValidPluginId(id)) return error.InvalidPluginId; + const present_at: ?usize = blk: { + for (editor.disabled_plugin_ids.items, 0..) |d, i| { + if (std.mem.eql(u8, d, id)) break :blk i; + } + break :blk null; + }; + if (disabled) { + if (present_at == null) { + const dup = try fizzy.app.allocator.dupe(u8, id); + errdefer fizzy.app.allocator.free(dup); + try editor.disabled_plugin_ids.append(fizzy.app.allocator, dup); + } + } else if (present_at) |i| { + const owned = editor.disabled_plugin_ids.orderedRemove(i); + fizzy.app.allocator.free(owned); + } + editor.settings.disabled_plugins = editor.disabled_plugin_ids.items; + if (comptime builtin.target.cpu.arch == .wasm32) { + editor.host.markSettingsDirty(); + } else { + // Durable, synchronous write now; fall back to the autosave if the write fails. + editor.saveSettingsRaw() catch |err| { + dvui.log.err("Failed to persist disabled plugins immediately ({s}); deferring to autosave", .{@errorName(err)}); + editor.host.markSettingsDirty(); + }; + } +} + +/// Rebuild the whole window keybind map from scratch: shell binds + every *currently +/// registered* plugin's `contributeKeybinds`. Used after a plugin is unregistered so its +/// binds (whose key strings live in the soon-to-be-`dlclose`d image) are dropped. +fn rebuildKeybinds(editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const window = dvui.currentWindow(); + window.keybinds.clearRetainingCapacity(); + Keybinds.register() catch |err| dvui.log.err("keybind rebuild (shell) failed: {s}", .{@errorName(err)}); + for (editor.host.plugins.items) |plugin| { + plugin.contributeKeybinds(window) catch |err| + dvui.log.err("keybind rebuild ('{s}') failed: {s}", .{ plugin.id, @errorName(err) }); + } +} + +/// True if `plugin` owns any currently-dirty open document. +fn pluginHasDirtyDocs(editor: *Editor, plugin: *sdk.Plugin) bool { + for (editor.open_files.values()) |doc| { + if (doc.owner == plugin and doc.owner.isDirty(doc)) return true; + } + return false; +} + +pub const UnloadError = error{ NotUnloadable, DirtyDocuments }; + +/// Load `{config}/plugins/{id}.{ext}` live and register it. Reuses the same loader + +/// dvui/render-bridge sync path as `loadUserPlugins`. Caller ensures `id` is not already +/// registered. On success the lib is appended to `loaded_plugin_libs`. +pub fn loadUserPluginById(editor: *Editor, id: []const u8) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable; + const file_name = try PluginLoader.pluginFilename(id, fizzy.app.allocator); + defer fizzy.app.allocator.free(file_name); + const path = try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "plugins", file_name }); + errdefer fizzy.app.allocator.free(path); + + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, id, .{ + .gpa = &fizzy.app.allocator, + .arg_b = @ptrCast(&editor.host), + .arg_c = null, + }); + try editor.appendLoadedPluginLib(loaded); + syncLoadedPluginDvuiContexts(editor); + syncLoadedPluginRenderBridge(editor); + rebuildKeybinds(editor); +} + +/// Install (file already downloaded to the plugins dir by the store backend) + load live. +/// Clears any disabled flag so the plugin stays enabled across restarts. +pub fn installAndLoadPlugin(editor: *Editor, id: []const u8) !void { + if (isBundledPluginId(id)) return error.NotUnloadable; + if (editor.host.pluginById(id) != null) return; // already loaded + try editor.setDisabledPersisted(id, false); + try editor.loadUserPluginById(id); +} + +/// True if `plugin` owns any document with an async save still in flight. +fn pluginHasSavingDocs(editor: *Editor, plugin: *sdk.Plugin) bool { + for (editor.open_files.values()) |doc| { + if (doc.owner == plugin and doc.owner.isDocumentSaving(doc)) return true; + } + return false; +} + +/// Spin until none of `plugin`'s open documents report `isDocumentSaving`. Called from +/// `unloadPlugin` on the GUI thread while the save-queue worker runs concurrently. +fn waitForPluginSaves(editor: *Editor, plugin: *sdk.Plugin) void { + while (pluginHasSavingDocs(editor, plugin)) { + std.Thread.yield() catch {}; + } +} + +/// Cancel and await every in-flight `FileLoadJob` owned by `plugin`, then drop its staging +/// buffer — so `unloadPlugin` can never `dlclose` the image while a worker thread is still +/// inside `owner.loadDocument` / `deinitDocumentBuffer` (use-after-free). Owner-scoped so a +/// load belonging to an unrelated plugin survives. Mirrors `waitForPluginSaves`; runs on the +/// GUI thread before any teardown. +fn cancelPluginLoadingJobs(editor: *Editor, plugin: *sdk.Plugin) void { + if (editor.loading_jobs.count() == 0) return; + + // Signal cancellation first so a worker that has not yet entered (or has just exited) the + // loader bails at its next checkpoint instead of re-entering the soon-unmapped image. + { + var it = editor.loading_jobs.valueIterator(); + while (it.next()) |job_ptr| { + if (job_ptr.*.owner == plugin) job_ptr.*.cancelled.store(true, .monotonic); + } + } + + // Collect this plugin's jobs up front — cleanup mutates `loading_jobs`, so we can't hold + // the map iterator across removal. + var owned: std.ArrayListUnmanaged(*FileLoadJob) = .empty; + defer owned.deinit(fizzy.app.allocator); + { + var it = editor.loading_jobs.valueIterator(); + while (it.next()) |job_ptr| { + if (job_ptr.*.owner == plugin) owned.append(fizzy.app.allocator, job_ptr.*) catch {}; + } + } + + for (owned.items) |job| { + // Block until the worker has fully left the dylib before we free through the owner. + while (!job.done.load(.acquire)) std.Thread.yield() catch {}; + _ = editor.loading_jobs.remove(job.path); + // Drop the partial open without inserting it into `open_files`. `ready`/`failed` + // need exactly one `deinitDocumentBuffer`; a `cancelled` job was either freed by the + // worker (late cancel) or never constructed (early cancel), so skip it to avoid a + // double-free / deinit-on-uninitialized buffer. + switch (job.currentPhase()) { + .ready, .failed => job.owner.deinitDocumentBuffer(job.doc_buf.ptr), + else => {}, + } + job.destroy(); + } +} + +/// Unload a runtime user plugin live: close its documents, tear down its contributions, +/// deinit its state, then `dlclose`. With `force == false`, aborts with `DirtyDocuments` +/// if any owned document is dirty (the caller decides whether to prompt/save first). +pub fn unloadPlugin(editor: *Editor, id: []const u8, force: bool) UnloadError!void { + if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable; + if (!editor.isUnloadablePlugin(id)) return error.NotUnloadable; + const plugin = editor.host.pluginById(id) orelse return error.NotUnloadable; + + const lib_index: usize = blk: { + for (editor.loaded_plugin_libs.items, 0..) |loaded, i| { + if (std.mem.eql(u8, loaded.plugin_id, id)) break :blk i; + } + return error.NotUnloadable; + }; + + if (!force and editor.pluginHasDirtyDocs(plugin)) return error.DirtyDocuments; + + // Let in-flight async saves finish while the owning `File` records still exist. + editor.waitForPluginSaves(plugin); + + // Cancel + await any in-flight file loads owned by this plugin so no worker calls into + // the dylib after we `dlclose` it below. + editor.cancelPluginLoadingJobs(plugin); + + // Close every document this plugin owns. Collect ids first — closing mutates + // `open_files` underneath us. + var owned: std.ArrayListUnmanaged(u64) = .empty; + defer owned.deinit(fizzy.app.allocator); + for (editor.open_files.values()) |doc| { + if (doc.owner == plugin) owned.append(fizzy.app.allocator, doc.id) catch {}; + } + for (owned.items) |doc_id| editor.rawCloseFileID(doc_id) catch |err| + dvui.log.err("unloadPlugin '{s}': closing doc {d} failed: {s}", .{ id, doc_id, @errorName(err) }); + + // Drop empty workspace panes (and plugin canvas chrome) before plugin `deinit`. + editor.rebuildWorkspaces() catch |err| + dvui.log.err("unloadPlugin '{s}': rebuildWorkspaces failed: {s}", .{ id, @errorName(err) }); + + // Remove all contributions + services + active-id references (before dlclose), then + // run the plugin's own teardown. + editor.host.unregisterPlugin(plugin); + plugin.deinit(); + + // Drop the unloaded plugin's keybinds by rebuilding from the survivors. + rebuildKeybinds(editor); + + // Unmap the image and free our bookkeeping for it. + var loaded = editor.loaded_plugin_libs.orderedRemove(lib_index); + loaded.lib.close(); + fizzy.app.allocator.free(loaded.plugin_id); + fizzy.app.allocator.free(loaded.path); +} + +/// Enable or disable a plugin, persisting the choice and applying it live: disabling +/// unloads now; enabling loads the installed dylib now. +pub fn setPluginEnabled(editor: *Editor, id: []const u8, enabled: bool, force: bool) !void { + if (isBundledPluginId(id)) return error.NotUnloadable; + + // Static built-ins (e.g. `example`) stay registered for the whole session; toggle them by + // persisting the disabled flag + hiding/showing their sidebar views instead of load/unload. + if (isStaticHidePlugin(id)) { + try editor.setDisabledPersisted(id, !enabled); + editor.setPluginViewsHidden(id, !enabled); + return; + } + + if (enabled) { + try editor.setDisabledPersisted(id, false); + if (editor.host.pluginById(id) == null) try editor.loadUserPluginById(id); + } else { + // Persist before unload: `id` may point at static memory inside the plugin image. + try editor.setDisabledPersisted(id, true); + try editor.unloadPlugin(id, force); + } +} + +/// Replace an installed plugin with a freshly downloaded build (in the plugins dir already) +/// by unloading then reloading. `force` controls dirty-document handling on the unload. +pub fn updatePlugin(editor: *Editor, id: []const u8, force: bool) !void { + if (isBundledPluginId(id)) return error.NotUnloadable; + try editor.unloadPlugin(id, force); + try editor.loadUserPluginById(id); +} + +/// Fully remove a user plugin: unload it if loaded, clear any disabled flag, and delete its +/// dylib from `{config}/plugins/`. `force` controls dirty-document handling on the unload. +pub fn uninstallPlugin(editor: *Editor, id: []const u8, force: bool) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable; + if (isBundledPluginId(id)) return error.NotUnloadable; + // Static built-ins have no dylib on disk to delete — they can only be hidden, not removed. + if (isStaticHidePlugin(id)) return error.NotUnloadable; + if (editor.host.pluginById(id) != null) try editor.unloadPlugin(id, force); + // Drop any persisted disabled flag — the plugin no longer exists to be disabled. + try editor.setDisabledPersisted(id, false); + + const file_name = try PluginLoader.pluginFilename(id, fizzy.app.allocator); + defer fizzy.app.allocator.free(file_name); + const path = try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "plugins", file_name }); + defer fizzy.app.allocator.free(path); + std.Io.Dir.deleteFileAbsolute(dvui.io, path) catch |err| + dvui.log.warn("uninstallPlugin '{s}': could not delete {s}: {s}", .{ id, path, @errorName(err) }); } 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 +1077,37 @@ 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 (loadTextFromDylibEnabled()) { + editor.loadTextDylib(fizzy.app.root_path) catch |err| { + dvui.log.warn("text dylib load failed ({s}); falling back to static plugin", .{@errorName(err)}); + try text_mod.plugin.register(&editor.host); }; - try pixelartPlugin(editor).initPlugin(); } else { - try pixelart.plugin.register(&editor.host); - try pixelartPlugin(editor).initPlugin(); + try text_mod.plugin.register(&editor.host); } - 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); + + // Seed the runtime disabled set from settings (and re-point the persisted slice at + // it) before scanning, so disabled plugins are skipped at startup. + editor.seedDisabledPlugins(); + + // Static built-ins (example) stay registered even when disabled; honor a persisted or + // first-run-default disabled state by hiding their sidebar views. + if (editor.isPluginDisabled("example")) editor.setPluginViewsHidden("example", true); + + // 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: Plugin store (owner = null; not a plugin). Registered just before + // Settings so its icon sits directly above the cog in the sidebar rail. + try PluginStore.register(&editor.host); // Shell built-in: Settings (owner = null; not a plugin). try editor.host.registerSidebarView(.{ @@ -617,7 +1121,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 = "shell.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 }); @@ -637,7 +1141,11 @@ pub fn postInit(editor: *Editor) !void { // `web_main.zig`). if (comptime builtin.target.cpu.arch != .wasm32) { editor.workbench.initService(&editor.host); - try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api); + try editor.host.registerService( + Workbench.Api.service_name, + &editor.workbench.api, + editor.host.pluginById("workbench"), + ); } } @@ -683,7 +1191,6 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .explorerRect = shellExplorerRect, .explorerVirtualSize = shellExplorerVirtualSize, .showSaveDialog = shellShowSaveDialog, - .uiAtlas = shellUiAtlas, .activeDoc = shellActiveDoc, .docByIndex = shellDocByIndex, .docById = shellDocById, @@ -708,13 +1215,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 +1229,6 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{ .trackQuitSaveInFlight = shellTrackQuitSaveInFlight, .resumeSaveAllQuit = shellResumeSaveAllQuit, .abortSaveAllQuit = shellAbortSaveAllQuit, - .startPackProject = shellStartPackProject, - .isPackingActive = shellIsPackingActive, }; fn shellCtx(ctx: *anyopaque) *Editor { @@ -777,13 +1278,6 @@ fn shellShowSaveDialog( 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)), - }; -} fn shellActiveDoc(ctx: *anyopaque) ?sdk.DocHandle { return shellCtx(ctx).activeDoc(); } @@ -880,26 +1374,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 +1428,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 +1489,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 +1615,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; } @@ -1229,6 +1708,12 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Drain any "Save and Close" requests whose async save has settled. editor.tickPendingSaveCloses(); + + // Complete any finished plugin downloads by loading them live. Done here, before the + // Host-registry iterations below, so a newly-registered plugin never mutates a list + // mid-iteration. + PluginStore.tick(); + var needs_save_status_anim_tick = false; for (editor.host.plugins.items) |plugin| { if (plugin.tickOpenDocuments()) needs_save_status_anim_tick = true; @@ -1282,7 +1767,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 +1778,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 +1973,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 +2120,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 +2163,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 +2206,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 +2457,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 +2530,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 +2730,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 +2915,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 +2924,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 +2958,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 +2982,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 +3033,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 +3060,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) }); }; @@ -2763,6 +3261,11 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void { } pub fn deinit(editor: *Editor) !void { + // Tear workspaces down first: `Workspace.deinit` calls back into the owning plugin + // (e.g. `removeCanvasPane`), so it must run while plugin state is still alive — i.e. before + // the plugin `deinit` loop below frees it. + editor.workbench.deinitWorkspaces(); + // 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. for (editor.host.plugins.items) |plugin| plugin.deinit(); @@ -2811,18 +3314,19 @@ 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(); + PluginStore.deinit(); editor.unloadPluginLibs(); editor.host.deinit(); editor.workbench.deinit(); - // Pixel-art state (tools/colors/project/pack jobs) is torn down by - // `State.deinit` in `App.AppDeinit`, after this returns. + // Pixel-art state is owned by the pixi plugin now: its `pluginDeinit` (run in the plugin + // loop above) persists the project and frees its own state + packer. 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/editor/InstalledPlugins.zig b/src/editor/InstalledPlugins.zig new file mode 100644 index 00000000..252631f8 --- /dev/null +++ b/src/editor/InstalledPlugins.zig @@ -0,0 +1,25 @@ +//! Settings → Plugins: a pointer to the dedicated **Plugins** sidebar tab, which now owns the +//! full inventory + install/enable/disable/update controls (see `PluginStore.zig`). Kept as a +//! thin breadcrumb so users who look under Settings are directed to the right place. +const std = @import("std"); +const dvui = @import("dvui"); +const sdk = @import("sdk"); + +pub fn register(host: *sdk.Host) !void { + try host.registerSettingsSection(.{ + .id = "shell.settings.plugins", + .title = "Plugins", + .draw = drawPlugins, + }); +} + +fn drawPlugins(_: ?*anyopaque) anyerror!void { + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer vbox.deinit(); + dvui.labelNoFmt( + @src(), + "Browse, install, enable/disable, and update plugins in the Plugins tab (the bag icon in the sidebar, above Settings).", + .{}, + .{ .margin = .{ .y = 4 } }, + ); +} diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 981473bb..a0037481 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("shell.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..ba524f57 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,72 @@ 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 a plugin's user-facing display name straight from the dylib, **without +/// registering it**. Opens the image, reads the optional `fizzy_plugin_name` export, copies the +/// string out (the dylib is closed before returning), and unloads. Returns null when the dylib +/// can't be opened or predates the `fizzy_plugin_name` symbol. Caller owns the returned slice. +pub fn probeName(allocator: std.mem.Allocator, path: []const u8) ?[]u8 { + var lib = DynLib.open(path) catch return null; + defer lib.close(); + const get_name = lib.lookup(dylib_api.GetPluginNameFn, dylib_api.symbol_plugin_name) orelse return null; + const name = std.mem.span(get_name()); + if (name.len == 0) return null; + return allocator.dupe(u8, name) catch null; +} + +/// 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/PluginStore.zig b/src/editor/PluginStore.zig new file mode 100644 index 00000000..00564781 --- /dev/null +++ b/src/editor/PluginStore.zig @@ -0,0 +1,846 @@ +//! Shell built-in: the **Plugins** sidebar tab — discover / install / update / enable / disable +//! / uninstall plugins. Registered above Settings. +//! +//! Downloads run on a worker thread (`Job`); the actual live load happens on the main thread in +//! `tick` (it mutates the Host registries + dvui keybinds). The registry index is fetched + +//! parsed by the backend (`store.Catalog`); compatibility is matched on the host ABI +//! fingerprint + arch. +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const sdk = @import("sdk"); +const icons = @import("icons"); +const fizzy = @import("../fizzy.zig"); +const store = @import("../backend/plugin_store/store.zig"); +const PluginLoader = @import("PluginLoader.zig"); + +const compat = store.compat; +const version = sdk.version; +const dylib = sdk.dylib; + +/// README rendering depends on the in-tree markdown engine, which links cmark (libc) and is +/// native-only. The store never runs on wasm (`register` bails on wasm32), so the web build gets +/// a no-op stub and the `readme`/`markdown` modules are only resolved on native. +const Readme = if (builtin.target.cpu.arch == .wasm32) struct { + pub fn select(_: []const u8, _: []const u8) void {} + pub fn selectedId() ?[]const u8 { + return null; + } + pub fn draw() void {} + pub fn clear() void {} + pub fn deinit() void {} +} else @import("readme.zig"); + +pub const view_id = "shell.store"; +/// Center provider that renders the selected plugin's README. Mirrors the way the workbench +/// center renders the active document: while the store tab is active and a plugin is selected, +/// `tick` swaps the active center to this provider; deselecting (or leaving the tab) restores +/// the previous center. +pub const readme_center_id = "shell.store.readme"; +const default_registry_url = "https://plugins.fizzyed.it/index.json"; + +/// True while we have hijacked the active center to show a README, plus the center id to restore +/// when the selection is cleared or the store tab is no longer active. +var readme_center_active = false; +var saved_center: ?[]const u8 = null; + +var catalog: ?store.Catalog = null; +var registry_url_owned: ?[]u8 = null; +var first_draw_done = false; + +/// Transient status line shown in the header (e.g. an action error). Module-owned buffer. +var status_message: [256]u8 = undefined; +var status_len: usize = 0; + +fn setStatus(comptime fmt: []const u8, args: anytype) void { + const s = std.fmt.bufPrint(&status_message, fmt, args) catch { + status_len = 0; + return; + }; + status_len = s.len; +} + +// ---- async install jobs ---------------------------------------------------- + +const JobStatus = enum(u8) { downloading, downloaded, failed }; + +const Job = struct { + status: std.atomic.Value(u8), + id: []u8, + url: []u8, + sha256: []u8, + dest: []u8, + is_update: bool, + err_buf: [64]u8 = undefined, + err_len: usize = 0, +}; + +var jobs: std.StringArrayHashMapUnmanaged(*Job) = .empty; + +/// UI actions queued during `draw` and applied in `tick` so plugin unload never mutates +/// `host.plugins` (or dlcloses an image) while the store view is still iterating it. +const PendingAction = union(enum) { + set_enabled: struct { id: []u8, enabled: bool }, + uninstall: struct { id: []u8 }, +}; + +var pending_actions: std.ArrayListUnmanaged(PendingAction) = .empty; + +/// Last-known display name per plugin id (app-allocator owned). A sideloaded plugin only exposes +/// its display name while loaded — once disabled it is unloaded and we'd otherwise fall back to the +/// bare id, which changes the A→Z sort position (e.g. "Terminal" → "ghostty") every time it is +/// toggled. We remember the name the first time we see it (loaded plugin or registry row) and reuse +/// it as the stable title for the disabled/failed states. (Session-scoped: a plugin disabled before +/// it was ever loaded this session still shows its id until enabled once.) +var name_cache: std.StringArrayHashMapUnmanaged([]u8) = .empty; + +/// Cache `id`'s display name if it is a real name distinct from the id. Updates an existing entry +/// when the name changes (e.g. a version that renamed itself). +fn rememberName(id: []const u8, name: []const u8) void { + if (name.len == 0 or std.mem.eql(u8, name, id)) return; + const a = fizzy.app.allocator; + const gop = name_cache.getOrPut(a, id) catch return; + if (gop.found_existing) { + if (std.mem.eql(u8, gop.value_ptr.*, name)) return; + a.free(gop.value_ptr.*); + gop.value_ptr.* = a.dupe(u8, name) catch { + _ = name_cache.swapRemove(id); + return; + }; + return; + } + // New entry: own the key independently of the (borrowed) caller slice. + const key = a.dupe(u8, id) catch { + _ = name_cache.swapRemove(id); + return; + }; + gop.key_ptr.* = key; + gop.value_ptr.* = a.dupe(u8, name) catch { + _ = name_cache.swapRemove(id); + a.free(key); + return; + }; +} + +/// The remembered display name for `id`, or `fallback` (the id) when we've never seen it loaded. +fn resolveTitle(id: []const u8, fallback: []const u8) []const u8 { + return name_cache.get(id) orelse fallback; +} + +/// Query the real display name of every disabled plugin straight from its on-disk dylib (via the +/// `fizzy_plugin_name` export — no register), seeding `name_cache`. This covers plugins that were +/// disabled *before* they were ever loaded this session, so a disabled card shows its true name +/// (and keeps its A→Z position) without a fragile on-disk name cache. Cheap and bounded: only runs +/// on first draw / Refresh, and only probes ids whose name we don't already know. +fn probeDisabledNames() void { + const editor = fizzy.editor; + const a = fizzy.app.allocator; + const plugins_dir = std.fs.path.join(a, &.{ editor.config_folder, "plugins" }) catch return; + defer a.free(plugins_dir); + + for (editor.disabled_plugin_ids.items) |id| { + if (!std.unicode.utf8ValidateSlice(id)) continue; + if (name_cache.get(id) != null) continue; // already known (loaded / registry / prior probe) + if (editor.host.pluginById(id) != null) continue; // loaded → name comes from the live plugin + const file_name = PluginLoader.pluginFilename(id, a) catch continue; + defer a.free(file_name); + const path = std.fs.path.join(a, &.{ plugins_dir, file_name }) catch continue; + defer a.free(path); + if (PluginLoader.probeName(a, path)) |name| { + defer a.free(name); + rememberName(id, name); + } + } +} + +pub fn register(host: *sdk.Host) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return; // no dylib loading on web + const url = resolveRegistryUrl(); + catalog = store.Catalog.init(fizzy.app.allocator, dvui.io, url); + try host.registerSidebarView(.{ + .id = view_id, + .icon = dvui.entypo.shop, + .title = "Plugins", + .draw = draw, + }); + // README center provider. Registered after the workbench center (see `postInit` order) so it + // never becomes the default active center; `tick` activates it on demand. + try host.registerCenter(.{ + .id = readme_center_id, + .draw = drawReadmeCenter, + }); +} + +/// Center provider: paint the selected plugin's README. Active only while `tick` has swapped us +/// in (store tab active + a plugin selected). Uses the same rounded, content-colored card the +/// workbench homepage / empty state draws (`sdk.pane_layout.emptyStateCard`) so the store center +/// matches the rest of the app. +fn drawReadmeCenter(_: ?*anyopaque) anyerror!dvui.App.Result { + const host = &fizzy.editor.host; + var content_color = dvui.themeGet().color(.window, .fill); + switch (builtin.os.tag) { + .macos, .windows => { + if (!host.isMaximized()) content_color = content_color.opacity(host.contentOpacity()); + }, + else => {}, + } + + var card = sdk.pane_layout.emptyStateCard(content_color, hashId(readme_center_id)); + defer card.deinit(); + + var pane = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(16) }); + defer pane.deinit(); + Readme.draw(); + return .ok; +} + +/// `FIZZY_PLUGIN_REGISTRY_URL` overrides the default (used for local E2E testing). Owned for the +/// process lifetime (freed in `deinit`). +fn resolveRegistryUrl() []const u8 { + if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_PLUGIN_REGISTRY_URL")) |override| { + if (override.len > 0) { + registry_url_owned = override; + return override; + } + fizzy.app.allocator.free(override); + } else |_| {} + return default_registry_url; +} + +pub fn deinit() void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + for (jobs.values()) |job| freeJob(job); + jobs.deinit(fizzy.app.allocator); + for (pending_actions.items) |action| switch (action) { + .set_enabled => |a| fizzy.app.allocator.free(a.id), + .uninstall => |a| fizzy.app.allocator.free(a.id), + }; + pending_actions.deinit(fizzy.app.allocator); + for (name_cache.keys()) |k| fizzy.app.allocator.free(k); + for (name_cache.values()) |v| fizzy.app.allocator.free(v); + name_cache.deinit(fizzy.app.allocator); + Readme.deinit(); + if (catalog) |*c| c.deinit(); + catalog = null; + if (registry_url_owned) |u| { + fizzy.app.allocator.free(u); + registry_url_owned = null; + } +} + +fn freeJob(job: *Job) void { + fizzy.app.allocator.free(job.id); + fizzy.app.allocator.free(job.url); + fizzy.app.allocator.free(job.sha256); + fizzy.app.allocator.free(job.dest); + fizzy.app.allocator.destroy(job); +} + +// ---- per-frame completion (main thread) ------------------------------------ + +/// Complete any finished downloads by loading them live, and apply plugin enable/disable / +/// uninstall requests queued from the store UI. Called once per frame from `Editor.tick`, +/// before the Host-registry iterations, so a freshly-registered or unloaded plugin never +/// mutates a list mid-iteration. +pub fn tick() void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + + syncReadmeCenter(); + + for (pending_actions.items) |action| switch (action) { + .set_enabled => |a| { + applySetEnabled(a.id, a.enabled); + fizzy.app.allocator.free(a.id); + }, + .uninstall => |a| { + applyUninstall(a.id); + fizzy.app.allocator.free(a.id); + }, + }; + pending_actions.clearRetainingCapacity(); + + var i: usize = 0; + while (i < jobs.count()) { + const job = jobs.values()[i]; + switch (@as(JobStatus, @enumFromInt(job.status.load(.acquire)))) { + .downloading, .failed => i += 1, + .downloaded => { + const loaded = if (job.is_update) + fizzy.editor.updatePlugin(job.id, true) + else + fizzy.editor.installAndLoadPlugin(job.id); + loaded catch |err| { + setStatus("'{s}' failed to load: {s}", .{ job.id, @errorName(err) }); + const n = @min(@errorName(err).len, job.err_buf.len); + @memcpy(job.err_buf[0..n], @errorName(err)[0..n]); + job.err_len = n; + job.status.store(@intFromEnum(JobStatus.failed), .release); + i += 1; + continue; + }; + // Installed + loaded: drop the job so the card shows normal installed state. + jobs.swapRemoveAt(i); + freeJob(job); + // do not advance i — swapRemove moved a new entry into slot i + }, + } + } +} + +/// Drive the active center from the store selection: while the store tab is active and a plugin +/// is selected, show its README in the center; otherwise restore whatever center was active when +/// we took over. Idempotent — safe to call every frame. +fn syncReadmeCenter() void { + const host = &fizzy.editor.host; + const want = host.isActiveSidebarView(view_id) and Readme.selectedId() != null; + if (want and !readme_center_active) { + saved_center = host.active_center; + host.setActiveCenter(readme_center_id); + readme_center_active = true; + } else if (!want and readme_center_active) { + host.active_center = saved_center; + saved_center = null; + readme_center_active = false; + } +} + +/// Select `entry` (showing its README in the center), or clear the selection if it is already the +/// selected card. Only one plugin is selectable at a time. +fn toggleSelect(entry: StoreEntry) void { + if (Readme.selectedId()) |sid| { + if (std.mem.eql(u8, sid, entry.id)) { + Readme.clear(); + return; + } + } + Readme.select(entry.id, repoUrl(entry) orelse ""); +} + +fn worker(job: *Job) void { + store.download.download(fizzy.app.allocator, dvui.io, job.url, job.sha256, job.dest) catch |err| { + const n = @min(@errorName(err).len, job.err_buf.len); + @memcpy(job.err_buf[0..n], @errorName(err)[0..n]); + job.err_len = n; + job.status.store(@intFromEnum(JobStatus.failed), .release); + return; + }; + job.status.store(@intFromEnum(JobStatus.downloaded), .release); +} + +fn removeJob(id: []const u8) void { + if (jobs.fetchSwapRemove(id)) |kv| freeJob(kv.value); +} + +/// Kick off a download for `id`'s selected release on a worker thread. +fn startDownload(id: []const u8, release: store.Release, is_update: bool) void { + removeJob(id); // replace any prior failed job + const dl = release.downloadFor(compat.hostKey()) orelse return; + + const job = buildJob(id, dl, is_update) catch { + setStatus("could not prepare download for '{s}'", .{id}); + return; + }; + jobs.put(fizzy.app.allocator, job.id, job) catch { + freeJob(job); + return; + }; + const thread = std.Thread.spawn(.{}, worker, .{job}) catch { + _ = jobs.swapRemove(job.id); + freeJob(job); + setStatus("could not start download for '{s}'", .{id}); + return; + }; + thread.detach(); +} + +/// Allocate a `Job` with all strings owned; `errdefer` unwinds every partial allocation so a +/// mid-build OOM never leaks. +fn buildJob(id: []const u8, dl: store.registry.Download, is_update: bool) !*Job { + const a = fizzy.app.allocator; + + const plugins_dir = try std.fs.path.join(a, &.{ fizzy.editor.config_folder, "plugins" }); + defer a.free(plugins_dir); + std.Io.Dir.createDirAbsolute(dvui.io, plugins_dir, .default_dir) catch {}; // best-effort; exists is fine + const file_name = try PluginLoader.pluginFilename(id, a); + defer a.free(file_name); + + const job = try a.create(Job); + errdefer a.destroy(job); + const id_dup = try a.dupe(u8, id); + errdefer a.free(id_dup); + const url_dup = try a.dupe(u8, dl.url); + errdefer a.free(url_dup); + const sha_dup = try a.dupe(u8, dl.sha256); + errdefer a.free(sha_dup); + const dest = try std.fs.path.join(a, &.{ plugins_dir, file_name }); + errdefer a.free(dest); + + job.* = .{ + .status = .init(@intFromEnum(JobStatus.downloading)), + .id = id_dup, + .url = url_dup, + .sha256 = sha_dup, + .dest = dest, + .is_update = is_update, + }; + return job; +} + +// ---- drawing --------------------------------------------------------------- + +fn installedVersion(id: []const u8) ?std.SemanticVersion { + for (fizzy.editor.loaded_plugin_libs.items) |loaded| { + if (std.mem.eql(u8, loaded.plugin_id, id)) return loaded.version_info.plugin_version; + } + return null; +} + +fn isBundled(id: []const u8) bool { + return std.mem.eql(u8, id, "workbench") or std.mem.eql(u8, id, "text"); +} + +/// One deterministic row in the store tree, merged from the registry index plus the local +/// plugin/disabled/failed lists. +/// +/// **Lifetime:** every slice here is *borrowed* with one of three lifetimes — registry strings +/// are valid only while the catalog lock is held (the worker frees the arena on `refresh`), +/// `plugin.display_name`/`plugin.id` live in dylib/static memory only while the plugin is +/// loaded, and disabled ids are app-allocator-owned. The whole build → sort → draw pass runs +/// inside a single `catalog.acquire()`/`release()` scope and the dvui frame arena, so none of +/// these are retained past the lock release or across frames. +const StoreEntry = struct { + id: []const u8, + title: []const u8, + kind: enum { registry, local, disabled, failed }, + registry: ?store.PluginEntry = null, + plugin: ?*sdk.Plugin = null, + failed_reason: []const u8 = "", +}; + +/// Stable, position-independent widget/branch id for a plugin id (avoids the old loop-index +/// ids that shifted as rows were added/removed). +fn hashId(id: []const u8) usize { + return @truncate(std.hash.Wyhash.hash(0, id)); +} + +fn containsId(entries: []const StoreEntry, id: []const u8) bool { + for (entries) |e| { + if (std.mem.eql(u8, e.id, id)) return true; + } + return false; +} + +/// A→Z by display title (case-insensitive ASCII), tie-broken on id for stability. +fn entryLess(_: void, lhs: StoreEntry, rhs: StoreEntry) bool { + return switch (std.ascii.orderIgnoreCase(lhs.title, rhs.title)) { + .lt => true, + .gt => false, + .eq => std.mem.order(u8, lhs.id, rhs.id) == .lt, + }; +} + +fn fieldMatches(haystack: []const u8, needle: []const u8) bool { + return std.ascii.indexOfIgnoreCase(haystack, needle) != null; +} + +/// Case-insensitive substring match across id, title, and (for registry rows) description, +/// author, and tags — mirroring the Files tab filter behaviour. +fn matchesFilter(entry: StoreEntry, filter: []const u8) bool { + if (filter.len == 0) return true; + if (fieldMatches(entry.id, filter)) return true; + if (fieldMatches(entry.title, filter)) return true; + if (entry.registry) |r| { + if (fieldMatches(r.description, filter)) return true; + if (fieldMatches(r.author, filter)) return true; + for (r.tags) |tag| if (fieldMatches(tag, filter)) return true; + } + if (entry.plugin) |p| { + if (fieldMatches(p.id, filter)) return true; + if (fieldMatches(p.display_name, filter)) return true; + } + return false; +} + +fn draw(_: ?*anyopaque) anyerror!void { + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer vbox.deinit(); + + // First time the tab is shown, fetch the registry and learn disabled plugins' real names. + if (!first_draw_done) { + first_draw_done = true; + if (catalog) |*c| c.refresh(); + probeDisabledNames(); + } + + try drawHeader(); + + // Filter row — same shape as the file tree (search icon + borderless text entry). + var filter_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .y = 4 } }); + dvui.icon( + @src(), + "FilterIcon", + icons.tvg.lucide.search, + .{ .stroke_color = dvui.themeGet().color(.window, .text) }, + .{ .gravity_y = 0.5, .padding = dvui.Rect.all(0) }, + ); + const filter_edit = dvui.textEntry(@src(), .{ .placeholder = "Filter..." }, .{ + .expand = .horizontal, + .background = false, + }); + const filter_text = filter_edit.getText(); + filter_edit.deinit(); + filter_hbox.deinit(); + + const cat = if (catalog) |*c| c else return; + const maybe_index = cat.acquire(); + defer cat.release(); + + // Build one deduped, A→Z model under the catalog lock, into the per-frame dvui arena. + const arena = dvui.currentWindow().arena(); + var entries: std.ArrayListUnmanaged(StoreEntry) = .empty; + + const editor = fizzy.editor; + + // Precedence — one row per id: registry > loaded/local > disabled > failed. A registry row + // already reflects loaded/disabled/available/needs-rebuild for its id, so it is the richest + // representation whenever an id is published. + if (maybe_index) |index| { + for (index.plugins) |entry| { + rememberName(entry.id, entry.name); + entries.append(arena, .{ + .id = entry.id, + .title = if (entry.name.len > 0) entry.name else resolveTitle(entry.id, entry.id), + .kind = .registry, + .registry = entry, + }) catch {}; + } + } + // Locally-present plugins the registry doesn't list: bundled built-ins + sideloaded dylibs. + for (editor.host.plugins.items) |plugin| { + rememberName(plugin.id, plugin.display_name); + if (containsId(entries.items, plugin.id)) continue; + entries.append(arena, .{ + .id = plugin.id, + .title = plugin.display_name, + .kind = .local, + .plugin = plugin, + }) catch {}; + } + // Disabled plugins are unloaded (not in `host.plugins`) but remain on disk; reuse the name we + // remembered while they were loaded so they keep their A→Z position across enable/disable. + for (editor.disabled_plugin_ids.items) |id| { + if (!std.unicode.utf8ValidateSlice(id)) continue; + if (editor.host.pluginById(id) != null) continue; + if (containsId(entries.items, id)) continue; + entries.append(arena, .{ .id = id, .title = resolveTitle(id, id), .kind = .disabled }) catch {}; + } + // Load failures (folded into the same dedup pass so an id never renders twice). + for (editor.failed_user_plugins.items) |f| { + if (containsId(entries.items, f.id)) continue; + entries.append(arena, .{ .id = f.id, .title = resolveTitle(f.id, f.id), .kind = .failed, .failed_reason = f.reason }) catch {}; + } + + std.sort.pdq(StoreEntry, entries.items, {}, entryLess); + + // Surface a registry-fetch problem above the list (local plugins still render below it). + if (maybe_index == null) switch (cat.status()) { + .fetching => dvui.labelNoFmt(@src(), "Fetching plugin registry…", .{}, .{ .margin = .{ .y = 8 } }), + .failed => dvui.labelNoFmt(@src(), "Could not reach the plugin registry.", .{}, .{ + .margin = .{ .y = 8 }, + .color_text = dvui.themeGet().color(.err, .text), + }), + else => {}, + }; + + // Flat A→Z card list. Selecting a card (clicking anywhere outside its controls) shows that + // plugin's README in the center pane — see `syncReadmeCenter`/`drawReadmeCenter`. + var shown: usize = 0; + for (entries.items) |entry| { + if (!matchesFilter(entry, filter_text)) continue; + shown += 1; + drawCard(entry); + } + + if (shown == 0) { + if (filter_text.len > 0) { + dvui.labelNoFmt(@src(), "No plugins match the filter.", .{}, .{ .margin = .{ .y = 8 } }); + } else if (maybe_index != null) { + dvui.labelNoFmt(@src(), "No plugins available.", .{}, .{ .margin = .{ .y = 8 } }); + } + } +} + +/// One flat store card: a clickable container (logo + info + state controls). Clicking anywhere +/// outside the controls selects the plugin (its README shows in the center). The controls consume +/// their own clicks so the card-level click never double-fires. +fn drawCard(entry: StoreEntry) void { + const theme = dvui.themeGet(); + const selected = if (Readme.selectedId()) |sid| std.mem.eql(u8, sid, entry.id) else false; + // Disabled plugins read as a faded card: half the surface fill opacity and half the shadow. + const disabled = fizzy.editor.isPluginDisabled(entry.id); + + const fill = if (selected) + theme.color(.control, .fill).opacity(0.5) + else + theme.color(.content, .fill).opacity(if (disabled) 0.5 else 1.0); + const shadow_alpha: f32 = if (disabled) 0.125 else 0.25; + + var bw: dvui.ButtonWidget = undefined; + bw.init(@src(), .{}, .{ + .id_extra = hashId(entry.id), + .expand = .horizontal, + .margin = .all(4), + .padding = .all(8), + .corner_radius = dvui.Rect.all(8), + .background = true, + .color_fill = fill, + .color_fill_hover = theme.color(.control, .fill).opacity(0.5), + .color_fill_press = theme.color(.control, .fill_press), + .box_shadow = .{ + .color = .black, + .corner_radius = dvui.Rect.all(8), + .fade = 4, + .alpha = shadow_alpha, + }, + }); + defer bw.deinit(); + // Hover highlight without consuming click events, so the inner controls get first dibs; the + // card's own click is processed *after* the controls (see `bw.processEvents()` below). + bw.processHover(); + bw.drawBackground(); + + { + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); + defer hbox.deinit(); + + // 1. Logo (gravity 0). Generic placeholder for now; real logos land in Phase META. + dvui.icon( + @src(), + "PluginLogo", + icons.tvg.lucide.package, + .{ .stroke_color = theme.color(.window, .text) }, + .{ .gravity_y = 0.5, .min_size_content = .{ .w = 32, .h = 32 } }, + ); + + // 2. Info column: large title + dim monospace "id · version · date" subtitle. + { + var info = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .gravity_y = 0.5, + .margin = .{ .x = 8 }, + }); + defer info.deinit(); + + dvui.labelNoFmt(@src(), entry.title, .{}, .{ .font = dvui.Font.theme(.title) }); + + var sub_buf: [192]u8 = undefined; + dvui.labelNoFmt(@src(), subtitle(&sub_buf, entry), .{}, .{ + .font = dvui.Font.theme(.mono), + .color_text = theme.color(.window, .text).opacity(0.65), + }); + } + + // 3. State controls, right-justified. These run their own processEvents (inside + // `drawCardControls`) and consume their clicks before the card does. + drawCardControls(entry); + } + + // Now claim the card-body click — `dvui.clicked` skips events a control already handled. + bw.processEvents(); + if (bw.clicked()) toggleSelect(entry); +} + +/// Compose the dim subtitle `id · version · date` into `buf`, skipping parts we don't have. +/// `version` prefers the loaded plugin's version, then the selected registry release's version; +/// `date` is the selected release's publish date. +fn subtitle(buf: []u8, entry: StoreEntry) []const u8 { + var ver_buf: [32]u8 = undefined; + const ver: ?[]const u8 = blk: { + if (installedVersion(entry.id)) |v| + break :blk std.fmt.bufPrint(&ver_buf, "v{d}.{d}.{d}", .{ v.major, v.minor, v.patch }) catch null; + if (selectedRelease(entry)) |rel| + break :blk std.fmt.bufPrint(&ver_buf, "v{s}", .{rel.version}) catch null; + break :blk null; + }; + const date: ?[]const u8 = if (selectedRelease(entry)) |rel| + (if (rel.published.len > 0) rel.published else null) + else + null; + + if (ver) |vv| { + if (date) |dd| + return std.fmt.bufPrint(buf, "{s} · {s} · {s}", .{ entry.id, vv, dd }) catch entry.id; + return std.fmt.bufPrint(buf, "{s} · {s}", .{ entry.id, vv }) catch entry.id; + } + if (date) |dd| + return std.fmt.bufPrint(buf, "{s} · {s}", .{ entry.id, dd }) catch entry.id; + return entry.id; +} + +/// The registry release that is compatible with this host, if `entry` has a registry row. +fn selectedRelease(entry: StoreEntry) ?store.Release { + const r = entry.registry orelse return null; + return compat.selectRelease(r, dylib.abi_fingerprint, compat.hostKey()); +} + +/// Right-justified controls whose shape depends on install state (see plan Phase 1R-c): +/// * available in store → a single install button (down-to-line arrow); +/// * installed → an Enabled checkbox + a trash uninstall button; +/// * protected bundled fallback (text/workbench) → no controls; +/// * static built-in (example) → just the Enabled checkbox (nothing to uninstall). +fn drawCardControls(entry: StoreEntry) void { + const editor = fizzy.editor; + const theme = dvui.themeGet(); + const muted = theme.color(.window, .text).opacity(0.7); + + var ctl = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_x = 1.0, .gravity_y = 0.5 }); + defer ctl.deinit(); + + // An in-flight / failed install job preempts the normal controls. + if (jobs.get(entry.id)) |job| switch (@as(JobStatus, @enumFromInt(job.status.load(.acquire)))) { + .downloading => { + dvui.labelNoFmt(@src(), "Installing…", .{}, .{ .gravity_y = 0.5, .color_text = muted, .font = dvui.Font.theme(.mono) }); + return; + }, + .failed => { + if (selectedRelease(entry)) |rel| { + if (dvui.buttonIcon(@src(), "Retry", icons.tvg.lucide.@"rotate-ccw", .{}, .{ .stroke_color = theme.color(.err, .text) }, .{ .gravity_y = 0.5 })) + startDownload(entry.id, rel, false); + } + return; + }, + .downloaded => {}, // about to complete in tick(); fall through to installed controls + }; + + // Protected universal fallbacks: never disablable / uninstallable. + if (isBundled(entry.id)) { + dvui.labelNoFmt(@src(), "Built-in", .{}, .{ .gravity_y = 0.5, .color_text = muted, .font = dvui.Font.theme(.mono) }); + return; + } + + const loaded = editor.host.pluginById(entry.id) != null; + const disabled = editor.isPluginDisabled(entry.id); + + // Static built-ins (example): toggle a hidden sidebar view; no dylib to uninstall. + if (fizzy.Editor.isStaticHidePlugin(entry.id)) { + var enabled = !disabled; + if (dvui.checkbox(@src(), &enabled, "Enabled", .{ .gravity_y = 0.5 })) queueSetEnabled(entry.id, enabled); + return; + } + + // Installed (loaded dylib, or disabled-on-disk, or a sideloaded local): disable switch + trash. + if (loaded or disabled or entry.kind == .local or entry.kind == .disabled) { + var enabled = !disabled; + if (dvui.checkbox(@src(), &enabled, "Enabled", .{ .gravity_y = 0.5 })) queueSetEnabled(entry.id, enabled); + if (dvui.buttonIcon(@src(), "Uninstall", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = theme.color(.err, .text) }, .{ .gravity_y = 0.5 })) + queueUninstall(entry.id); + return; + } + + // Available in the store but not installed. + if (selectedRelease(entry)) |rel| { + if (dvui.buttonIcon(@src(), "Install", icons.tvg.lucide.@"arrow-down-to-line", .{}, .{ .stroke_color = theme.color(.control, .text) }, .{ .gravity_y = 0.5 })) + startDownload(entry.id, rel, false); + return; + } + + // Registry row with no host-compatible release, or a load failure. + const msg: []const u8 = if (entry.kind == .failed) "Failed" else "Needs rebuild"; + dvui.labelNoFmt(@src(), msg, .{}, .{ .gravity_y = 0.5, .color_text = theme.color(.err, .text), .font = dvui.Font.theme(.mono) }); +} + +/// Best-effort repository URL for a store entry (registry homepage for now). Built-in / sideloaded +/// plugins gain a `repository` field with the Phase 4a manifest bump. +fn repoUrl(entry: StoreEntry) ?[]const u8 { + if (entry.registry) |r| { + if (r.homepage.len > 0) return r.homepage; + } + return null; +} + +fn drawHeader() !void { + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .h = 6 } }); + defer hbox.deinit(); + + var buf: [96]u8 = undefined; + const host_sdk = std.fmt.bufPrint(&buf, "Fizzy SDK {d}.{d}.{d} · ABI 0x{x}", .{ + version.sdk_version.major, + version.sdk_version.minor, + version.sdk_version.patch, + dylib.abi_fingerprint, + }) catch "Fizzy SDK ?"; + dvui.labelNoFmt(@src(), host_sdk, .{}, .{ .gravity_y = 0.5 }); + + if (dvui.button(@src(), "Refresh", .{}, .{ .gravity_x = 1.0 })) { + status_len = 0; + if (catalog) |*c| c.refresh(); + probeDisabledNames(); + } + + if (status_len > 0) { + dvui.labelNoFmt(@src(), status_message[0..status_len], .{}, .{ + .gravity_x = 1.0, + .color_text = dvui.themeGet().color(.err, .text), + }); + } +} + +fn removePendingForId(id: []const u8) void { + var i: usize = 0; + while (i < pending_actions.items.len) { + const action = pending_actions.items[i]; + const matches = switch (action) { + .set_enabled => |a| std.mem.eql(u8, a.id, id), + .uninstall => |a| std.mem.eql(u8, a.id, id), + }; + if (matches) { + switch (action) { + .set_enabled => |a| fizzy.app.allocator.free(a.id), + .uninstall => |a| fizzy.app.allocator.free(a.id), + } + _ = pending_actions.orderedRemove(i); + } else { + i += 1; + } + } +} + +fn queueSetEnabled(id: []const u8, enabled: bool) void { + removePendingForId(id); + const dup = fizzy.app.allocator.dupe(u8, id) catch { + setStatus("'{s}' could not be queued", .{id}); + return; + }; + pending_actions.append(fizzy.app.allocator, .{ .set_enabled = .{ .id = dup, .enabled = enabled } }) catch { + fizzy.app.allocator.free(dup); + setStatus("'{s}' could not be queued", .{id}); + }; +} + +fn queueUninstall(id: []const u8) void { + removePendingForId(id); + const dup = fizzy.app.allocator.dupe(u8, id) catch { + setStatus("'{s}' could not be queued", .{id}); + return; + }; + pending_actions.append(fizzy.app.allocator, .{ .uninstall = .{ .id = dup } }) catch { + fizzy.app.allocator.free(dup); + setStatus("'{s}' could not be queued", .{id}); + }; +} + +fn applySetEnabled(id: []const u8, enabled: bool) void { + status_len = 0; + fizzy.editor.setPluginEnabled(id, enabled, false) catch |err| switch (err) { + error.DirtyDocuments => setStatus("'{s}' has unsaved changes — save or close them first", .{id}), + else => setStatus("'{s}' could not be {s}: {s}", .{ id, if (enabled) "enabled" else "disabled", @errorName(err) }), + }; +} + +fn applyUninstall(id: []const u8) void { + status_len = 0; + fizzy.editor.uninstallPlugin(id, false) catch |err| switch (err) { + error.DirtyDocuments => setStatus("'{s}' has unsaved changes — save or close them first", .{id}), + else => setStatus("'{s}' could not be uninstalled: {s}", .{ id, @errorName(err) }), + }; +} diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig index c6ebd68e..369eb3ed 100644 --- a/src/editor/Settings.zig +++ b/src/editor/Settings.zig @@ -54,6 +54,16 @@ window_opacity_light: f32 = 0.3, /// Opacity of the content area (also drives plugin panes that match the shell chrome). content_opacity: f32 = 0.7, +/// Plugin ids the user has disabled in the store. Skipped at startup by +/// `Editor.loadUserPlugins` and unloaded live by `Editor.setPluginEnabled`. The slice +/// is pointed at an `Editor`-owned list at runtime (see `Editor.disabled_plugin_ids`); +/// it is only read here for (de)serialization. +/// +/// Default disables the bundled `example` plugin on a fresh install (it is a template, not a +/// day-to-day tool). An existing `settings.json` overrides this — once the user enables it the +/// persisted list no longer contains "example", so the choice sticks. +disabled_plugins: []const []const u8 = &.{"example"}, + 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.). @@ -84,6 +94,8 @@ pub fn load(allocator: std.mem.Allocator, path: []const u8) !Settings { const options = std.json.ParseOptions{ .duplicate_field_behavior = .use_first, .ignore_unknown_fields = true, + // Copy *every* parsed string into the parse arena (kept alive in `parsed` until `deinit`). + .allocate = .alloc_always, }; const p = std.json.parseFromSlice(Settings, allocator, data, options) catch |err| { dvui.log.warn("Could not parse settings.json ({s}); using defaults.", .{@errorName(err)}); @@ -187,7 +199,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..9e7e439c 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -6,9 +6,20 @@ const App = fizzy.App; const Editor = fizzy.Editor; const SidebarView = fizzy.sdk.SidebarView; +const PluginStore = @import("PluginStore.zig"); pub const Sidebar = @This(); +/// Persisted scroll position for the plugin-icon rail (retained across frames). +var scroll_info: dvui.ScrollInfo = .{}; + +/// Shell built-in views pinned to the bottom of the rail (always visible). Everything else — +/// the plugin-contributed views — scrolls above them in registration (load) order. +fn isPinned(id: []const u8) bool { + return std.mem.eql(u8, id, PluginStore.view_id) or + std.mem.eql(u8, id, Editor.view_settings); +} + pub fn init() !Sidebar { return .{}; } @@ -34,11 +45,53 @@ pub fn draw(_: Sidebar) !Action { var ret: Action = .none; - // 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; + // Plugin-contributed views scroll in a bounded area (load order). When more icons exist than + // fit, an edge shadow hints at the hidden ones — matching the scroll-shadow used elsewhere. + { + const pane = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = false, + }); + + var scroll = dvui.scrollArea(@src(), .{ + .scroll_info = &scroll_info, + .horizontal_bar = .hide, + .vertical_bar = .hide, + }, .{ + .expand = .both, + .background = false, + }); + + for (fizzy.editor.host.sidebar_views.items, 0..) |*view, i| { + if (view.hidden or isPinned(view.id)) continue; + const a = try drawOption(view, i, 20); + if (a != .none) ret = a; + } + + const voff = scroll.si.offset(.vertical); + const vmax = scroll.si.scrollMax(.vertical); + scroll.deinit(); + + const cs = pane.data().contentRectScale(); + if (voff > 0.5) fizzy.dvui.drawEdgeShadow(cs, .top, .{}); + if (voff < vmax - 0.5) fizzy.dvui.drawEdgeShadow(cs, .bottom, .{}); + + pane.deinit(); + } + + // Plugin store + Settings: pinned to the bottom of the rail, always visible. + { + var bottom = dvui.box(@src(), .{ .dir = .vertical }, .{ + .gravity_y = 1.0, + .background = false, + }); + defer bottom.deinit(); + + for (fizzy.editor.host.sidebar_views.items, 0..) |*view, i| { + if (view.hidden or !isPinned(view.id)) continue; + const a = try drawOption(view, i, 20); + if (a != .none) ret = a; + } } return ret; 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/editor/readme.zig b/src/editor/readme.zig new file mode 100644 index 00000000..032eb3f7 --- /dev/null +++ b/src/editor/readme.zig @@ -0,0 +1,214 @@ +//! Plugin README rendering for the store. +//! +//! Fetches a plugin's `README.md` from its repository over HTTPS on a worker thread, then +//! renders it read-only via the in-tree markdown render library (`src/markdown`). There is no +//! document/plugin detour — the store calls `select()` when a plugin is chosen and `draw()` +//! each frame to paint the current selection's README. +//! +//! Native-only: the markdown engine links cmark (needs libc) and the store itself never runs on +//! wasm, so this whole module is gated out of the web build at the import site in the store. +const std = @import("std"); +const dvui = @import("dvui"); +const fizzy = @import("../fizzy.zig"); +const markdown = @import("markdown"); + +const Status = enum(u8) { idle, fetching, ready, not_found, failed }; + +/// One in-flight / rendered README. Only one plugin is selected at a time, so the module keeps a +/// single `current`. +const Readme = struct { + id: []u8, + repo: []u8, + io: std.Io, + status: std.atomic.Value(u8) = .init(@intFromEnum(Status.idle)), + /// Fetched README bytes (app-allocator owned). Written once by the worker before it flips + /// `status` to `ready` with release ordering; read on the UI thread only after an acquire + /// load sees `ready`, so no lock is needed for the bytes themselves. + bytes: ?[]u8 = null, + thread: ?std.Thread = null, + preview: markdown.Preview = .{}, + + fn statusValue(self: *Readme) Status { + return @enumFromInt(self.status.load(.acquire)); + } +}; + +var current: ?Readme = null; + +fn gpa() std.mem.Allocator { + return fizzy.app.allocator; +} + +/// Select `id` (from its `repo` URL) as the README to show. No-op if already selected. Spawns the +/// fetch worker on first selection of an id. +pub fn select(id: []const u8, repo: []const u8) void { + if (current) |*c| { + if (std.mem.eql(u8, c.id, id)) return; + clearCurrent(); + } + + const id_owned = gpa().dupe(u8, id) catch return; + const repo_owned = gpa().dupe(u8, repo) catch { + gpa().free(id_owned); + return; + }; + + current = .{ .id = id_owned, .repo = repo_owned, .io = dvui.io }; + const self = ¤t.?; + self.status.store(@intFromEnum(Status.fetching), .release); + self.thread = std.Thread.spawn(.{}, worker, .{self}) catch { + self.status.store(@intFromEnum(Status.failed), .release); + return; + }; +} + +/// The id currently selected (or null). Lets the store highlight the active card. +pub fn selectedId() ?[]const u8 { + return if (current) |*c| c.id else null; +} + +pub fn deinit() void { + clearCurrent(); +} + +/// Drop the current selection (e.g. the store "back" button). +pub fn clear() void { + clearCurrent(); +} + +fn clearCurrent() void { + if (current) |*c| { + if (c.thread) |t| { + t.join(); + c.thread = null; + } + c.preview.deinit(); + if (c.bytes) |b| gpa().free(b); + gpa().free(c.id); + gpa().free(c.repo); + } + current = null; +} + +/// Render the current selection's README into the current dvui parent. Shows placeholder text +/// while fetching / on failure. Safe to call every frame. +pub fn draw() void { + const c = if (current) |*cur| cur else { + dvui.labelNoFmt(@src(), "Select a plugin to read its README.", .{}, .{ + .expand = .both, + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.window, .text).opacity(0.7), + }); + return; + }; + + switch (c.statusValue()) { + .idle, .fetching => dvui.labelNoFmt(@src(), "Loading README…", .{}, .{ + .expand = .both, + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.window, .text).opacity(0.7), + }), + .not_found => dvui.labelNoFmt(@src(), "No README found for this plugin.", .{}, .{ + .expand = .both, + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.window, .text).opacity(0.7), + }), + .failed => dvui.labelNoFmt(@src(), "Could not fetch the README.", .{}, .{ + .expand = .both, + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.err, .text).opacity(0.85), + }), + .ready => { + const bytes = c.bytes orelse return; + markdown.drawPreview(&c.preview, bytes, gpa(), .{ .io = c.io }); + }, + } +} + +// ---- worker ----------------------------------------------------------------- + +fn worker(self: *Readme) void { + const candidates = rawReadmeUrls(self.repo) orelse { + self.status.store(@intFromEnum(Status.not_found), .release); + return; + }; + + var found: ?[]u8 = null; + for (candidates.slice()) |url| { + if (fetchOk(self.io, url)) |body| { + found = body; + break; + } + } + + if (found) |body| { + self.bytes = body; + self.status.store(@intFromEnum(Status.ready), .release); + } else { + self.status.store(@intFromEnum(Status.not_found), .release); + } +} + +/// GET `url`; return the body bytes (app-allocator owned) on HTTP 200, else null. +fn fetchOk(io: std.Io, url: []const u8) ?[]u8 { + var client: std.http.Client = .{ .allocator = gpa(), .io = io }; + defer client.deinit(); + + var body: std.Io.Writer.Allocating = .init(gpa()); + defer body.deinit(); + + const result = client.fetch(.{ + .location = .{ .url = url }, + .response_writer = &body.writer, + }) catch return null; + if (result.status != .ok) return null; + + return gpa().dupe(u8, body.written()) catch null; +} + +const UrlList = struct { + buf: [3][]const u8 = undefined, + len: usize = 0, + fn slice(self: *const UrlList) []const []const u8 { + return self.buf[0..self.len]; + } +}; + +var url_storage: [3][256]u8 = undefined; + +/// Derive candidate raw README URLs from a GitHub repository link. Returns null for hosts we +/// can't map. Not thread-safe across selections, but only one worker runs at a time. +fn rawReadmeUrls(repo: []const u8) ?UrlList { + var r = repo; + // Strip scheme. + inline for (.{ "https://", "http://" }) |p| { + if (std.mem.startsWith(u8, r, p)) r = r[p.len..]; + } + if (!std.mem.startsWith(u8, r, "github.com/")) return null; + r = r["github.com/".len..]; + r = std.mem.trimEnd(u8, r, "/"); + if (std.mem.endsWith(u8, r, ".git")) r = r[0 .. r.len - 4]; + + // owner/repo = first two path segments. + var it = std.mem.splitScalar(u8, r, '/'); + const owner = it.next() orelse return null; + const name = it.next() orelse return null; + if (owner.len == 0 or name.len == 0) return null; + + var list: UrlList = .{}; + const refs = [_][]const u8{ "HEAD", "main", "master" }; + for (refs, 0..) |ref, i| { + const s = std.fmt.bufPrint( + &url_storage[i], + "https://raw.githubusercontent.com/{s}/{s}/{s}/README.md", + .{ owner, name, ref }, + ) catch continue; + list.buf[list.len] = s; + list.len += 1; + } + return list; +} diff --git a/src/fizzy.zig b/src/fizzy.zig index cfa78dbb..1a2dbf38 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -1,7 +1,7 @@ const std = @import("std"); -/// Shared infrastructure module (gfx, math, fs, generated atlas, platform, -/// paths, the generic dvui hub + widgets). Consumed by the shell and plugins. +/// Shared infrastructure module (gfx, math, fs, platform, paths, the generic +/// dvui hub + widgets). Consumed by the shell and plugins. pub const core = @import("core"); pub const version: std.SemanticVersion = .{ @@ -10,10 +10,6 @@ pub const version: std.SemanticVersion = .{ .patch = 0, }; -// Generated files, these contain helpers for autocomplete -// So you can get a named index into atlas.sprites -pub const atlas = core.atlas; - // Other helpers and namespaces pub const fs = core.fs; pub const image = core.image; @@ -28,13 +24,9 @@ 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"); - // Global pointers pub var app: *App = undefined; pub var editor: *Editor = undefined; -pub var packer: *pixelart_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/markdown/markdown.zig b/src/markdown/markdown.zig new file mode 100644 index 00000000..35bf57b2 --- /dev/null +++ b/src/markdown/markdown.zig @@ -0,0 +1,128 @@ +//! In-tree markdown render library (host-side, native-only). +//! +//! This is the reusable *render engine* extracted from the markdown plugin — no document, +//! plugin, or editor machinery. The plugin store calls `drawPreview` to render fetched +//! README bytes; a future `.md` editing wrapper can reuse the same engine for its preview +//! pane (paired with `sdk.text` for the source pane). +//! +//! Depends on the `cmark-gfm` C library, so it is wired into native builds only and gated +//! out of the wasm build (see `build/markdown.zig`). +const std = @import("std"); +const dvui = @import("dvui"); + +const md_parse = @import("md/cmark_parse.zig"); +const render_ast = @import("md/render_ast.zig"); + +pub const RenderState = render_ast.RenderState; + +/// Persistent, caller-owned preview state: caches the parsed AST + precomputed render data +/// keyed by a content hash, plus the scroll position. Reuse one instance across frames for a +/// given rendered document (e.g. the store's currently-selected plugin README). +pub const Preview = struct { + scroll: dvui.ScrollInfo = .{}, + content_hash: u64 = std.math.maxInt(u64), + ast_root: ?*anyopaque = null, + gpa: ?std.mem.Allocator = null, + rs: render_ast.RenderState = .{}, + + pub fn deinit(self: *Preview) void { + md_parse.freeCachedRoot(self.ast_root); + self.ast_root = null; + if (self.gpa) |gpa| self.rs.deinit(gpa); + self.* = .{}; + } + + /// Re-parse only when the content changes (hash mismatch). All persistent allocations use + /// `gpa`; pass the same allocator every frame for a given Preview. + fn ensureParsed(self: *Preview, content: []const u8, gpa: std.mem.Allocator) void { + self.gpa = gpa; + var hasher = std.hash.XxHash3.init(0); + hasher.update(content); + const h = hasher.final(); + if (self.content_hash == h and self.ast_root != null) return; + md_parse.freeCachedRoot(self.ast_root); + self.ast_root = null; + self.rs.clear(gpa); + self.content_hash = h; + if (md_parse.parseMarkdown(content)) |ast| { + self.ast_root = @ptrCast(ast.root.n); + _ = render_ast.scanNode(ast.root, &self.rs, gpa); + } + } +}; + +pub const PreviewOptions = struct { + /// `std.Io` used for image loads. Required. + io: std.Io, + /// Base dir for resolving relative `![alt](path)` images. READMEs fetched from a remote repo + /// have no local base, so relative images simply won't resolve — that's fine. + image_base_dir: []const u8 = ".", + /// Seed for widget ids so multiple previews don't collide. + id_extra: u64 = 0, +}; + +/// Render `bytes` as a read-only markdown preview (own scroll area) into the current dvui parent. +/// Safe to call every frame; parsing is cached on `state` by content hash. +pub fn drawPreview( + state: *Preview, + bytes: []const u8, + gpa: std.mem.Allocator, + opts: PreviewOptions, +) void { + state.ensureParsed(bytes, gpa); + + if (state.ast_root) |rp| { + const root: md_parse.Node = .{ .n = @ptrCast(@alignCast(rp)) }; + render_ast.preloadImages(root, .{ + .image_base_dir = opts.image_base_dir, + .io = opts.io, + .gpa = gpa, + .rs = &state.rs, + }); + } + + var scroll = dvui.scrollArea(@src(), .{ + .scroll_info = &state.scroll, + .horizontal_bar = .hide, + .vertical_bar = .auto_overlay, + }, .{ + .expand = .both, + .background = true, + .color_fill = dvui.themeGet().fill, + .style = .content, + .id_extra = opts.id_extra, + }); + defer scroll.deinit(); + + if (state.ast_root) |rp| { + var v = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .gravity_x = 0, + .padding = .{ .x = 8, .y = 8, .w = 8, .h = 8 }, + .id_extra = opts.id_extra + 1, + }); + defer v.deinit(); + + const root: md_parse.Node = .{ .n = @ptrCast(@alignCast(rp)) }; + render_ast.renderDocument(root, .{ + .image_base_dir = opts.image_base_dir, + .io = opts.io, + .gpa = gpa, + .rs = &state.rs, + .id_base = @intCast(opts.id_extra << 16), + }); + } else { + dvui.labelNoFmt( + @src(), + "Could not parse markdown.", + .{}, + .{ + .expand = .both, + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.err, .text).opacity(0.85), + .id_extra = opts.id_extra, + }, + ); + } +} diff --git a/src/markdown/md/cmark_headers.h b/src/markdown/md/cmark_headers.h new file mode 100644 index 00000000..2cd4728d --- /dev/null +++ b/src/markdown/md/cmark_headers.h @@ -0,0 +1,14 @@ +#ifndef DVUI_EDITOR_CMARK_HEADERS_H +#define DVUI_EDITOR_CMARK_HEADERS_H + +#include +#include +#include +#include + +#include "cmark-gfm.h" +#include "cmark-gfm-extension_api.h" +#include "registry.h" +#include "cmark-gfm-core-extensions.h" + +#endif diff --git a/src/markdown/md/cmark_parse.zig b/src/markdown/md/cmark_parse.zig new file mode 100644 index 00000000..c3a39604 --- /dev/null +++ b/src/markdown/md/cmark_parse.zig @@ -0,0 +1,114 @@ +const std = @import("std"); + +pub const c = @cImport({ + @cInclude("cmark_headers.h"); +}); + +/// Declared in `src/registry.h` (not all public headers re-export it). +pub extern fn cmark_list_syntax_extensions(mem: *c.cmark_mem) ?*c.cmark_llist; + +pub const Node = struct { + n: *c.cmark_node, + + pub fn firstChild(n: Node) ?Node { + const ptr = c.cmark_node_first_child(n.n) orelse return null; + return .{ .n = ptr }; + } + + pub fn nextSibling(n: Node) ?Node { + const ptr = c.cmark_node_next(n.n) orelse return null; + return .{ .n = ptr }; + } + + pub fn nodeType(n: Node) c.cmark_node_type { + return c.cmark_node_get_type(n.n); + } + + pub fn typeString(n: Node) [:0]const u8 { + const s = c.cmark_node_get_type_string(n.n) orelse return ""; + return std.mem.span(s); + } + + pub fn literal(n: Node) ?[:0]const u8 { + const ptr = c.cmark_node_get_literal(n.n) orelse return null; + return std.mem.span(ptr); + } + + pub fn linkUrl(n: Node) ?[:0]const u8 { + const ptr = c.cmark_node_get_url(n.n) orelse return null; + return std.mem.span(ptr); + } + + pub fn linkTitle(n: Node) ?[:0]const u8 { + const ptr = c.cmark_node_get_title(n.n) orelse return null; + const s = std.mem.span(ptr); + if (s.len == 0) return null; + return s; + } + + pub fn fenceInfo(n: Node) ?[:0]const u8 { + const ptr = c.cmark_node_get_fence_info(n.n) orelse return null; + return std.mem.span(ptr); + } + + pub fn headingLevel(n: Node) i32 { + return c.cmark_node_get_heading_level(n.n); + } + + pub const ListKind = enum { ul, ol }; + + pub fn listKind(n: Node) ListKind { + return switch (c.cmark_node_get_list_type(n.n)) { + c.CMARK_BULLET_LIST => .ul, + c.CMARK_ORDERED_LIST => .ol, + else => .ul, + }; + } + + pub fn listStart(n: Node) i32 { + return c.cmark_node_get_list_start(n.n); + } + + pub fn tableRowIsHeader(n: Node) bool { + return c.cmark_gfm_extensions_get_table_row_is_header(n.n) != 0; + } + + pub fn taskListItemChecked(n: Node) bool { + return c.cmark_gfm_extensions_get_tasklist_item_checked(n.n); + } +}; + +pub const CMarkAst = struct { + root: Node, + extensions: ?*c.cmark_llist, +}; + +pub fn parseMarkdown(src: []const u8) ?CMarkAst { + const extensions = blk: { + c.cmark_gfm_core_extensions_ensure_registered(); + break :blk cmark_list_syntax_extensions(c.cmark_get_arena_mem_allocator()); + }; + + const options = c.CMARK_OPT_DEFAULT | c.CMARK_OPT_SAFE | c.CMARK_OPT_SMART | c.CMARK_OPT_FOOTNOTES; + const parser = c.cmark_parser_new(options) orelse return null; + defer c.cmark_parser_free(parser); + + _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("table")); + _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("strikethrough")); + _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("tasklist")); + _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("autolink")); + + c.cmark_parser_feed(parser, src.ptr, @intCast(src.len)); + const root_ptr = c.cmark_parser_finish(parser) orelse return null; + return .{ + .root = .{ .n = root_ptr }, + .extensions = extensions, + }; +} + +pub fn freeCachedRoot(ptr: ?*anyopaque) void { + if (ptr) |p| { + const n: *c.cmark_node = @ptrCast(@alignCast(p)); + c.cmark_node_free(n); + } +} diff --git a/src/markdown/md/render_ast.zig b/src/markdown/md/render_ast.zig new file mode 100644 index 00000000..d2a9efb0 --- /dev/null +++ b/src/markdown/md/render_ast.zig @@ -0,0 +1,909 @@ +const std = @import("std"); +const Io = std.Io; + +const dvui = @import("dvui"); + +const md = @import("cmark_parse.zig"); + +// Extension node kinds that cmark-gfm identifies by type string rather than +// integer constant. Precomputed once after parsing so rendering never calls +// typeString() or any C FFI inside the per-frame draw loop. +pub const ExtNodeKind = enum { table, table_row, table_header, table_cell, strikethrough }; + +/// All precomputed per-AST render data. Lives in MarkDownPreviewWidget.State, +/// rebuilt whenever the content hash changes, freed on deinit. +pub const RenderState = struct { + /// abs_path (gpa-owned) → raw image bytes (gpa-owned). + image_cache: std.StringHashMapUnmanaged([]u8) = .empty, + /// @intFromPtr(bytes.ptr) → natural image size, cached to avoid per-frame stbi_info. + image_sizes: std.AutoHashMapUnmanaged(usize, dvui.Size) = .empty, + /// @intFromPtr(node.n) → ExtNodeKind (extension nodes only). + ext_node_kinds: std.AutoHashMapUnmanaged(usize, ExtNodeKind) = .empty, + /// Set of @intFromPtr(node.n) for every node whose subtree contains an IMAGE. + subtree_has_image: std.AutoHashMapUnmanaged(usize, void) = .empty, + /// @intFromPtr(table_node.n) → column count (from header row). + /// Avoids re-traversing the header row every render frame. + table_col_counts: std.AutoHashMapUnmanaged(usize, usize) = .empty, + + pub fn deinit(self: *RenderState, gpa: std.mem.Allocator) void { + self.clear(gpa); + self.image_cache.deinit(gpa); + self.image_sizes.deinit(gpa); + self.ext_node_kinds.deinit(gpa); + self.subtree_has_image.deinit(gpa); + self.table_col_counts.deinit(gpa); + } + + pub fn clear(self: *RenderState, gpa: std.mem.Allocator) void { + var it = self.image_cache.iterator(); + while (it.next()) |kv| { + gpa.free(kv.key_ptr.*); + gpa.free(kv.value_ptr.*); + } + self.image_cache.clearRetainingCapacity(); + self.image_sizes.clearRetainingCapacity(); + self.ext_node_kinds.clearRetainingCapacity(); + self.subtree_has_image.clearRetainingCapacity(); + self.table_col_counts.clearRetainingCapacity(); + } +}; + +/// dvui ids derive from @src(); repeated layouts in loops/recursion need unique `.id_extra`. +const IdGen = struct { + n: usize = 0, + fn next(g: *IdGen) usize { + g.n += 1; + return g.n; + } +}; + +pub const RenderContext = struct { + /// Directory of the markdown file (for resolving relative `![alt](path)`). + image_base_dir: ?[]const u8 = null, + io: Io, + /// Persistent allocator (same lifetime as State). Used for image cache. + gpa: std.mem.Allocator, + /// Precomputed per-AST data: node kind map, image subtree set, image cache. + rs: *RenderState, + /// Seed for per-document widget id_extra values (avoids collisions with other panes/docs). + id_base: usize = 0, +}; + +const max_image_bytes: usize = 16 * 1024 * 1024; +const max_image_display_width: f32 = 720; +const max_image_display_height: f32 = 540; + +// --------------------------------------------------------------------------- +// Per-node fast lookups (replaces isTable/typeString calls in render loop) +// --------------------------------------------------------------------------- + +inline fn extKind(ctx: RenderContext, n: md.Node) ?ExtNodeKind { + return ctx.rs.ext_node_kinds.get(@intFromPtr(n.n)); +} + +inline fn hasImageSubtree(ctx: RenderContext, n: md.Node) bool { + return ctx.rs.subtree_has_image.contains(@intFromPtr(n.n)); +} + +// --------------------------------------------------------------------------- +// AST pre-scan (called once after parsing, results stored in State) +// --------------------------------------------------------------------------- + +/// Walk the AST once, populating rs.ext_node_kinds and rs.subtree_has_image. +/// Returns true when any node in the subtree rooted at `node` is an IMAGE. +pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool { + const ts = node.typeString(); + if (std.mem.eql(u8, ts, "table")) { + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table) catch {}; + // Count columns once from the header (or first body row) so the render + // loop never needs to re-traverse the row for this. + var num_cols: usize = 0; + var r = node.firstChild(); + while (r) |row| : (r = row.nextSibling()) { + const rts = row.typeString(); + if (!std.mem.eql(u8, rts, "table_header") and !std.mem.eql(u8, rts, "table_row")) continue; + var cl = row.firstChild(); + while (cl) |cell| : (cl = cell.nextSibling()) { + if (std.mem.eql(u8, cell.typeString(), "table_cell")) num_cols += 1; + } + break; + } + rs.table_col_counts.put(gpa, @intFromPtr(node.n), num_cols) catch {}; + } else if (std.mem.eql(u8, ts, "table_row")) + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table_row) catch {} + else if (std.mem.eql(u8, ts, "table_header")) + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table_header) catch {} + else if (std.mem.eql(u8, ts, "table_cell")) + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table_cell) catch {} + else if (std.mem.eql(u8, ts, "strikethrough")) + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .strikethrough) catch {}; + + var self_has_image = (node.nodeType() == md.c.CMARK_NODE_IMAGE); + var child = node.firstChild(); + while (child) |ch| : (child = ch.nextSibling()) { + if (scanNode(ch, rs, gpa)) self_has_image = true; + } + if (self_has_image) + rs.subtree_has_image.put(gpa, @intFromPtr(node.n), {}) catch {}; + + return self_has_image; +} + +// --------------------------------------------------------------------------- +// Image preloading (keep GPU textures warm every frame, even when pane is closed) +// --------------------------------------------------------------------------- + +/// Touch or create the GPU texture for every local image in the AST. +/// Call every frame from MarkDownPreviewWidget.init() so dvui's one-frame +/// texture eviction policy never fires between animation frames. +pub fn preloadImages(root: md.Node, ctx: RenderContext) void { + if (!ctx.rs.subtree_has_image.contains(@intFromPtr(root.n))) return; + const arena = dvui.currentWindow().arena(); + preloadImageSubtree(root, ctx, arena); +} + +fn preloadImageSubtree(node: md.Node, ctx: RenderContext, arena: std.mem.Allocator) void { + if (node.nodeType() == md.c.CMARK_NODE_IMAGE) { + preloadSingleImage(node, ctx, arena); + return; + } + if (!ctx.rs.subtree_has_image.contains(@intFromPtr(node.n))) return; + var child = node.firstChild(); + while (child) |ch| : (child = ch.nextSibling()) { + preloadImageSubtree(ch, ctx, arena); + } +} + +fn preloadSingleImage(img: md.Node, ctx: RenderContext, arena: std.mem.Allocator) void { + const raw_url = img.linkUrl() orelse return; + const abs_path = resolvedLocalImagePath(ctx, arena, raw_url) orelse return; + + const bytes: []const u8 = blk: { + if (ctx.rs.image_cache.get(abs_path)) |cached| break :blk cached; + const fresh = Io.Dir.cwd().readFileAlloc(ctx.io, abs_path, ctx.gpa, .limited(max_image_bytes)) catch return; + const key = ctx.gpa.dupe(u8, abs_path) catch { + ctx.gpa.free(fresh); + return; + }; + ctx.rs.image_cache.put(ctx.gpa, key, fresh) catch { + ctx.gpa.free(key); + ctx.gpa.free(fresh); + return; + }; + break :blk fresh; + }; + + const dvui_key: dvui.Texture.Cache.Key = blk: { + var h = dvui.fnv.init(); + const bp = bytes.ptr; + h.update(std.mem.asBytes(&bp)); + const it = @intFromEnum(dvui.enums.TextureInterpolation.linear); + h.update(std.mem.asBytes(&it)); + break :blk h.final(); + }; + + // Cache hit: texture already warm this frame, nothing to do. + if (dvui.textureGetCached(dvui_key) != null) return; + + // Cache miss: decode + GPU upload now so the animation first frame is free. + const source: dvui.ImageSource = .{ .imageFile = .{ + .bytes = bytes, + .name = abs_path, + .invalidation = .ptr, + } }; + const tex = dvui.Texture.fromImageSource(source) catch return; + ctx.rs.image_sizes.put(ctx.gpa, @intFromPtr(bytes.ptr), .{ + .w = @floatFromInt(tex.width), + .h = @floatFromInt(tex.height), + }) catch {}; + dvui.textureAddToCache(dvui_key, tex); +} + +// --------------------------------------------------------------------------- +// Image rendering helpers +// --------------------------------------------------------------------------- + +fn resolvedLocalImagePath(ctx: RenderContext, arena: std.mem.Allocator, src: []const u8) ?[]const u8 { + const t = std.mem.trim(u8, src, " \t\r\n"); + if (t.len == 0) return null; + if (std.ascii.startsWithIgnoreCase(t, "http://")) return null; + if (std.ascii.startsWithIgnoreCase(t, "https://")) return null; + if (std.fs.path.isAbsolute(t)) + return std.fs.path.resolve(arena, &.{t}) catch null; + const base = ctx.image_base_dir orelse return null; + return std.fs.path.resolve(arena, &.{ base, t }) catch null; +} + +/// Plain UTF-8 for `TextLayoutWidget.addLink`; nested emph/strong in the label lose per-span styling. +fn appendInlinePlainText(arena: std.mem.Allocator, n: md.Node, out: *std.ArrayList(u8)) std.mem.Allocator.Error!void { + var cur: ?md.Node = n.firstChild(); + while (cur) |x| : (cur = x.nextSibling()) { + switch (x.nodeType()) { + md.c.CMARK_NODE_TEXT => { + if (x.literal()) |t| try out.appendSlice(arena, t); + }, + md.c.CMARK_NODE_SOFTBREAK => { + try out.append(arena, ' '); + }, + md.c.CMARK_NODE_LINEBREAK => { + try out.append(arena, '\n'); + }, + md.c.CMARK_NODE_CODE => { + if (x.literal()) |t| try out.appendSlice(arena, t); + }, + md.c.CMARK_NODE_LINK => { + try appendInlinePlainText(arena, x, out); + }, + md.c.CMARK_NODE_IMAGE => { + try out.appendSlice(arena, "!["); + try appendInlinePlainText(arena, x, out); + try out.append(arena, ']'); + if (x.linkUrl()) |u| { + try out.append(arena, '('); + try out.appendSlice(arena, u); + try out.append(arena, ')'); + } + }, + else => { + if (x.firstChild()) |_| { + try appendInlinePlainText(arena, x, out); + } else if (x.literal()) |t| { + try out.appendSlice(arena, t); + } + }, + } + } +} + +fn linkLabelPlainText(link: md.Node, arena: std.mem.Allocator) std.mem.Allocator.Error![]const u8 { + var list: std.ArrayList(u8) = .empty; + errdefer list.deinit(arena); + try appendInlinePlainText(arena, link, &list); + return try list.toOwnedSlice(arena); +} + +fn renderMarkdownImagePlaceholder(msg: []const u8, ids: *IdGen) void { + dvui.labelNoFmt(@src(), msg, .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 2, .h = 2 }, + .color_text = dvui.themeGet().color(.control, .text).opacity(0.55), + .font = dvui.Font.theme(.mono).larger(-1), + .id_extra = ids.next(), + }); +} + +fn renderMarkdownImage(img: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { + _ = span; + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .y = 4, .h = 4 }, + .id_extra = ids.next(), + }); + defer outer.deinit(); + + const arena = dvui.currentWindow().arena(); + const raw_url = img.linkUrl() orelse { + renderMarkdownImagePlaceholder("(missing image src)", ids); + return; + }; + const url_trim = std.mem.trim(u8, raw_url, " \t\r\n"); + if (url_trim.len == 0) { + renderMarkdownImagePlaceholder("(empty image src)", ids); + return; + } + + const alt_owned = linkLabelPlainText(img, arena) catch ""; + const alt: []const u8 = alt_owned; + + if (std.ascii.startsWithIgnoreCase(url_trim, "http://") or std.ascii.startsWithIgnoreCase(url_trim, "https://")) { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .id_extra = ids.next(), + }); + defer tl.deinit(); + if (alt.len > 0) { + tl.addText(alt, .{ .color_text = dvui.themeGet().color(.control, .text).opacity(0.85) }); + tl.addText(" ", .{}); + } + tl.addLink(.{ .url = url_trim, .text = "open" }, .{ + .font = dvui.Font.theme(.mono), + }); + return; + } + + const abs_path = resolvedLocalImagePath(ctx, arena, url_trim) orelse { + renderMarkdownImagePlaceholder("cannot resolve image path (save file or use absolute path)", ids); + return; + }; + + // Use persistent cache to avoid reading the file every frame. + const bytes: []const u8 = blk: { + if (ctx.rs.image_cache.get(abs_path)) |cached| break :blk cached; + + const fresh = Io.Dir.cwd().readFileAlloc(ctx.io, abs_path, ctx.gpa, .limited(max_image_bytes)) catch { + renderMarkdownImagePlaceholder("could not read image", ids); + return; + }; + const key = ctx.gpa.dupe(u8, abs_path) catch { + ctx.gpa.free(fresh); + renderMarkdownImagePlaceholder("could not read image", ids); + return; + }; + ctx.rs.image_cache.put(ctx.gpa, key, fresh) catch { + ctx.gpa.free(key); + ctx.gpa.free(fresh); + renderMarkdownImagePlaceholder("could not cache image", ids); + return; + }; + break :blk fresh; + }; + + // Compute the same cache key dvui uses for this imageFile with .ptr invalidation. + // dvui's hash() calls stbi_info but ignores the result for .ptr — we skip it entirely. + const dvui_key: dvui.Texture.Cache.Key = blk: { + var h = dvui.fnv.init(); + const bp = bytes.ptr; + h.update(std.mem.asBytes(&bp)); + const it = @intFromEnum(dvui.enums.TextureInterpolation.linear); + h.update(std.mem.asBytes(&it)); + break :blk h.final(); + }; + + // Fast path: texture already in dvui's cache from a prior visible frame. + // Use .texture source to bypass hash()/stbi_info entirely on this frame. + // Slow path: texture not yet created. Use imageFile source so dvui creates it + // lazily inside renderImage (only when the image is actually in the clip rect). + var source: dvui.ImageSource = .{ .imageFile = .{ + .bytes = bytes, + .name = abs_path, + .invalidation = .ptr, + } }; + const nat: dvui.Size = if (dvui.textureGetCached(dvui_key)) |tex| nat: { + source = .{ .texture = tex }; + break :nat .{ .w = @floatFromInt(tex.width), .h = @floatFromInt(tex.height) }; + } else nat: { + const size_key = @intFromPtr(bytes.ptr); + break :nat ctx.rs.image_sizes.get(size_key) orelse sz: { + const sz = dvui.imageSize(source) catch { + renderMarkdownImagePlaceholder("unsupported or corrupt image", ids); + return; + }; + ctx.rs.image_sizes.put(ctx.gpa, size_key, sz) catch {}; + break :sz sz; + }; + }; + + if (nat.w <= 0 or nat.h <= 0) { + renderMarkdownImagePlaceholder("invalid image size", ids); + return; + } + + const r = nat.w / nat.h; + const max_fit_w = @min(max_image_display_width, max_image_display_height * r); + const max_fit_h = @min(max_image_display_height, max_image_display_width / r); + + const scale = @min(1.0, @min(max_fit_w / nat.w, max_fit_h / nat.h)); + const dw = nat.w * scale; + const dh = nat.h * scale; + + _ = dvui.image(@src(), .{ .source = source, .shrink = .ratio }, .{ + .min_size_content = .{ .w = dw, .h = dh }, + .max_size_content = dvui.Options.MaxSize.size(.{ .w = max_fit_w, .h = max_fit_h }), + .expand = .ratio, + .label = .{ .text = if (alt.len > 0) alt else "markdown image" }, + .id_extra = ids.next(), + }); + + if (alt.len > 0) { + var cap = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 2, .h = 0 }, + .id_extra = ids.next(), + }); + defer cap.deinit(); + cap.addText(alt, .{ + .font = dvui.Font.theme(.body).larger(-1), + .color_text = dvui.themeGet().color(.control, .text).opacity(0.65), + }); + } +} + +fn renderInlineFlowContainer(container: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { + var cur: ?md.Node = container.firstChild(); + while (cur) |node| { + if (node.nodeType() == md.c.CMARK_NODE_IMAGE) { + renderMarkdownImage(node, span, ctx, ids); + cur = node.nextSibling(); + continue; + } + if (hasImageSubtree(ctx, node)) { + switch (node.nodeType()) { + md.c.CMARK_NODE_EMPH => { + if (node.firstChild()) |_| { + const f = span.fontGet().withStyle(.italic); + renderInlineFlowContainer(node, span.override(.{ .font = f }), ctx, ids); + } + }, + md.c.CMARK_NODE_STRONG => { + if (node.firstChild()) |_| { + const f = span.fontGet().withWeight(.bold); + renderInlineFlowContainer(node, span.override(.{ .font = f }), ctx, ids); + } + }, + md.c.CMARK_NODE_LINK => { + const link_font = span.fontGet().withUnderline(.{}); + const link_color = dvui.themeGet().focus; + renderInlineFlowContainer(node, span.override(.{ .font = link_font, .color_text = link_color }), ctx, ids); + }, + else => { + if (extKind(ctx, node) == .strikethrough) { + const strike_font = span.fontGet().withStrike(.{}); + const strike_color = dvui.themeGet().color(.control, .text).opacity(0.5); + renderInlineFlowContainer(node, span.override(.{ .font = strike_font, .color_text = strike_color }), ctx, ids); + } else if (node.firstChild()) |_| { + renderInlineFlowContainer(node, span, ctx, ids); + } else if (node.literal()) |t| { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .background = span.background, + .id_extra = ids.next(), + }); + defer tl.deinit(); + tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + } + }, + } + cur = node.nextSibling(); + continue; + } + + // Batch a run of siblings that contain no images into one textLayout. + const run_first = node; + var run_last = node; + var scan: ?md.Node = node; + while (scan) |s| { + if (s.nodeType() == md.c.CMARK_NODE_IMAGE) break; + if (hasImageSubtree(ctx, s)) break; + run_last = s; + scan = s.nextSibling(); + } + + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 2, .h = 2 }, + .background = span.background, + .id_extra = ids.next(), + }); + defer tl.deinit(); + var z: ?md.Node = run_first; + while (z) |w| { + renderInlineNodeToTl(tl, w, span, ctx, ids); + if (w.n == run_last.n) break; + z = w.nextSibling(); + } + cur = run_last.nextSibling(); + } +} + +/// `span` carries inherited font/color down into inline content. +/// Only `.font` and `.color_text` are meaningful here. +/// Caller must ensure `n` has no `CMARK_NODE_IMAGE` in any descendant. +fn renderInlines(tl: *dvui.TextLayoutWidget, n: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { + std.debug.assert(!hasImageSubtree(ctx, n)); + var cur: ?md.Node = n.firstChild(); + while (cur) |x| : (cur = x.nextSibling()) { + renderInlineNodeToTl(tl, x, span, ctx, ids); + } +} + +fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { + switch (x.nodeType()) { + md.c.CMARK_NODE_TEXT => { + if (x.literal()) |t| tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + }, + md.c.CMARK_NODE_SOFTBREAK => { + tl.addText(" ", .{}); + }, + md.c.CMARK_NODE_LINEBREAK => { + tl.addText("\n", .{}); + }, + md.c.CMARK_NODE_CODE => { + if (x.literal()) |t| { + tl.addText(t, .{ + // Match the editor's monospace size (also `Font.theme(.mono)`). + .font = dvui.Font.theme(.mono), + .color_text = dvui.themeGet().color(.control, .text).opacity(0.9), + }); + } + }, + md.c.CMARK_NODE_EMPH => { + if (x.firstChild()) |_| { + const f = span.fontGet().withStyle(.italic); + renderInlines(tl, x, span.override(.{ .font = f }), ctx, ids); + } + }, + md.c.CMARK_NODE_STRONG => { + if (x.firstChild()) |_| { + const f = span.fontGet().withWeight(.bold); + renderInlines(tl, x, span.override(.{ .font = f }), ctx, ids); + } + }, + md.c.CMARK_NODE_LINK => { + const link_font = span.fontGet().withUnderline(.{}); + const link_color = dvui.themeGet().focus; + const link_opts = span.override(.{ .font = link_font, .color_text = link_color }); + const url = x.linkUrl() orelse ""; + if (url.len == 0) { + if (x.firstChild()) |_| renderInlines(tl, x, link_opts, ctx, ids); + } else { + const arena = dvui.currentWindow().arena(); + if (linkLabelPlainText(x, arena)) |display| { + tl.addLink(.{ + .url = url, + .text = if (display.len == 0) null else display, + }, link_opts); + } else |_| { + if (x.firstChild()) |_| renderInlines(tl, x, link_opts, ctx, ids); + } + } + }, + md.c.CMARK_NODE_IMAGE => unreachable, + md.c.CMARK_NODE_HTML_INLINE => { + if (x.literal()) |t| tl.addText(t, .{ + .font = dvui.Font.theme(.mono), + .color_text = dvui.themeGet().color(.err, .text), + }); + }, + md.c.CMARK_NODE_FOOTNOTE_REFERENCE => { + if (x.literal()) |t| { + const fn_font = dvui.Font.theme(.mono).larger(-1); + const fn_color = dvui.themeGet().focus.opacity(0.8); + tl.addText("[^", .{ .font = fn_font, .color_text = fn_color }); + tl.addText(t, .{ .font = fn_font, .color_text = fn_color }); + tl.addText("]", .{ .font = fn_font, .color_text = fn_color }); + } + }, + else => { + if (extKind(ctx, x) == .strikethrough) { + const strike_font = span.fontGet().withStrike(.{}); + const strike_color = dvui.themeGet().color(.control, .text).opacity(0.5); + renderInlines(tl, x, span.override(.{ .font = strike_font, .color_text = strike_color }), ctx, ids); + } else if (x.firstChild()) |_| { + renderInlines(tl, x, span, ctx, ids); + } else if (x.literal()) |t| { + tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + } + }, + } +} + +fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { + const t = n.nodeType(); + switch (t) { + md.c.CMARK_NODE_DOCUMENT => { + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + }, + md.c.CMARK_NODE_BLOCK_QUOTE => { + var outer = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .margin = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, + .id_extra = ids.next(), + }); + defer outer.deinit(); + + _ = dvui.spacer(@src(), .{ + .min_size_content = .{ .w = 3, .h = 0 }, + .expand = .vertical, + .background = true, + .color_fill = dvui.themeGet().color(.highlight, .fill).opacity(0.75), + .corner_radius = dvui.Rect.all(2), + .id_extra = ids.next(), + }); + + var content = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .padding = .{ .x = 10, .y = 4, .w = 0, .h = 4 }, + .id_extra = ids.next(), + }); + defer content.deinit(); + + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + }, + md.c.CMARK_NODE_LIST => { + var it = n.firstChild(); + var idx: i32 = n.listStart(); + const list_kind = n.listKind(); + const col_w = dvui.Font.theme(.body).sizeM(2.2, 0).w; + while (it) |item_node| : (it = item_node.nextSibling()) { + if (item_node.nodeType() != md.c.CMARK_NODE_ITEM) { + renderBlock(item_node, ids, ctx); + continue; + } + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .margin = .{ .y = 1 }, + .id_extra = ids.next(), + }); + defer row.deinit(); + + var buf: [24]u8 = undefined; + const is_task = list_kind == .ul and item_node.taskListItemChecked(); + const bullet_str: []const u8 = if (is_task) + "✓" + else switch (list_kind) { + .ul => "•", + .ol => std.fmt.bufPrint(&buf, "{d}.", .{idx}) catch "?", + }; + if (list_kind == .ol) idx += 1; + + const bullet_color = if (is_task) + dvui.themeGet().color(.highlight, .fill) + else + dvui.themeGet().color(.control, .text).opacity(0.45); + + { + var pb = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .min_size_content = .{ .w = col_w, .h = 0 }, + .gravity_y = 0, + .id_extra = ids.next(), + }); + _ = dvui.spacer(@src(), .{ .expand = .horizontal, .id_extra = ids.next() }); + dvui.labelNoFmt(@src(), bullet_str, .{}, .{ + .gravity_y = 0, + .color_text = bullet_color, + .id_extra = ids.next(), + }); + pb.deinit(); + } + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 5, .h = 0 }, .id_extra = ids.next() }); + + var col = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .id_extra = ids.next(), + }); + defer col.deinit(); + + var sub = item_node.firstChild(); + while (sub) |s| : (sub = s.nextSibling()) { + renderBlock(s, ids, ctx); + } + } + }, + md.c.CMARK_NODE_ITEM => { + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + }, + md.c.CMARK_NODE_CODE_BLOCK => { + const info = n.fenceInfo() orelse ""; + const code = n.literal() orelse ""; + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .y = 6 }, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill).opacity(0.9), + .corner_radius = dvui.Rect.all(6), + .border = dvui.Rect.all(1), + .color_border = dvui.themeGet().border.opacity(0.35), + .id_extra = ids.next(), + }); + defer outer.deinit(); + + if (info.len > 0) { + var hdr = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .padding = .{ .x = 10, .y = 5, .w = 10, .h = 5 }, + .background = true, + .color_fill = dvui.themeGet().border.opacity(0.12), + .id_extra = ids.next(), + }); + defer hdr.deinit(); + var tl_i = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .id_extra = ids.next() }); + tl_i.addText(info, .{ + .font = dvui.Font.theme(.mono).withWeight(.bold), + .color_text = dvui.themeGet().color(.control, .text).opacity(0.55), + }); + tl_i.deinit(); + } + + var tl_c = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .padding = .{ .x = 10, .y = 8, .w = 10, .h = 8 }, + .id_extra = ids.next(), + }); + defer tl_c.deinit(); + tl_c.addText(code, .{ .font = dvui.Font.theme(.mono) }); + }, + md.c.CMARK_NODE_HTML_BLOCK => { + if (n.literal()) |h| { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 2 }, + .padding = .{ .x = 8, .y = 4, .w = 8, .h = 4 }, + .background = true, + .color_fill = dvui.themeGet().color(.err, .fill).opacity(0.08), + .id_extra = ids.next(), + }); + defer tl.deinit(); + tl.addText(h, .{ + .font = dvui.Font.theme(.mono), + .color_text = dvui.themeGet().color(.err, .text).opacity(0.85), + }); + } + }, + md.c.CMARK_NODE_PARAGRAPH => { + if (!hasImageSubtree(ctx, n)) { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 4, .h = 4 }, + .id_extra = ids.next(), + }); + defer tl.deinit(); + renderInlines(tl, n, .{}, ctx, ids); + } else { + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .y = 4, .h = 4 }, + .id_extra = ids.next(), + }); + defer outer.deinit(); + renderInlineFlowContainer(n, .{}, ctx, ids); + } + }, + md.c.CMARK_NODE_HEADING => { + const level = @max(1, @min(6, n.headingLevel())); + const size_bump: f32 = switch (level) { + 1 => 9, + 2 => 6, + 3 => 3, + 4 => 1, + else => 0, + }; + const top_margin: f32 = switch (level) { + 1 => 18, + 2 => 14, + 3 => 10, + else => 7, + }; + const heading_font = dvui.Font.theme(.heading).larger(size_bump - 2).withWeight(.bold); + const span: dvui.Options = .{ .font = heading_font }; + + if (!hasImageSubtree(ctx, n)) { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = top_margin, .h = 2 }, + .font = heading_font, + .id_extra = ids.next(), + }); + defer tl.deinit(); + renderInlines(tl, n, span, ctx, ids); + } else { + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .y = top_margin, .h = 2 }, + .id_extra = ids.next(), + }); + defer outer.deinit(); + renderInlineFlowContainer(n, span, ctx, ids); + } + }, + md.c.CMARK_NODE_THEMATIC_BREAK => { + _ = dvui.separator(@src(), .{ + .expand = .horizontal, + .margin = .{ .y = 10, .h = 10 }, + .color_fill = dvui.themeGet().border.opacity(0.45), + .id_extra = ids.next(), + }); + }, + md.c.CMARK_NODE_FOOTNOTE_DEFINITION => { + if (n.literal()) |name| { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 4 }, + .id_extra = ids.next(), + }); + const fn_font = dvui.Font.theme(.mono).larger(-1); + const fn_color = dvui.themeGet().focus.opacity(0.8); + tl.addText("[^", .{ .font = fn_font, .color_text = fn_color }); + tl.addText(name, .{ .font = fn_font, .color_text = fn_color }); + tl.addText("]: ", .{ .font = fn_font, .color_text = fn_color }); + tl.deinit(); + } + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + }, + else => { + if (extKind(ctx, n) == .table) { + const arena = dvui.currentWindow().arena(); + + const num_cols = ctx.rs.table_col_counts.get(@intFromPtr(n.n)) orelse return; + if (num_cols == 0) return; + + var table_wrap = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .none, + .margin = .{ .y = 6 }, + .id_extra = ids.next(), + }); + defer table_wrap.deinit(); + + var g = dvui.grid(@src(), .numCols(num_cols), .{ + .scroll_opts = .{ + .horizontal_bar = .auto, + .vertical_bar = .hide, + }, + }, .{ + .expand = .none, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill).opacity(0.3), + .corner_radius = dvui.Rect.all(4), + .border = dvui.Rect.all(1), + .color_border = dvui.themeGet().border.opacity(0.3), + .id_extra = ids.next(), + }); + defer g.deinit(); + + const banded: dvui.GridWidget.CellStyle.Banded = .{ + .alt_cell_opts = .{ + .color_fill = dvui.themeGet().color(.control, .fill_press), + .background = true, + }, + }; + + const cell_padding: dvui.Rect = .{ .x = 8, .y = 5, .w = 8, .h = 5 }; + + var body_row: usize = 0; + var c = n.firstChild(); + while (c) |row| : (c = row.nextSibling()) { + const rk = extKind(ctx, row); + if (rk != .table_row and rk != .table_header) continue; + + if (rk == .table_header) { + var col: usize = 0; + var cl = row.firstChild(); + while (cl) |cell| : (cl = cell.nextSibling()) { + if (extKind(ctx, cell) != .table_cell) continue; + const label = linkLabelPlainText(cell, arena) catch ""; + const cell_pos: dvui.GridWidget.Cell = .colRow(col, 0); + var hdr_cell_opts = banded.cellOptions(cell_pos); + hdr_cell_opts.padding = cell_padding; + var hcell = g.headerCell(@src(), col, hdr_cell_opts); + defer hcell.deinit(); + dvui.labelNoFmt(@src(), label, .{}, .{ + .expand = .horizontal, + .gravity_x = 0.5, + .gravity_y = 0.5, + .font = dvui.Font.theme(.body).withWeight(.bold), + .id_extra = ids.next(), + }); + col += 1; + } + } else { + var col: usize = 0; + var cl = row.firstChild(); + while (cl) |cell| : (cl = cell.nextSibling()) { + if (extKind(ctx, cell) != .table_cell) continue; + const cell_pos: dvui.GridWidget.Cell = .colRow(col, body_row); + var cell_opts = banded.cellOptions(cell_pos); + cell_opts.padding = cell_padding; + var cell_box = g.bodyCell(@src(), cell_pos, cell_opts); + defer cell_box.deinit(); + renderInlineFlowContainer(cell, .{ .background = false }, ctx, ids); + col += 1; + } + body_row += 1; + } + } + } else { + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + } + }, + } +} + +pub fn renderDocument(root: md.Node, ctx: RenderContext) void { + var ids: IdGen = .{ .n = ctx.id_base }; + renderBlock(root, &ids, ctx); +} diff --git a/src/plugins/code/code.zig b/src/plugins/code/code.zig deleted file mode 100644 index d394de71..00000000 --- a/src/plugins/code/code.zig +++ /dev/null @@ -1,15 +0,0 @@ -//! 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"); -pub const CodeEditor = @import("src/CodeEditor.zig"); -pub const SyntaxHighlight = @import("src/SyntaxHighlight.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/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/example/build.zig b/src/plugins/example/build.zig new file mode 100644 index 00000000..53372cb0 --- /dev/null +++ b/src/plugins/example/build.zig @@ -0,0 +1,20 @@ +//! 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`. Copy this for a new pure-Zig plugin. `zig build install` builds it +//! and installs it into this OS's fizzy plugins dir (the editor loads it on next launch), and also +//! leaves `zig-out/example.` for packaging. 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/module.zig b/src/plugins/pixelart/module.zig deleted file mode 100644 index a7e17fc2..00000000 --- a/src/plugins/pixelart/module.zig +++ /dev/null @@ -1,58 +0,0 @@ -//! 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 -//! `../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 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"); - -/// 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 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/Animation.zig b/src/plugins/pixelart/src/Animation.zig deleted file mode 100644 index f044c0ae..00000000 --- a/src/plugins/pixelart/src/Animation.zig +++ /dev/null @@ -1,145 +0,0 @@ -const std = @import("std"); -const Animation = @This(); - -name: []const u8, -frames: []Frame, - -pub const Frame = struct { - sprite_index: usize, - ms: u32, -}; - -pub const AnimationV2 = struct { - name: []const u8, - frames: []usize, - fps: f32, -}; - -pub const AnimationV1 = struct { - name: []const u8, - start: usize, - length: usize, - fps: f32, -}; - -pub fn init(allocator: std.mem.Allocator, name: []const u8, frames: []usize) !Animation { - return .{ - .name = try allocator.dupe(u8, name), - .frames = try allocator.dupe(Frame, frames), - }; -} - -pub fn eql(a: Animation, b: Animation) bool { - var e: bool = true; - if (a.frames.len != b.frames.len) { - return false; - } - - for (a.frames, b.frames) |frame_a, frame_b| { - if (frame_a.sprite_index != frame_b.sprite_index) { - e = false; - break; - } else if (frame_a.ms != frame_b.ms) { - e = false; - break; - } - } - - return e; -} - -pub fn eqlFrames(a: Animation, frames: []Frame) bool { - var e: bool = true; - - if (a.frames.len != frames.len) { - return false; - } - - for (a.frames, frames) |frame_a, frame_b| { - if (frame_a.sprite_index != frame_b.sprite_index) { - e = false; - break; - } else if (frame_a.ms != frame_b.ms) { - e = false; - break; - } - } - - return e; -} - -pub fn deinit(self: *Animation, allocator: std.mem.Allocator) void { - allocator.free(self.name); - allocator.free(self.frames); -} - -// ---------------------------------------------------------------- -// Tests -// ---------------------------------------------------------------- - -const expect = std.testing.expect; -const expectEqual = std.testing.expectEqual; - -test "eql: identical frame lists compare equal" { - var f_a = [_]Frame{ - .{ .sprite_index = 0, .ms = 100 }, - .{ .sprite_index = 1, .ms = 100 }, - .{ .sprite_index = 2, .ms = 100 }, - }; - var f_b = [_]Frame{ - .{ .sprite_index = 0, .ms = 100 }, - .{ .sprite_index = 1, .ms = 100 }, - .{ .sprite_index = 2, .ms = 100 }, - }; - const a: Animation = .{ .name = "idle", .frames = &f_a }; - const b: Animation = .{ .name = "idle", .frames = &f_b }; - try expect(eql(a, b)); -} - -test "eql: differing sprite_index makes animations unequal" { - var f_a = [_]Frame{.{ .sprite_index = 0, .ms = 100 }}; - var f_b = [_]Frame{.{ .sprite_index = 1, .ms = 100 }}; - const a: Animation = .{ .name = "idle", .frames = &f_a }; - const b: Animation = .{ .name = "idle", .frames = &f_b }; - try expect(!eql(a, b)); -} - -test "eql: differing ms makes animations unequal" { - var f_a = [_]Frame{.{ .sprite_index = 0, .ms = 100 }}; - var f_b = [_]Frame{.{ .sprite_index = 0, .ms = 250 }}; - const a: Animation = .{ .name = "idle", .frames = &f_a }; - const b: Animation = .{ .name = "idle", .frames = &f_b }; - try expect(!eql(a, b)); -} - -test "eql: differing frame counts make animations unequal" { - var f_a = [_]Frame{ - .{ .sprite_index = 0, .ms = 100 }, - .{ .sprite_index = 1, .ms = 100 }, - }; - var f_b = [_]Frame{ - .{ .sprite_index = 0, .ms = 100 }, - }; - const a: Animation = .{ .name = "idle", .frames = &f_a }; - const b: Animation = .{ .name = "idle", .frames = &f_b }; - try expect(!eql(a, b)); -} - -test "eqlFrames: matches when contents match exactly" { - var f = [_]Frame{ - .{ .sprite_index = 5, .ms = 50 }, - .{ .sprite_index = 6, .ms = 50 }, - }; - var probe = [_]Frame{ - .{ .sprite_index = 5, .ms = 50 }, - .{ .sprite_index = 6, .ms = 50 }, - }; - const a: Animation = .{ .name = "x", .frames = &f }; - try expect(eqlFrames(a, &probe)); -} - -test "eqlFrames: empty frames compare equal" { - var empty: [0]Frame = .{}; - const a: Animation = .{ .name = "empty", .frames = &empty }; - try expect(eqlFrames(a, &empty)); -} diff --git a/src/plugins/pixelart/src/Atlas.zig b/src/plugins/pixelart/src/Atlas.zig deleted file mode 100644 index 6d159e43..00000000 --- a/src/plugins/pixelart/src/Atlas.zig +++ /dev/null @@ -1,112 +0,0 @@ -const std = @import("std"); - -const Atlas = @This(); - -pub const Sprite = struct { - origin: [2]f32 = .{ 0.0, 0.0 }, - source: [4]u32, -}; - -const Animation = @import("Animation.zig"); - -sprites: []Sprite, -animations: []Animation, - -const AtlasV2 = struct { - sprites: []Sprite, - animations: []Animation.AnimationV2, -}; - -const AtlasV1 = struct { - sprites: []Sprite, - animations: []Animation.AnimationV1, -}; - -pub fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, file: []const u8) !Atlas { - 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); -} - -pub fn loadFromBytes(allocator: std.mem.Allocator, bytes: []const u8) !Atlas { - const options = std.json.ParseOptions{ .duplicate_field_behavior = .use_first, .ignore_unknown_fields = true }; - - if (std.json.parseFromSlice(Atlas, allocator, bytes, options) catch null) |parsed| { - const animations = try allocator.dupe(Animation, parsed.value.animations); - - for (animations) |*animation| { - animation.name = try allocator.dupe(u8, animation.name); - } - - return .{ - .sprites = try allocator.dupe(Sprite, parsed.value.sprites), - .animations = animations, - }; - } else if (std.json.parseFromSlice(AtlasV2, allocator, bytes, options) catch null) |parsed| { - const animations = try allocator.alloc(Animation, parsed.value.animations.len); - for (animations, parsed.value.animations) |*animation, old_animation| { - animation.name = try allocator.dupe(u8, old_animation.name); - animation.frames = try allocator.alloc(Animation.Frame, old_animation.frames.len); - for (animation.frames, old_animation.frames) |*frame, old_frame| { - frame.sprite_index = old_frame; - const fps = if (old_animation.fps > 0) old_animation.fps else 1.0; - frame.ms = @intFromFloat(1000.0 / fps); - } - } - - return .{ - .sprites = try allocator.dupe(Sprite, parsed.value.sprites), - .animations = animations, - }; - } else if (std.json.parseFromSlice(AtlasV1, allocator, bytes, options) catch null) |parsed| { - const animations = try allocator.alloc(Animation, parsed.value.animations.len); - for (animations, parsed.value.animations) |*animation, old_animation| { - animation.name = try allocator.dupe(u8, old_animation.name); - animation.frames = try allocator.alloc(Animation.Frame, old_animation.length); - for (animation.frames, old_animation.start..old_animation.start + old_animation.length) |*frame, frame_index| { - frame.sprite_index = frame_index; - const fps = if (old_animation.fps > 0) old_animation.fps else 1.0; - frame.ms = @intFromFloat(1000.0 / fps); - } - } - - return .{ - .sprites = try allocator.dupe(Sprite, parsed.value.sprites), - .animations = animations, - }; - } - - return error.CannotLoadAtlas; -} - -pub fn spriteName(atlas: *Atlas, allocator: std.mem.Allocator, index: usize) ![]const u8 { - for (atlas.animations) |animation| { - for (animation.frames, 0..) |frame, frame_index| { - if (frame.sprite_index != index) continue; - - if (animation.frames.len > 1) { - return std.fmt.allocPrint(allocator, "{s}_{d}", .{ animation.name, frame_index }); - } else { - return std.fmt.allocPrint(allocator, "{s}", .{animation.name}); - } - } - } - - return std.fmt.allocPrint(allocator, "Sprite_{d}", .{index}); -} - -pub fn deinit(atlas: *Atlas, allocator: std.mem.Allocator) void { - for (atlas.animations) |*animation| { - allocator.free(animation.name); - } - - allocator.free(atlas.sprites); - allocator.free(atlas.animations); -} diff --git a/src/plugins/pixelart/src/CanvasData.zig b/src/plugins/pixelart/src/CanvasData.zig deleted file mode 100644 index 0d869641..00000000 --- a/src/plugins/pixelart/src/CanvasData.zig +++ /dev/null @@ -1,1271 +0,0 @@ -//! 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 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) stays on the workbench `Workspace` and is exposed through `WorkbenchPaneView`. -const std = @import("std"); -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; - -const File = pixelart.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(Globals.allocator(), "column_drag_{d}", .{grouping}) catch "column_drag", - .rows_drag_name = std.fmt.allocPrint(Globals.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. The names are -/// short-lived and never freed. -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); -} - -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 + Globals.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; - 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); - 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 = pixelart.core.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: pixelart.core.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 (pixelart.core.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 = Globals.state.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 })) { - 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 })) { - Globals.state.host.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 = Globals.state.docs.activeFile(Globals.state.host) 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 (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, - .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 => Globals.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(), .{ - .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); - }, - .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 => Globals.state.host.copy() catch { - dvui.log.err("Failed to copy", .{}); - }, - .paste => Globals.state.host.paste() catch { - dvui.log.err("Failed to paste", .{}); - }, - .transform => Globals.state.host.transform() catch { - dvui.log.err("Failed to start transform", .{}); - }, - .grid_layout => { - if (Globals.state.host.activeDoc()) |doc| GridLayout.request(doc.id); - }, - } - } - } -} - -/// 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 = Globals.state.docs.activeFile(Globals.state.host) 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 (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, - .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); - 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/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/Docs.zig b/src/plugins/pixelart/src/Docs.zig deleted file mode 100644 index 7a351b04..00000000 --- a/src/plugins/pixelart/src/Docs.zig +++ /dev/null @@ -1,37 +0,0 @@ -//! 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. -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.fileById(doc.id); -} - -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/src/File.zig b/src/plugins/pixelart/src/File.zig deleted file mode 100644 index df2157cf..00000000 --- a/src/plugins/pixelart/src/File.zig +++ /dev/null @@ -1,108 +0,0 @@ -const std = @import("std"); - -const Layer = @import("Layer.zig"); -const Sprite = @import("Sprite.zig"); -const Animation = @import("Animation.zig"); - -const File = @This(); - -/// Version of fizzy that created this file -version: std.SemanticVersion, - -// Grid data -columns: u32, -rows: u32, -column_width: u32, -row_height: u32, - -// Layer data -layers: []Layer, -// Origins of sprites -sprites: []Sprite, -// Lists of sprite indexes and timings -animations: []Animation, - -pub fn deinit(self: *File, allocator: std.mem.Allocator) void { - for (self.layers) |*layer| { - allocator.free(layer.name); - } - for (self.animations) |*animation| { - allocator.free(animation.frames); - allocator.free(animation.name); - } - allocator.free(self.layers); - allocator.free(self.sprites); - allocator.free(self.animations); -} - -/// Older file format, describes animations by frame indices with no duration information -pub const FileV3 = struct { - version: std.SemanticVersion, - columns: u32, - rows: u32, - column_width: u32, - row_height: u32, - layers: []Layer, - sprites: []Sprite, - animations: []Animation.AnimationV2, - - pub fn deinit(self: *File, allocator: std.mem.Allocator) void { - for (self.layers) |*layer| { - allocator.free(layer.name); - } - for (self.animations) |*animation| { - allocator.free(animation.name); - } - allocator.free(self.layers); - allocator.free(self.sprites); - allocator.free(self.animations); - } -}; - -/// Older file format, describes files by width and height and tile size -pub const FileV2 = struct { - version: std.SemanticVersion, - width: u32, - height: u32, - tile_width: u32, - tile_height: u32, - layers: []Layer, - sprites: []Sprite, - animations: []Animation.AnimationV2, - - pub fn deinit(self: *File, allocator: std.mem.Allocator) void { - for (self.layers) |*layer| { - allocator.free(layer.name); - } - for (self.animations) |*animation| { - allocator.free(animation.name); - } - allocator.free(self.layers); - allocator.free(self.sprites); - allocator.free(self.animations); - } -}; - -/// Original file format, has a different animation format -pub const FileV1 = struct { - version: std.SemanticVersion, - width: u32, - height: u32, - tile_width: u32, - tile_height: u32, - layers: []Layer, - sprites: []Sprite, - animations: []Animation.AnimationV1, - - pub fn deinit(self: *File, allocator: std.mem.Allocator) void { - for (self.layers) |*layer| { - allocator.free(layer.name); - } - for (self.animations) |*animation| { - allocator.free(animation.name); - } - allocator.free(self.layers); - allocator.free(self.sprites); - allocator.free(self.animations); - } -}; 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/pixelart/src/LDTKTileset.zig b/src/plugins/pixelart/src/LDTKTileset.zig deleted file mode 100644 index 216a59d6..00000000 --- a/src/plugins/pixelart/src/LDTKTileset.zig +++ /dev/null @@ -1,16 +0,0 @@ -const std = @import("std"); -const core = @import("mach").core; - -pub const LDTKCompatibility = struct { - tilesets: []LDTKTileset, -}; - -const LDTKTileset = @This(); - -pub const LDTKSprite = struct { - src: [2]u32, -}; - -layer_paths: [][:0]const u8, -sprite_size: [2]u32, -sprites: []LDTKSprite, diff --git a/src/plugins/pixelart/src/Layer.zig b/src/plugins/pixelart/src/Layer.zig deleted file mode 100644 index 5c753a02..00000000 --- a/src/plugins/pixelart/src/Layer.zig +++ /dev/null @@ -1,11 +0,0 @@ -const std = @import("std"); - -const Layer = @This(); - -name: []const u8, -visible: bool = true, -collapse: bool = false, - -pub fn deinit(layer: *Layer, allocator: std.mem.Allocator) void { - allocator.free(layer.name); -} diff --git a/src/plugins/pixelart/src/PackJob.zig b/src/plugins/pixelart/src/PackJob.zig deleted file mode 100644 index e3583213..00000000 --- a/src/plugins/pixelart/src/PackJob.zig +++ /dev/null @@ -1,738 +0,0 @@ -//! Background project-pack job. Owns a worker thread that runs the full append-pack-blit pipeline -//! off the main thread so packing large projects doesn't stall the editor. -//! -//! Inputs are gathered on the main thread: open files are snapshotted into thread-isolated -//! `PackFile` values (deep copies of layer pixels + sprite/animation metadata); unopened files -//! are passed as paths and the worker loads them via `Internal.File.fromPath`. Either way the -//! 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 -//! published. -//! -//! Ownership / threading model: -//! - `inputs` is owned by the job; each input owns its own buffers. Freed in `destroy()`. -//! - `result_atlas` is written by the worker, read by the main thread only after -//! `done.load(.acquire)`. On consume the main thread takes ownership of its allocations. -//! - `phase` / `cancelled` are atomic; either side may read or write them. - -const std = @import("std"); -const dvui = @import("dvui"); -const zstbi = @import("zstbi"); -const perf = pixelart.perf; -const reduce_alg = @import("algorithms/reduce.zig"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; - -const PackJob = @This(); - -pub const Phase = enum(u8) { - queued = 0, - loading = 1, - appending = 2, - packing = 3, - blitting = 4, - ready = 5, - failed = 6, - cancelled = 7, -}; - -// ---------------------------------------------------------------------------- -// Thread-safe snapshot of the pack-relevant data for a single file. -// ---------------------------------------------------------------------------- - -pub const PackLayer = struct { - name: []u8, - visible: bool, - collapse: bool, - width: u32, - height: u32, - pixels: [][4]u8, - - fn deinit(self: *PackLayer, allocator: std.mem.Allocator) void { - allocator.free(self.name); - allocator.free(self.pixels); - } -}; - -pub const PackSprite = struct { - origin: [2]f32, -}; - -pub const PackAnimation = struct { - name: []u8, - frames: []pixelart.Animation.Frame, - - fn deinit(self: *PackAnimation, allocator: std.mem.Allocator) void { - allocator.free(self.name); - allocator.free(self.frames); - } -}; - -pub const PackFile = struct { - columns: u32, - column_width: u32, - row_height: u32, - width: u32, - height: u32, - layers: []PackLayer, - sprites: []PackSprite, - animations: []PackAnimation, - allocator: std.mem.Allocator, - - /// 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 { - const src_layers = file.layers.slice(); - - var layers = try allocator.alloc(PackLayer, src_layers.len); - var layers_initialized: usize = 0; - errdefer { - for (layers[0..layers_initialized]) |*l| l.deinit(allocator); - allocator.free(layers); - } - - var i: usize = 0; - while (i < src_layers.len) : (i += 1) { - const layer = src_layers.get(i); - 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 name_copy = try allocator.dupe(u8, layer.name); - errdefer allocator.free(name_copy); - - const pixels_copy = try allocator.dupe([4]u8, src_pixels); - - layers[i] = .{ - .name = name_copy, - .visible = layer.visible, - .collapse = layer.collapse, - .width = layer_w, - .height = layer_h, - .pixels = pixels_copy, - }; - layers_initialized = i + 1; - } - - const src_sprites = file.sprites.slice(); - const sprites = try allocator.alloc(PackSprite, src_sprites.len); - errdefer allocator.free(sprites); - for (sprites, 0..) |*dst, idx| { - const s = src_sprites.get(idx); - dst.* = .{ .origin = s.origin }; - } - - const src_anims = file.animations.slice(); - var anims = try allocator.alloc(PackAnimation, src_anims.len); - var anims_initialized: usize = 0; - errdefer { - for (anims[0..anims_initialized]) |*a| a.deinit(allocator); - allocator.free(anims); - } - var a: usize = 0; - while (a < src_anims.len) : (a += 1) { - 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); - anims[a] = .{ .name = name_copy, .frames = frames_copy }; - anims_initialized = a + 1; - } - - return .{ - .columns = file.columns, - .column_width = file.column_width, - .row_height = file.row_height, - .width = file.width(), - .height = file.height(), - .layers = layers, - .sprites = sprites, - .animations = anims, - .allocator = allocator, - }; - } - - /// 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); - var file = maybe_file orelse return null; - defer file.deinit(); - return try PackFile.fromOpenFile(allocator, &file); - } - - pub fn deinit(self: *PackFile) void { - for (self.layers) |*l| l.deinit(self.allocator); - self.allocator.free(self.layers); - self.allocator.free(self.sprites); - for (self.animations) |*anim| anim.deinit(self.allocator); - self.allocator.free(self.animations); - } -}; - -pub const PackInput = union(enum) { - open: PackFile, - /// Owned path string. Worker loads from disk and converts. - path: []u8, - - pub fn deinit(self: *PackInput, allocator: std.mem.Allocator) void { - switch (self.*) { - .open => |*pf| pf.deinit(), - .path => |p| allocator.free(p), - } - } -}; - -// ---------------------------------------------------------------------------- -// Job state -// ---------------------------------------------------------------------------- - -allocator: std.mem.Allocator, - -/// All inputs to pack, in deterministic order. Owned. -inputs: []PackInput, - -/// Captured at create time on the GUI thread; the worker uses it to wake the main loop on -/// completion via `dvui.refresh(window, ...)` so small projects don't sit completed-but- -/// unconsumed waiting for an unrelated input event. -window: *dvui.Window, - -started_at_ns: i128, - -phase: std.atomic.Value(u8) = .init(@intFromEnum(Phase.queued)), - -/// Worker reports `(done_inputs, total_inputs)` while in the `loading` / `appending` phases. -progress_num: std.atomic.Value(u32) = .init(0), -progress_den: std.atomic.Value(u32) = .init(0), - -cancelled: std.atomic.Value(bool) = .init(false), - -/// Worker → main publish flag. `release` on write, `acquire` on read. -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, - -/// Set to `true` once the main thread has consumed `result_atlas` (so `destroy()` knows not -/// to free the moved-out atlas allocations). -result_consumed: bool = false, - -err: ?anyerror = null, - -pub fn create(allocator: std.mem.Allocator, inputs: []PackInput) !*PackJob { - const job = try allocator.create(PackJob); - job.* = .{ - .allocator = allocator, - .inputs = inputs, - .window = dvui.currentWindow(), - .started_at_ns = perf.nanoTimestamp(), - }; - return job; -} - -pub fn destroy(job: *PackJob) void { - const a = job.allocator; - for (job.inputs) |*input| input.deinit(a); - 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 - // 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)); - for (atlas.data.animations) |*anim| a.free(anim.name); - a.free(atlas.data.animations); - a.free(atlas.data.sprites); - } - a.destroy(job); -} - -pub fn elapsedExceeds(job: *const PackJob, threshold_ms: i64) bool { - const elapsed_ns = perf.nanoTimestamp() - job.started_at_ns; - return @divTrunc(elapsed_ns, std.time.ns_per_ms) >= threshold_ms; -} - -pub fn currentPhase(job: *const PackJob) Phase { - const raw = job.phase.load(.acquire); - return switch (raw) { - 0 => .queued, - 1 => .loading, - 2 => .appending, - 3 => .packing, - 4 => .blitting, - 5 => .ready, - 6 => .failed, - 7 => .cancelled, - else => .queued, - }; -} - -pub fn phaseLabel(phase: Phase) []const u8 { - return switch (phase) { - .queued => "Queued", - .loading => "Loading", - .appending => "Reducing", - .packing => "Packing", - .blitting => "Compositing", - .ready => "Done", - .failed => "Failed", - .cancelled => "Cancelled", - }; -} - -// ---------------------------------------------------------------------------- -// Worker -// ---------------------------------------------------------------------------- - -/// Worker entry point. Spawn with `std.Thread.spawn(.{}, PackJob.workerMain, .{job})`. -pub fn workerMain(job: *PackJob) void { - defer { - job.done.store(true, .release); - dvui.refresh(job.window, @src(), null); - } - - // 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(Globals.allocator()) catch |e| { - job.err = e; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - }; - var ws = work; - defer ws.deinit(); - - // Resolve and append each input. Both `.open` snapshots and `.path` loads must outlive - // the append phase, because the sprite list stores borrowed pointers into their pixel - // buffers and `buildAtlas` blits straight from those pointers. `.open` inputs are owned - // by `job.inputs` for the job's full lifetime; `.path`-loaded files are parked in - // `ws.loaded_files` (freed with `ws.deinit`). - job.phase.store(@intFromEnum(Phase.loading), .release); - job.progress_den.store(@intCast(job.inputs.len), .monotonic); - - for (job.inputs, 0..) |*input, idx| { - if (job.cancelled.load(.monotonic)) { - job.phase.store(@intFromEnum(Phase.cancelled), .release); - return; - } - - switch (input.*) { - .open => |*pf| { - job.phase.store(@intFromEnum(Phase.appending), .release); - ws.appendPackFile(pf) catch |e| { - job.err = e; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - }; - }, - .path => |path| { - // The wasm path of `startPackProject` only ever emits `.open` - // inputs (browser has no project folder to scan), so this - // branch is unreachable on wasm. Static-gate it to keep - // `File.fromPath` (which calls `Io.Dir.cwd()` / `posix.AT`, - // unavailable on `wasm32-freestanding`) out of the wasm - // reachability graph. - if (comptime @import("builtin").target.cpu.arch == .wasm32) { - job.err = error.PathInputsNotSupportedOnWasm; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - } - job.phase.store(@intFromEnum(Phase.loading), .release); - const maybe_pf = PackFile.fromPath(Globals.allocator(), path) catch |e| { - job.err = e; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - }; - if (maybe_pf) |pf_val| { - ws.loaded_files.append(pf_val) catch |e| { - var tmp = pf_val; - tmp.deinit(); - job.err = e; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - }; - job.phase.store(@intFromEnum(Phase.appending), .release); - const ref = &ws.loaded_files.items[ws.loaded_files.items.len - 1]; - ws.appendPackFile(ref) catch |e| { - job.err = e; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - }; - } - }, - } - job.progress_num.store(@intCast(idx + 1), .monotonic); - } - - if (ws.frames.items.len == 0) { - // Nothing to pack — keep `result_atlas == null`, surface as `ready`. The main thread - // treats null-result the same as the old `packAndClear` early-out: leave the existing - // atlas in place. - job.phase.store(@intFromEnum(Phase.ready), .release); - return; - } - - // Try increasing texture sizes until everything fits. - job.phase.store(@intFromEnum(Phase.packing), .release); - const tex_size = ws.packRects() catch |e| { - job.err = e; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - } orelse { - job.err = error.PackFailed; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - }; - - if (job.cancelled.load(.monotonic)) { - job.phase.store(@intFromEnum(Phase.cancelled), .release); - return; - } - - // Blit each emitted sprite into a fresh atlas pixel buffer at the location chosen by - // `packRects`, then assemble the `Internal.Atlas` value that the main thread will install. - job.phase.store(@intFromEnum(Phase.blitting), .release); - const atlas = ws.buildAtlas(tex_size) catch |e| { - job.err = e; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - }; - - 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); - job.phase.store(@intFromEnum(Phase.cancelled), .release); - return; - } - - job.result_atlas = atlas; - job.phase.store(@intFromEnum(Phase.ready), .release); -} - -// ---------------------------------------------------------------------------- -// Worker-side state. Mirrors the layout the synchronous `Packer` built up across `append` and -// `packAndClear`, but is wholly owned by the worker thread. -// ---------------------------------------------------------------------------- - -/// Borrowed view of a sprite's reduced pixel region inside its source buffer (a `PackLayer`'s -/// pixels, or a carry-over buffer for collapse chains). `buildAtlas` blits directly from -/// `source` using `stride`; no intermediate per-sprite allocation. The referenced buffer -/// must outlive the worker state — see `loaded_files` / `carry_overs` in `WorkerState`. -const WorkerSpriteRef = struct { - source: [*]const [4]u8, - src_x: u32, - src_y: u32, - w: u32, - h: u32, - stride: u32, -}; - -const WorkerSprite = struct { - image: ?WorkerSpriteRef = null, - origin: [2]f32 = .{ 0, 0 }, -}; - -const WorkerAnimation = struct { - name: []u8, - frames: []pixelart.Animation.Frame, - - fn deinit(self: *WorkerAnimation, allocator: std.mem.Allocator) void { - allocator.free(self.name); - allocator.free(self.frames); - } -}; - -const WorkerState = struct { - allocator: std.mem.Allocator, - frames: std.array_list.Managed(zstbi.Rect), - sprites: std.array_list.Managed(WorkerSprite), - animations: std.array_list.Managed(WorkerAnimation), - - /// `.path`-loaded `PackFile`s held alive for the worker's lifetime so the sprite refs - /// recorded during append remain valid through `buildAtlas`. `.open` snapshots already - /// live in `job.inputs` until the job is destroyed. - loaded_files: std.array_list.Managed(PackFile), - - /// Per-collapse-chain carry-over buffers (file-sized RGBA grids). Retained for the same - /// reason as `loaded_files`: sprite refs point into these. - carry_overs: std.array_list.Managed([][4]u8), - - id_counter: u32 = 0, - - fn init(allocator: std.mem.Allocator) !WorkerState { - return .{ - .allocator = allocator, - .frames = std.array_list.Managed(zstbi.Rect).init(allocator), - .sprites = std.array_list.Managed(WorkerSprite).init(allocator), - .animations = std.array_list.Managed(WorkerAnimation).init(allocator), - .loaded_files = std.array_list.Managed(PackFile).init(allocator), - .carry_overs = std.array_list.Managed([][4]u8).init(allocator), - }; - } - - fn deinit(self: *WorkerState) void { - for (self.animations.items) |*anim| anim.deinit(self.allocator); - for (self.loaded_files.items) |*pf| pf.deinit(); - for (self.carry_overs.items) |buf| self.allocator.free(buf); - self.frames.deinit(); - self.sprites.deinit(); - self.animations.deinit(); - self.loaded_files.deinit(); - self.carry_overs.deinit(); - } - - fn newId(self: *WorkerState) u32 { - const i = self.id_counter; - self.id_counter += 1; - return i; - } - - /// Mirrors `Packer.append`: walks the layer stack, honours collapse / visibility, and - /// emits sprite refs (borrowed pointers into either the layer's own pixel buffer or a - /// chain-local carry-over buffer) + animation entries into the worker state. Allocates - /// only the carry-over buffer per collapse chain — sprite pixels themselves are never - /// copied here; `buildAtlas` blits straight from the borrowed source. - fn appendPackFile(self: *WorkerState, pf: *const PackFile) !void { - const layers = pf.layers; - - // Carry-over pixel buffer for the current collapse chain. Sized to the full file - // canvas, matching the temporary `Layer.init(..., file.width(), file.height(), ...)` - // the synchronous Packer used. `null` until a collapse chain starts; when the chain - // ends the buffer moves into `self.carry_overs` so the sprite refs that point into it - // stay valid through `buildAtlas`. - var carry_over: ?[][4]u8 = null; - var carry_w: u32 = 0; - var carry_h: u32 = 0; - errdefer if (carry_over) |buf| self.allocator.free(buf); - - var index: usize = 0; - while (index < layers.len) : (index += 1) { - const layer = &layers[index]; - if (!layer.visible) continue; - - const last_item = index == layers.len - 1; - const prev_collapses = index != 0 and layers[index - 1].collapse; - - // True if we're inside (or just exited) a collapse chain involving `layer`. - const in_chain = (layer.collapse and !last_item) or prev_collapses; - if (in_chain) { - if (carry_over == null) { - const buf = try self.allocator.alloc([4]u8, pf.width * pf.height); - @memset(buf, .{ 0, 0, 0, 0 }); - carry_over = buf; - carry_w = pf.width; - carry_h = pf.height; - } - const dst_pixels = carry_over.?; - for (layer.pixels, dst_pixels) |src, *dst| { - if (src[3] != 0 and dst[3] == 0) dst.* = src; - } - if (layer.collapse and !last_item) continue; - } - - // Which pixels feed sprite reduction this iteration: the carry-over (if active) - // or the layer itself. Either way the buffer must outlive `buildAtlas` (see - // `loaded_files` / `carry_overs`). - const cur_pixels: [][4]u8 = if (carry_over) |buf| buf else layer.pixels; - const cur_w: u32 = if (carry_over != null) carry_w else layer.width; - const cur_h: u32 = if (carry_over != null) carry_h else layer.height; - - // Same sprite count as `File.spriteCount`: columns * rows. - const rows: u32 = if (pf.row_height == 0) 0 else pf.height / pf.row_height; - const total_sprites: usize = @as(usize, pf.columns) * @as(usize, rows); - - var sprite_index: usize = 0; - while (sprite_index < total_sprites) : (sprite_index += 1) { - const column = @as(u32, @intCast(sprite_index)) % pf.columns; - const row = @as(u32, @intCast(sprite_index)) / pf.columns; - const src_x: u32 = @min(column * pf.column_width, pf.width); - const src_y: u32 = @min(row * pf.row_height, pf.height); - - const src_rect: reduce_alg.Rect = .{ - .x = src_x, - .y = src_y, - .w = pf.column_width, - .h = pf.row_height, - }; - - if (reduce_alg.reduce(cur_pixels, cur_w, cur_h, src_rect)) |r| { - const offset_x = r.x - src_x; - const offset_y = r.y - src_y; - - const orig_x: f32 = if (sprite_index < pf.sprites.len) pf.sprites[sprite_index].origin[0] else 0; - const orig_y: f32 = if (sprite_index < pf.sprites.len) pf.sprites[sprite_index].origin[1] else 0; - - try self.sprites.append(.{ - .image = .{ - .source = cur_pixels.ptr, - .src_x = r.x, - .src_y = r.y, - .w = r.w, - .h = r.h, - .stride = cur_w, - }, - .origin = .{ orig_x - @as(f32, @floatFromInt(offset_x)), orig_y - @as(f32, @floatFromInt(offset_y)) }, - }); - try self.frames.append(.{ - .id = self.newId(), - .w = @intCast(r.w), - .h = @intCast(r.h), - }); - - const new_sprite_index = self.sprites.items.len - 1; - for (pf.animations) |anim| { - 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); - for (frames, anim.frames, 0..) |*current_frame, src_frame, i| { - current_frame.* = .{ - .sprite_index = new_sprite_index + i, - .ms = src_frame.ms, - }; - } - const merged_name = try std.fmt.allocPrint(self.allocator, "{s}_{s}", .{ anim.name, layer.name }); - try self.animations.append(.{ .name = merged_name, .frames = frames }); - } - } else { - // Empty reduced region — but the sprite may still appear in an animation, - // in which case we must emit a placeholder to keep frame indices stable. - for (pf.animations) |anim| { - for (anim.frames) |frame| { - if (frame.sprite_index != sprite_index) continue; - try self.sprites.append(.{ .image = null, .origin = .{ 0, 0 } }); - try self.frames.append(.{ - .id = self.newId(), - .w = 2, - .h = 2, - }); - } - } - } - } - - // End of a collapse chain. Move the carry-over into the worker's retained list so - // any sprite refs that point into it stay valid past this iteration. - if (carry_over) |buf| { - try self.carry_overs.append(buf); - carry_over = null; - } - } - - // If the file's last layer was still part of an unclosed chain (only happens when - // every visible layer up to the last had `collapse = true`), move that buffer too. - if (carry_over) |buf| { - try self.carry_overs.append(buf); - carry_over = null; - } - } - - fn packRects(self: *WorkerState) !?[2]u16 { - if (self.frames.items.len == 0) return null; - - var ctx: zstbi.Context = undefined; - const node_count = 4096 * 2; - var nodes: [node_count]zstbi.Node = undefined; - - const texture_sizes = [_][2]u32{ - .{ 256, 256 }, .{ 512, 256 }, .{ 256, 512 }, - .{ 512, 512 }, .{ 1024, 512 }, .{ 512, 1024 }, - .{ 1024, 1024 }, .{ 2048, 1024 }, .{ 1024, 2048 }, - .{ 2048, 2048 }, .{ 4096, 2048 }, .{ 2048, 4096 }, - .{ 4096, 4096 }, .{ 8192, 4096 }, .{ 4096, 8192 }, - }; - - for (texture_sizes) |tex_size| { - zstbi.initTarget(&ctx, tex_size[0], tex_size[1], &nodes); - zstbi.setupHeuristic(&ctx, zstbi.Heuristic.skyline_bl_sort_height); - if (zstbi.packRects(&ctx, self.frames.items) == 1) { - return .{ @intCast(tex_size[0]), @intCast(tex_size[1]) }; - } - } - - return null; - } - - /// Allocate the final atlas pixels and metadata, blit each emitted sprite into its packed - /// slot, and return an `Internal.Atlas` that owns all of its allocations through the app - /// allocator (so it survives past the job's lifetime). - /// - /// IMPORTANT: this runs on the worker thread, so we cannot use `Layer.blit` — it calls - /// `invalidate()` → `dvui.textureInvalidateCache`, which dereferences `currentWindow()` - /// 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 { - 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); - @memset(pixels, .{ 0, 0, 0, 0 }); - - const tex_w: usize = tex_size[0]; - for (self.frames.items, self.sprites.items) |frame, sprite| { - if (sprite.image) |ref| { - const slice = frame.slice(); - const dst_x: usize = slice[0]; - const dst_y: usize = slice[1]; - const w: usize = @intCast(ref.w); - const h: usize = @intCast(ref.h); - const stride: usize = @intCast(ref.stride); - const src_x: usize = @intCast(ref.src_x); - const src_y: usize = @intCast(ref.src_y); - // Blit straight from the borrowed source buffer (a layer or carry-over) into - // the atlas — no intermediate per-sprite copy, just one pass per pixel. - var row: usize = 0; - while (row < h) : (row += 1) { - const src_row_start = (src_y + row) * stride + src_x; - const src_row = ref.source[src_row_start .. src_row_start + w]; - const dst_row_start = (dst_y + row) * tex_w + dst_x; - const dst_row = pixels[dst_row_start .. dst_row_start + w]; - @memcpy(dst_row, src_row); - } - } - } - - 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 Globals.allocator().alloc(pixelart.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, 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); - anims_initialized += 1; - } - - return .{ - .source = .{ - .pixelsPMA = .{ - .rgba = @ptrCast(pixels), - .width = tex_size[0], - .height = tex_size[1], - .interpolation = .nearest, - .invalidation = .ptr, - }, - }, - .data = .{ - .sprites = sprites_out, - .animations = animations_out, - }, - }; - } -}; diff --git a/src/plugins/pixelart/src/Packer.zig b/src/plugins/pixelart/src/Packer.zig deleted file mode 100644 index 7af26053..00000000 --- a/src/plugins/pixelart/src/Packer.zig +++ /dev/null @@ -1,389 +0,0 @@ -const std = @import("std"); -const zstbi = @import("zstbi"); -const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; - - -pub const LDTKTileset = @import("LDTKTileset.zig"); - -const Packer = @This(); - -pub const Image = struct { - width: usize, - height: usize, - pixels: [][4]u8, - - pub fn deinit(self: Image, allocator: std.mem.Allocator) void { - allocator.free(self.pixels); - } -}; - -pub const Sprite = struct { - image: ?Image = null, - origin: [2]f32 = .{ 0.0, 0.0 }, - - pub fn deinit(self: *Sprite, allocator: std.mem.Allocator) void { - if (self.image) |*image| { - image.deinit(allocator); - } - } -}; - -frames: std.array_list.Managed(zstbi.Rect), -sprites: std.array_list.Managed(Sprite), -animations: std.array_list.Managed(pixelart.Animation), -id_counter: u32 = 0, -placeholder: Image, -contains_height: bool = false, -open_files: std.array_list.Managed(pixelart.internal.File), -target: PackTarget = .project, -//camera: fizzy.gfx.Camera = .{}, -atlas: ?pixelart.internal.Atlas = null, - -/// Monotonic time (`pixelart.perf.nanoTimestamp`) when the current in-memory atlas was last installed. -last_packed_at_ns: ?i128 = null, - -ldtk: bool = false, -ldtk_tilesets: std.array_list.Managed(LDTKTileset), - -pub const PackTarget = enum { - project, - all_open, - single_open, -}; - -pub fn init(allocator: std.mem.Allocator) !Packer { - const pixels: [][4]u8 = try allocator.alloc([4]u8, 4); - for (pixels) |*pixel| { - pixel[3] = 0; - } - - 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), - .placeholder = .{ .width = 2, .height = 2, .pixels = pixels }, - .ldtk_tilesets = std.array_list.Managed(LDTKTileset).init(allocator), - }; -} - -pub fn newId(self: *Packer) u32 { - const i = self.id_counter; - self.id_counter += 1; - return i; -} - -pub fn deinit(self: *Packer) void { - Globals.allocator().free(self.placeholder.pixels); - self.clearAndFree(); - self.sprites.deinit(); - self.frames.deinit(); - self.animations.deinit(); - self.ldtk_tilesets.deinit(); -} - -pub fn clearAndFree(self: *Packer) void { - for (self.sprites.items) |*sprite| { - sprite.deinit(Globals.allocator()); - } - for (self.animations.items) |*animation| { - Globals.allocator().free(animation.name); - } - for (self.ldtk_tilesets.items) |*tileset| { - for (tileset.layer_paths) |path| { - Globals.allocator().free(path); - } - Globals.allocator().free(tileset.sprites); - Globals.allocator().free(tileset.layer_paths); - } - self.frames.clearAndFree(); - self.sprites.clearAndFree(); - self.animations.clearAndFree(); - self.contains_height = false; - self.ldtk_tilesets.clearAndFree(); - - for (self.open_files.items) |*file| { - file.deinit(); - } - self.open_files.clearAndFree(); -} - -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: ?pixelart.Layer = null; - var index: usize = 0; - while (index < file.layers.slice().len) : (index += 1) { - var layer = file.layers.get(index); - if (!layer.visible) continue; - - const last_item: bool = index == file.layers.slice().len - 1; - - // 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( - 0, - "", - file.width(), - file.height(), - .{ .r = 0, .g = 0, .b = 0, .a = 0 }, - .ptr, - ); - - const src_pixels = layer.pixels(); - const dst_pixels = current_layer.pixels(); - - for (src_pixels, dst_pixels) |src, *dst| { - if (src[3] != 0 and dst[3] == 0) { //alpha - dst.* = src; - } - } - layer_opt = current_layer; - - if (layer.collapse and !last_item) { - continue; - } - } - - var current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else layer; - - const size: dvui.Size = dvui.imageSize(layer.source) catch .{ .w = 0, .h = 0 }; - - const layer_width = @as(usize, @intFromFloat(size.w)); - var sprite_index: usize = 0; - while (sprite_index < file.spriteCount()) : (sprite_index += 1) { - const sprite = file.sprites.slice().get(sprite_index); - const columns = file.columns; - - const column = @mod(@as(u32, @intCast(sprite_index)), columns); - const row = @divTrunc(@as(u32, @intCast(sprite_index)), columns); - - const src_x = std.math.clamp(column * file.column_width, 0, file.width()); - const src_y = std.math.clamp(row * file.row_height, 0, file.height()); - - const src_rect: dvui.Rect = .{ .x = @floatFromInt(src_x), .y = @floatFromInt(src_y), .w = @floatFromInt(file.column_width), .h = @floatFromInt(file.row_height) }; - - if (current_layer.reduce(src_rect)) |reduced_rect| { - const reduced_src_x: usize = @intFromFloat(reduced_rect.x); - const reduced_src_y: usize = @intFromFloat(reduced_rect.y); - const reduced_src_width: usize = @intFromFloat(reduced_rect.w); - const reduced_src_height: usize = @intFromFloat(reduced_rect.h); - - const offset = .{ reduced_src_x - src_x, reduced_src_y - src_y }; - const src_pixels = current_layer.pixels(); - - // Allocate pixels for reduced image - var image: Image = .{ - .width = reduced_src_width, - .height = reduced_src_height, - .pixels = try Globals.allocator().alloc([4]u8, reduced_src_width * reduced_src_height), - }; - - @memset(image.pixels, .{ 0, 0, 0, 0 }); - - // Copy pixels to image - { - var y: usize = reduced_src_y; - while (y < reduced_src_y + reduced_src_height) : (y += 1) { - const start = reduced_src_x + y * layer_width; - const src = src_pixels[start .. start + reduced_src_width]; - const dst = image.pixels[(y - reduced_src_y) * image.width .. (y - reduced_src_y) * image.width + image.width]; - @memcpy(dst, src); - } - } - - try self.sprites.append(.{ - .image = image, - //.heightmap_image = heightmap_image, - .origin = .{ sprite.origin[0] - @as(f32, @floatFromInt(offset[0])), sprite.origin[1] - @as(f32, @floatFromInt(offset[1])) }, - }); - - try self.frames.append(.{ .id = self.newId(), .w = @as(c_ushort, @intCast(image.width)), .h = @as(c_ushort, @intCast(image.height)) }); - - const new_sprite_index = self.sprites.items.len - 1; - 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); - 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 }), - .frames = frames, - }); - } - } - } else { - var animation_index: usize = 0; - while (animation_index < file.animations.slice().len) : (animation_index += 1) { - const animation = file.animations.slice().get(animation_index); - - for (animation.frames) |frame| { - if (frame.sprite_index == sprite_index) { - - // Sprite contains no pixels but is part of an animation - // To preserve the animation, add a blank pixel to the sprites list - try self.sprites.append(.{ - .image = null, - .origin = .{ 0, 0 }, - }); - - try self.frames.append(.{ - .id = self.newId(), - .w = 2, - .h = 2, - }); - } - } - } - } - } - - if (layer_opt) |*t| { - t.deinit(); - layer_opt = null; - } - } -} - -pub fn appendProject(packer: *Packer) !void { - if (Globals.state.host.folder()) |root_directory| { - try recurseFiles(packer, root_directory); - } -} - -pub fn recurseFiles(packer: *Packer, root_directory: []const u8) !void { - const recursor = struct { - fn search(p: *Packer, 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 (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 (Globals.state.docs.fileFromPath(abs_path)) |file| { - try p.append(file); - } else { - 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(Globals.allocator(), &[_][]const u8{ directory, entry.name }); - defer Globals.allocator().free(abs_path); - try search(p, abs_path); - } - } - } - }.search; - - try recursor(packer, root_directory); - - return; -} - -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( - 0, - "", - size[0], - size[1], - .{ .r = 0, .g = 0, .b = 0, .a = 0 }, - .ptr, - ); - - for (packer.frames.items, packer.sprites.items) |frame, sprite| { - if (sprite.image) |image| { - const slice = frame.slice(); - - atlas_layer.blit(image.pixels, .{ - .x = @floatFromInt(slice[0]), - .y = @floatFromInt(slice[1]), - .w = @floatFromInt(slice[2]), - .h = @floatFromInt(slice[3]), - }, .{}); - } - } - 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), - }; - - for (atlas.sprites, packer.sprites.items, packer.frames.items) |*dst, src, src_rect| { - dst.source = .{ src_rect.x, src_rect.y, src_rect.w, src_rect.h }; - dst.origin = src.origin; - } - - 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.length = src.length; - // dst.start = src.start; - } - - if (packer.atlas) |*current_atlas| { - current_atlas.deinitCheckerboardTile(); - for (current_atlas.data.animations) |*animation| { - Globals.allocator().free(animation.name); - } - Globals.allocator().free(current_atlas.data.sprites); - Globals.allocator().free(current_atlas.data.animations); - - Globals.allocator().free(pixelart.image.bytes(current_atlas.source)); - - current_atlas.data = atlas; - current_atlas.source = atlas_layer.source; - current_atlas.initCheckerboardTile(); - } else { - packer.atlas = .{ - .source = atlas_layer.source, - .data = atlas, - }; - packer.atlas.?.initCheckerboardTile(); - } - - packer.last_packed_at_ns = pixelart.perf.nanoTimestamp(); - packer.clearAndFree(); - } -} - -pub fn packRects(self: *Packer) !?[2]u16 { - if (self.frames.items.len == 0) return null; - - var ctx: zstbi.Context = undefined; - const node_count = 4096 * 2; - var nodes: [node_count]zstbi.Node = undefined; - - const texture_sizes = [_][2]u32{ - [_]u32{ 256, 256 }, [_]u32{ 512, 256 }, [_]u32{ 256, 512 }, - [_]u32{ 512, 512 }, [_]u32{ 1024, 512 }, [_]u32{ 512, 1024 }, - [_]u32{ 1024, 1024 }, [_]u32{ 2048, 1024 }, [_]u32{ 1024, 2048 }, - [_]u32{ 2048, 2048 }, [_]u32{ 4096, 2048 }, [_]u32{ 2048, 4096 }, - [_]u32{ 4096, 4096 }, [_]u32{ 8192, 4096 }, [_]u32{ 4096, 8192 }, - }; - - for (texture_sizes) |tex_size| { - zstbi.initTarget(&ctx, tex_size[0], tex_size[1], &nodes); - zstbi.setupHeuristic(&ctx, zstbi.Heuristic.skyline_bl_sort_height); - if (zstbi.packRects(&ctx, self.frames.items) == 1) { - return .{ @as(u16, @intCast(tex_size[0])), @as(u16, @intCast(tex_size[1])) }; - } - } - - return null; -} diff --git a/src/plugins/pixelart/src/Project.zig b/src/plugins/pixelart/src/Project.zig deleted file mode 100644 index 767dc0eb..00000000 --- a/src/plugins/pixelart/src/Project.zig +++ /dev/null @@ -1,117 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; - -const Project = @This(); - -pub var parsed: ?std.json.Parsed(Project) = null; -pub var read: ?[]u8 = null; - -/// Path for the final packed texture to save -packed_image_output: ?[]const u8 = null, - -/// Path for the final packed heightmap to save -//packed_heightmap_output: ?[]const u8 = null, - -/// Path for the final packed atlas to save -packed_atlas_output: ?[]const u8 = null, - -/// If true, the entire project will be repacked and exported on any project file save -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 (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 }; - if (std.json.parseFromSlice(Project, allocator, r, options) catch null) |p| { - parsed = p; - - // if (p.value.packed_atlas_output) |packed_atlas_output| { - // @memcpy(fizzy.editor.buffers.atlas_path[0..packed_atlas_output.len], packed_atlas_output); - // } - - // if (p.value.packed_image_output) |packed_image_output| { - // @memcpy(fizzy.editor.buffers.texture_path[0..packed_image_output.len], packed_image_output); - // } - - // if (p.value.packed_heightmap_output) |packed_heightmap_output| { - // @memcpy(fizzy.editor.buffers.heightmap_path[0..packed_heightmap_output.len], packed_heightmap_output); - // } - - return .{ - .packed_atlas_output = if (p.value.packed_atlas_output) |output| allocator.dupe(u8, output) catch null else null, - .packed_image_output = if (p.value.packed_image_output) |output| allocator.dupe(u8, output) catch null else null, - .pack_on_save = p.value.pack_on_save, - }; - } else { - std.log.debug("Failed to parse project file!", .{}); - } - } - } - - return null; -} - -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); - const options = std.json.Stringify.Options{}; - - 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, - .pack_on_save = project.pack_on_save, - }, options); - - try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = file, .data = str }); - - return; - } - - return error.FailedToSaveProject; -} - -/// 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; - - if (project.packed_atlas_output) |packed_atlas_output| { - try atlas.save(packed_atlas_output, .data); - } - - if (project.packed_image_output) |packed_image_output| { - try atlas.save(packed_image_output, .source); - } - - // if (project.packed_heightmap_output) |packed_heightmap_output| { - // const path = try std.fs.path.joinZ(Globals.state.host.arena(), &.{ parent_folder, packed_heightmap_output }); - // try atlas.save(path, .heightmap); - // } -} - -pub fn deinit(self: *Project, allocator: std.mem.Allocator) void { - if (read) |r| allocator.free(r); - - if (parsed) |p| { - p.deinit(); - parsed = null; - } - - if (self.packed_atlas_output) |output| { - allocator.free(output); - } - - if (self.packed_image_output) |output| { - allocator.free(output); - } -} diff --git a/src/plugins/pixelart/src/Settings.zig b/src/plugins/pixelart/src/Settings.zig deleted file mode 100644 index 59f90919..00000000 --- a/src/plugins/pixelart/src/Settings.zig +++ /dev/null @@ -1,212 +0,0 @@ -//! 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 dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; -const sdk = pixelart.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, host: *sdk.Host) ResolvedPanZoomScheme { - return switch (settings.input_scheme) { - .auto => switch (dvui.mouseType()) { - // Runtime platform detection so macOS web users get the trackpad default. - .unknown => if (host.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 = Globals.state; - - 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/src/Sprite.zig b/src/plugins/pixelart/src/Sprite.zig deleted file mode 100644 index ec3c3e90..00000000 --- a/src/plugins/pixelart/src/Sprite.zig +++ /dev/null @@ -1 +0,0 @@ -origin: [2]f32 = .{ 0.0, 0.0 }, diff --git a/src/plugins/pixelart/src/State.zig b/src/plugins/pixelart/src/State.zig deleted file mode 100644 index 039b1144..00000000 --- a/src/plugins/pixelart/src/State.zig +++ /dev/null @@ -1,143 +0,0 @@ -//! 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 -//! 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`. -const std = @import("std"); -const builtin = @import("builtin"); -const dvui = @import("dvui"); -const assets = @import("assets"); -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"); -const CanvasData = @import("CanvasData.zig"); -const Globals = @import("Globals.zig"); -pub const Settings = @import("Settings.zig"); -pub const Docs = @import("Docs.zig"); - -const State = @This(); - -/// A floating sprite cut/copied from the canvas, pasted relative to `offset`. -pub const SpriteClipboard = struct { - source: dvui.ImageSource, - offset: dvui.Point, -}; - -/// 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 = .{}, - -tools: Tools, -colors: Colors = .{}, - -/// 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 = .{}, -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. -layers_ratio: f32 = 0.5, - -/// 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, - -/// 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, - .settings = Settings.load(host), - .tools = try .init(allocator), - }; - 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", .{}); - }; - } -} - -/// 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 - // on hard quit — better than a use-after-free if a worker hasn't yet observed it. - job.cancelled.store(true, .monotonic); - } - 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); - } - - 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/Tools.zig b/src/plugins/pixelart/src/Tools.zig deleted file mode 100644 index 1ba3ac69..00000000 --- a/src/plugins/pixelart/src/Tools.zig +++ /dev/null @@ -1,448 +0,0 @@ -const std = @import("std"); -const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; - -const Tools = @This(); - -pub const max_brush_size: u32 = 256; -pub const max_brush_size_float: f32 = @as(f32, @floatFromInt(max_brush_size)); -pub const min_full_stroke_size: u32 = 10; - -pub const Tool = enum(u32) { - pointer, - pencil, - eraser, - bucket, - selection, -}; - -pub const Shape = enum(u32) { - circle, - square, -}; - -/// Pixel selection uses the brush stroke; box selection uses a rectangular marquee; -/// color selection flood-fills contiguous pixels of the clicked color on the active layer. -pub const SelectionMode = enum { - pixel, - box, - color, -}; - -pub const RadialMenu = struct { - mouse_position: dvui.Point.Physical = .{ .x = 0.0, .y = 0.0 }, - center: dvui.Point.Physical = .{ .x = 0.0, .y = 0.0 }, - visible: bool = false, - /// Opened by press-and-hold on empty workspace (not Space / quick-tools). Both paths pin - /// `center` at open; this flag only selects hold-specific dismiss behavior. - opened_by_press: bool = false, - /// Ignore the first pointer release after a hold-open (lifting the opening finger). - suppress_next_pointer_release: bool = false, - /// Press began outside the menu while it is hold-open; used for click-outside dismiss. - outside_click_press_p: ?dvui.Point.Physical = null, - - pub fn close(self: *RadialMenu) void { - self.visible = false; - self.opened_by_press = false; - self.suppress_next_pointer_release = false; - self.outside_click_press_p = null; - } - - /// Physical hit radius for the radial tool ring (matches `drawRadialMenu` outer disc). - pub fn hitRadiusPhysical() f32 { - return 165.0; - } - - pub fn containsPhysical(self: RadialMenu, p: dvui.Point.Physical) bool { - const r = hitRadiusPhysical(); - const dx = p.x - self.center.x; - const dy = p.y - self.center.y; - return dx * dx + dy * dy <= r * r; - } -}; - -pub const default_pencil_stroke_size: u8 = 1; -pub const default_selection_stroke_size: u8 = 6; - -current: Tool = .pointer, -previous: Tool = .pointer, -/// The stroke size for the currently active tool. Mirrors either -/// `pencil_stroke_size` or `selection_stroke_size` depending on `current`. -stroke_size: u8 = default_pencil_stroke_size, -/// Independent stroke size used by pencil/eraser/bucket. -pencil_stroke_size: u8 = default_pencil_stroke_size, -/// Independent stroke size used by the selection tool. -selection_stroke_size: u8 = default_selection_stroke_size, -stroke_shape: Shape = .circle, -previous_drawing_tool: Tool = .pencil, -radial_menu: RadialMenu = .{}, -selection_mode: SelectionMode = .box, - -stroke: std.StaticBitSet(max_brush_size * max_brush_size) = .initEmpty(), -offset_table: [][2]f32 = undefined, - -pub fn init(allocator: std.mem.Allocator) !Tools { - var tools: Tools = .{ - .offset_table = try allocator.alloc([2]f32, max_brush_size * max_brush_size), - }; - - for (0..(max_brush_size * max_brush_size)) |index| { - const center: dvui.Point = .{ .x = @floor(max_brush_size_float / 2), .y = @floor(max_brush_size_float / 2) }; - const x: f32 = @as(f32, @floatFromInt(@mod(index, max_brush_size))); - const y: f32 = @as(f32, @floatFromInt(index)) / max_brush_size_float; - tools.offset_table[index] = .{ @floor(x - center.x), @floor(y - center.y) }; - } - - tools.setStrokeSize(tools.strokeSizeFor(tools.current)); - - return tools; -} - -/// Returns the stored stroke size for the given tool. -fn strokeSizeFor(self: *const Tools, tool: Tool) u8 { - return switch (tool) { - .selection => self.selection_stroke_size, - else => self.pencil_stroke_size, - }; -} - -/// Recreates the stroke bitset and writes-through the size to the -/// per-tool storage for the currently active tool. -pub fn setStrokeSize(self: *Tools, size: u8) void { - self.stroke_size = size; - switch (self.current) { - .selection => self.selection_stroke_size = size, - .pencil, .eraser, .bucket => self.pencil_stroke_size = size, - .pointer => {}, - } - - const stroke_size: usize = @intCast(size); - - self.stroke.setRangeValue(.{ .start = 0, .end = max_brush_size * max_brush_size }, false); - - const center: dvui.Point = .{ .x = @floor(max_brush_size_float / 2), .y = @floor(max_brush_size_float / 2) }; - - for (0..(stroke_size * stroke_size)) |index| { - if (self.getIndexShapeOffset(center, index)) |i| { - self.stroke.set(i); - } - } -} - -pub fn deinit(self: *Tools, allocator: std.mem.Allocator) void { - allocator.free(self.offset_table); -} - -pub fn set(self: *Tools, tool: Tool) void { - if (self.current != tool) { - // if (fizzy.editor.getFile(fizzy.editor.open_file_index)) |file| { - // // if (file.transform_texture != null and tool != .pointer) - // // return; - - // switch (tool) { - // .heightmap => { - // file.heightmap.enable(); - // if (file.heightmap.layer == null) - // return; - // }, - // .pointer => { - // file.heightmap.disable(); - - // // if (self.current == .selection) - // // file.selection_layer.clear(true); - // }, - // else => {}, - // } - // } - self.previous = self.current; - switch (self.previous) { - .pencil, .bucket => |t| self.previous_drawing_tool = t, - else => {}, - } - self.current = tool; - self.setStrokeSize(self.strokeSizeFor(tool)); - if (tool == .pencil or tool == .eraser) { - Globals.state.host.requestCompositeWarmup(); - } - } -} - -pub fn swap(self: *Tools) void { - const temp = self.current; - self.current = self.previous; - self.previous = temp; -} - -pub fn getIndex(_: *Tools, point: dvui.Point) ?usize { - if (point.x < 0 or point.y < 0) { - return null; - } - - if (point.x >= max_brush_size_float or point.y >= max_brush_size_float) { - return null; - } - - const p: [2]usize = .{ @intFromFloat(point.x), @intFromFloat(point.y) }; - - const index = p[0] + p[1] * @as(usize, @intFromFloat(max_brush_size_float)); - if (index >= max_brush_size * max_brush_size) { - return 0; - } - return index; -} - -/// 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 = self.stroke_shape; - const s: i32 = @intCast(self.stroke_size); - - if (s == 1) { - if (current_index != 0) - return null; - - if (self.getIndex(origin)) |index| { - return index; - } - } - - const size_center_offset: i32 = -@divFloor(@as(i32, @intCast(s)), 2); - const index_i32: i32 = @as(i32, @intCast(current_index)); - const pixel_offset: [2]i32 = .{ @mod(index_i32, s) + size_center_offset, @divFloor(index_i32, s) + size_center_offset }; - - if (shape == .circle) { - const extra_pixel_offset_circle: [2]i32 = if (@mod(s, 2) == 0) .{ 1, 1 } else .{ 0, 0 }; - const pixel_offset_circle: [2]i32 = .{ pixel_offset[0] * 2 + extra_pixel_offset_circle[0], pixel_offset[1] * 2 + extra_pixel_offset_circle[1] }; - const sqr_magnitude = pixel_offset_circle[0] * pixel_offset_circle[0] + pixel_offset_circle[1] * pixel_offset_circle[1]; - - // adjust radius check for nicer looking circles - const radius_check_mult: f32 = (if (s == 3 or s > 10) 0.7 else 0.8); - - if (@as(f32, @floatFromInt(sqr_magnitude)) > @as(f32, @floatFromInt(s * s)) * radius_check_mult) { - return null; - } - } - - const pixel_i32: [2]i32 = .{ @as(i32, @intFromFloat(origin.x)) + pixel_offset[0], @as(i32, @intFromFloat(origin.y)) + pixel_offset[1] }; - const size_i32: [2]i32 = .{ @as(i32, @intCast(max_brush_size)), @as(i32, @intCast(max_brush_size)) }; - - if (pixel_i32[0] < 0 or pixel_i32[1] < 0 or pixel_i32[0] >= size_i32[0] or pixel_i32[1] >= size_i32[1]) { - return null; - } - - const pixel: dvui.Point = .{ .x = @floatFromInt(pixel_i32[0]), .y = @floatFromInt(pixel_i32[1]) }; - - if (self.getIndex(pixel)) |index| { - return index; - } - - return null; -} - -pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64) !void { - const tool_name = switch (tool) { - .pointer => "POINTER", - .pencil => "PENCIL", - .eraser => "ERASER", - .bucket => "BUCKET", - .selection => "SELECTION", - }; - - const tool_description = switch (tool) { - .pointer => "Select and move cells, rows, or columns. \n" ++ - "Hold cmd/ctrl to add to selection, and shift to subtract. \n" ++ - "Dragging can add multiple cells at once.", - .pencil => "Draw on the canvas with the left mouse button.\n" ++ - "Right click to pick up a color from the canvas. \n" ++ - "[ & ] keys increase and decrease the stroke size.", - .eraser => "Erase on the canvas.\n" ++ - "Right click an empty area to switch to the eraser tool. \n" ++ - "[ & ] keys increase and decrease the erase size.", - .bucket => "Fill the canvas with a color.\n" ++ "Hold cmd/ctrl to replace all color, non-contiguously.\n", - .selection => "Pixel mode brushes with stroke size.\nBox mode drags a rectangular marquee.\nColor mode selects contiguous pixels of the clicked color.\n" ++ "Hold cmd/ctrl to add to selection, and shift to subtract.\n", - }; - - var tooltip: dvui.FloatingTooltipWidget = undefined; - tooltip.init(@src(), .{ - .active_rect = rect, - .delay = 500_000, - .interactive = if (tool == .selection) true else false, - }, .{ - .id_extra = @intCast(id_extra), - .color_fill = dvui.themeGet().color(.content, .fill).opacity(0.9), - .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 animator = dvui.animate(@src(), .{ - .kind = .alpha, - .duration = 500_000, - }, .{ - .expand = .both, - }); - defer animator.deinit(); - - var vbox2 = dvui.box(@src(), .{ .dir = .vertical }, dvui.FloatingTooltipWidget.defaults.override(.{ - .background = false, - .expand = .both, - .border = dvui.Rect.all(0), - })); - defer vbox2.deinit(); - - pixelart.core.dvui.labelWithKeybind( - tool_name, - switch (tool) { - .pointer => dvui.currentWindow().keybinds.get("pointer") orelse .{}, - .pencil => dvui.currentWindow().keybinds.get("pencil") orelse .{}, - .eraser => dvui.currentWindow().keybinds.get("eraser") orelse .{}, - .bucket => dvui.currentWindow().keybinds.get("bucket") orelse .{}, - .selection => dvui.currentWindow().keybinds.get("selection") orelse .{}, - }, - true, - .{ - .font = dvui.Font.theme(.heading), - }, - .{ - .font = dvui.Font.theme(.mono), - .margin = dvui.Rect.all(4), - }, - ); - - _ = dvui.separator(@src(), .{ .expand = .horizontal }); - - dvui.labelNoFmt(@src(), tool_description, .{}, .{ - .font = dvui.Font.theme(.body).larger(-1.0), - .margin = dvui.Rect.all(4), - }); - - if (tool == .selection) { - _ = dvui.separator(@src(), .{ .expand = .horizontal }); - - var mode_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .gravity_x = 0.5, - .margin = dvui.Rect.all(4), - }); - defer mode_row.deinit(); - - 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 (Globals.state.colors.file_tree_palette) |*palette| { - mode_color = palette.getDVUIColor(4); - } - - { - var mode_box = dvui.groupBox(@src(), "SELECTION MODE", .{ - .expand = .horizontal, - .margin = dvui.Rect.all(4), - .font = dvui.Font.theme(.heading), - }); - defer mode_box.deinit(); - - var mode_arrange_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - }); - defer mode_arrange_box.deinit(); - - for (0..3) |mi| { - const mode: SelectionMode = switch (mi) { - 0 => .box, - 1 => .pixel, - 2 => .color, - else => unreachable, - }; - const cap = switch (mi) { - 0 => "BOX", - 1 => "PIXEL", - 2 => "COLOR", - else => unreachable, - }; - const selected = Globals.state.tools.selection_mode == mode; - - var mode_col = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - .margin = dvui.Rect.rect(6, 0, 6, 0), - .id_extra = @intCast(id_extra * 10 + mi), - }); - 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], - }; - const uv = dvui.Rect{ - .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w, - .y = @as(f32, @floatFromInt(sprite.source[1])) / atlas_size.h, - .w = @as(f32, @floatFromInt(sprite.source[2])) / atlas_size.w, - .h = @as(f32, @floatFromInt(sprite.source[3])) / atlas_size.h, - }; - - dvui.labelNoFmt(@src(), cap, .{}, .{ - .font = dvui.Font.theme(.heading), - .gravity_x = 0.5, - .margin = dvui.Rect.rect(0, 0, 0, 6), - .id_extra = @intCast(id_extra * 10 + mi), - }); - - var mode_button: dvui.ButtonWidget = undefined; - mode_button.init(@src(), .{}, .{ - .expand = .none, - .min_size_content = .{ .w = 40, .h = 40 }, - .id_extra = @intCast(id_extra * 10 + mi + 1), - .background = true, - .corner_radius = dvui.Rect.all(1000), - .color_fill = if (selected) dvui.themeGet().color(.content, .fill) else .transparent, - .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - .box_shadow = if (selected) .{ - .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), - }); - defer mode_button.deinit(); - - if (mode_button.hovered()) { - mode_button.data().options.color_border = mode_color; - } - - mode_button.processEvents(); - mode_button.drawBackground(); - - var rs = mode_button.data().contentRectScale(); - const width = @as(f32, @floatFromInt(sprite.source[2])) * rs.s; - const height = @as(f32, @floatFromInt(sprite.source[3])) * rs.s; - rs.r.x = @round(rs.r.x + (rs.r.w - width) / 2.0); - rs.r.y = @round(rs.r.y + (rs.r.h - height) / 2.0); - rs.r.w = width; - rs.r.h = height; - - dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{ - .uv = uv, - .fade = 0.0, - }) catch { - std.log.err("Failed to render selection mode icon", .{}); - }; - - if (mode_button.clicked()) { - Globals.state.tools.selection_mode = mode; - } - } - } - } - } -} diff --git a/src/plugins/pixelart/src/Transform.zig b/src/plugins/pixelart/src/Transform.zig deleted file mode 100644 index edbddffb..00000000 --- a/src/plugins/pixelart/src/Transform.zig +++ /dev/null @@ -1,281 +0,0 @@ -const std = @import("std"); -const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; - -pub const Transform = @This(); - -/// Points of the transform -/// 1-4: the corner vertices of the transform -/// 5: the pivot point, defaulted to the center of the transform -/// 6: the rotation point -target_texture: dvui.Texture.Target, -data_points: [6]dvui.Point, -track_pivot: bool = false, -dragging: bool = false, -active_point: ?TransformPoint = null, -rotation: f32 = 0.0, -start_rotation: f32 = 0.0, -radius: f32 = 0.0, -file_id: u64, -layer_id: u64, -source: dvui.ImageSource, -ortho: bool = true, - -pub fn point(self: *Transform, transform_point: TransformPoint) *dvui.Point { - return &self.data_points[@intFromEnum(transform_point)]; -} - -/// Accepts the current transform and applies it to the currently selected layer -/// Actively transformed pixels are being copied to the temporary layer for display -/// During a transform, the temporary layer is not used for anything else -/// Transform layer contains the pixels being transformed prior to transformation, -/// and the active layer has had those pixels removed. -/// -/// 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| { - var layer = file.getLayer(self.layer_id) orelse return; - - 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 (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 (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| { - if (layer_pixel[3] != 0) { - file.buffers.stroke.appendAssumeCapacity(pixel_index, layer_pixel); - } else if (transform_pixel[3] != 0 or temp_pixel.a != 0) { - file.buffers.stroke.appendAssumeCapacity(pixel_index, transform_pixel); - } - } - for (pix, 0..) |temp_pixel, pixel_index| { - if (temp_pixel.a != 0) { - @memcpy(&layer.pixels()[pixel_index], &[_]u8{ temp_pixel.r, temp_pixel.g, temp_pixel.b, temp_pixel.a }); - } - } - - // 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) { - file.editor.selection_layer.clearMask(); - for (pix, 0..) |temp_pixel, pixel_index| { - if (temp_pixel.a != 0) { - file.editor.selection_layer.mask.set(pixel_index); - } - } - } - - const t_after_loop: i128 = if (pixelart.perf.record) pixelart.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 (pixelart.perf.record) pixelart.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 (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(); - file.invalidateActiveLayerTransparencyMaskCache(); - file.editor.transform_layer.clear(); - file.editor.transform_layer.clearMask(); - file.editor.transform_layer.invalidate(); - file.editor.transform = null; - 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 (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| { - @memcpy(&layer.pixels()[pixel_index], &file.editor.transform_layer.pixels()[pixel_index]); - } - layer.invalidate(); - file.invalidateActiveLayerTransparencyMaskCache(); - - file.editor.transform_layer.clear(); - file.editor.transform_layer.clearMask(); - file.editor.transform_layer.invalidate(); - file.editor.transform = null; - Globals.allocator().free(pixelart.image.bytes(self.source)); - self.* = undefined; - } -} - -pub fn updateRadius(self: *Transform) void { - var radius: f32 = 0.0; - for (self.data_points[0..4]) |*p| { - const diff = p.diff(self.point(.pivot).*); - if (diff.length() > radius) { - radius = diff.length() + 4; - } - } - self.radius = radius; -} - -pub fn centroid(self: *Transform) dvui.Point { - var ret = self.data_points[0]; - for (self.data_points[1..4]) |*p| { - ret.x += p.x; - ret.y += p.y; - } - ret.x /= 4; - ret.y /= 4; - return ret; -} - -pub fn move(self: *Transform, delta: dvui.Point) void { - self.point(.top_left).* = self.point(.top_left).plus(delta); - self.point(.top_right).* = self.point(.top_right).plus(delta); - self.point(.bottom_right).* = self.point(.bottom_right).plus(delta); - self.point(.bottom_left).* = self.point(.bottom_left).plus(delta); - self.point(.pivot).* = self.point(.pivot).plus(delta); - self.point(.rotate).* = self.point(.rotate).plus(delta); -} - -pub fn hovered(self: *Transform, data_point: dvui.Point) bool { - var is_hovered = false; - - var path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - path.addPoint(.{ .x = self.point(.top_left).x, .y = self.point(.top_left).y }); - path.addPoint(.{ .x = self.point(.top_right).x, .y = self.point(.top_right).y }); - path.addPoint(.{ .x = self.point(.bottom_right).x, .y = self.point(.bottom_right).y }); - path.addPoint(.{ .x = self.point(.bottom_left).x, .y = self.point(.bottom_left).y }); - - const cent = self.centroid(); - - var triangles = path.build().fillConvexTriangles(dvui.currentWindow().arena(), .{ - .center = .{ .x = cent.x, .y = cent.y }, - .color = .white, - }) catch null; - - if (triangles) |*t| { - t.rotate(.{ .x = self.point(.pivot).x, .y = self.point(.pivot).y }, self.rotation); - - const top_left = t.vertexes[0]; - const top_right = t.vertexes[1]; - const bottom_right = t.vertexes[2]; - const bottom_left = t.vertexes[3]; - - { - const triangle_1 = [3]dvui.Point{ - .{ .x = top_left.pos.x, .y = top_left.pos.y }, - .{ .x = top_right.pos.x, .y = top_right.pos.y }, - .{ .x = data_point.x, .y = data_point.y }, - }; - - const triangle_2 = [3]dvui.Point{ - .{ .x = top_right.pos.x, .y = top_right.pos.y }, - .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y }, - .{ .x = data_point.x, .y = data_point.y }, - }; - - const triangle_3 = [3]dvui.Point{ - .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y }, - .{ .x = top_left.pos.x, .y = top_left.pos.y }, - .{ .x = data_point.x, .y = data_point.y }, - }; - - const triangle_4 = [3]dvui.Point{ - .{ .x = top_left.pos.x, .y = top_left.pos.y }, - .{ .x = top_right.pos.x, .y = top_right.pos.y }, - .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y }, - }; - - const area_1 = area(triangle_1); - const area_2 = area(triangle_2); - const area_3 = area(triangle_3); - const area_4 = area(triangle_4); - - const combined = area_1 + area_2 + area_3; - const diff = @abs(combined - area_4); - - if (!is_hovered) - is_hovered = diff < 0.1; - } - { - const triangle_1 = [3]dvui.Point{ - .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y }, - .{ .x = bottom_left.pos.x, .y = bottom_left.pos.y }, - .{ .x = data_point.x, .y = data_point.y }, - }; - - const triangle_2 = [3]dvui.Point{ - .{ .x = bottom_left.pos.x, .y = bottom_left.pos.y }, - .{ .x = top_left.pos.x, .y = top_left.pos.y }, - .{ .x = data_point.x, .y = data_point.y }, - }; - - const triangle_3 = [3]dvui.Point{ - .{ .x = top_left.pos.x, .y = top_left.pos.y }, - .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y }, - .{ .x = data_point.x, .y = data_point.y }, - }; - - const triangle_4 = [3]dvui.Point{ - .{ .x = top_left.pos.x, .y = top_left.pos.y }, - .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y }, - .{ .x = bottom_left.pos.x, .y = bottom_left.pos.y }, - }; - - const area_1 = area(triangle_1); - const area_2 = area(triangle_2); - const area_3 = area(triangle_3); - const area_4 = area(triangle_4); - - const combined = area_1 + area_2 + area_3; - const diff = @abs(combined - area_4); - - if (!is_hovered) - is_hovered = diff < 0.1; - } - } - - return is_hovered; -} - -fn area(triangle: [3]dvui.Point) f32 { - return @abs((triangle[0].x * (triangle[1].y - triangle[2].y) + triangle[1].x * (triangle[2].y - triangle[0].y) + triangle[2].x * (triangle[0].y - triangle[1].y)) / 2.0); -} - -pub const TransformPoint = enum(usize) { - top_left = 0, - top_right = 1, - bottom_right = 2, - bottom_left = 3, - pivot = 4, - rotate = 5, -}; diff --git a/src/plugins/pixelart/src/algorithms/algorithms.zig b/src/plugins/pixelart/src/algorithms/algorithms.zig deleted file mode 100644 index b663c080..00000000 --- a/src/plugins/pixelart/src/algorithms/algorithms.zig +++ /dev/null @@ -1,2 +0,0 @@ -pub const brezenham = @import("brezenham.zig"); -pub const reduce = @import("reduce.zig"); diff --git a/src/plugins/pixelart/src/algorithms/brezenham.zig b/src/plugins/pixelart/src/algorithms/brezenham.zig deleted file mode 100644 index 2e7f40b1..00000000 --- a/src/plugins/pixelart/src/algorithms/brezenham.zig +++ /dev/null @@ -1,44 +0,0 @@ -const std = @import("std"); -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(Globals.state.host.arena()); - - // Round input points to nearest integer grid - const x0: i32 = @intFromFloat(@floor(start.x)); - const y0: i32 = @intFromFloat(@floor(start.y)); - const x1: i32 = @intFromFloat(@floor(end.x)); - const y1: i32 = @intFromFloat(@floor(end.y)); - - const dx: i32 = @intCast(@abs(x1 - x0)); - const dy: i32 = @intCast(@abs(y1 - y0)); - - var x: i32 = x0; - var y: i32 = y0; - - const sx: i32 = if (x0 < x1) 1 else -1; - const sy: i32 = if (y0 < y1) 1 else -1; - - var err: i32 = dx - dy; - - while (true) { - try output.append(.{ .x = @floatFromInt(x), .y = @floatFromInt(y) }); - - if (x == x1 and y == y1) break; - - const e2 = 2 * err; - if (e2 > -dy) { - err -= dy; - x += sx; - } - if (e2 < dx) { - err += dx; - y += sy; - } - } - - return output.items; -} diff --git a/src/plugins/pixelart/src/algorithms/reduce.zig b/src/plugins/pixelart/src/algorithms/reduce.zig deleted file mode 100644 index 69546d97..00000000 --- a/src/plugins/pixelart/src/algorithms/reduce.zig +++ /dev/null @@ -1,452 +0,0 @@ -//! Pure transparency-aware bounding-rect tightening for sprite packing. -//! -//! The atlas packer (`tools/Packer.zig`) walks each grid cell of every visible layer and asks -//! "what is the smallest sub-rect of this cell that contains all opaque pixels?" before handing -//! the bitmap to the rect packer. That avoids reserving texture space for fully-transparent -//! borders. The same call also reports the offset by which the sprite *origin* must shift so -//! that the in-game anchor point (feet, hand, muzzle, …) still lines up after the bitmap is -//! tightened. -//! -//! This module is std-only: no dvui, no fizzy globals, no allocator. `Internal.Layer.reduce` is -//! a thin wrapper around `reduce` here, and `Packer.append` consumes both `reduce` and -//! `originAfterReduce`. -//! -//! Behavior pinned by tests in this file: -//! * Empty input — fully transparent rect, zero-area rect, or rect outside the layer — returns -//! `null` (caller should drop the sprite or substitute a placeholder). -//! * Non-empty input returns a rect whose four edges each touch at least one opaque pixel. -//! * Returned rect is contained inside the requested src rect (clamped to the layer). -//! * `originAfterReduce` is exact: `origin' = origin - (reduced_xy - src_xy)` so the world-space -//! anchor lands on the same pixel before/after the reduce. - -const std = @import("std"); -const builtin = @import("builtin"); - -/// Integer-pixel rect. Distinct from dvui.Rect (which is f32) so this module stays std-only. -pub const Rect = struct { - x: u32, - y: u32, - w: u32, - h: u32, -}; - -/// Treat any pixel with `pixels[i][3] != 0` as opaque. Matches the production rule used -/// throughout the editor (drawing tools clear alpha-zero pixels rather than touching alpha). -inline fn isOpaque(p: [4]u8) bool { - return p[3] != 0; -} - -/// True if any pixel in the contiguous `pixels` is opaque (alpha byte != 0). -/// -/// Vectorized: each RGBA pixel bitcasts to one `u32`, so it ORs the alpha bytes of -/// `vec_len` pixels per step and bails on the first opaque chunk -pub fn anyOpaque(pixels: []const [4]u8) bool { - var i: usize = 0; - if (std.simd.suggestVectorLength(u32)) |vec_len| { - const V = @Vector(vec_len, u32); - // Alpha is byte 3 of each pixel: the high byte of the u32 on little-endian - // targets, the low byte on big-endian. - const alpha_mask: V = @splat(switch (builtin.cpu.arch.endian()) { - .little => 0xFF00_0000, - .big => 0x0000_00FF, - }); - while (i + vec_len <= pixels.len) : (i += vec_len) { - const chunk: V = @bitCast(pixels[i..][0..vec_len].*); - if (@reduce(.Or, chunk & alpha_mask) != 0) return true; - } - } - while (i < pixels.len) : (i += 1) if (isOpaque(pixels[i])) return true; - return false; -} - -/// Tighten `src` to the smallest sub-rect of `pixels` (laid out row-major, `layer_width` wide and -/// `layer_height` tall) that still contains every opaque pixel inside `src`. Returns `null` when: -/// -/// * `src` has zero area, or its origin is outside the layer (caller passed nonsense), or -/// * every pixel covered by the (clamped) src rect is fully transparent. -/// -/// The returned rect is always non-empty (`w > 0 and h > 0`), fully contained within both -/// `src` (after clamp) and the layer extents, and has at least one opaque pixel touching each of -/// its four edges. -pub fn reduce( - pixels: []const [4]u8, - layer_width: u32, - layer_height: u32, - src: Rect, -) ?Rect { - if (src.w == 0 or src.h == 0) return null; - if (src.x >= layer_width or src.y >= layer_height) return null; - if (@as(usize, layer_width) * @as(usize, layer_height) != pixels.len) return null; - - const x_end = @min(src.x + src.w, layer_width); - const y_end = @min(src.y + src.h, layer_height); - - var top: u32 = src.y; - var bottom: u32 = y_end - 1; - var left: u32 = src.x; - var right: u32 = x_end - 1; - - // Find the topmost row with any opaque pixel inside the src column range. - top: while (top <= bottom) : (top += 1) { - const row_start: usize = @as(usize, left) + @as(usize, top) * layer_width; - const row = pixels[row_start .. row_start + (right - left + 1)]; - if (anyOpaque(row)) break :top; - } - if (top > bottom) return null; - - // Find the bottommost row with any opaque pixel. - bottom: while (bottom >= top) : (bottom -= 1) { - const row_start: usize = @as(usize, left) + @as(usize, bottom) * layer_width; - const row = pixels[row_start .. row_start + (right - left + 1)]; - if (anyOpaque(row)) break :bottom; - if (bottom == 0) break; - } - - // Tighten left edge by scanning columns within the [top..bottom] band. - left: while (left < right) : (left += 1) { - var y = top; - while (y <= bottom) : (y += 1) { - const idx = @as(usize, left) + @as(usize, y) * layer_width; - if (isOpaque(pixels[idx])) break :left; - } - } - - // Tighten right edge symmetrically. - right: while (right > left) : (right -= 1) { - var y = top; - while (y <= bottom) : (y += 1) { - const idx = @as(usize, right) + @as(usize, y) * layer_width; - if (isOpaque(pixels[idx])) break :right; - } - } - - return .{ - .x = left, - .y = top, - .w = right - left + 1, - .h = bottom - top + 1, - }; -} - -/// New sprite origin after a reduce step. The packer ships sprites with their bitmap tightened -/// to the rect returned by `reduce`, so the sprite origin (used at runtime as the pivot when the -/// sprite is placed in the world) must shift by the same `(dx, dy)` to keep the anchor on the -/// same pixel. With `cell_x`, `cell_y` the top-left of the cell the sprite was sliced from, and -/// `reduced_x`, `reduced_y` the top-left of the reduced rect, the new origin is: -/// -/// origin' = origin - (reduced - cell) -/// -/// Origins are stored in *cell-local* pixel coordinates (e.g. `(8, 16)` means "pivot 8 px right -/// and 16 px down inside the cell"), so subtracting the reduce offset gives the pivot's location -/// inside the *reduced* bitmap. -/// -/// Invariant: `reduced_x >= cell_x` and `reduced_y >= cell_y` (caller guaranteed by `reduce`). -pub fn originAfterReduce( - origin: [2]f32, - cell_x: u32, - cell_y: u32, - reduced_x: u32, - reduced_y: u32, -) [2]f32 { - std.debug.assert(reduced_x >= cell_x); - std.debug.assert(reduced_y >= cell_y); - const dx: f32 = @floatFromInt(reduced_x - cell_x); - const dy: f32 = @floatFromInt(reduced_y - cell_y); - return .{ origin[0] - dx, origin[1] - dy }; -} - -// ---------------------------------------------------------------- -// Tests -// ---------------------------------------------------------------- - -const expectEqual = std.testing.expectEqual; -const expect = std.testing.expect; - -const transparent: [4]u8 = .{ 0, 0, 0, 0 }; -const opaque_red: [4]u8 = .{ 255, 0, 0, 255 }; - -/// Build a `width × height` pixel buffer pre-filled with transparent pixels. -fn blankPixels(comptime width: u32, comptime height: u32) [width * height][4]u8 { - var out: [width * height][4]u8 = undefined; - @memset(&out, transparent); - return out; -} - -test "reduce returns null for fully transparent src" { - var px = blankPixels(8, 8); - try expectEqual(@as(?Rect, null), reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 })); -} - -test "reduce returns null for zero-area src" { - var px = blankPixels(8, 8); - px[0] = opaque_red; - try expectEqual(@as(?Rect, null), reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 0, .h = 4 })); - try expectEqual(@as(?Rect, null), reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 4, .h = 0 })); -} - -test "reduce returns null for src origin outside the layer" { - var px = blankPixels(4, 4); - try expectEqual(@as(?Rect, null), reduce(&px, 4, 4, .{ .x = 4, .y = 0, .w = 1, .h = 1 })); - try expectEqual(@as(?Rect, null), reduce(&px, 4, 4, .{ .x = 0, .y = 9, .w = 1, .h = 1 })); -} - -test "reduce returns null on layer/pixels length mismatch (defensive)" { - var px = blankPixels(4, 4); - px[0] = opaque_red; - try expectEqual(@as(?Rect, null), reduce(&px, 5, 4, .{ .x = 0, .y = 0, .w = 1, .h = 1 })); -} - -test "reduce: single opaque pixel collapses src to 1x1" { - var px = blankPixels(8, 8); - px[3 * 8 + 5] = opaque_red; - const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 }) orelse return error.Unexpected; - try expectEqual(@as(u32, 5), r.x); - try expectEqual(@as(u32, 3), r.y); - try expectEqual(@as(u32, 1), r.w); - try expectEqual(@as(u32, 1), r.h); -} - -test "reduce: opaque pixel at (0,0) — corners returned exactly" { - var px = blankPixels(8, 8); - px[0] = opaque_red; - const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 }) orelse return error.Unexpected; - try expectEqual(@as(u32, 0), r.x); - try expectEqual(@as(u32, 0), r.y); - try expectEqual(@as(u32, 1), r.w); - try expectEqual(@as(u32, 1), r.h); -} - -test "reduce: opaque pixel at bottom-right corner" { - var px = blankPixels(8, 8); - px[7 * 8 + 7] = opaque_red; - const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 }) orelse return error.Unexpected; - try expectEqual(@as(u32, 7), r.x); - try expectEqual(@as(u32, 7), r.y); - try expectEqual(@as(u32, 1), r.w); - try expectEqual(@as(u32, 1), r.h); -} - -test "reduce: tightens around an opaque rectangle inside the cell" { - // Paint a 3x2 rectangle at (2,4) inside an 8x8 layer. - var px = blankPixels(8, 8); - var y: u32 = 4; - while (y < 6) : (y += 1) { - var x: u32 = 2; - while (x < 5) : (x += 1) { - px[y * 8 + x] = opaque_red; - } - } - const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 }) orelse return error.Unexpected; - try expectEqual(@as(u32, 2), r.x); - try expectEqual(@as(u32, 4), r.y); - try expectEqual(@as(u32, 3), r.w); - try expectEqual(@as(u32, 2), r.h); -} - -test "reduce: src rect smaller than layer is honoured (does not see pixels outside)" { - var px = blankPixels(8, 8); - // Opaque pixel outside the src rect — must not affect the reduce. - px[7 * 8 + 7] = opaque_red; - // Opaque pixel inside the src rect. - px[1 * 8 + 1] = opaque_red; - const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 4, .h = 4 }) orelse return error.Unexpected; - try expectEqual(@as(u32, 1), r.x); - try expectEqual(@as(u32, 1), r.y); - try expectEqual(@as(u32, 1), r.w); - try expectEqual(@as(u32, 1), r.h); -} - -test "reduce: returned rect is fully contained in the (clamped) src rect" { - var px = blankPixels(16, 16); - // Stripe across the full layer. - var x: u32 = 0; - while (x < 16) : (x += 1) px[5 * 16 + x] = opaque_red; - // Pick a src rect off-center; reduce should clamp to the stripe within it. - const src = Rect{ .x = 4, .y = 3, .w = 6, .h = 5 }; - const r = reduce(&px, 16, 16, src) orelse return error.Unexpected; - try expect(r.x >= src.x); - try expect(r.y >= src.y); - try expect(r.x + r.w <= src.x + src.w); - try expect(r.y + r.h <= src.y + src.h); - try expectEqual(@as(u32, 5), r.y); - try expectEqual(@as(u32, 1), r.h); - try expectEqual(@as(u32, src.x), r.x); - try expectEqual(@as(u32, src.w), r.w); -} - -test "reduce: src that overshoots the layer is clamped, not rejected" { - var px = blankPixels(8, 8); - px[7 * 8 + 7] = opaque_red; - const r = reduce(&px, 8, 8, .{ .x = 6, .y = 6, .w = 32, .h = 32 }) orelse return error.Unexpected; - try expectEqual(@as(u32, 7), r.x); - try expectEqual(@as(u32, 7), r.y); - try expectEqual(@as(u32, 1), r.w); - try expectEqual(@as(u32, 1), r.h); -} - -test "reduce: separate opaque islands inside the src rect are spanned by one bbox" { - var px = blankPixels(10, 10); - px[2 * 10 + 1] = opaque_red; - px[7 * 10 + 8] = opaque_red; - const r = reduce(&px, 10, 10, .{ .x = 0, .y = 0, .w = 10, .h = 10 }) orelse return error.Unexpected; - try expectEqual(@as(u32, 1), r.x); - try expectEqual(@as(u32, 2), r.y); - try expectEqual(@as(u32, 8), r.w); - try expectEqual(@as(u32, 6), r.h); -} - -test "reduce: alpha=0 with non-zero RGB is treated as transparent" { - var px = blankPixels(4, 4); - // Many pipelines write color into transparent slots. The reducer must look at alpha only. - px[0] = .{ 255, 255, 255, 0 }; - px[5] = opaque_red; - const r = reduce(&px, 4, 4, .{ .x = 0, .y = 0, .w = 4, .h = 4 }) orelse return error.Unexpected; - try expectEqual(@as(u32, 1), r.x); - try expectEqual(@as(u32, 1), r.y); - try expectEqual(@as(u32, 1), r.w); - try expectEqual(@as(u32, 1), r.h); -} - -test "reduce: returned rect's edges each touch an opaque pixel" { - var px = blankPixels(12, 12); - // L-shape: column at x=2 from y=2..6, row at y=6 from x=2..8. - var y: u32 = 2; - while (y <= 6) : (y += 1) px[y * 12 + 2] = opaque_red; - var x: u32 = 2; - while (x <= 8) : (x += 1) px[6 * 12 + x] = opaque_red; - const r = reduce(&px, 12, 12, .{ .x = 0, .y = 0, .w = 12, .h = 12 }) orelse return error.Unexpected; - try expectEqual(@as(u32, 2), r.x); - try expectEqual(@as(u32, 2), r.y); - try expectEqual(@as(u32, 7), r.w); - try expectEqual(@as(u32, 5), r.h); - - // Top edge: row r.y has at least one opaque pixel within [r.x, r.x+r.w). - var has_top = false; - var has_bot = false; - var has_left = false; - var has_right = false; - { - var i: u32 = 0; - while (i < r.w) : (i += 1) { - if (isOpaque(px[r.y * 12 + (r.x + i)])) has_top = true; - if (isOpaque(px[(r.y + r.h - 1) * 12 + (r.x + i)])) has_bot = true; - } - } - { - var i: u32 = 0; - while (i < r.h) : (i += 1) { - if (isOpaque(px[(r.y + i) * 12 + r.x])) has_left = true; - if (isOpaque(px[(r.y + i) * 12 + (r.x + r.w - 1)])) has_right = true; - } - } - try expect(has_top); - try expect(has_bot); - try expect(has_left); - try expect(has_right); -} - -test "anyOpaque: empty slice is not opaque" { - const px: []const [4]u8 = &.{}; - try expect(!anyOpaque(px)); -} - -test "anyOpaque: all transparent is false" { - var px = blankPixels(16, 16); - try expect(!anyOpaque(&px)); -} - -test "anyOpaque: alpha=0 with non-zero RGB still counts as transparent" { - var px = blankPixels(8, 8); - @memset(&px, .{ 255, 255, 255, 0 }); - try expect(!anyOpaque(&px)); -} - -test "anyOpaque: a single opaque pixel anywhere is detected" { - // Cover positions inside the first vector chunk, on a likely chunk boundary, - // and in the ragged tail past the last full vector. - for ([_]usize{ 0, 1, 7, 8, 15, 16, 31, 63, 64, 100, 254, 255 }) |idx| { - var px = blankPixels(16, 16); // 256 pixels - px[idx] = opaque_red; - try expect(anyOpaque(&px)); - } -} - -test "anyOpaque: detects opaque pixel in a non-vector-multiple length (tail)" { - // 13 is prime — guarantees a scalar-tail remainder for any vector width. - var px = blankPixels(13, 1); - try expect(!anyOpaque(&px)); - px[12] = opaque_red; - try expect(anyOpaque(&px)); -} - -test "anyOpaque: only the alpha byte matters (RGB ignored)" { - var px = blankPixels(8, 8); - // Opaque red has alpha 255 -> opaque. - px[5] = opaque_red; - try expect(anyOpaque(&px)); - // Clearing alpha (but leaving RGB) makes the whole slice transparent again. - px[5] = .{ 255, 0, 0, 0 }; - try expect(!anyOpaque(&px)); -} - -test "originAfterReduce: zero offset leaves the origin untouched" { - const o = originAfterReduce(.{ 4.0, 7.5 }, 0, 0, 0, 0); - try expectEqual(@as(f32, 4.0), o[0]); - try expectEqual(@as(f32, 7.5), o[1]); -} - -test "originAfterReduce: shifts by the reduce delta within the cell" { - // Cell (32, 32) reduced rect at (35, 38) → offsets (3, 6). Origin (12, 24) becomes (9, 18). - const o = originAfterReduce(.{ 12.0, 24.0 }, 32, 32, 35, 38); - try expectEqual(@as(f32, 9.0), o[0]); - try expectEqual(@as(f32, 18.0), o[1]); -} - -test "originAfterReduce: anchor lands on the same world pixel after tighten" { - // Sprite is sliced from cell (16,16); origin in cell-local space is (10, 12). - const cell_x: u32 = 16; - const cell_y: u32 = 16; - const origin_local: [2]f32 = .{ 10.0, 12.0 }; - - // Reducer tightens the bitmap to start at (19, 17) inside the layer. - const reduced_x: u32 = 19; - const reduced_y: u32 = 17; - - const new_origin = originAfterReduce(origin_local, cell_x, cell_y, reduced_x, reduced_y); - - // Convert both origins back to layer-space and check the world pixel is identical. - const orig_world_x: f32 = origin_local[0] + @as(f32, @floatFromInt(cell_x)); - const orig_world_y: f32 = origin_local[1] + @as(f32, @floatFromInt(cell_y)); - const new_world_x: f32 = new_origin[0] + @as(f32, @floatFromInt(reduced_x)); - const new_world_y: f32 = new_origin[1] + @as(f32, @floatFromInt(reduced_y)); - try expectEqual(orig_world_x, new_world_x); - try expectEqual(orig_world_y, new_world_y); -} - -test "originAfterReduce + reduce: round-trip on a real bitmap" { - // Paint a 2x2 opaque block at (5, 6) inside an 8x8 cell at (0, 0) within a 16x16 layer. - // Cell origin (0, 0); sprite origin in cell-local space at (4, 5) (just below the block). - var px = blankPixels(16, 16); - var y: u32 = 6; - while (y < 8) : (y += 1) { - var x: u32 = 5; - while (x < 7) : (x += 1) { - px[y * 16 + x] = opaque_red; - } - } - - const cell = Rect{ .x = 0, .y = 0, .w = 8, .h = 8 }; - const r = reduce(&px, 16, 16, cell) orelse return error.Unexpected; - try expectEqual(@as(u32, 5), r.x); - try expectEqual(@as(u32, 6), r.y); - - const new_origin = originAfterReduce(.{ 4.0, 5.0 }, cell.x, cell.y, r.x, r.y); - // Origin in reduced-bitmap-local space. - try expectEqual(@as(f32, -1.0), new_origin[0]); - try expectEqual(@as(f32, -1.0), new_origin[1]); - // World pixel preserved. - try expectEqual( - @as(f32, 4.0) + @as(f32, @floatFromInt(cell.x)), - new_origin[0] + @as(f32, @floatFromInt(r.x)), - ); -} diff --git a/src/plugins/pixelart/src/clipboard.zig b/src/plugins/pixelart/src/clipboard.zig deleted file mode 100644 index 3cab67d6..00000000 --- a/src/plugins/pixelart/src/clipboard.zig +++ /dev/null @@ -1,220 +0,0 @@ -//! 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/deps/msf_gif/fizzy_msf_gif_wasm.c b/src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c deleted file mode 100644 index 18ecca45..00000000 --- a/src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c +++ /dev/null @@ -1,55 +0,0 @@ -// msf_gif encoder for wasm32-freestanding: no ; route heap through DVUI's -// exported allocator (same as zstbi / zip shims). SSE2 paths are disabled — not available on wasm. - -#include - -void *memcpy(void *restrict dest, const void *restrict src, size_t n) { - unsigned char *d = (unsigned char *)dest; - const unsigned char *s = (const unsigned char *)src; - for (size_t i = 0; i < n; ++i) d[i] = s[i]; - return dest; -} - -void *memset(void *s, int c, size_t n) { - unsigned char *p = (unsigned char *)s; - const unsigned char byte = (unsigned char)c; - for (size_t i = 0; i < n; ++i) p[i] = byte; - return s; -} - -extern void *dvui_c_alloc(size_t size); -extern void dvui_c_free(void *ptr); - -static void *fizzy_msf_gif_malloc(size_t newSize) { - return dvui_c_alloc(newSize); -} - -static void *fizzy_msf_gif_realloc(void *oldMemory, size_t oldSize, size_t newSize) { - if (newSize == 0) { - dvui_c_free(oldMemory); - return NULL; - } - void *ptr = dvui_c_alloc(newSize); - if (ptr == NULL) return NULL; - if (oldMemory != NULL && oldSize > 0) { - const size_t copy = oldSize < newSize ? oldSize : newSize; - unsigned char *dst = (unsigned char *)ptr; - const unsigned char *src = (const unsigned char *)oldMemory; - for (size_t i = 0; i < copy; ++i) dst[i] = src[i]; - dvui_c_free(oldMemory); - } - return ptr; -} - -static void fizzy_msf_gif_free(void *oldMemory) { - dvui_c_free(oldMemory); -} - -#define MSF_GIF_MALLOC(contextPointer, newSize) fizzy_msf_gif_malloc(newSize) -#define MSF_GIF_REALLOC(contextPointer, oldMemory, oldSize, newSize) fizzy_msf_gif_realloc(oldMemory, oldSize, newSize) -#define MSF_GIF_FREE(contextPointer, oldMemory, oldSize) fizzy_msf_gif_free(oldMemory) - -#define MSF_GIF_IMPL -#define MSF_USE_ALPHA -#define MSF_GIF_NO_SSE2 -#include "msf_gif.h" diff --git a/src/plugins/pixelart/src/deps/msf_gif/msf_gif.c b/src/plugins/pixelart/src/deps/msf_gif/msf_gif.c deleted file mode 100644 index 27b8c41a..00000000 --- a/src/plugins/pixelart/src/deps/msf_gif/msf_gif.c +++ /dev/null @@ -1,3 +0,0 @@ -#define MSF_GIF_IMPL -#define MSF_USE_ALPHA -#include "msf_gif.h" \ No newline at end of file diff --git a/src/plugins/pixelart/src/deps/msf_gif/msf_gif.h b/src/plugins/pixelart/src/deps/msf_gif/msf_gif.h deleted file mode 100644 index 395f1c84..00000000 --- a/src/plugins/pixelart/src/deps/msf_gif/msf_gif.h +++ /dev/null @@ -1,735 +0,0 @@ -/* -HOW TO USE: - - In exactly one translation unit (.c or .cpp file), #define MSF_GIF_IMPL before including the header, like so: - - #define MSF_GIF_IMPL - #include "msf_gif.h" - - Everywhere else, just include the header like normal. - - -USAGE EXAMPLE: - - int width = 480, height = 320, centisecondsPerFrame = 5, quality = 16; - MsfGifState gifState = {}; - // msf_gif_bgra_flag = true; //optionally, set this flag if your pixels are in BGRA format instead of RGBA - // msf_gif_alpha_threshold = 128; //optionally, enable transparency (see function documentation below for details) - msf_gif_begin(&gifState, width, height); - msf_gif_frame(&gifState, ..., centisecondsPerFrame, quality, width * 4); //frame 1 - msf_gif_frame(&gifState, ..., centisecondsPerFrame, quality, width * 4); //frame 2 - msf_gif_frame(&gifState, ..., centisecondsPerFrame, quality, width * 4); //frame 3, etc... - MsfGifResult result = msf_gif_end(&gifState); - if (result.data) { - FILE * fp = fopen("MyGif.gif", "wb"); - fwrite(result.data, result.dataSize, 1, fp); - fclose(fp); - } - msf_gif_free(result); - -Detailed function documentation can be found in the header section below. - - -ERROR HANDLING: - - If memory allocation fails, the functions will signal the error via their return values. - If one function call fails, the library will free all of its allocations, - and all subsequent calls will safely no-op and return 0 until the next call to `msf_gif_begin()`. - Therefore, it's safe to check only the return value of `msf_gif_end()`. - - -REPLACING MALLOC: - - This library uses malloc+realloc+free internally for memory allocation. - To facilitate integration with custom memory allocators, these calls go through macros, which can be redefined. - The expected function signature equivalents of the macros are as follows: - - void * MSF_GIF_MALLOC(void * context, size_t newSize) - void * MSF_GIF_REALLOC(void * context, void * oldMemory, size_t oldSize, size_t newSize) - void MSF_GIF_FREE(void * context, void * oldMemory, size_t oldSize) - - If your allocator needs a context pointer, you can set the `customAllocatorContext` field of the MsfGifState struct - before calling msf_gif_begin(), and it will be passed to all subsequent allocator macro calls. - - The maximum number of bytes the library will allocate to encode a single gif is bounded by the following formula: - `(2 * 1024 * 1024) + (128 * 1024) + (width * height * 8) + ((2048 + width * height * 1.5) * 2 * frameCount)` - The peak heap memory usage in bytes, if using a general-purpose heap allocator, is bounded by the following formula: - `(2 * 1024 * 1024) + (128 * 1024) + (width * height * 11) + 2048 + (16 * frameCount) + (2 * sizeOfResultingGif) - - -See end of file for license information. -*/ - -//version 2.4 - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -/// HEADER /// -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -#ifndef MSF_GIF_H -#define MSF_GIF_H - -#include -#include - -typedef struct { - void * data; - size_t dataSize; - - size_t allocSize; //internal use - void * contextPointer; //internal use -} MsfGifResult; - -typedef struct { //internal use - uint32_t * pixels; - int depth, count, rbits, gbits, bbits; -} MsfCookedFrame; - -typedef struct MsfGifBuffer { //internal use - struct MsfGifBuffer * next; - size_t size; - uint8_t data[1]; -} MsfGifBuffer; - -typedef size_t (* MsfGifFileWriteFunc) (const void * buffer, size_t size, size_t count, void * stream); -typedef struct { //internal use - MsfGifFileWriteFunc fileWriteFunc; - void * fileWriteData; - MsfCookedFrame previousFrame; - MsfCookedFrame currentFrame; - int16_t * lzwMem; - uint8_t * tlbMem; - uint8_t * usedMem; - MsfGifBuffer * listHead; - MsfGifBuffer * listTail; - int width, height; - void * customAllocatorContext; - int framesSubmitted; //needed for transparency to work correctly (because we reach into the previous frame) -} MsfGifState; - -#ifdef __cplusplus -extern "C" { -#endif //__cplusplus - -/** - * @param width Image width in pixels. - * @param height Image height in pixels. - * @return Non-zero on success, 0 on error. - */ -int msf_gif_begin(MsfGifState * handle, int width, int height); - -/** - * @param pixelData Pointer to raw framebuffer data. Rows must be contiguous in memory, in RGBA8 format - * (or BGRA8 if you have set `msf_gif_bgra_flag = true`). - * Note: This function does NOT free `pixelData`. You must free it yourself afterwards. - * @param centiSecondsPerFrame How many hundredths of a second this frame should be displayed for. - * Note: This being specified in centiseconds is a limitation of the GIF format. - * @param quality This parameter limits the maximum color accuracy for quantization. - * Actual color accuracy varies dynamically based on how many colors are used in the frame. - * `quality` is clamped between 1 and 16. The recommended default is 16. - * Lowering this value can result in smaller gifs and slightly faster exports, - * but the resulting gifs may look noticeably worse with a more extreme dither pattern. - * @param pitchInBytes The number of bytes from the beginning of one row of pixels to the beginning of the next. - * If zero, the rows will be assumed to be contiguous (equivalent to `width * 4`). - * If negative, the rows will be reversed, thus flipping the image vertically. - * Regardless, `pixelData` should always point to the *first* row in memory, not the last. - * @return Non-zero on success, 0 on error. - */ -int msf_gif_frame(MsfGifState * handle, uint8_t * pixelData, int centiSecondsPerFrame, int quality, int pitchInBytes); - -/** - * @return A block of memory containing the gif file data, or NULL on error. - * You are responsible for freeing this via `msf_gif_free()`. - */ -MsfGifResult msf_gif_end(MsfGifState * handle); - -/** - * @param result The MsfGifResult struct, verbatim as it was returned from `msf_gif_end()`. - */ -void msf_gif_free(MsfGifResult result); - -//The gif format only supports 1-bit transparency, meaning a pixel will either be fully transparent or fully opaque. -//Pixels with an alpha value less than the alpha threshold will be treated as transparent. -//To enable exporting transparent gifs, set it to a value between 1 and 255 (inclusive) before calling msf_gif_frame(). -//Setting it to 0 causes the alpha channel to be ignored. Its initial value is 0. -extern int msf_gif_alpha_threshold; - -//Set `msf_gif_bgra_flag = true` before calling `msf_gif_frame()` if your pixels are in BGRA byte order instead of RBGA. -extern int msf_gif_bgra_flag; - - - -//TO-FILE FUNCTIONS -//These functions are equivalent to the ones above, but they write results to a file incrementally, -//instead of building a buffer in memory. This can result in lower memory usage when saving large gifs, -//because memory usage is bounded by only the size of a single frame, and is not dependent on the number of frames. -//There is currently no reason to use these unless you are on a memory-constrained platform. -//If in doubt about which API to use, for now you should use the normal (non-file) functions above. -//The signature of MsfGifFileWriteFunc matches fwrite for convenience, so that you can use the C file API like so: -// FILE * fp = fopen("MyGif.gif", "wb"); -// msf_gif_begin_to_file(&handle, width, height, (MsfGifFileWriteFunc) fwrite, (void *) fp); -// msf_gif_frame_to_file(...) -// msf_gif_end_to_file(&handle); -// fclose(fp); -//If you use a custom file write function, you must take care to return the same values that fwrite() would return. -//Note that all three functions will potentially write to the file. -int msf_gif_begin_to_file(MsfGifState * handle, int width, int height, MsfGifFileWriteFunc func, void * filePointer); -int msf_gif_frame_to_file(MsfGifState * handle, uint8_t * pixelData, int centiSecondsPerFrame, int quality, int pitchInBytes); -int msf_gif_end_to_file(MsfGifState * handle); //returns 0 on error and non-zero on success - -#ifdef __cplusplus -} -#endif //__cplusplus - -#endif //MSF_GIF_H - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -/// IMPLEMENTATION /// -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -#ifdef MSF_GIF_IMPL -#ifndef MSF_GIF_ALREADY_IMPLEMENTED_IN_THIS_TRANSLATION_UNIT -#define MSF_GIF_ALREADY_IMPLEMENTED_IN_THIS_TRANSLATION_UNIT - -//ensure the library user has either defined all of malloc/realloc/free, or none -#if defined(MSF_GIF_MALLOC) && defined(MSF_GIF_REALLOC) && defined(MSF_GIF_FREE) //ok -#elif !defined(MSF_GIF_MALLOC) && !defined(MSF_GIF_REALLOC) && !defined(MSF_GIF_FREE) //ok -#else -#error "You must either define all of MSF_GIF_MALLOC, MSF_GIF_REALLOC, and MSF_GIF_FREE, or define none of them" -#endif - -//provide default allocator definitions that redirect to the standard global allocator -#if !defined(MSF_GIF_MALLOC) -#include //malloc, etc. -#define MSF_GIF_MALLOC(contextPointer, newSize) malloc(newSize) -#define MSF_GIF_REALLOC(contextPointer, oldMemory, oldSize, newSize) realloc(oldMemory, newSize) -#define MSF_GIF_FREE(contextPointer, oldMemory, oldSize) free(oldMemory) -#endif - -//instrumentation for capturing profiling traces (useless for the library user, but useful for the library author) -#ifdef MSF_GIF_ENABLE_TRACING -#define MsfTimeFunc TimeFunc -#define MsfTimeLoop TimeLoop -#define msf_init_profiling_thread init_profiling_thread -#else -#define MsfTimeFunc -#define MsfTimeLoop(name) -#define msf_init_profiling_thread() -#endif //MSF_GIF_ENABLE_TRACING - -#include //memcpy - -//TODO: use compiler-specific notation to force-inline functions currently marked inline -#if defined(__GNUC__) //gcc, clang -static inline int msf_bit_log(int i) { return 32 - __builtin_clz(i); } -#elif defined(_MSC_VER) //msvc -#include -static inline int msf_bit_log(int i) { unsigned long idx; _BitScanReverse(&idx, i); return idx + 1; } -#else //fallback implementation for other compilers -//from https://stackoverflow.com/a/31718095/3064745 - thanks! -static inline int msf_bit_log(int i) { - static const int MultiplyDeBruijnBitPosition[32] = { - 0, 9, 1, 10, 13, 21, 2, 29, 11, 14, 16, 18, 22, 25, 3, 30, - 8, 12, 20, 28, 15, 17, 24, 7, 19, 27, 23, 6, 26, 5, 4, 31, - }; - i |= i >> 1; - i |= i >> 2; - i |= i >> 4; - i |= i >> 8; - i |= i >> 16; - return MultiplyDeBruijnBitPosition[(uint32_t)(i * 0x07C4ACDDU) >> 27] + 1; -} -#endif -static inline int msf_imin(int a, int b) { return a < b? a : b; } -static inline int msf_imax(int a, int b) { return b < a? a : b; } - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -/// Frame Cooking /// -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -#if (defined (__SSE2__) || defined (_M_X64) || _M_IX86_FP == 2) && !defined(MSF_GIF_NO_SSE2) -#include -#endif - -int msf_gif_alpha_threshold = 0; -int msf_gif_bgra_flag = 0; - -static void msf_cook_frame(MsfCookedFrame * frame, uint8_t * raw, uint8_t * used, - int width, int height, int pitch, int depth) -{ MsfTimeFunc - //bit depth for each channel - const static int rdepthsArray[17] = { 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5 }; - const static int gdepthsArray[17] = { 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6 }; - const static int bdepthsArray[17] = { 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5 }; - //this extra level of indirection looks unnecessary but we need to explicitly decay the arrays to pointers - //in order to be able to swap them because of C's annoying not-quite-pointers, not-quite-value-types stack arrays. - const int * rdepths = msf_gif_bgra_flag? bdepthsArray : rdepthsArray; - const int * gdepths = gdepthsArray; - const int * bdepths = msf_gif_bgra_flag? rdepthsArray : bdepthsArray; - - const static int ditherKernel[16] = { - 0 << 12, 8 << 12, 2 << 12, 10 << 12, - 12 << 12, 4 << 12, 14 << 12, 6 << 12, - 3 << 12, 11 << 12, 1 << 12, 9 << 12, - 15 << 12, 7 << 12, 13 << 12, 5 << 12, - }; - - uint32_t * cooked = frame->pixels; - int count = 0; - MsfTimeLoop("do") do { - int rbits = rdepths[depth], gbits = gdepths[depth], bbits = bdepths[depth]; - int paletteSize = (1 << (rbits + gbits + bbits)) + 1; - memset(used, 0, paletteSize * sizeof(uint8_t)); - - //TODO: document what this math does and why it's correct - int rdiff = (1 << (8 - rbits)) - 1; - int gdiff = (1 << (8 - gbits)) - 1; - int bdiff = (1 << (8 - bbits)) - 1; - short rmul = (short) ((255.0f - rdiff) / 255.0f * 257); - short gmul = (short) ((255.0f - gdiff) / 255.0f * 257); - short bmul = (short) ((255.0f - bdiff) / 255.0f * 257); - - int gmask = ((1 << gbits) - 1) << rbits; - int bmask = ((1 << bbits) - 1) << rbits << gbits; - - MsfTimeLoop("cook") for (int y = 0; y < height; ++y) { - int x = 0; - - #if (defined (__SSE2__) || defined (_M_X64) || _M_IX86_FP == 2) && !defined(MSF_GIF_NO_SSE2) - __m128i k = _mm_loadu_si128((__m128i *) &ditherKernel[(y & 3) * 4]); - __m128i k2 = _mm_or_si128(_mm_srli_epi32(k, rbits), _mm_slli_epi32(_mm_srli_epi32(k, bbits), 16)); - for (; x < width - 3; x += 4) { - uint8_t * pixels = &raw[y * pitch + x * 4]; - __m128i p = _mm_loadu_si128((__m128i *) pixels); - - __m128i rb = _mm_and_si128(p, _mm_set1_epi32(0x00FF00FF)); - __m128i rb1 = _mm_mullo_epi16(rb, _mm_set_epi16(bmul, rmul, bmul, rmul, bmul, rmul, bmul, rmul)); - __m128i rb2 = _mm_adds_epu16(rb1, k2); - __m128i r3 = _mm_srli_epi32(_mm_and_si128(rb2, _mm_set1_epi32(0x0000FFFF)), 16 - rbits); - __m128i b3 = _mm_and_si128(_mm_srli_epi32(rb2, 32 - rbits - gbits - bbits), _mm_set1_epi32(bmask)); - - __m128i g = _mm_and_si128(_mm_srli_epi32(p, 8), _mm_set1_epi32(0x000000FF)); - __m128i g1 = _mm_mullo_epi16(g, _mm_set1_epi32(gmul)); - __m128i g2 = _mm_adds_epu16(g1, _mm_srli_epi32(k, gbits)); - __m128i g3 = _mm_and_si128(_mm_srli_epi32(g2, 16 - rbits - gbits), _mm_set1_epi32(gmask)); - - __m128i out = _mm_or_si128(_mm_or_si128(r3, g3), b3); - - //mask in transparency based on threshold - //NOTE: we can theoretically do a sub instead of srli by doing an unsigned compare via bias - // to maybe save a TINY amount of throughput? but lol who cares maybe I'll do it later -m - __m128i invAlphaMask = _mm_cmplt_epi32(_mm_srli_epi32(p, 24), _mm_set1_epi32(msf_gif_alpha_threshold)); - out = _mm_or_si128(_mm_and_si128(invAlphaMask, _mm_set1_epi32(paletteSize - 1)), _mm_andnot_si128(invAlphaMask, out)); - - //TODO: does storing this as a __m128i then reading it back as a uint32_t violate strict aliasing? - uint32_t * c = &cooked[y * width + x]; - _mm_storeu_si128((__m128i *) c, out); - } - #endif - - //scalar cleanup loop - for (; x < width; ++x) { - uint8_t * p = &raw[y * pitch + x * 4]; - - //transparent pixel if alpha is low - if (p[3] < msf_gif_alpha_threshold) { - cooked[y * width + x] = paletteSize - 1; - continue; - } - - int dx = x & 3, dy = y & 3; - int k = ditherKernel[dy * 4 + dx]; - cooked[y * width + x] = - (msf_imin(65535, p[2] * bmul + (k >> bbits)) >> (16 - rbits - gbits - bbits) & bmask) | - (msf_imin(65535, p[1] * gmul + (k >> gbits)) >> (16 - rbits - gbits ) & gmask) | - msf_imin(65535, p[0] * rmul + (k >> rbits)) >> (16 - rbits ); - } - } - - count = 0; - MsfTimeLoop("mark") for (int i = 0; i < width * height; ++i) { - used[cooked[i]] = 1; - } - - //count used colors, transparent is ignored - MsfTimeLoop("count") for (int j = 0; j < paletteSize - 1; ++j) { - count += used[j]; - } - } while (count >= 256 && --depth); - - MsfCookedFrame ret = { cooked, depth, count, rdepths[depth], gdepths[depth], bdepths[depth] }; - *frame = ret; -} - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -/// Frame Compression /// -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -static inline void msf_put_code(uint8_t * * writeHead, uint32_t * blockBits, int len, uint32_t code) { - //insert new code into block buffer - int idx = *blockBits / 8; - int bit = *blockBits % 8; - (*writeHead)[idx + 0] |= code << bit ; - (*writeHead)[idx + 1] |= code >> ( 8 - bit); - (*writeHead)[idx + 2] |= code >> (16 - bit); - *blockBits += len; - - //prep the next block buffer if the current one is full - if (*blockBits >= 256 * 8) { - *blockBits -= 255 * 8; - (*writeHead) += 256; - (*writeHead)[2] = (*writeHead)[1]; - (*writeHead)[1] = (*writeHead)[0]; - (*writeHead)[0] = 255; - memset((*writeHead) + 4, 0, 256); - } -} - -typedef struct { - int16_t * data; - int len; - int stride; -} MsfStridedList; - -static inline void msf_lzw_reset(MsfStridedList * lzw, int tableSize, int stride) { MsfTimeFunc - memset(lzw->data, 0xFF, 4096 * stride * sizeof(int16_t)); - lzw->len = tableSize + 2; - lzw->stride = stride; -} - -//PERF TODO: is it possible to use the same array for both `used` and `tlb`? -static MsfGifBuffer * msf_compress_frame(void * allocContext, int width, int height, int centiSeconds, - MsfCookedFrame frame, MsfGifState * handle, uint8_t * used, uint8_t * tlb, int16_t * lzwMem) -{ MsfTimeFunc - //NOTE: We reserve enough memory for the theoretical worst case upfront because it's a reasonable amount, - // and prevents us from ever having to check size or realloc during compression. - //NOTE: headers + color table + 12 bits per pixel (since 12 bits is the maximum code size) - // + space for at least one full Image Data block (needed for small images since we zero a whole block at a time) - int maxBufSize = offsetof(MsfGifBuffer, data) + 32 + 256 * 3 + width * height * 3 / 2 + (256 + 4); - MsfGifBuffer * buffer = (MsfGifBuffer *) MSF_GIF_MALLOC(allocContext, maxBufSize); - if (!buffer) { return NULL; } - uint8_t * writeHead = buffer->data; - MsfStridedList lzw = { lzwMem }; - - //allocate tlb - int totalBits = frame.rbits + frame.gbits + frame.bbits; - int tlbSize = (1 << totalBits) + 1; - - //generate palette - typedef struct { uint8_t r, g, b; } Color3; - Color3 table[256] = { {0} }; - int tableIdx = 1; //we start counting at 1 because 0 is the transparent color - //transparent is always last in the table - tlb[tlbSize-1] = 0; - MsfTimeLoop("table") for (int i = 0; i < tlbSize-1; ++i) { - if (used[i]) { - tlb[i] = tableIdx; - int rmask = (1 << frame.rbits) - 1; - int gmask = (1 << frame.gbits) - 1; - //isolate components - int r = i & rmask; - int g = i >> frame.rbits & gmask; - int b = i >> (frame.rbits + frame.gbits); - //shift into highest bits - r <<= 8 - frame.rbits; - g <<= 8 - frame.gbits; - b <<= 8 - frame.bbits; - table[tableIdx].r = r | r >> frame.rbits | r >> (frame.rbits * 2) | r >> (frame.rbits * 3); - table[tableIdx].g = g | g >> frame.gbits | g >> (frame.gbits * 2) | g >> (frame.gbits * 3); - table[tableIdx].b = b | b >> frame.bbits | b >> (frame.bbits * 2) | b >> (frame.bbits * 3); - if (msf_gif_bgra_flag) { - uint8_t temp = table[tableIdx].r; - table[tableIdx].r = table[tableIdx].b; - table[tableIdx].b = temp; - } - ++tableIdx; - } - } - int hasTransparentPixels = used[tlbSize-1]; - - //SPEC: "Because of some algorithmic constraints however, black & white images which have one color bit - // must be indicated as having a code size of 2." - int tableBits = msf_imax(2, msf_bit_log(tableIdx - 1)); - int tableSize = 1 << tableBits; - //NOTE: we don't just compare `depth` field here because it will be wrong for the first frame and we will segfault - MsfCookedFrame previous = handle->previousFrame; - int hasSamePal = frame.rbits == previous.rbits && frame.gbits == previous.gbits && frame.bbits == previous.bbits; - int framesCompatible = hasSamePal && !hasTransparentPixels; - - //write the Graphics `Control Extension` and `Image Descriptor` blocks - //NOTE: because __attribute__((__packed__)) is annoyingly compiler-specific, we do this unreadable weirdness - char headerBytes[19] = "\x21\xF9\x04\x05\0\0\0\0" "\x2C\0\0\0\0\0\0\0\0\x80"; - //NOTE: we need to check the frame number because if we reach into the buffer prior to the first frame, - // we'll just clobber the file header instead, which is a bug - if (hasTransparentPixels && handle->framesSubmitted > 0) { - handle->listTail->data[3] = 0x09; //set the previous frame's disposal to background, so transparency is possible - } - memcpy(&headerBytes[4], ¢iSeconds, 2); - memcpy(&headerBytes[13], &width, 2); - memcpy(&headerBytes[15], &height, 2); - headerBytes[17] |= tableBits - 1; - memcpy(writeHead, headerBytes, 18); - writeHead += 18; - - //write Local Color Table - memcpy(writeHead, table, tableSize * sizeof(Color3)); - writeHead += tableSize * sizeof(Color3); - *writeHead++ = tableBits; - - //prep first Image Data block (analogous to what we do at the end of msf_put_code()) - memset(writeHead, 0, 256 + 4); //we write up to 4 bytes ahead of the end of the block - see msf_put_code() - writeHead[0] = 255; - uint32_t blockBits = 8; //relative to block.head - - //SPEC: "Encoders should output a Clear code as the first code of each image data stream." - msf_lzw_reset(&lzw, tableSize, tableIdx); - msf_put_code(&writeHead, &blockBits, msf_bit_log(lzw.len - 1), tableSize); - - int lastCode = framesCompatible && frame.pixels[0] == previous.pixels[0]? 0 : tlb[frame.pixels[0]]; - MsfTimeLoop("compress") for (int i = 1; i < width * height; ++i) { - //PERF: branching vs. branchless version of this line is observed to have no discernable impact on speed - int color = framesCompatible && frame.pixels[i] == previous.pixels[i]? 0 : tlb[frame.pixels[i]]; - int code = (&lzw.data[lastCode * lzw.stride])[color]; - if (code < 0) { - //write to code stream - int codeBits = msf_bit_log(lzw.len - 1); - msf_put_code(&writeHead, &blockBits, codeBits, lastCode); - - if (lzw.len > 4095) { - //reset buffer code table - msf_put_code(&writeHead, &blockBits, codeBits, tableSize); - msf_lzw_reset(&lzw, tableSize, tableIdx); - } else { - (&lzw.data[lastCode * lzw.stride])[color] = lzw.len; - ++lzw.len; - } - - lastCode = color; - } else { - lastCode = code; - } - } - - //write code for leftover index buffer contents, then the end code - msf_put_code(&writeHead, &blockBits, msf_imin(12, msf_bit_log(lzw.len - 1)), lastCode); - msf_put_code(&writeHead, &blockBits, msf_imin(12, msf_bit_log(lzw.len)), tableSize + 1); - - //flush remaining data - if (blockBits > 8) { - int bytes = (blockBits + 7) / 8; //round up - writeHead[0] = bytes - 1; - writeHead += bytes; - } - *writeHead++ = 0; //terminating block - - //fill in buffer header and shrink buffer to fit data - buffer->next = NULL; - buffer->size = writeHead - buffer->data; - MsfGifBuffer * moved = - (MsfGifBuffer *) MSF_GIF_REALLOC(allocContext, buffer, maxBufSize, offsetof(MsfGifBuffer, data) + buffer->size); - if (!moved) { MSF_GIF_FREE(allocContext, buffer, maxBufSize); return NULL; } - return moved; -} - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -/// To-memory API /// -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -static const int lzwAllocSize = 4096 * 256 * sizeof(int16_t); -static const int tlbAllocSize = ((1 << 16) + 1) * sizeof(uint8_t); -static const int usedAllocSize = ((1 << 16) + 1) * sizeof(uint8_t); - -//NOTE: by C standard library conventions, freeing NULL should be a no-op, -// but just in case the user's custom free doesn't follow that rule, we do null checks on our end as well. -static void msf_free_gif_state(MsfGifState * handle) { - if (handle->previousFrame.pixels) MSF_GIF_FREE(handle->customAllocatorContext, handle->previousFrame.pixels, - handle->width * handle->height * sizeof(uint32_t)); - if (handle->currentFrame.pixels) MSF_GIF_FREE(handle->customAllocatorContext, handle->currentFrame.pixels, - handle->width * handle->height * sizeof(uint32_t)); - if (handle->lzwMem) MSF_GIF_FREE(handle->customAllocatorContext, handle->lzwMem, lzwAllocSize); - if (handle->tlbMem) MSF_GIF_FREE(handle->customAllocatorContext, handle->tlbMem, tlbAllocSize); - if (handle->usedMem) MSF_GIF_FREE(handle->customAllocatorContext, handle->usedMem, usedAllocSize); - for (MsfGifBuffer * node = handle->listHead; node;) { - MsfGifBuffer * next = node->next; //NOTE: we have to copy the `next` pointer BEFORE freeing the node holding it - MSF_GIF_FREE(handle->customAllocatorContext, node, offsetof(MsfGifBuffer, data) + node->size); - node = next; - } - handle->listHead = NULL; //this implicitly marks the handle as invalid until the next msf_gif_begin() call -} - -int msf_gif_begin(MsfGifState * handle, int width, int height) { MsfTimeFunc - //To help avoid potential overflow errors, let's just not even try to support images larger than 1GB in size. - //And let's also reject images with width or height more or less than what the gif format itself supports. - const int MAX_PIXELS = 268435456; //2^30 / 4 = 1GB / bytesPerPixel - if (width < 1 || height < 1 || width > 65535 || height > 65535 || width >= MAX_PIXELS / height) { - handle->listHead = NULL; //this implicitly marks the handle as invalid until the next msf_gif_begin() call - return 0; - } - - //NOTE: we cannot stomp the entire struct to zero because we must preserve `customAllocatorContext`. - MsfCookedFrame empty = {0}; //god I hate MSVC... - handle->previousFrame = empty; - handle->currentFrame = empty; - handle->width = width; - handle->height = height; - handle->framesSubmitted = 0; - - //NOTE: Default stack sizes for some platforms are very small. Emscripten in particular uses a 64k stack by default. - // So anything that large or larger must be allocated on the heap, even if its maximum size is compile-time known. - // The `lzw`, `tlb`, and `used` arrays are 2MB, 64KB, and 64KB respectively, so we allocate them here. - // We could make them arrays at global scope, but that would create problems if the library is used from multiple threads. - handle->lzwMem = (int16_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, lzwAllocSize); - handle->tlbMem = (uint8_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, tlbAllocSize); - handle->usedMem = (uint8_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, usedAllocSize); - handle->previousFrame.pixels = - (uint32_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, handle->width * handle->height * sizeof(uint32_t)); - handle->currentFrame.pixels = - (uint32_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, handle->width * handle->height * sizeof(uint32_t)); - - //setup header buffer header (lol) - handle->listHead = (MsfGifBuffer *) MSF_GIF_MALLOC(handle->customAllocatorContext, offsetof(MsfGifBuffer, data) + 32); - if (!handle->listHead || !handle->lzwMem || !handle->previousFrame.pixels || !handle->currentFrame.pixels) { - msf_free_gif_state(handle); - return 0; - } - handle->listTail = handle->listHead; - handle->listHead->next = NULL; - handle->listHead->size = 32; - - //NOTE: because __attribute__((__packed__)) is annoyingly compiler-specific, we do this unreadable weirdness - char headerBytes[33] = "GIF89a\0\0\0\0\x70\0\0" "\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0\0"; - memcpy(&headerBytes[6], &width, 2); - memcpy(&headerBytes[8], &height, 2); - memcpy(handle->listHead->data, headerBytes, 32); - return 1; -} - -int msf_gif_frame(MsfGifState * handle, uint8_t * pixelData, int centiSecondsPerFrame, int quality, int pitchInBytes) -{ MsfTimeFunc - if (!handle->listHead) { return 0; } - //TODO: sanity-check `pitchInBytes` - - quality = msf_imax(1, msf_imin(16, quality)); - if (pitchInBytes == 0) pitchInBytes = handle->width * 4; - if (pitchInBytes < 0) pixelData -= pitchInBytes * (handle->height - 1); - - msf_cook_frame(&handle->currentFrame, pixelData, handle->usedMem, handle->width, handle->height, pitchInBytes, - msf_imin(quality, handle->previousFrame.depth + 160 / msf_imax(1, handle->previousFrame.count))); - - MsfGifBuffer * buffer = msf_compress_frame(handle->customAllocatorContext, handle->width, handle->height, - centiSecondsPerFrame, handle->currentFrame, handle, handle->usedMem, handle->tlbMem, handle->lzwMem); - if (!buffer) { msf_free_gif_state(handle); return 0; } - handle->listTail->next = buffer; - handle->listTail = buffer; - - //swap current and previous frames - MsfCookedFrame tmp = handle->previousFrame; - handle->previousFrame = handle->currentFrame; - handle->currentFrame = tmp; - - handle->framesSubmitted += 1; - return 1; -} - -MsfGifResult msf_gif_end(MsfGifState * handle) { MsfTimeFunc - if (!handle->listHead) { MsfGifResult empty = {0}; return empty; } - - //first pass: determine total size - size_t total = 1; //1 byte for trailing marker - for (MsfGifBuffer * node = handle->listHead; node; node = node->next) { total += node->size; } - - //second pass: write data - uint8_t * buffer = (uint8_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, total); - if (buffer) { - uint8_t * writeHead = buffer; - for (MsfGifBuffer * node = handle->listHead; node; node = node->next) { - memcpy(writeHead, node->data, node->size); - writeHead += node->size; - } - *writeHead++ = 0x3B; - } - - //third pass: free buffers - msf_free_gif_state(handle); - - MsfGifResult ret = { buffer, total, total, handle->customAllocatorContext }; - return ret; -} - -void msf_gif_free(MsfGifResult result) { MsfTimeFunc - if (result.data) { MSF_GIF_FREE(result.contextPointer, result.data, result.allocSize); } -} - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -/// To-file API /// -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -int msf_gif_begin_to_file(MsfGifState * handle, int width, int height, MsfGifFileWriteFunc func, void * filePointer) { - handle->fileWriteFunc = func; - handle->fileWriteData = filePointer; - return msf_gif_begin(handle, width, height); -} - -int msf_gif_frame_to_file(MsfGifState * handle, uint8_t * pixelData, int centiSecondsPerFrame, int quality, int pitchInBytes) { - if (!msf_gif_frame(handle, pixelData, centiSecondsPerFrame, quality, pitchInBytes)) { return 0; } - - //NOTE: this is a somewhat hacky implementation which is not perfectly efficient, but it's good enough for now - MsfGifBuffer * head = handle->listHead; - if (!handle->fileWriteFunc(head->data, head->size, 1, handle->fileWriteData)) { msf_free_gif_state(handle); return 0; } - handle->listHead = head->next; - MSF_GIF_FREE(handle->customAllocatorContext, head, offsetof(MsfGifBuffer, data) + head->size); - return 1; -} - -int msf_gif_end_to_file(MsfGifState * handle) { - //NOTE: this is a somewhat hacky implementation which is not perfectly efficient, but it's good enough for now - MsfGifResult result = msf_gif_end(handle); - int ret = (int) handle->fileWriteFunc(result.data, result.dataSize, 1, handle->fileWriteData); - msf_gif_free(result); - return ret; -} - -#endif //MSF_GIF_ALREADY_IMPLEMENTED_IN_THIS_TRANSLATION_UNIT -#endif //MSF_GIF_IMPL - -/* ------------------------------------------------------------------------------- -This software is available under 2 licenses -- choose whichever you prefer. ------------------------------------------------------------------------------- -ALTERNATIVE A - MIT License -Copyright (c) 2025 Miles Fogle -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. ------------------------------------------------------------------------------- -ALTERNATIVE B - Public Domain (www.unlicense.org) -This is free and unencumbered software released into the public domain. -Anyone is free to copy, modify, publish, use, compile, sell, or distribute this -software, either in source code form or as a compiled binary, for any purpose, -commercial or non-commercial, and by any means. -In jurisdictions that recognize copyright laws, the author or authors of this -software dedicate any and all copyright interest in the software to the public -domain. We make this dedication for the benefit of the public at large and to -the detriment of our heirs and successors. We intend this dedication to be an -overt act of relinquishment in perpetuity of all present and future rights to -this software under copyright law. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------- -*/ diff --git a/src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig b/src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig deleted file mode 100644 index 999a1b6a..00000000 --- a/src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig +++ /dev/null @@ -1,87 +0,0 @@ -const std = @import("std"); - -pub extern var msf_gif_alpha_threshold: u32; - -pub const MSFGifResult = extern struct { - data: ?[*]u8, - dataSize: usize, - allocSize: usize, - contextPointer: ?*anyopaque, -}; - -pub const MSFGifCookedFrame = extern struct { - pixels: ?[*]u32, - depth: c_int, - count: c_int, - rbits: c_int, - gbits: c_int, - bbits: c_int, -}; - -pub const MSFGifBuffer = extern struct { - next: ?*MSFGifBuffer, - size: usize, - data: [1]u8, // flexible array member in C -}; - -pub const MSFGifState = extern struct { - fileWriteFunc: ?*const fn (?*const anyopaque, usize, usize, ?*anyopaque) callconv(.c) usize, - fileWriteData: ?*anyopaque, - previousFrame: MSFGifCookedFrame, - currentFrame: MSFGifCookedFrame, - lzwMem: ?*anyopaque, - tlbMem: ?*anyopaque, - usedMem: ?*anyopaque, - listHead: ?*MSFGifBuffer, - listTail: ?*MSFGifBuffer, - width: c_int, - height: c_int, - customAllocatorContext: ?*anyopaque, - framesSubmitted: c_int, -}; - -pub extern fn msf_gif_begin( - handle: *MSFGifState, - width: c_int, - height: c_int, -) c_int; - -pub extern fn msf_gif_frame( - handle: *MSFGifState, - pixel_data: [*]u8, - centi_seconds_per_frame: c_int, - quality: c_int, - pitch_in_bytes: c_int, -) c_int; - -pub extern fn msf_gif_end( - handle: *MSFGifState, -) MSFGifResult; - -pub extern fn msf_gif_free( - result: MSFGifResult, -) void; - -// Helper Zig wrappers - -pub fn begin(handle: *MSFGifState, width: u32, height: u32) c_int { - return msf_gif_begin(handle, @intCast(width), @intCast(height)); -} - -pub fn frame( - handle: *MSFGifState, - pixel_data: [*]u8, - centi_seconds_per_frame: i32, - //quality: i32, // 16 is recommended, can be lowered for faster exports but may look worse - //pitch_in_bytes: i32, // 0 means contiguous rows, negative means reversed rows -) c_int { - return msf_gif_frame(handle, pixel_data, @intCast(centi_seconds_per_frame), 16, 0); -} - -pub fn end(handle: *MSFGifState) MSFGifResult { - return msf_gif_end(handle); -} - -pub fn free(result: MSFGifResult) void { - msf_gif_free(result); -} diff --git a/src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h b/src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h deleted file mode 100644 index b3a6e162..00000000 --- a/src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h +++ /dev/null @@ -1,7 +0,0 @@ -// Minimal for msf_gif on wasm32-freestanding. -#pragma once - -#include - -void *memcpy(void *restrict dest, const void *restrict src, size_t n); -void *memset(void *s, int c, size_t n); diff --git a/src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c b/src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c deleted file mode 100644 index 3b365dfb..00000000 --- a/src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c +++ /dev/null @@ -1,39 +0,0 @@ -// Heap + sort shims so `zstbi.c` (stb_rect_pack + stb_image_resize2) can compile -// on wasm32-freestanding, where `` is not available. Routes C -// allocations to DVUI's exported allocator (same source the zip + stb_image -// shims use). The qsort shim is a simple insertion sort — stb_rect_pack uses it -// once per atlas to sort rects by height, and even for thousand-rect atlases -// O(n²) is negligible compared to the actual pack work. - -#include - -extern void *dvui_c_alloc(size_t size); -extern void dvui_c_free(void *ptr); - -void *fizzy_stbi_malloc(size_t size) { - return dvui_c_alloc(size); -} - -void fizzy_stbi_free(void *ptr) { - dvui_c_free(ptr); -} - -typedef int (*fizzy_stbi_cmp)(const void *, const void *); - -static void fizzy_stbi_swap_bytes(unsigned char *a, unsigned char *b, size_t n) { - while (n--) { - unsigned char t = *a; - *a++ = *b; - *b++ = t; - } -} - -void fizzy_stbi_qsort(void *base, size_t nmemb, size_t size, fizzy_stbi_cmp cmp) { - if (nmemb < 2 || size == 0) return; - unsigned char *arr = (unsigned char *)base; - for (size_t i = 1; i < nmemb; ++i) { - for (size_t j = i; j > 0 && cmp(arr + (j - 1) * size, arr + j * size) > 0; --j) { - fizzy_stbi_swap_bytes(arr + (j - 1) * size, arr + j * size, size); - } - } -} diff --git a/src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h b/src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h deleted file mode 100644 index 6146ab7e..00000000 --- a/src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h +++ /dev/null @@ -1,10651 +0,0 @@ -/* stb_image_resize2 - v2.17 - public domain image resizing - - by Jeff Roberts (v2) and Jorge L Rodriguez - http://github.com/nothings/stb - - Can be threaded with the extended API. SSE2, AVX, Neon and WASM SIMD support. Only - scaling and translation is supported, no rotations or shears. - - COMPILING & LINKING - In one C/C++ file that #includes this file, do this: - #define STB_IMAGE_RESIZE_IMPLEMENTATION - before the #include. That will create the implementation in that file. - - EASY API CALLS: - Easy API downsamples w/Mitchell filter, upsamples w/cubic interpolation, clamps to edge. - - stbir_resize_uint8_srgb( input_pixels, input_w, input_h, input_stride_in_bytes, - output_pixels, output_w, output_h, output_stride_in_bytes, - pixel_layout_enum ) - - stbir_resize_uint8_linear( input_pixels, input_w, input_h, input_stride_in_bytes, - output_pixels, output_w, output_h, output_stride_in_bytes, - pixel_layout_enum ) - - stbir_resize_float_linear( input_pixels, input_w, input_h, input_stride_in_bytes, - output_pixels, output_w, output_h, output_stride_in_bytes, - pixel_layout_enum ) - - If you pass NULL or zero for the output_pixels, we will allocate the output buffer - for you and return it from the function (free with free() or STBIR_FREE). - As a special case, XX_stride_in_bytes of 0 means packed continuously in memory. - - API LEVELS - There are three levels of API - easy-to-use, medium-complexity and extended-complexity. - - See the "header file" section of the source for API documentation. - - ADDITIONAL DOCUMENTATION - - MEMORY ALLOCATION - By default, we use malloc and free for memory allocation. To override the - memory allocation, before the implementation #include, add a: - - #define STBIR_MALLOC(size,user_data) ... - #define STBIR_FREE(ptr,user_data) ... - - Each resize makes exactly one call to malloc/free (unless you use the - extended API where you can do one allocation for many resizes). Under - address sanitizer, we do separate allocations to find overread/writes. - - PERFORMANCE - This library was written with an emphasis on performance. When testing - stb_image_resize with RGBA, the fastest mode is STBIR_4CHANNEL with - STBIR_TYPE_UINT8 pixels and CLAMPed edges (which is what many other resize - libs do by default). Also, make sure SIMD is turned on of course (default - for 64-bit targets). Avoid WRAP edge mode if you want the fastest speed. - - This library also comes with profiling built-in. If you define STBIR_PROFILE, - you can use the advanced API and get low-level profiling information by - calling stbir_resize_extended_profile_info() or stbir_resize_split_profile_info() - after a resize. - - SIMD - Most of the routines have optimized SSE2, AVX, NEON and WASM versions. - - On Microsoft compilers, we automatically turn on SIMD for 64-bit x64 and - ARM; for 32-bit x86 and ARM, you select SIMD mode by defining STBIR_SSE2 or - STBIR_NEON. For AVX and AVX2, we auto-select it by detecting the /arch:AVX - or /arch:AVX2 switches. You can also always manually turn SSE2, AVX or AVX2 - support on by defining STBIR_SSE2, STBIR_AVX or STBIR_AVX2. - - On Linux, SSE2 and Neon is on by default for 64-bit x64 or ARM64. For 32-bit, - we select x86 SIMD mode by whether you have -msse2, -mavx or -mavx2 enabled - on the command line. For 32-bit ARM, you must pass -mfpu=neon-vfpv4 for both - clang and GCC, but GCC also requires an additional -mfp16-format=ieee to - automatically enable NEON. - - On x86 platforms, you can also define STBIR_FP16C to turn on FP16C instructions - for converting back and forth to half-floats. This is autoselected when we - are using AVX2. Clang and GCC also require the -mf16c switch. ARM always uses - the built-in half float hardware NEON instructions. - - You can also tell us to use multiply-add instructions with STBIR_USE_FMA. - Because x86 doesn't always have fma, we turn it off by default to maintain - determinism across all platforms. If you don't care about non-FMA determinism - and are willing to restrict yourself to more recent x86 CPUs (around the AVX - timeframe), then fma will give you around a 15% speedup. - - You can force off SIMD in all cases by defining STBIR_NO_SIMD. You can turn - off AVX or AVX2 specifically with STBIR_NO_AVX or STBIR_NO_AVX2. AVX is 10% - to 40% faster, and AVX2 is generally another 12%. - - ALPHA CHANNEL - Most of the resizing functions provide the ability to control how the alpha - channel of an image is processed. - - When alpha represents transparency, it is important that when combining - colors with filtering, the pixels should not be treated equally; they - should use a weighted average based on their alpha values. For example, - if a pixel is 1% opaque bright green and another pixel is 99% opaque - black and you average them, the average will be 50% opaque, but the - unweighted average and will be a middling green color, while the weighted - average will be nearly black. This means the unweighted version introduced - green energy that didn't exist in the source image. - - (If you want to know why this makes sense, you can work out the math for - the following: consider what happens if you alpha composite a source image - over a fixed color and then average the output, vs. if you average the - source image pixels and then composite that over the same fixed color. - Only the weighted average produces the same result as the ground truth - composite-then-average result.) - - Therefore, it is in general best to "alpha weight" the pixels when applying - filters to them. This essentially means multiplying the colors by the alpha - values before combining them, and then dividing by the alpha value at the - end. - - The computer graphics industry introduced a technique called "premultiplied - alpha" or "associated alpha" in which image colors are stored in image files - already multiplied by their alpha. This saves some math when compositing, - and also avoids the need to divide by the alpha at the end (which is quite - inefficient). However, while premultiplied alpha is common in the movie CGI - industry, it is not commonplace in other industries like videogames, and most - consumer file formats are generally expected to contain not-premultiplied - colors. For example, Photoshop saves PNG files "unpremultiplied", and web - browsers like Chrome and Firefox expect PNG images to be unpremultiplied. - - Note that there are three possibilities that might describe your image - and resize expectation: - - 1. images are not premultiplied, alpha weighting is desired - 2. images are not premultiplied, alpha weighting is not desired - 3. images are premultiplied - - Both case #2 and case #3 require the exact same math: no alpha weighting - should be applied or removed. Only case 1 requires extra math operations; - the other two cases can be handled identically. - - stb_image_resize expects case #1 by default, applying alpha weighting to - images, expecting the input images to be unpremultiplied. This is what the - COLOR+ALPHA buffer types tell the resizer to do. - - When you use the pixel layouts STBIR_RGBA, STBIR_BGRA, STBIR_ARGB, - STBIR_ABGR, STBIR_RX, or STBIR_XR you are telling us that the pixels are - non-premultiplied. In these cases, the resizer will alpha weight the colors - (effectively creating the premultiplied image), do the filtering, and then - convert back to non-premult on exit. - - When you use the pixel layouts STBIR_RGBA_PM, STBIR_RGBA_PM, STBIR_RGBA_PM, - STBIR_RGBA_PM, STBIR_RX_PM or STBIR_XR_PM, you are telling that the pixels - ARE premultiplied. In this case, the resizer doesn't have to do the - premultipling - it can filter directly on the input. This about twice as - fast as the non-premultiplied case, so it's the right option if your data is - already setup correctly. - - When you use the pixel layout STBIR_4CHANNEL or STBIR_2CHANNEL, you are - telling us that there is no channel that represents transparency; it may be - RGB and some unrelated fourth channel that has been stored in the alpha - channel, but it is actually not alpha. No special processing will be - performed. - - The difference between the generic 4 or 2 channel layouts, and the - specialized _PM versions is with the _PM versions you are telling us that - the data *is* alpha, just don't premultiply it. That's important when - using SRGB pixel formats, we need to know where the alpha is, because - it is converted linearly (rather than with the SRGB converters). - - Because alpha weighting produces the same effect as premultiplying, you - even have the option with non-premultiplied inputs to let the resizer - produce a premultiplied output. Because the intially computed alpha-weighted - output image is effectively premultiplied, this is actually more performant - than the normal path which un-premultiplies the output image as a final step. - - Finally, when converting both in and out of non-premulitplied space (for - example, when using STBIR_RGBA), we go to somewhat heroic measures to - ensure that areas with zero alpha value pixels get something reasonable - in the RGB values. If you don't care about the RGB values of zero alpha - pixels, you can call the stbir_set_non_pm_alpha_speed_over_quality() - function - this runs a premultiplied resize about 25% faster. That said, - when you really care about speed, using premultiplied pixels for both in - and out (STBIR_RGBA_PM, etc) much faster than both of these premultiplied - options. - - PIXEL LAYOUT CONVERSION - The resizer can convert from some pixel layouts to others. When using the - stbir_set_pixel_layouts(), you can, for example, specify STBIR_RGBA - on input, and STBIR_ARGB on output, and it will re-organize the channels - during the resize. Currently, you can only convert between two pixel - layouts with the same number of channels. - - DETERMINISM - We commit to being deterministic (from x64 to ARM to scalar to SIMD, etc). - This requires compiling with fast-math off (using at least /fp:precise). - Also, you must turn off fp-contracting (which turns mult+adds into fmas)! - We attempt to do this with pragmas, but with Clang, you usually want to add - -ffp-contract=off to the command line as well. - - For 32-bit x86, you must use SSE and SSE2 codegen for determinism. That is, - if the scalar x87 unit gets used at all, we immediately lose determinism. - On Microsoft Visual Studio 2008 and earlier, from what we can tell there is - no way to be deterministic in 32-bit x86 (some x87 always leaks in, even - with fp:strict). On 32-bit x86 GCC, determinism requires both -msse2 and - -fpmath=sse. - - Note that we will not be deterministic with float data containing NaNs - - the NaNs will propagate differently on different SIMD and platforms. - - If you turn on STBIR_USE_FMA, then we will be deterministic with other - fma targets, but we will differ from non-fma targets (this is unavoidable, - because a fma isn't simply an add with a mult - it also introduces a - rounding difference compared to non-fma instruction sequences. - - FLOAT PIXEL FORMAT RANGE - Any range of values can be used for the non-alpha float data that you pass - in (0 to 1, -1 to 1, whatever). However, if you are inputting float values - but *outputting* bytes or shorts, you must use a range of 0 to 1 so that we - scale back properly. The alpha channel must also be 0 to 1 for any format - that does premultiplication prior to resizing. - - Note also that with float output, using filters with negative lobes, the - output filtered values might go slightly out of range. You can define - STBIR_FLOAT_LOW_CLAMP and/or STBIR_FLOAT_HIGH_CLAMP to specify the range - to clamp to on output, if that's important. - - MAX/MIN SCALE FACTORS - The input pixel resolutions are in integers, and we do the internal pointer - resolution in size_t sized integers. However, the scale ratio from input - resolution to output resolution is calculated in float form. This means - the effective possible scale ratio is limited to 24 bits (or 16 million - to 1). As you get close to the size of the float resolution (again, 16 - million pixels wide or high), you might start seeing float inaccuracy - issues in general in the pipeline. If you have to do extreme resizes, - you can usually do this is multiple stages (using float intermediate - buffers). - - FLIPPED IMAGES - Stride is just the delta from one scanline to the next. This means you can - use a negative stride to handle inverted images (point to the final - scanline and use a negative stride). You can invert the input or output, - using negative strides. - - DEFAULT FILTERS - For functions which don't provide explicit control over what filters to - use, you can change the compile-time defaults with: - - #define STBIR_DEFAULT_FILTER_UPSAMPLE STBIR_FILTER_something - #define STBIR_DEFAULT_FILTER_DOWNSAMPLE STBIR_FILTER_something - - See stbir_filter in the header-file section for the list of filters. - - NEW FILTERS - A number of 1D filter kernels are supplied. For a list of supported - filters, see the stbir_filter enum. You can install your own filters by - using the stbir_set_filter_callbacks function. - - PROGRESS - For interactive use with slow resize operations, you can use the - scanline callbacks in the extended API. It would have to be a *very* large - image resample to need progress though - we're very fast. - - CEIL and FLOOR - In scalar mode, the only functions we use from math.h are ceilf and floorf, - but if you have your own versions, you can define the STBIR_CEILF(v) and - STBIR_FLOORF(v) macros and we'll use them instead. In SIMD, we just use - our own versions. - - ASSERT - Define STBIR_ASSERT(boolval) to override assert() and not use assert.h - - PORTING FROM VERSION 1 - The API has changed. You can continue to use the old version of stb_image_resize.h, - which is available in the "deprecated/" directory. - - If you're using the old simple-to-use API, porting is straightforward. - (For more advanced APIs, read the documentation.) - - stbir_resize_uint8(): - - call `stbir_resize_uint8_linear`, cast channel count to `stbir_pixel_layout` - - stbir_resize_float(): - - call `stbir_resize_float_linear`, cast channel count to `stbir_pixel_layout` - - stbir_resize_uint8_srgb(): - - function name is unchanged - - cast channel count to `stbir_pixel_layout` - - above is sufficient unless your image has alpha and it's not RGBA/BGRA - - in that case, follow the below instructions for stbir_resize_uint8_srgb_edgemode - - stbir_resize_uint8_srgb_edgemode() - - switch to the "medium complexity" API - - stbir_resize(), very similar API but a few more parameters: - - pixel_layout: cast channel count to `stbir_pixel_layout` - - data_type: STBIR_TYPE_UINT8_SRGB - - edge: unchanged (STBIR_EDGE_WRAP, etc.) - - filter: STBIR_FILTER_DEFAULT - - which channel is alpha is specified in stbir_pixel_layout, see enum for details - - FUTURE TODOS - * For polyphase integral filters, we just memcpy the coeffs to dupe - them, but we should indirect and use the same coeff memory. - * Add pixel layout conversions for sensible different channel counts - (maybe, 1->3/4, 3->4, 4->1, 3->1). - * For SIMD encode and decode scanline routines, do any pre-aligning - for bad input/output buffer alignments and pitch? - * For very wide scanlines, we should we do vertical strips to stay within - L2 cache. Maybe do chunks of 1K pixels at a time. There would be - some pixel reconversion, but probably dwarfed by things falling out - of cache. Probably also something possible with alternating between - scattering and gathering at high resize scales? - * Should we have a multiple MIPs at the same time function (could keep - more memory in cache during multiple resizes)? - * Rewrite the coefficient generator to do many at once. - * AVX-512 vertical kernels - worried about downclocking here. - * Convert the reincludes to macros when we know they aren't changing. - * Experiment with pivoting the horizontal and always using the - vertical filters (which are faster, but perhaps not enough to overcome - the pivot cost and the extra memory touches). Need to buffer the whole - image so have to balance memory use. - * Most of our code is internally function pointers, should we compile - all the SIMD stuff always and dynamically dispatch? - - CONTRIBUTORS - Jeff Roberts: 2.0 implementation, optimizations, SIMD - Martins Mozeiko: NEON simd, WASM simd, clang and GCC whisperer - Fabian Giesen: half float and srgb converters - Sean Barrett: API design, optimizations - Jorge L Rodriguez: Original 1.0 implementation - Aras Pranckevicius: bugfixes - Nathan Reed: warning fixes for 1.0 - - REVISIONS - 2.17 (2025-10-25) silly format bug in easy-to-use APIs. - 2.16 (2025-10-21) fixed the easy-to-use APIs to allow inverted bitmaps (negative - strides), fix vertical filter kernel callback, fix threaded - gather buffer priming (and assert). - (thanks adipose, TainZerL, and Harrison Green) - 2.15 (2025-07-17) fixed an assert in debug mode when using floats with input - callbacks, work around GCC warning when adding to null ptr - (thanks Johannes Spohr and Pyry Kovanen). - 2.14 (2025-05-09) fixed a bug using downsampling gather horizontal first, and - scatter with vertical first. - 2.13 (2025-02-27) fixed a bug when using input callbacks, turned off simd for - tiny-c, fixed some variables that should have been static, - fixes a bug when calculating temp memory with resizes that - exceed 2GB of temp memory (very large resizes). - 2.12 (2024-10-18) fix incorrect use of user_data with STBIR_FREE - 2.11 (2024-09-08) fix harmless asan warnings in 2-channel and 3-channel mode - with AVX-2, fix some weird scaling edge conditions with - point sample mode. - 2.10 (2024-07-27) fix the defines GCC and mingw for loop unroll control, - fix MSVC 32-bit arm half float routines. - 2.09 (2024-06-19) fix the defines for 32-bit ARM GCC builds (was selecting - hardware half floats). - 2.08 (2024-06-10) fix for RGB->BGR three channel flips and add SIMD (thanks - to Ryan Salsbury), fix for sub-rect resizes, use the - pragmas to control unrolling when they are available. - 2.07 (2024-05-24) fix for slow final split during threaded conversions of very - wide scanlines when downsampling (caused by extra input - converting), fix for wide scanline resamples with many - splits (int overflow), fix GCC warning. - 2.06 (2024-02-10) fix for identical width/height 3x or more down-scaling - undersampling a single row on rare resize ratios (about 1%). - 2.05 (2024-02-07) fix for 2 pixel to 1 pixel resizes with wrap (thanks Aras), - fix for output callback (thanks Julien Koenen). - 2.04 (2023-11-17) fix for rare AVX bug, shadowed symbol (thanks Nikola Smiljanic). - 2.03 (2023-11-01) ASAN and TSAN warnings fixed, minor tweaks. - 2.00 (2023-10-10) mostly new source: new api, optimizations, simd, vertical-first, etc - 2x-5x faster without simd, 4x-12x faster with simd, - in some cases, 20x to 40x faster esp resizing large to very small. - 0.96 (2019-03-04) fixed warnings - 0.95 (2017-07-23) fixed warnings - 0.94 (2017-03-18) fixed warnings - 0.93 (2017-03-03) fixed bug with certain combinations of heights - 0.92 (2017-01-02) fix integer overflow on large (>2GB) images - 0.91 (2016-04-02) fix warnings; fix handling of subpixel regions - 0.90 (2014-09-17) first released version - - LICENSE - See end of file for license information. -*/ - -#if !defined(STB_IMAGE_RESIZE_DO_HORIZONTALS) && !defined(STB_IMAGE_RESIZE_DO_VERTICALS) && !defined(STB_IMAGE_RESIZE_DO_CODERS) // for internal re-includes - -#ifndef STBIR_INCLUDE_STB_IMAGE_RESIZE2_H -#define STBIR_INCLUDE_STB_IMAGE_RESIZE2_H - -#include -#ifdef _MSC_VER -typedef unsigned char stbir_uint8; -typedef unsigned short stbir_uint16; -typedef unsigned int stbir_uint32; -typedef unsigned __int64 stbir_uint64; -#else -#include -typedef uint8_t stbir_uint8; -typedef uint16_t stbir_uint16; -typedef uint32_t stbir_uint32; -typedef uint64_t stbir_uint64; -#endif - -#ifndef STBIRDEF -#ifdef STB_IMAGE_RESIZE_STATIC -#define STBIRDEF static -#else -#ifdef __cplusplus -#define STBIRDEF extern "C" -#else -#define STBIRDEF extern -#endif -#endif -#endif - -////////////////////////////////////////////////////////////////////////////// -//// start "header file" /////////////////////////////////////////////////// -// -// Easy-to-use API: -// -// * stride is the offset between successive rows of image data -// in memory, in bytes. specify 0 for packed continuously in memory -// * colorspace is linear or sRGB as specified by function name -// * Uses the default filters -// * Uses edge mode clamped -// * returned result is 1 for success or 0 in case of an error. - - -// stbir_pixel_layout specifies: -// number of channels -// order of channels -// whether color is premultiplied by alpha -// for back compatibility, you can cast the old channel count to an stbir_pixel_layout -typedef enum -{ - STBIR_1CHANNEL = 1, - STBIR_2CHANNEL = 2, - STBIR_RGB = 3, // 3-chan, with order specified (for channel flipping) - STBIR_BGR = 0, // 3-chan, with order specified (for channel flipping) - STBIR_4CHANNEL = 5, - - STBIR_RGBA = 4, // alpha formats, where alpha is NOT premultiplied into color channels - STBIR_BGRA = 6, - STBIR_ARGB = 7, - STBIR_ABGR = 8, - STBIR_RA = 9, - STBIR_AR = 10, - - STBIR_RGBA_PM = 11, // alpha formats, where alpha is premultiplied into color channels - STBIR_BGRA_PM = 12, - STBIR_ARGB_PM = 13, - STBIR_ABGR_PM = 14, - STBIR_RA_PM = 15, - STBIR_AR_PM = 16, - - STBIR_RGBA_NO_AW = 11, // alpha formats, where NO alpha weighting is applied at all! - STBIR_BGRA_NO_AW = 12, // these are just synonyms for the _PM flags (which also do - STBIR_ARGB_NO_AW = 13, // no alpha weighting). These names just make it more clear - STBIR_ABGR_NO_AW = 14, // for some folks). - STBIR_RA_NO_AW = 15, - STBIR_AR_NO_AW = 16, - -} stbir_pixel_layout; - -//=============================================================== -// Simple-complexity API -// -// If output_pixels is NULL (0), then we will allocate the buffer and return it to you. -//-------------------------------- - -STBIRDEF unsigned char * stbir_resize_uint8_srgb( const unsigned char *input_pixels , int input_w , int input_h, int input_stride_in_bytes, - unsigned char *output_pixels, int output_w, int output_h, int output_stride_in_bytes, - stbir_pixel_layout pixel_type ); - -STBIRDEF unsigned char * stbir_resize_uint8_linear( const unsigned char *input_pixels , int input_w , int input_h, int input_stride_in_bytes, - unsigned char *output_pixels, int output_w, int output_h, int output_stride_in_bytes, - stbir_pixel_layout pixel_type ); - -STBIRDEF float * stbir_resize_float_linear( const float *input_pixels , int input_w , int input_h, int input_stride_in_bytes, - float *output_pixels, int output_w, int output_h, int output_stride_in_bytes, - stbir_pixel_layout pixel_type ); -//=============================================================== - -//=============================================================== -// Medium-complexity API -// -// This extends the easy-to-use API as follows: -// -// * Can specify the datatype - U8, U8_SRGB, U16, FLOAT, HALF_FLOAT -// * Edge wrap can selected explicitly -// * Filter can be selected explicitly -//-------------------------------- - -typedef enum -{ - STBIR_EDGE_CLAMP = 0, - STBIR_EDGE_REFLECT = 1, - STBIR_EDGE_WRAP = 2, // this edge mode is slower and uses more memory - STBIR_EDGE_ZERO = 3, -} stbir_edge; - -typedef enum -{ - STBIR_FILTER_DEFAULT = 0, // use same filter type that easy-to-use API chooses - STBIR_FILTER_BOX = 1, // A trapezoid w/1-pixel wide ramps, same result as box for integer scale ratios - STBIR_FILTER_TRIANGLE = 2, // On upsampling, produces same results as bilinear texture filtering - STBIR_FILTER_CUBICBSPLINE = 3, // The cubic b-spline (aka Mitchell-Netrevalli with B=1,C=0), gaussian-esque - STBIR_FILTER_CATMULLROM = 4, // An interpolating cubic spline - STBIR_FILTER_MITCHELL = 5, // Mitchell-Netrevalli filter with B=1/3, C=1/3 - STBIR_FILTER_POINT_SAMPLE = 6, // Simple point sampling - STBIR_FILTER_OTHER = 7, // User callback specified -} stbir_filter; - -typedef enum -{ - STBIR_TYPE_UINT8 = 0, - STBIR_TYPE_UINT8_SRGB = 1, - STBIR_TYPE_UINT8_SRGB_ALPHA = 2, // alpha channel, when present, should also be SRGB (this is very unusual) - STBIR_TYPE_UINT16 = 3, - STBIR_TYPE_FLOAT = 4, - STBIR_TYPE_HALF_FLOAT = 5 -} stbir_datatype; - -// medium api -STBIRDEF void * stbir_resize( const void *input_pixels , int input_w , int input_h, int input_stride_in_bytes, - void *output_pixels, int output_w, int output_h, int output_stride_in_bytes, - stbir_pixel_layout pixel_layout, stbir_datatype data_type, - stbir_edge edge, stbir_filter filter ); -//=============================================================== - - - -//=============================================================== -// Extended-complexity API -// -// This API exposes all resize functionality. -// -// * Separate filter types for each axis -// * Separate edge modes for each axis -// * Separate input and output data types -// * Can specify regions with subpixel correctness -// * Can specify alpha flags -// * Can specify a memory callback -// * Can specify a callback data type for pixel input and output -// * Can be threaded for a single resize -// * Can be used to resize many frames without recalculating the sampler info -// -// Use this API as follows: -// 1) Call the stbir_resize_init function on a local STBIR_RESIZE structure -// 2) Call any of the stbir_set functions -// 3) Optionally call stbir_build_samplers() if you are going to resample multiple times -// with the same input and output dimensions (like resizing video frames) -// 4) Resample by calling stbir_resize_extended(). -// 5) Call stbir_free_samplers() if you called stbir_build_samplers() -//-------------------------------- - - -// Types: - -// INPUT CALLBACK: this callback is used for input scanlines -typedef void const * stbir_input_callback( void * optional_output, void const * input_ptr, int num_pixels, int x, int y, void * context ); - -// OUTPUT CALLBACK: this callback is used for output scanlines -typedef void stbir_output_callback( void const * output_ptr, int num_pixels, int y, void * context ); - -// callbacks for user installed filters -typedef float stbir__kernel_callback( float x, float scale, void * user_data ); // centered at zero -typedef float stbir__support_callback( float scale, void * user_data ); - -// internal structure with precomputed scaling -typedef struct stbir__info stbir__info; - -typedef struct STBIR_RESIZE // use the stbir_resize_init and stbir_override functions to set these values for future compatibility -{ - void * user_data; - void const * input_pixels; - int input_w, input_h; - double input_s0, input_t0, input_s1, input_t1; - stbir_input_callback * input_cb; - void * output_pixels; - int output_w, output_h; - int output_subx, output_suby, output_subw, output_subh; - stbir_output_callback * output_cb; - int input_stride_in_bytes; - int output_stride_in_bytes; - int splits; - int fast_alpha; - int needs_rebuild; - int called_alloc; - stbir_pixel_layout input_pixel_layout_public; - stbir_pixel_layout output_pixel_layout_public; - stbir_datatype input_data_type; - stbir_datatype output_data_type; - stbir_filter horizontal_filter, vertical_filter; - stbir_edge horizontal_edge, vertical_edge; - stbir__kernel_callback * horizontal_filter_kernel; stbir__support_callback * horizontal_filter_support; - stbir__kernel_callback * vertical_filter_kernel; stbir__support_callback * vertical_filter_support; - stbir__info * samplers; -} STBIR_RESIZE; - -// extended complexity api - - -// First off, you must ALWAYS call stbir_resize_init on your resize structure before any of the other calls! -STBIRDEF void stbir_resize_init( STBIR_RESIZE * resize, - const void *input_pixels, int input_w, int input_h, int input_stride_in_bytes, // stride can be zero - void *output_pixels, int output_w, int output_h, int output_stride_in_bytes, // stride can be zero - stbir_pixel_layout pixel_layout, stbir_datatype data_type ); - -//=============================================================== -// You can update these parameters any time after resize_init and there is no cost -//-------------------------------- - -STBIRDEF void stbir_set_datatypes( STBIR_RESIZE * resize, stbir_datatype input_type, stbir_datatype output_type ); -STBIRDEF void stbir_set_pixel_callbacks( STBIR_RESIZE * resize, stbir_input_callback * input_cb, stbir_output_callback * output_cb ); // no callbacks by default -STBIRDEF void stbir_set_user_data( STBIR_RESIZE * resize, void * user_data ); // pass back STBIR_RESIZE* by default -STBIRDEF void stbir_set_buffer_ptrs( STBIR_RESIZE * resize, const void * input_pixels, int input_stride_in_bytes, void * output_pixels, int output_stride_in_bytes ); - -//=============================================================== - - -//=============================================================== -// If you call any of these functions, you will trigger a sampler rebuild! -//-------------------------------- - -STBIRDEF int stbir_set_pixel_layouts( STBIR_RESIZE * resize, stbir_pixel_layout input_pixel_layout, stbir_pixel_layout output_pixel_layout ); // sets new buffer layouts -STBIRDEF int stbir_set_edgemodes( STBIR_RESIZE * resize, stbir_edge horizontal_edge, stbir_edge vertical_edge ); // CLAMP by default - -STBIRDEF int stbir_set_filters( STBIR_RESIZE * resize, stbir_filter horizontal_filter, stbir_filter vertical_filter ); // STBIR_DEFAULT_FILTER_UPSAMPLE/DOWNSAMPLE by default -STBIRDEF int stbir_set_filter_callbacks( STBIR_RESIZE * resize, stbir__kernel_callback * horizontal_filter, stbir__support_callback * horizontal_support, stbir__kernel_callback * vertical_filter, stbir__support_callback * vertical_support ); - -STBIRDEF int stbir_set_pixel_subrect( STBIR_RESIZE * resize, int subx, int suby, int subw, int subh ); // sets both sub-regions (full regions by default) -STBIRDEF int stbir_set_input_subrect( STBIR_RESIZE * resize, double s0, double t0, double s1, double t1 ); // sets input sub-region (full region by default) -STBIRDEF int stbir_set_output_pixel_subrect( STBIR_RESIZE * resize, int subx, int suby, int subw, int subh ); // sets output sub-region (full region by default) - -// when inputting AND outputting non-premultiplied alpha pixels, we use a slower but higher quality technique -// that fills the zero alpha pixel's RGB values with something plausible. If you don't care about areas of -// zero alpha, you can call this function to get about a 25% speed improvement for STBIR_RGBA to STBIR_RGBA -// types of resizes. -STBIRDEF int stbir_set_non_pm_alpha_speed_over_quality( STBIR_RESIZE * resize, int non_pma_alpha_speed_over_quality ); -//=============================================================== - - -//=============================================================== -// You can call build_samplers to prebuild all the internal data we need to resample. -// Then, if you call resize_extended many times with the same resize, you only pay the -// cost once. -// If you do call build_samplers, you MUST call free_samplers eventually. -//-------------------------------- - -// This builds the samplers and does one allocation -STBIRDEF int stbir_build_samplers( STBIR_RESIZE * resize ); - -// You MUST call this, if you call stbir_build_samplers or stbir_build_samplers_with_splits -STBIRDEF void stbir_free_samplers( STBIR_RESIZE * resize ); -//=============================================================== - - -// And this is the main function to perform the resize synchronously on one thread. -STBIRDEF int stbir_resize_extended( STBIR_RESIZE * resize ); - - -//=============================================================== -// Use these functions for multithreading. -// 1) You call stbir_build_samplers_with_splits first on the main thread -// 2) Then stbir_resize_with_split on each thread -// 3) stbir_free_samplers when done on the main thread -//-------------------------------- - -// This will build samplers for threading. -// You can pass in the number of threads you'd like to use (try_splits). -// It returns the number of splits (threads) that you can call it with. -/// It might be less if the image resize can't be split up that many ways. - -STBIRDEF int stbir_build_samplers_with_splits( STBIR_RESIZE * resize, int try_splits ); - -// This function does a split of the resizing (you call this fuction for each -// split, on multiple threads). A split is a piece of the output resize pixel space. - -// Note that you MUST call stbir_build_samplers_with_splits before stbir_resize_extended_split! - -// Usually, you will always call stbir_resize_split with split_start as the thread_index -// and "1" for the split_count. -// But, if you have a weird situation where you MIGHT want 8 threads, but sometimes -// only 4 threads, you can use 0,2,4,6 for the split_start's and use "2" for the -// split_count each time to turn in into a 4 thread resize. (This is unusual). - -STBIRDEF int stbir_resize_extended_split( STBIR_RESIZE * resize, int split_start, int split_count ); -//=============================================================== - - -//=============================================================== -// Pixel Callbacks info: -//-------------------------------- - -// The input callback is super flexible - it calls you with the input address -// (based on the stride and base pointer), it gives you an optional_output -// pointer that you can fill, or you can just return your own pointer into -// your own data. -// -// You can also do conversion from non-supported data types if necessary - in -// this case, you ignore the input_ptr and just use the x and y parameters to -// calculate your own input_ptr based on the size of each non-supported pixel. -// (Something like the third example below.) -// -// You can also install just an input or just an output callback by setting the -// callback that you don't want to zero. -// -// First example, progress: (getting a callback that you can monitor the progress): -// void const * my_callback( void * optional_output, void const * input_ptr, int num_pixels, int x, int y, void * context ) -// { -// percentage_done = y / input_height; -// return input_ptr; // use buffer from call -// } -// -// Next example, copying: (copy from some other buffer or stream): -// void const * my_callback( void * optional_output, void const * input_ptr, int num_pixels, int x, int y, void * context ) -// { -// CopyOrStreamData( optional_output, other_data_src, num_pixels * pixel_width_in_bytes ); -// return optional_output; // return the optional buffer that we filled -// } -// -// Third example, input another buffer without copying: (zero-copy from other buffer): -// void const * my_callback( void * optional_output, void const * input_ptr, int num_pixels, int x, int y, void * context ) -// { -// void * pixels = ( (char*) other_image_base ) + ( y * other_image_stride ) + ( x * other_pixel_width_in_bytes ); -// return pixels; // return pointer to your data without copying -// } -// -// -// The output callback is considerably simpler - it just calls you so that you can dump -// out each scanline. You could even directly copy out to disk if you have a simple format -// like TGA or BMP. You can also convert to other output types here if you want. -// -// Simple example: -// void const * my_output( void * output_ptr, int num_pixels, int y, void * context ) -// { -// percentage_done = y / output_height; -// fwrite( output_ptr, pixel_width_in_bytes, num_pixels, output_file ); -// } -//=============================================================== - - - - -//=============================================================== -// optional built-in profiling API -//-------------------------------- - -#ifdef STBIR_PROFILE - -typedef struct STBIR_PROFILE_INFO -{ - stbir_uint64 total_clocks; - - // how many clocks spent (of total_clocks) in the various resize routines, along with a string description - // there are "resize_count" number of zones - stbir_uint64 clocks[ 8 ]; - char const ** descriptions; - - // count of clocks and descriptions - stbir_uint32 count; -} STBIR_PROFILE_INFO; - -// use after calling stbir_resize_extended (or stbir_build_samplers or stbir_build_samplers_with_splits) -STBIRDEF void stbir_resize_build_profile_info( STBIR_PROFILE_INFO * out_info, STBIR_RESIZE const * resize ); - -// use after calling stbir_resize_extended -STBIRDEF void stbir_resize_extended_profile_info( STBIR_PROFILE_INFO * out_info, STBIR_RESIZE const * resize ); - -// use after calling stbir_resize_extended_split -STBIRDEF void stbir_resize_split_profile_info( STBIR_PROFILE_INFO * out_info, STBIR_RESIZE const * resize, int split_start, int split_num ); - -//=============================================================== - -#endif - - -//// end header file ///////////////////////////////////////////////////// -#endif // STBIR_INCLUDE_STB_IMAGE_RESIZE2_H - -#if defined(STB_IMAGE_RESIZE_IMPLEMENTATION) || defined(STB_IMAGE_RESIZE2_IMPLEMENTATION) - -#ifndef STBIR_ASSERT -#include -#define STBIR_ASSERT(x) assert(x) -#endif - -#ifndef STBIR_MALLOC -#include -#define STBIR_MALLOC(size,user_data) ((void)(user_data), malloc(size)) -#define STBIR_FREE(ptr,user_data) ((void)(user_data), free(ptr)) -// (we used the comma operator to evaluate user_data, to avoid "unused parameter" warnings) -#endif - -#ifdef _MSC_VER - -#define stbir__inline __forceinline - -#else - -#define stbir__inline __inline__ - -// Clang address sanitizer -#if defined(__has_feature) - #if __has_feature(address_sanitizer) || __has_feature(memory_sanitizer) - #ifndef STBIR__SEPARATE_ALLOCATIONS - #define STBIR__SEPARATE_ALLOCATIONS - #endif - #endif -#endif - -#endif - -// GCC and MSVC -#if defined(__SANITIZE_ADDRESS__) - #ifndef STBIR__SEPARATE_ALLOCATIONS - #define STBIR__SEPARATE_ALLOCATIONS - #endif -#endif - -// Always turn off automatic FMA use - use STBIR_USE_FMA if you want. -// Otherwise, this is a determinism disaster. -#ifndef STBIR_DONT_CHANGE_FP_CONTRACT // override in case you don't want this behavior -#if defined(_MSC_VER) && !defined(__clang__) -#if _MSC_VER > 1200 -#pragma fp_contract(off) -#endif -#elif defined(__GNUC__) && !defined(__clang__) -#pragma GCC optimize("fp-contract=off") -#else -#pragma STDC FP_CONTRACT OFF -#endif -#endif - -#ifdef _MSC_VER -#define STBIR__UNUSED(v) (void)(v) -#else -#define STBIR__UNUSED(v) (void)sizeof(v) -#endif - -#define STBIR__ARRAY_SIZE(a) (sizeof((a))/sizeof((a)[0])) - - -#ifndef STBIR_DEFAULT_FILTER_UPSAMPLE -#define STBIR_DEFAULT_FILTER_UPSAMPLE STBIR_FILTER_CATMULLROM -#endif - -#ifndef STBIR_DEFAULT_FILTER_DOWNSAMPLE -#define STBIR_DEFAULT_FILTER_DOWNSAMPLE STBIR_FILTER_MITCHELL -#endif - - -#ifndef STBIR__HEADER_FILENAME -#define STBIR__HEADER_FILENAME "stb_image_resize2.h" -#endif - -// the internal pixel layout enums are in a different order, so we can easily do range comparisons of types -// the public pixel layout is ordered in a way that if you cast num_channels (1-4) to the enum, you get something sensible -typedef enum -{ - STBIRI_1CHANNEL = 0, - STBIRI_2CHANNEL = 1, - STBIRI_RGB = 2, - STBIRI_BGR = 3, - STBIRI_4CHANNEL = 4, - - STBIRI_RGBA = 5, - STBIRI_BGRA = 6, - STBIRI_ARGB = 7, - STBIRI_ABGR = 8, - STBIRI_RA = 9, - STBIRI_AR = 10, - - STBIRI_RGBA_PM = 11, - STBIRI_BGRA_PM = 12, - STBIRI_ARGB_PM = 13, - STBIRI_ABGR_PM = 14, - STBIRI_RA_PM = 15, - STBIRI_AR_PM = 16, -} stbir_internal_pixel_layout; - -// define the public pixel layouts to not compile inside the implementation (to avoid accidental use) -#define STBIR_BGR bad_dont_use_in_implementation -#define STBIR_1CHANNEL STBIR_BGR -#define STBIR_2CHANNEL STBIR_BGR -#define STBIR_RGB STBIR_BGR -#define STBIR_RGBA STBIR_BGR -#define STBIR_4CHANNEL STBIR_BGR -#define STBIR_BGRA STBIR_BGR -#define STBIR_ARGB STBIR_BGR -#define STBIR_ABGR STBIR_BGR -#define STBIR_RA STBIR_BGR -#define STBIR_AR STBIR_BGR -#define STBIR_RGBA_PM STBIR_BGR -#define STBIR_BGRA_PM STBIR_BGR -#define STBIR_ARGB_PM STBIR_BGR -#define STBIR_ABGR_PM STBIR_BGR -#define STBIR_RA_PM STBIR_BGR -#define STBIR_AR_PM STBIR_BGR - -// must match stbir_datatype -static unsigned char stbir__type_size[] = { - 1,1,1,2,4,2 // STBIR_TYPE_UINT8,STBIR_TYPE_UINT8_SRGB,STBIR_TYPE_UINT8_SRGB_ALPHA,STBIR_TYPE_UINT16,STBIR_TYPE_FLOAT,STBIR_TYPE_HALF_FLOAT -}; - -// When gathering, the contributors are which source pixels contribute. -// When scattering, the contributors are which destination pixels are contributed to. -typedef struct -{ - int n0; // First contributing pixel - int n1; // Last contributing pixel -} stbir__contributors; - -typedef struct -{ - int lowest; // First sample index for whole filter - int highest; // Last sample index for whole filter - int widest; // widest single set of samples for an output -} stbir__filter_extent_info; - -typedef struct -{ - int n0; // First pixel of decode buffer to write to - int n1; // Last pixel of decode that will be written to - int pixel_offset_for_input; // Pixel offset into input_scanline -} stbir__span; - -typedef struct stbir__scale_info -{ - int input_full_size; - int output_sub_size; - float scale; - float inv_scale; - float pixel_shift; // starting shift in output pixel space (in pixels) - int scale_is_rational; - stbir_uint32 scale_numerator, scale_denominator; -} stbir__scale_info; - -typedef struct -{ - stbir__contributors * contributors; - float* coefficients; - stbir__contributors * gather_prescatter_contributors; - float * gather_prescatter_coefficients; - stbir__scale_info scale_info; - float support; - stbir_filter filter_enum; - stbir__kernel_callback * filter_kernel; - stbir__support_callback * filter_support; - stbir_edge edge; - int coefficient_width; - int filter_pixel_width; - int filter_pixel_margin; - int num_contributors; - int contributors_size; - int coefficients_size; - stbir__filter_extent_info extent_info; - int is_gather; // 0 = scatter, 1 = gather with scale >= 1, 2 = gather with scale < 1 - int gather_prescatter_num_contributors; - int gather_prescatter_coefficient_width; - int gather_prescatter_contributors_size; - int gather_prescatter_coefficients_size; -} stbir__sampler; - -typedef struct -{ - stbir__contributors conservative; - int edge_sizes[2]; // this can be less than filter_pixel_margin, if the filter and scaling falls off - stbir__span spans[2]; // can be two spans, if doing input subrect with clamp mode WRAP -} stbir__extents; - -typedef struct -{ -#ifdef STBIR_PROFILE - union - { - struct { stbir_uint64 total, looping, vertical, horizontal, decode, encode, alpha, unalpha; } named; - stbir_uint64 array[8]; - } profile; - stbir_uint64 * current_zone_excluded_ptr; -#endif - float* decode_buffer; - - int ring_buffer_first_scanline; - int ring_buffer_last_scanline; - int ring_buffer_begin_index; // first_scanline is at this index in the ring buffer - int start_output_y, end_output_y; - int start_input_y, end_input_y; // used in scatter only - - #ifdef STBIR__SEPARATE_ALLOCATIONS - float** ring_buffers; // one pointer for each ring buffer - #else - float* ring_buffer; // one big buffer that we index into - #endif - - float* vertical_buffer; - - char no_cache_straddle[64]; -} stbir__per_split_info; - -typedef float * stbir__decode_pixels_func( float * decode, int width_times_channels, void const * input ); -typedef void stbir__alpha_weight_func( float * decode_buffer, int width_times_channels ); -typedef void stbir__horizontal_gather_channels_func( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, - stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ); -typedef void stbir__alpha_unweight_func(float * encode_buffer, int width_times_channels ); -typedef void stbir__encode_pixels_func( void * output, int width_times_channels, float const * encode ); - -struct stbir__info -{ -#ifdef STBIR_PROFILE - union - { - struct { stbir_uint64 total, build, alloc, horizontal, vertical, cleanup, pivot; } named; - stbir_uint64 array[7]; - } profile; - stbir_uint64 * current_zone_excluded_ptr; -#endif - stbir__sampler horizontal; - stbir__sampler vertical; - - void const * input_data; - void * output_data; - - int input_stride_bytes; - int output_stride_bytes; - int ring_buffer_length_bytes; // The length of an individual entry in the ring buffer. The total number of ring buffers is stbir__get_filter_pixel_width(filter) - int ring_buffer_num_entries; // Total number of entries in the ring buffer. - - stbir_datatype input_type; - stbir_datatype output_type; - - stbir_input_callback * in_pixels_cb; - void * user_data; - stbir_output_callback * out_pixels_cb; - - stbir__extents scanline_extents; - - void * alloced_mem; - stbir__per_split_info * split_info; // by default 1, but there will be N of these allocated based on the thread init you did - - stbir__decode_pixels_func * decode_pixels; - stbir__alpha_weight_func * alpha_weight; - stbir__horizontal_gather_channels_func * horizontal_gather_channels; - stbir__alpha_unweight_func * alpha_unweight; - stbir__encode_pixels_func * encode_pixels; - - int alloc_ring_buffer_num_entries; // Number of entries in the ring buffer that will be allocated - int splits; // count of splits - - stbir_internal_pixel_layout input_pixel_layout_internal; - stbir_internal_pixel_layout output_pixel_layout_internal; - - int input_color_and_type; - int offset_x, offset_y; // offset within output_data - int vertical_first; - int channels; - int effective_channels; // same as channels, except on RGBA/ARGB (7), or XA/AX (3) - size_t alloced_total; -}; - - -#define stbir__max_uint8_as_float 255.0f -#define stbir__max_uint16_as_float 65535.0f -#define stbir__max_uint8_as_float_inverted 3.9215689e-03f // (1.0f/255.0f) -#define stbir__max_uint16_as_float_inverted 1.5259022e-05f // (1.0f/65535.0f) -#define stbir__small_float ((float)1 / (1 << 20) / (1 << 20) / (1 << 20) / (1 << 20) / (1 << 20) / (1 << 20)) - -// min/max friendly -#define STBIR_CLAMP(x, xmin, xmax) for(;;) { \ - if ( (x) < (xmin) ) (x) = (xmin); \ - if ( (x) > (xmax) ) (x) = (xmax); \ - break; \ -} - -static stbir__inline int stbir__min(int a, int b) -{ - return a < b ? a : b; -} - -static stbir__inline int stbir__max(int a, int b) -{ - return a > b ? a : b; -} - -static float stbir__srgb_uchar_to_linear_float[256] = { - 0.000000f, 0.000304f, 0.000607f, 0.000911f, 0.001214f, 0.001518f, 0.001821f, 0.002125f, 0.002428f, 0.002732f, 0.003035f, - 0.003347f, 0.003677f, 0.004025f, 0.004391f, 0.004777f, 0.005182f, 0.005605f, 0.006049f, 0.006512f, 0.006995f, 0.007499f, - 0.008023f, 0.008568f, 0.009134f, 0.009721f, 0.010330f, 0.010960f, 0.011612f, 0.012286f, 0.012983f, 0.013702f, 0.014444f, - 0.015209f, 0.015996f, 0.016807f, 0.017642f, 0.018500f, 0.019382f, 0.020289f, 0.021219f, 0.022174f, 0.023153f, 0.024158f, - 0.025187f, 0.026241f, 0.027321f, 0.028426f, 0.029557f, 0.030713f, 0.031896f, 0.033105f, 0.034340f, 0.035601f, 0.036889f, - 0.038204f, 0.039546f, 0.040915f, 0.042311f, 0.043735f, 0.045186f, 0.046665f, 0.048172f, 0.049707f, 0.051269f, 0.052861f, - 0.054480f, 0.056128f, 0.057805f, 0.059511f, 0.061246f, 0.063010f, 0.064803f, 0.066626f, 0.068478f, 0.070360f, 0.072272f, - 0.074214f, 0.076185f, 0.078187f, 0.080220f, 0.082283f, 0.084376f, 0.086500f, 0.088656f, 0.090842f, 0.093059f, 0.095307f, - 0.097587f, 0.099899f, 0.102242f, 0.104616f, 0.107023f, 0.109462f, 0.111932f, 0.114435f, 0.116971f, 0.119538f, 0.122139f, - 0.124772f, 0.127438f, 0.130136f, 0.132868f, 0.135633f, 0.138432f, 0.141263f, 0.144128f, 0.147027f, 0.149960f, 0.152926f, - 0.155926f, 0.158961f, 0.162029f, 0.165132f, 0.168269f, 0.171441f, 0.174647f, 0.177888f, 0.181164f, 0.184475f, 0.187821f, - 0.191202f, 0.194618f, 0.198069f, 0.201556f, 0.205079f, 0.208637f, 0.212231f, 0.215861f, 0.219526f, 0.223228f, 0.226966f, - 0.230740f, 0.234551f, 0.238398f, 0.242281f, 0.246201f, 0.250158f, 0.254152f, 0.258183f, 0.262251f, 0.266356f, 0.270498f, - 0.274677f, 0.278894f, 0.283149f, 0.287441f, 0.291771f, 0.296138f, 0.300544f, 0.304987f, 0.309469f, 0.313989f, 0.318547f, - 0.323143f, 0.327778f, 0.332452f, 0.337164f, 0.341914f, 0.346704f, 0.351533f, 0.356400f, 0.361307f, 0.366253f, 0.371238f, - 0.376262f, 0.381326f, 0.386430f, 0.391573f, 0.396755f, 0.401978f, 0.407240f, 0.412543f, 0.417885f, 0.423268f, 0.428691f, - 0.434154f, 0.439657f, 0.445201f, 0.450786f, 0.456411f, 0.462077f, 0.467784f, 0.473532f, 0.479320f, 0.485150f, 0.491021f, - 0.496933f, 0.502887f, 0.508881f, 0.514918f, 0.520996f, 0.527115f, 0.533276f, 0.539480f, 0.545725f, 0.552011f, 0.558340f, - 0.564712f, 0.571125f, 0.577581f, 0.584078f, 0.590619f, 0.597202f, 0.603827f, 0.610496f, 0.617207f, 0.623960f, 0.630757f, - 0.637597f, 0.644480f, 0.651406f, 0.658375f, 0.665387f, 0.672443f, 0.679543f, 0.686685f, 0.693872f, 0.701102f, 0.708376f, - 0.715694f, 0.723055f, 0.730461f, 0.737911f, 0.745404f, 0.752942f, 0.760525f, 0.768151f, 0.775822f, 0.783538f, 0.791298f, - 0.799103f, 0.806952f, 0.814847f, 0.822786f, 0.830770f, 0.838799f, 0.846873f, 0.854993f, 0.863157f, 0.871367f, 0.879622f, - 0.887923f, 0.896269f, 0.904661f, 0.913099f, 0.921582f, 0.930111f, 0.938686f, 0.947307f, 0.955974f, 0.964686f, 0.973445f, - 0.982251f, 0.991102f, 1.0f -}; - -typedef union -{ - unsigned int u; - float f; -} stbir__FP32; - -// From https://gist.github.com/rygorous/2203834 - -static const stbir_uint32 fp32_to_srgb8_tab4[104] = { - 0x0073000d, 0x007a000d, 0x0080000d, 0x0087000d, 0x008d000d, 0x0094000d, 0x009a000d, 0x00a1000d, - 0x00a7001a, 0x00b4001a, 0x00c1001a, 0x00ce001a, 0x00da001a, 0x00e7001a, 0x00f4001a, 0x0101001a, - 0x010e0033, 0x01280033, 0x01410033, 0x015b0033, 0x01750033, 0x018f0033, 0x01a80033, 0x01c20033, - 0x01dc0067, 0x020f0067, 0x02430067, 0x02760067, 0x02aa0067, 0x02dd0067, 0x03110067, 0x03440067, - 0x037800ce, 0x03df00ce, 0x044600ce, 0x04ad00ce, 0x051400ce, 0x057b00c5, 0x05dd00bc, 0x063b00b5, - 0x06970158, 0x07420142, 0x07e30130, 0x087b0120, 0x090b0112, 0x09940106, 0x0a1700fc, 0x0a9500f2, - 0x0b0f01cb, 0x0bf401ae, 0x0ccb0195, 0x0d950180, 0x0e56016e, 0x0f0d015e, 0x0fbc0150, 0x10630143, - 0x11070264, 0x1238023e, 0x1357021d, 0x14660201, 0x156601e9, 0x165a01d3, 0x174401c0, 0x182401af, - 0x18fe0331, 0x1a9602fe, 0x1c1502d2, 0x1d7e02ad, 0x1ed4028d, 0x201a0270, 0x21520256, 0x227d0240, - 0x239f0443, 0x25c003fe, 0x27bf03c4, 0x29a10392, 0x2b6a0367, 0x2d1d0341, 0x2ebe031f, 0x304d0300, - 0x31d105b0, 0x34a80555, 0x37520507, 0x39d504c5, 0x3c37048b, 0x3e7c0458, 0x40a8042a, 0x42bd0401, - 0x44c20798, 0x488e071e, 0x4c1c06b6, 0x4f76065d, 0x52a50610, 0x55ac05cc, 0x5892058f, 0x5b590559, - 0x5e0c0a23, 0x631c0980, 0x67db08f6, 0x6c55087f, 0x70940818, 0x74a007bd, 0x787d076c, 0x7c330723, -}; - -static stbir__inline stbir_uint8 stbir__linear_to_srgb_uchar(float in) -{ - static const stbir__FP32 almostone = { 0x3f7fffff }; // 1-eps - static const stbir__FP32 minval = { (127-13) << 23 }; - stbir_uint32 tab,bias,scale,t; - stbir__FP32 f; - - // Clamp to [2^(-13), 1-eps]; these two values map to 0 and 1, respectively. - // The tests are carefully written so that NaNs map to 0, same as in the reference - // implementation. - if (!(in > minval.f)) // written this way to catch NaNs - return 0; - if (in > almostone.f) - return 255; - - // Do the table lookup and unpack bias, scale - f.f = in; - tab = fp32_to_srgb8_tab4[(f.u - minval.u) >> 20]; - bias = (tab >> 16) << 9; - scale = tab & 0xffff; - - // Grab next-highest mantissa bits and perform linear interpolation - t = (f.u >> 12) & 0xff; - return (unsigned char) ((bias + scale*t) >> 16); -} - -#ifndef STBIR_FORCE_GATHER_FILTER_SCANLINES_AMOUNT -#define STBIR_FORCE_GATHER_FILTER_SCANLINES_AMOUNT 32 // when downsampling and <= 32 scanlines of buffering, use gather. gather used down to 1/8th scaling for 25% win. -#endif - -#ifndef STBIR_FORCE_MINIMUM_SCANLINES_FOR_SPLITS -#define STBIR_FORCE_MINIMUM_SCANLINES_FOR_SPLITS 4 // when threading, what is the minimum number of scanlines for a split? -#endif - -#define STBIR_INPUT_CALLBACK_PADDING 3 - -#ifdef _M_IX86_FP -#if ( _M_IX86_FP >= 1 ) -#ifndef STBIR_SSE -#define STBIR_SSE -#endif -#endif -#endif - -#ifdef __TINYC__ - // tiny c has no intrinsics yet - this can become a version check if they add them - #define STBIR_NO_SIMD -#endif - -#if defined(_x86_64) || defined( __x86_64__ ) || defined( _M_X64 ) || defined(__x86_64) || defined(_M_AMD64) || defined(__SSE2__) || defined(STBIR_SSE) || defined(STBIR_SSE2) - #ifndef STBIR_SSE2 - #define STBIR_SSE2 - #endif - #if defined(__AVX__) || defined(STBIR_AVX2) - #ifndef STBIR_AVX - #ifndef STBIR_NO_AVX - #define STBIR_AVX - #endif - #endif - #endif - #if defined(__AVX2__) || defined(STBIR_AVX2) - #ifndef STBIR_NO_AVX2 - #ifndef STBIR_AVX2 - #define STBIR_AVX2 - #endif - #if defined( _MSC_VER ) && !defined(__clang__) - #ifndef STBIR_FP16C // FP16C instructions are on all AVX2 cpus, so we can autoselect it here on microsoft - clang needs -m16c - #define STBIR_FP16C - #endif - #endif - #endif - #endif - #ifdef __F16C__ - #ifndef STBIR_FP16C // turn on FP16C instructions if the define is set (for clang and gcc) - #define STBIR_FP16C - #endif - #endif -#endif - -#if defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ ) || ((__ARM_NEON_FP & 4) != 0) || defined(__ARM_NEON__) -#ifndef STBIR_NEON -#define STBIR_NEON -#endif -#endif - -#if defined(_M_ARM) || defined(__arm__) -#ifdef STBIR_USE_FMA -#undef STBIR_USE_FMA // no FMA for 32-bit arm on MSVC -#endif -#endif - -#if defined(__wasm__) && defined(__wasm_simd128__) -#ifndef STBIR_WASM -#define STBIR_WASM -#endif -#endif - -// restrict pointers for the output pointers, other loop and unroll control -#if defined( _MSC_VER ) && !defined(__clang__) - #define STBIR_STREAMOUT_PTR( star ) star __restrict - #define STBIR_NO_UNROLL( ptr ) __assume(ptr) // this oddly keeps msvc from unrolling a loop - #if _MSC_VER >= 1900 - #define STBIR_NO_UNROLL_LOOP_START __pragma(loop( no_vector )) - #else - #define STBIR_NO_UNROLL_LOOP_START - #endif -#elif defined( __clang__ ) - #define STBIR_STREAMOUT_PTR( star ) star __restrict__ - #define STBIR_NO_UNROLL( ptr ) __asm__ (""::"r"(ptr)) - #if ( __clang_major__ >= 4 ) || ( ( __clang_major__ >= 3 ) && ( __clang_minor__ >= 5 ) ) - #define STBIR_NO_UNROLL_LOOP_START _Pragma("clang loop unroll(disable)") _Pragma("clang loop vectorize(disable)") - #else - #define STBIR_NO_UNROLL_LOOP_START - #endif -#elif defined( __GNUC__ ) - #define STBIR_STREAMOUT_PTR( star ) star __restrict__ - #define STBIR_NO_UNROLL( ptr ) __asm__ (""::"r"(ptr)) - #if __GNUC__ >= 14 - #define STBIR_NO_UNROLL_LOOP_START _Pragma("GCC unroll 0") _Pragma("GCC novector") - #else - #define STBIR_NO_UNROLL_LOOP_START - #endif - #define STBIR_NO_UNROLL_LOOP_START_INF_FOR -#else - #define STBIR_STREAMOUT_PTR( star ) star - #define STBIR_NO_UNROLL( ptr ) - #define STBIR_NO_UNROLL_LOOP_START -#endif - -#ifndef STBIR_NO_UNROLL_LOOP_START_INF_FOR -#define STBIR_NO_UNROLL_LOOP_START_INF_FOR STBIR_NO_UNROLL_LOOP_START -#endif - -#ifdef STBIR_NO_SIMD // force simd off for whatever reason - -// force simd off overrides everything else, so clear it all - -#ifdef STBIR_SSE2 -#undef STBIR_SSE2 -#endif - -#ifdef STBIR_AVX -#undef STBIR_AVX -#endif - -#ifdef STBIR_NEON -#undef STBIR_NEON -#endif - -#ifdef STBIR_AVX2 -#undef STBIR_AVX2 -#endif - -#ifdef STBIR_FP16C -#undef STBIR_FP16C -#endif - -#ifdef STBIR_WASM -#undef STBIR_WASM -#endif - -#ifdef STBIR_SIMD -#undef STBIR_SIMD -#endif - -#else // STBIR_SIMD - -#ifdef STBIR_SSE2 - #include - - #define stbir__simdf __m128 - #define stbir__simdi __m128i - - #define stbir_simdi_castf( reg ) _mm_castps_si128(reg) - #define stbir_simdf_casti( reg ) _mm_castsi128_ps(reg) - - #define stbir__simdf_load( reg, ptr ) (reg) = _mm_loadu_ps( (float const*)(ptr) ) - #define stbir__simdi_load( reg, ptr ) (reg) = _mm_loadu_si128 ( (stbir__simdi const*)(ptr) ) - #define stbir__simdf_load1( out, ptr ) (out) = _mm_load_ss( (float const*)(ptr) ) // top values can be random (not denormal or nan for perf) - #define stbir__simdi_load1( out, ptr ) (out) = _mm_castps_si128( _mm_load_ss( (float const*)(ptr) )) - #define stbir__simdf_load1z( out, ptr ) (out) = _mm_load_ss( (float const*)(ptr) ) // top values must be zero - #define stbir__simdf_frep4( fvar ) _mm_set_ps1( fvar ) - #define stbir__simdf_load1frep4( out, fvar ) (out) = _mm_set_ps1( fvar ) - #define stbir__simdf_load2( out, ptr ) (out) = _mm_castsi128_ps( _mm_loadl_epi64( (__m128i*)(ptr)) ) // top values can be random (not denormal or nan for perf) - #define stbir__simdf_load2z( out, ptr ) (out) = _mm_castsi128_ps( _mm_loadl_epi64( (__m128i*)(ptr)) ) // top values must be zero - #define stbir__simdf_load2hmerge( out, reg, ptr ) (out) = _mm_castpd_ps(_mm_loadh_pd( _mm_castps_pd(reg), (double*)(ptr) )) - - #define stbir__simdf_zeroP() _mm_setzero_ps() - #define stbir__simdf_zero( reg ) (reg) = _mm_setzero_ps() - - #define stbir__simdf_store( ptr, reg ) _mm_storeu_ps( (float*)(ptr), reg ) - #define stbir__simdf_store1( ptr, reg ) _mm_store_ss( (float*)(ptr), reg ) - #define stbir__simdf_store2( ptr, reg ) _mm_storel_epi64( (__m128i*)(ptr), _mm_castps_si128(reg) ) - #define stbir__simdf_store2h( ptr, reg ) _mm_storeh_pd( (double*)(ptr), _mm_castps_pd(reg) ) - - #define stbir__simdi_store( ptr, reg ) _mm_storeu_si128( (__m128i*)(ptr), reg ) - #define stbir__simdi_store1( ptr, reg ) _mm_store_ss( (float*)(ptr), _mm_castsi128_ps(reg) ) - #define stbir__simdi_store2( ptr, reg ) _mm_storel_epi64( (__m128i*)(ptr), (reg) ) - - #define stbir__prefetch( ptr ) _mm_prefetch((char*)(ptr), _MM_HINT_T0 ) - - #define stbir__simdi_expand_u8_to_u32(out0,out1,out2,out3,ireg) \ - { \ - stbir__simdi zero = _mm_setzero_si128(); \ - out2 = _mm_unpacklo_epi8( ireg, zero ); \ - out3 = _mm_unpackhi_epi8( ireg, zero ); \ - out0 = _mm_unpacklo_epi16( out2, zero ); \ - out1 = _mm_unpackhi_epi16( out2, zero ); \ - out2 = _mm_unpacklo_epi16( out3, zero ); \ - out3 = _mm_unpackhi_epi16( out3, zero ); \ - } - -#define stbir__simdi_expand_u8_to_1u32(out,ireg) \ - { \ - stbir__simdi zero = _mm_setzero_si128(); \ - out = _mm_unpacklo_epi8( ireg, zero ); \ - out = _mm_unpacklo_epi16( out, zero ); \ - } - - #define stbir__simdi_expand_u16_to_u32(out0,out1,ireg) \ - { \ - stbir__simdi zero = _mm_setzero_si128(); \ - out0 = _mm_unpacklo_epi16( ireg, zero ); \ - out1 = _mm_unpackhi_epi16( ireg, zero ); \ - } - - #define stbir__simdf_convert_float_to_i32( i, f ) (i) = _mm_cvttps_epi32(f) - #define stbir__simdf_convert_float_to_int( f ) _mm_cvtt_ss2si(f) - #define stbir__simdf_convert_float_to_uint8( f ) ((unsigned char)_mm_cvtsi128_si32(_mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(f,STBIR__CONSTF(STBIR_max_uint8_as_float)),_mm_setzero_ps())))) - #define stbir__simdf_convert_float_to_short( f ) ((unsigned short)_mm_cvtsi128_si32(_mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(f,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps())))) - - #define stbir__simdi_to_int( i ) _mm_cvtsi128_si32(i) - #define stbir__simdi_convert_i32_to_float(out, ireg) (out) = _mm_cvtepi32_ps( ireg ) - #define stbir__simdf_add( out, reg0, reg1 ) (out) = _mm_add_ps( reg0, reg1 ) - #define stbir__simdf_mult( out, reg0, reg1 ) (out) = _mm_mul_ps( reg0, reg1 ) - #define stbir__simdf_mult_mem( out, reg, ptr ) (out) = _mm_mul_ps( reg, _mm_loadu_ps( (float const*)(ptr) ) ) - #define stbir__simdf_mult1_mem( out, reg, ptr ) (out) = _mm_mul_ss( reg, _mm_load_ss( (float const*)(ptr) ) ) - #define stbir__simdf_add_mem( out, reg, ptr ) (out) = _mm_add_ps( reg, _mm_loadu_ps( (float const*)(ptr) ) ) - #define stbir__simdf_add1_mem( out, reg, ptr ) (out) = _mm_add_ss( reg, _mm_load_ss( (float const*)(ptr) ) ) - - #ifdef STBIR_USE_FMA // not on by default to maintain bit identical simd to non-simd - #include - #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = _mm_fmadd_ps( mul1, mul2, add ) - #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = _mm_fmadd_ss( mul1, mul2, add ) - #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = _mm_fmadd_ps( mul, _mm_loadu_ps( (float const*)(ptr) ), add ) - #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = _mm_fmadd_ss( mul, _mm_load_ss( (float const*)(ptr) ), add ) - #else - #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = _mm_add_ps( add, _mm_mul_ps( mul1, mul2 ) ) - #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = _mm_add_ss( add, _mm_mul_ss( mul1, mul2 ) ) - #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = _mm_add_ps( add, _mm_mul_ps( mul, _mm_loadu_ps( (float const*)(ptr) ) ) ) - #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = _mm_add_ss( add, _mm_mul_ss( mul, _mm_load_ss( (float const*)(ptr) ) ) ) - #endif - - #define stbir__simdf_add1( out, reg0, reg1 ) (out) = _mm_add_ss( reg0, reg1 ) - #define stbir__simdf_mult1( out, reg0, reg1 ) (out) = _mm_mul_ss( reg0, reg1 ) - - #define stbir__simdf_and( out, reg0, reg1 ) (out) = _mm_and_ps( reg0, reg1 ) - #define stbir__simdf_or( out, reg0, reg1 ) (out) = _mm_or_ps( reg0, reg1 ) - - #define stbir__simdf_min( out, reg0, reg1 ) (out) = _mm_min_ps( reg0, reg1 ) - #define stbir__simdf_max( out, reg0, reg1 ) (out) = _mm_max_ps( reg0, reg1 ) - #define stbir__simdf_min1( out, reg0, reg1 ) (out) = _mm_min_ss( reg0, reg1 ) - #define stbir__simdf_max1( out, reg0, reg1 ) (out) = _mm_max_ss( reg0, reg1 ) - - #define stbir__simdf_0123ABCDto3ABx( out, reg0, reg1 ) (out)=_mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( _mm_shuffle_ps( reg1,reg0, (0<<0) + (1<<2) + (2<<4) + (3<<6) )), (3<<0) + (0<<2) + (1<<4) + (2<<6) ) ) - #define stbir__simdf_0123ABCDto23Ax( out, reg0, reg1 ) (out)=_mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( _mm_shuffle_ps( reg1,reg0, (0<<0) + (1<<2) + (2<<4) + (3<<6) )), (2<<0) + (3<<2) + (0<<4) + (1<<6) ) ) - - static const stbir__simdf STBIR_zeroones = { 0.0f,1.0f,0.0f,1.0f }; - static const stbir__simdf STBIR_onezeros = { 1.0f,0.0f,1.0f,0.0f }; - #define stbir__simdf_aaa1( out, alp, ones ) (out)=_mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( _mm_movehl_ps( ones, alp ) ), (1<<0) + (1<<2) + (1<<4) + (2<<6) ) ) - #define stbir__simdf_1aaa( out, alp, ones ) (out)=_mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( _mm_movelh_ps( ones, alp ) ), (0<<0) + (2<<2) + (2<<4) + (2<<6) ) ) - #define stbir__simdf_a1a1( out, alp, ones) (out) = _mm_or_ps( _mm_castsi128_ps( _mm_srli_epi64( _mm_castps_si128(alp), 32 ) ), STBIR_zeroones ) - #define stbir__simdf_1a1a( out, alp, ones) (out) = _mm_or_ps( _mm_castsi128_ps( _mm_slli_epi64( _mm_castps_si128(alp), 32 ) ), STBIR_onezeros ) - - #define stbir__simdf_swiz( reg, one, two, three, four ) _mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( reg ), (one<<0) + (two<<2) + (three<<4) + (four<<6) ) ) - - #define stbir__simdi_and( out, reg0, reg1 ) (out) = _mm_and_si128( reg0, reg1 ) - #define stbir__simdi_or( out, reg0, reg1 ) (out) = _mm_or_si128( reg0, reg1 ) - #define stbir__simdi_16madd( out, reg0, reg1 ) (out) = _mm_madd_epi16( reg0, reg1 ) - - #define stbir__simdf_pack_to_8bytes(out,aa,bb) \ - { \ - stbir__simdf af,bf; \ - stbir__simdi a,b; \ - af = _mm_min_ps( aa, STBIR_max_uint8_as_float ); \ - bf = _mm_min_ps( bb, STBIR_max_uint8_as_float ); \ - af = _mm_max_ps( af, _mm_setzero_ps() ); \ - bf = _mm_max_ps( bf, _mm_setzero_ps() ); \ - a = _mm_cvttps_epi32( af ); \ - b = _mm_cvttps_epi32( bf ); \ - a = _mm_packs_epi32( a, b ); \ - out = _mm_packus_epi16( a, a ); \ - } - - #define stbir__simdf_load4_transposed( o0, o1, o2, o3, ptr ) \ - stbir__simdf_load( o0, (ptr) ); \ - stbir__simdf_load( o1, (ptr)+4 ); \ - stbir__simdf_load( o2, (ptr)+8 ); \ - stbir__simdf_load( o3, (ptr)+12 ); \ - { \ - __m128 tmp0, tmp1, tmp2, tmp3; \ - tmp0 = _mm_unpacklo_ps(o0, o1); \ - tmp2 = _mm_unpacklo_ps(o2, o3); \ - tmp1 = _mm_unpackhi_ps(o0, o1); \ - tmp3 = _mm_unpackhi_ps(o2, o3); \ - o0 = _mm_movelh_ps(tmp0, tmp2); \ - o1 = _mm_movehl_ps(tmp2, tmp0); \ - o2 = _mm_movelh_ps(tmp1, tmp3); \ - o3 = _mm_movehl_ps(tmp3, tmp1); \ - } - - #define stbir__interleave_pack_and_store_16_u8( ptr, r0, r1, r2, r3 ) \ - r0 = _mm_packs_epi32( r0, r1 ); \ - r2 = _mm_packs_epi32( r2, r3 ); \ - r1 = _mm_unpacklo_epi16( r0, r2 ); \ - r3 = _mm_unpackhi_epi16( r0, r2 ); \ - r0 = _mm_unpacklo_epi16( r1, r3 ); \ - r2 = _mm_unpackhi_epi16( r1, r3 ); \ - r0 = _mm_packus_epi16( r0, r2 ); \ - stbir__simdi_store( ptr, r0 ); \ - - #define stbir__simdi_32shr( out, reg, imm ) out = _mm_srli_epi32( reg, imm ) - - #if defined(_MSC_VER) && !defined(__clang__) - // msvc inits with 8 bytes - #define STBIR__CONST_32_TO_8( v ) (char)(unsigned char)((v)&255),(char)(unsigned char)(((v)>>8)&255),(char)(unsigned char)(((v)>>16)&255),(char)(unsigned char)(((v)>>24)&255) - #define STBIR__CONST_4_32i( v ) STBIR__CONST_32_TO_8( v ), STBIR__CONST_32_TO_8( v ), STBIR__CONST_32_TO_8( v ), STBIR__CONST_32_TO_8( v ) - #define STBIR__CONST_4d_32i( v0, v1, v2, v3 ) STBIR__CONST_32_TO_8( v0 ), STBIR__CONST_32_TO_8( v1 ), STBIR__CONST_32_TO_8( v2 ), STBIR__CONST_32_TO_8( v3 ) - #else - // everything else inits with long long's - #define STBIR__CONST_4_32i( v ) (long long)((((stbir_uint64)(stbir_uint32)(v))<<32)|((stbir_uint64)(stbir_uint32)(v))),(long long)((((stbir_uint64)(stbir_uint32)(v))<<32)|((stbir_uint64)(stbir_uint32)(v))) - #define STBIR__CONST_4d_32i( v0, v1, v2, v3 ) (long long)((((stbir_uint64)(stbir_uint32)(v1))<<32)|((stbir_uint64)(stbir_uint32)(v0))),(long long)((((stbir_uint64)(stbir_uint32)(v3))<<32)|((stbir_uint64)(stbir_uint32)(v2))) - #endif - - #define STBIR__SIMDF_CONST(var, x) stbir__simdf var = { x, x, x, x } - #define STBIR__SIMDI_CONST(var, x) stbir__simdi var = { STBIR__CONST_4_32i(x) } - #define STBIR__CONSTF(var) (var) - #define STBIR__CONSTI(var) (var) - - #if defined(STBIR_AVX) || defined(__SSE4_1__) - #include - #define stbir__simdf_pack_to_8words(out,reg0,reg1) out = _mm_packus_epi32(_mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(reg0,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps())), _mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(reg1,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps()))) - #else - static STBIR__SIMDI_CONST(stbir__s32_32768, 32768); - static STBIR__SIMDI_CONST(stbir__s16_32768, ((32768<<16)|32768)); - - #define stbir__simdf_pack_to_8words(out,reg0,reg1) \ - { \ - stbir__simdi tmp0,tmp1; \ - tmp0 = _mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(reg0,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps())); \ - tmp1 = _mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(reg1,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps())); \ - tmp0 = _mm_sub_epi32( tmp0, stbir__s32_32768 ); \ - tmp1 = _mm_sub_epi32( tmp1, stbir__s32_32768 ); \ - out = _mm_packs_epi32( tmp0, tmp1 ); \ - out = _mm_sub_epi16( out, stbir__s16_32768 ); \ - } - - #endif - - #define STBIR_SIMD - - // if we detect AVX, set the simd8 defines - #ifdef STBIR_AVX - #include - #define STBIR_SIMD8 - #define stbir__simdf8 __m256 - #define stbir__simdi8 __m256i - #define stbir__simdf8_load( out, ptr ) (out) = _mm256_loadu_ps( (float const *)(ptr) ) - #define stbir__simdi8_load( out, ptr ) (out) = _mm256_loadu_si256( (__m256i const *)(ptr) ) - #define stbir__simdf8_mult( out, a, b ) (out) = _mm256_mul_ps( (a), (b) ) - #define stbir__simdf8_store( ptr, out ) _mm256_storeu_ps( (float*)(ptr), out ) - #define stbir__simdi8_store( ptr, reg ) _mm256_storeu_si256( (__m256i*)(ptr), reg ) - #define stbir__simdf8_frep8( fval ) _mm256_set1_ps( fval ) - - #define stbir__simdf8_min( out, reg0, reg1 ) (out) = _mm256_min_ps( reg0, reg1 ) - #define stbir__simdf8_max( out, reg0, reg1 ) (out) = _mm256_max_ps( reg0, reg1 ) - - #define stbir__simdf8_add4halves( out, bot4, top8 ) (out) = _mm_add_ps( bot4, _mm256_extractf128_ps( top8, 1 ) ) - #define stbir__simdf8_mult_mem( out, reg, ptr ) (out) = _mm256_mul_ps( reg, _mm256_loadu_ps( (float const*)(ptr) ) ) - #define stbir__simdf8_add_mem( out, reg, ptr ) (out) = _mm256_add_ps( reg, _mm256_loadu_ps( (float const*)(ptr) ) ) - #define stbir__simdf8_add( out, a, b ) (out) = _mm256_add_ps( a, b ) - #define stbir__simdf8_load1b( out, ptr ) (out) = _mm256_broadcast_ss( ptr ) - #define stbir__simdf_load1rep4( out, ptr ) (out) = _mm_broadcast_ss( ptr ) // avx load instruction - - #define stbir__simdi8_convert_i32_to_float(out, ireg) (out) = _mm256_cvtepi32_ps( ireg ) - #define stbir__simdf8_convert_float_to_i32( i, f ) (i) = _mm256_cvttps_epi32(f) - - #define stbir__simdf8_bot4s( out, a, b ) (out) = _mm256_permute2f128_ps(a,b, (0<<0)+(2<<4) ) - #define stbir__simdf8_top4s( out, a, b ) (out) = _mm256_permute2f128_ps(a,b, (1<<0)+(3<<4) ) - - #define stbir__simdf8_gettop4( reg ) _mm256_extractf128_ps(reg,1) - - #ifdef STBIR_AVX2 - - #define stbir__simdi8_expand_u8_to_u32(out0,out1,ireg) \ - { \ - stbir__simdi8 a, zero =_mm256_setzero_si256();\ - a = _mm256_permute4x64_epi64( _mm256_unpacklo_epi8( _mm256_permute4x64_epi64(_mm256_castsi128_si256(ireg),(0<<0)+(2<<2)+(1<<4)+(3<<6)), zero ),(0<<0)+(2<<2)+(1<<4)+(3<<6)); \ - out0 = _mm256_unpacklo_epi16( a, zero ); \ - out1 = _mm256_unpackhi_epi16( a, zero ); \ - } - - #define stbir__simdf8_pack_to_16bytes(out,aa,bb) \ - { \ - stbir__simdi8 t; \ - stbir__simdf8 af,bf; \ - stbir__simdi8 a,b; \ - af = _mm256_min_ps( aa, STBIR_max_uint8_as_floatX ); \ - bf = _mm256_min_ps( bb, STBIR_max_uint8_as_floatX ); \ - af = _mm256_max_ps( af, _mm256_setzero_ps() ); \ - bf = _mm256_max_ps( bf, _mm256_setzero_ps() ); \ - a = _mm256_cvttps_epi32( af ); \ - b = _mm256_cvttps_epi32( bf ); \ - t = _mm256_permute4x64_epi64( _mm256_packs_epi32( a, b ), (0<<0)+(2<<2)+(1<<4)+(3<<6) ); \ - out = _mm256_castsi256_si128( _mm256_permute4x64_epi64( _mm256_packus_epi16( t, t ), (0<<0)+(2<<2)+(1<<4)+(3<<6) ) ); \ - } - - #define stbir__simdi8_expand_u16_to_u32(out,ireg) out = _mm256_unpacklo_epi16( _mm256_permute4x64_epi64(_mm256_castsi128_si256(ireg),(0<<0)+(2<<2)+(1<<4)+(3<<6)), _mm256_setzero_si256() ); - - #define stbir__simdf8_pack_to_16words(out,aa,bb) \ - { \ - stbir__simdf8 af,bf; \ - stbir__simdi8 a,b; \ - af = _mm256_min_ps( aa, STBIR_max_uint16_as_floatX ); \ - bf = _mm256_min_ps( bb, STBIR_max_uint16_as_floatX ); \ - af = _mm256_max_ps( af, _mm256_setzero_ps() ); \ - bf = _mm256_max_ps( bf, _mm256_setzero_ps() ); \ - a = _mm256_cvttps_epi32( af ); \ - b = _mm256_cvttps_epi32( bf ); \ - (out) = _mm256_permute4x64_epi64( _mm256_packus_epi32(a, b), (0<<0)+(2<<2)+(1<<4)+(3<<6) ); \ - } - - #else - - #define stbir__simdi8_expand_u8_to_u32(out0,out1,ireg) \ - { \ - stbir__simdi a,zero = _mm_setzero_si128(); \ - a = _mm_unpacklo_epi8( ireg, zero ); \ - out0 = _mm256_setr_m128i( _mm_unpacklo_epi16( a, zero ), _mm_unpackhi_epi16( a, zero ) ); \ - a = _mm_unpackhi_epi8( ireg, zero ); \ - out1 = _mm256_setr_m128i( _mm_unpacklo_epi16( a, zero ), _mm_unpackhi_epi16( a, zero ) ); \ - } - - #define stbir__simdf8_pack_to_16bytes(out,aa,bb) \ - { \ - stbir__simdi t; \ - stbir__simdf8 af,bf; \ - stbir__simdi8 a,b; \ - af = _mm256_min_ps( aa, STBIR_max_uint8_as_floatX ); \ - bf = _mm256_min_ps( bb, STBIR_max_uint8_as_floatX ); \ - af = _mm256_max_ps( af, _mm256_setzero_ps() ); \ - bf = _mm256_max_ps( bf, _mm256_setzero_ps() ); \ - a = _mm256_cvttps_epi32( af ); \ - b = _mm256_cvttps_epi32( bf ); \ - out = _mm_packs_epi32( _mm256_castsi256_si128(a), _mm256_extractf128_si256( a, 1 ) ); \ - out = _mm_packus_epi16( out, out ); \ - t = _mm_packs_epi32( _mm256_castsi256_si128(b), _mm256_extractf128_si256( b, 1 ) ); \ - t = _mm_packus_epi16( t, t ); \ - out = _mm_castps_si128( _mm_shuffle_ps( _mm_castsi128_ps(out), _mm_castsi128_ps(t), (0<<0)+(1<<2)+(0<<4)+(1<<6) ) ); \ - } - - #define stbir__simdi8_expand_u16_to_u32(out,ireg) \ - { \ - stbir__simdi a,b,zero = _mm_setzero_si128(); \ - a = _mm_unpacklo_epi16( ireg, zero ); \ - b = _mm_unpackhi_epi16( ireg, zero ); \ - out = _mm256_insertf128_si256( _mm256_castsi128_si256( a ), b, 1 ); \ - } - - #define stbir__simdf8_pack_to_16words(out,aa,bb) \ - { \ - stbir__simdi t0,t1; \ - stbir__simdf8 af,bf; \ - stbir__simdi8 a,b; \ - af = _mm256_min_ps( aa, STBIR_max_uint16_as_floatX ); \ - bf = _mm256_min_ps( bb, STBIR_max_uint16_as_floatX ); \ - af = _mm256_max_ps( af, _mm256_setzero_ps() ); \ - bf = _mm256_max_ps( bf, _mm256_setzero_ps() ); \ - a = _mm256_cvttps_epi32( af ); \ - b = _mm256_cvttps_epi32( bf ); \ - t0 = _mm_packus_epi32( _mm256_castsi256_si128(a), _mm256_extractf128_si256( a, 1 ) ); \ - t1 = _mm_packus_epi32( _mm256_castsi256_si128(b), _mm256_extractf128_si256( b, 1 ) ); \ - out = _mm256_setr_m128i( t0, t1 ); \ - } - - #endif - - static __m256i stbir_00001111 = { STBIR__CONST_4d_32i( 0, 0, 0, 0 ), STBIR__CONST_4d_32i( 1, 1, 1, 1 ) }; - #define stbir__simdf8_0123to00001111( out, in ) (out) = _mm256_permutevar_ps ( in, stbir_00001111 ) - - static __m256i stbir_22223333 = { STBIR__CONST_4d_32i( 2, 2, 2, 2 ), STBIR__CONST_4d_32i( 3, 3, 3, 3 ) }; - #define stbir__simdf8_0123to22223333( out, in ) (out) = _mm256_permutevar_ps ( in, stbir_22223333 ) - - #define stbir__simdf8_0123to2222( out, in ) (out) = stbir__simdf_swiz(_mm256_castps256_ps128(in), 2,2,2,2 ) - - #define stbir__simdf8_load4b( out, ptr ) (out) = _mm256_broadcast_ps( (__m128 const *)(ptr) ) - - static __m256i stbir_00112233 = { STBIR__CONST_4d_32i( 0, 0, 1, 1 ), STBIR__CONST_4d_32i( 2, 2, 3, 3 ) }; - #define stbir__simdf8_0123to00112233( out, in ) (out) = _mm256_permutevar_ps ( in, stbir_00112233 ) - #define stbir__simdf8_add4( out, a8, b ) (out) = _mm256_add_ps( a8, _mm256_castps128_ps256( b ) ) - - static __m256i stbir_load6 = { STBIR__CONST_4_32i( 0x80000000 ), STBIR__CONST_4d_32i( 0x80000000, 0x80000000, 0, 0 ) }; - #define stbir__simdf8_load6z( out, ptr ) (out) = _mm256_maskload_ps( ptr, stbir_load6 ) - - #define stbir__simdf8_0123to00000000( out, in ) (out) = _mm256_shuffle_ps ( in, in, (0<<0)+(0<<2)+(0<<4)+(0<<6) ) - #define stbir__simdf8_0123to11111111( out, in ) (out) = _mm256_shuffle_ps ( in, in, (1<<0)+(1<<2)+(1<<4)+(1<<6) ) - #define stbir__simdf8_0123to22222222( out, in ) (out) = _mm256_shuffle_ps ( in, in, (2<<0)+(2<<2)+(2<<4)+(2<<6) ) - #define stbir__simdf8_0123to33333333( out, in ) (out) = _mm256_shuffle_ps ( in, in, (3<<0)+(3<<2)+(3<<4)+(3<<6) ) - #define stbir__simdf8_0123to21032103( out, in ) (out) = _mm256_shuffle_ps ( in, in, (2<<0)+(1<<2)+(0<<4)+(3<<6) ) - #define stbir__simdf8_0123to32103210( out, in ) (out) = _mm256_shuffle_ps ( in, in, (3<<0)+(2<<2)+(1<<4)+(0<<6) ) - #define stbir__simdf8_0123to12301230( out, in ) (out) = _mm256_shuffle_ps ( in, in, (1<<0)+(2<<2)+(3<<4)+(0<<6) ) - #define stbir__simdf8_0123to10321032( out, in ) (out) = _mm256_shuffle_ps ( in, in, (1<<0)+(0<<2)+(3<<4)+(2<<6) ) - #define stbir__simdf8_0123to30123012( out, in ) (out) = _mm256_shuffle_ps ( in, in, (3<<0)+(0<<2)+(1<<4)+(2<<6) ) - - #define stbir__simdf8_0123to11331133( out, in ) (out) = _mm256_shuffle_ps ( in, in, (1<<0)+(1<<2)+(3<<4)+(3<<6) ) - #define stbir__simdf8_0123to00220022( out, in ) (out) = _mm256_shuffle_ps ( in, in, (0<<0)+(0<<2)+(2<<4)+(2<<6) ) - - #define stbir__simdf8_aaa1( out, alp, ones ) (out) = _mm256_blend_ps( alp, ones, (1<<0)+(1<<1)+(1<<2)+(0<<3)+(1<<4)+(1<<5)+(1<<6)+(0<<7)); (out)=_mm256_shuffle_ps( out,out, (3<<0) + (3<<2) + (3<<4) + (0<<6) ) - #define stbir__simdf8_1aaa( out, alp, ones ) (out) = _mm256_blend_ps( alp, ones, (0<<0)+(1<<1)+(1<<2)+(1<<3)+(0<<4)+(1<<5)+(1<<6)+(1<<7)); (out)=_mm256_shuffle_ps( out,out, (1<<0) + (0<<2) + (0<<4) + (0<<6) ) - #define stbir__simdf8_a1a1( out, alp, ones) (out) = _mm256_blend_ps( alp, ones, (1<<0)+(0<<1)+(1<<2)+(0<<3)+(1<<4)+(0<<5)+(1<<6)+(0<<7)); (out)=_mm256_shuffle_ps( out,out, (1<<0) + (0<<2) + (3<<4) + (2<<6) ) - #define stbir__simdf8_1a1a( out, alp, ones) (out) = _mm256_blend_ps( alp, ones, (0<<0)+(1<<1)+(0<<2)+(1<<3)+(0<<4)+(1<<5)+(0<<6)+(1<<7)); (out)=_mm256_shuffle_ps( out,out, (1<<0) + (0<<2) + (3<<4) + (2<<6) ) - - #define stbir__simdf8_zero( reg ) (reg) = _mm256_setzero_ps() - - #ifdef STBIR_USE_FMA // not on by default to maintain bit identical simd to non-simd - #define stbir__simdf8_madd( out, add, mul1, mul2 ) (out) = _mm256_fmadd_ps( mul1, mul2, add ) - #define stbir__simdf8_madd_mem( out, add, mul, ptr ) (out) = _mm256_fmadd_ps( mul, _mm256_loadu_ps( (float const*)(ptr) ), add ) - #define stbir__simdf8_madd_mem4( out, add, mul, ptr )(out) = _mm256_fmadd_ps( _mm256_setr_m128( mul, _mm_setzero_ps() ), _mm256_setr_m128( _mm_loadu_ps( (float const*)(ptr) ), _mm_setzero_ps() ), add ) - #else - #define stbir__simdf8_madd( out, add, mul1, mul2 ) (out) = _mm256_add_ps( add, _mm256_mul_ps( mul1, mul2 ) ) - #define stbir__simdf8_madd_mem( out, add, mul, ptr ) (out) = _mm256_add_ps( add, _mm256_mul_ps( mul, _mm256_loadu_ps( (float const*)(ptr) ) ) ) - #define stbir__simdf8_madd_mem4( out, add, mul, ptr ) (out) = _mm256_add_ps( add, _mm256_setr_m128( _mm_mul_ps( mul, _mm_loadu_ps( (float const*)(ptr) ) ), _mm_setzero_ps() ) ) - #endif - #define stbir__if_simdf8_cast_to_simdf4( val ) _mm256_castps256_ps128( val ) - - #endif - - #ifdef STBIR_FLOORF - #undef STBIR_FLOORF - #endif - #define STBIR_FLOORF stbir_simd_floorf - static stbir__inline float stbir_simd_floorf(float x) // martins floorf - { - #if defined(STBIR_AVX) || defined(__SSE4_1__) || defined(STBIR_SSE41) - __m128 t = _mm_set_ss(x); - return _mm_cvtss_f32( _mm_floor_ss(t, t) ); - #else - __m128 f = _mm_set_ss(x); - __m128 t = _mm_cvtepi32_ps(_mm_cvttps_epi32(f)); - __m128 r = _mm_add_ss(t, _mm_and_ps(_mm_cmplt_ss(f, t), _mm_set_ss(-1.0f))); - return _mm_cvtss_f32(r); - #endif - } - - #ifdef STBIR_CEILF - #undef STBIR_CEILF - #endif - #define STBIR_CEILF stbir_simd_ceilf - static stbir__inline float stbir_simd_ceilf(float x) // martins ceilf - { - #if defined(STBIR_AVX) || defined(__SSE4_1__) || defined(STBIR_SSE41) - __m128 t = _mm_set_ss(x); - return _mm_cvtss_f32( _mm_ceil_ss(t, t) ); - #else - __m128 f = _mm_set_ss(x); - __m128 t = _mm_cvtepi32_ps(_mm_cvttps_epi32(f)); - __m128 r = _mm_add_ss(t, _mm_and_ps(_mm_cmplt_ss(t, f), _mm_set_ss(1.0f))); - return _mm_cvtss_f32(r); - #endif - } - -#elif defined(STBIR_NEON) - - #include - - #define stbir__simdf float32x4_t - #define stbir__simdi uint32x4_t - - #define stbir_simdi_castf( reg ) vreinterpretq_u32_f32(reg) - #define stbir_simdf_casti( reg ) vreinterpretq_f32_u32(reg) - - #define stbir__simdf_load( reg, ptr ) (reg) = vld1q_f32( (float const*)(ptr) ) - #define stbir__simdi_load( reg, ptr ) (reg) = vld1q_u32( (uint32_t const*)(ptr) ) - #define stbir__simdf_load1( out, ptr ) (out) = vld1q_dup_f32( (float const*)(ptr) ) // top values can be random (not denormal or nan for perf) - #define stbir__simdi_load1( out, ptr ) (out) = vld1q_dup_u32( (uint32_t const*)(ptr) ) - #define stbir__simdf_load1z( out, ptr ) (out) = vld1q_lane_f32( (float const*)(ptr), vdupq_n_f32(0), 0 ) // top values must be zero - #define stbir__simdf_frep4( fvar ) vdupq_n_f32( fvar ) - #define stbir__simdf_load1frep4( out, fvar ) (out) = vdupq_n_f32( fvar ) - #define stbir__simdf_load2( out, ptr ) (out) = vcombine_f32( vld1_f32( (float const*)(ptr) ), vcreate_f32(0) ) // top values can be random (not denormal or nan for perf) - #define stbir__simdf_load2z( out, ptr ) (out) = vcombine_f32( vld1_f32( (float const*)(ptr) ), vcreate_f32(0) ) // top values must be zero - #define stbir__simdf_load2hmerge( out, reg, ptr ) (out) = vcombine_f32( vget_low_f32(reg), vld1_f32( (float const*)(ptr) ) ) - - #define stbir__simdf_zeroP() vdupq_n_f32(0) - #define stbir__simdf_zero( reg ) (reg) = vdupq_n_f32(0) - - #define stbir__simdf_store( ptr, reg ) vst1q_f32( (float*)(ptr), reg ) - #define stbir__simdf_store1( ptr, reg ) vst1q_lane_f32( (float*)(ptr), reg, 0) - #define stbir__simdf_store2( ptr, reg ) vst1_f32( (float*)(ptr), vget_low_f32(reg) ) - #define stbir__simdf_store2h( ptr, reg ) vst1_f32( (float*)(ptr), vget_high_f32(reg) ) - - #define stbir__simdi_store( ptr, reg ) vst1q_u32( (uint32_t*)(ptr), reg ) - #define stbir__simdi_store1( ptr, reg ) vst1q_lane_u32( (uint32_t*)(ptr), reg, 0 ) - #define stbir__simdi_store2( ptr, reg ) vst1_u32( (uint32_t*)(ptr), vget_low_u32(reg) ) - - #define stbir__prefetch( ptr ) - - #define stbir__simdi_expand_u8_to_u32(out0,out1,out2,out3,ireg) \ - { \ - uint16x8_t l = vmovl_u8( vget_low_u8 ( vreinterpretq_u8_u32(ireg) ) ); \ - uint16x8_t h = vmovl_u8( vget_high_u8( vreinterpretq_u8_u32(ireg) ) ); \ - out0 = vmovl_u16( vget_low_u16 ( l ) ); \ - out1 = vmovl_u16( vget_high_u16( l ) ); \ - out2 = vmovl_u16( vget_low_u16 ( h ) ); \ - out3 = vmovl_u16( vget_high_u16( h ) ); \ - } - - #define stbir__simdi_expand_u8_to_1u32(out,ireg) \ - { \ - uint16x8_t tmp = vmovl_u8( vget_low_u8( vreinterpretq_u8_u32(ireg) ) ); \ - out = vmovl_u16( vget_low_u16( tmp ) ); \ - } - - #define stbir__simdi_expand_u16_to_u32(out0,out1,ireg) \ - { \ - uint16x8_t tmp = vreinterpretq_u16_u32(ireg); \ - out0 = vmovl_u16( vget_low_u16 ( tmp ) ); \ - out1 = vmovl_u16( vget_high_u16( tmp ) ); \ - } - - #define stbir__simdf_convert_float_to_i32( i, f ) (i) = vreinterpretq_u32_s32( vcvtq_s32_f32(f) ) - #define stbir__simdf_convert_float_to_int( f ) vgetq_lane_s32(vcvtq_s32_f32(f), 0) - #define stbir__simdi_to_int( i ) (int)vgetq_lane_u32(i, 0) - #define stbir__simdf_convert_float_to_uint8( f ) ((unsigned char)vgetq_lane_s32(vcvtq_s32_f32(vmaxq_f32(vminq_f32(f,STBIR__CONSTF(STBIR_max_uint8_as_float)),vdupq_n_f32(0))), 0)) - #define stbir__simdf_convert_float_to_short( f ) ((unsigned short)vgetq_lane_s32(vcvtq_s32_f32(vmaxq_f32(vminq_f32(f,STBIR__CONSTF(STBIR_max_uint16_as_float)),vdupq_n_f32(0))), 0)) - #define stbir__simdi_convert_i32_to_float(out, ireg) (out) = vcvtq_f32_s32( vreinterpretq_s32_u32(ireg) ) - #define stbir__simdf_add( out, reg0, reg1 ) (out) = vaddq_f32( reg0, reg1 ) - #define stbir__simdf_mult( out, reg0, reg1 ) (out) = vmulq_f32( reg0, reg1 ) - #define stbir__simdf_mult_mem( out, reg, ptr ) (out) = vmulq_f32( reg, vld1q_f32( (float const*)(ptr) ) ) - #define stbir__simdf_mult1_mem( out, reg, ptr ) (out) = vmulq_f32( reg, vld1q_dup_f32( (float const*)(ptr) ) ) - #define stbir__simdf_add_mem( out, reg, ptr ) (out) = vaddq_f32( reg, vld1q_f32( (float const*)(ptr) ) ) - #define stbir__simdf_add1_mem( out, reg, ptr ) (out) = vaddq_f32( reg, vld1q_dup_f32( (float const*)(ptr) ) ) - - #ifdef STBIR_USE_FMA // not on by default to maintain bit identical simd to non-simd (and also x64 no madd to arm madd) - #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = vfmaq_f32( add, mul1, mul2 ) - #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = vfmaq_f32( add, mul1, mul2 ) - #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = vfmaq_f32( add, mul, vld1q_f32( (float const*)(ptr) ) ) - #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = vfmaq_f32( add, mul, vld1q_dup_f32( (float const*)(ptr) ) ) - #else - #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = vaddq_f32( add, vmulq_f32( mul1, mul2 ) ) - #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = vaddq_f32( add, vmulq_f32( mul1, mul2 ) ) - #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = vaddq_f32( add, vmulq_f32( mul, vld1q_f32( (float const*)(ptr) ) ) ) - #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = vaddq_f32( add, vmulq_f32( mul, vld1q_dup_f32( (float const*)(ptr) ) ) ) - #endif - - #define stbir__simdf_add1( out, reg0, reg1 ) (out) = vaddq_f32( reg0, reg1 ) - #define stbir__simdf_mult1( out, reg0, reg1 ) (out) = vmulq_f32( reg0, reg1 ) - - #define stbir__simdf_and( out, reg0, reg1 ) (out) = vreinterpretq_f32_u32( vandq_u32( vreinterpretq_u32_f32(reg0), vreinterpretq_u32_f32(reg1) ) ) - #define stbir__simdf_or( out, reg0, reg1 ) (out) = vreinterpretq_f32_u32( vorrq_u32( vreinterpretq_u32_f32(reg0), vreinterpretq_u32_f32(reg1) ) ) - - #define stbir__simdf_min( out, reg0, reg1 ) (out) = vminq_f32( reg0, reg1 ) - #define stbir__simdf_max( out, reg0, reg1 ) (out) = vmaxq_f32( reg0, reg1 ) - #define stbir__simdf_min1( out, reg0, reg1 ) (out) = vminq_f32( reg0, reg1 ) - #define stbir__simdf_max1( out, reg0, reg1 ) (out) = vmaxq_f32( reg0, reg1 ) - - #define stbir__simdf_0123ABCDto3ABx( out, reg0, reg1 ) (out) = vextq_f32( reg0, reg1, 3 ) - #define stbir__simdf_0123ABCDto23Ax( out, reg0, reg1 ) (out) = vextq_f32( reg0, reg1, 2 ) - - #define stbir__simdf_a1a1( out, alp, ones ) (out) = vzipq_f32(vuzpq_f32(alp, alp).val[1], ones).val[0] - #define stbir__simdf_1a1a( out, alp, ones ) (out) = vzipq_f32(ones, vuzpq_f32(alp, alp).val[0]).val[0] - - #if defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ ) - - #define stbir__simdf_aaa1( out, alp, ones ) (out) = vcopyq_laneq_f32(vdupq_n_f32(vgetq_lane_f32(alp, 3)), 3, ones, 3) - #define stbir__simdf_1aaa( out, alp, ones ) (out) = vcopyq_laneq_f32(vdupq_n_f32(vgetq_lane_f32(alp, 0)), 0, ones, 0) - - #if defined( _MSC_VER ) && !defined(__clang__) - #define stbir_make16(a,b,c,d) vcombine_u8( \ - vcreate_u8( (4*a+0) | ((4*a+1)<<8) | ((4*a+2)<<16) | ((4*a+3)<<24) | \ - ((stbir_uint64)(4*b+0)<<32) | ((stbir_uint64)(4*b+1)<<40) | ((stbir_uint64)(4*b+2)<<48) | ((stbir_uint64)(4*b+3)<<56)), \ - vcreate_u8( (4*c+0) | ((4*c+1)<<8) | ((4*c+2)<<16) | ((4*c+3)<<24) | \ - ((stbir_uint64)(4*d+0)<<32) | ((stbir_uint64)(4*d+1)<<40) | ((stbir_uint64)(4*d+2)<<48) | ((stbir_uint64)(4*d+3)<<56) ) ) - - static stbir__inline uint8x16x2_t stbir_make16x2(float32x4_t rega,float32x4_t regb) - { - uint8x16x2_t r = { vreinterpretq_u8_f32(rega), vreinterpretq_u8_f32(regb) }; - return r; - } - #else - #define stbir_make16(a,b,c,d) (uint8x16_t){4*a+0,4*a+1,4*a+2,4*a+3,4*b+0,4*b+1,4*b+2,4*b+3,4*c+0,4*c+1,4*c+2,4*c+3,4*d+0,4*d+1,4*d+2,4*d+3} - #define stbir_make16x2(a,b) (uint8x16x2_t){{vreinterpretq_u8_f32(a),vreinterpretq_u8_f32(b)}} - #endif - - #define stbir__simdf_swiz( reg, one, two, three, four ) vreinterpretq_f32_u8( vqtbl1q_u8( vreinterpretq_u8_f32(reg), stbir_make16(one, two, three, four) ) ) - #define stbir__simdf_swiz2( rega, regb, one, two, three, four ) vreinterpretq_f32_u8( vqtbl2q_u8( stbir_make16x2(rega,regb), stbir_make16(one, two, three, four) ) ) - - #define stbir__simdi_16madd( out, reg0, reg1 ) \ - { \ - int16x8_t r0 = vreinterpretq_s16_u32(reg0); \ - int16x8_t r1 = vreinterpretq_s16_u32(reg1); \ - int32x4_t tmp0 = vmull_s16( vget_low_s16(r0), vget_low_s16(r1) ); \ - int32x4_t tmp1 = vmull_s16( vget_high_s16(r0), vget_high_s16(r1) ); \ - (out) = vreinterpretq_u32_s32( vpaddq_s32(tmp0, tmp1) ); \ - } - - #else - - #define stbir__simdf_aaa1( out, alp, ones ) (out) = vsetq_lane_f32(1.0f, vdupq_n_f32(vgetq_lane_f32(alp, 3)), 3) - #define stbir__simdf_1aaa( out, alp, ones ) (out) = vsetq_lane_f32(1.0f, vdupq_n_f32(vgetq_lane_f32(alp, 0)), 0) - - #if defined( _MSC_VER ) && !defined(__clang__) - static stbir__inline uint8x8x2_t stbir_make8x2(float32x4_t reg) - { - uint8x8x2_t r = { { vget_low_u8(vreinterpretq_u8_f32(reg)), vget_high_u8(vreinterpretq_u8_f32(reg)) } }; - return r; - } - #define stbir_make8(a,b) vcreate_u8( \ - (4*a+0) | ((4*a+1)<<8) | ((4*a+2)<<16) | ((4*a+3)<<24) | \ - ((stbir_uint64)(4*b+0)<<32) | ((stbir_uint64)(4*b+1)<<40) | ((stbir_uint64)(4*b+2)<<48) | ((stbir_uint64)(4*b+3)<<56) ) - #else - #define stbir_make8x2(reg) (uint8x8x2_t){ { vget_low_u8(vreinterpretq_u8_f32(reg)), vget_high_u8(vreinterpretq_u8_f32(reg)) } } - #define stbir_make8(a,b) (uint8x8_t){4*a+0,4*a+1,4*a+2,4*a+3,4*b+0,4*b+1,4*b+2,4*b+3} - #endif - - #define stbir__simdf_swiz( reg, one, two, three, four ) vreinterpretq_f32_u8( vcombine_u8( \ - vtbl2_u8( stbir_make8x2( reg ), stbir_make8( one, two ) ), \ - vtbl2_u8( stbir_make8x2( reg ), stbir_make8( three, four ) ) ) ) - - #define stbir__simdi_16madd( out, reg0, reg1 ) \ - { \ - int16x8_t r0 = vreinterpretq_s16_u32(reg0); \ - int16x8_t r1 = vreinterpretq_s16_u32(reg1); \ - int32x4_t tmp0 = vmull_s16( vget_low_s16(r0), vget_low_s16(r1) ); \ - int32x4_t tmp1 = vmull_s16( vget_high_s16(r0), vget_high_s16(r1) ); \ - int32x2_t out0 = vpadd_s32( vget_low_s32(tmp0), vget_high_s32(tmp0) ); \ - int32x2_t out1 = vpadd_s32( vget_low_s32(tmp1), vget_high_s32(tmp1) ); \ - (out) = vreinterpretq_u32_s32( vcombine_s32(out0, out1) ); \ - } - - #endif - - #define stbir__simdi_and( out, reg0, reg1 ) (out) = vandq_u32( reg0, reg1 ) - #define stbir__simdi_or( out, reg0, reg1 ) (out) = vorrq_u32( reg0, reg1 ) - - #define stbir__simdf_pack_to_8bytes(out,aa,bb) \ - { \ - float32x4_t af = vmaxq_f32( vminq_f32(aa,STBIR__CONSTF(STBIR_max_uint8_as_float) ), vdupq_n_f32(0) ); \ - float32x4_t bf = vmaxq_f32( vminq_f32(bb,STBIR__CONSTF(STBIR_max_uint8_as_float) ), vdupq_n_f32(0) ); \ - int16x4_t ai = vqmovn_s32( vcvtq_s32_f32( af ) ); \ - int16x4_t bi = vqmovn_s32( vcvtq_s32_f32( bf ) ); \ - uint8x8_t out8 = vqmovun_s16( vcombine_s16(ai, bi) ); \ - out = vreinterpretq_u32_u8( vcombine_u8(out8, out8) ); \ - } - - #define stbir__simdf_pack_to_8words(out,aa,bb) \ - { \ - float32x4_t af = vmaxq_f32( vminq_f32(aa,STBIR__CONSTF(STBIR_max_uint16_as_float) ), vdupq_n_f32(0) ); \ - float32x4_t bf = vmaxq_f32( vminq_f32(bb,STBIR__CONSTF(STBIR_max_uint16_as_float) ), vdupq_n_f32(0) ); \ - int32x4_t ai = vcvtq_s32_f32( af ); \ - int32x4_t bi = vcvtq_s32_f32( bf ); \ - out = vreinterpretq_u32_u16( vcombine_u16(vqmovun_s32(ai), vqmovun_s32(bi)) ); \ - } - - #define stbir__interleave_pack_and_store_16_u8( ptr, r0, r1, r2, r3 ) \ - { \ - int16x4x2_t tmp0 = vzip_s16( vqmovn_s32(vreinterpretq_s32_u32(r0)), vqmovn_s32(vreinterpretq_s32_u32(r2)) ); \ - int16x4x2_t tmp1 = vzip_s16( vqmovn_s32(vreinterpretq_s32_u32(r1)), vqmovn_s32(vreinterpretq_s32_u32(r3)) ); \ - uint8x8x2_t out = \ - { { \ - vqmovun_s16( vcombine_s16(tmp0.val[0], tmp0.val[1]) ), \ - vqmovun_s16( vcombine_s16(tmp1.val[0], tmp1.val[1]) ), \ - } }; \ - vst2_u8(ptr, out); \ - } - - #define stbir__simdf_load4_transposed( o0, o1, o2, o3, ptr ) \ - { \ - float32x4x4_t tmp = vld4q_f32(ptr); \ - o0 = tmp.val[0]; \ - o1 = tmp.val[1]; \ - o2 = tmp.val[2]; \ - o3 = tmp.val[3]; \ - } - - #define stbir__simdi_32shr( out, reg, imm ) out = vshrq_n_u32( reg, imm ) - - #if defined( _MSC_VER ) && !defined(__clang__) - #define STBIR__SIMDF_CONST(var, x) __declspec(align(8)) float var[] = { x, x, x, x } - #define STBIR__SIMDI_CONST(var, x) __declspec(align(8)) uint32_t var[] = { x, x, x, x } - #define STBIR__CONSTF(var) (*(const float32x4_t*)var) - #define STBIR__CONSTI(var) (*(const uint32x4_t*)var) - #else - #define STBIR__SIMDF_CONST(var, x) stbir__simdf var = { x, x, x, x } - #define STBIR__SIMDI_CONST(var, x) stbir__simdi var = { x, x, x, x } - #define STBIR__CONSTF(var) (var) - #define STBIR__CONSTI(var) (var) - #endif - - #ifdef STBIR_FLOORF - #undef STBIR_FLOORF - #endif - #define STBIR_FLOORF stbir_simd_floorf - static stbir__inline float stbir_simd_floorf(float x) - { - #if defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ ) - return vget_lane_f32( vrndm_f32( vdup_n_f32(x) ), 0); - #else - float32x2_t f = vdup_n_f32(x); - float32x2_t t = vcvt_f32_s32(vcvt_s32_f32(f)); - uint32x2_t a = vclt_f32(f, t); - uint32x2_t b = vreinterpret_u32_f32(vdup_n_f32(-1.0f)); - float32x2_t r = vadd_f32(t, vreinterpret_f32_u32(vand_u32(a, b))); - return vget_lane_f32(r, 0); - #endif - } - - #ifdef STBIR_CEILF - #undef STBIR_CEILF - #endif - #define STBIR_CEILF stbir_simd_ceilf - static stbir__inline float stbir_simd_ceilf(float x) - { - #if defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ ) - return vget_lane_f32( vrndp_f32( vdup_n_f32(x) ), 0); - #else - float32x2_t f = vdup_n_f32(x); - float32x2_t t = vcvt_f32_s32(vcvt_s32_f32(f)); - uint32x2_t a = vclt_f32(t, f); - uint32x2_t b = vreinterpret_u32_f32(vdup_n_f32(1.0f)); - float32x2_t r = vadd_f32(t, vreinterpret_f32_u32(vand_u32(a, b))); - return vget_lane_f32(r, 0); - #endif - } - - #define STBIR_SIMD - -#elif defined(STBIR_WASM) - - #include - - #define stbir__simdf v128_t - #define stbir__simdi v128_t - - #define stbir_simdi_castf( reg ) (reg) - #define stbir_simdf_casti( reg ) (reg) - - #define stbir__simdf_load( reg, ptr ) (reg) = wasm_v128_load( (void const*)(ptr) ) - #define stbir__simdi_load( reg, ptr ) (reg) = wasm_v128_load( (void const*)(ptr) ) - #define stbir__simdf_load1( out, ptr ) (out) = wasm_v128_load32_splat( (void const*)(ptr) ) // top values can be random (not denormal or nan for perf) - #define stbir__simdi_load1( out, ptr ) (out) = wasm_v128_load32_splat( (void const*)(ptr) ) - #define stbir__simdf_load1z( out, ptr ) (out) = wasm_v128_load32_zero( (void const*)(ptr) ) // top values must be zero - #define stbir__simdf_frep4( fvar ) wasm_f32x4_splat( fvar ) - #define stbir__simdf_load1frep4( out, fvar ) (out) = wasm_f32x4_splat( fvar ) - #define stbir__simdf_load2( out, ptr ) (out) = wasm_v128_load64_splat( (void const*)(ptr) ) // top values can be random (not denormal or nan for perf) - #define stbir__simdf_load2z( out, ptr ) (out) = wasm_v128_load64_zero( (void const*)(ptr) ) // top values must be zero - #define stbir__simdf_load2hmerge( out, reg, ptr ) (out) = wasm_v128_load64_lane( (void const*)(ptr), reg, 1 ) - - #define stbir__simdf_zeroP() wasm_f32x4_const_splat(0) - #define stbir__simdf_zero( reg ) (reg) = wasm_f32x4_const_splat(0) - - #define stbir__simdf_store( ptr, reg ) wasm_v128_store( (void*)(ptr), reg ) - #define stbir__simdf_store1( ptr, reg ) wasm_v128_store32_lane( (void*)(ptr), reg, 0 ) - #define stbir__simdf_store2( ptr, reg ) wasm_v128_store64_lane( (void*)(ptr), reg, 0 ) - #define stbir__simdf_store2h( ptr, reg ) wasm_v128_store64_lane( (void*)(ptr), reg, 1 ) - - #define stbir__simdi_store( ptr, reg ) wasm_v128_store( (void*)(ptr), reg ) - #define stbir__simdi_store1( ptr, reg ) wasm_v128_store32_lane( (void*)(ptr), reg, 0 ) - #define stbir__simdi_store2( ptr, reg ) wasm_v128_store64_lane( (void*)(ptr), reg, 0 ) - - #define stbir__prefetch( ptr ) - - #define stbir__simdi_expand_u8_to_u32(out0,out1,out2,out3,ireg) \ - { \ - v128_t l = wasm_u16x8_extend_low_u8x16 ( ireg ); \ - v128_t h = wasm_u16x8_extend_high_u8x16( ireg ); \ - out0 = wasm_u32x4_extend_low_u16x8 ( l ); \ - out1 = wasm_u32x4_extend_high_u16x8( l ); \ - out2 = wasm_u32x4_extend_low_u16x8 ( h ); \ - out3 = wasm_u32x4_extend_high_u16x8( h ); \ - } - - #define stbir__simdi_expand_u8_to_1u32(out,ireg) \ - { \ - v128_t tmp = wasm_u16x8_extend_low_u8x16(ireg); \ - out = wasm_u32x4_extend_low_u16x8(tmp); \ - } - - #define stbir__simdi_expand_u16_to_u32(out0,out1,ireg) \ - { \ - out0 = wasm_u32x4_extend_low_u16x8 ( ireg ); \ - out1 = wasm_u32x4_extend_high_u16x8( ireg ); \ - } - - #define stbir__simdf_convert_float_to_i32( i, f ) (i) = wasm_i32x4_trunc_sat_f32x4(f) - #define stbir__simdf_convert_float_to_int( f ) wasm_i32x4_extract_lane(wasm_i32x4_trunc_sat_f32x4(f), 0) - #define stbir__simdi_to_int( i ) wasm_i32x4_extract_lane(i, 0) - #define stbir__simdf_convert_float_to_uint8( f ) ((unsigned char)wasm_i32x4_extract_lane(wasm_i32x4_trunc_sat_f32x4(wasm_f32x4_max(wasm_f32x4_min(f,STBIR_max_uint8_as_float),wasm_f32x4_const_splat(0))), 0)) - #define stbir__simdf_convert_float_to_short( f ) ((unsigned short)wasm_i32x4_extract_lane(wasm_i32x4_trunc_sat_f32x4(wasm_f32x4_max(wasm_f32x4_min(f,STBIR_max_uint16_as_float),wasm_f32x4_const_splat(0))), 0)) - #define stbir__simdi_convert_i32_to_float(out, ireg) (out) = wasm_f32x4_convert_i32x4(ireg) - #define stbir__simdf_add( out, reg0, reg1 ) (out) = wasm_f32x4_add( reg0, reg1 ) - #define stbir__simdf_mult( out, reg0, reg1 ) (out) = wasm_f32x4_mul( reg0, reg1 ) - #define stbir__simdf_mult_mem( out, reg, ptr ) (out) = wasm_f32x4_mul( reg, wasm_v128_load( (void const*)(ptr) ) ) - #define stbir__simdf_mult1_mem( out, reg, ptr ) (out) = wasm_f32x4_mul( reg, wasm_v128_load32_splat( (void const*)(ptr) ) ) - #define stbir__simdf_add_mem( out, reg, ptr ) (out) = wasm_f32x4_add( reg, wasm_v128_load( (void const*)(ptr) ) ) - #define stbir__simdf_add1_mem( out, reg, ptr ) (out) = wasm_f32x4_add( reg, wasm_v128_load32_splat( (void const*)(ptr) ) ) - - #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = wasm_f32x4_add( add, wasm_f32x4_mul( mul1, mul2 ) ) - #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = wasm_f32x4_add( add, wasm_f32x4_mul( mul1, mul2 ) ) - #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = wasm_f32x4_add( add, wasm_f32x4_mul( mul, wasm_v128_load( (void const*)(ptr) ) ) ) - #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = wasm_f32x4_add( add, wasm_f32x4_mul( mul, wasm_v128_load32_splat( (void const*)(ptr) ) ) ) - - #define stbir__simdf_add1( out, reg0, reg1 ) (out) = wasm_f32x4_add( reg0, reg1 ) - #define stbir__simdf_mult1( out, reg0, reg1 ) (out) = wasm_f32x4_mul( reg0, reg1 ) - - #define stbir__simdf_and( out, reg0, reg1 ) (out) = wasm_v128_and( reg0, reg1 ) - #define stbir__simdf_or( out, reg0, reg1 ) (out) = wasm_v128_or( reg0, reg1 ) - - #define stbir__simdf_min( out, reg0, reg1 ) (out) = wasm_f32x4_min( reg0, reg1 ) - #define stbir__simdf_max( out, reg0, reg1 ) (out) = wasm_f32x4_max( reg0, reg1 ) - #define stbir__simdf_min1( out, reg0, reg1 ) (out) = wasm_f32x4_min( reg0, reg1 ) - #define stbir__simdf_max1( out, reg0, reg1 ) (out) = wasm_f32x4_max( reg0, reg1 ) - - #define stbir__simdf_0123ABCDto3ABx( out, reg0, reg1 ) (out) = wasm_i32x4_shuffle( reg0, reg1, 3, 4, 5, -1 ) - #define stbir__simdf_0123ABCDto23Ax( out, reg0, reg1 ) (out) = wasm_i32x4_shuffle( reg0, reg1, 2, 3, 4, -1 ) - - #define stbir__simdf_aaa1(out,alp,ones) (out) = wasm_i32x4_shuffle(alp, ones, 3, 3, 3, 4) - #define stbir__simdf_1aaa(out,alp,ones) (out) = wasm_i32x4_shuffle(alp, ones, 4, 0, 0, 0) - #define stbir__simdf_a1a1(out,alp,ones) (out) = wasm_i32x4_shuffle(alp, ones, 1, 4, 3, 4) - #define stbir__simdf_1a1a(out,alp,ones) (out) = wasm_i32x4_shuffle(alp, ones, 4, 0, 4, 2) - - #define stbir__simdf_swiz( reg, one, two, three, four ) wasm_i32x4_shuffle(reg, reg, one, two, three, four) - - #define stbir__simdi_and( out, reg0, reg1 ) (out) = wasm_v128_and( reg0, reg1 ) - #define stbir__simdi_or( out, reg0, reg1 ) (out) = wasm_v128_or( reg0, reg1 ) - #define stbir__simdi_16madd( out, reg0, reg1 ) (out) = wasm_i32x4_dot_i16x8( reg0, reg1 ) - - #define stbir__simdf_pack_to_8bytes(out,aa,bb) \ - { \ - v128_t af = wasm_f32x4_max( wasm_f32x4_min(aa, STBIR_max_uint8_as_float), wasm_f32x4_const_splat(0) ); \ - v128_t bf = wasm_f32x4_max( wasm_f32x4_min(bb, STBIR_max_uint8_as_float), wasm_f32x4_const_splat(0) ); \ - v128_t ai = wasm_i32x4_trunc_sat_f32x4( af ); \ - v128_t bi = wasm_i32x4_trunc_sat_f32x4( bf ); \ - v128_t out16 = wasm_i16x8_narrow_i32x4( ai, bi ); \ - out = wasm_u8x16_narrow_i16x8( out16, out16 ); \ - } - - #define stbir__simdf_pack_to_8words(out,aa,bb) \ - { \ - v128_t af = wasm_f32x4_max( wasm_f32x4_min(aa, STBIR_max_uint16_as_float), wasm_f32x4_const_splat(0)); \ - v128_t bf = wasm_f32x4_max( wasm_f32x4_min(bb, STBIR_max_uint16_as_float), wasm_f32x4_const_splat(0)); \ - v128_t ai = wasm_i32x4_trunc_sat_f32x4( af ); \ - v128_t bi = wasm_i32x4_trunc_sat_f32x4( bf ); \ - out = wasm_u16x8_narrow_i32x4( ai, bi ); \ - } - - #define stbir__interleave_pack_and_store_16_u8( ptr, r0, r1, r2, r3 ) \ - { \ - v128_t tmp0 = wasm_i16x8_narrow_i32x4(r0, r1); \ - v128_t tmp1 = wasm_i16x8_narrow_i32x4(r2, r3); \ - v128_t tmp = wasm_u8x16_narrow_i16x8(tmp0, tmp1); \ - tmp = wasm_i8x16_shuffle(tmp, tmp, 0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15); \ - wasm_v128_store( (void*)(ptr), tmp); \ - } - - #define stbir__simdf_load4_transposed( o0, o1, o2, o3, ptr ) \ - { \ - v128_t t0 = wasm_v128_load( ptr ); \ - v128_t t1 = wasm_v128_load( ptr+4 ); \ - v128_t t2 = wasm_v128_load( ptr+8 ); \ - v128_t t3 = wasm_v128_load( ptr+12 ); \ - v128_t s0 = wasm_i32x4_shuffle(t0, t1, 0, 4, 2, 6); \ - v128_t s1 = wasm_i32x4_shuffle(t0, t1, 1, 5, 3, 7); \ - v128_t s2 = wasm_i32x4_shuffle(t2, t3, 0, 4, 2, 6); \ - v128_t s3 = wasm_i32x4_shuffle(t2, t3, 1, 5, 3, 7); \ - o0 = wasm_i32x4_shuffle(s0, s2, 0, 1, 4, 5); \ - o1 = wasm_i32x4_shuffle(s1, s3, 0, 1, 4, 5); \ - o2 = wasm_i32x4_shuffle(s0, s2, 2, 3, 6, 7); \ - o3 = wasm_i32x4_shuffle(s1, s3, 2, 3, 6, 7); \ - } - - #define stbir__simdi_32shr( out, reg, imm ) out = wasm_u32x4_shr( reg, imm ) - - typedef float stbir__f32x4 __attribute__((__vector_size__(16), __aligned__(16))); - #define STBIR__SIMDF_CONST(var, x) stbir__simdf var = (v128_t)(stbir__f32x4){ x, x, x, x } - #define STBIR__SIMDI_CONST(var, x) stbir__simdi var = { x, x, x, x } - #define STBIR__CONSTF(var) (var) - #define STBIR__CONSTI(var) (var) - - #ifdef STBIR_FLOORF - #undef STBIR_FLOORF - #endif - #define STBIR_FLOORF stbir_simd_floorf - static stbir__inline float stbir_simd_floorf(float x) - { - return wasm_f32x4_extract_lane( wasm_f32x4_floor( wasm_f32x4_splat(x) ), 0); - } - - #ifdef STBIR_CEILF - #undef STBIR_CEILF - #endif - #define STBIR_CEILF stbir_simd_ceilf - static stbir__inline float stbir_simd_ceilf(float x) - { - return wasm_f32x4_extract_lane( wasm_f32x4_ceil( wasm_f32x4_splat(x) ), 0); - } - - #define STBIR_SIMD - -#endif // SSE2/NEON/WASM - -#endif // NO SIMD - -#ifdef STBIR_SIMD8 - #define stbir__simdfX stbir__simdf8 - #define stbir__simdiX stbir__simdi8 - #define stbir__simdfX_load stbir__simdf8_load - #define stbir__simdiX_load stbir__simdi8_load - #define stbir__simdfX_mult stbir__simdf8_mult - #define stbir__simdfX_add_mem stbir__simdf8_add_mem - #define stbir__simdfX_madd_mem stbir__simdf8_madd_mem - #define stbir__simdfX_store stbir__simdf8_store - #define stbir__simdiX_store stbir__simdi8_store - #define stbir__simdf_frepX stbir__simdf8_frep8 - #define stbir__simdfX_madd stbir__simdf8_madd - #define stbir__simdfX_min stbir__simdf8_min - #define stbir__simdfX_max stbir__simdf8_max - #define stbir__simdfX_aaa1 stbir__simdf8_aaa1 - #define stbir__simdfX_1aaa stbir__simdf8_1aaa - #define stbir__simdfX_a1a1 stbir__simdf8_a1a1 - #define stbir__simdfX_1a1a stbir__simdf8_1a1a - #define stbir__simdfX_convert_float_to_i32 stbir__simdf8_convert_float_to_i32 - #define stbir__simdfX_pack_to_words stbir__simdf8_pack_to_16words - #define stbir__simdfX_zero stbir__simdf8_zero - #define STBIR_onesX STBIR_ones8 - #define STBIR_max_uint8_as_floatX STBIR_max_uint8_as_float8 - #define STBIR_max_uint16_as_floatX STBIR_max_uint16_as_float8 - #define STBIR_simd_point5X STBIR_simd_point58 - #define stbir__simdfX_float_count 8 - #define stbir__simdfX_0123to1230 stbir__simdf8_0123to12301230 - #define stbir__simdfX_0123to2103 stbir__simdf8_0123to21032103 - static const stbir__simdf8 STBIR_max_uint16_as_float_inverted8 = { stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted }; - static const stbir__simdf8 STBIR_max_uint8_as_float_inverted8 = { stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted }; - static const stbir__simdf8 STBIR_ones8 = { 1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0 }; - static const stbir__simdf8 STBIR_simd_point58 = { 0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 }; - static const stbir__simdf8 STBIR_max_uint8_as_float8 = { stbir__max_uint8_as_float,stbir__max_uint8_as_float,stbir__max_uint8_as_float,stbir__max_uint8_as_float, stbir__max_uint8_as_float,stbir__max_uint8_as_float,stbir__max_uint8_as_float,stbir__max_uint8_as_float }; - static const stbir__simdf8 STBIR_max_uint16_as_float8 = { stbir__max_uint16_as_float,stbir__max_uint16_as_float,stbir__max_uint16_as_float,stbir__max_uint16_as_float, stbir__max_uint16_as_float,stbir__max_uint16_as_float,stbir__max_uint16_as_float,stbir__max_uint16_as_float }; -#else - #define stbir__simdfX stbir__simdf - #define stbir__simdiX stbir__simdi - #define stbir__simdfX_load stbir__simdf_load - #define stbir__simdiX_load stbir__simdi_load - #define stbir__simdfX_mult stbir__simdf_mult - #define stbir__simdfX_add_mem stbir__simdf_add_mem - #define stbir__simdfX_madd_mem stbir__simdf_madd_mem - #define stbir__simdfX_store stbir__simdf_store - #define stbir__simdiX_store stbir__simdi_store - #define stbir__simdf_frepX stbir__simdf_frep4 - #define stbir__simdfX_madd stbir__simdf_madd - #define stbir__simdfX_min stbir__simdf_min - #define stbir__simdfX_max stbir__simdf_max - #define stbir__simdfX_aaa1 stbir__simdf_aaa1 - #define stbir__simdfX_1aaa stbir__simdf_1aaa - #define stbir__simdfX_a1a1 stbir__simdf_a1a1 - #define stbir__simdfX_1a1a stbir__simdf_1a1a - #define stbir__simdfX_convert_float_to_i32 stbir__simdf_convert_float_to_i32 - #define stbir__simdfX_pack_to_words stbir__simdf_pack_to_8words - #define stbir__simdfX_zero stbir__simdf_zero - #define STBIR_onesX STBIR__CONSTF(STBIR_ones) - #define STBIR_simd_point5X STBIR__CONSTF(STBIR_simd_point5) - #define STBIR_max_uint8_as_floatX STBIR__CONSTF(STBIR_max_uint8_as_float) - #define STBIR_max_uint16_as_floatX STBIR__CONSTF(STBIR_max_uint16_as_float) - #define stbir__simdfX_float_count 4 - #define stbir__if_simdf8_cast_to_simdf4( val ) ( val ) - #define stbir__simdfX_0123to1230 stbir__simdf_0123to1230 - #define stbir__simdfX_0123to2103 stbir__simdf_0123to2103 -#endif - - -#if defined(STBIR_NEON) && !defined(_M_ARM) && !defined(__arm__) - - #if defined( _MSC_VER ) && !defined(__clang__) - typedef __int16 stbir__FP16; - #else - typedef float16_t stbir__FP16; - #endif - -#else // no NEON, or 32-bit ARM for MSVC - - typedef union stbir__FP16 - { - unsigned short u; - } stbir__FP16; - -#endif - -#if (!defined(STBIR_NEON) && !defined(STBIR_FP16C)) || (defined(STBIR_NEON) && defined(_M_ARM)) || (defined(STBIR_NEON) && defined(__arm__)) - - // Fabian's half float routines, see: https://gist.github.com/rygorous/2156668 - - static stbir__inline float stbir__half_to_float( stbir__FP16 h ) - { - static const stbir__FP32 magic = { (254 - 15) << 23 }; - static const stbir__FP32 was_infnan = { (127 + 16) << 23 }; - stbir__FP32 o; - - o.u = (h.u & 0x7fff) << 13; // exponent/mantissa bits - o.f *= magic.f; // exponent adjust - if (o.f >= was_infnan.f) // make sure Inf/NaN survive - o.u |= 255 << 23; - o.u |= (h.u & 0x8000) << 16; // sign bit - return o.f; - } - - static stbir__inline stbir__FP16 stbir__float_to_half(float val) - { - stbir__FP32 f32infty = { 255 << 23 }; - stbir__FP32 f16max = { (127 + 16) << 23 }; - stbir__FP32 denorm_magic = { ((127 - 15) + (23 - 10) + 1) << 23 }; - unsigned int sign_mask = 0x80000000u; - stbir__FP16 o = { 0 }; - stbir__FP32 f; - unsigned int sign; - - f.f = val; - sign = f.u & sign_mask; - f.u ^= sign; - - if (f.u >= f16max.u) // result is Inf or NaN (all exponent bits set) - o.u = (f.u > f32infty.u) ? 0x7e00 : 0x7c00; // NaN->qNaN and Inf->Inf - else // (De)normalized number or zero - { - if (f.u < (113 << 23)) // resulting FP16 is subnormal or zero - { - // use a magic value to align our 10 mantissa bits at the bottom of - // the float. as long as FP addition is round-to-nearest-even this - // just works. - f.f += denorm_magic.f; - // and one integer subtract of the bias later, we have our final float! - o.u = (unsigned short) ( f.u - denorm_magic.u ); - } - else - { - unsigned int mant_odd = (f.u >> 13) & 1; // resulting mantissa is odd - // update exponent, rounding bias part 1 - f.u = f.u + ((15u - 127) << 23) + 0xfff; - // rounding bias part 2 - f.u += mant_odd; - // take the bits! - o.u = (unsigned short) ( f.u >> 13 ); - } - } - - o.u |= sign >> 16; - return o; - } - -#endif - - -#if defined(STBIR_FP16C) - - #include - - static stbir__inline void stbir__half_to_float_SIMD(float * output, stbir__FP16 const * input) - { - _mm256_storeu_ps( (float*)output, _mm256_cvtph_ps( _mm_loadu_si128( (__m128i const* )input ) ) ); - } - - static stbir__inline void stbir__float_to_half_SIMD(stbir__FP16 * output, float const * input) - { - _mm_storeu_si128( (__m128i*)output, _mm256_cvtps_ph( _mm256_loadu_ps( input ), 0 ) ); - } - - static stbir__inline float stbir__half_to_float( stbir__FP16 h ) - { - return _mm_cvtss_f32( _mm_cvtph_ps( _mm_cvtsi32_si128( (int)h.u ) ) ); - } - - static stbir__inline stbir__FP16 stbir__float_to_half( float f ) - { - stbir__FP16 h; - h.u = (unsigned short) _mm_cvtsi128_si32( _mm_cvtps_ph( _mm_set_ss( f ), 0 ) ); - return h; - } - -#elif defined(STBIR_SSE2) - - // Fabian's half float routines, see: https://gist.github.com/rygorous/2156668 - stbir__inline static void stbir__half_to_float_SIMD(float * output, void const * input) - { - static const STBIR__SIMDI_CONST(mask_nosign, 0x7fff); - static const STBIR__SIMDI_CONST(smallest_normal, 0x0400); - static const STBIR__SIMDI_CONST(infinity, 0x7c00); - static const STBIR__SIMDI_CONST(expadjust_normal, (127 - 15) << 23); - static const STBIR__SIMDI_CONST(magic_denorm, 113 << 23); - - __m128i i = _mm_loadu_si128 ( (__m128i const*)(input) ); - __m128i h = _mm_unpacklo_epi16 ( i, _mm_setzero_si128() ); - __m128i mnosign = STBIR__CONSTI(mask_nosign); - __m128i eadjust = STBIR__CONSTI(expadjust_normal); - __m128i smallest = STBIR__CONSTI(smallest_normal); - __m128i infty = STBIR__CONSTI(infinity); - __m128i expmant = _mm_and_si128(mnosign, h); - __m128i justsign = _mm_xor_si128(h, expmant); - __m128i b_notinfnan = _mm_cmpgt_epi32(infty, expmant); - __m128i b_isdenorm = _mm_cmpgt_epi32(smallest, expmant); - __m128i shifted = _mm_slli_epi32(expmant, 13); - __m128i adj_infnan = _mm_andnot_si128(b_notinfnan, eadjust); - __m128i adjusted = _mm_add_epi32(eadjust, shifted); - __m128i den1 = _mm_add_epi32(shifted, STBIR__CONSTI(magic_denorm)); - __m128i adjusted2 = _mm_add_epi32(adjusted, adj_infnan); - __m128 den2 = _mm_sub_ps(_mm_castsi128_ps(den1), *(const __m128 *)&magic_denorm); - __m128 adjusted3 = _mm_and_ps(den2, _mm_castsi128_ps(b_isdenorm)); - __m128 adjusted4 = _mm_andnot_ps(_mm_castsi128_ps(b_isdenorm), _mm_castsi128_ps(adjusted2)); - __m128 adjusted5 = _mm_or_ps(adjusted3, adjusted4); - __m128i sign = _mm_slli_epi32(justsign, 16); - __m128 final = _mm_or_ps(adjusted5, _mm_castsi128_ps(sign)); - stbir__simdf_store( output + 0, final ); - - h = _mm_unpackhi_epi16 ( i, _mm_setzero_si128() ); - expmant = _mm_and_si128(mnosign, h); - justsign = _mm_xor_si128(h, expmant); - b_notinfnan = _mm_cmpgt_epi32(infty, expmant); - b_isdenorm = _mm_cmpgt_epi32(smallest, expmant); - shifted = _mm_slli_epi32(expmant, 13); - adj_infnan = _mm_andnot_si128(b_notinfnan, eadjust); - adjusted = _mm_add_epi32(eadjust, shifted); - den1 = _mm_add_epi32(shifted, STBIR__CONSTI(magic_denorm)); - adjusted2 = _mm_add_epi32(adjusted, adj_infnan); - den2 = _mm_sub_ps(_mm_castsi128_ps(den1), *(const __m128 *)&magic_denorm); - adjusted3 = _mm_and_ps(den2, _mm_castsi128_ps(b_isdenorm)); - adjusted4 = _mm_andnot_ps(_mm_castsi128_ps(b_isdenorm), _mm_castsi128_ps(adjusted2)); - adjusted5 = _mm_or_ps(adjusted3, adjusted4); - sign = _mm_slli_epi32(justsign, 16); - final = _mm_or_ps(adjusted5, _mm_castsi128_ps(sign)); - stbir__simdf_store( output + 4, final ); - - // ~38 SSE2 ops for 8 values - } - - // Fabian's round-to-nearest-even float to half - // ~48 SSE2 ops for 8 output - stbir__inline static void stbir__float_to_half_SIMD(void * output, float const * input) - { - static const STBIR__SIMDI_CONST(mask_sign, 0x80000000u); - static const STBIR__SIMDI_CONST(c_f16max, (127 + 16) << 23); // all FP32 values >=this round to +inf - static const STBIR__SIMDI_CONST(c_nanbit, 0x200); - static const STBIR__SIMDI_CONST(c_infty_as_fp16, 0x7c00); - static const STBIR__SIMDI_CONST(c_min_normal, (127 - 14) << 23); // smallest FP32 that yields a normalized FP16 - static const STBIR__SIMDI_CONST(c_subnorm_magic, ((127 - 15) + (23 - 10) + 1) << 23); - static const STBIR__SIMDI_CONST(c_normal_bias, 0xfff - ((127 - 15) << 23)); // adjust exponent and add mantissa rounding - - __m128 f = _mm_loadu_ps(input); - __m128 msign = _mm_castsi128_ps(STBIR__CONSTI(mask_sign)); - __m128 justsign = _mm_and_ps(msign, f); - __m128 absf = _mm_xor_ps(f, justsign); - __m128i absf_int = _mm_castps_si128(absf); // the cast is "free" (extra bypass latency, but no thruput hit) - __m128i f16max = STBIR__CONSTI(c_f16max); - __m128 b_isnan = _mm_cmpunord_ps(absf, absf); // is this a NaN? - __m128i b_isregular = _mm_cmpgt_epi32(f16max, absf_int); // (sub)normalized or special? - __m128i nanbit = _mm_and_si128(_mm_castps_si128(b_isnan), STBIR__CONSTI(c_nanbit)); - __m128i inf_or_nan = _mm_or_si128(nanbit, STBIR__CONSTI(c_infty_as_fp16)); // output for specials - - __m128i min_normal = STBIR__CONSTI(c_min_normal); - __m128i b_issub = _mm_cmpgt_epi32(min_normal, absf_int); - - // "result is subnormal" path - __m128 subnorm1 = _mm_add_ps(absf, _mm_castsi128_ps(STBIR__CONSTI(c_subnorm_magic))); // magic value to round output mantissa - __m128i subnorm2 = _mm_sub_epi32(_mm_castps_si128(subnorm1), STBIR__CONSTI(c_subnorm_magic)); // subtract out bias - - // "result is normal" path - __m128i mantoddbit = _mm_slli_epi32(absf_int, 31 - 13); // shift bit 13 (mantissa LSB) to sign - __m128i mantodd = _mm_srai_epi32(mantoddbit, 31); // -1 if FP16 mantissa odd, else 0 - - __m128i round1 = _mm_add_epi32(absf_int, STBIR__CONSTI(c_normal_bias)); - __m128i round2 = _mm_sub_epi32(round1, mantodd); // if mantissa LSB odd, bias towards rounding up (RTNE) - __m128i normal = _mm_srli_epi32(round2, 13); // rounded result - - // combine the two non-specials - __m128i nonspecial = _mm_or_si128(_mm_and_si128(subnorm2, b_issub), _mm_andnot_si128(b_issub, normal)); - - // merge in specials as well - __m128i joined = _mm_or_si128(_mm_and_si128(nonspecial, b_isregular), _mm_andnot_si128(b_isregular, inf_or_nan)); - - __m128i sign_shift = _mm_srai_epi32(_mm_castps_si128(justsign), 16); - __m128i final2, final= _mm_or_si128(joined, sign_shift); - - f = _mm_loadu_ps(input+4); - justsign = _mm_and_ps(msign, f); - absf = _mm_xor_ps(f, justsign); - absf_int = _mm_castps_si128(absf); // the cast is "free" (extra bypass latency, but no thruput hit) - b_isnan = _mm_cmpunord_ps(absf, absf); // is this a NaN? - b_isregular = _mm_cmpgt_epi32(f16max, absf_int); // (sub)normalized or special? - nanbit = _mm_and_si128(_mm_castps_si128(b_isnan), c_nanbit); - inf_or_nan = _mm_or_si128(nanbit, STBIR__CONSTI(c_infty_as_fp16)); // output for specials - - b_issub = _mm_cmpgt_epi32(min_normal, absf_int); - - // "result is subnormal" path - subnorm1 = _mm_add_ps(absf, _mm_castsi128_ps(STBIR__CONSTI(c_subnorm_magic))); // magic value to round output mantissa - subnorm2 = _mm_sub_epi32(_mm_castps_si128(subnorm1), STBIR__CONSTI(c_subnorm_magic)); // subtract out bias - - // "result is normal" path - mantoddbit = _mm_slli_epi32(absf_int, 31 - 13); // shift bit 13 (mantissa LSB) to sign - mantodd = _mm_srai_epi32(mantoddbit, 31); // -1 if FP16 mantissa odd, else 0 - - round1 = _mm_add_epi32(absf_int, STBIR__CONSTI(c_normal_bias)); - round2 = _mm_sub_epi32(round1, mantodd); // if mantissa LSB odd, bias towards rounding up (RTNE) - normal = _mm_srli_epi32(round2, 13); // rounded result - - // combine the two non-specials - nonspecial = _mm_or_si128(_mm_and_si128(subnorm2, b_issub), _mm_andnot_si128(b_issub, normal)); - - // merge in specials as well - joined = _mm_or_si128(_mm_and_si128(nonspecial, b_isregular), _mm_andnot_si128(b_isregular, inf_or_nan)); - - sign_shift = _mm_srai_epi32(_mm_castps_si128(justsign), 16); - final2 = _mm_or_si128(joined, sign_shift); - final = _mm_packs_epi32(final, final2); - stbir__simdi_store( output,final ); - } - -#elif defined(STBIR_NEON) && defined(_MSC_VER) && defined(_M_ARM64) && !defined(__clang__) // 64-bit ARM on MSVC (not clang) - - static stbir__inline void stbir__half_to_float_SIMD(float * output, stbir__FP16 const * input) - { - float16x4_t in0 = vld1_f16(input + 0); - float16x4_t in1 = vld1_f16(input + 4); - vst1q_f32(output + 0, vcvt_f32_f16(in0)); - vst1q_f32(output + 4, vcvt_f32_f16(in1)); - } - - static stbir__inline void stbir__float_to_half_SIMD(stbir__FP16 * output, float const * input) - { - float16x4_t out0 = vcvt_f16_f32(vld1q_f32(input + 0)); - float16x4_t out1 = vcvt_f16_f32(vld1q_f32(input + 4)); - vst1_f16(output+0, out0); - vst1_f16(output+4, out1); - } - - static stbir__inline float stbir__half_to_float( stbir__FP16 h ) - { - return vgetq_lane_f32(vcvt_f32_f16(vld1_dup_f16(&h)), 0); - } - - static stbir__inline stbir__FP16 stbir__float_to_half( float f ) - { - return vget_lane_f16(vcvt_f16_f32(vdupq_n_f32(f)), 0).n16_u16[0]; - } - -#elif defined(STBIR_NEON) && ( defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ ) ) // 64-bit ARM - - static stbir__inline void stbir__half_to_float_SIMD(float * output, stbir__FP16 const * input) - { - float16x8_t in = vld1q_f16(input); - vst1q_f32(output + 0, vcvt_f32_f16(vget_low_f16(in))); - vst1q_f32(output + 4, vcvt_f32_f16(vget_high_f16(in))); - } - - static stbir__inline void stbir__float_to_half_SIMD(stbir__FP16 * output, float const * input) - { - float16x4_t out0 = vcvt_f16_f32(vld1q_f32(input + 0)); - float16x4_t out1 = vcvt_f16_f32(vld1q_f32(input + 4)); - vst1q_f16(output, vcombine_f16(out0, out1)); - } - - static stbir__inline float stbir__half_to_float( stbir__FP16 h ) - { - return vgetq_lane_f32(vcvt_f32_f16(vdup_n_f16(h)), 0); - } - - static stbir__inline stbir__FP16 stbir__float_to_half( float f ) - { - return vget_lane_f16(vcvt_f16_f32(vdupq_n_f32(f)), 0); - } - -#elif defined(STBIR_WASM) || (defined(STBIR_NEON) && (defined(_MSC_VER) || defined(_M_ARM) || defined(__arm__))) // WASM or 32-bit ARM on MSVC/clang - - static stbir__inline void stbir__half_to_float_SIMD(float * output, stbir__FP16 const * input) - { - for (int i=0; i<8; i++) - { - output[i] = stbir__half_to_float(input[i]); - } - } - static stbir__inline void stbir__float_to_half_SIMD(stbir__FP16 * output, float const * input) - { - for (int i=0; i<8; i++) - { - output[i] = stbir__float_to_half(input[i]); - } - } - -#endif - - -#ifdef STBIR_SIMD - -#define stbir__simdf_0123to3333( out, reg ) (out) = stbir__simdf_swiz( reg, 3,3,3,3 ) -#define stbir__simdf_0123to2222( out, reg ) (out) = stbir__simdf_swiz( reg, 2,2,2,2 ) -#define stbir__simdf_0123to1111( out, reg ) (out) = stbir__simdf_swiz( reg, 1,1,1,1 ) -#define stbir__simdf_0123to0000( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,0,0 ) -#define stbir__simdf_0123to0003( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,0,3 ) -#define stbir__simdf_0123to0001( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,0,1 ) -#define stbir__simdf_0123to1122( out, reg ) (out) = stbir__simdf_swiz( reg, 1,1,2,2 ) -#define stbir__simdf_0123to2333( out, reg ) (out) = stbir__simdf_swiz( reg, 2,3,3,3 ) -#define stbir__simdf_0123to0023( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,2,3 ) -#define stbir__simdf_0123to1230( out, reg ) (out) = stbir__simdf_swiz( reg, 1,2,3,0 ) -#define stbir__simdf_0123to2103( out, reg ) (out) = stbir__simdf_swiz( reg, 2,1,0,3 ) -#define stbir__simdf_0123to3210( out, reg ) (out) = stbir__simdf_swiz( reg, 3,2,1,0 ) -#define stbir__simdf_0123to2301( out, reg ) (out) = stbir__simdf_swiz( reg, 2,3,0,1 ) -#define stbir__simdf_0123to3012( out, reg ) (out) = stbir__simdf_swiz( reg, 3,0,1,2 ) -#define stbir__simdf_0123to0011( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,1,1 ) -#define stbir__simdf_0123to1100( out, reg ) (out) = stbir__simdf_swiz( reg, 1,1,0,0 ) -#define stbir__simdf_0123to2233( out, reg ) (out) = stbir__simdf_swiz( reg, 2,2,3,3 ) -#define stbir__simdf_0123to1133( out, reg ) (out) = stbir__simdf_swiz( reg, 1,1,3,3 ) -#define stbir__simdf_0123to0022( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,2,2 ) -#define stbir__simdf_0123to1032( out, reg ) (out) = stbir__simdf_swiz( reg, 1,0,3,2 ) - -typedef union stbir__simdi_u32 -{ - stbir_uint32 m128i_u32[4]; - int m128i_i32[4]; - stbir__simdi m128i_i128; -} stbir__simdi_u32; - -static const int STBIR_mask[9] = { 0,0,0,-1,-1,-1,0,0,0 }; - -static const STBIR__SIMDF_CONST(STBIR_max_uint8_as_float, stbir__max_uint8_as_float); -static const STBIR__SIMDF_CONST(STBIR_max_uint16_as_float, stbir__max_uint16_as_float); -static const STBIR__SIMDF_CONST(STBIR_max_uint8_as_float_inverted, stbir__max_uint8_as_float_inverted); -static const STBIR__SIMDF_CONST(STBIR_max_uint16_as_float_inverted, stbir__max_uint16_as_float_inverted); - -static const STBIR__SIMDF_CONST(STBIR_simd_point5, 0.5f); -static const STBIR__SIMDF_CONST(STBIR_ones, 1.0f); -static const STBIR__SIMDI_CONST(STBIR_almost_zero, (127 - 13) << 23); -static const STBIR__SIMDI_CONST(STBIR_almost_one, 0x3f7fffff); -static const STBIR__SIMDI_CONST(STBIR_mastissa_mask, 0xff); -static const STBIR__SIMDI_CONST(STBIR_topscale, 0x02000000); - -// Basically, in simd mode, we unroll the proper amount, and we don't want -// the non-simd remnant loops to be unroll because they only run a few times -// Adding this switch saves about 5K on clang which is Captain Unroll the 3rd. -#define STBIR_SIMD_STREAMOUT_PTR( star ) STBIR_STREAMOUT_PTR( star ) -#define STBIR_SIMD_NO_UNROLL(ptr) STBIR_NO_UNROLL(ptr) -#define STBIR_SIMD_NO_UNROLL_LOOP_START STBIR_NO_UNROLL_LOOP_START -#define STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR STBIR_NO_UNROLL_LOOP_START_INF_FOR - -#ifdef STBIR_MEMCPY -#undef STBIR_MEMCPY -#endif -#define STBIR_MEMCPY stbir_simd_memcpy - -// override normal use of memcpy with much simpler copy (faster and smaller with our sized copies) -static void stbir_simd_memcpy( void * dest, void const * src, size_t bytes ) -{ - char STBIR_SIMD_STREAMOUT_PTR (*) d = (char*) dest; - char STBIR_SIMD_STREAMOUT_PTR( * ) d_end = ((char*) dest) + bytes; - ptrdiff_t ofs_to_src = (char*)src - (char*)dest; - - // check overlaps - STBIR_ASSERT( ( ( d >= ( (char*)src) + bytes ) ) || ( ( d + bytes ) <= (char*)src ) ); - - if ( bytes < (16*stbir__simdfX_float_count) ) - { - if ( bytes < 16 ) - { - if ( bytes ) - { - STBIR_SIMD_NO_UNROLL_LOOP_START - do - { - STBIR_SIMD_NO_UNROLL(d); - d[ 0 ] = d[ ofs_to_src ]; - ++d; - } while ( d < d_end ); - } - } - else - { - stbir__simdf x; - // do one unaligned to get us aligned for the stream out below - stbir__simdf_load( x, ( d + ofs_to_src ) ); - stbir__simdf_store( d, x ); - d = (char*)( ( ( (size_t)d ) + 16 ) & ~15 ); - - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - STBIR_SIMD_NO_UNROLL(d); - - if ( d > ( d_end - 16 ) ) - { - if ( d == d_end ) - return; - d = d_end - 16; - } - - stbir__simdf_load( x, ( d + ofs_to_src ) ); - stbir__simdf_store( d, x ); - d += 16; - } - } - } - else - { - stbir__simdfX x0,x1,x2,x3; - - // do one unaligned to get us aligned for the stream out below - stbir__simdfX_load( x0, ( d + ofs_to_src ) + 0*stbir__simdfX_float_count ); - stbir__simdfX_load( x1, ( d + ofs_to_src ) + 4*stbir__simdfX_float_count ); - stbir__simdfX_load( x2, ( d + ofs_to_src ) + 8*stbir__simdfX_float_count ); - stbir__simdfX_load( x3, ( d + ofs_to_src ) + 12*stbir__simdfX_float_count ); - stbir__simdfX_store( d + 0*stbir__simdfX_float_count, x0 ); - stbir__simdfX_store( d + 4*stbir__simdfX_float_count, x1 ); - stbir__simdfX_store( d + 8*stbir__simdfX_float_count, x2 ); - stbir__simdfX_store( d + 12*stbir__simdfX_float_count, x3 ); - d = (char*)( ( ( (size_t)d ) + (16*stbir__simdfX_float_count) ) & ~((16*stbir__simdfX_float_count)-1) ); - - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - STBIR_SIMD_NO_UNROLL(d); - - if ( d > ( d_end - (16*stbir__simdfX_float_count) ) ) - { - if ( d == d_end ) - return; - d = d_end - (16*stbir__simdfX_float_count); - } - - stbir__simdfX_load( x0, ( d + ofs_to_src ) + 0*stbir__simdfX_float_count ); - stbir__simdfX_load( x1, ( d + ofs_to_src ) + 4*stbir__simdfX_float_count ); - stbir__simdfX_load( x2, ( d + ofs_to_src ) + 8*stbir__simdfX_float_count ); - stbir__simdfX_load( x3, ( d + ofs_to_src ) + 12*stbir__simdfX_float_count ); - stbir__simdfX_store( d + 0*stbir__simdfX_float_count, x0 ); - stbir__simdfX_store( d + 4*stbir__simdfX_float_count, x1 ); - stbir__simdfX_store( d + 8*stbir__simdfX_float_count, x2 ); - stbir__simdfX_store( d + 12*stbir__simdfX_float_count, x3 ); - d += (16*stbir__simdfX_float_count); - } - } -} - -// memcpy that is specically intentionally overlapping (src is smaller then dest, so can be -// a normal forward copy, bytes is divisible by 4 and bytes is greater than or equal to -// the diff between dest and src) -static void stbir_overlapping_memcpy( void * dest, void const * src, size_t bytes ) -{ - char STBIR_SIMD_STREAMOUT_PTR (*) sd = (char*) src; - char STBIR_SIMD_STREAMOUT_PTR( * ) s_end = ((char*) src) + bytes; - ptrdiff_t ofs_to_dest = (char*)dest - (char*)src; - - if ( ofs_to_dest >= 16 ) // is the overlap more than 16 away? - { - char STBIR_SIMD_STREAMOUT_PTR( * ) s_end16 = ((char*) src) + (bytes&~15); - STBIR_SIMD_NO_UNROLL_LOOP_START - do - { - stbir__simdf x; - STBIR_SIMD_NO_UNROLL(sd); - stbir__simdf_load( x, sd ); - stbir__simdf_store( ( sd + ofs_to_dest ), x ); - sd += 16; - } while ( sd < s_end16 ); - - if ( sd == s_end ) - return; - } - - do - { - STBIR_SIMD_NO_UNROLL(sd); - *(int*)( sd + ofs_to_dest ) = *(int*) sd; - sd += 4; - } while ( sd < s_end ); -} - -#else // no SSE2 - -// when in scalar mode, we let unrolling happen, so this macro just does the __restrict -#define STBIR_SIMD_STREAMOUT_PTR( star ) STBIR_STREAMOUT_PTR( star ) -#define STBIR_SIMD_NO_UNROLL(ptr) -#define STBIR_SIMD_NO_UNROLL_LOOP_START -#define STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - -#endif // SSE2 - - -#ifdef STBIR_PROFILE - -#ifndef STBIR_PROFILE_FUNC - -#if defined(_x86_64) || defined( __x86_64__ ) || defined( _M_X64 ) || defined(__x86_64) || defined(__SSE2__) || defined(STBIR_SSE) || defined( _M_IX86_FP ) || defined(__i386) || defined( __i386__ ) || defined( _M_IX86 ) || defined( _X86_ ) - -#ifdef _MSC_VER - - STBIRDEF stbir_uint64 __rdtsc(); - #define STBIR_PROFILE_FUNC() __rdtsc() - -#else // non msvc - - static stbir__inline stbir_uint64 STBIR_PROFILE_FUNC() - { - stbir_uint32 lo, hi; - asm volatile ("rdtsc" : "=a" (lo), "=d" (hi) ); - return ( ( (stbir_uint64) hi ) << 32 ) | ( (stbir_uint64) lo ); - } - -#endif // msvc - -#elif defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ ) || defined(__ARM_NEON__) - -#if defined( _MSC_VER ) && !defined(__clang__) - - #define STBIR_PROFILE_FUNC() _ReadStatusReg(ARM64_CNTVCT) - -#else - - static stbir__inline stbir_uint64 STBIR_PROFILE_FUNC() - { - stbir_uint64 tsc; - asm volatile("mrs %0, cntvct_el0" : "=r" (tsc)); - return tsc; - } - -#endif - -#else // x64, arm - -#error Unknown platform for profiling. - -#endif // x64, arm - -#endif // STBIR_PROFILE_FUNC - -#define STBIR_ONLY_PROFILE_GET_SPLIT_INFO ,stbir__per_split_info * split_info -#define STBIR_ONLY_PROFILE_SET_SPLIT_INFO ,split_info - -#define STBIR_ONLY_PROFILE_BUILD_GET_INFO ,stbir__info * profile_info -#define STBIR_ONLY_PROFILE_BUILD_SET_INFO ,profile_info - -// super light-weight micro profiler -#define STBIR_PROFILE_START_ll( info, wh ) { stbir_uint64 wh##thiszonetime = STBIR_PROFILE_FUNC(); stbir_uint64 * wh##save_parent_excluded_ptr = info->current_zone_excluded_ptr; stbir_uint64 wh##current_zone_excluded = 0; info->current_zone_excluded_ptr = &wh##current_zone_excluded; -#define STBIR_PROFILE_END_ll( info, wh ) wh##thiszonetime = STBIR_PROFILE_FUNC() - wh##thiszonetime; info->profile.named.wh += wh##thiszonetime - wh##current_zone_excluded; *wh##save_parent_excluded_ptr += wh##thiszonetime; info->current_zone_excluded_ptr = wh##save_parent_excluded_ptr; } -#define STBIR_PROFILE_FIRST_START_ll( info, wh ) { int i; info->current_zone_excluded_ptr = &info->profile.named.total; for(i=0;iprofile.array);i++) info->profile.array[i]=0; } STBIR_PROFILE_START_ll( info, wh ); -#define STBIR_PROFILE_CLEAR_EXTRAS_ll( info, num ) { int extra; for(extra=1;extra<(num);extra++) { int i; for(i=0;iprofile.array);i++) (info)[extra].profile.array[i]=0; } } - -// for thread data -#define STBIR_PROFILE_START( wh ) STBIR_PROFILE_START_ll( split_info, wh ) -#define STBIR_PROFILE_END( wh ) STBIR_PROFILE_END_ll( split_info, wh ) -#define STBIR_PROFILE_FIRST_START( wh ) STBIR_PROFILE_FIRST_START_ll( split_info, wh ) -#define STBIR_PROFILE_CLEAR_EXTRAS() STBIR_PROFILE_CLEAR_EXTRAS_ll( split_info, split_count ) - -// for build data -#define STBIR_PROFILE_BUILD_START( wh ) STBIR_PROFILE_START_ll( profile_info, wh ) -#define STBIR_PROFILE_BUILD_END( wh ) STBIR_PROFILE_END_ll( profile_info, wh ) -#define STBIR_PROFILE_BUILD_FIRST_START( wh ) STBIR_PROFILE_FIRST_START_ll( profile_info, wh ) -#define STBIR_PROFILE_BUILD_CLEAR( info ) { int i; for(i=0;iprofile.array);i++) info->profile.array[i]=0; } - -#else // no profile - -#define STBIR_ONLY_PROFILE_GET_SPLIT_INFO -#define STBIR_ONLY_PROFILE_SET_SPLIT_INFO - -#define STBIR_ONLY_PROFILE_BUILD_GET_INFO -#define STBIR_ONLY_PROFILE_BUILD_SET_INFO - -#define STBIR_PROFILE_START( wh ) -#define STBIR_PROFILE_END( wh ) -#define STBIR_PROFILE_FIRST_START( wh ) -#define STBIR_PROFILE_CLEAR_EXTRAS( ) - -#define STBIR_PROFILE_BUILD_START( wh ) -#define STBIR_PROFILE_BUILD_END( wh ) -#define STBIR_PROFILE_BUILD_FIRST_START( wh ) -#define STBIR_PROFILE_BUILD_CLEAR( info ) - -#endif // stbir_profile - -#ifndef STBIR_CEILF -#include -#if _MSC_VER <= 1200 // support VC6 for Sean -#define STBIR_CEILF(x) ((float)ceil((float)(x))) -#define STBIR_FLOORF(x) ((float)floor((float)(x))) -#else -#define STBIR_CEILF(x) ceilf(x) -#define STBIR_FLOORF(x) floorf(x) -#endif -#endif - -#ifndef STBIR_MEMCPY -// For memcpy -#include -#define STBIR_MEMCPY( dest, src, len ) memcpy( dest, src, len ) -#endif - -#ifndef STBIR_SIMD - -// memcpy that is specifically intentionally overlapping (src is smaller then dest, so can be -// a normal forward copy, bytes is divisible by 4 and bytes is greater than or equal to -// the diff between dest and src) -static void stbir_overlapping_memcpy( void * dest, void const * src, size_t bytes ) -{ - char STBIR_SIMD_STREAMOUT_PTR (*) sd = (char*) src; - char STBIR_SIMD_STREAMOUT_PTR( * ) s_end = ((char*) src) + bytes; - ptrdiff_t ofs_to_dest = (char*)dest - (char*)src; - - if ( ofs_to_dest >= 8 ) // is the overlap more than 8 away? - { - char STBIR_SIMD_STREAMOUT_PTR( * ) s_end8 = ((char*) src) + (bytes&~7); - STBIR_NO_UNROLL_LOOP_START - do - { - STBIR_NO_UNROLL(sd); - *(stbir_uint64*)( sd + ofs_to_dest ) = *(stbir_uint64*) sd; - sd += 8; - } while ( sd < s_end8 ); - - if ( sd == s_end ) - return; - } - - STBIR_NO_UNROLL_LOOP_START - do - { - STBIR_NO_UNROLL(sd); - *(int*)( sd + ofs_to_dest ) = *(int*) sd; - sd += 4; - } while ( sd < s_end ); -} - -#endif - -static float stbir__filter_trapezoid(float x, float scale, void * user_data) -{ - float halfscale = scale / 2; - float t = 0.5f + halfscale; - STBIR_ASSERT(scale <= 1); - STBIR__UNUSED(user_data); - - if ( x < 0.0f ) x = -x; - - if (x >= t) - return 0.0f; - else - { - float r = 0.5f - halfscale; - if (x <= r) - return 1.0f; - else - return (t - x) / scale; - } -} - -static float stbir__support_trapezoid(float scale, void * user_data) -{ - STBIR__UNUSED(user_data); - return 0.5f + scale / 2.0f; -} - -static float stbir__filter_triangle(float x, float s, void * user_data) -{ - STBIR__UNUSED(s); - STBIR__UNUSED(user_data); - - if ( x < 0.0f ) x = -x; - - if (x <= 1.0f) - return 1.0f - x; - else - return 0.0f; -} - -static float stbir__filter_point(float x, float s, void * user_data) -{ - STBIR__UNUSED(x); - STBIR__UNUSED(s); - STBIR__UNUSED(user_data); - - return 1.0f; -} - -static float stbir__filter_cubic(float x, float s, void * user_data) -{ - STBIR__UNUSED(s); - STBIR__UNUSED(user_data); - - if ( x < 0.0f ) x = -x; - - if (x < 1.0f) - return (4.0f + x*x*(3.0f*x - 6.0f))/6.0f; - else if (x < 2.0f) - return (8.0f + x*(-12.0f + x*(6.0f - x)))/6.0f; - - return (0.0f); -} - -static float stbir__filter_catmullrom(float x, float s, void * user_data) -{ - STBIR__UNUSED(s); - STBIR__UNUSED(user_data); - - if ( x < 0.0f ) x = -x; - - if (x < 1.0f) - return 1.0f - x*x*(2.5f - 1.5f*x); - else if (x < 2.0f) - return 2.0f - x*(4.0f + x*(0.5f*x - 2.5f)); - - return (0.0f); -} - -static float stbir__filter_mitchell(float x, float s, void * user_data) -{ - STBIR__UNUSED(s); - STBIR__UNUSED(user_data); - - if ( x < 0.0f ) x = -x; - - if (x < 1.0f) - return (16.0f + x*x*(21.0f * x - 36.0f))/18.0f; - else if (x < 2.0f) - return (32.0f + x*(-60.0f + x*(36.0f - 7.0f*x)))/18.0f; - - return (0.0f); -} - -static float stbir__support_zeropoint5(float s, void * user_data) -{ - STBIR__UNUSED(s); - STBIR__UNUSED(user_data); - return 0.5f; -} - -static float stbir__support_one(float s, void * user_data) -{ - STBIR__UNUSED(s); - STBIR__UNUSED(user_data); - return 1; -} - -static float stbir__support_two(float s, void * user_data) -{ - STBIR__UNUSED(s); - STBIR__UNUSED(user_data); - return 2; -} - -// This is the maximum number of input samples that can affect an output sample -// with the given filter from the output pixel's perspective -static int stbir__get_filter_pixel_width(stbir__support_callback * support, float scale, void * user_data) -{ - STBIR_ASSERT(support != 0); - - if ( scale >= ( 1.0f-stbir__small_float ) ) // upscale - return (int)STBIR_CEILF(support(1.0f/scale,user_data) * 2.0f); - else - return (int)STBIR_CEILF(support(scale,user_data) * 2.0f / scale); -} - -// this is how many coefficents per run of the filter (which is different -// from the filter_pixel_width depending on if we are scattering or gathering) -static int stbir__get_coefficient_width(stbir__sampler * samp, int is_gather, void * user_data) -{ - float scale = samp->scale_info.scale; - stbir__support_callback * support = samp->filter_support; - - switch( is_gather ) - { - case 1: - return (int)STBIR_CEILF(support(1.0f / scale, user_data) * 2.0f); - case 2: - return (int)STBIR_CEILF(support(scale, user_data) * 2.0f / scale); - case 0: - return (int)STBIR_CEILF(support(scale, user_data) * 2.0f); - default: - STBIR_ASSERT( (is_gather >= 0 ) && (is_gather <= 2 ) ); - return 0; - } -} - -static int stbir__get_contributors(stbir__sampler * samp, int is_gather) -{ - if (is_gather) - return samp->scale_info.output_sub_size; - else - return (samp->scale_info.input_full_size + samp->filter_pixel_margin * 2); -} - -static int stbir__edge_zero_full( int n, int max ) -{ - STBIR__UNUSED(n); - STBIR__UNUSED(max); - return 0; // NOTREACHED -} - -static int stbir__edge_clamp_full( int n, int max ) -{ - if (n < 0) - return 0; - - if (n >= max) - return max - 1; - - return n; // NOTREACHED -} - -static int stbir__edge_reflect_full( int n, int max ) -{ - if (n < 0) - { - if (n > -max) - return -n; - else - return max - 1; - } - - if (n >= max) - { - int max2 = max * 2; - if (n >= max2) - return 0; - else - return max2 - n - 1; - } - - return n; // NOTREACHED -} - -static int stbir__edge_wrap_full( int n, int max ) -{ - if (n >= 0) - return (n % max); - else - { - int m = (-n) % max; - - if (m != 0) - m = max - m; - - return (m); - } -} - -typedef int stbir__edge_wrap_func( int n, int max ); -static stbir__edge_wrap_func * stbir__edge_wrap_slow[] = -{ - stbir__edge_clamp_full, // STBIR_EDGE_CLAMP - stbir__edge_reflect_full, // STBIR_EDGE_REFLECT - stbir__edge_wrap_full, // STBIR_EDGE_WRAP - stbir__edge_zero_full, // STBIR_EDGE_ZERO -}; - -stbir__inline static int stbir__edge_wrap(stbir_edge edge, int n, int max) -{ - // avoid per-pixel switch - if (n >= 0 && n < max) - return n; - return stbir__edge_wrap_slow[edge]( n, max ); -} - -#define STBIR__MERGE_RUNS_PIXEL_THRESHOLD 16 - -// get information on the extents of a sampler -static void stbir__get_extents( stbir__sampler * samp, stbir__extents * scanline_extents ) -{ - int j, stop; - int left_margin, right_margin; - int min_n = 0x7fffffff, max_n = -0x7fffffff; - int min_left = 0x7fffffff, max_left = -0x7fffffff; - int min_right = 0x7fffffff, max_right = -0x7fffffff; - stbir_edge edge = samp->edge; - stbir__contributors* contributors = samp->contributors; - int output_sub_size = samp->scale_info.output_sub_size; - int input_full_size = samp->scale_info.input_full_size; - int filter_pixel_margin = samp->filter_pixel_margin; - - STBIR_ASSERT( samp->is_gather ); - - stop = output_sub_size; - for (j = 0; j < stop; j++ ) - { - STBIR_ASSERT( contributors[j].n1 >= contributors[j].n0 ); - if ( contributors[j].n0 < min_n ) - { - min_n = contributors[j].n0; - stop = j + filter_pixel_margin; // if we find a new min, only scan another filter width - if ( stop > output_sub_size ) stop = output_sub_size; - } - } - - stop = 0; - for (j = output_sub_size - 1; j >= stop; j-- ) - { - STBIR_ASSERT( contributors[j].n1 >= contributors[j].n0 ); - if ( contributors[j].n1 > max_n ) - { - max_n = contributors[j].n1; - stop = j - filter_pixel_margin; // if we find a new max, only scan another filter width - if (stop<0) stop = 0; - } - } - - STBIR_ASSERT( scanline_extents->conservative.n0 <= min_n ); - STBIR_ASSERT( scanline_extents->conservative.n1 >= max_n ); - - // now calculate how much into the margins we really read - left_margin = 0; - if ( min_n < 0 ) - { - left_margin = -min_n; - min_n = 0; - } - - right_margin = 0; - if ( max_n >= input_full_size ) - { - right_margin = max_n - input_full_size + 1; - max_n = input_full_size - 1; - } - - // index 1 is margin pixel extents (how many pixels we hang over the edge) - scanline_extents->edge_sizes[0] = left_margin; - scanline_extents->edge_sizes[1] = right_margin; - - // index 2 is pixels read from the input - scanline_extents->spans[0].n0 = min_n; - scanline_extents->spans[0].n1 = max_n; - scanline_extents->spans[0].pixel_offset_for_input = min_n; - - // default to no other input range - scanline_extents->spans[1].n0 = 0; - scanline_extents->spans[1].n1 = -1; - scanline_extents->spans[1].pixel_offset_for_input = 0; - - // don't have to do edge calc for zero clamp - if ( edge == STBIR_EDGE_ZERO ) - return; - - // convert margin pixels to the pixels within the input (min and max) - for( j = -left_margin ; j < 0 ; j++ ) - { - int p = stbir__edge_wrap( edge, j, input_full_size ); - if ( p < min_left ) - min_left = p; - if ( p > max_left ) - max_left = p; - } - - for( j = input_full_size ; j < (input_full_size + right_margin) ; j++ ) - { - int p = stbir__edge_wrap( edge, j, input_full_size ); - if ( p < min_right ) - min_right = p; - if ( p > max_right ) - max_right = p; - } - - // merge the left margin pixel region if it connects within 4 pixels of main pixel region - if ( min_left != 0x7fffffff ) - { - if ( ( ( min_left <= min_n ) && ( ( max_left + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= min_n ) ) || - ( ( min_n <= min_left ) && ( ( max_n + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= max_left ) ) ) - { - scanline_extents->spans[0].n0 = min_n = stbir__min( min_n, min_left ); - scanline_extents->spans[0].n1 = max_n = stbir__max( max_n, max_left ); - scanline_extents->spans[0].pixel_offset_for_input = min_n; - left_margin = 0; - } - } - - // merge the right margin pixel region if it connects within 4 pixels of main pixel region - if ( min_right != 0x7fffffff ) - { - if ( ( ( min_right <= min_n ) && ( ( max_right + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= min_n ) ) || - ( ( min_n <= min_right ) && ( ( max_n + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= max_right ) ) ) - { - scanline_extents->spans[0].n0 = min_n = stbir__min( min_n, min_right ); - scanline_extents->spans[0].n1 = max_n = stbir__max( max_n, max_right ); - scanline_extents->spans[0].pixel_offset_for_input = min_n; - right_margin = 0; - } - } - - STBIR_ASSERT( scanline_extents->conservative.n0 <= min_n ); - STBIR_ASSERT( scanline_extents->conservative.n1 >= max_n ); - - // you get two ranges when you have the WRAP edge mode and you are doing just the a piece of the resize - // so you need to get a second run of pixels from the opposite side of the scanline (which you - // wouldn't need except for WRAP) - - - // if we can't merge the min_left range, add it as a second range - if ( ( left_margin ) && ( min_left != 0x7fffffff ) ) - { - stbir__span * newspan = scanline_extents->spans + 1; - STBIR_ASSERT( right_margin == 0 ); - if ( min_left < scanline_extents->spans[0].n0 ) - { - scanline_extents->spans[1].pixel_offset_for_input = scanline_extents->spans[0].n0; - scanline_extents->spans[1].n0 = scanline_extents->spans[0].n0; - scanline_extents->spans[1].n1 = scanline_extents->spans[0].n1; - --newspan; - } - newspan->pixel_offset_for_input = min_left; - newspan->n0 = -left_margin; - newspan->n1 = ( max_left - min_left ) - left_margin; - scanline_extents->edge_sizes[0] = 0; // don't need to copy the left margin, since we are directly decoding into the margin - } - // if we can't merge the min_left range, add it as a second range - else - if ( ( right_margin ) && ( min_right != 0x7fffffff ) ) - { - stbir__span * newspan = scanline_extents->spans + 1; - if ( min_right < scanline_extents->spans[0].n0 ) - { - scanline_extents->spans[1].pixel_offset_for_input = scanline_extents->spans[0].n0; - scanline_extents->spans[1].n0 = scanline_extents->spans[0].n0; - scanline_extents->spans[1].n1 = scanline_extents->spans[0].n1; - --newspan; - } - newspan->pixel_offset_for_input = min_right; - newspan->n0 = scanline_extents->spans[1].n1 + 1; - newspan->n1 = scanline_extents->spans[1].n1 + 1 + ( max_right - min_right ); - scanline_extents->edge_sizes[1] = 0; // don't need to copy the right margin, since we are directly decoding into the margin - } - - // sort the spans into write output order - if ( ( scanline_extents->spans[1].n1 > scanline_extents->spans[1].n0 ) && ( scanline_extents->spans[0].n0 > scanline_extents->spans[1].n0 ) ) - { - stbir__span tspan = scanline_extents->spans[0]; - scanline_extents->spans[0] = scanline_extents->spans[1]; - scanline_extents->spans[1] = tspan; - } -} - -static void stbir__calculate_in_pixel_range( int * first_pixel, int * last_pixel, float out_pixel_center, float out_filter_radius, float inv_scale, float out_shift, int input_size, stbir_edge edge ) -{ - int first, last; - float out_pixel_influence_lowerbound = out_pixel_center - out_filter_radius; - float out_pixel_influence_upperbound = out_pixel_center + out_filter_radius; - - float in_pixel_influence_lowerbound = (out_pixel_influence_lowerbound + out_shift) * inv_scale; - float in_pixel_influence_upperbound = (out_pixel_influence_upperbound + out_shift) * inv_scale; - - first = (int)(STBIR_FLOORF(in_pixel_influence_lowerbound + 0.5f)); - last = (int)(STBIR_FLOORF(in_pixel_influence_upperbound - 0.5f)); - if ( last < first ) last = first; // point sample mode can span a value *right* at 0.5, and cause these to cross - - if ( edge == STBIR_EDGE_WRAP ) - { - if ( first < -input_size ) - first = -input_size; - if ( last >= (input_size*2)) - last = (input_size*2) - 1; - } - - *first_pixel = first; - *last_pixel = last; -} - -static void stbir__calculate_coefficients_for_gather_upsample( float out_filter_radius, stbir__kernel_callback * kernel, stbir__scale_info * scale_info, int num_contributors, stbir__contributors* contributors, float* coefficient_group, int coefficient_width, stbir_edge edge, void * user_data ) -{ - int n, end; - float inv_scale = scale_info->inv_scale; - float out_shift = scale_info->pixel_shift; - int input_size = scale_info->input_full_size; - int numerator = scale_info->scale_numerator; - int polyphase = ( ( scale_info->scale_is_rational ) && ( numerator < num_contributors ) ); - - // Looping through out pixels - end = num_contributors; if ( polyphase ) end = numerator; - for (n = 0; n < end; n++) - { - int i; - int last_non_zero; - float out_pixel_center = (float)n + 0.5f; - float in_center_of_out = (out_pixel_center + out_shift) * inv_scale; - - int in_first_pixel, in_last_pixel; - - stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, out_pixel_center, out_filter_radius, inv_scale, out_shift, input_size, edge ); - - // make sure we never generate a range larger than our precalculated coeff width - // this only happens in point sample mode, but it's a good safe thing to do anyway - if ( ( in_last_pixel - in_first_pixel + 1 ) > coefficient_width ) - in_last_pixel = in_first_pixel + coefficient_width - 1; - - last_non_zero = -1; - for (i = 0; i <= in_last_pixel - in_first_pixel; i++) - { - float in_pixel_center = (float)(i + in_first_pixel) + 0.5f; - float coeff = kernel(in_center_of_out - in_pixel_center, inv_scale, user_data); - - // kill denormals - if ( ( ( coeff < stbir__small_float ) && ( coeff > -stbir__small_float ) ) ) - { - if ( i == 0 ) // if we're at the front, just eat zero contributors - { - STBIR_ASSERT ( ( in_last_pixel - in_first_pixel ) != 0 ); // there should be at least one contrib - ++in_first_pixel; - i--; - continue; - } - coeff = 0; // make sure is fully zero (should keep denormals away) - } - else - last_non_zero = i; - - coefficient_group[i] = coeff; - } - - in_last_pixel = last_non_zero+in_first_pixel; // kills trailing zeros - contributors->n0 = in_first_pixel; - contributors->n1 = in_last_pixel; - - STBIR_ASSERT(contributors->n1 >= contributors->n0); - - ++contributors; - coefficient_group += coefficient_width; - } -} - -static void stbir__insert_coeff( stbir__contributors * contribs, float * coeffs, int new_pixel, float new_coeff, int max_width ) -{ - if ( new_pixel <= contribs->n1 ) // before the end - { - if ( new_pixel < contribs->n0 ) // before the front? - { - if ( ( contribs->n1 - new_pixel + 1 ) <= max_width ) - { - int j, o = contribs->n0 - new_pixel; - for ( j = contribs->n1 - contribs->n0 ; j <= 0 ; j-- ) - coeffs[ j + o ] = coeffs[ j ]; - for ( j = 1 ; j < o ; j-- ) - coeffs[ j ] = coeffs[ 0 ]; - coeffs[ 0 ] = new_coeff; - contribs->n0 = new_pixel; - } - } - else - { - coeffs[ new_pixel - contribs->n0 ] += new_coeff; - } - } - else - { - if ( ( new_pixel - contribs->n0 + 1 ) <= max_width ) - { - int j, e = new_pixel - contribs->n0; - for( j = ( contribs->n1 - contribs->n0 ) + 1 ; j < e ; j++ ) // clear in-betweens coeffs if there are any - coeffs[j] = 0; - - coeffs[ e ] = new_coeff; - contribs->n1 = new_pixel; - } - } -} - -static void stbir__calculate_out_pixel_range( int * first_pixel, int * last_pixel, float in_pixel_center, float in_pixels_radius, float scale, float out_shift, int out_size ) -{ - float in_pixel_influence_lowerbound = in_pixel_center - in_pixels_radius; - float in_pixel_influence_upperbound = in_pixel_center + in_pixels_radius; - float out_pixel_influence_lowerbound = in_pixel_influence_lowerbound * scale - out_shift; - float out_pixel_influence_upperbound = in_pixel_influence_upperbound * scale - out_shift; - int out_first_pixel = (int)(STBIR_FLOORF(out_pixel_influence_lowerbound + 0.5f)); - int out_last_pixel = (int)(STBIR_FLOORF(out_pixel_influence_upperbound - 0.5f)); - - if ( out_first_pixel < 0 ) - out_first_pixel = 0; - if ( out_last_pixel >= out_size ) - out_last_pixel = out_size - 1; - *first_pixel = out_first_pixel; - *last_pixel = out_last_pixel; -} - -static void stbir__calculate_coefficients_for_gather_downsample( int start, int end, float in_pixels_radius, stbir__kernel_callback * kernel, stbir__scale_info * scale_info, int coefficient_width, int num_contributors, stbir__contributors * contributors, float * coefficient_group, void * user_data ) -{ - int in_pixel; - int i; - int first_out_inited = -1; - float scale = scale_info->scale; - float out_shift = scale_info->pixel_shift; - int out_size = scale_info->output_sub_size; - int numerator = scale_info->scale_numerator; - int polyphase = ( ( scale_info->scale_is_rational ) && ( numerator < out_size ) ); - - STBIR__UNUSED(num_contributors); - - // Loop through the input pixels - for (in_pixel = start; in_pixel < end; in_pixel++) - { - float in_pixel_center = (float)in_pixel + 0.5f; - float out_center_of_in = in_pixel_center * scale - out_shift; - int out_first_pixel, out_last_pixel; - - stbir__calculate_out_pixel_range( &out_first_pixel, &out_last_pixel, in_pixel_center, in_pixels_radius, scale, out_shift, out_size ); - - if ( out_first_pixel > out_last_pixel ) - continue; - - // clamp or exit if we are using polyphase filtering, and the limit is up - if ( polyphase ) - { - // when polyphase, you only have to do coeffs up to the numerator count - if ( out_first_pixel == numerator ) - break; - - // don't do any extra work, clamp last pixel at numerator too - if ( out_last_pixel >= numerator ) - out_last_pixel = numerator - 1; - } - - for (i = 0; i <= out_last_pixel - out_first_pixel; i++) - { - float out_pixel_center = (float)(i + out_first_pixel) + 0.5f; - float x = out_pixel_center - out_center_of_in; - float coeff = kernel(x, scale, user_data) * scale; - - // kill the coeff if it's too small (avoid denormals) - if ( ( ( coeff < stbir__small_float ) && ( coeff > -stbir__small_float ) ) ) - coeff = 0.0f; - - { - int out = i + out_first_pixel; - float * coeffs = coefficient_group + out * coefficient_width; - stbir__contributors * contribs = contributors + out; - - // is this the first time this output pixel has been seen? Init it. - if ( out > first_out_inited ) - { - STBIR_ASSERT( out == ( first_out_inited + 1 ) ); // ensure we have only advanced one at time - first_out_inited = out; - contribs->n0 = in_pixel; - contribs->n1 = in_pixel; - coeffs[0] = coeff; - } - else - { - // insert on end (always in order) - if ( coeffs[0] == 0.0f ) // if the first coefficent is zero, then zap it for this coeffs - { - STBIR_ASSERT( ( in_pixel - contribs->n0 ) == 1 ); // ensure that when we zap, we're at the 2nd pos - contribs->n0 = in_pixel; - } - contribs->n1 = in_pixel; - STBIR_ASSERT( ( in_pixel - contribs->n0 ) < coefficient_width ); - coeffs[in_pixel - contribs->n0] = coeff; - } - } - } - } -} - -#ifdef STBIR_RENORMALIZE_IN_FLOAT -#define STBIR_RENORM_TYPE float -#else -#define STBIR_RENORM_TYPE double -#endif - -static void stbir__cleanup_gathered_coefficients( stbir_edge edge, stbir__filter_extent_info* filter_info, stbir__scale_info * scale_info, int num_contributors, stbir__contributors* contributors, float * coefficient_group, int coefficient_width ) -{ - int input_size = scale_info->input_full_size; - int input_last_n1 = input_size - 1; - int n, end; - int lowest = 0x7fffffff; - int highest = -0x7fffffff; - int widest = -1; - int numerator = scale_info->scale_numerator; - int denominator = scale_info->scale_denominator; - int polyphase = ( ( scale_info->scale_is_rational ) && ( numerator < num_contributors ) ); - float * coeffs; - stbir__contributors * contribs; - - // weight all the coeffs for each sample - coeffs = coefficient_group; - contribs = contributors; - end = num_contributors; if ( polyphase ) end = numerator; - for (n = 0; n < end; n++) - { - int i; - STBIR_RENORM_TYPE filter_scale, total_filter = 0; - int e; - - // add all contribs - e = contribs->n1 - contribs->n0; - for( i = 0 ; i <= e ; i++ ) - { - total_filter += (STBIR_RENORM_TYPE) coeffs[i]; - STBIR_ASSERT( ( coeffs[i] >= -2.0f ) && ( coeffs[i] <= 2.0f ) ); // check for wonky weights - } - - // rescale - if ( ( total_filter < stbir__small_float ) && ( total_filter > -stbir__small_float ) ) - { - // all coeffs are extremely small, just zero it - contribs->n1 = contribs->n0; - coeffs[0] = 0.0f; - } - else - { - // if the total isn't 1.0, rescale everything - if ( ( total_filter < (1.0f-stbir__small_float) ) || ( total_filter > (1.0f+stbir__small_float) ) ) - { - filter_scale = ((STBIR_RENORM_TYPE)1.0) / total_filter; - - // scale them all - for (i = 0; i <= e; i++) - coeffs[i] = (float) ( coeffs[i] * filter_scale ); - } - } - ++contribs; - coeffs += coefficient_width; - } - - // if we have a rational for the scale, we can exploit the polyphaseness to not calculate - // most of the coefficients, so we copy them here - if ( polyphase ) - { - stbir__contributors * prev_contribs = contributors; - stbir__contributors * cur_contribs = contributors + numerator; - - for( n = numerator ; n < num_contributors ; n++ ) - { - cur_contribs->n0 = prev_contribs->n0 + denominator; - cur_contribs->n1 = prev_contribs->n1 + denominator; - ++cur_contribs; - ++prev_contribs; - } - stbir_overlapping_memcpy( coefficient_group + numerator * coefficient_width, coefficient_group, ( num_contributors - numerator ) * coefficient_width * sizeof( coeffs[ 0 ] ) ); - } - - coeffs = coefficient_group; - contribs = contributors; - - for (n = 0; n < num_contributors; n++) - { - int i; - - // in zero edge mode, just remove out of bounds contribs completely (since their weights are accounted for now) - if ( edge == STBIR_EDGE_ZERO ) - { - // shrink the right side if necessary - if ( contribs->n1 > input_last_n1 ) - contribs->n1 = input_last_n1; - - // shrink the left side - if ( contribs->n0 < 0 ) - { - int j, left, skips = 0; - - skips = -contribs->n0; - contribs->n0 = 0; - - // now move down the weights - left = contribs->n1 - contribs->n0 + 1; - if ( left > 0 ) - { - for( j = 0 ; j < left ; j++ ) - coeffs[ j ] = coeffs[ j + skips ]; - } - } - } - else if ( ( edge == STBIR_EDGE_CLAMP ) || ( edge == STBIR_EDGE_REFLECT ) ) - { - // for clamp and reflect, calculate the true inbounds position (based on edge type) and just add that to the existing weight - - // right hand side first - if ( contribs->n1 > input_last_n1 ) - { - int start = contribs->n0; - int endi = contribs->n1; - contribs->n1 = input_last_n1; - for( i = input_size; i <= endi; i++ ) - stbir__insert_coeff( contribs, coeffs, stbir__edge_wrap_slow[edge]( i, input_size ), coeffs[i-start], coefficient_width ); - } - - // now check left hand edge - if ( contribs->n0 < 0 ) - { - int save_n0; - float save_n0_coeff; - float * c = coeffs - ( contribs->n0 + 1 ); - - // reinsert the coeffs with it reflected or clamped (insert accumulates, if the coeffs exist) - for( i = -1 ; i > contribs->n0 ; i-- ) - stbir__insert_coeff( contribs, coeffs, stbir__edge_wrap_slow[edge]( i, input_size ), *c--, coefficient_width ); - save_n0 = contribs->n0; - save_n0_coeff = c[0]; // save it, since we didn't do the final one (i==n0), because there might be too many coeffs to hold (before we resize)! - - // now slide all the coeffs down (since we have accumulated them in the positive contribs) and reset the first contrib - contribs->n0 = 0; - for(i = 0 ; i <= contribs->n1 ; i++ ) - coeffs[i] = coeffs[i-save_n0]; - - // now that we have shrunk down the contribs, we insert the first one safely - stbir__insert_coeff( contribs, coeffs, stbir__edge_wrap_slow[edge]( save_n0, input_size ), save_n0_coeff, coefficient_width ); - } - } - - if ( contribs->n0 <= contribs->n1 ) - { - int diff = contribs->n1 - contribs->n0 + 1; - while ( diff && ( coeffs[ diff-1 ] == 0.0f ) ) - --diff; - - contribs->n1 = contribs->n0 + diff - 1; - - if ( contribs->n0 <= contribs->n1 ) - { - if ( contribs->n0 < lowest ) - lowest = contribs->n0; - if ( contribs->n1 > highest ) - highest = contribs->n1; - if ( diff > widest ) - widest = diff; - } - - // re-zero out unused coefficients (if any) - for( i = diff ; i < coefficient_width ; i++ ) - coeffs[i] = 0.0f; - } - - ++contribs; - coeffs += coefficient_width; - } - filter_info->lowest = lowest; - filter_info->highest = highest; - filter_info->widest = widest; -} - -#undef STBIR_RENORM_TYPE - -static int stbir__pack_coefficients( int num_contributors, stbir__contributors* contributors, float * coefficents, int coefficient_width, int widest, int row0, int row1 ) -{ - #define STBIR_MOVE_1( dest, src ) { STBIR_NO_UNROLL(dest); ((stbir_uint32*)(dest))[0] = ((stbir_uint32*)(src))[0]; } - #define STBIR_MOVE_2( dest, src ) { STBIR_NO_UNROLL(dest); ((stbir_uint64*)(dest))[0] = ((stbir_uint64*)(src))[0]; } - #ifdef STBIR_SIMD - #define STBIR_MOVE_4( dest, src ) { stbir__simdf t; STBIR_NO_UNROLL(dest); stbir__simdf_load( t, src ); stbir__simdf_store( dest, t ); } - #else - #define STBIR_MOVE_4( dest, src ) { STBIR_NO_UNROLL(dest); ((stbir_uint64*)(dest))[0] = ((stbir_uint64*)(src))[0]; ((stbir_uint64*)(dest))[1] = ((stbir_uint64*)(src))[1]; } - #endif - - int row_end = row1 + 1; - STBIR__UNUSED( row0 ); // only used in an assert - - if ( coefficient_width != widest ) - { - float * pc = coefficents; - float * coeffs = coefficents; - float * pc_end = coefficents + num_contributors * widest; - switch( widest ) - { - case 1: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_1( pc, coeffs ); - ++pc; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 2: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_2( pc, coeffs ); - pc += 2; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 3: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_2( pc, coeffs ); - STBIR_MOVE_1( pc+2, coeffs+2 ); - pc += 3; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 4: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_4( pc, coeffs ); - pc += 4; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 5: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_4( pc, coeffs ); - STBIR_MOVE_1( pc+4, coeffs+4 ); - pc += 5; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 6: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_4( pc, coeffs ); - STBIR_MOVE_2( pc+4, coeffs+4 ); - pc += 6; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 7: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_4( pc, coeffs ); - STBIR_MOVE_2( pc+4, coeffs+4 ); - STBIR_MOVE_1( pc+6, coeffs+6 ); - pc += 7; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 8: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_4( pc, coeffs ); - STBIR_MOVE_4( pc+4, coeffs+4 ); - pc += 8; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 9: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_4( pc, coeffs ); - STBIR_MOVE_4( pc+4, coeffs+4 ); - STBIR_MOVE_1( pc+8, coeffs+8 ); - pc += 9; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 10: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_4( pc, coeffs ); - STBIR_MOVE_4( pc+4, coeffs+4 ); - STBIR_MOVE_2( pc+8, coeffs+8 ); - pc += 10; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 11: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_4( pc, coeffs ); - STBIR_MOVE_4( pc+4, coeffs+4 ); - STBIR_MOVE_2( pc+8, coeffs+8 ); - STBIR_MOVE_1( pc+10, coeffs+10 ); - pc += 11; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - case 12: - STBIR_NO_UNROLL_LOOP_START - do { - STBIR_MOVE_4( pc, coeffs ); - STBIR_MOVE_4( pc+4, coeffs+4 ); - STBIR_MOVE_4( pc+8, coeffs+8 ); - pc += 12; - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - default: - STBIR_NO_UNROLL_LOOP_START - do { - float * copy_end = pc + widest - 4; - float * c = coeffs; - do { - STBIR_NO_UNROLL( pc ); - STBIR_MOVE_4( pc, c ); - pc += 4; - c += 4; - } while ( pc <= copy_end ); - copy_end += 4; - STBIR_NO_UNROLL_LOOP_START - while ( pc < copy_end ) - { - STBIR_MOVE_1( pc, c ); - ++pc; ++c; - } - coeffs += coefficient_width; - } while ( pc < pc_end ); - break; - } - } - - // some horizontal routines read one float off the end (which is then masked off), so put in a sentinal so we don't read an snan or denormal - coefficents[ widest * num_contributors ] = 8888.0f; - - // the minimum we might read for unrolled filters widths is 12. So, we need to - // make sure we never read outside the decode buffer, by possibly moving - // the sample area back into the scanline, and putting zeros weights first. - // we start on the right edge and check until we're well past the possible - // clip area (2*widest). - { - stbir__contributors * contribs = contributors + num_contributors - 1; - float * coeffs = coefficents + widest * ( num_contributors - 1 ); - - // go until no chance of clipping (this is usually less than 8 lops) - while ( ( contribs >= contributors ) && ( ( contribs->n0 + widest*2 ) >= row_end ) ) - { - // might we clip?? - if ( ( contribs->n0 + widest ) > row_end ) - { - int stop_range = widest; - - // if range is larger than 12, it will be handled by generic loops that can terminate on the exact length - // of this contrib n1, instead of a fixed widest amount - so calculate this - if ( widest > 12 ) - { - int mod; - - // how far will be read in the n_coeff loop (which depends on the widest count mod4); - mod = widest & 3; - stop_range = ( ( ( contribs->n1 - contribs->n0 + 1 ) - mod + 3 ) & ~3 ) + mod; - - // the n_coeff loops do a minimum amount of coeffs, so factor that in! - if ( stop_range < ( 8 + mod ) ) stop_range = 8 + mod; - } - - // now see if we still clip with the refined range - if ( ( contribs->n0 + stop_range ) > row_end ) - { - int new_n0 = row_end - stop_range; - int num = contribs->n1 - contribs->n0 + 1; - int backup = contribs->n0 - new_n0; - float * from_co = coeffs + num - 1; - float * to_co = from_co + backup; - - STBIR_ASSERT( ( new_n0 >= row0 ) && ( new_n0 < contribs->n0 ) ); - - // move the coeffs over - while( num ) - { - *to_co-- = *from_co--; - --num; - } - // zero new positions - while ( to_co >= coeffs ) - *to_co-- = 0; - // set new start point - contribs->n0 = new_n0; - if ( widest > 12 ) - { - int mod; - - // how far will be read in the n_coeff loop (which depends on the widest count mod4); - mod = widest & 3; - stop_range = ( ( ( contribs->n1 - contribs->n0 + 1 ) - mod + 3 ) & ~3 ) + mod; - - // the n_coeff loops do a minimum amount of coeffs, so factor that in! - if ( stop_range < ( 8 + mod ) ) stop_range = 8 + mod; - } - } - } - --contribs; - coeffs -= widest; - } - } - - return widest; - #undef STBIR_MOVE_1 - #undef STBIR_MOVE_2 - #undef STBIR_MOVE_4 -} - -static void stbir__calculate_filters( stbir__sampler * samp, stbir__sampler * other_axis_for_pivot, void * user_data STBIR_ONLY_PROFILE_BUILD_GET_INFO ) -{ - int n; - float scale = samp->scale_info.scale; - stbir__kernel_callback * kernel = samp->filter_kernel; - stbir__support_callback * support = samp->filter_support; - float inv_scale = samp->scale_info.inv_scale; - int input_full_size = samp->scale_info.input_full_size; - int gather_num_contributors = samp->num_contributors; - stbir__contributors* gather_contributors = samp->contributors; - float * gather_coeffs = samp->coefficients; - int gather_coefficient_width = samp->coefficient_width; - - switch ( samp->is_gather ) - { - case 1: // gather upsample - { - float out_pixels_radius = support(inv_scale,user_data) * scale; - - stbir__calculate_coefficients_for_gather_upsample( out_pixels_radius, kernel, &samp->scale_info, gather_num_contributors, gather_contributors, gather_coeffs, gather_coefficient_width, samp->edge, user_data ); - - STBIR_PROFILE_BUILD_START( cleanup ); - stbir__cleanup_gathered_coefficients( samp->edge, &samp->extent_info, &samp->scale_info, gather_num_contributors, gather_contributors, gather_coeffs, gather_coefficient_width ); - STBIR_PROFILE_BUILD_END( cleanup ); - } - break; - - case 0: // scatter downsample (only on vertical) - case 2: // gather downsample - { - float in_pixels_radius = support(scale,user_data) * inv_scale; - int filter_pixel_margin = samp->filter_pixel_margin; - int input_end = input_full_size + filter_pixel_margin; - - // if this is a scatter, we do a downsample gather to get the coeffs, and then pivot after - if ( !samp->is_gather ) - { - // check if we are using the same gather downsample on the horizontal as this vertical, - // if so, then we don't have to generate them, we can just pivot from the horizontal. - if ( other_axis_for_pivot ) - { - gather_contributors = other_axis_for_pivot->contributors; - gather_coeffs = other_axis_for_pivot->coefficients; - gather_coefficient_width = other_axis_for_pivot->coefficient_width; - gather_num_contributors = other_axis_for_pivot->num_contributors; - samp->extent_info.lowest = other_axis_for_pivot->extent_info.lowest; - samp->extent_info.highest = other_axis_for_pivot->extent_info.highest; - samp->extent_info.widest = other_axis_for_pivot->extent_info.widest; - goto jump_right_to_pivot; - } - - gather_contributors = samp->gather_prescatter_contributors; - gather_coeffs = samp->gather_prescatter_coefficients; - gather_coefficient_width = samp->gather_prescatter_coefficient_width; - gather_num_contributors = samp->gather_prescatter_num_contributors; - } - - stbir__calculate_coefficients_for_gather_downsample( -filter_pixel_margin, input_end, in_pixels_radius, kernel, &samp->scale_info, gather_coefficient_width, gather_num_contributors, gather_contributors, gather_coeffs, user_data ); - - STBIR_PROFILE_BUILD_START( cleanup ); - stbir__cleanup_gathered_coefficients( samp->edge, &samp->extent_info, &samp->scale_info, gather_num_contributors, gather_contributors, gather_coeffs, gather_coefficient_width ); - STBIR_PROFILE_BUILD_END( cleanup ); - - if ( !samp->is_gather ) - { - // if this is a scatter (vertical only), then we need to pivot the coeffs - stbir__contributors * scatter_contributors; - int highest_set; - - jump_right_to_pivot: - - STBIR_PROFILE_BUILD_START( pivot ); - - highest_set = (-filter_pixel_margin) - 1; - for (n = 0; n < gather_num_contributors; n++) - { - int k; - int gn0 = gather_contributors->n0, gn1 = gather_contributors->n1; - int scatter_coefficient_width = samp->coefficient_width; - float * scatter_coeffs = samp->coefficients + ( gn0 + filter_pixel_margin ) * scatter_coefficient_width; - float * g_coeffs = gather_coeffs; - scatter_contributors = samp->contributors + ( gn0 + filter_pixel_margin ); - - for (k = gn0 ; k <= gn1 ; k++ ) - { - float gc = *g_coeffs++; - - // skip zero and denormals - must skip zeros to avoid adding coeffs beyond scatter_coefficient_width - // (which happens when pivoting from horizontal, which might have dummy zeros) - if ( ( ( gc >= stbir__small_float ) || ( gc <= -stbir__small_float ) ) ) - { - if ( ( k > highest_set ) || ( scatter_contributors->n0 > scatter_contributors->n1 ) ) - { - { - // if we are skipping over several contributors, we need to clear the skipped ones - stbir__contributors * clear_contributors = samp->contributors + ( highest_set + filter_pixel_margin + 1); - while ( clear_contributors < scatter_contributors ) - { - clear_contributors->n0 = 0; - clear_contributors->n1 = -1; - ++clear_contributors; - } - } - scatter_contributors->n0 = n; - scatter_contributors->n1 = n; - scatter_coeffs[0] = gc; - highest_set = k; - } - else - { - stbir__insert_coeff( scatter_contributors, scatter_coeffs, n, gc, scatter_coefficient_width ); - } - STBIR_ASSERT( ( scatter_contributors->n1 - scatter_contributors->n0 + 1 ) <= scatter_coefficient_width ); - } - ++scatter_contributors; - scatter_coeffs += scatter_coefficient_width; - } - - ++gather_contributors; - gather_coeffs += gather_coefficient_width; - } - - // now clear any unset contribs - { - stbir__contributors * clear_contributors = samp->contributors + ( highest_set + filter_pixel_margin + 1); - stbir__contributors * end_contributors = samp->contributors + samp->num_contributors; - while ( clear_contributors < end_contributors ) - { - clear_contributors->n0 = 0; - clear_contributors->n1 = -1; - ++clear_contributors; - } - } - - STBIR_PROFILE_BUILD_END( pivot ); - } - } - break; - } -} - - -//======================================================================================================== -// scanline decoders and encoders - -#define stbir__coder_min_num 1 -#define STB_IMAGE_RESIZE_DO_CODERS -#include STBIR__HEADER_FILENAME - -#define stbir__decode_suffix BGRA -#define stbir__decode_swizzle -#define stbir__decode_order0 2 -#define stbir__decode_order1 1 -#define stbir__decode_order2 0 -#define stbir__decode_order3 3 -#define stbir__encode_order0 2 -#define stbir__encode_order1 1 -#define stbir__encode_order2 0 -#define stbir__encode_order3 3 -#define stbir__coder_min_num 4 -#define STB_IMAGE_RESIZE_DO_CODERS -#include STBIR__HEADER_FILENAME - -#define stbir__decode_suffix ARGB -#define stbir__decode_swizzle -#define stbir__decode_order0 1 -#define stbir__decode_order1 2 -#define stbir__decode_order2 3 -#define stbir__decode_order3 0 -#define stbir__encode_order0 3 -#define stbir__encode_order1 0 -#define stbir__encode_order2 1 -#define stbir__encode_order3 2 -#define stbir__coder_min_num 4 -#define STB_IMAGE_RESIZE_DO_CODERS -#include STBIR__HEADER_FILENAME - -#define stbir__decode_suffix ABGR -#define stbir__decode_swizzle -#define stbir__decode_order0 3 -#define stbir__decode_order1 2 -#define stbir__decode_order2 1 -#define stbir__decode_order3 0 -#define stbir__encode_order0 3 -#define stbir__encode_order1 2 -#define stbir__encode_order2 1 -#define stbir__encode_order3 0 -#define stbir__coder_min_num 4 -#define STB_IMAGE_RESIZE_DO_CODERS -#include STBIR__HEADER_FILENAME - -#define stbir__decode_suffix AR -#define stbir__decode_swizzle -#define stbir__decode_order0 1 -#define stbir__decode_order1 0 -#define stbir__decode_order2 3 -#define stbir__decode_order3 2 -#define stbir__encode_order0 1 -#define stbir__encode_order1 0 -#define stbir__encode_order2 3 -#define stbir__encode_order3 2 -#define stbir__coder_min_num 2 -#define STB_IMAGE_RESIZE_DO_CODERS -#include STBIR__HEADER_FILENAME - - -// fancy alpha means we expand to keep both premultipied and non-premultiplied color channels -static void stbir__fancy_alpha_weight_4ch( float * out_buffer, int width_times_channels ) -{ - float STBIR_STREAMOUT_PTR(*) out = out_buffer; - float const * end_decode = out_buffer + ( width_times_channels / 4 ) * 7; // decode buffer aligned to end of out_buffer - float STBIR_STREAMOUT_PTR(*) decode = (float*)end_decode - width_times_channels; - - // fancy alpha is stored internally as R G B A Rpm Gpm Bpm - - #ifdef STBIR_SIMD - - #ifdef STBIR_SIMD8 - decode += 16; - STBIR_NO_UNROLL_LOOP_START - while ( decode <= end_decode ) - { - stbir__simdf8 d0,d1,a0,a1,p0,p1; - STBIR_NO_UNROLL(decode); - stbir__simdf8_load( d0, decode-16 ); - stbir__simdf8_load( d1, decode-16+8 ); - stbir__simdf8_0123to33333333( a0, d0 ); - stbir__simdf8_0123to33333333( a1, d1 ); - stbir__simdf8_mult( p0, a0, d0 ); - stbir__simdf8_mult( p1, a1, d1 ); - stbir__simdf8_bot4s( a0, d0, p0 ); - stbir__simdf8_bot4s( a1, d1, p1 ); - stbir__simdf8_top4s( d0, d0, p0 ); - stbir__simdf8_top4s( d1, d1, p1 ); - stbir__simdf8_store ( out, a0 ); - stbir__simdf8_store ( out+7, d0 ); - stbir__simdf8_store ( out+14, a1 ); - stbir__simdf8_store ( out+21, d1 ); - decode += 16; - out += 28; - } - decode -= 16; - #else - decode += 8; - STBIR_NO_UNROLL_LOOP_START - while ( decode <= end_decode ) - { - stbir__simdf d0,a0,d1,a1,p0,p1; - STBIR_NO_UNROLL(decode); - stbir__simdf_load( d0, decode-8 ); - stbir__simdf_load( d1, decode-8+4 ); - stbir__simdf_0123to3333( a0, d0 ); - stbir__simdf_0123to3333( a1, d1 ); - stbir__simdf_mult( p0, a0, d0 ); - stbir__simdf_mult( p1, a1, d1 ); - stbir__simdf_store ( out, d0 ); - stbir__simdf_store ( out+4, p0 ); - stbir__simdf_store ( out+7, d1 ); - stbir__simdf_store ( out+7+4, p1 ); - decode += 8; - out += 14; - } - decode -= 8; - #endif - - // might be one last odd pixel - #ifdef STBIR_SIMD8 - STBIR_NO_UNROLL_LOOP_START - while ( decode < end_decode ) - #else - if ( decode < end_decode ) - #endif - { - stbir__simdf d,a,p; - STBIR_NO_UNROLL(decode); - stbir__simdf_load( d, decode ); - stbir__simdf_0123to3333( a, d ); - stbir__simdf_mult( p, a, d ); - stbir__simdf_store ( out, d ); - stbir__simdf_store ( out+4, p ); - decode += 4; - out += 7; - } - - #else - - while( decode < end_decode ) - { - float r = decode[0], g = decode[1], b = decode[2], alpha = decode[3]; - out[0] = r; - out[1] = g; - out[2] = b; - out[3] = alpha; - out[4] = r * alpha; - out[5] = g * alpha; - out[6] = b * alpha; - out += 7; - decode += 4; - } - - #endif -} - -static void stbir__fancy_alpha_weight_2ch( float * out_buffer, int width_times_channels ) -{ - float STBIR_STREAMOUT_PTR(*) out = out_buffer; - float const * end_decode = out_buffer + ( width_times_channels / 2 ) * 3; - float STBIR_STREAMOUT_PTR(*) decode = (float*)end_decode - width_times_channels; - - // for fancy alpha, turns into: [X A Xpm][X A Xpm],etc - - #ifdef STBIR_SIMD - - decode += 8; - if ( decode <= end_decode ) - { - STBIR_NO_UNROLL_LOOP_START - do { - #ifdef STBIR_SIMD8 - stbir__simdf8 d0,a0,p0; - STBIR_NO_UNROLL(decode); - stbir__simdf8_load( d0, decode-8 ); - stbir__simdf8_0123to11331133( p0, d0 ); - stbir__simdf8_0123to00220022( a0, d0 ); - stbir__simdf8_mult( p0, p0, a0 ); - - stbir__simdf_store2( out, stbir__if_simdf8_cast_to_simdf4( d0 ) ); - stbir__simdf_store( out+2, stbir__if_simdf8_cast_to_simdf4( p0 ) ); - stbir__simdf_store2h( out+3, stbir__if_simdf8_cast_to_simdf4( d0 ) ); - - stbir__simdf_store2( out+6, stbir__simdf8_gettop4( d0 ) ); - stbir__simdf_store( out+8, stbir__simdf8_gettop4( p0 ) ); - stbir__simdf_store2h( out+9, stbir__simdf8_gettop4( d0 ) ); - #else - stbir__simdf d0,a0,d1,a1,p0,p1; - STBIR_NO_UNROLL(decode); - stbir__simdf_load( d0, decode-8 ); - stbir__simdf_load( d1, decode-8+4 ); - stbir__simdf_0123to1133( p0, d0 ); - stbir__simdf_0123to1133( p1, d1 ); - stbir__simdf_0123to0022( a0, d0 ); - stbir__simdf_0123to0022( a1, d1 ); - stbir__simdf_mult( p0, p0, a0 ); - stbir__simdf_mult( p1, p1, a1 ); - - stbir__simdf_store2( out, d0 ); - stbir__simdf_store( out+2, p0 ); - stbir__simdf_store2h( out+3, d0 ); - - stbir__simdf_store2( out+6, d1 ); - stbir__simdf_store( out+8, p1 ); - stbir__simdf_store2h( out+9, d1 ); - #endif - decode += 8; - out += 12; - } while ( decode <= end_decode ); - } - decode -= 8; - #endif - - STBIR_SIMD_NO_UNROLL_LOOP_START - while( decode < end_decode ) - { - float x = decode[0], y = decode[1]; - STBIR_SIMD_NO_UNROLL(decode); - out[0] = x; - out[1] = y; - out[2] = x * y; - out += 3; - decode += 2; - } -} - -static void stbir__fancy_alpha_unweight_4ch( float * encode_buffer, int width_times_channels ) -{ - float STBIR_SIMD_STREAMOUT_PTR(*) encode = encode_buffer; - float STBIR_SIMD_STREAMOUT_PTR(*) input = encode_buffer; - float const * end_output = encode_buffer + width_times_channels; - - // fancy RGBA is stored internally as R G B A Rpm Gpm Bpm - - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float alpha = input[3]; -#ifdef STBIR_SIMD - stbir__simdf i,ia; - STBIR_SIMD_NO_UNROLL(encode); - if ( alpha < stbir__small_float ) - { - stbir__simdf_load( i, input ); - stbir__simdf_store( encode, i ); - } - else - { - stbir__simdf_load1frep4( ia, 1.0f / alpha ); - stbir__simdf_load( i, input+4 ); - stbir__simdf_mult( i, i, ia ); - stbir__simdf_store( encode, i ); - encode[3] = alpha; - } -#else - if ( alpha < stbir__small_float ) - { - encode[0] = input[0]; - encode[1] = input[1]; - encode[2] = input[2]; - } - else - { - float ialpha = 1.0f / alpha; - encode[0] = input[4] * ialpha; - encode[1] = input[5] * ialpha; - encode[2] = input[6] * ialpha; - } - encode[3] = alpha; -#endif - - input += 7; - encode += 4; - } while ( encode < end_output ); -} - -// format: [X A Xpm][X A Xpm] etc -static void stbir__fancy_alpha_unweight_2ch( float * encode_buffer, int width_times_channels ) -{ - float STBIR_SIMD_STREAMOUT_PTR(*) encode = encode_buffer; - float STBIR_SIMD_STREAMOUT_PTR(*) input = encode_buffer; - float const * end_output = encode_buffer + width_times_channels; - - do { - float alpha = input[1]; - encode[0] = input[0]; - if ( alpha >= stbir__small_float ) - encode[0] = input[2] / alpha; - encode[1] = alpha; - - input += 3; - encode += 2; - } while ( encode < end_output ); -} - -static void stbir__simple_alpha_weight_4ch( float * decode_buffer, int width_times_channels ) -{ - float STBIR_STREAMOUT_PTR(*) decode = decode_buffer; - float const * end_decode = decode_buffer + width_times_channels; - - #ifdef STBIR_SIMD - { - decode += 2 * stbir__simdfX_float_count; - STBIR_NO_UNROLL_LOOP_START - while ( decode <= end_decode ) - { - stbir__simdfX d0,a0,d1,a1; - STBIR_NO_UNROLL(decode); - stbir__simdfX_load( d0, decode-2*stbir__simdfX_float_count ); - stbir__simdfX_load( d1, decode-2*stbir__simdfX_float_count+stbir__simdfX_float_count ); - stbir__simdfX_aaa1( a0, d0, STBIR_onesX ); - stbir__simdfX_aaa1( a1, d1, STBIR_onesX ); - stbir__simdfX_mult( d0, d0, a0 ); - stbir__simdfX_mult( d1, d1, a1 ); - stbir__simdfX_store ( decode-2*stbir__simdfX_float_count, d0 ); - stbir__simdfX_store ( decode-2*stbir__simdfX_float_count+stbir__simdfX_float_count, d1 ); - decode += 2 * stbir__simdfX_float_count; - } - decode -= 2 * stbir__simdfX_float_count; - - // few last pixels remnants - #ifdef STBIR_SIMD8 - STBIR_NO_UNROLL_LOOP_START - while ( decode < end_decode ) - #else - if ( decode < end_decode ) - #endif - { - stbir__simdf d,a; - stbir__simdf_load( d, decode ); - stbir__simdf_aaa1( a, d, STBIR__CONSTF(STBIR_ones) ); - stbir__simdf_mult( d, d, a ); - stbir__simdf_store ( decode, d ); - decode += 4; - } - } - - #else - - while( decode < end_decode ) - { - float alpha = decode[3]; - decode[0] *= alpha; - decode[1] *= alpha; - decode[2] *= alpha; - decode += 4; - } - - #endif -} - -static void stbir__simple_alpha_weight_2ch( float * decode_buffer, int width_times_channels ) -{ - float STBIR_STREAMOUT_PTR(*) decode = decode_buffer; - float const * end_decode = decode_buffer + width_times_channels; - - #ifdef STBIR_SIMD - decode += 2 * stbir__simdfX_float_count; - STBIR_NO_UNROLL_LOOP_START - while ( decode <= end_decode ) - { - stbir__simdfX d0,a0,d1,a1; - STBIR_NO_UNROLL(decode); - stbir__simdfX_load( d0, decode-2*stbir__simdfX_float_count ); - stbir__simdfX_load( d1, decode-2*stbir__simdfX_float_count+stbir__simdfX_float_count ); - stbir__simdfX_a1a1( a0, d0, STBIR_onesX ); - stbir__simdfX_a1a1( a1, d1, STBIR_onesX ); - stbir__simdfX_mult( d0, d0, a0 ); - stbir__simdfX_mult( d1, d1, a1 ); - stbir__simdfX_store ( decode-2*stbir__simdfX_float_count, d0 ); - stbir__simdfX_store ( decode-2*stbir__simdfX_float_count+stbir__simdfX_float_count, d1 ); - decode += 2 * stbir__simdfX_float_count; - } - decode -= 2 * stbir__simdfX_float_count; - #endif - - STBIR_SIMD_NO_UNROLL_LOOP_START - while( decode < end_decode ) - { - float alpha = decode[1]; - STBIR_SIMD_NO_UNROLL(decode); - decode[0] *= alpha; - decode += 2; - } -} - -static void stbir__simple_alpha_unweight_4ch( float * encode_buffer, int width_times_channels ) -{ - float STBIR_SIMD_STREAMOUT_PTR(*) encode = encode_buffer; - float const * end_output = encode_buffer + width_times_channels; - - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float alpha = encode[3]; - -#ifdef STBIR_SIMD - stbir__simdf i,ia; - STBIR_SIMD_NO_UNROLL(encode); - if ( alpha >= stbir__small_float ) - { - stbir__simdf_load1frep4( ia, 1.0f / alpha ); - stbir__simdf_load( i, encode ); - stbir__simdf_mult( i, i, ia ); - stbir__simdf_store( encode, i ); - encode[3] = alpha; - } -#else - if ( alpha >= stbir__small_float ) - { - float ialpha = 1.0f / alpha; - encode[0] *= ialpha; - encode[1] *= ialpha; - encode[2] *= ialpha; - } -#endif - encode += 4; - } while ( encode < end_output ); -} - -static void stbir__simple_alpha_unweight_2ch( float * encode_buffer, int width_times_channels ) -{ - float STBIR_SIMD_STREAMOUT_PTR(*) encode = encode_buffer; - float const * end_output = encode_buffer + width_times_channels; - - do { - float alpha = encode[1]; - if ( alpha >= stbir__small_float ) - encode[0] /= alpha; - encode += 2; - } while ( encode < end_output ); -} - - -// only used in RGB->BGR or BGR->RGB -static void stbir__simple_flip_3ch( float * decode_buffer, int width_times_channels ) -{ - float STBIR_STREAMOUT_PTR(*) decode = decode_buffer; - float const * end_decode = decode_buffer + width_times_channels; - -#ifdef STBIR_SIMD - #ifdef stbir__simdf_swiz2 // do we have two argument swizzles? - end_decode -= 12; - STBIR_NO_UNROLL_LOOP_START - while( decode <= end_decode ) - { - // on arm64 8 instructions, no overlapping stores - stbir__simdf a,b,c,na,nb; - STBIR_SIMD_NO_UNROLL(decode); - stbir__simdf_load( a, decode ); - stbir__simdf_load( b, decode+4 ); - stbir__simdf_load( c, decode+8 ); - - na = stbir__simdf_swiz2( a, b, 2, 1, 0, 5 ); - b = stbir__simdf_swiz2( a, b, 4, 3, 6, 7 ); - nb = stbir__simdf_swiz2( b, c, 0, 1, 4, 3 ); - c = stbir__simdf_swiz2( b, c, 2, 7, 6, 5 ); - - stbir__simdf_store( decode, na ); - stbir__simdf_store( decode+4, nb ); - stbir__simdf_store( decode+8, c ); - decode += 12; - } - end_decode += 12; - #else - end_decode -= 24; - STBIR_NO_UNROLL_LOOP_START - while( decode <= end_decode ) - { - // 26 instructions on x64 - stbir__simdf a,b,c,d,e,f,g; - float i21, i23; - STBIR_SIMD_NO_UNROLL(decode); - stbir__simdf_load( a, decode ); - stbir__simdf_load( b, decode+3 ); - stbir__simdf_load( c, decode+6 ); - stbir__simdf_load( d, decode+9 ); - stbir__simdf_load( e, decode+12 ); - stbir__simdf_load( f, decode+15 ); - stbir__simdf_load( g, decode+18 ); - - a = stbir__simdf_swiz( a, 2, 1, 0, 3 ); - b = stbir__simdf_swiz( b, 2, 1, 0, 3 ); - c = stbir__simdf_swiz( c, 2, 1, 0, 3 ); - d = stbir__simdf_swiz( d, 2, 1, 0, 3 ); - e = stbir__simdf_swiz( e, 2, 1, 0, 3 ); - f = stbir__simdf_swiz( f, 2, 1, 0, 3 ); - g = stbir__simdf_swiz( g, 2, 1, 0, 3 ); - - // stores overlap, need to be in order, - stbir__simdf_store( decode, a ); - i21 = decode[21]; - stbir__simdf_store( decode+3, b ); - i23 = decode[23]; - stbir__simdf_store( decode+6, c ); - stbir__simdf_store( decode+9, d ); - stbir__simdf_store( decode+12, e ); - stbir__simdf_store( decode+15, f ); - stbir__simdf_store( decode+18, g ); - decode[21] = i23; - decode[23] = i21; - decode += 24; - } - end_decode += 24; - #endif -#else - end_decode -= 12; - STBIR_NO_UNROLL_LOOP_START - while( decode <= end_decode ) - { - // 16 instructions - float t0,t1,t2,t3; - STBIR_NO_UNROLL(decode); - t0 = decode[0]; t1 = decode[3]; t2 = decode[6]; t3 = decode[9]; - decode[0] = decode[2]; decode[3] = decode[5]; decode[6] = decode[8]; decode[9] = decode[11]; - decode[2] = t0; decode[5] = t1; decode[8] = t2; decode[11] = t3; - decode += 12; - } - end_decode += 12; -#endif - - STBIR_NO_UNROLL_LOOP_START - while( decode < end_decode ) - { - float t = decode[0]; - STBIR_NO_UNROLL(decode); - decode[0] = decode[2]; - decode[2] = t; - decode += 3; - } -} - - - -static void stbir__decode_scanline(stbir__info const * stbir_info, int n, float * output_buffer STBIR_ONLY_PROFILE_GET_SPLIT_INFO ) -{ - int channels = stbir_info->channels; - int effective_channels = stbir_info->effective_channels; - int input_sample_in_bytes = stbir__type_size[stbir_info->input_type] * channels; - stbir_edge edge_horizontal = stbir_info->horizontal.edge; - stbir_edge edge_vertical = stbir_info->vertical.edge; - int row = stbir__edge_wrap(edge_vertical, n, stbir_info->vertical.scale_info.input_full_size); - const void* input_plane_data = ( (char *) stbir_info->input_data ) + (size_t)row * (size_t) stbir_info->input_stride_bytes; - stbir__span const * spans = stbir_info->scanline_extents.spans; - float * full_decode_buffer = output_buffer - stbir_info->scanline_extents.conservative.n0 * effective_channels; - float * last_decoded = 0; - - // if we are on edge_zero, and we get in here with an out of bounds n, then the calculate filters has failed - STBIR_ASSERT( !(edge_vertical == STBIR_EDGE_ZERO && (n < 0 || n >= stbir_info->vertical.scale_info.input_full_size)) ); - - do - { - float * decode_buffer; - void const * input_data; - float * end_decode; - int width_times_channels; - int width; - - if ( spans->n1 < spans->n0 ) - break; - - width = spans->n1 + 1 - spans->n0; - decode_buffer = full_decode_buffer + spans->n0 * effective_channels; - end_decode = full_decode_buffer + ( spans->n1 + 1 ) * effective_channels; - width_times_channels = width * channels; - - // read directly out of input plane by default - input_data = ( (char*)input_plane_data ) + spans->pixel_offset_for_input * input_sample_in_bytes; - - // if we have an input callback, call it to get the input data - if ( stbir_info->in_pixels_cb ) - { - // call the callback with a temp buffer (that they can choose to use or not). the temp is just right aligned memory in the decode_buffer itself - input_data = stbir_info->in_pixels_cb( ( (char*) end_decode ) - ( width * input_sample_in_bytes ) + ( ( stbir_info->input_type != STBIR_TYPE_FLOAT ) ? ( sizeof(float)*STBIR_INPUT_CALLBACK_PADDING ) : 0 ), input_plane_data, width, spans->pixel_offset_for_input, row, stbir_info->user_data ); - } - - STBIR_PROFILE_START( decode ); - // convert the pixels info the float decode_buffer, (we index from end_decode, so that when channelsdecode_pixels( (float*)end_decode - width_times_channels, width_times_channels, input_data ); - STBIR_PROFILE_END( decode ); - - if (stbir_info->alpha_weight) - { - STBIR_PROFILE_START( alpha ); - stbir_info->alpha_weight( decode_buffer, width_times_channels ); - STBIR_PROFILE_END( alpha ); - } - - ++spans; - } while ( spans <= ( &stbir_info->scanline_extents.spans[1] ) ); - - // handle the edge_wrap filter (all other types are handled back out at the calculate_filter stage) - // basically the idea here is that if we have the whole scanline in memory, we don't redecode the - // wrapped edge pixels, and instead just memcpy them from the scanline into the edge positions - if ( ( edge_horizontal == STBIR_EDGE_WRAP ) && ( stbir_info->scanline_extents.edge_sizes[0] | stbir_info->scanline_extents.edge_sizes[1] ) ) - { - // this code only runs if we're in edge_wrap, and we're doing the entire scanline - int e, start_x[2]; - int input_full_size = stbir_info->horizontal.scale_info.input_full_size; - - start_x[0] = -stbir_info->scanline_extents.edge_sizes[0]; // left edge start x - start_x[1] = input_full_size; // right edge - - for( e = 0; e < 2 ; e++ ) - { - // do each margin - int margin = stbir_info->scanline_extents.edge_sizes[e]; - if ( margin ) - { - int x = start_x[e]; - float * marg = full_decode_buffer + x * effective_channels; - float const * src = full_decode_buffer + stbir__edge_wrap(edge_horizontal, x, input_full_size) * effective_channels; - STBIR_MEMCPY( marg, src, margin * effective_channels * sizeof(float) ); - if ( e == 1 ) last_decoded = marg + margin * effective_channels; - } - } - } - - // some of the horizontal gathers read one float off the edge (which is masked out), but we force a zero here to make sure no NaNs leak in - // (we can't pre-zero it, because the input callback can use that area as padding) - last_decoded[0] = 0.0f; - - // we clear this extra float, because the final output pixel filter kernel might have used one less coeff than the max filter width - // when this happens, we do read that pixel from the input, so it too could be Nan, so just zero an extra one. - // this fits because each scanline is padded by three floats (STBIR_INPUT_CALLBACK_PADDING) - last_decoded[1] = 0.0f; -} - - -//================= -// Do 1 channel horizontal routines - -#ifdef STBIR_SIMD - -#define stbir__1_coeff_only() \ - stbir__simdf tot,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1( c, hc ); \ - stbir__simdf_mult1_mem( tot, c, decode ); - -#define stbir__2_coeff_only() \ - stbir__simdf tot,c,d; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load2z( c, hc ); \ - stbir__simdf_load2( d, decode ); \ - stbir__simdf_mult( tot, c, d ); \ - stbir__simdf_0123to1230( c, tot ); \ - stbir__simdf_add1( tot, tot, c ); - -#define stbir__3_coeff_only() \ - stbir__simdf tot,c,t; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( c, hc ); \ - stbir__simdf_mult_mem( tot, c, decode ); \ - stbir__simdf_0123to1230( c, tot ); \ - stbir__simdf_0123to2301( t, tot ); \ - stbir__simdf_add1( tot, tot, c ); \ - stbir__simdf_add1( tot, tot, t ); - -#define stbir__store_output_tiny() \ - stbir__simdf_store1( output, tot ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 1; - -#define stbir__4_coeff_start() \ - stbir__simdf tot,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( c, hc ); \ - stbir__simdf_mult_mem( tot, c, decode ); \ - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( c, hc + (ofs) ); \ - stbir__simdf_madd_mem( tot, tot, c, decode+(ofs) ); - -#define stbir__1_coeff_remnant( ofs ) \ - { stbir__simdf d; \ - stbir__simdf_load1z( c, hc + (ofs) ); \ - stbir__simdf_load1( d, decode + (ofs) ); \ - stbir__simdf_madd( tot, tot, d, c ); } - -#define stbir__2_coeff_remnant( ofs ) \ - { stbir__simdf d; \ - stbir__simdf_load2z( c, hc+(ofs) ); \ - stbir__simdf_load2( d, decode+(ofs) ); \ - stbir__simdf_madd( tot, tot, d, c ); } - -#define stbir__3_coeff_setup() \ - stbir__simdf mask; \ - stbir__simdf_load( mask, STBIR_mask + 3 ); - -#define stbir__3_coeff_remnant( ofs ) \ - stbir__simdf_load( c, hc+(ofs) ); \ - stbir__simdf_and( c, c, mask ); \ - stbir__simdf_madd_mem( tot, tot, c, decode+(ofs) ); - -#define stbir__store_output() \ - stbir__simdf_0123to2301( c, tot ); \ - stbir__simdf_add( tot, tot, c ); \ - stbir__simdf_0123to1230( c, tot ); \ - stbir__simdf_add1( tot, tot, c ); \ - stbir__simdf_store1( output, tot ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 1; - -#else - -#define stbir__1_coeff_only() \ - float tot; \ - tot = decode[0]*hc[0]; - -#define stbir__2_coeff_only() \ - float tot; \ - tot = decode[0] * hc[0]; \ - tot += decode[1] * hc[1]; - -#define stbir__3_coeff_only() \ - float tot; \ - tot = decode[0] * hc[0]; \ - tot += decode[1] * hc[1]; \ - tot += decode[2] * hc[2]; - -#define stbir__store_output_tiny() \ - output[0] = tot; \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 1; - -#define stbir__4_coeff_start() \ - float tot0,tot1,tot2,tot3; \ - tot0 = decode[0] * hc[0]; \ - tot1 = decode[1] * hc[1]; \ - tot2 = decode[2] * hc[2]; \ - tot3 = decode[3] * hc[3]; - -#define stbir__4_coeff_continue_from_4( ofs ) \ - tot0 += decode[0+(ofs)] * hc[0+(ofs)]; \ - tot1 += decode[1+(ofs)] * hc[1+(ofs)]; \ - tot2 += decode[2+(ofs)] * hc[2+(ofs)]; \ - tot3 += decode[3+(ofs)] * hc[3+(ofs)]; - -#define stbir__1_coeff_remnant( ofs ) \ - tot0 += decode[0+(ofs)] * hc[0+(ofs)]; - -#define stbir__2_coeff_remnant( ofs ) \ - tot0 += decode[0+(ofs)] * hc[0+(ofs)]; \ - tot1 += decode[1+(ofs)] * hc[1+(ofs)]; \ - -#define stbir__3_coeff_remnant( ofs ) \ - tot0 += decode[0+(ofs)] * hc[0+(ofs)]; \ - tot1 += decode[1+(ofs)] * hc[1+(ofs)]; \ - tot2 += decode[2+(ofs)] * hc[2+(ofs)]; - -#define stbir__store_output() \ - output[0] = (tot0+tot2)+(tot1+tot3); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 1; - -#endif - -#define STBIR__horizontal_channels 1 -#define STB_IMAGE_RESIZE_DO_HORIZONTALS -#include STBIR__HEADER_FILENAME - - -//================= -// Do 2 channel horizontal routines - -#ifdef STBIR_SIMD - -#define stbir__1_coeff_only() \ - stbir__simdf tot,c,d; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1z( c, hc ); \ - stbir__simdf_0123to0011( c, c ); \ - stbir__simdf_load2( d, decode ); \ - stbir__simdf_mult( tot, d, c ); - -#define stbir__2_coeff_only() \ - stbir__simdf tot,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load2( c, hc ); \ - stbir__simdf_0123to0011( c, c ); \ - stbir__simdf_mult_mem( tot, c, decode ); - -#define stbir__3_coeff_only() \ - stbir__simdf tot,c,cs,d; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc ); \ - stbir__simdf_0123to0011( c, cs ); \ - stbir__simdf_mult_mem( tot, c, decode ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_load2z( d, decode+4 ); \ - stbir__simdf_madd( tot, tot, d, c ); - -#define stbir__store_output_tiny() \ - stbir__simdf_0123to2301( c, tot ); \ - stbir__simdf_add( tot, tot, c ); \ - stbir__simdf_store2( output, tot ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 2; - -#ifdef STBIR_SIMD8 - -#define stbir__4_coeff_start() \ - stbir__simdf8 tot0,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc ); \ - stbir__simdf8_0123to00112233( c, cs ); \ - stbir__simdf8_mult_mem( tot0, c, decode ); - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc + (ofs) ); \ - stbir__simdf8_0123to00112233( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*2 ); - -#define stbir__1_coeff_remnant( ofs ) \ - { stbir__simdf t,d; \ - stbir__simdf_load1z( t, hc + (ofs) ); \ - stbir__simdf_load2( d, decode + (ofs) * 2 ); \ - stbir__simdf_0123to0011( t, t ); \ - stbir__simdf_mult( t, t, d ); \ - stbir__simdf8_add4( tot0, tot0, t ); } - -#define stbir__2_coeff_remnant( ofs ) \ - { stbir__simdf t; \ - stbir__simdf_load2( t, hc + (ofs) ); \ - stbir__simdf_0123to0011( t, t ); \ - stbir__simdf_mult_mem( t, t, decode+(ofs)*2 ); \ - stbir__simdf8_add4( tot0, tot0, t ); } - -#define stbir__3_coeff_remnant( ofs ) \ - { stbir__simdf8 d; \ - stbir__simdf8_load4b( cs, hc + (ofs) ); \ - stbir__simdf8_0123to00112233( c, cs ); \ - stbir__simdf8_load6z( d, decode+(ofs)*2 ); \ - stbir__simdf8_madd( tot0, tot0, c, d ); } - -#define stbir__store_output() \ - { stbir__simdf t,d; \ - stbir__simdf8_add4halves( t, stbir__if_simdf8_cast_to_simdf4(tot0), tot0 ); \ - stbir__simdf_0123to2301( d, t ); \ - stbir__simdf_add( t, t, d ); \ - stbir__simdf_store2( output, t ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 2; } - -#else - -#define stbir__4_coeff_start() \ - stbir__simdf tot0,tot1,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc ); \ - stbir__simdf_0123to0011( c, cs ); \ - stbir__simdf_mult_mem( tot0, c, decode ); \ - stbir__simdf_0123to2233( c, cs ); \ - stbir__simdf_mult_mem( tot1, c, decode+4 ); - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc + (ofs) ); \ - stbir__simdf_0123to0011( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*2 ); \ - stbir__simdf_0123to2233( c, cs ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*2+4 ); - -#define stbir__1_coeff_remnant( ofs ) \ - { stbir__simdf d; \ - stbir__simdf_load1z( cs, hc + (ofs) ); \ - stbir__simdf_0123to0011( c, cs ); \ - stbir__simdf_load2( d, decode + (ofs) * 2 ); \ - stbir__simdf_madd( tot0, tot0, d, c ); } - -#define stbir__2_coeff_remnant( ofs ) \ - stbir__simdf_load2( cs, hc + (ofs) ); \ - stbir__simdf_0123to0011( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*2 ); - -#define stbir__3_coeff_remnant( ofs ) \ - { stbir__simdf d; \ - stbir__simdf_load( cs, hc + (ofs) ); \ - stbir__simdf_0123to0011( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*2 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_load2z( d, decode + (ofs) * 2 + 4 ); \ - stbir__simdf_madd( tot1, tot1, d, c ); } - -#define stbir__store_output() \ - stbir__simdf_add( tot0, tot0, tot1 ); \ - stbir__simdf_0123to2301( c, tot0 ); \ - stbir__simdf_add( tot0, tot0, c ); \ - stbir__simdf_store2( output, tot0 ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 2; - -#endif - -#else - -#define stbir__1_coeff_only() \ - float tota,totb,c; \ - c = hc[0]; \ - tota = decode[0]*c; \ - totb = decode[1]*c; - -#define stbir__2_coeff_only() \ - float tota,totb,c; \ - c = hc[0]; \ - tota = decode[0]*c; \ - totb = decode[1]*c; \ - c = hc[1]; \ - tota += decode[2]*c; \ - totb += decode[3]*c; - -// this weird order of add matches the simd -#define stbir__3_coeff_only() \ - float tota,totb,c; \ - c = hc[0]; \ - tota = decode[0]*c; \ - totb = decode[1]*c; \ - c = hc[2]; \ - tota += decode[4]*c; \ - totb += decode[5]*c; \ - c = hc[1]; \ - tota += decode[2]*c; \ - totb += decode[3]*c; - -#define stbir__store_output_tiny() \ - output[0] = tota; \ - output[1] = totb; \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 2; - -#define stbir__4_coeff_start() \ - float tota0,tota1,tota2,tota3,totb0,totb1,totb2,totb3,c; \ - c = hc[0]; \ - tota0 = decode[0]*c; \ - totb0 = decode[1]*c; \ - c = hc[1]; \ - tota1 = decode[2]*c; \ - totb1 = decode[3]*c; \ - c = hc[2]; \ - tota2 = decode[4]*c; \ - totb2 = decode[5]*c; \ - c = hc[3]; \ - tota3 = decode[6]*c; \ - totb3 = decode[7]*c; - -#define stbir__4_coeff_continue_from_4( ofs ) \ - c = hc[0+(ofs)]; \ - tota0 += decode[0+(ofs)*2]*c; \ - totb0 += decode[1+(ofs)*2]*c; \ - c = hc[1+(ofs)]; \ - tota1 += decode[2+(ofs)*2]*c; \ - totb1 += decode[3+(ofs)*2]*c; \ - c = hc[2+(ofs)]; \ - tota2 += decode[4+(ofs)*2]*c; \ - totb2 += decode[5+(ofs)*2]*c; \ - c = hc[3+(ofs)]; \ - tota3 += decode[6+(ofs)*2]*c; \ - totb3 += decode[7+(ofs)*2]*c; - -#define stbir__1_coeff_remnant( ofs ) \ - c = hc[0+(ofs)]; \ - tota0 += decode[0+(ofs)*2] * c; \ - totb0 += decode[1+(ofs)*2] * c; - -#define stbir__2_coeff_remnant( ofs ) \ - c = hc[0+(ofs)]; \ - tota0 += decode[0+(ofs)*2] * c; \ - totb0 += decode[1+(ofs)*2] * c; \ - c = hc[1+(ofs)]; \ - tota1 += decode[2+(ofs)*2] * c; \ - totb1 += decode[3+(ofs)*2] * c; - -#define stbir__3_coeff_remnant( ofs ) \ - c = hc[0+(ofs)]; \ - tota0 += decode[0+(ofs)*2] * c; \ - totb0 += decode[1+(ofs)*2] * c; \ - c = hc[1+(ofs)]; \ - tota1 += decode[2+(ofs)*2] * c; \ - totb1 += decode[3+(ofs)*2] * c; \ - c = hc[2+(ofs)]; \ - tota2 += decode[4+(ofs)*2] * c; \ - totb2 += decode[5+(ofs)*2] * c; - -#define stbir__store_output() \ - output[0] = (tota0+tota2)+(tota1+tota3); \ - output[1] = (totb0+totb2)+(totb1+totb3); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 2; - -#endif - -#define STBIR__horizontal_channels 2 -#define STB_IMAGE_RESIZE_DO_HORIZONTALS -#include STBIR__HEADER_FILENAME - - -//================= -// Do 3 channel horizontal routines - -#ifdef STBIR_SIMD - -#define stbir__1_coeff_only() \ - stbir__simdf tot,c,d; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1z( c, hc ); \ - stbir__simdf_0123to0001( c, c ); \ - stbir__simdf_load( d, decode ); \ - stbir__simdf_mult( tot, d, c ); - -#define stbir__2_coeff_only() \ - stbir__simdf tot,c,cs,d; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load2( cs, hc ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_load( d, decode ); \ - stbir__simdf_mult( tot, d, c ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_load( d, decode+3 ); \ - stbir__simdf_madd( tot, tot, d, c ); - -#define stbir__3_coeff_only() \ - stbir__simdf tot,c,d,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_load( d, decode ); \ - stbir__simdf_mult( tot, d, c ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_load( d, decode+3 ); \ - stbir__simdf_madd( tot, tot, d, c ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_load( d, decode+6 ); \ - stbir__simdf_madd( tot, tot, d, c ); - -#define stbir__store_output_tiny() \ - stbir__simdf_store2( output, tot ); \ - stbir__simdf_0123to2301( tot, tot ); \ - stbir__simdf_store1( output+2, tot ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 3; - -#ifdef STBIR_SIMD8 - -// we're loading from the XXXYYY decode by -1 to get the XXXYYY into different halves of the AVX reg fyi -#define stbir__4_coeff_start() \ - stbir__simdf8 tot0,tot1,c,cs; stbir__simdf t; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc ); \ - stbir__simdf8_0123to00001111( c, cs ); \ - stbir__simdf8_mult_mem( tot0, c, decode - 1 ); \ - stbir__simdf8_0123to22223333( c, cs ); \ - stbir__simdf8_mult_mem( tot1, c, decode+6 - 1 ); - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc + (ofs) ); \ - stbir__simdf8_0123to00001111( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*3 - 1 ); \ - stbir__simdf8_0123to22223333( c, cs ); \ - stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*3 + 6 - 1 ); - -#define stbir__1_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1rep4( t, hc + (ofs) ); \ - stbir__simdf8_madd_mem4( tot0, tot0, t, decode+(ofs)*3 - 1 ); - -#define stbir__2_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc + (ofs) - 2 ); \ - stbir__simdf8_0123to22223333( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*3 - 1 ); - - #define stbir__3_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc + (ofs) ); \ - stbir__simdf8_0123to00001111( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*3 - 1 ); \ - stbir__simdf8_0123to2222( t, cs ); \ - stbir__simdf8_madd_mem4( tot1, tot1, t, decode+(ofs)*3 + 6 - 1 ); - -#define stbir__store_output() \ - stbir__simdf8_add( tot0, tot0, tot1 ); \ - stbir__simdf_0123to1230( t, stbir__if_simdf8_cast_to_simdf4( tot0 ) ); \ - stbir__simdf8_add4halves( t, t, tot0 ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 3; \ - if ( output < output_end ) \ - { \ - stbir__simdf_store( output-3, t ); \ - continue; \ - } \ - { stbir__simdf tt; stbir__simdf_0123to2301( tt, t ); \ - stbir__simdf_store2( output-3, t ); \ - stbir__simdf_store1( output+2-3, tt ); } \ - break; - - -#else - -#define stbir__4_coeff_start() \ - stbir__simdf tot0,tot1,tot2,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc ); \ - stbir__simdf_0123to0001( c, cs ); \ - stbir__simdf_mult_mem( tot0, c, decode ); \ - stbir__simdf_0123to1122( c, cs ); \ - stbir__simdf_mult_mem( tot1, c, decode+4 ); \ - stbir__simdf_0123to2333( c, cs ); \ - stbir__simdf_mult_mem( tot2, c, decode+8 ); - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc + (ofs) ); \ - stbir__simdf_0123to0001( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*3 ); \ - stbir__simdf_0123to1122( c, cs ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*3+4 ); \ - stbir__simdf_0123to2333( c, cs ); \ - stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*3+8 ); - -#define stbir__1_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1z( c, hc + (ofs) ); \ - stbir__simdf_0123to0001( c, c ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*3 ); - -#define stbir__2_coeff_remnant( ofs ) \ - { stbir__simdf d; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load2z( cs, hc + (ofs) ); \ - stbir__simdf_0123to0001( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*3 ); \ - stbir__simdf_0123to1122( c, cs ); \ - stbir__simdf_load2z( d, decode+(ofs)*3+4 ); \ - stbir__simdf_madd( tot1, tot1, c, d ); } - -#define stbir__3_coeff_remnant( ofs ) \ - { stbir__simdf d; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc + (ofs) ); \ - stbir__simdf_0123to0001( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*3 ); \ - stbir__simdf_0123to1122( c, cs ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*3+4 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_load1z( d, decode+(ofs)*3+8 ); \ - stbir__simdf_madd( tot2, tot2, c, d ); } - -#define stbir__store_output() \ - stbir__simdf_0123ABCDto3ABx( c, tot0, tot1 ); \ - stbir__simdf_0123ABCDto23Ax( cs, tot1, tot2 ); \ - stbir__simdf_0123to1230( tot2, tot2 ); \ - stbir__simdf_add( tot0, tot0, cs ); \ - stbir__simdf_add( c, c, tot2 ); \ - stbir__simdf_add( tot0, tot0, c ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 3; \ - if ( output < output_end ) \ - { \ - stbir__simdf_store( output-3, tot0 ); \ - continue; \ - } \ - stbir__simdf_0123to2301( tot1, tot0 ); \ - stbir__simdf_store2( output-3, tot0 ); \ - stbir__simdf_store1( output+2-3, tot1 ); \ - break; - -#endif - -#else - -#define stbir__1_coeff_only() \ - float tot0, tot1, tot2, c; \ - c = hc[0]; \ - tot0 = decode[0]*c; \ - tot1 = decode[1]*c; \ - tot2 = decode[2]*c; - -#define stbir__2_coeff_only() \ - float tot0, tot1, tot2, c; \ - c = hc[0]; \ - tot0 = decode[0]*c; \ - tot1 = decode[1]*c; \ - tot2 = decode[2]*c; \ - c = hc[1]; \ - tot0 += decode[3]*c; \ - tot1 += decode[4]*c; \ - tot2 += decode[5]*c; - -#define stbir__3_coeff_only() \ - float tot0, tot1, tot2, c; \ - c = hc[0]; \ - tot0 = decode[0]*c; \ - tot1 = decode[1]*c; \ - tot2 = decode[2]*c; \ - c = hc[1]; \ - tot0 += decode[3]*c; \ - tot1 += decode[4]*c; \ - tot2 += decode[5]*c; \ - c = hc[2]; \ - tot0 += decode[6]*c; \ - tot1 += decode[7]*c; \ - tot2 += decode[8]*c; - -#define stbir__store_output_tiny() \ - output[0] = tot0; \ - output[1] = tot1; \ - output[2] = tot2; \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 3; - -#define stbir__4_coeff_start() \ - float tota0,tota1,tota2,totb0,totb1,totb2,totc0,totc1,totc2,totd0,totd1,totd2,c; \ - c = hc[0]; \ - tota0 = decode[0]*c; \ - tota1 = decode[1]*c; \ - tota2 = decode[2]*c; \ - c = hc[1]; \ - totb0 = decode[3]*c; \ - totb1 = decode[4]*c; \ - totb2 = decode[5]*c; \ - c = hc[2]; \ - totc0 = decode[6]*c; \ - totc1 = decode[7]*c; \ - totc2 = decode[8]*c; \ - c = hc[3]; \ - totd0 = decode[9]*c; \ - totd1 = decode[10]*c; \ - totd2 = decode[11]*c; - -#define stbir__4_coeff_continue_from_4( ofs ) \ - c = hc[0+(ofs)]; \ - tota0 += decode[0+(ofs)*3]*c; \ - tota1 += decode[1+(ofs)*3]*c; \ - tota2 += decode[2+(ofs)*3]*c; \ - c = hc[1+(ofs)]; \ - totb0 += decode[3+(ofs)*3]*c; \ - totb1 += decode[4+(ofs)*3]*c; \ - totb2 += decode[5+(ofs)*3]*c; \ - c = hc[2+(ofs)]; \ - totc0 += decode[6+(ofs)*3]*c; \ - totc1 += decode[7+(ofs)*3]*c; \ - totc2 += decode[8+(ofs)*3]*c; \ - c = hc[3+(ofs)]; \ - totd0 += decode[9+(ofs)*3]*c; \ - totd1 += decode[10+(ofs)*3]*c; \ - totd2 += decode[11+(ofs)*3]*c; - -#define stbir__1_coeff_remnant( ofs ) \ - c = hc[0+(ofs)]; \ - tota0 += decode[0+(ofs)*3]*c; \ - tota1 += decode[1+(ofs)*3]*c; \ - tota2 += decode[2+(ofs)*3]*c; - -#define stbir__2_coeff_remnant( ofs ) \ - c = hc[0+(ofs)]; \ - tota0 += decode[0+(ofs)*3]*c; \ - tota1 += decode[1+(ofs)*3]*c; \ - tota2 += decode[2+(ofs)*3]*c; \ - c = hc[1+(ofs)]; \ - totb0 += decode[3+(ofs)*3]*c; \ - totb1 += decode[4+(ofs)*3]*c; \ - totb2 += decode[5+(ofs)*3]*c; \ - -#define stbir__3_coeff_remnant( ofs ) \ - c = hc[0+(ofs)]; \ - tota0 += decode[0+(ofs)*3]*c; \ - tota1 += decode[1+(ofs)*3]*c; \ - tota2 += decode[2+(ofs)*3]*c; \ - c = hc[1+(ofs)]; \ - totb0 += decode[3+(ofs)*3]*c; \ - totb1 += decode[4+(ofs)*3]*c; \ - totb2 += decode[5+(ofs)*3]*c; \ - c = hc[2+(ofs)]; \ - totc0 += decode[6+(ofs)*3]*c; \ - totc1 += decode[7+(ofs)*3]*c; \ - totc2 += decode[8+(ofs)*3]*c; - -#define stbir__store_output() \ - output[0] = (tota0+totc0)+(totb0+totd0); \ - output[1] = (tota1+totc1)+(totb1+totd1); \ - output[2] = (tota2+totc2)+(totb2+totd2); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 3; - -#endif - -#define STBIR__horizontal_channels 3 -#define STB_IMAGE_RESIZE_DO_HORIZONTALS -#include STBIR__HEADER_FILENAME - -//================= -// Do 4 channel horizontal routines - -#ifdef STBIR_SIMD - -#define stbir__1_coeff_only() \ - stbir__simdf tot,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1( c, hc ); \ - stbir__simdf_0123to0000( c, c ); \ - stbir__simdf_mult_mem( tot, c, decode ); - -#define stbir__2_coeff_only() \ - stbir__simdf tot,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load2( cs, hc ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_mult_mem( tot, c, decode ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot, tot, c, decode+4 ); - -#define stbir__3_coeff_only() \ - stbir__simdf tot,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_mult_mem( tot, c, decode ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot, tot, c, decode+4 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_madd_mem( tot, tot, c, decode+8 ); - -#define stbir__store_output_tiny() \ - stbir__simdf_store( output, tot ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 4; - -#ifdef STBIR_SIMD8 - -#define stbir__4_coeff_start() \ - stbir__simdf8 tot0,c,cs; stbir__simdf t; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc ); \ - stbir__simdf8_0123to00001111( c, cs ); \ - stbir__simdf8_mult_mem( tot0, c, decode ); \ - stbir__simdf8_0123to22223333( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+8 ); - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc + (ofs) ); \ - stbir__simdf8_0123to00001111( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \ - stbir__simdf8_0123to22223333( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*4+8 ); - -#define stbir__1_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1rep4( t, hc + (ofs) ); \ - stbir__simdf8_madd_mem4( tot0, tot0, t, decode+(ofs)*4 ); - -#define stbir__2_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc + (ofs) - 2 ); \ - stbir__simdf8_0123to22223333( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); - - #define stbir__3_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc + (ofs) ); \ - stbir__simdf8_0123to00001111( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \ - stbir__simdf8_0123to2222( t, cs ); \ - stbir__simdf8_madd_mem4( tot0, tot0, t, decode+(ofs)*4+8 ); - -#define stbir__store_output() \ - stbir__simdf8_add4halves( t, stbir__if_simdf8_cast_to_simdf4(tot0), tot0 ); \ - stbir__simdf_store( output, t ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 4; - -#else - -#define stbir__4_coeff_start() \ - stbir__simdf tot0,tot1,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_mult_mem( tot0, c, decode ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_mult_mem( tot1, c, decode+4 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+8 ); \ - stbir__simdf_0123to3333( c, cs ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+12 ); - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc + (ofs) ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*4+4 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4+8 ); \ - stbir__simdf_0123to3333( c, cs ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*4+12 ); - -#define stbir__1_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1( c, hc + (ofs) ); \ - stbir__simdf_0123to0000( c, c ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); - -#define stbir__2_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load2( cs, hc + (ofs) ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*4+4 ); - -#define stbir__3_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc + (ofs) ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*4+4 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4+8 ); - -#define stbir__store_output() \ - stbir__simdf_add( tot0, tot0, tot1 ); \ - stbir__simdf_store( output, tot0 ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 4; - -#endif - -#else - -#define stbir__1_coeff_only() \ - float p0,p1,p2,p3,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0]; \ - p0 = decode[0] * c; \ - p1 = decode[1] * c; \ - p2 = decode[2] * c; \ - p3 = decode[3] * c; - -#define stbir__2_coeff_only() \ - float p0,p1,p2,p3,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0]; \ - p0 = decode[0] * c; \ - p1 = decode[1] * c; \ - p2 = decode[2] * c; \ - p3 = decode[3] * c; \ - c = hc[1]; \ - p0 += decode[4] * c; \ - p1 += decode[5] * c; \ - p2 += decode[6] * c; \ - p3 += decode[7] * c; - -#define stbir__3_coeff_only() \ - float p0,p1,p2,p3,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0]; \ - p0 = decode[0] * c; \ - p1 = decode[1] * c; \ - p2 = decode[2] * c; \ - p3 = decode[3] * c; \ - c = hc[1]; \ - p0 += decode[4] * c; \ - p1 += decode[5] * c; \ - p2 += decode[6] * c; \ - p3 += decode[7] * c; \ - c = hc[2]; \ - p0 += decode[8] * c; \ - p1 += decode[9] * c; \ - p2 += decode[10] * c; \ - p3 += decode[11] * c; - -#define stbir__store_output_tiny() \ - output[0] = p0; \ - output[1] = p1; \ - output[2] = p2; \ - output[3] = p3; \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 4; - -#define stbir__4_coeff_start() \ - float x0,x1,x2,x3,y0,y1,y2,y3,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0]; \ - x0 = decode[0] * c; \ - x1 = decode[1] * c; \ - x2 = decode[2] * c; \ - x3 = decode[3] * c; \ - c = hc[1]; \ - y0 = decode[4] * c; \ - y1 = decode[5] * c; \ - y2 = decode[6] * c; \ - y3 = decode[7] * c; \ - c = hc[2]; \ - x0 += decode[8] * c; \ - x1 += decode[9] * c; \ - x2 += decode[10] * c; \ - x3 += decode[11] * c; \ - c = hc[3]; \ - y0 += decode[12] * c; \ - y1 += decode[13] * c; \ - y2 += decode[14] * c; \ - y3 += decode[15] * c; - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0+(ofs)]; \ - x0 += decode[0+(ofs)*4] * c; \ - x1 += decode[1+(ofs)*4] * c; \ - x2 += decode[2+(ofs)*4] * c; \ - x3 += decode[3+(ofs)*4] * c; \ - c = hc[1+(ofs)]; \ - y0 += decode[4+(ofs)*4] * c; \ - y1 += decode[5+(ofs)*4] * c; \ - y2 += decode[6+(ofs)*4] * c; \ - y3 += decode[7+(ofs)*4] * c; \ - c = hc[2+(ofs)]; \ - x0 += decode[8+(ofs)*4] * c; \ - x1 += decode[9+(ofs)*4] * c; \ - x2 += decode[10+(ofs)*4] * c; \ - x3 += decode[11+(ofs)*4] * c; \ - c = hc[3+(ofs)]; \ - y0 += decode[12+(ofs)*4] * c; \ - y1 += decode[13+(ofs)*4] * c; \ - y2 += decode[14+(ofs)*4] * c; \ - y3 += decode[15+(ofs)*4] * c; - -#define stbir__1_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0+(ofs)]; \ - x0 += decode[0+(ofs)*4] * c; \ - x1 += decode[1+(ofs)*4] * c; \ - x2 += decode[2+(ofs)*4] * c; \ - x3 += decode[3+(ofs)*4] * c; - -#define stbir__2_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0+(ofs)]; \ - x0 += decode[0+(ofs)*4] * c; \ - x1 += decode[1+(ofs)*4] * c; \ - x2 += decode[2+(ofs)*4] * c; \ - x3 += decode[3+(ofs)*4] * c; \ - c = hc[1+(ofs)]; \ - y0 += decode[4+(ofs)*4] * c; \ - y1 += decode[5+(ofs)*4] * c; \ - y2 += decode[6+(ofs)*4] * c; \ - y3 += decode[7+(ofs)*4] * c; - -#define stbir__3_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0+(ofs)]; \ - x0 += decode[0+(ofs)*4] * c; \ - x1 += decode[1+(ofs)*4] * c; \ - x2 += decode[2+(ofs)*4] * c; \ - x3 += decode[3+(ofs)*4] * c; \ - c = hc[1+(ofs)]; \ - y0 += decode[4+(ofs)*4] * c; \ - y1 += decode[5+(ofs)*4] * c; \ - y2 += decode[6+(ofs)*4] * c; \ - y3 += decode[7+(ofs)*4] * c; \ - c = hc[2+(ofs)]; \ - x0 += decode[8+(ofs)*4] * c; \ - x1 += decode[9+(ofs)*4] * c; \ - x2 += decode[10+(ofs)*4] * c; \ - x3 += decode[11+(ofs)*4] * c; - -#define stbir__store_output() \ - output[0] = x0 + y0; \ - output[1] = x1 + y1; \ - output[2] = x2 + y2; \ - output[3] = x3 + y3; \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 4; - -#endif - -#define STBIR__horizontal_channels 4 -#define STB_IMAGE_RESIZE_DO_HORIZONTALS -#include STBIR__HEADER_FILENAME - - - -//================= -// Do 7 channel horizontal routines - -#ifdef STBIR_SIMD - -#define stbir__1_coeff_only() \ - stbir__simdf tot0,tot1,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1( c, hc ); \ - stbir__simdf_0123to0000( c, c ); \ - stbir__simdf_mult_mem( tot0, c, decode ); \ - stbir__simdf_mult_mem( tot1, c, decode+3 ); - -#define stbir__2_coeff_only() \ - stbir__simdf tot0,tot1,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load2( cs, hc ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_mult_mem( tot0, c, decode ); \ - stbir__simdf_mult_mem( tot1, c, decode+3 ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+7 ); \ - stbir__simdf_madd_mem( tot1, tot1, c,decode+10 ); - -#define stbir__3_coeff_only() \ - stbir__simdf tot0,tot1,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_mult_mem( tot0, c, decode ); \ - stbir__simdf_mult_mem( tot1, c, decode+3 ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+7 ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+10 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+14 ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+17 ); - -#define stbir__store_output_tiny() \ - stbir__simdf_store( output+3, tot1 ); \ - stbir__simdf_store( output, tot0 ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 7; - -#ifdef STBIR_SIMD8 - -#define stbir__4_coeff_start() \ - stbir__simdf8 tot0,tot1,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc ); \ - stbir__simdf8_0123to00000000( c, cs ); \ - stbir__simdf8_mult_mem( tot0, c, decode ); \ - stbir__simdf8_0123to11111111( c, cs ); \ - stbir__simdf8_mult_mem( tot1, c, decode+7 ); \ - stbir__simdf8_0123to22222222( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+14 ); \ - stbir__simdf8_0123to33333333( c, cs ); \ - stbir__simdf8_madd_mem( tot1, tot1, c, decode+21 ); - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc + (ofs) ); \ - stbir__simdf8_0123to00000000( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \ - stbir__simdf8_0123to11111111( c, cs ); \ - stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*7+7 ); \ - stbir__simdf8_0123to22222222( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7+14 ); \ - stbir__simdf8_0123to33333333( c, cs ); \ - stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*7+21 ); - -#define stbir__1_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load1b( c, hc + (ofs) ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); - -#define stbir__2_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load1b( c, hc + (ofs) ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \ - stbir__simdf8_load1b( c, hc + (ofs)+1 ); \ - stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*7+7 ); - -#define stbir__3_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf8_load4b( cs, hc + (ofs) ); \ - stbir__simdf8_0123to00000000( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \ - stbir__simdf8_0123to11111111( c, cs ); \ - stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*7+7 ); \ - stbir__simdf8_0123to22222222( c, cs ); \ - stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7+14 ); - -#define stbir__store_output() \ - stbir__simdf8_add( tot0, tot0, tot1 ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 7; \ - if ( output < output_end ) \ - { \ - stbir__simdf8_store( output-7, tot0 ); \ - continue; \ - } \ - stbir__simdf_store( output-7+3, stbir__simdf_swiz(stbir__simdf8_gettop4(tot0),0,0,1,2) ); \ - stbir__simdf_store( output-7, stbir__if_simdf8_cast_to_simdf4(tot0) ); \ - break; - -#else - -#define stbir__4_coeff_start() \ - stbir__simdf tot0,tot1,tot2,tot3,c,cs; \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_mult_mem( tot0, c, decode ); \ - stbir__simdf_mult_mem( tot1, c, decode+3 ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_mult_mem( tot2, c, decode+7 ); \ - stbir__simdf_mult_mem( tot3, c, decode+10 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+14 ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+17 ); \ - stbir__simdf_0123to3333( c, cs ); \ - stbir__simdf_madd_mem( tot2, tot2, c, decode+21 ); \ - stbir__simdf_madd_mem( tot3, tot3, c, decode+24 ); - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc + (ofs) ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+3 ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*7+7 ); \ - stbir__simdf_madd_mem( tot3, tot3, c, decode+(ofs)*7+10 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7+14 ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+17 ); \ - stbir__simdf_0123to3333( c, cs ); \ - stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*7+21 ); \ - stbir__simdf_madd_mem( tot3, tot3, c, decode+(ofs)*7+24 ); - -#define stbir__1_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load1( c, hc + (ofs) ); \ - stbir__simdf_0123to0000( c, c ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+3 ); \ - -#define stbir__2_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load2( cs, hc + (ofs) ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+3 ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*7+7 ); \ - stbir__simdf_madd_mem( tot3, tot3, c, decode+(ofs)*7+10 ); - -#define stbir__3_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - stbir__simdf_load( cs, hc + (ofs) ); \ - stbir__simdf_0123to0000( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+3 ); \ - stbir__simdf_0123to1111( c, cs ); \ - stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*7+7 ); \ - stbir__simdf_madd_mem( tot3, tot3, c, decode+(ofs)*7+10 ); \ - stbir__simdf_0123to2222( c, cs ); \ - stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7+14 ); \ - stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+17 ); - -#define stbir__store_output() \ - stbir__simdf_add( tot0, tot0, tot2 ); \ - stbir__simdf_add( tot1, tot1, tot3 ); \ - stbir__simdf_store( output+3, tot1 ); \ - stbir__simdf_store( output, tot0 ); \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 7; - -#endif - -#else - -#define stbir__1_coeff_only() \ - float tot0, tot1, tot2, tot3, tot4, tot5, tot6, c; \ - c = hc[0]; \ - tot0 = decode[0]*c; \ - tot1 = decode[1]*c; \ - tot2 = decode[2]*c; \ - tot3 = decode[3]*c; \ - tot4 = decode[4]*c; \ - tot5 = decode[5]*c; \ - tot6 = decode[6]*c; - -#define stbir__2_coeff_only() \ - float tot0, tot1, tot2, tot3, tot4, tot5, tot6, c; \ - c = hc[0]; \ - tot0 = decode[0]*c; \ - tot1 = decode[1]*c; \ - tot2 = decode[2]*c; \ - tot3 = decode[3]*c; \ - tot4 = decode[4]*c; \ - tot5 = decode[5]*c; \ - tot6 = decode[6]*c; \ - c = hc[1]; \ - tot0 += decode[7]*c; \ - tot1 += decode[8]*c; \ - tot2 += decode[9]*c; \ - tot3 += decode[10]*c; \ - tot4 += decode[11]*c; \ - tot5 += decode[12]*c; \ - tot6 += decode[13]*c; \ - -#define stbir__3_coeff_only() \ - float tot0, tot1, tot2, tot3, tot4, tot5, tot6, c; \ - c = hc[0]; \ - tot0 = decode[0]*c; \ - tot1 = decode[1]*c; \ - tot2 = decode[2]*c; \ - tot3 = decode[3]*c; \ - tot4 = decode[4]*c; \ - tot5 = decode[5]*c; \ - tot6 = decode[6]*c; \ - c = hc[1]; \ - tot0 += decode[7]*c; \ - tot1 += decode[8]*c; \ - tot2 += decode[9]*c; \ - tot3 += decode[10]*c; \ - tot4 += decode[11]*c; \ - tot5 += decode[12]*c; \ - tot6 += decode[13]*c; \ - c = hc[2]; \ - tot0 += decode[14]*c; \ - tot1 += decode[15]*c; \ - tot2 += decode[16]*c; \ - tot3 += decode[17]*c; \ - tot4 += decode[18]*c; \ - tot5 += decode[19]*c; \ - tot6 += decode[20]*c; \ - -#define stbir__store_output_tiny() \ - output[0] = tot0; \ - output[1] = tot1; \ - output[2] = tot2; \ - output[3] = tot3; \ - output[4] = tot4; \ - output[5] = tot5; \ - output[6] = tot6; \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 7; - -#define stbir__4_coeff_start() \ - float x0,x1,x2,x3,x4,x5,x6,y0,y1,y2,y3,y4,y5,y6,c; \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0]; \ - x0 = decode[0] * c; \ - x1 = decode[1] * c; \ - x2 = decode[2] * c; \ - x3 = decode[3] * c; \ - x4 = decode[4] * c; \ - x5 = decode[5] * c; \ - x6 = decode[6] * c; \ - c = hc[1]; \ - y0 = decode[7] * c; \ - y1 = decode[8] * c; \ - y2 = decode[9] * c; \ - y3 = decode[10] * c; \ - y4 = decode[11] * c; \ - y5 = decode[12] * c; \ - y6 = decode[13] * c; \ - c = hc[2]; \ - x0 += decode[14] * c; \ - x1 += decode[15] * c; \ - x2 += decode[16] * c; \ - x3 += decode[17] * c; \ - x4 += decode[18] * c; \ - x5 += decode[19] * c; \ - x6 += decode[20] * c; \ - c = hc[3]; \ - y0 += decode[21] * c; \ - y1 += decode[22] * c; \ - y2 += decode[23] * c; \ - y3 += decode[24] * c; \ - y4 += decode[25] * c; \ - y5 += decode[26] * c; \ - y6 += decode[27] * c; - -#define stbir__4_coeff_continue_from_4( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0+(ofs)]; \ - x0 += decode[0+(ofs)*7] * c; \ - x1 += decode[1+(ofs)*7] * c; \ - x2 += decode[2+(ofs)*7] * c; \ - x3 += decode[3+(ofs)*7] * c; \ - x4 += decode[4+(ofs)*7] * c; \ - x5 += decode[5+(ofs)*7] * c; \ - x6 += decode[6+(ofs)*7] * c; \ - c = hc[1+(ofs)]; \ - y0 += decode[7+(ofs)*7] * c; \ - y1 += decode[8+(ofs)*7] * c; \ - y2 += decode[9+(ofs)*7] * c; \ - y3 += decode[10+(ofs)*7] * c; \ - y4 += decode[11+(ofs)*7] * c; \ - y5 += decode[12+(ofs)*7] * c; \ - y6 += decode[13+(ofs)*7] * c; \ - c = hc[2+(ofs)]; \ - x0 += decode[14+(ofs)*7] * c; \ - x1 += decode[15+(ofs)*7] * c; \ - x2 += decode[16+(ofs)*7] * c; \ - x3 += decode[17+(ofs)*7] * c; \ - x4 += decode[18+(ofs)*7] * c; \ - x5 += decode[19+(ofs)*7] * c; \ - x6 += decode[20+(ofs)*7] * c; \ - c = hc[3+(ofs)]; \ - y0 += decode[21+(ofs)*7] * c; \ - y1 += decode[22+(ofs)*7] * c; \ - y2 += decode[23+(ofs)*7] * c; \ - y3 += decode[24+(ofs)*7] * c; \ - y4 += decode[25+(ofs)*7] * c; \ - y5 += decode[26+(ofs)*7] * c; \ - y6 += decode[27+(ofs)*7] * c; - -#define stbir__1_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0+(ofs)]; \ - x0 += decode[0+(ofs)*7] * c; \ - x1 += decode[1+(ofs)*7] * c; \ - x2 += decode[2+(ofs)*7] * c; \ - x3 += decode[3+(ofs)*7] * c; \ - x4 += decode[4+(ofs)*7] * c; \ - x5 += decode[5+(ofs)*7] * c; \ - x6 += decode[6+(ofs)*7] * c; \ - -#define stbir__2_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0+(ofs)]; \ - x0 += decode[0+(ofs)*7] * c; \ - x1 += decode[1+(ofs)*7] * c; \ - x2 += decode[2+(ofs)*7] * c; \ - x3 += decode[3+(ofs)*7] * c; \ - x4 += decode[4+(ofs)*7] * c; \ - x5 += decode[5+(ofs)*7] * c; \ - x6 += decode[6+(ofs)*7] * c; \ - c = hc[1+(ofs)]; \ - y0 += decode[7+(ofs)*7] * c; \ - y1 += decode[8+(ofs)*7] * c; \ - y2 += decode[9+(ofs)*7] * c; \ - y3 += decode[10+(ofs)*7] * c; \ - y4 += decode[11+(ofs)*7] * c; \ - y5 += decode[12+(ofs)*7] * c; \ - y6 += decode[13+(ofs)*7] * c; \ - -#define stbir__3_coeff_remnant( ofs ) \ - STBIR_SIMD_NO_UNROLL(decode); \ - c = hc[0+(ofs)]; \ - x0 += decode[0+(ofs)*7] * c; \ - x1 += decode[1+(ofs)*7] * c; \ - x2 += decode[2+(ofs)*7] * c; \ - x3 += decode[3+(ofs)*7] * c; \ - x4 += decode[4+(ofs)*7] * c; \ - x5 += decode[5+(ofs)*7] * c; \ - x6 += decode[6+(ofs)*7] * c; \ - c = hc[1+(ofs)]; \ - y0 += decode[7+(ofs)*7] * c; \ - y1 += decode[8+(ofs)*7] * c; \ - y2 += decode[9+(ofs)*7] * c; \ - y3 += decode[10+(ofs)*7] * c; \ - y4 += decode[11+(ofs)*7] * c; \ - y5 += decode[12+(ofs)*7] * c; \ - y6 += decode[13+(ofs)*7] * c; \ - c = hc[2+(ofs)]; \ - x0 += decode[14+(ofs)*7] * c; \ - x1 += decode[15+(ofs)*7] * c; \ - x2 += decode[16+(ofs)*7] * c; \ - x3 += decode[17+(ofs)*7] * c; \ - x4 += decode[18+(ofs)*7] * c; \ - x5 += decode[19+(ofs)*7] * c; \ - x6 += decode[20+(ofs)*7] * c; \ - -#define stbir__store_output() \ - output[0] = x0 + y0; \ - output[1] = x1 + y1; \ - output[2] = x2 + y2; \ - output[3] = x3 + y3; \ - output[4] = x4 + y4; \ - output[5] = x5 + y5; \ - output[6] = x6 + y6; \ - horizontal_coefficients += coefficient_width; \ - ++horizontal_contributors; \ - output += 7; - -#endif - -#define STBIR__horizontal_channels 7 -#define STB_IMAGE_RESIZE_DO_HORIZONTALS -#include STBIR__HEADER_FILENAME - - -// include all of the vertical resamplers (both scatter and gather versions) - -#define STBIR__vertical_channels 1 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 1 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 2 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 2 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 3 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 3 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 4 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 4 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 5 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 5 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 6 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 6 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 7 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 7 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 8 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#include STBIR__HEADER_FILENAME - -#define STBIR__vertical_channels 8 -#define STB_IMAGE_RESIZE_DO_VERTICALS -#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#include STBIR__HEADER_FILENAME - -typedef void STBIR_VERTICAL_GATHERFUNC( float * output, float const * coeffs, float const ** inputs, float const * input0_end ); - -static STBIR_VERTICAL_GATHERFUNC * stbir__vertical_gathers[ 8 ] = -{ - stbir__vertical_gather_with_1_coeffs,stbir__vertical_gather_with_2_coeffs,stbir__vertical_gather_with_3_coeffs,stbir__vertical_gather_with_4_coeffs,stbir__vertical_gather_with_5_coeffs,stbir__vertical_gather_with_6_coeffs,stbir__vertical_gather_with_7_coeffs,stbir__vertical_gather_with_8_coeffs -}; - -static STBIR_VERTICAL_GATHERFUNC * stbir__vertical_gathers_continues[ 8 ] = -{ - stbir__vertical_gather_with_1_coeffs_cont,stbir__vertical_gather_with_2_coeffs_cont,stbir__vertical_gather_with_3_coeffs_cont,stbir__vertical_gather_with_4_coeffs_cont,stbir__vertical_gather_with_5_coeffs_cont,stbir__vertical_gather_with_6_coeffs_cont,stbir__vertical_gather_with_7_coeffs_cont,stbir__vertical_gather_with_8_coeffs_cont -}; - -typedef void STBIR_VERTICAL_SCATTERFUNC( float ** outputs, float const * coeffs, float const * input, float const * input_end ); - -static STBIR_VERTICAL_SCATTERFUNC * stbir__vertical_scatter_sets[ 8 ] = -{ - stbir__vertical_scatter_with_1_coeffs,stbir__vertical_scatter_with_2_coeffs,stbir__vertical_scatter_with_3_coeffs,stbir__vertical_scatter_with_4_coeffs,stbir__vertical_scatter_with_5_coeffs,stbir__vertical_scatter_with_6_coeffs,stbir__vertical_scatter_with_7_coeffs,stbir__vertical_scatter_with_8_coeffs -}; - -static STBIR_VERTICAL_SCATTERFUNC * stbir__vertical_scatter_blends[ 8 ] = -{ - stbir__vertical_scatter_with_1_coeffs_cont,stbir__vertical_scatter_with_2_coeffs_cont,stbir__vertical_scatter_with_3_coeffs_cont,stbir__vertical_scatter_with_4_coeffs_cont,stbir__vertical_scatter_with_5_coeffs_cont,stbir__vertical_scatter_with_6_coeffs_cont,stbir__vertical_scatter_with_7_coeffs_cont,stbir__vertical_scatter_with_8_coeffs_cont -}; - - -static void stbir__encode_scanline( stbir__info const * stbir_info, void *output_buffer_data, float * encode_buffer, int row STBIR_ONLY_PROFILE_GET_SPLIT_INFO ) -{ - int num_pixels = stbir_info->horizontal.scale_info.output_sub_size; - int channels = stbir_info->channels; - int width_times_channels = num_pixels * channels; - void * output_buffer; - - // un-alpha weight if we need to - if ( stbir_info->alpha_unweight ) - { - STBIR_PROFILE_START( unalpha ); - stbir_info->alpha_unweight( encode_buffer, width_times_channels ); - STBIR_PROFILE_END( unalpha ); - } - - // write directly into output by default - output_buffer = output_buffer_data; - - // if we have an output callback, we first convert the decode buffer in place (and then hand that to the callback) - if ( stbir_info->out_pixels_cb ) - output_buffer = encode_buffer; - - STBIR_PROFILE_START( encode ); - // convert into the output buffer - stbir_info->encode_pixels( output_buffer, width_times_channels, encode_buffer ); - STBIR_PROFILE_END( encode ); - - // if we have an output callback, call it to send the data - if ( stbir_info->out_pixels_cb ) - stbir_info->out_pixels_cb( output_buffer, num_pixels, row, stbir_info->user_data ); -} - - -// Get the ring buffer pointer for an index -static float* stbir__get_ring_buffer_entry(stbir__info const * stbir_info, stbir__per_split_info const * split_info, int index ) -{ - STBIR_ASSERT( index < stbir_info->ring_buffer_num_entries ); - - #ifdef STBIR__SEPARATE_ALLOCATIONS - return split_info->ring_buffers[ index ]; - #else - return (float*) ( ( (char*) split_info->ring_buffer ) + ( index * stbir_info->ring_buffer_length_bytes ) ); - #endif -} - -// Get the specified scan line from the ring buffer -static float* stbir__get_ring_buffer_scanline(stbir__info const * stbir_info, stbir__per_split_info const * split_info, int get_scanline) -{ - int ring_buffer_index = (split_info->ring_buffer_begin_index + (get_scanline - split_info->ring_buffer_first_scanline)) % stbir_info->ring_buffer_num_entries; - return stbir__get_ring_buffer_entry( stbir_info, split_info, ring_buffer_index ); -} - -static void stbir__resample_horizontal_gather(stbir__info const * stbir_info, float* output_buffer, float const * input_buffer STBIR_ONLY_PROFILE_GET_SPLIT_INFO ) -{ - float const * decode_buffer = input_buffer - ( stbir_info->scanline_extents.conservative.n0 * stbir_info->effective_channels ); - - STBIR_PROFILE_START( horizontal ); - if ( ( stbir_info->horizontal.filter_enum == STBIR_FILTER_POINT_SAMPLE ) && ( stbir_info->horizontal.scale_info.scale == 1.0f ) ) - STBIR_MEMCPY( output_buffer, input_buffer, stbir_info->horizontal.scale_info.output_sub_size * sizeof( float ) * stbir_info->effective_channels ); - else - stbir_info->horizontal_gather_channels( output_buffer, stbir_info->horizontal.scale_info.output_sub_size, decode_buffer, stbir_info->horizontal.contributors, stbir_info->horizontal.coefficients, stbir_info->horizontal.coefficient_width ); - STBIR_PROFILE_END( horizontal ); -} - -static void stbir__resample_vertical_gather(stbir__info const * stbir_info, stbir__per_split_info* split_info, int n, int contrib_n0, int contrib_n1, float const * vertical_coefficients ) -{ - float* encode_buffer = split_info->vertical_buffer; - float* decode_buffer = split_info->decode_buffer; - int vertical_first = stbir_info->vertical_first; - int width = (vertical_first) ? ( stbir_info->scanline_extents.conservative.n1-stbir_info->scanline_extents.conservative.n0+1 ) : stbir_info->horizontal.scale_info.output_sub_size; - int width_times_channels = stbir_info->effective_channels * width; - - STBIR_ASSERT( stbir_info->vertical.is_gather ); - - // loop over the contributing scanlines and scale into the buffer - STBIR_PROFILE_START( vertical ); - { - int k = 0, total = contrib_n1 - contrib_n0 + 1; - STBIR_ASSERT( total > 0 ); - do { - float const * inputs[8]; - int i, cnt = total; if ( cnt > 8 ) cnt = 8; - for( i = 0 ; i < cnt ; i++ ) - inputs[ i ] = stbir__get_ring_buffer_scanline(stbir_info, split_info, k+i+contrib_n0 ); - - // call the N scanlines at a time function (up to 8 scanlines of blending at once) - ((k==0)?stbir__vertical_gathers:stbir__vertical_gathers_continues)[cnt-1]( (vertical_first) ? decode_buffer : encode_buffer, vertical_coefficients + k, inputs, inputs[0] + width_times_channels ); - k += cnt; - total -= cnt; - } while ( total ); - } - STBIR_PROFILE_END( vertical ); - - if ( vertical_first ) - { - // Now resample the gathered vertical data in the horizontal axis into the encode buffer - decode_buffer[ width_times_channels ] = 0.0f; // clear two over for horizontals with a remnant of 3 - decode_buffer[ width_times_channels+1 ] = 0.0f; - stbir__resample_horizontal_gather(stbir_info, encode_buffer, decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); - } - - stbir__encode_scanline( stbir_info, ( (char *) stbir_info->output_data ) + ((size_t)n * (size_t)stbir_info->output_stride_bytes), - encode_buffer, n STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); -} - -static void stbir__decode_and_resample_for_vertical_gather_loop(stbir__info const * stbir_info, stbir__per_split_info* split_info, int n) -{ - int ring_buffer_index; - float* ring_buffer; - - // Decode the nth scanline from the source image into the decode buffer. - stbir__decode_scanline( stbir_info, n, split_info->decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); - - // update new end scanline - split_info->ring_buffer_last_scanline = n; - - // get ring buffer - ring_buffer_index = (split_info->ring_buffer_begin_index + (split_info->ring_buffer_last_scanline - split_info->ring_buffer_first_scanline)) % stbir_info->ring_buffer_num_entries; - ring_buffer = stbir__get_ring_buffer_entry(stbir_info, split_info, ring_buffer_index); - - // Now resample it into the ring buffer. - stbir__resample_horizontal_gather( stbir_info, ring_buffer, split_info->decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); - - // Now it's sitting in the ring buffer ready to be used as source for the vertical sampling. -} - -static void stbir__vertical_gather_loop( stbir__info const * stbir_info, stbir__per_split_info* split_info, int split_count ) -{ - int y, start_output_y, end_output_y; - stbir__contributors* vertical_contributors = stbir_info->vertical.contributors; - float const * vertical_coefficients = stbir_info->vertical.coefficients; - - STBIR_ASSERT( stbir_info->vertical.is_gather ); - - start_output_y = split_info->start_output_y; - end_output_y = split_info[split_count-1].end_output_y; - - vertical_contributors += start_output_y; - vertical_coefficients += start_output_y * stbir_info->vertical.coefficient_width; - - // initialize the ring buffer for gathering - split_info->ring_buffer_begin_index = 0; - split_info->ring_buffer_first_scanline = vertical_contributors->n0; - split_info->ring_buffer_last_scanline = split_info->ring_buffer_first_scanline - 1; // means "empty" - - for (y = start_output_y; y < end_output_y; y++) - { - int in_first_scanline, in_last_scanline; - - in_first_scanline = vertical_contributors->n0; - in_last_scanline = vertical_contributors->n1; - - // make sure the indexing hasn't broken - STBIR_ASSERT( in_first_scanline >= split_info->ring_buffer_first_scanline ); - - // Load in new scanlines - while (in_last_scanline > split_info->ring_buffer_last_scanline) - { - STBIR_ASSERT( ( split_info->ring_buffer_last_scanline - split_info->ring_buffer_first_scanline + 1 ) <= stbir_info->ring_buffer_num_entries ); - - // make sure there was room in the ring buffer when we add new scanlines - if ( ( split_info->ring_buffer_last_scanline - split_info->ring_buffer_first_scanline + 1 ) == stbir_info->ring_buffer_num_entries ) - { - split_info->ring_buffer_first_scanline++; - split_info->ring_buffer_begin_index++; - } - - if ( stbir_info->vertical_first ) - { - float * ring_buffer = stbir__get_ring_buffer_scanline( stbir_info, split_info, ++split_info->ring_buffer_last_scanline ); - // Decode the nth scanline from the source image into the decode buffer. - stbir__decode_scanline( stbir_info, split_info->ring_buffer_last_scanline, ring_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); - } - else - { - stbir__decode_and_resample_for_vertical_gather_loop(stbir_info, split_info, split_info->ring_buffer_last_scanline + 1); - } - } - - // Now all buffers should be ready to write a row of vertical sampling, so do it. - stbir__resample_vertical_gather(stbir_info, split_info, y, in_first_scanline, in_last_scanline, vertical_coefficients ); - - ++vertical_contributors; - vertical_coefficients += stbir_info->vertical.coefficient_width; - } -} - -#define STBIR__FLOAT_EMPTY_MARKER 3.0e+38F -#define STBIR__FLOAT_BUFFER_IS_EMPTY(ptr) ((ptr)[0]==STBIR__FLOAT_EMPTY_MARKER) - -static void stbir__encode_first_scanline_from_scatter(stbir__info const * stbir_info, stbir__per_split_info* split_info) -{ - // evict a scanline out into the output buffer - float* ring_buffer_entry = stbir__get_ring_buffer_entry(stbir_info, split_info, split_info->ring_buffer_begin_index ); - - // dump the scanline out - stbir__encode_scanline( stbir_info, ( (char *)stbir_info->output_data ) + ( (size_t)split_info->ring_buffer_first_scanline * (size_t)stbir_info->output_stride_bytes ), ring_buffer_entry, split_info->ring_buffer_first_scanline STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); - - // mark it as empty - ring_buffer_entry[ 0 ] = STBIR__FLOAT_EMPTY_MARKER; - - // advance the first scanline - split_info->ring_buffer_first_scanline++; - if ( ++split_info->ring_buffer_begin_index == stbir_info->ring_buffer_num_entries ) - split_info->ring_buffer_begin_index = 0; -} - -static void stbir__horizontal_resample_and_encode_first_scanline_from_scatter(stbir__info const * stbir_info, stbir__per_split_info* split_info) -{ - // evict a scanline out into the output buffer - - float* ring_buffer_entry = stbir__get_ring_buffer_entry(stbir_info, split_info, split_info->ring_buffer_begin_index ); - - // Now resample it into the buffer. - stbir__resample_horizontal_gather( stbir_info, split_info->vertical_buffer, ring_buffer_entry STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); - - // dump the scanline out - stbir__encode_scanline( stbir_info, ( (char *)stbir_info->output_data ) + ( (size_t)split_info->ring_buffer_first_scanline * (size_t)stbir_info->output_stride_bytes ), split_info->vertical_buffer, split_info->ring_buffer_first_scanline STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); - - // mark it as empty - ring_buffer_entry[ 0 ] = STBIR__FLOAT_EMPTY_MARKER; - - // advance the first scanline - split_info->ring_buffer_first_scanline++; - if ( ++split_info->ring_buffer_begin_index == stbir_info->ring_buffer_num_entries ) - split_info->ring_buffer_begin_index = 0; -} - -static void stbir__resample_vertical_scatter(stbir__info const * stbir_info, stbir__per_split_info* split_info, int n0, int n1, float const * vertical_coefficients, float const * vertical_buffer, float const * vertical_buffer_end ) -{ - STBIR_ASSERT( !stbir_info->vertical.is_gather ); - - STBIR_PROFILE_START( vertical ); - { - int k = 0, total = n1 - n0 + 1; - STBIR_ASSERT( total > 0 ); - do { - float * outputs[8]; - int i, n = total; if ( n > 8 ) n = 8; - for( i = 0 ; i < n ; i++ ) - { - outputs[ i ] = stbir__get_ring_buffer_scanline(stbir_info, split_info, k+i+n0 ); - if ( ( i ) && ( STBIR__FLOAT_BUFFER_IS_EMPTY( outputs[i] ) != STBIR__FLOAT_BUFFER_IS_EMPTY( outputs[0] ) ) ) // make sure runs are of the same type - { - n = i; - break; - } - } - // call the scatter to N scanlines at a time function (up to 8 scanlines of scattering at once) - ((STBIR__FLOAT_BUFFER_IS_EMPTY( outputs[0] ))?stbir__vertical_scatter_sets:stbir__vertical_scatter_blends)[n-1]( outputs, vertical_coefficients + k, vertical_buffer, vertical_buffer_end ); - k += n; - total -= n; - } while ( total ); - } - - STBIR_PROFILE_END( vertical ); -} - -typedef void stbir__handle_scanline_for_scatter_func(stbir__info const * stbir_info, stbir__per_split_info* split_info); - -static void stbir__vertical_scatter_loop( stbir__info const * stbir_info, stbir__per_split_info* split_info, int split_count ) -{ - int y, start_output_y, end_output_y, start_input_y, end_input_y; - stbir__contributors* vertical_contributors = stbir_info->vertical.contributors; - float const * vertical_coefficients = stbir_info->vertical.coefficients; - stbir__handle_scanline_for_scatter_func * handle_scanline_for_scatter; - void * scanline_scatter_buffer; - void * scanline_scatter_buffer_end; - int on_first_input_y, last_input_y; - int width = (stbir_info->vertical_first) ? ( stbir_info->scanline_extents.conservative.n1-stbir_info->scanline_extents.conservative.n0+1 ) : stbir_info->horizontal.scale_info.output_sub_size; - int width_times_channels = stbir_info->effective_channels * width; - - STBIR_ASSERT( !stbir_info->vertical.is_gather ); - - start_output_y = split_info->start_output_y; - end_output_y = split_info[split_count-1].end_output_y; // may do multiple split counts - - start_input_y = split_info->start_input_y; - end_input_y = split_info[split_count-1].end_input_y; - - // adjust for starting offset start_input_y - y = start_input_y + stbir_info->vertical.filter_pixel_margin; - vertical_contributors += y ; - vertical_coefficients += stbir_info->vertical.coefficient_width * y; - - if ( stbir_info->vertical_first ) - { - handle_scanline_for_scatter = stbir__horizontal_resample_and_encode_first_scanline_from_scatter; - scanline_scatter_buffer = split_info->decode_buffer; - scanline_scatter_buffer_end = ( (char*) scanline_scatter_buffer ) + sizeof( float ) * stbir_info->effective_channels * (stbir_info->scanline_extents.conservative.n1-stbir_info->scanline_extents.conservative.n0+1); - } - else - { - handle_scanline_for_scatter = stbir__encode_first_scanline_from_scatter; - scanline_scatter_buffer = split_info->vertical_buffer; - scanline_scatter_buffer_end = ( (char*) scanline_scatter_buffer ) + sizeof( float ) * stbir_info->effective_channels * stbir_info->horizontal.scale_info.output_sub_size; - } - - // initialize the ring buffer for scattering - split_info->ring_buffer_first_scanline = start_output_y; - split_info->ring_buffer_last_scanline = -1; - split_info->ring_buffer_begin_index = -1; - - // mark all the buffers as empty to start - for( y = 0 ; y < stbir_info->ring_buffer_num_entries ; y++ ) - { - float * decode_buffer = stbir__get_ring_buffer_entry( stbir_info, split_info, y ); - decode_buffer[ width_times_channels ] = 0.0f; // clear two over for horizontals with a remnant of 3 - decode_buffer[ width_times_channels+1 ] = 0.0f; - decode_buffer[0] = STBIR__FLOAT_EMPTY_MARKER; // only used on scatter - } - - // do the loop in input space - on_first_input_y = 1; last_input_y = start_input_y; - for (y = start_input_y ; y < end_input_y; y++) - { - int out_first_scanline, out_last_scanline; - - out_first_scanline = vertical_contributors->n0; - out_last_scanline = vertical_contributors->n1; - - STBIR_ASSERT(out_last_scanline - out_first_scanline + 1 <= stbir_info->ring_buffer_num_entries); - - if ( ( out_last_scanline >= out_first_scanline ) && ( ( ( out_first_scanline >= start_output_y ) && ( out_first_scanline < end_output_y ) ) || ( ( out_last_scanline >= start_output_y ) && ( out_last_scanline < end_output_y ) ) ) ) - { - float const * vc = vertical_coefficients; - - // keep track of the range actually seen for the next resize - last_input_y = y; - if ( ( on_first_input_y ) && ( y > start_input_y ) ) - split_info->start_input_y = y; - on_first_input_y = 0; - - // clip the region - if ( out_first_scanline < start_output_y ) - { - vc += start_output_y - out_first_scanline; - out_first_scanline = start_output_y; - } - - if ( out_last_scanline >= end_output_y ) - out_last_scanline = end_output_y - 1; - - // if very first scanline, init the index - if (split_info->ring_buffer_begin_index < 0) - split_info->ring_buffer_begin_index = out_first_scanline - start_output_y; - - STBIR_ASSERT( split_info->ring_buffer_begin_index <= out_first_scanline ); - - // Decode the nth scanline from the source image into the decode buffer. - stbir__decode_scanline( stbir_info, y, split_info->decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); - - // When horizontal first, we resample horizontally into the vertical buffer before we scatter it out - if ( !stbir_info->vertical_first ) - stbir__resample_horizontal_gather( stbir_info, split_info->vertical_buffer, split_info->decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO ); - - // Now it's sitting in the buffer ready to be distributed into the ring buffers. - - // evict from the ringbuffer, if we need are full - if ( ( ( split_info->ring_buffer_last_scanline - split_info->ring_buffer_first_scanline + 1 ) == stbir_info->ring_buffer_num_entries ) && - ( out_last_scanline > split_info->ring_buffer_last_scanline ) ) - handle_scanline_for_scatter( stbir_info, split_info ); - - // Now the horizontal buffer is ready to write to all ring buffer rows, so do it. - stbir__resample_vertical_scatter(stbir_info, split_info, out_first_scanline, out_last_scanline, vc, (float*)scanline_scatter_buffer, (float*)scanline_scatter_buffer_end ); - - // update the end of the buffer - if ( out_last_scanline > split_info->ring_buffer_last_scanline ) - split_info->ring_buffer_last_scanline = out_last_scanline; - } - ++vertical_contributors; - vertical_coefficients += stbir_info->vertical.coefficient_width; - } - - // now evict the scanlines that are left over in the ring buffer - while ( split_info->ring_buffer_first_scanline < end_output_y ) - handle_scanline_for_scatter(stbir_info, split_info); - - // update the end_input_y if we do multiple resizes with the same data - ++last_input_y; - for( y = 0 ; y < split_count; y++ ) - if ( split_info[y].end_input_y > last_input_y ) - split_info[y].end_input_y = last_input_y; -} - - -static stbir__kernel_callback * stbir__builtin_kernels[] = { 0, stbir__filter_trapezoid, stbir__filter_triangle, stbir__filter_cubic, stbir__filter_catmullrom, stbir__filter_mitchell, stbir__filter_point }; -static stbir__support_callback * stbir__builtin_supports[] = { 0, stbir__support_trapezoid, stbir__support_one, stbir__support_two, stbir__support_two, stbir__support_two, stbir__support_zeropoint5 }; - -static void stbir__set_sampler(stbir__sampler * samp, stbir_filter filter, stbir__kernel_callback * kernel, stbir__support_callback * support, stbir_edge edge, stbir__scale_info * scale_info, int always_gather, void * user_data ) -{ - // set filter - if (filter == 0) - { - filter = STBIR_DEFAULT_FILTER_DOWNSAMPLE; // default to downsample - if (scale_info->scale >= ( 1.0f - stbir__small_float ) ) - { - if ( (scale_info->scale <= ( 1.0f + stbir__small_float ) ) && ( STBIR_CEILF(scale_info->pixel_shift) == scale_info->pixel_shift ) ) - filter = STBIR_FILTER_POINT_SAMPLE; - else - filter = STBIR_DEFAULT_FILTER_UPSAMPLE; - } - } - samp->filter_enum = filter; - - STBIR_ASSERT(samp->filter_enum != 0); - STBIR_ASSERT((unsigned)samp->filter_enum < STBIR_FILTER_OTHER); - samp->filter_kernel = stbir__builtin_kernels[ filter ]; - samp->filter_support = stbir__builtin_supports[ filter ]; - - if ( kernel && support ) - { - samp->filter_kernel = kernel; - samp->filter_support = support; - samp->filter_enum = STBIR_FILTER_OTHER; - } - - samp->edge = edge; - samp->filter_pixel_width = stbir__get_filter_pixel_width (samp->filter_support, scale_info->scale, user_data ); - // Gather is always better, but in extreme downsamples, you have to most or all of the data in memory - // For horizontal, we always have all the pixels, so we always use gather here (always_gather==1). - // For vertical, we use gather if scaling up (which means we will have samp->filter_pixel_width - // scanlines in memory at once). - samp->is_gather = 0; - if ( scale_info->scale >= ( 1.0f - stbir__small_float ) ) - samp->is_gather = 1; - else if ( ( always_gather ) || ( samp->filter_pixel_width <= STBIR_FORCE_GATHER_FILTER_SCANLINES_AMOUNT ) ) - samp->is_gather = 2; - - // pre calculate stuff based on the above - samp->coefficient_width = stbir__get_coefficient_width(samp, samp->is_gather, user_data); - - // filter_pixel_width is the conservative size in pixels of input that affect an output pixel. - // In rare cases (only with 2 pix to 1 pix with the default filters), it's possible that the - // filter will extend before or after the scanline beyond just one extra entire copy of the - // scanline (we would hit the edge twice). We don't let you do that, so we clamp the total - // width to 3x the total of input pixel (once for the scanline, once for the left side - // overhang, and once for the right side). We only do this for edge mode, since the other - // modes can just re-edge clamp back in again. - if ( edge == STBIR_EDGE_WRAP ) - if ( samp->filter_pixel_width > ( scale_info->input_full_size * 3 ) ) - samp->filter_pixel_width = scale_info->input_full_size * 3; - - // This is how much to expand buffers to account for filters seeking outside - // the image boundaries. - samp->filter_pixel_margin = samp->filter_pixel_width / 2; - - // filter_pixel_margin is the amount that this filter can overhang on just one side of either - // end of the scanline (left or the right). Since we only allow you to overhang 1 scanline's - // worth of pixels, we clamp this one side of overhang to the input scanline size. Again, - // this clamping only happens in rare cases with the default filters (2 pix to 1 pix). - if ( edge == STBIR_EDGE_WRAP ) - if ( samp->filter_pixel_margin > scale_info->input_full_size ) - samp->filter_pixel_margin = scale_info->input_full_size; - - samp->num_contributors = stbir__get_contributors(samp, samp->is_gather); - - samp->contributors_size = samp->num_contributors * sizeof(stbir__contributors); - samp->coefficients_size = samp->num_contributors * samp->coefficient_width * sizeof(float) + sizeof(float)*STBIR_INPUT_CALLBACK_PADDING; // extra sizeof(float) is padding - - samp->gather_prescatter_contributors = 0; - samp->gather_prescatter_coefficients = 0; - if ( samp->is_gather == 0 ) - { - samp->gather_prescatter_coefficient_width = samp->filter_pixel_width; - samp->gather_prescatter_num_contributors = stbir__get_contributors(samp, 2); - samp->gather_prescatter_contributors_size = samp->gather_prescatter_num_contributors * sizeof(stbir__contributors); - samp->gather_prescatter_coefficients_size = samp->gather_prescatter_num_contributors * samp->gather_prescatter_coefficient_width * sizeof(float); - } -} - -static void stbir__get_conservative_extents( stbir__sampler * samp, stbir__contributors * range, void * user_data ) -{ - float scale = samp->scale_info.scale; - float out_shift = samp->scale_info.pixel_shift; - stbir__support_callback * support = samp->filter_support; - int input_full_size = samp->scale_info.input_full_size; - stbir_edge edge = samp->edge; - float inv_scale = samp->scale_info.inv_scale; - - STBIR_ASSERT( samp->is_gather != 0 ); - - if ( samp->is_gather == 1 ) - { - int in_first_pixel, in_last_pixel; - float out_filter_radius = support(inv_scale, user_data) * scale; - - stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, 0.5, out_filter_radius, inv_scale, out_shift, input_full_size, edge ); - range->n0 = in_first_pixel; - stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, ( (float)(samp->scale_info.output_sub_size-1) ) + 0.5f, out_filter_radius, inv_scale, out_shift, input_full_size, edge ); - range->n1 = in_last_pixel; - } - else if ( samp->is_gather == 2 ) // downsample gather, refine - { - float in_pixels_radius = support(scale, user_data) * inv_scale; - int filter_pixel_margin = samp->filter_pixel_margin; - int output_sub_size = samp->scale_info.output_sub_size; - int input_end; - int n; - int in_first_pixel, in_last_pixel; - - // get a conservative area of the input range - stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, 0, 0, inv_scale, out_shift, input_full_size, edge ); - range->n0 = in_first_pixel; - stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, (float)output_sub_size, 0, inv_scale, out_shift, input_full_size, edge ); - range->n1 = in_last_pixel; - - // now go through the margin to the start of area to find bottom - n = range->n0 + 1; - input_end = -filter_pixel_margin; - while( n >= input_end ) - { - int out_first_pixel, out_last_pixel; - stbir__calculate_out_pixel_range( &out_first_pixel, &out_last_pixel, ((float)n)+0.5f, in_pixels_radius, scale, out_shift, output_sub_size ); - if ( out_first_pixel > out_last_pixel ) - break; - - if ( ( out_first_pixel < output_sub_size ) || ( out_last_pixel >= 0 ) ) - range->n0 = n; - --n; - } - - // now go through the end of the area through the margin to find top - n = range->n1 - 1; - input_end = n + 1 + filter_pixel_margin; - while( n <= input_end ) - { - int out_first_pixel, out_last_pixel; - stbir__calculate_out_pixel_range( &out_first_pixel, &out_last_pixel, ((float)n)+0.5f, in_pixels_radius, scale, out_shift, output_sub_size ); - if ( out_first_pixel > out_last_pixel ) - break; - if ( ( out_first_pixel < output_sub_size ) || ( out_last_pixel >= 0 ) ) - range->n1 = n; - ++n; - } - } - - if ( samp->edge == STBIR_EDGE_WRAP ) - { - // if we are wrapping, and we are very close to the image size (so the edges might merge), just use the scanline up to the edge - if ( ( range->n0 > 0 ) && ( range->n1 >= input_full_size ) ) - { - int marg = range->n1 - input_full_size + 1; - if ( ( marg + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= range->n0 ) - range->n0 = 0; - } - if ( ( range->n0 < 0 ) && ( range->n1 < (input_full_size-1) ) ) - { - int marg = -range->n0; - if ( ( input_full_size - marg - STBIR__MERGE_RUNS_PIXEL_THRESHOLD - 1 ) <= range->n1 ) - range->n1 = input_full_size - 1; - } - } - else - { - // for non-edge-wrap modes, we never read over the edge, so clamp - if ( range->n0 < 0 ) - range->n0 = 0; - if ( range->n1 >= input_full_size ) - range->n1 = input_full_size - 1; - } -} - -static void stbir__get_split_info( stbir__per_split_info* split_info, int splits, int output_height, int vertical_pixel_margin, int input_full_height, int is_gather, stbir__contributors * contribs ) -{ - int i, cur; - int left = output_height; - - cur = 0; - for( i = 0 ; i < splits ; i++ ) - { - int each; - - split_info[i].start_output_y = cur; - each = left / ( splits - i ); - split_info[i].end_output_y = cur + each; - - // ok, when we are gathering, we need to make sure we are starting on a y offset that doesn't have - // a "special" set of coefficients. Basically, with exactly the right filter at exactly the right - // resize at exactly the right phase, some of the coefficents can be zero. When they are zero, we - // don't process them at all. But this leads to a tricky thing with the thread splits, where we - // might have a set of two coeffs like this for example: (4,4) and (3,6). The 4,4 means there was - // just one single coeff because things worked out perfectly (normally, they all have 4 coeffs - // like the range 3,6. The problem is that if we start right on the (4,4) on a brand new thread, - // then when we get to (3,6), we don't have the "3" sample in memory (because we didn't load - // it on the initial (4,4) range because it didn't have a 3 (we only add new samples that are - // larger than our existing samples - it's just how the eviction works). So, our solution here - // is pretty simple, if we start right on a range that has samples that start earlier, then we - // simply bump up our previous thread split range to include it, and then start this threads - // range with the smaller sample. It just moves one scanline from one thread split to another, - // so that we end with the unusual one, instead of start with it. To do this, we check 2-4 - // sample at each thread split start and then occassionally move them. - - if ( ( is_gather ) && ( i ) ) - { - stbir__contributors * small_contribs; - int j, smallest, stop, start_n0; - stbir__contributors * split_contribs = contribs + cur; - - // scan for a max of 3x the filter width or until the next thread split - stop = vertical_pixel_margin * 3; - if ( each < stop ) - stop = each; - - // loops a few times before early out - smallest = 0; - small_contribs = split_contribs; - start_n0 = small_contribs->n0; - for( j = 1 ; j <= stop ; j++ ) - { - ++split_contribs; - if ( split_contribs->n0 > start_n0 ) - break; - if ( split_contribs->n0 < small_contribs->n0 ) - { - small_contribs = split_contribs; - smallest = j; - } - } - - split_info[i-1].end_output_y += smallest; - split_info[i].start_output_y += smallest; - } - - cur += each; - left -= each; - - // scatter range (updated to minimum as you run it) - split_info[i].start_input_y = -vertical_pixel_margin; - split_info[i].end_input_y = input_full_height + vertical_pixel_margin; - } -} - -static void stbir__free_internal_mem( stbir__info *info ) -{ - #define STBIR__FREE_AND_CLEAR( ptr ) { if ( ptr ) { void * p = (ptr); (ptr) = 0; STBIR_FREE( p, info->user_data); } } - - if ( info ) - { - #ifndef STBIR__SEPARATE_ALLOCATIONS - STBIR__FREE_AND_CLEAR( info->alloced_mem ); - #else - int i,j; - - if ( ( info->vertical.gather_prescatter_contributors ) && ( (void*)info->vertical.gather_prescatter_contributors != (void*)info->split_info[0].decode_buffer ) ) - { - STBIR__FREE_AND_CLEAR( info->vertical.gather_prescatter_coefficients ); - STBIR__FREE_AND_CLEAR( info->vertical.gather_prescatter_contributors ); - } - for( i = 0 ; i < info->splits ; i++ ) - { - for( j = 0 ; j < info->alloc_ring_buffer_num_entries ; j++ ) - { - #ifdef STBIR_SIMD8 - if ( info->effective_channels == 3 ) - --info->split_info[i].ring_buffers[j]; // avx in 3 channel mode needs one float at the start of the buffer - #endif - STBIR__FREE_AND_CLEAR( info->split_info[i].ring_buffers[j] ); - } - - #ifdef STBIR_SIMD8 - if ( info->effective_channels == 3 ) - --info->split_info[i].decode_buffer; // avx in 3 channel mode needs one float at the start of the buffer - #endif - STBIR__FREE_AND_CLEAR( info->split_info[i].decode_buffer ); - STBIR__FREE_AND_CLEAR( info->split_info[i].ring_buffers ); - STBIR__FREE_AND_CLEAR( info->split_info[i].vertical_buffer ); - } - STBIR__FREE_AND_CLEAR( info->split_info ); - if ( info->vertical.coefficients != info->horizontal.coefficients ) - { - STBIR__FREE_AND_CLEAR( info->vertical.coefficients ); - STBIR__FREE_AND_CLEAR( info->vertical.contributors ); - } - STBIR__FREE_AND_CLEAR( info->horizontal.coefficients ); - STBIR__FREE_AND_CLEAR( info->horizontal.contributors ); - STBIR__FREE_AND_CLEAR( info->alloced_mem ); - STBIR_FREE( info, info->user_data ); - #endif - } - - #undef STBIR__FREE_AND_CLEAR -} - -static int stbir__get_max_split( int splits, int height ) -{ - int i; - int max = 0; - - for( i = 0 ; i < splits ; i++ ) - { - int each = height / ( splits - i ); - if ( each > max ) - max = each; - height -= each; - } - return max; -} - -static stbir__horizontal_gather_channels_func ** stbir__horizontal_gather_n_coeffs_funcs[8] = -{ - 0, stbir__horizontal_gather_1_channels_with_n_coeffs_funcs, stbir__horizontal_gather_2_channels_with_n_coeffs_funcs, stbir__horizontal_gather_3_channels_with_n_coeffs_funcs, stbir__horizontal_gather_4_channels_with_n_coeffs_funcs, 0,0, stbir__horizontal_gather_7_channels_with_n_coeffs_funcs -}; - -static stbir__horizontal_gather_channels_func ** stbir__horizontal_gather_channels_funcs[8] = -{ - 0, stbir__horizontal_gather_1_channels_funcs, stbir__horizontal_gather_2_channels_funcs, stbir__horizontal_gather_3_channels_funcs, stbir__horizontal_gather_4_channels_funcs, 0,0, stbir__horizontal_gather_7_channels_funcs -}; - -// there are six resize classifications: 0 == vertical scatter, 1 == vertical gather < 1x scale, 2 == vertical gather 1x-2x scale, 4 == vertical gather < 3x scale, 4 == vertical gather > 3x scale, 5 == <=4 pixel height, 6 == <=4 pixel wide column -#define STBIR_RESIZE_CLASSIFICATIONS 8 - -static float stbir__compute_weights[5][STBIR_RESIZE_CLASSIFICATIONS][4]= // 5 = 0=1chan, 1=2chan, 2=3chan, 3=4chan, 4=7chan -{ - { - { 1.00000f, 1.00000f, 0.31250f, 1.00000f }, - { 0.56250f, 0.59375f, 0.00000f, 0.96875f }, - { 1.00000f, 0.06250f, 0.00000f, 1.00000f }, - { 0.00000f, 0.09375f, 1.00000f, 1.00000f }, - { 1.00000f, 1.00000f, 1.00000f, 1.00000f }, - { 0.03125f, 0.12500f, 1.00000f, 1.00000f }, - { 0.06250f, 0.12500f, 0.00000f, 1.00000f }, - { 0.00000f, 1.00000f, 0.00000f, 0.03125f }, - }, { - { 0.00000f, 0.84375f, 0.00000f, 0.03125f }, - { 0.09375f, 0.93750f, 0.00000f, 0.78125f }, - { 0.87500f, 0.21875f, 0.00000f, 0.96875f }, - { 0.09375f, 0.09375f, 1.00000f, 1.00000f }, - { 1.00000f, 1.00000f, 1.00000f, 1.00000f }, - { 0.03125f, 0.12500f, 1.00000f, 1.00000f }, - { 0.06250f, 0.12500f, 0.00000f, 1.00000f }, - { 0.00000f, 1.00000f, 0.00000f, 0.53125f }, - }, { - { 0.00000f, 0.53125f, 0.00000f, 0.03125f }, - { 0.06250f, 0.96875f, 0.00000f, 0.53125f }, - { 0.87500f, 0.18750f, 0.00000f, 0.93750f }, - { 0.00000f, 0.09375f, 1.00000f, 1.00000f }, - { 1.00000f, 1.00000f, 1.00000f, 1.00000f }, - { 0.03125f, 0.12500f, 1.00000f, 1.00000f }, - { 0.06250f, 0.12500f, 0.00000f, 1.00000f }, - { 0.00000f, 1.00000f, 0.00000f, 0.56250f }, - }, { - { 0.00000f, 0.50000f, 0.00000f, 0.71875f }, - { 0.06250f, 0.84375f, 0.00000f, 0.87500f }, - { 1.00000f, 0.50000f, 0.50000f, 0.96875f }, - { 1.00000f, 0.09375f, 0.31250f, 0.50000f }, - { 1.00000f, 1.00000f, 1.00000f, 1.00000f }, - { 1.00000f, 0.03125f, 0.03125f, 0.53125f }, - { 0.18750f, 0.12500f, 0.00000f, 1.00000f }, - { 0.00000f, 1.00000f, 0.03125f, 0.18750f }, - }, { - { 0.00000f, 0.59375f, 0.00000f, 0.96875f }, - { 0.06250f, 0.81250f, 0.06250f, 0.59375f }, - { 0.75000f, 0.43750f, 0.12500f, 0.96875f }, - { 0.87500f, 0.06250f, 0.18750f, 0.43750f }, - { 1.00000f, 1.00000f, 1.00000f, 1.00000f }, - { 0.15625f, 0.12500f, 1.00000f, 1.00000f }, - { 0.06250f, 0.12500f, 0.00000f, 1.00000f }, - { 0.00000f, 1.00000f, 0.03125f, 0.34375f }, - } -}; - -// structure that allow us to query and override info for training the costs -typedef struct STBIR__V_FIRST_INFO -{ - double v_cost, h_cost; - int control_v_first; // 0 = no control, 1 = force hori, 2 = force vert - int v_first; - int v_resize_classification; - int is_gather; -} STBIR__V_FIRST_INFO; - -#ifdef STBIR__V_FIRST_INFO_BUFFER -static STBIR__V_FIRST_INFO STBIR__V_FIRST_INFO_BUFFER = {0}; -#define STBIR__V_FIRST_INFO_POINTER &STBIR__V_FIRST_INFO_BUFFER -#else -#define STBIR__V_FIRST_INFO_POINTER 0 -#endif - -// Figure out whether to scale along the horizontal or vertical first. -// This only *super* important when you are scaling by a massively -// different amount in the vertical vs the horizontal (for example, if -// you are scaling by 2x in the width, and 0.5x in the height, then you -// want to do the vertical scale first, because it's around 3x faster -// in that order. -// -// In more normal circumstances, this makes a 20-40% differences, so -// it's good to get right, but not critical. The normal way that you -// decide which direction goes first is just figuring out which -// direction does more multiplies. But with modern CPUs with their -// fancy caches and SIMD and high IPC abilities, so there's just a lot -// more that goes into it. -// -// My handwavy sort of solution is to have an app that does a whole -// bunch of timing for both vertical and horizontal first modes, -// and then another app that can read lots of these timing files -// and try to search for the best weights to use. Dotimings.c -// is the app that does a bunch of timings, and vf_train.c is the -// app that solves for the best weights (and shows how well it -// does currently). - -static int stbir__should_do_vertical_first( float weights_table[STBIR_RESIZE_CLASSIFICATIONS][4], int horizontal_filter_pixel_width, float horizontal_scale, int horizontal_output_size, int vertical_filter_pixel_width, float vertical_scale, int vertical_output_size, int is_gather, STBIR__V_FIRST_INFO * info ) -{ - double v_cost, h_cost; - float * weights; - int vertical_first; - int v_classification; - - // categorize the resize into buckets - if ( ( vertical_output_size <= 4 ) || ( horizontal_output_size <= 4 ) ) - v_classification = ( vertical_output_size < horizontal_output_size ) ? 6 : 7; - else if ( vertical_scale <= 1.0f ) - v_classification = ( is_gather ) ? 1 : 0; - else if ( vertical_scale <= 2.0f) - v_classification = 2; - else if ( vertical_scale <= 3.0f) - v_classification = 3; - else if ( vertical_scale <= 4.0f) - v_classification = 5; - else - v_classification = 6; - - // use the right weights - weights = weights_table[ v_classification ]; - - // this is the costs when you don't take into account modern CPUs with high ipc and simd and caches - wish we had a better estimate - h_cost = (float)horizontal_filter_pixel_width * weights[0] + horizontal_scale * (float)vertical_filter_pixel_width * weights[1]; - v_cost = (float)vertical_filter_pixel_width * weights[2] + vertical_scale * (float)horizontal_filter_pixel_width * weights[3]; - - // use computation estimate to decide vertical first or not - vertical_first = ( v_cost <= h_cost ) ? 1 : 0; - - // save these, if requested - if ( info ) - { - info->h_cost = h_cost; - info->v_cost = v_cost; - info->v_resize_classification = v_classification; - info->v_first = vertical_first; - info->is_gather = is_gather; - } - - // and this allows us to override everything for testing (see dotiming.c) - if ( ( info ) && ( info->control_v_first ) ) - vertical_first = ( info->control_v_first == 2 ) ? 1 : 0; - - return vertical_first; -} - -// layout lookups - must match stbir_internal_pixel_layout -static unsigned char stbir__pixel_channels[] = { - 1,2,3,3,4, // 1ch, 2ch, rgb, bgr, 4ch - 4,4,4,4,2,2, // RGBA,BGRA,ARGB,ABGR,RA,AR - 4,4,4,4,2,2, // RGBA_PM,BGRA_PM,ARGB_PM,ABGR_PM,RA_PM,AR_PM -}; - -// the internal pixel layout enums are in a different order, so we can easily do range comparisons of types -// the public pixel layout is ordered in a way that if you cast num_channels (1-4) to the enum, you get something sensible -static stbir_internal_pixel_layout stbir__pixel_layout_convert_public_to_internal[] = { - STBIRI_BGR, STBIRI_1CHANNEL, STBIRI_2CHANNEL, STBIRI_RGB, STBIRI_RGBA, - STBIRI_4CHANNEL, STBIRI_BGRA, STBIRI_ARGB, STBIRI_ABGR, STBIRI_RA, STBIRI_AR, - STBIRI_RGBA_PM, STBIRI_BGRA_PM, STBIRI_ARGB_PM, STBIRI_ABGR_PM, STBIRI_RA_PM, STBIRI_AR_PM, -}; - -static stbir__info * stbir__alloc_internal_mem_and_build_samplers( stbir__sampler * horizontal, stbir__sampler * vertical, stbir__contributors * conservative, stbir_pixel_layout input_pixel_layout_public, stbir_pixel_layout output_pixel_layout_public, int splits, int new_x, int new_y, int fast_alpha, void * user_data STBIR_ONLY_PROFILE_BUILD_GET_INFO ) -{ - static char stbir_channel_count_index[8]={ 9,0,1,2, 3,9,9,4 }; - - stbir__info * info = 0; - void * alloced = 0; - size_t alloced_total = 0; - int vertical_first; - size_t decode_buffer_size, ring_buffer_length_bytes, ring_buffer_size, vertical_buffer_size; - int alloc_ring_buffer_num_entries; - - int alpha_weighting_type = 0; // 0=none, 1=simple, 2=fancy - int conservative_split_output_size = stbir__get_max_split( splits, vertical->scale_info.output_sub_size ); - stbir_internal_pixel_layout input_pixel_layout = stbir__pixel_layout_convert_public_to_internal[ input_pixel_layout_public ]; - stbir_internal_pixel_layout output_pixel_layout = stbir__pixel_layout_convert_public_to_internal[ output_pixel_layout_public ]; - int channels = stbir__pixel_channels[ input_pixel_layout ]; - int effective_channels = channels; - - // first figure out what type of alpha weighting to use (if any) - if ( ( horizontal->filter_enum != STBIR_FILTER_POINT_SAMPLE ) || ( vertical->filter_enum != STBIR_FILTER_POINT_SAMPLE ) ) // no alpha weighting on point sampling - { - if ( ( input_pixel_layout >= STBIRI_RGBA ) && ( input_pixel_layout <= STBIRI_AR ) && ( output_pixel_layout >= STBIRI_RGBA ) && ( output_pixel_layout <= STBIRI_AR ) ) - { - if ( fast_alpha ) - { - alpha_weighting_type = 4; - } - else - { - static int fancy_alpha_effective_cnts[6] = { 7, 7, 7, 7, 3, 3 }; - alpha_weighting_type = 2; - effective_channels = fancy_alpha_effective_cnts[ input_pixel_layout - STBIRI_RGBA ]; - } - } - else if ( ( input_pixel_layout >= STBIRI_RGBA_PM ) && ( input_pixel_layout <= STBIRI_AR_PM ) && ( output_pixel_layout >= STBIRI_RGBA ) && ( output_pixel_layout <= STBIRI_AR ) ) - { - // input premult, output non-premult - alpha_weighting_type = 3; - } - else if ( ( input_pixel_layout >= STBIRI_RGBA ) && ( input_pixel_layout <= STBIRI_AR ) && ( output_pixel_layout >= STBIRI_RGBA_PM ) && ( output_pixel_layout <= STBIRI_AR_PM ) ) - { - // input non-premult, output premult - alpha_weighting_type = 1; - } - } - - // channel in and out count must match currently - if ( channels != stbir__pixel_channels[ output_pixel_layout ] ) - return 0; - - // get vertical first - vertical_first = stbir__should_do_vertical_first( stbir__compute_weights[ (int)stbir_channel_count_index[ effective_channels ] ], horizontal->filter_pixel_width, horizontal->scale_info.scale, horizontal->scale_info.output_sub_size, vertical->filter_pixel_width, vertical->scale_info.scale, vertical->scale_info.output_sub_size, vertical->is_gather, STBIR__V_FIRST_INFO_POINTER ); - - // sometimes read one float off in some of the unrolled loops (with a weight of zero coeff, so it doesn't have an effect) - // we use a few extra floats instead of just 1, so that input callback buffer can overlap with the decode buffer without - // the conversion routines overwriting the callback input data. - decode_buffer_size = ( conservative->n1 - conservative->n0 + 1 ) * effective_channels * sizeof(float) + sizeof(float)*STBIR_INPUT_CALLBACK_PADDING; // extra floats for input callback stagger - -#if defined( STBIR__SEPARATE_ALLOCATIONS ) && defined(STBIR_SIMD8) - if ( effective_channels == 3 ) - decode_buffer_size += sizeof(float); // avx in 3 channel mode needs one float at the start of the buffer (only with separate allocations) -#endif - - ring_buffer_length_bytes = (size_t)horizontal->scale_info.output_sub_size * (size_t)effective_channels * sizeof(float) + sizeof(float)*STBIR_INPUT_CALLBACK_PADDING; // extra floats for padding - - // if we do vertical first, the ring buffer holds a whole decoded line - if ( vertical_first ) - ring_buffer_length_bytes = ( decode_buffer_size + 15 ) & ~15; - - if ( ( ring_buffer_length_bytes & 4095 ) == 0 ) ring_buffer_length_bytes += 64*3; // avoid 4k alias - - // One extra entry because floating point precision problems sometimes cause an extra to be necessary. - alloc_ring_buffer_num_entries = vertical->filter_pixel_width + 1; - - // we never need more ring buffer entries than the scanlines we're outputting when in scatter mode - if ( ( !vertical->is_gather ) && ( alloc_ring_buffer_num_entries > conservative_split_output_size ) ) - alloc_ring_buffer_num_entries = conservative_split_output_size; - - ring_buffer_size = (size_t)alloc_ring_buffer_num_entries * (size_t)ring_buffer_length_bytes; - - // The vertical buffer is used differently, depending on whether we are scattering - // the vertical scanlines, or gathering them. - // If scattering, it's used at the temp buffer to accumulate each output. - // If gathering, it's just the output buffer. - vertical_buffer_size = (size_t)horizontal->scale_info.output_sub_size * (size_t)effective_channels * sizeof(float) + sizeof(float); // extra float for padding - - // we make two passes through this loop, 1st to add everything up, 2nd to allocate and init - for(;;) - { - int i; - void * advance_mem = alloced; - int copy_horizontal = 0; - stbir__sampler * possibly_use_horizontal_for_pivot = 0; - -#ifdef STBIR__SEPARATE_ALLOCATIONS - #define STBIR__NEXT_PTR( ptr, size, ntype ) if ( alloced ) { void * p = STBIR_MALLOC( size, user_data); if ( p == 0 ) { stbir__free_internal_mem( info ); return 0; } (ptr) = (ntype*)p; } -#else - #define STBIR__NEXT_PTR( ptr, size, ntype ) advance_mem = (void*) ( ( ((size_t)advance_mem) + 15 ) & ~15 ); if ( alloced ) ptr = (ntype*)advance_mem; advance_mem = (char*)(((size_t)advance_mem) + (size)); -#endif - - STBIR__NEXT_PTR( info, sizeof( stbir__info ), stbir__info ); - - STBIR__NEXT_PTR( info->split_info, sizeof( stbir__per_split_info ) * splits, stbir__per_split_info ); - - if ( info ) - { - static stbir__alpha_weight_func * fancy_alpha_weights[6] = { stbir__fancy_alpha_weight_4ch, stbir__fancy_alpha_weight_4ch, stbir__fancy_alpha_weight_4ch, stbir__fancy_alpha_weight_4ch, stbir__fancy_alpha_weight_2ch, stbir__fancy_alpha_weight_2ch }; - static stbir__alpha_unweight_func * fancy_alpha_unweights[6] = { stbir__fancy_alpha_unweight_4ch, stbir__fancy_alpha_unweight_4ch, stbir__fancy_alpha_unweight_4ch, stbir__fancy_alpha_unweight_4ch, stbir__fancy_alpha_unweight_2ch, stbir__fancy_alpha_unweight_2ch }; - static stbir__alpha_weight_func * simple_alpha_weights[6] = { stbir__simple_alpha_weight_4ch, stbir__simple_alpha_weight_4ch, stbir__simple_alpha_weight_4ch, stbir__simple_alpha_weight_4ch, stbir__simple_alpha_weight_2ch, stbir__simple_alpha_weight_2ch }; - static stbir__alpha_unweight_func * simple_alpha_unweights[6] = { stbir__simple_alpha_unweight_4ch, stbir__simple_alpha_unweight_4ch, stbir__simple_alpha_unweight_4ch, stbir__simple_alpha_unweight_4ch, stbir__simple_alpha_unweight_2ch, stbir__simple_alpha_unweight_2ch }; - - // initialize info fields - info->alloced_mem = alloced; - info->alloced_total = alloced_total; - - info->channels = channels; - info->effective_channels = effective_channels; - - info->offset_x = new_x; - info->offset_y = new_y; - info->alloc_ring_buffer_num_entries = (int)alloc_ring_buffer_num_entries; - info->ring_buffer_num_entries = 0; - info->ring_buffer_length_bytes = (int)ring_buffer_length_bytes; - info->splits = splits; - info->vertical_first = vertical_first; - - info->input_pixel_layout_internal = input_pixel_layout; - info->output_pixel_layout_internal = output_pixel_layout; - - // setup alpha weight functions - info->alpha_weight = 0; - info->alpha_unweight = 0; - - // handle alpha weighting functions and overrides - if ( alpha_weighting_type == 2 ) - { - // high quality alpha multiplying on the way in, dividing on the way out - info->alpha_weight = fancy_alpha_weights[ input_pixel_layout - STBIRI_RGBA ]; - info->alpha_unweight = fancy_alpha_unweights[ output_pixel_layout - STBIRI_RGBA ]; - } - else if ( alpha_weighting_type == 4 ) - { - // fast alpha multiplying on the way in, dividing on the way out - info->alpha_weight = simple_alpha_weights[ input_pixel_layout - STBIRI_RGBA ]; - info->alpha_unweight = simple_alpha_unweights[ output_pixel_layout - STBIRI_RGBA ]; - } - else if ( alpha_weighting_type == 1 ) - { - // fast alpha on the way in, leave in premultiplied form on way out - info->alpha_weight = simple_alpha_weights[ input_pixel_layout - STBIRI_RGBA ]; - } - else if ( alpha_weighting_type == 3 ) - { - // incoming is premultiplied, fast alpha dividing on the way out - non-premultiplied output - info->alpha_unweight = simple_alpha_unweights[ output_pixel_layout - STBIRI_RGBA ]; - } - - // handle 3-chan color flipping, using the alpha weight path - if ( ( ( input_pixel_layout == STBIRI_RGB ) && ( output_pixel_layout == STBIRI_BGR ) ) || - ( ( input_pixel_layout == STBIRI_BGR ) && ( output_pixel_layout == STBIRI_RGB ) ) ) - { - // do the flipping on the smaller of the two ends - if ( horizontal->scale_info.scale < 1.0f ) - info->alpha_unweight = stbir__simple_flip_3ch; - else - info->alpha_weight = stbir__simple_flip_3ch; - } - - } - - // get all the per-split buffers - for( i = 0 ; i < splits ; i++ ) - { - STBIR__NEXT_PTR( info->split_info[i].decode_buffer, decode_buffer_size, float ); - -#ifdef STBIR__SEPARATE_ALLOCATIONS - - #ifdef STBIR_SIMD8 - if ( ( info ) && ( effective_channels == 3 ) ) - ++info->split_info[i].decode_buffer; // avx in 3 channel mode needs one float at the start of the buffer - #endif - - STBIR__NEXT_PTR( info->split_info[i].ring_buffers, alloc_ring_buffer_num_entries * sizeof(float*), float* ); - { - int j; - for( j = 0 ; j < alloc_ring_buffer_num_entries ; j++ ) - { - STBIR__NEXT_PTR( info->split_info[i].ring_buffers[j], ring_buffer_length_bytes, float ); - #ifdef STBIR_SIMD8 - if ( ( info ) && ( effective_channels == 3 ) ) - ++info->split_info[i].ring_buffers[j]; // avx in 3 channel mode needs one float at the start of the buffer - #endif - } - } -#else - STBIR__NEXT_PTR( info->split_info[i].ring_buffer, ring_buffer_size, float ); -#endif - STBIR__NEXT_PTR( info->split_info[i].vertical_buffer, vertical_buffer_size, float ); - } - - // alloc memory for to-be-pivoted coeffs (if necessary) - if ( vertical->is_gather == 0 ) - { - size_t both; - size_t temp_mem_amt; - - // when in vertical scatter mode, we first build the coefficients in gather mode, and then pivot after, - // that means we need two buffers, so we try to use the decode buffer and ring buffer for this. if that - // is too small, we just allocate extra memory to use as this temp. - - both = (size_t)vertical->gather_prescatter_contributors_size + (size_t)vertical->gather_prescatter_coefficients_size; - -#ifdef STBIR__SEPARATE_ALLOCATIONS - temp_mem_amt = decode_buffer_size; - - #ifdef STBIR_SIMD8 - if ( effective_channels == 3 ) - --temp_mem_amt; // avx in 3 channel mode needs one float at the start of the buffer - #endif -#else - temp_mem_amt = (size_t)( decode_buffer_size + ring_buffer_size + vertical_buffer_size ) * (size_t)splits; -#endif - if ( temp_mem_amt >= both ) - { - if ( info ) - { - vertical->gather_prescatter_contributors = (stbir__contributors*)info->split_info[0].decode_buffer; - vertical->gather_prescatter_coefficients = (float*) ( ( (char*)info->split_info[0].decode_buffer ) + vertical->gather_prescatter_contributors_size ); - } - } - else - { - // ring+decode memory is too small, so allocate temp memory - STBIR__NEXT_PTR( vertical->gather_prescatter_contributors, vertical->gather_prescatter_contributors_size, stbir__contributors ); - STBIR__NEXT_PTR( vertical->gather_prescatter_coefficients, vertical->gather_prescatter_coefficients_size, float ); - } - } - - STBIR__NEXT_PTR( horizontal->contributors, horizontal->contributors_size, stbir__contributors ); - STBIR__NEXT_PTR( horizontal->coefficients, horizontal->coefficients_size, float ); - - // are the two filters identical?? (happens a lot with mipmap generation) - if ( ( horizontal->filter_kernel == vertical->filter_kernel ) && ( horizontal->filter_support == vertical->filter_support ) && ( horizontal->edge == vertical->edge ) && ( horizontal->scale_info.output_sub_size == vertical->scale_info.output_sub_size ) ) - { - float diff_scale = horizontal->scale_info.scale - vertical->scale_info.scale; - float diff_shift = horizontal->scale_info.pixel_shift - vertical->scale_info.pixel_shift; - if ( diff_scale < 0.0f ) diff_scale = -diff_scale; - if ( diff_shift < 0.0f ) diff_shift = -diff_shift; - if ( ( diff_scale <= stbir__small_float ) && ( diff_shift <= stbir__small_float ) ) - { - if ( horizontal->is_gather == vertical->is_gather ) - { - copy_horizontal = 1; - goto no_vert_alloc; - } - // everything matches, but vertical is scatter, horizontal is gather, use horizontal coeffs for vertical pivot coeffs - possibly_use_horizontal_for_pivot = horizontal; - } - } - - STBIR__NEXT_PTR( vertical->contributors, vertical->contributors_size, stbir__contributors ); - STBIR__NEXT_PTR( vertical->coefficients, vertical->coefficients_size, float ); - - no_vert_alloc: - - if ( info ) - { - STBIR_PROFILE_BUILD_START( horizontal ); - - stbir__calculate_filters( horizontal, 0, user_data STBIR_ONLY_PROFILE_BUILD_SET_INFO ); - - // setup the horizontal gather functions - // start with defaulting to the n_coeffs functions (specialized on channels and remnant leftover) - info->horizontal_gather_channels = stbir__horizontal_gather_n_coeffs_funcs[ effective_channels ][ horizontal->extent_info.widest & 3 ]; - // but if the number of coeffs <= 12, use another set of special cases. <=12 coeffs is any enlarging resize, or shrinking resize down to about 1/3 size - if ( horizontal->extent_info.widest <= 12 ) - info->horizontal_gather_channels = stbir__horizontal_gather_channels_funcs[ effective_channels ][ horizontal->extent_info.widest - 1 ]; - - info->scanline_extents.conservative.n0 = conservative->n0; - info->scanline_extents.conservative.n1 = conservative->n1; - - // get exact extents - stbir__get_extents( horizontal, &info->scanline_extents ); - - // pack the horizontal coeffs - horizontal->coefficient_width = stbir__pack_coefficients(horizontal->num_contributors, horizontal->contributors, horizontal->coefficients, horizontal->coefficient_width, horizontal->extent_info.widest, info->scanline_extents.conservative.n0, info->scanline_extents.conservative.n1 ); - - STBIR_MEMCPY( &info->horizontal, horizontal, sizeof( stbir__sampler ) ); - - STBIR_PROFILE_BUILD_END( horizontal ); - - if ( copy_horizontal ) - { - STBIR_MEMCPY( &info->vertical, horizontal, sizeof( stbir__sampler ) ); - } - else - { - STBIR_PROFILE_BUILD_START( vertical ); - - stbir__calculate_filters( vertical, possibly_use_horizontal_for_pivot, user_data STBIR_ONLY_PROFILE_BUILD_SET_INFO ); - STBIR_MEMCPY( &info->vertical, vertical, sizeof( stbir__sampler ) ); - - STBIR_PROFILE_BUILD_END( vertical ); - } - - // setup the vertical split ranges - stbir__get_split_info( info->split_info, info->splits, info->vertical.scale_info.output_sub_size, info->vertical.filter_pixel_margin, info->vertical.scale_info.input_full_size, info->vertical.is_gather, info->vertical.contributors ); - - // now we know precisely how many entries we need - info->ring_buffer_num_entries = info->vertical.extent_info.widest; - - // we never need more ring buffer entries than the scanlines we're outputting - if ( ( !info->vertical.is_gather ) && ( info->ring_buffer_num_entries > conservative_split_output_size ) ) - info->ring_buffer_num_entries = conservative_split_output_size; - STBIR_ASSERT( info->ring_buffer_num_entries <= info->alloc_ring_buffer_num_entries ); - } - #undef STBIR__NEXT_PTR - - - // is this the first time through loop? - if ( info == 0 ) - { - alloced_total = ( 15 + (size_t)advance_mem ); - alloced = STBIR_MALLOC( alloced_total, user_data ); - if ( alloced == 0 ) - return 0; - } - else - return info; // success - } -} - -static int stbir__perform_resize( stbir__info const * info, int split_start, int split_count ) -{ - stbir__per_split_info * split_info = info->split_info + split_start; - - STBIR_PROFILE_CLEAR_EXTRAS(); - - STBIR_PROFILE_FIRST_START( looping ); - if (info->vertical.is_gather) - stbir__vertical_gather_loop( info, split_info, split_count ); - else - stbir__vertical_scatter_loop( info, split_info, split_count ); - STBIR_PROFILE_END( looping ); - - return 1; -} - -static void stbir__update_info_from_resize( stbir__info * info, STBIR_RESIZE * resize ) -{ - static stbir__decode_pixels_func * decode_simple[STBIR_TYPE_HALF_FLOAT-STBIR_TYPE_UINT8_SRGB+1]= - { - /* 1ch-4ch */ stbir__decode_uint8_srgb, stbir__decode_uint8_srgb, 0, stbir__decode_float_linear, stbir__decode_half_float_linear, - }; - - static stbir__decode_pixels_func * decode_alphas[STBIRI_AR-STBIRI_RGBA+1][STBIR_TYPE_HALF_FLOAT-STBIR_TYPE_UINT8_SRGB+1]= - { - { /* RGBA */ stbir__decode_uint8_srgb4_linearalpha, stbir__decode_uint8_srgb, 0, stbir__decode_float_linear, stbir__decode_half_float_linear }, - { /* BGRA */ stbir__decode_uint8_srgb4_linearalpha_BGRA, stbir__decode_uint8_srgb_BGRA, 0, stbir__decode_float_linear_BGRA, stbir__decode_half_float_linear_BGRA }, - { /* ARGB */ stbir__decode_uint8_srgb4_linearalpha_ARGB, stbir__decode_uint8_srgb_ARGB, 0, stbir__decode_float_linear_ARGB, stbir__decode_half_float_linear_ARGB }, - { /* ABGR */ stbir__decode_uint8_srgb4_linearalpha_ABGR, stbir__decode_uint8_srgb_ABGR, 0, stbir__decode_float_linear_ABGR, stbir__decode_half_float_linear_ABGR }, - { /* RA */ stbir__decode_uint8_srgb2_linearalpha, stbir__decode_uint8_srgb, 0, stbir__decode_float_linear, stbir__decode_half_float_linear }, - { /* AR */ stbir__decode_uint8_srgb2_linearalpha_AR, stbir__decode_uint8_srgb_AR, 0, stbir__decode_float_linear_AR, stbir__decode_half_float_linear_AR }, - }; - - static stbir__decode_pixels_func * decode_simple_scaled_or_not[2][2]= - { - { stbir__decode_uint8_linear_scaled, stbir__decode_uint8_linear }, { stbir__decode_uint16_linear_scaled, stbir__decode_uint16_linear }, - }; - - static stbir__decode_pixels_func * decode_alphas_scaled_or_not[STBIRI_AR-STBIRI_RGBA+1][2][2]= - { - { /* RGBA */ { stbir__decode_uint8_linear_scaled, stbir__decode_uint8_linear }, { stbir__decode_uint16_linear_scaled, stbir__decode_uint16_linear } }, - { /* BGRA */ { stbir__decode_uint8_linear_scaled_BGRA, stbir__decode_uint8_linear_BGRA }, { stbir__decode_uint16_linear_scaled_BGRA, stbir__decode_uint16_linear_BGRA } }, - { /* ARGB */ { stbir__decode_uint8_linear_scaled_ARGB, stbir__decode_uint8_linear_ARGB }, { stbir__decode_uint16_linear_scaled_ARGB, stbir__decode_uint16_linear_ARGB } }, - { /* ABGR */ { stbir__decode_uint8_linear_scaled_ABGR, stbir__decode_uint8_linear_ABGR }, { stbir__decode_uint16_linear_scaled_ABGR, stbir__decode_uint16_linear_ABGR } }, - { /* RA */ { stbir__decode_uint8_linear_scaled, stbir__decode_uint8_linear }, { stbir__decode_uint16_linear_scaled, stbir__decode_uint16_linear } }, - { /* AR */ { stbir__decode_uint8_linear_scaled_AR, stbir__decode_uint8_linear_AR }, { stbir__decode_uint16_linear_scaled_AR, stbir__decode_uint16_linear_AR } } - }; - - static stbir__encode_pixels_func * encode_simple[STBIR_TYPE_HALF_FLOAT-STBIR_TYPE_UINT8_SRGB+1]= - { - /* 1ch-4ch */ stbir__encode_uint8_srgb, stbir__encode_uint8_srgb, 0, stbir__encode_float_linear, stbir__encode_half_float_linear, - }; - - static stbir__encode_pixels_func * encode_alphas[STBIRI_AR-STBIRI_RGBA+1][STBIR_TYPE_HALF_FLOAT-STBIR_TYPE_UINT8_SRGB+1]= - { - { /* RGBA */ stbir__encode_uint8_srgb4_linearalpha, stbir__encode_uint8_srgb, 0, stbir__encode_float_linear, stbir__encode_half_float_linear }, - { /* BGRA */ stbir__encode_uint8_srgb4_linearalpha_BGRA, stbir__encode_uint8_srgb_BGRA, 0, stbir__encode_float_linear_BGRA, stbir__encode_half_float_linear_BGRA }, - { /* ARGB */ stbir__encode_uint8_srgb4_linearalpha_ARGB, stbir__encode_uint8_srgb_ARGB, 0, stbir__encode_float_linear_ARGB, stbir__encode_half_float_linear_ARGB }, - { /* ABGR */ stbir__encode_uint8_srgb4_linearalpha_ABGR, stbir__encode_uint8_srgb_ABGR, 0, stbir__encode_float_linear_ABGR, stbir__encode_half_float_linear_ABGR }, - { /* RA */ stbir__encode_uint8_srgb2_linearalpha, stbir__encode_uint8_srgb, 0, stbir__encode_float_linear, stbir__encode_half_float_linear }, - { /* AR */ stbir__encode_uint8_srgb2_linearalpha_AR, stbir__encode_uint8_srgb_AR, 0, stbir__encode_float_linear_AR, stbir__encode_half_float_linear_AR } - }; - - static stbir__encode_pixels_func * encode_simple_scaled_or_not[2][2]= - { - { stbir__encode_uint8_linear_scaled, stbir__encode_uint8_linear }, { stbir__encode_uint16_linear_scaled, stbir__encode_uint16_linear }, - }; - - static stbir__encode_pixels_func * encode_alphas_scaled_or_not[STBIRI_AR-STBIRI_RGBA+1][2][2]= - { - { /* RGBA */ { stbir__encode_uint8_linear_scaled, stbir__encode_uint8_linear }, { stbir__encode_uint16_linear_scaled, stbir__encode_uint16_linear } }, - { /* BGRA */ { stbir__encode_uint8_linear_scaled_BGRA, stbir__encode_uint8_linear_BGRA }, { stbir__encode_uint16_linear_scaled_BGRA, stbir__encode_uint16_linear_BGRA } }, - { /* ARGB */ { stbir__encode_uint8_linear_scaled_ARGB, stbir__encode_uint8_linear_ARGB }, { stbir__encode_uint16_linear_scaled_ARGB, stbir__encode_uint16_linear_ARGB } }, - { /* ABGR */ { stbir__encode_uint8_linear_scaled_ABGR, stbir__encode_uint8_linear_ABGR }, { stbir__encode_uint16_linear_scaled_ABGR, stbir__encode_uint16_linear_ABGR } }, - { /* RA */ { stbir__encode_uint8_linear_scaled, stbir__encode_uint8_linear }, { stbir__encode_uint16_linear_scaled, stbir__encode_uint16_linear } }, - { /* AR */ { stbir__encode_uint8_linear_scaled_AR, stbir__encode_uint8_linear_AR }, { stbir__encode_uint16_linear_scaled_AR, stbir__encode_uint16_linear_AR } } - }; - - stbir__decode_pixels_func * decode_pixels = 0; - stbir__encode_pixels_func * encode_pixels = 0; - stbir_datatype input_type, output_type; - - input_type = resize->input_data_type; - output_type = resize->output_data_type; - info->input_data = resize->input_pixels; - info->input_stride_bytes = resize->input_stride_in_bytes; - info->output_stride_bytes = resize->output_stride_in_bytes; - - // if we're completely point sampling, then we can turn off SRGB - if ( ( info->horizontal.filter_enum == STBIR_FILTER_POINT_SAMPLE ) && ( info->vertical.filter_enum == STBIR_FILTER_POINT_SAMPLE ) ) - { - if ( ( ( input_type == STBIR_TYPE_UINT8_SRGB ) || ( input_type == STBIR_TYPE_UINT8_SRGB_ALPHA ) ) && - ( ( output_type == STBIR_TYPE_UINT8_SRGB ) || ( output_type == STBIR_TYPE_UINT8_SRGB_ALPHA ) ) ) - { - input_type = STBIR_TYPE_UINT8; - output_type = STBIR_TYPE_UINT8; - } - } - - // recalc the output and input strides - if ( info->input_stride_bytes == 0 ) - info->input_stride_bytes = info->channels * info->horizontal.scale_info.input_full_size * stbir__type_size[input_type]; - - if ( info->output_stride_bytes == 0 ) - info->output_stride_bytes = info->channels * info->horizontal.scale_info.output_sub_size * stbir__type_size[output_type]; - - // calc offset - info->output_data = ( (char*) resize->output_pixels ) + ( (size_t) info->offset_y * (size_t) resize->output_stride_in_bytes ) + ( info->offset_x * info->channels * stbir__type_size[output_type] ); - - info->in_pixels_cb = resize->input_cb; - info->user_data = resize->user_data; - info->out_pixels_cb = resize->output_cb; - - // setup the input format converters - if ( ( input_type == STBIR_TYPE_UINT8 ) || ( input_type == STBIR_TYPE_UINT16 ) ) - { - int non_scaled = 0; - - // check if we can run unscaled - 0-255.0/0-65535.0 instead of 0-1.0 (which is a tiny bit faster when doing linear 8->8 or 16->16) - if ( ( !info->alpha_weight ) && ( !info->alpha_unweight ) ) // don't short circuit when alpha weighting (get everything to 0-1.0 as usual) - if ( ( ( input_type == STBIR_TYPE_UINT8 ) && ( output_type == STBIR_TYPE_UINT8 ) ) || ( ( input_type == STBIR_TYPE_UINT16 ) && ( output_type == STBIR_TYPE_UINT16 ) ) ) - non_scaled = 1; - - if ( info->input_pixel_layout_internal <= STBIRI_4CHANNEL ) - decode_pixels = decode_simple_scaled_or_not[ input_type == STBIR_TYPE_UINT16 ][ non_scaled ]; - else - decode_pixels = decode_alphas_scaled_or_not[ ( info->input_pixel_layout_internal - STBIRI_RGBA ) % ( STBIRI_AR-STBIRI_RGBA+1 ) ][ input_type == STBIR_TYPE_UINT16 ][ non_scaled ]; - } - else - { - if ( info->input_pixel_layout_internal <= STBIRI_4CHANNEL ) - decode_pixels = decode_simple[ input_type - STBIR_TYPE_UINT8_SRGB ]; - else - decode_pixels = decode_alphas[ ( info->input_pixel_layout_internal - STBIRI_RGBA ) % ( STBIRI_AR-STBIRI_RGBA+1 ) ][ input_type - STBIR_TYPE_UINT8_SRGB ]; - } - - // setup the output format converters - if ( ( output_type == STBIR_TYPE_UINT8 ) || ( output_type == STBIR_TYPE_UINT16 ) ) - { - int non_scaled = 0; - - // check if we can run unscaled - 0-255.0/0-65535.0 instead of 0-1.0 (which is a tiny bit faster when doing linear 8->8 or 16->16) - if ( ( !info->alpha_weight ) && ( !info->alpha_unweight ) ) // don't short circuit when alpha weighting (get everything to 0-1.0 as usual) - if ( ( ( input_type == STBIR_TYPE_UINT8 ) && ( output_type == STBIR_TYPE_UINT8 ) ) || ( ( input_type == STBIR_TYPE_UINT16 ) && ( output_type == STBIR_TYPE_UINT16 ) ) ) - non_scaled = 1; - - if ( info->output_pixel_layout_internal <= STBIRI_4CHANNEL ) - encode_pixels = encode_simple_scaled_or_not[ output_type == STBIR_TYPE_UINT16 ][ non_scaled ]; - else - encode_pixels = encode_alphas_scaled_or_not[ ( info->output_pixel_layout_internal - STBIRI_RGBA ) % ( STBIRI_AR-STBIRI_RGBA+1 ) ][ output_type == STBIR_TYPE_UINT16 ][ non_scaled ]; - } - else - { - if ( info->output_pixel_layout_internal <= STBIRI_4CHANNEL ) - encode_pixels = encode_simple[ output_type - STBIR_TYPE_UINT8_SRGB ]; - else - encode_pixels = encode_alphas[ ( info->output_pixel_layout_internal - STBIRI_RGBA ) % ( STBIRI_AR-STBIRI_RGBA+1 ) ][ output_type - STBIR_TYPE_UINT8_SRGB ]; - } - - info->input_type = input_type; - info->output_type = output_type; - info->decode_pixels = decode_pixels; - info->encode_pixels = encode_pixels; -} - -static void stbir__clip( int * outx, int * outsubw, int outw, double * u0, double * u1 ) -{ - double per, adj; - int over; - - // do left/top edge - if ( *outx < 0 ) - { - per = ( (double)*outx ) / ( (double)*outsubw ); // is negative - adj = per * ( *u1 - *u0 ); - *u0 -= adj; // increases u0 - *outx = 0; - } - - // do right/bot edge - over = outw - ( *outx + *outsubw ); - if ( over < 0 ) - { - per = ( (double)over ) / ( (double)*outsubw ); // is negative - adj = per * ( *u1 - *u0 ); - *u1 += adj; // decrease u1 - *outsubw = outw - *outx; - } -} - -// converts a double to a rational that has less than one float bit of error (returns 0 if unable to do so) -static int stbir__double_to_rational(double f, stbir_uint32 limit, stbir_uint32 *numer, stbir_uint32 *denom, int limit_denom ) // limit_denom (1) or limit numer (0) -{ - double err; - stbir_uint64 top, bot; - stbir_uint64 numer_last = 0; - stbir_uint64 denom_last = 1; - stbir_uint64 numer_estimate = 1; - stbir_uint64 denom_estimate = 0; - - // scale to past float error range - top = (stbir_uint64)( f * (double)(1 << 25) ); - bot = 1 << 25; - - // keep refining, but usually stops in a few loops - usually 5 for bad cases - for(;;) - { - stbir_uint64 est, temp; - - // hit limit, break out and do best full range estimate - if ( ( ( limit_denom ) ? denom_estimate : numer_estimate ) >= limit ) - break; - - // is the current error less than 1 bit of a float? if so, we're done - if ( denom_estimate ) - { - err = ( (double)numer_estimate / (double)denom_estimate ) - f; - if ( err < 0.0 ) err = -err; - if ( err < ( 1.0 / (double)(1<<24) ) ) - { - // yup, found it - *numer = (stbir_uint32) numer_estimate; - *denom = (stbir_uint32) denom_estimate; - return 1; - } - } - - // no more refinement bits left? break out and do full range estimate - if ( bot == 0 ) - break; - - // gcd the estimate bits - est = top / bot; - temp = top % bot; - top = bot; - bot = temp; - - // move remainders - temp = est * denom_estimate + denom_last; - denom_last = denom_estimate; - denom_estimate = temp; - - // move remainders - temp = est * numer_estimate + numer_last; - numer_last = numer_estimate; - numer_estimate = temp; - } - - // we didn't fine anything good enough for float, use a full range estimate - if ( limit_denom ) - { - numer_estimate= (stbir_uint64)( f * (double)limit + 0.5 ); - denom_estimate = limit; - } - else - { - numer_estimate = limit; - denom_estimate = (stbir_uint64)( ( (double)limit / f ) + 0.5 ); - } - - *numer = (stbir_uint32) numer_estimate; - *denom = (stbir_uint32) denom_estimate; - - err = ( denom_estimate ) ? ( ( (double)(stbir_uint32)numer_estimate / (double)(stbir_uint32)denom_estimate ) - f ) : 1.0; - if ( err < 0.0 ) err = -err; - return ( err < ( 1.0 / (double)(1<<24) ) ) ? 1 : 0; -} - -static int stbir__calculate_region_transform( stbir__scale_info * scale_info, int output_full_range, int * output_offset, int output_sub_range, int input_full_range, double input_s0, double input_s1 ) -{ - double output_range, input_range, output_s, input_s, ratio, scale; - - input_s = input_s1 - input_s0; - - // null area - if ( ( output_full_range == 0 ) || ( input_full_range == 0 ) || - ( output_sub_range == 0 ) || ( input_s <= stbir__small_float ) ) - return 0; - - // are either of the ranges completely out of bounds? - if ( ( *output_offset >= output_full_range ) || ( ( *output_offset + output_sub_range ) <= 0 ) || ( input_s0 >= (1.0f-stbir__small_float) ) || ( input_s1 <= stbir__small_float ) ) - return 0; - - output_range = (double)output_full_range; - input_range = (double)input_full_range; - - output_s = ( (double)output_sub_range) / output_range; - - // figure out the scaling to use - ratio = output_s / input_s; - - // save scale before clipping - scale = ( output_range / input_range ) * ratio; - scale_info->scale = (float)scale; - scale_info->inv_scale = (float)( 1.0 / scale ); - - // clip output area to left/right output edges (and adjust input area) - stbir__clip( output_offset, &output_sub_range, output_full_range, &input_s0, &input_s1 ); - - // recalc input area - input_s = input_s1 - input_s0; - - // after clipping do we have zero input area? - if ( input_s <= stbir__small_float ) - return 0; - - // calculate and store the starting source offsets in output pixel space - scale_info->pixel_shift = (float) ( input_s0 * ratio * output_range ); - - scale_info->scale_is_rational = stbir__double_to_rational( scale, ( scale <= 1.0 ) ? output_full_range : input_full_range, &scale_info->scale_numerator, &scale_info->scale_denominator, ( scale >= 1.0 ) ); - - scale_info->input_full_size = input_full_range; - scale_info->output_sub_size = output_sub_range; - - return 1; -} - - -static void stbir__init_and_set_layout( STBIR_RESIZE * resize, stbir_pixel_layout pixel_layout, stbir_datatype data_type ) -{ - resize->input_cb = 0; - resize->output_cb = 0; - resize->user_data = resize; - resize->samplers = 0; - resize->called_alloc = 0; - resize->horizontal_filter = STBIR_FILTER_DEFAULT; - resize->horizontal_filter_kernel = 0; resize->horizontal_filter_support = 0; - resize->vertical_filter = STBIR_FILTER_DEFAULT; - resize->vertical_filter_kernel = 0; resize->vertical_filter_support = 0; - resize->horizontal_edge = STBIR_EDGE_CLAMP; - resize->vertical_edge = STBIR_EDGE_CLAMP; - resize->input_s0 = 0; resize->input_t0 = 0; resize->input_s1 = 1; resize->input_t1 = 1; - resize->output_subx = 0; resize->output_suby = 0; resize->output_subw = resize->output_w; resize->output_subh = resize->output_h; - resize->input_data_type = data_type; - resize->output_data_type = data_type; - resize->input_pixel_layout_public = pixel_layout; - resize->output_pixel_layout_public = pixel_layout; - resize->needs_rebuild = 1; -} - -STBIRDEF void stbir_resize_init( STBIR_RESIZE * resize, - const void *input_pixels, int input_w, int input_h, int input_stride_in_bytes, // stride can be zero - void *output_pixels, int output_w, int output_h, int output_stride_in_bytes, // stride can be zero - stbir_pixel_layout pixel_layout, stbir_datatype data_type ) -{ - resize->input_pixels = input_pixels; - resize->input_w = input_w; - resize->input_h = input_h; - resize->input_stride_in_bytes = input_stride_in_bytes; - resize->output_pixels = output_pixels; - resize->output_w = output_w; - resize->output_h = output_h; - resize->output_stride_in_bytes = output_stride_in_bytes; - resize->fast_alpha = 0; - - stbir__init_and_set_layout( resize, pixel_layout, data_type ); -} - -// You can update parameters any time after resize_init -STBIRDEF void stbir_set_datatypes( STBIR_RESIZE * resize, stbir_datatype input_type, stbir_datatype output_type ) // by default, datatype from resize_init -{ - resize->input_data_type = input_type; - resize->output_data_type = output_type; - if ( ( resize->samplers ) && ( !resize->needs_rebuild ) ) - stbir__update_info_from_resize( resize->samplers, resize ); -} - -STBIRDEF void stbir_set_pixel_callbacks( STBIR_RESIZE * resize, stbir_input_callback * input_cb, stbir_output_callback * output_cb ) // no callbacks by default -{ - resize->input_cb = input_cb; - resize->output_cb = output_cb; - - if ( ( resize->samplers ) && ( !resize->needs_rebuild ) ) - { - resize->samplers->in_pixels_cb = input_cb; - resize->samplers->out_pixels_cb = output_cb; - } -} - -STBIRDEF void stbir_set_user_data( STBIR_RESIZE * resize, void * user_data ) // pass back STBIR_RESIZE* by default -{ - resize->user_data = user_data; - if ( ( resize->samplers ) && ( !resize->needs_rebuild ) ) - resize->samplers->user_data = user_data; -} - -STBIRDEF void stbir_set_buffer_ptrs( STBIR_RESIZE * resize, const void * input_pixels, int input_stride_in_bytes, void * output_pixels, int output_stride_in_bytes ) -{ - resize->input_pixels = input_pixels; - resize->input_stride_in_bytes = input_stride_in_bytes; - resize->output_pixels = output_pixels; - resize->output_stride_in_bytes = output_stride_in_bytes; - if ( ( resize->samplers ) && ( !resize->needs_rebuild ) ) - stbir__update_info_from_resize( resize->samplers, resize ); -} - - -STBIRDEF int stbir_set_edgemodes( STBIR_RESIZE * resize, stbir_edge horizontal_edge, stbir_edge vertical_edge ) // CLAMP by default -{ - resize->horizontal_edge = horizontal_edge; - resize->vertical_edge = vertical_edge; - resize->needs_rebuild = 1; - return 1; -} - -STBIRDEF int stbir_set_filters( STBIR_RESIZE * resize, stbir_filter horizontal_filter, stbir_filter vertical_filter ) // STBIR_DEFAULT_FILTER_UPSAMPLE/DOWNSAMPLE by default -{ - resize->horizontal_filter = horizontal_filter; - resize->vertical_filter = vertical_filter; - resize->needs_rebuild = 1; - return 1; -} - -STBIRDEF int stbir_set_filter_callbacks( STBIR_RESIZE * resize, stbir__kernel_callback * horizontal_filter, stbir__support_callback * horizontal_support, stbir__kernel_callback * vertical_filter, stbir__support_callback * vertical_support ) -{ - resize->horizontal_filter_kernel = horizontal_filter; resize->horizontal_filter_support = horizontal_support; - resize->vertical_filter_kernel = vertical_filter; resize->vertical_filter_support = vertical_support; - resize->needs_rebuild = 1; - return 1; -} - -STBIRDEF int stbir_set_pixel_layouts( STBIR_RESIZE * resize, stbir_pixel_layout input_pixel_layout, stbir_pixel_layout output_pixel_layout ) // sets new pixel layouts -{ - resize->input_pixel_layout_public = input_pixel_layout; - resize->output_pixel_layout_public = output_pixel_layout; - resize->needs_rebuild = 1; - return 1; -} - - -STBIRDEF int stbir_set_non_pm_alpha_speed_over_quality( STBIR_RESIZE * resize, int non_pma_alpha_speed_over_quality ) // sets alpha speed -{ - resize->fast_alpha = non_pma_alpha_speed_over_quality; - resize->needs_rebuild = 1; - return 1; -} - -STBIRDEF int stbir_set_input_subrect( STBIR_RESIZE * resize, double s0, double t0, double s1, double t1 ) // sets input region (full region by default) -{ - resize->input_s0 = s0; - resize->input_t0 = t0; - resize->input_s1 = s1; - resize->input_t1 = t1; - resize->needs_rebuild = 1; - - // are we inbounds? - if ( ( s1 < stbir__small_float ) || ( (s1-s0) < stbir__small_float ) || - ( t1 < stbir__small_float ) || ( (t1-t0) < stbir__small_float ) || - ( s0 > (1.0f-stbir__small_float) ) || - ( t0 > (1.0f-stbir__small_float) ) ) - return 0; - - return 1; -} - -STBIRDEF int stbir_set_output_pixel_subrect( STBIR_RESIZE * resize, int subx, int suby, int subw, int subh ) // sets input region (full region by default) -{ - resize->output_subx = subx; - resize->output_suby = suby; - resize->output_subw = subw; - resize->output_subh = subh; - resize->needs_rebuild = 1; - - // are we inbounds? - if ( ( subx >= resize->output_w ) || ( ( subx + subw ) <= 0 ) || ( suby >= resize->output_h ) || ( ( suby + subh ) <= 0 ) || ( subw == 0 ) || ( subh == 0 ) ) - return 0; - - return 1; -} - -STBIRDEF int stbir_set_pixel_subrect( STBIR_RESIZE * resize, int subx, int suby, int subw, int subh ) // sets both regions (full regions by default) -{ - double s0, t0, s1, t1; - - s0 = ( (double)subx ) / ( (double)resize->output_w ); - t0 = ( (double)suby ) / ( (double)resize->output_h ); - s1 = ( (double)(subx+subw) ) / ( (double)resize->output_w ); - t1 = ( (double)(suby+subh) ) / ( (double)resize->output_h ); - - resize->input_s0 = s0; - resize->input_t0 = t0; - resize->input_s1 = s1; - resize->input_t1 = t1; - resize->output_subx = subx; - resize->output_suby = suby; - resize->output_subw = subw; - resize->output_subh = subh; - resize->needs_rebuild = 1; - - // are we inbounds? - if ( ( subx >= resize->output_w ) || ( ( subx + subw ) <= 0 ) || ( suby >= resize->output_h ) || ( ( suby + subh ) <= 0 ) || ( subw == 0 ) || ( subh == 0 ) ) - return 0; - - return 1; -} - -static int stbir__perform_build( STBIR_RESIZE * resize, int splits ) -{ - stbir__contributors conservative = { 0, 0 }; - stbir__sampler horizontal, vertical; - int new_output_subx, new_output_suby; - stbir__info * out_info; - #ifdef STBIR_PROFILE - stbir__info profile_infod; // used to contain building profile info before everything is allocated - stbir__info * profile_info = &profile_infod; - #endif - - // have we already built the samplers? - if ( resize->samplers ) - return 0; - - #define STBIR_RETURN_ERROR_AND_ASSERT( exp ) STBIR_ASSERT( !(exp) ); if (exp) return 0; - STBIR_RETURN_ERROR_AND_ASSERT( (unsigned)resize->horizontal_filter >= STBIR_FILTER_OTHER) - STBIR_RETURN_ERROR_AND_ASSERT( (unsigned)resize->vertical_filter >= STBIR_FILTER_OTHER) - #undef STBIR_RETURN_ERROR_AND_ASSERT - - if ( splits <= 0 ) - return 0; - - STBIR_PROFILE_BUILD_FIRST_START( build ); - - new_output_subx = resize->output_subx; - new_output_suby = resize->output_suby; - - // do horizontal clip and scale calcs - if ( !stbir__calculate_region_transform( &horizontal.scale_info, resize->output_w, &new_output_subx, resize->output_subw, resize->input_w, resize->input_s0, resize->input_s1 ) ) - return 0; - - // do vertical clip and scale calcs - if ( !stbir__calculate_region_transform( &vertical.scale_info, resize->output_h, &new_output_suby, resize->output_subh, resize->input_h, resize->input_t0, resize->input_t1 ) ) - return 0; - - // if nothing to do, just return - if ( ( horizontal.scale_info.output_sub_size == 0 ) || ( vertical.scale_info.output_sub_size == 0 ) ) - return 0; - - stbir__set_sampler(&horizontal, resize->horizontal_filter, resize->horizontal_filter_kernel, resize->horizontal_filter_support, resize->horizontal_edge, &horizontal.scale_info, 1, resize->user_data ); - stbir__get_conservative_extents( &horizontal, &conservative, resize->user_data ); - stbir__set_sampler(&vertical, resize->vertical_filter, resize->vertical_filter_kernel, resize->vertical_filter_support, resize->vertical_edge, &vertical.scale_info, 0, resize->user_data ); - - if ( ( vertical.scale_info.output_sub_size / splits ) < STBIR_FORCE_MINIMUM_SCANLINES_FOR_SPLITS ) // each split should be a minimum of 4 scanlines (handwavey choice) - { - splits = vertical.scale_info.output_sub_size / STBIR_FORCE_MINIMUM_SCANLINES_FOR_SPLITS; - if ( splits == 0 ) splits = 1; - } - - STBIR_PROFILE_BUILD_START( alloc ); - out_info = stbir__alloc_internal_mem_and_build_samplers( &horizontal, &vertical, &conservative, resize->input_pixel_layout_public, resize->output_pixel_layout_public, splits, new_output_subx, new_output_suby, resize->fast_alpha, resize->user_data STBIR_ONLY_PROFILE_BUILD_SET_INFO ); - STBIR_PROFILE_BUILD_END( alloc ); - STBIR_PROFILE_BUILD_END( build ); - - if ( out_info ) - { - resize->splits = splits; - resize->samplers = out_info; - resize->needs_rebuild = 0; - #ifdef STBIR_PROFILE - STBIR_MEMCPY( &out_info->profile, &profile_infod.profile, sizeof( out_info->profile ) ); - #endif - - // update anything that can be changed without recalcing samplers - stbir__update_info_from_resize( out_info, resize ); - - return splits; - } - - return 0; -} - -void stbir_free_samplers( STBIR_RESIZE * resize ) -{ - if ( resize->samplers ) - { - stbir__free_internal_mem( resize->samplers ); - resize->samplers = 0; - resize->called_alloc = 0; - } -} - -STBIRDEF int stbir_build_samplers_with_splits( STBIR_RESIZE * resize, int splits ) -{ - if ( ( resize->samplers == 0 ) || ( resize->needs_rebuild ) ) - { - if ( resize->samplers ) - stbir_free_samplers( resize ); - - resize->called_alloc = 1; - return stbir__perform_build( resize, splits ); - } - - STBIR_PROFILE_BUILD_CLEAR( resize->samplers ); - - return 1; -} - -STBIRDEF int stbir_build_samplers( STBIR_RESIZE * resize ) -{ - return stbir_build_samplers_with_splits( resize, 1 ); -} - -STBIRDEF int stbir_resize_extended( STBIR_RESIZE * resize ) -{ - int result; - - if ( ( resize->samplers == 0 ) || ( resize->needs_rebuild ) ) - { - int alloc_state = resize->called_alloc; // remember allocated state - - if ( resize->samplers ) - { - stbir__free_internal_mem( resize->samplers ); - resize->samplers = 0; - } - - if ( !stbir_build_samplers( resize ) ) - return 0; - - resize->called_alloc = alloc_state; - - // if build_samplers succeeded (above), but there are no samplers set, then - // the area to stretch into was zero pixels, so don't do anything and return - // success - if ( resize->samplers == 0 ) - return 1; - } - else - { - // didn't build anything - clear it - STBIR_PROFILE_BUILD_CLEAR( resize->samplers ); - } - - // do resize - result = stbir__perform_resize( resize->samplers, 0, resize->splits ); - - // if we alloced, then free - if ( !resize->called_alloc ) - { - stbir_free_samplers( resize ); - resize->samplers = 0; - } - - return result; -} - -STBIRDEF int stbir_resize_extended_split( STBIR_RESIZE * resize, int split_start, int split_count ) -{ - STBIR_ASSERT( resize->samplers ); - - // if we're just doing the whole thing, call full - if ( ( split_start == -1 ) || ( ( split_start == 0 ) && ( split_count == resize->splits ) ) ) - return stbir_resize_extended( resize ); - - // you **must** build samplers first when using split resize - if ( ( resize->samplers == 0 ) || ( resize->needs_rebuild ) ) - return 0; - - if ( ( split_start >= resize->splits ) || ( split_start < 0 ) || ( ( split_start + split_count ) > resize->splits ) || ( split_count <= 0 ) ) - return 0; - - // do resize - return stbir__perform_resize( resize->samplers, split_start, split_count ); -} - - -static void * stbir_quick_resize_helper( const void *input_pixels , int input_w , int input_h, int input_stride_in_bytes, - void *output_pixels, int output_w, int output_h, int output_stride_in_bytes, - stbir_pixel_layout pixel_layout, stbir_datatype data_type, stbir_edge edge, stbir_filter filter ) -{ - STBIR_RESIZE resize; - int scanline_output_in_bytes; - int positive_output_stride_in_bytes; - void * start_ptr; - void * free_ptr; - - scanline_output_in_bytes = output_w * stbir__type_size[ data_type ] * stbir__pixel_channels[ stbir__pixel_layout_convert_public_to_internal[ pixel_layout ] ]; - if ( scanline_output_in_bytes == 0 ) - return 0; - - // if zero stride, use scanline output - if ( output_stride_in_bytes == 0 ) - output_stride_in_bytes = scanline_output_in_bytes; - - // abs value for inverted images (negative pitches) - positive_output_stride_in_bytes = output_stride_in_bytes; - if ( positive_output_stride_in_bytes < 0 ) - positive_output_stride_in_bytes = -positive_output_stride_in_bytes; - - // is the requested stride smaller than the scanline output? if so, just fail - if ( positive_output_stride_in_bytes < scanline_output_in_bytes ) - return 0; - - start_ptr = output_pixels; - free_ptr = 0; // no free pointer, since they passed buffer to use - - // did they pass a zero for the dest? if so, allocate the buffer - if ( output_pixels == 0 ) - { - size_t size; - char * ptr; - - size = (size_t)positive_output_stride_in_bytes * (size_t)output_h; - if ( size == 0 ) - return 0; - - ptr = (char*) STBIR_MALLOC( size, 0 ); - if ( ptr == 0 ) - return 0; - - free_ptr = ptr; - - // point at the last scanline, if they requested a flipped image - if ( output_stride_in_bytes < 0 ) - start_ptr = ptr + ( (size_t)positive_output_stride_in_bytes * (size_t)( output_h - 1 ) ); - else - start_ptr = ptr; - } - - // ok, now do the resize - stbir_resize_init( &resize, - input_pixels, input_w, input_h, input_stride_in_bytes, - start_ptr, output_w, output_h, output_stride_in_bytes, - pixel_layout, data_type ); - - resize.horizontal_edge = edge; - resize.vertical_edge = edge; - resize.horizontal_filter = filter; - resize.vertical_filter = filter; - - if ( !stbir_resize_extended( &resize ) ) - { - if ( free_ptr ) - STBIR_FREE( free_ptr, 0 ); - return 0; - } - - return (free_ptr) ? free_ptr : start_ptr; -} - - - -STBIRDEF unsigned char * stbir_resize_uint8_linear( const unsigned char *input_pixels , int input_w , int input_h, int input_stride_in_bytes, - unsigned char *output_pixels, int output_w, int output_h, int output_stride_in_bytes, - stbir_pixel_layout pixel_layout ) -{ - return (unsigned char *) stbir_quick_resize_helper( input_pixels , input_w , input_h, input_stride_in_bytes, - output_pixels, output_w, output_h, output_stride_in_bytes, - pixel_layout, STBIR_TYPE_UINT8, STBIR_EDGE_CLAMP, STBIR_FILTER_DEFAULT ); -} - -STBIRDEF unsigned char * stbir_resize_uint8_srgb( const unsigned char *input_pixels , int input_w , int input_h, int input_stride_in_bytes, - unsigned char *output_pixels, int output_w, int output_h, int output_stride_in_bytes, - stbir_pixel_layout pixel_layout ) -{ - return (unsigned char *) stbir_quick_resize_helper( input_pixels , input_w , input_h, input_stride_in_bytes, - output_pixels, output_w, output_h, output_stride_in_bytes, - pixel_layout, STBIR_TYPE_UINT8_SRGB, STBIR_EDGE_CLAMP, STBIR_FILTER_DEFAULT ); -} - - -STBIRDEF float * stbir_resize_float_linear( const float *input_pixels , int input_w , int input_h, int input_stride_in_bytes, - float *output_pixels, int output_w, int output_h, int output_stride_in_bytes, - stbir_pixel_layout pixel_layout ) -{ - return (float *) stbir_quick_resize_helper( input_pixels , input_w , input_h, input_stride_in_bytes, - output_pixels, output_w, output_h, output_stride_in_bytes, - pixel_layout, STBIR_TYPE_FLOAT, STBIR_EDGE_CLAMP, STBIR_FILTER_DEFAULT ); -} - - -STBIRDEF void * stbir_resize( const void *input_pixels , int input_w , int input_h, int input_stride_in_bytes, - void *output_pixels, int output_w, int output_h, int output_stride_in_bytes, - stbir_pixel_layout pixel_layout, stbir_datatype data_type, - stbir_edge edge, stbir_filter filter ) -{ - return (void *) stbir_quick_resize_helper( input_pixels , input_w , input_h, input_stride_in_bytes, - output_pixels, output_w, output_h, output_stride_in_bytes, - pixel_layout, data_type, edge, filter ); -} - -#ifdef STBIR_PROFILE - -STBIRDEF void stbir_resize_build_profile_info( STBIR_PROFILE_INFO * info, STBIR_RESIZE const * resize ) -{ - static char const * bdescriptions[6] = { "Building", "Allocating", "Horizontal sampler", "Vertical sampler", "Coefficient cleanup", "Coefficient piovot" } ; - stbir__info* samp = resize->samplers; - int i; - - typedef int testa[ (STBIR__ARRAY_SIZE( bdescriptions ) == (STBIR__ARRAY_SIZE( samp->profile.array )-1) )?1:-1]; - typedef int testb[ (sizeof( samp->profile.array ) == (sizeof(samp->profile.named)) )?1:-1]; - typedef int testc[ (sizeof( info->clocks ) >= (sizeof(samp->profile.named)) )?1:-1]; - - for( i = 0 ; i < STBIR__ARRAY_SIZE( bdescriptions ) ; i++) - info->clocks[i] = samp->profile.array[i+1]; - - info->total_clocks = samp->profile.named.total; - info->descriptions = bdescriptions; - info->count = STBIR__ARRAY_SIZE( bdescriptions ); -} - -STBIRDEF void stbir_resize_split_profile_info( STBIR_PROFILE_INFO * info, STBIR_RESIZE const * resize, int split_start, int split_count ) -{ - static char const * descriptions[7] = { "Looping", "Vertical sampling", "Horizontal sampling", "Scanline input", "Scanline output", "Alpha weighting", "Alpha unweighting" }; - stbir__per_split_info * split_info; - int s, i; - - typedef int testa[ (STBIR__ARRAY_SIZE( descriptions ) == (STBIR__ARRAY_SIZE( split_info->profile.array )-1) )?1:-1]; - typedef int testb[ (sizeof( split_info->profile.array ) == (sizeof(split_info->profile.named)) )?1:-1]; - typedef int testc[ (sizeof( info->clocks ) >= (sizeof(split_info->profile.named)) )?1:-1]; - - if ( split_start == -1 ) - { - split_start = 0; - split_count = resize->samplers->splits; - } - - if ( ( split_start >= resize->splits ) || ( split_start < 0 ) || ( ( split_start + split_count ) > resize->splits ) || ( split_count <= 0 ) ) - { - info->total_clocks = 0; - info->descriptions = 0; - info->count = 0; - return; - } - - split_info = resize->samplers->split_info + split_start; - - // sum up the profile from all the splits - for( i = 0 ; i < STBIR__ARRAY_SIZE( descriptions ) ; i++ ) - { - stbir_uint64 sum = 0; - for( s = 0 ; s < split_count ; s++ ) - sum += split_info[s].profile.array[i+1]; - info->clocks[i] = sum; - } - - info->total_clocks = split_info->profile.named.total; - info->descriptions = descriptions; - info->count = STBIR__ARRAY_SIZE( descriptions ); -} - -STBIRDEF void stbir_resize_extended_profile_info( STBIR_PROFILE_INFO * info, STBIR_RESIZE const * resize ) -{ - stbir_resize_split_profile_info( info, resize, -1, 0 ); -} - -#endif // STBIR_PROFILE - -#undef STBIR_BGR -#undef STBIR_1CHANNEL -#undef STBIR_2CHANNEL -#undef STBIR_RGB -#undef STBIR_RGBA -#undef STBIR_4CHANNEL -#undef STBIR_BGRA -#undef STBIR_ARGB -#undef STBIR_ABGR -#undef STBIR_RA -#undef STBIR_AR -#undef STBIR_RGBA_PM -#undef STBIR_BGRA_PM -#undef STBIR_ARGB_PM -#undef STBIR_ABGR_PM -#undef STBIR_RA_PM -#undef STBIR_AR_PM - -#endif // STB_IMAGE_RESIZE_IMPLEMENTATION - -#else // STB_IMAGE_RESIZE_HORIZONTALS&STB_IMAGE_RESIZE_DO_VERTICALS - -// we reinclude the header file to define all the horizontal functions -// specializing each function for the number of coeffs is 20-40% faster *OVERALL* - -// by including the header file again this way, we can still debug the functions - -#define STBIR_strs_join2( start, mid, end ) start##mid##end -#define STBIR_strs_join1( start, mid, end ) STBIR_strs_join2( start, mid, end ) - -#define STBIR_strs_join24( start, mid1, mid2, end ) start##mid1##mid2##end -#define STBIR_strs_join14( start, mid1, mid2, end ) STBIR_strs_join24( start, mid1, mid2, end ) - -#ifdef STB_IMAGE_RESIZE_DO_CODERS - -#ifdef stbir__decode_suffix -#define STBIR__CODER_NAME( name ) STBIR_strs_join1( name, _, stbir__decode_suffix ) -#else -#define STBIR__CODER_NAME( name ) name -#endif - -#ifdef stbir__decode_swizzle -#define stbir__decode_simdf8_flip(reg) STBIR_strs_join1( STBIR_strs_join1( STBIR_strs_join1( STBIR_strs_join1( stbir__simdf8_0123to,stbir__decode_order0,stbir__decode_order1),stbir__decode_order2,stbir__decode_order3),stbir__decode_order0,stbir__decode_order1),stbir__decode_order2,stbir__decode_order3)(reg, reg) -#define stbir__decode_simdf4_flip(reg) STBIR_strs_join1( STBIR_strs_join1( stbir__simdf_0123to,stbir__decode_order0,stbir__decode_order1),stbir__decode_order2,stbir__decode_order3)(reg, reg) -#define stbir__encode_simdf8_unflip(reg) STBIR_strs_join1( STBIR_strs_join1( STBIR_strs_join1( STBIR_strs_join1( stbir__simdf8_0123to,stbir__encode_order0,stbir__encode_order1),stbir__encode_order2,stbir__encode_order3),stbir__encode_order0,stbir__encode_order1),stbir__encode_order2,stbir__encode_order3)(reg, reg) -#define stbir__encode_simdf4_unflip(reg) STBIR_strs_join1( STBIR_strs_join1( stbir__simdf_0123to,stbir__encode_order0,stbir__encode_order1),stbir__encode_order2,stbir__encode_order3)(reg, reg) -#else -#define stbir__decode_order0 0 -#define stbir__decode_order1 1 -#define stbir__decode_order2 2 -#define stbir__decode_order3 3 -#define stbir__encode_order0 0 -#define stbir__encode_order1 1 -#define stbir__encode_order2 2 -#define stbir__encode_order3 3 -#define stbir__decode_simdf8_flip(reg) -#define stbir__decode_simdf4_flip(reg) -#define stbir__encode_simdf8_unflip(reg) -#define stbir__encode_simdf4_unflip(reg) -#endif - -#ifdef STBIR_SIMD8 -#define stbir__encode_simdfX_unflip stbir__encode_simdf8_unflip -#else -#define stbir__encode_simdfX_unflip stbir__encode_simdf4_unflip -#endif - -static float * STBIR__CODER_NAME( stbir__decode_uint8_linear_scaled )( float * decodep, int width_times_channels, void const * inputp ) -{ - float STBIR_STREAMOUT_PTR( * ) decode = decodep; - float * decode_end = (float*) decode + width_times_channels; - unsigned char const * input = (unsigned char const*)inputp; - - #ifdef STBIR_SIMD - unsigned char const * end_input_m16 = input + width_times_channels - 16; - if ( width_times_channels >= 16 ) - { - decode_end -= 16; - STBIR_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - #ifdef STBIR_SIMD8 - stbir__simdi i; stbir__simdi8 o0,o1; - stbir__simdf8 of0, of1; - STBIR_NO_UNROLL(decode); - stbir__simdi_load( i, input ); - stbir__simdi8_expand_u8_to_u32( o0, o1, i ); - stbir__simdi8_convert_i32_to_float( of0, o0 ); - stbir__simdi8_convert_i32_to_float( of1, o1 ); - stbir__simdf8_mult( of0, of0, STBIR_max_uint8_as_float_inverted8); - stbir__simdf8_mult( of1, of1, STBIR_max_uint8_as_float_inverted8); - stbir__decode_simdf8_flip( of0 ); - stbir__decode_simdf8_flip( of1 ); - stbir__simdf8_store( decode + 0, of0 ); - stbir__simdf8_store( decode + 8, of1 ); - #else - stbir__simdi i, o0, o1, o2, o3; - stbir__simdf of0, of1, of2, of3; - STBIR_NO_UNROLL(decode); - stbir__simdi_load( i, input ); - stbir__simdi_expand_u8_to_u32( o0,o1,o2,o3,i); - stbir__simdi_convert_i32_to_float( of0, o0 ); - stbir__simdi_convert_i32_to_float( of1, o1 ); - stbir__simdi_convert_i32_to_float( of2, o2 ); - stbir__simdi_convert_i32_to_float( of3, o3 ); - stbir__simdf_mult( of0, of0, STBIR__CONSTF(STBIR_max_uint8_as_float_inverted) ); - stbir__simdf_mult( of1, of1, STBIR__CONSTF(STBIR_max_uint8_as_float_inverted) ); - stbir__simdf_mult( of2, of2, STBIR__CONSTF(STBIR_max_uint8_as_float_inverted) ); - stbir__simdf_mult( of3, of3, STBIR__CONSTF(STBIR_max_uint8_as_float_inverted) ); - stbir__decode_simdf4_flip( of0 ); - stbir__decode_simdf4_flip( of1 ); - stbir__decode_simdf4_flip( of2 ); - stbir__decode_simdf4_flip( of3 ); - stbir__simdf_store( decode + 0, of0 ); - stbir__simdf_store( decode + 4, of1 ); - stbir__simdf_store( decode + 8, of2 ); - stbir__simdf_store( decode + 12, of3 ); - #endif - decode += 16; - input += 16; - if ( decode <= decode_end ) - continue; - if ( decode == ( decode_end + 16 ) ) - break; - decode = decode_end; // backup and do last couple - input = end_input_m16; - } - return decode_end + 16; - } - #endif - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - decode += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( decode <= decode_end ) - { - STBIR_SIMD_NO_UNROLL(decode); - decode[0-4] = ((float)(input[stbir__decode_order0])) * stbir__max_uint8_as_float_inverted; - decode[1-4] = ((float)(input[stbir__decode_order1])) * stbir__max_uint8_as_float_inverted; - decode[2-4] = ((float)(input[stbir__decode_order2])) * stbir__max_uint8_as_float_inverted; - decode[3-4] = ((float)(input[stbir__decode_order3])) * stbir__max_uint8_as_float_inverted; - decode += 4; - input += 4; - } - decode -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( decode < decode_end ) - { - STBIR_NO_UNROLL(decode); - decode[0] = ((float)(input[stbir__decode_order0])) * stbir__max_uint8_as_float_inverted; - #if stbir__coder_min_num >= 2 - decode[1] = ((float)(input[stbir__decode_order1])) * stbir__max_uint8_as_float_inverted; - #endif - #if stbir__coder_min_num >= 3 - decode[2] = ((float)(input[stbir__decode_order2])) * stbir__max_uint8_as_float_inverted; - #endif - decode += stbir__coder_min_num; - input += stbir__coder_min_num; - } - #endif - - return decode_end; -} - -static void STBIR__CODER_NAME( stbir__encode_uint8_linear_scaled )( void * outputp, int width_times_channels, float const * encode ) -{ - unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char *) outputp; - unsigned char * end_output = ( (unsigned char *) output ) + width_times_channels; - - #ifdef STBIR_SIMD - if ( width_times_channels >= stbir__simdfX_float_count*2 ) - { - float const * end_encode_m8 = encode + width_times_channels - stbir__simdfX_float_count*2; - end_output -= stbir__simdfX_float_count*2; - STBIR_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - stbir__simdfX e0, e1; - stbir__simdi i; - STBIR_SIMD_NO_UNROLL(encode); - stbir__simdfX_madd_mem( e0, STBIR_simd_point5X, STBIR_max_uint8_as_floatX, encode ); - stbir__simdfX_madd_mem( e1, STBIR_simd_point5X, STBIR_max_uint8_as_floatX, encode+stbir__simdfX_float_count ); - stbir__encode_simdfX_unflip( e0 ); - stbir__encode_simdfX_unflip( e1 ); - #ifdef STBIR_SIMD8 - stbir__simdf8_pack_to_16bytes( i, e0, e1 ); - stbir__simdi_store( output, i ); - #else - stbir__simdf_pack_to_8bytes( i, e0, e1 ); - stbir__simdi_store2( output, i ); - #endif - encode += stbir__simdfX_float_count*2; - output += stbir__simdfX_float_count*2; - if ( output <= end_output ) - continue; - if ( output == ( end_output + stbir__simdfX_float_count*2 ) ) - break; - output = end_output; // backup and do last couple - encode = end_encode_m8; - } - return; - } - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_NO_UNROLL_LOOP_START - while( output <= end_output ) - { - stbir__simdf e0; - stbir__simdi i0; - STBIR_NO_UNROLL(encode); - stbir__simdf_load( e0, encode ); - stbir__simdf_madd( e0, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint8_as_float), e0 ); - stbir__encode_simdf4_unflip( e0 ); - stbir__simdf_pack_to_8bytes( i0, e0, e0 ); // only use first 4 - *(int*)(output-4) = stbir__simdi_to_int( i0 ); - output += 4; - encode += 4; - } - output -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( output < end_output ) - { - stbir__simdf e0; - STBIR_NO_UNROLL(encode); - stbir__simdf_madd1_mem( e0, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint8_as_float), encode+stbir__encode_order0 ); output[0] = stbir__simdf_convert_float_to_uint8( e0 ); - #if stbir__coder_min_num >= 2 - stbir__simdf_madd1_mem( e0, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint8_as_float), encode+stbir__encode_order1 ); output[1] = stbir__simdf_convert_float_to_uint8( e0 ); - #endif - #if stbir__coder_min_num >= 3 - stbir__simdf_madd1_mem( e0, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint8_as_float), encode+stbir__encode_order2 ); output[2] = stbir__simdf_convert_float_to_uint8( e0 ); - #endif - output += stbir__coder_min_num; - encode += stbir__coder_min_num; - } - #endif - - #else - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - while( output <= end_output ) - { - float f; - f = encode[stbir__encode_order0] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[0-4] = (unsigned char)f; - f = encode[stbir__encode_order1] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[1-4] = (unsigned char)f; - f = encode[stbir__encode_order2] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[2-4] = (unsigned char)f; - f = encode[stbir__encode_order3] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[3-4] = (unsigned char)f; - output += 4; - encode += 4; - } - output -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( output < end_output ) - { - float f; - STBIR_NO_UNROLL(encode); - f = encode[stbir__encode_order0] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[0] = (unsigned char)f; - #if stbir__coder_min_num >= 2 - f = encode[stbir__encode_order1] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[1] = (unsigned char)f; - #endif - #if stbir__coder_min_num >= 3 - f = encode[stbir__encode_order2] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[2] = (unsigned char)f; - #endif - output += stbir__coder_min_num; - encode += stbir__coder_min_num; - } - #endif - #endif -} - -static float * STBIR__CODER_NAME(stbir__decode_uint8_linear)( float * decodep, int width_times_channels, void const * inputp ) -{ - float STBIR_STREAMOUT_PTR( * ) decode = decodep; - float * decode_end = (float*) decode + width_times_channels; - unsigned char const * input = (unsigned char const*)inputp; - - #ifdef STBIR_SIMD - unsigned char const * end_input_m16 = input + width_times_channels - 16; - if ( width_times_channels >= 16 ) - { - decode_end -= 16; - STBIR_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - #ifdef STBIR_SIMD8 - stbir__simdi i; stbir__simdi8 o0,o1; - stbir__simdf8 of0, of1; - STBIR_NO_UNROLL(decode); - stbir__simdi_load( i, input ); - stbir__simdi8_expand_u8_to_u32( o0, o1, i ); - stbir__simdi8_convert_i32_to_float( of0, o0 ); - stbir__simdi8_convert_i32_to_float( of1, o1 ); - stbir__decode_simdf8_flip( of0 ); - stbir__decode_simdf8_flip( of1 ); - stbir__simdf8_store( decode + 0, of0 ); - stbir__simdf8_store( decode + 8, of1 ); - #else - stbir__simdi i, o0, o1, o2, o3; - stbir__simdf of0, of1, of2, of3; - STBIR_NO_UNROLL(decode); - stbir__simdi_load( i, input ); - stbir__simdi_expand_u8_to_u32( o0,o1,o2,o3,i); - stbir__simdi_convert_i32_to_float( of0, o0 ); - stbir__simdi_convert_i32_to_float( of1, o1 ); - stbir__simdi_convert_i32_to_float( of2, o2 ); - stbir__simdi_convert_i32_to_float( of3, o3 ); - stbir__decode_simdf4_flip( of0 ); - stbir__decode_simdf4_flip( of1 ); - stbir__decode_simdf4_flip( of2 ); - stbir__decode_simdf4_flip( of3 ); - stbir__simdf_store( decode + 0, of0 ); - stbir__simdf_store( decode + 4, of1 ); - stbir__simdf_store( decode + 8, of2 ); - stbir__simdf_store( decode + 12, of3 ); -#endif - decode += 16; - input += 16; - if ( decode <= decode_end ) - continue; - if ( decode == ( decode_end + 16 ) ) - break; - decode = decode_end; // backup and do last couple - input = end_input_m16; - } - return decode_end + 16; - } - #endif - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - decode += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( decode <= decode_end ) - { - STBIR_SIMD_NO_UNROLL(decode); - decode[0-4] = ((float)(input[stbir__decode_order0])); - decode[1-4] = ((float)(input[stbir__decode_order1])); - decode[2-4] = ((float)(input[stbir__decode_order2])); - decode[3-4] = ((float)(input[stbir__decode_order3])); - decode += 4; - input += 4; - } - decode -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( decode < decode_end ) - { - STBIR_NO_UNROLL(decode); - decode[0] = ((float)(input[stbir__decode_order0])); - #if stbir__coder_min_num >= 2 - decode[1] = ((float)(input[stbir__decode_order1])); - #endif - #if stbir__coder_min_num >= 3 - decode[2] = ((float)(input[stbir__decode_order2])); - #endif - decode += stbir__coder_min_num; - input += stbir__coder_min_num; - } - #endif - return decode_end; -} - -static void STBIR__CODER_NAME( stbir__encode_uint8_linear )( void * outputp, int width_times_channels, float const * encode ) -{ - unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char *) outputp; - unsigned char * end_output = ( (unsigned char *) output ) + width_times_channels; - - #ifdef STBIR_SIMD - if ( width_times_channels >= stbir__simdfX_float_count*2 ) - { - float const * end_encode_m8 = encode + width_times_channels - stbir__simdfX_float_count*2; - end_output -= stbir__simdfX_float_count*2; - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - stbir__simdfX e0, e1; - stbir__simdi i; - STBIR_SIMD_NO_UNROLL(encode); - stbir__simdfX_add_mem( e0, STBIR_simd_point5X, encode ); - stbir__simdfX_add_mem( e1, STBIR_simd_point5X, encode+stbir__simdfX_float_count ); - stbir__encode_simdfX_unflip( e0 ); - stbir__encode_simdfX_unflip( e1 ); - #ifdef STBIR_SIMD8 - stbir__simdf8_pack_to_16bytes( i, e0, e1 ); - stbir__simdi_store( output, i ); - #else - stbir__simdf_pack_to_8bytes( i, e0, e1 ); - stbir__simdi_store2( output, i ); - #endif - encode += stbir__simdfX_float_count*2; - output += stbir__simdfX_float_count*2; - if ( output <= end_output ) - continue; - if ( output == ( end_output + stbir__simdfX_float_count*2 ) ) - break; - output = end_output; // backup and do last couple - encode = end_encode_m8; - } - return; - } - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_NO_UNROLL_LOOP_START - while( output <= end_output ) - { - stbir__simdf e0; - stbir__simdi i0; - STBIR_NO_UNROLL(encode); - stbir__simdf_load( e0, encode ); - stbir__simdf_add( e0, STBIR__CONSTF(STBIR_simd_point5), e0 ); - stbir__encode_simdf4_unflip( e0 ); - stbir__simdf_pack_to_8bytes( i0, e0, e0 ); // only use first 4 - *(int*)(output-4) = stbir__simdi_to_int( i0 ); - output += 4; - encode += 4; - } - output -= 4; - #endif - - #else - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - while( output <= end_output ) - { - float f; - f = encode[stbir__encode_order0] + 0.5f; STBIR_CLAMP(f, 0, 255); output[0-4] = (unsigned char)f; - f = encode[stbir__encode_order1] + 0.5f; STBIR_CLAMP(f, 0, 255); output[1-4] = (unsigned char)f; - f = encode[stbir__encode_order2] + 0.5f; STBIR_CLAMP(f, 0, 255); output[2-4] = (unsigned char)f; - f = encode[stbir__encode_order3] + 0.5f; STBIR_CLAMP(f, 0, 255); output[3-4] = (unsigned char)f; - output += 4; - encode += 4; - } - output -= 4; - #endif - - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( output < end_output ) - { - float f; - STBIR_NO_UNROLL(encode); - f = encode[stbir__encode_order0] + 0.5f; STBIR_CLAMP(f, 0, 255); output[0] = (unsigned char)f; - #if stbir__coder_min_num >= 2 - f = encode[stbir__encode_order1] + 0.5f; STBIR_CLAMP(f, 0, 255); output[1] = (unsigned char)f; - #endif - #if stbir__coder_min_num >= 3 - f = encode[stbir__encode_order2] + 0.5f; STBIR_CLAMP(f, 0, 255); output[2] = (unsigned char)f; - #endif - output += stbir__coder_min_num; - encode += stbir__coder_min_num; - } - #endif -} - -static float * STBIR__CODER_NAME(stbir__decode_uint8_srgb)( float * decodep, int width_times_channels, void const * inputp ) -{ - float STBIR_STREAMOUT_PTR( * ) decode = decodep; - float * decode_end = (float*) decode + width_times_channels; - unsigned char const * input = (unsigned char const *)inputp; - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - decode += 4; - while( decode <= decode_end ) - { - decode[0-4] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order0 ] ]; - decode[1-4] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order1 ] ]; - decode[2-4] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order2 ] ]; - decode[3-4] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order3 ] ]; - decode += 4; - input += 4; - } - decode -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( decode < decode_end ) - { - STBIR_NO_UNROLL(decode); - decode[0] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order0 ] ]; - #if stbir__coder_min_num >= 2 - decode[1] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order1 ] ]; - #endif - #if stbir__coder_min_num >= 3 - decode[2] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order2 ] ]; - #endif - decode += stbir__coder_min_num; - input += stbir__coder_min_num; - } - #endif - return decode_end; -} - -#define stbir__min_max_shift20( i, f ) \ - stbir__simdf_max( f, f, stbir_simdf_casti(STBIR__CONSTI( STBIR_almost_zero )) ); \ - stbir__simdf_min( f, f, stbir_simdf_casti(STBIR__CONSTI( STBIR_almost_one )) ); \ - stbir__simdi_32shr( i, stbir_simdi_castf( f ), 20 ); - -#define stbir__scale_and_convert( i, f ) \ - stbir__simdf_madd( f, STBIR__CONSTF( STBIR_simd_point5 ), STBIR__CONSTF( STBIR_max_uint8_as_float ), f ); \ - stbir__simdf_max( f, f, stbir__simdf_zeroP() ); \ - stbir__simdf_min( f, f, STBIR__CONSTF( STBIR_max_uint8_as_float ) ); \ - stbir__simdf_convert_float_to_i32( i, f ); - -#define stbir__linear_to_srgb_finish( i, f ) \ -{ \ - stbir__simdi temp; \ - stbir__simdi_32shr( temp, stbir_simdi_castf( f ), 12 ) ; \ - stbir__simdi_and( temp, temp, STBIR__CONSTI(STBIR_mastissa_mask) ); \ - stbir__simdi_or( temp, temp, STBIR__CONSTI(STBIR_topscale) ); \ - stbir__simdi_16madd( i, i, temp ); \ - stbir__simdi_32shr( i, i, 16 ); \ -} - -#define stbir__simdi_table_lookup2( v0,v1, table ) \ -{ \ - stbir__simdi_u32 temp0,temp1; \ - temp0.m128i_i128 = v0; \ - temp1.m128i_i128 = v1; \ - temp0.m128i_u32[0] = table[temp0.m128i_i32[0]]; temp0.m128i_u32[1] = table[temp0.m128i_i32[1]]; temp0.m128i_u32[2] = table[temp0.m128i_i32[2]]; temp0.m128i_u32[3] = table[temp0.m128i_i32[3]]; \ - temp1.m128i_u32[0] = table[temp1.m128i_i32[0]]; temp1.m128i_u32[1] = table[temp1.m128i_i32[1]]; temp1.m128i_u32[2] = table[temp1.m128i_i32[2]]; temp1.m128i_u32[3] = table[temp1.m128i_i32[3]]; \ - v0 = temp0.m128i_i128; \ - v1 = temp1.m128i_i128; \ -} - -#define stbir__simdi_table_lookup3( v0,v1,v2, table ) \ -{ \ - stbir__simdi_u32 temp0,temp1,temp2; \ - temp0.m128i_i128 = v0; \ - temp1.m128i_i128 = v1; \ - temp2.m128i_i128 = v2; \ - temp0.m128i_u32[0] = table[temp0.m128i_i32[0]]; temp0.m128i_u32[1] = table[temp0.m128i_i32[1]]; temp0.m128i_u32[2] = table[temp0.m128i_i32[2]]; temp0.m128i_u32[3] = table[temp0.m128i_i32[3]]; \ - temp1.m128i_u32[0] = table[temp1.m128i_i32[0]]; temp1.m128i_u32[1] = table[temp1.m128i_i32[1]]; temp1.m128i_u32[2] = table[temp1.m128i_i32[2]]; temp1.m128i_u32[3] = table[temp1.m128i_i32[3]]; \ - temp2.m128i_u32[0] = table[temp2.m128i_i32[0]]; temp2.m128i_u32[1] = table[temp2.m128i_i32[1]]; temp2.m128i_u32[2] = table[temp2.m128i_i32[2]]; temp2.m128i_u32[3] = table[temp2.m128i_i32[3]]; \ - v0 = temp0.m128i_i128; \ - v1 = temp1.m128i_i128; \ - v2 = temp2.m128i_i128; \ -} - -#define stbir__simdi_table_lookup4( v0,v1,v2,v3, table ) \ -{ \ - stbir__simdi_u32 temp0,temp1,temp2,temp3; \ - temp0.m128i_i128 = v0; \ - temp1.m128i_i128 = v1; \ - temp2.m128i_i128 = v2; \ - temp3.m128i_i128 = v3; \ - temp0.m128i_u32[0] = table[temp0.m128i_i32[0]]; temp0.m128i_u32[1] = table[temp0.m128i_i32[1]]; temp0.m128i_u32[2] = table[temp0.m128i_i32[2]]; temp0.m128i_u32[3] = table[temp0.m128i_i32[3]]; \ - temp1.m128i_u32[0] = table[temp1.m128i_i32[0]]; temp1.m128i_u32[1] = table[temp1.m128i_i32[1]]; temp1.m128i_u32[2] = table[temp1.m128i_i32[2]]; temp1.m128i_u32[3] = table[temp1.m128i_i32[3]]; \ - temp2.m128i_u32[0] = table[temp2.m128i_i32[0]]; temp2.m128i_u32[1] = table[temp2.m128i_i32[1]]; temp2.m128i_u32[2] = table[temp2.m128i_i32[2]]; temp2.m128i_u32[3] = table[temp2.m128i_i32[3]]; \ - temp3.m128i_u32[0] = table[temp3.m128i_i32[0]]; temp3.m128i_u32[1] = table[temp3.m128i_i32[1]]; temp3.m128i_u32[2] = table[temp3.m128i_i32[2]]; temp3.m128i_u32[3] = table[temp3.m128i_i32[3]]; \ - v0 = temp0.m128i_i128; \ - v1 = temp1.m128i_i128; \ - v2 = temp2.m128i_i128; \ - v3 = temp3.m128i_i128; \ -} - -static void STBIR__CODER_NAME( stbir__encode_uint8_srgb )( void * outputp, int width_times_channels, float const * encode ) -{ - unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char*) outputp; - unsigned char * end_output = ( (unsigned char*) output ) + width_times_channels; - - #ifdef STBIR_SIMD - - if ( width_times_channels >= 16 ) - { - float const * end_encode_m16 = encode + width_times_channels - 16; - end_output -= 16; - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - stbir__simdf f0, f1, f2, f3; - stbir__simdi i0, i1, i2, i3; - STBIR_SIMD_NO_UNROLL(encode); - - stbir__simdf_load4_transposed( f0, f1, f2, f3, encode ); - - stbir__min_max_shift20( i0, f0 ); - stbir__min_max_shift20( i1, f1 ); - stbir__min_max_shift20( i2, f2 ); - stbir__min_max_shift20( i3, f3 ); - - stbir__simdi_table_lookup4( i0, i1, i2, i3, ( fp32_to_srgb8_tab4 - (127-13)*8 ) ); - - stbir__linear_to_srgb_finish( i0, f0 ); - stbir__linear_to_srgb_finish( i1, f1 ); - stbir__linear_to_srgb_finish( i2, f2 ); - stbir__linear_to_srgb_finish( i3, f3 ); - - stbir__interleave_pack_and_store_16_u8( output, STBIR_strs_join1(i, ,stbir__encode_order0), STBIR_strs_join1(i, ,stbir__encode_order1), STBIR_strs_join1(i, ,stbir__encode_order2), STBIR_strs_join1(i, ,stbir__encode_order3) ); - - encode += 16; - output += 16; - if ( output <= end_output ) - continue; - if ( output == ( end_output + 16 ) ) - break; - output = end_output; // backup and do last couple - encode = end_encode_m16; - } - return; - } - #endif - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while ( output <= end_output ) - { - STBIR_SIMD_NO_UNROLL(encode); - - output[0-4] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order0] ); - output[1-4] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order1] ); - output[2-4] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order2] ); - output[3-4] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order3] ); - - output += 4; - encode += 4; - } - output -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( output < end_output ) - { - STBIR_NO_UNROLL(encode); - output[0] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order0] ); - #if stbir__coder_min_num >= 2 - output[1] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order1] ); - #endif - #if stbir__coder_min_num >= 3 - output[2] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order2] ); - #endif - output += stbir__coder_min_num; - encode += stbir__coder_min_num; - } - #endif -} - -#if ( stbir__coder_min_num == 4 ) || ( ( stbir__coder_min_num == 1 ) && ( !defined(stbir__decode_swizzle) ) ) - -static float * STBIR__CODER_NAME(stbir__decode_uint8_srgb4_linearalpha)( float * decodep, int width_times_channels, void const * inputp ) -{ - float STBIR_STREAMOUT_PTR( * ) decode = decodep; - float * decode_end = (float*) decode + width_times_channels; - unsigned char const * input = (unsigned char const *)inputp; - - do { - decode[0] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order0] ]; - decode[1] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order1] ]; - decode[2] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order2] ]; - decode[3] = ( (float) input[stbir__decode_order3] ) * stbir__max_uint8_as_float_inverted; - input += 4; - decode += 4; - } while( decode < decode_end ); - return decode_end; -} - - -static void STBIR__CODER_NAME( stbir__encode_uint8_srgb4_linearalpha )( void * outputp, int width_times_channels, float const * encode ) -{ - unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char*) outputp; - unsigned char * end_output = ( (unsigned char*) output ) + width_times_channels; - - #ifdef STBIR_SIMD - - if ( width_times_channels >= 16 ) - { - float const * end_encode_m16 = encode + width_times_channels - 16; - end_output -= 16; - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - stbir__simdf f0, f1, f2, f3; - stbir__simdi i0, i1, i2, i3; - - STBIR_SIMD_NO_UNROLL(encode); - stbir__simdf_load4_transposed( f0, f1, f2, f3, encode ); - - stbir__min_max_shift20( i0, f0 ); - stbir__min_max_shift20( i1, f1 ); - stbir__min_max_shift20( i2, f2 ); - stbir__scale_and_convert( i3, f3 ); - - stbir__simdi_table_lookup3( i0, i1, i2, ( fp32_to_srgb8_tab4 - (127-13)*8 ) ); - - stbir__linear_to_srgb_finish( i0, f0 ); - stbir__linear_to_srgb_finish( i1, f1 ); - stbir__linear_to_srgb_finish( i2, f2 ); - - stbir__interleave_pack_and_store_16_u8( output, STBIR_strs_join1(i, ,stbir__encode_order0), STBIR_strs_join1(i, ,stbir__encode_order1), STBIR_strs_join1(i, ,stbir__encode_order2), STBIR_strs_join1(i, ,stbir__encode_order3) ); - - output += 16; - encode += 16; - - if ( output <= end_output ) - continue; - if ( output == ( end_output + 16 ) ) - break; - output = end_output; // backup and do last couple - encode = end_encode_m16; - } - return; - } - #endif - - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float f; - STBIR_SIMD_NO_UNROLL(encode); - - output[stbir__decode_order0] = stbir__linear_to_srgb_uchar( encode[0] ); - output[stbir__decode_order1] = stbir__linear_to_srgb_uchar( encode[1] ); - output[stbir__decode_order2] = stbir__linear_to_srgb_uchar( encode[2] ); - - f = encode[3] * stbir__max_uint8_as_float + 0.5f; - STBIR_CLAMP(f, 0, 255); - output[stbir__decode_order3] = (unsigned char) f; - - output += 4; - encode += 4; - } while( output < end_output ); -} - -#endif - -#if ( stbir__coder_min_num == 2 ) || ( ( stbir__coder_min_num == 1 ) && ( !defined(stbir__decode_swizzle) ) ) - -static float * STBIR__CODER_NAME(stbir__decode_uint8_srgb2_linearalpha)( float * decodep, int width_times_channels, void const * inputp ) -{ - float STBIR_STREAMOUT_PTR( * ) decode = decodep; - float * decode_end = (float*) decode + width_times_channels; - unsigned char const * input = (unsigned char const *)inputp; - - decode += 4; - while( decode <= decode_end ) - { - decode[0-4] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order0] ]; - decode[1-4] = ( (float) input[stbir__decode_order1] ) * stbir__max_uint8_as_float_inverted; - decode[2-4] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order0+2] ]; - decode[3-4] = ( (float) input[stbir__decode_order1+2] ) * stbir__max_uint8_as_float_inverted; - input += 4; - decode += 4; - } - decode -= 4; - if( decode < decode_end ) - { - decode[0] = stbir__srgb_uchar_to_linear_float[ stbir__decode_order0 ]; - decode[1] = ( (float) input[stbir__decode_order1] ) * stbir__max_uint8_as_float_inverted; - } - return decode_end; -} - -static void STBIR__CODER_NAME( stbir__encode_uint8_srgb2_linearalpha )( void * outputp, int width_times_channels, float const * encode ) -{ - unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char*) outputp; - unsigned char * end_output = ( (unsigned char*) output ) + width_times_channels; - - #ifdef STBIR_SIMD - - if ( width_times_channels >= 16 ) - { - float const * end_encode_m16 = encode + width_times_channels - 16; - end_output -= 16; - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - stbir__simdf f0, f1, f2, f3; - stbir__simdi i0, i1, i2, i3; - - STBIR_SIMD_NO_UNROLL(encode); - stbir__simdf_load4_transposed( f0, f1, f2, f3, encode ); - - stbir__min_max_shift20( i0, f0 ); - stbir__scale_and_convert( i1, f1 ); - stbir__min_max_shift20( i2, f2 ); - stbir__scale_and_convert( i3, f3 ); - - stbir__simdi_table_lookup2( i0, i2, ( fp32_to_srgb8_tab4 - (127-13)*8 ) ); - - stbir__linear_to_srgb_finish( i0, f0 ); - stbir__linear_to_srgb_finish( i2, f2 ); - - stbir__interleave_pack_and_store_16_u8( output, STBIR_strs_join1(i, ,stbir__encode_order0), STBIR_strs_join1(i, ,stbir__encode_order1), STBIR_strs_join1(i, ,stbir__encode_order2), STBIR_strs_join1(i, ,stbir__encode_order3) ); - - output += 16; - encode += 16; - if ( output <= end_output ) - continue; - if ( output == ( end_output + 16 ) ) - break; - output = end_output; // backup and do last couple - encode = end_encode_m16; - } - return; - } - #endif - - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float f; - STBIR_SIMD_NO_UNROLL(encode); - - output[stbir__decode_order0] = stbir__linear_to_srgb_uchar( encode[0] ); - - f = encode[1] * stbir__max_uint8_as_float + 0.5f; - STBIR_CLAMP(f, 0, 255); - output[stbir__decode_order1] = (unsigned char) f; - - output += 2; - encode += 2; - } while( output < end_output ); -} - -#endif - -static float * STBIR__CODER_NAME(stbir__decode_uint16_linear_scaled)( float * decodep, int width_times_channels, void const * inputp ) -{ - float STBIR_STREAMOUT_PTR( * ) decode = decodep; - float * decode_end = (float*) decode + width_times_channels; - unsigned short const * input = (unsigned short const *)inputp; - - #ifdef STBIR_SIMD - unsigned short const * end_input_m8 = input + width_times_channels - 8; - if ( width_times_channels >= 8 ) - { - decode_end -= 8; - STBIR_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - #ifdef STBIR_SIMD8 - stbir__simdi i; stbir__simdi8 o; - stbir__simdf8 of; - STBIR_NO_UNROLL(decode); - stbir__simdi_load( i, input ); - stbir__simdi8_expand_u16_to_u32( o, i ); - stbir__simdi8_convert_i32_to_float( of, o ); - stbir__simdf8_mult( of, of, STBIR_max_uint16_as_float_inverted8); - stbir__decode_simdf8_flip( of ); - stbir__simdf8_store( decode + 0, of ); - #else - stbir__simdi i, o0, o1; - stbir__simdf of0, of1; - STBIR_NO_UNROLL(decode); - stbir__simdi_load( i, input ); - stbir__simdi_expand_u16_to_u32( o0,o1,i ); - stbir__simdi_convert_i32_to_float( of0, o0 ); - stbir__simdi_convert_i32_to_float( of1, o1 ); - stbir__simdf_mult( of0, of0, STBIR__CONSTF(STBIR_max_uint16_as_float_inverted) ); - stbir__simdf_mult( of1, of1, STBIR__CONSTF(STBIR_max_uint16_as_float_inverted)); - stbir__decode_simdf4_flip( of0 ); - stbir__decode_simdf4_flip( of1 ); - stbir__simdf_store( decode + 0, of0 ); - stbir__simdf_store( decode + 4, of1 ); - #endif - decode += 8; - input += 8; - if ( decode <= decode_end ) - continue; - if ( decode == ( decode_end + 8 ) ) - break; - decode = decode_end; // backup and do last couple - input = end_input_m8; - } - return decode_end + 8; - } - #endif - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - decode += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( decode <= decode_end ) - { - STBIR_SIMD_NO_UNROLL(decode); - decode[0-4] = ((float)(input[stbir__decode_order0])) * stbir__max_uint16_as_float_inverted; - decode[1-4] = ((float)(input[stbir__decode_order1])) * stbir__max_uint16_as_float_inverted; - decode[2-4] = ((float)(input[stbir__decode_order2])) * stbir__max_uint16_as_float_inverted; - decode[3-4] = ((float)(input[stbir__decode_order3])) * stbir__max_uint16_as_float_inverted; - decode += 4; - input += 4; - } - decode -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( decode < decode_end ) - { - STBIR_NO_UNROLL(decode); - decode[0] = ((float)(input[stbir__decode_order0])) * stbir__max_uint16_as_float_inverted; - #if stbir__coder_min_num >= 2 - decode[1] = ((float)(input[stbir__decode_order1])) * stbir__max_uint16_as_float_inverted; - #endif - #if stbir__coder_min_num >= 3 - decode[2] = ((float)(input[stbir__decode_order2])) * stbir__max_uint16_as_float_inverted; - #endif - decode += stbir__coder_min_num; - input += stbir__coder_min_num; - } - #endif - return decode_end; -} - - -static void STBIR__CODER_NAME(stbir__encode_uint16_linear_scaled)( void * outputp, int width_times_channels, float const * encode ) -{ - unsigned short STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned short*) outputp; - unsigned short * end_output = ( (unsigned short*) output ) + width_times_channels; - - #ifdef STBIR_SIMD - { - if ( width_times_channels >= stbir__simdfX_float_count*2 ) - { - float const * end_encode_m8 = encode + width_times_channels - stbir__simdfX_float_count*2; - end_output -= stbir__simdfX_float_count*2; - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - stbir__simdfX e0, e1; - stbir__simdiX i; - STBIR_SIMD_NO_UNROLL(encode); - stbir__simdfX_madd_mem( e0, STBIR_simd_point5X, STBIR_max_uint16_as_floatX, encode ); - stbir__simdfX_madd_mem( e1, STBIR_simd_point5X, STBIR_max_uint16_as_floatX, encode+stbir__simdfX_float_count ); - stbir__encode_simdfX_unflip( e0 ); - stbir__encode_simdfX_unflip( e1 ); - stbir__simdfX_pack_to_words( i, e0, e1 ); - stbir__simdiX_store( output, i ); - encode += stbir__simdfX_float_count*2; - output += stbir__simdfX_float_count*2; - if ( output <= end_output ) - continue; - if ( output == ( end_output + stbir__simdfX_float_count*2 ) ) - break; - output = end_output; // backup and do last couple - encode = end_encode_m8; - } - return; - } - } - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_NO_UNROLL_LOOP_START - while( output <= end_output ) - { - stbir__simdf e; - stbir__simdi i; - STBIR_NO_UNROLL(encode); - stbir__simdf_load( e, encode ); - stbir__simdf_madd( e, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint16_as_float), e ); - stbir__encode_simdf4_unflip( e ); - stbir__simdf_pack_to_8words( i, e, e ); // only use first 4 - stbir__simdi_store2( output-4, i ); - output += 4; - encode += 4; - } - output -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( output < end_output ) - { - stbir__simdf e; - STBIR_NO_UNROLL(encode); - stbir__simdf_madd1_mem( e, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint16_as_float), encode+stbir__encode_order0 ); output[0] = stbir__simdf_convert_float_to_short( e ); - #if stbir__coder_min_num >= 2 - stbir__simdf_madd1_mem( e, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint16_as_float), encode+stbir__encode_order1 ); output[1] = stbir__simdf_convert_float_to_short( e ); - #endif - #if stbir__coder_min_num >= 3 - stbir__simdf_madd1_mem( e, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint16_as_float), encode+stbir__encode_order2 ); output[2] = stbir__simdf_convert_float_to_short( e ); - #endif - output += stbir__coder_min_num; - encode += stbir__coder_min_num; - } - #endif - - #else - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( output <= end_output ) - { - float f; - STBIR_SIMD_NO_UNROLL(encode); - f = encode[stbir__encode_order0] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[0-4] = (unsigned short)f; - f = encode[stbir__encode_order1] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[1-4] = (unsigned short)f; - f = encode[stbir__encode_order2] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[2-4] = (unsigned short)f; - f = encode[stbir__encode_order3] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[3-4] = (unsigned short)f; - output += 4; - encode += 4; - } - output -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( output < end_output ) - { - float f; - STBIR_NO_UNROLL(encode); - f = encode[stbir__encode_order0] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[0] = (unsigned short)f; - #if stbir__coder_min_num >= 2 - f = encode[stbir__encode_order1] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[1] = (unsigned short)f; - #endif - #if stbir__coder_min_num >= 3 - f = encode[stbir__encode_order2] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[2] = (unsigned short)f; - #endif - output += stbir__coder_min_num; - encode += stbir__coder_min_num; - } - #endif - #endif -} - -static float * STBIR__CODER_NAME(stbir__decode_uint16_linear)( float * decodep, int width_times_channels, void const * inputp ) -{ - float STBIR_STREAMOUT_PTR( * ) decode = decodep; - float * decode_end = (float*) decode + width_times_channels; - unsigned short const * input = (unsigned short const *)inputp; - - #ifdef STBIR_SIMD - unsigned short const * end_input_m8 = input + width_times_channels - 8; - if ( width_times_channels >= 8 ) - { - decode_end -= 8; - STBIR_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - #ifdef STBIR_SIMD8 - stbir__simdi i; stbir__simdi8 o; - stbir__simdf8 of; - STBIR_NO_UNROLL(decode); - stbir__simdi_load( i, input ); - stbir__simdi8_expand_u16_to_u32( o, i ); - stbir__simdi8_convert_i32_to_float( of, o ); - stbir__decode_simdf8_flip( of ); - stbir__simdf8_store( decode + 0, of ); - #else - stbir__simdi i, o0, o1; - stbir__simdf of0, of1; - STBIR_NO_UNROLL(decode); - stbir__simdi_load( i, input ); - stbir__simdi_expand_u16_to_u32( o0, o1, i ); - stbir__simdi_convert_i32_to_float( of0, o0 ); - stbir__simdi_convert_i32_to_float( of1, o1 ); - stbir__decode_simdf4_flip( of0 ); - stbir__decode_simdf4_flip( of1 ); - stbir__simdf_store( decode + 0, of0 ); - stbir__simdf_store( decode + 4, of1 ); - #endif - decode += 8; - input += 8; - if ( decode <= decode_end ) - continue; - if ( decode == ( decode_end + 8 ) ) - break; - decode = decode_end; // backup and do last couple - input = end_input_m8; - } - return decode_end + 8; - } - #endif - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - decode += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( decode <= decode_end ) - { - STBIR_SIMD_NO_UNROLL(decode); - decode[0-4] = ((float)(input[stbir__decode_order0])); - decode[1-4] = ((float)(input[stbir__decode_order1])); - decode[2-4] = ((float)(input[stbir__decode_order2])); - decode[3-4] = ((float)(input[stbir__decode_order3])); - decode += 4; - input += 4; - } - decode -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( decode < decode_end ) - { - STBIR_NO_UNROLL(decode); - decode[0] = ((float)(input[stbir__decode_order0])); - #if stbir__coder_min_num >= 2 - decode[1] = ((float)(input[stbir__decode_order1])); - #endif - #if stbir__coder_min_num >= 3 - decode[2] = ((float)(input[stbir__decode_order2])); - #endif - decode += stbir__coder_min_num; - input += stbir__coder_min_num; - } - #endif - return decode_end; -} - -static void STBIR__CODER_NAME(stbir__encode_uint16_linear)( void * outputp, int width_times_channels, float const * encode ) -{ - unsigned short STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned short*) outputp; - unsigned short * end_output = ( (unsigned short*) output ) + width_times_channels; - - #ifdef STBIR_SIMD - { - if ( width_times_channels >= stbir__simdfX_float_count*2 ) - { - float const * end_encode_m8 = encode + width_times_channels - stbir__simdfX_float_count*2; - end_output -= stbir__simdfX_float_count*2; - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - stbir__simdfX e0, e1; - stbir__simdiX i; - STBIR_SIMD_NO_UNROLL(encode); - stbir__simdfX_add_mem( e0, STBIR_simd_point5X, encode ); - stbir__simdfX_add_mem( e1, STBIR_simd_point5X, encode+stbir__simdfX_float_count ); - stbir__encode_simdfX_unflip( e0 ); - stbir__encode_simdfX_unflip( e1 ); - stbir__simdfX_pack_to_words( i, e0, e1 ); - stbir__simdiX_store( output, i ); - encode += stbir__simdfX_float_count*2; - output += stbir__simdfX_float_count*2; - if ( output <= end_output ) - continue; - if ( output == ( end_output + stbir__simdfX_float_count*2 ) ) - break; - output = end_output; // backup and do last couple - encode = end_encode_m8; - } - return; - } - } - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_NO_UNROLL_LOOP_START - while( output <= end_output ) - { - stbir__simdf e; - stbir__simdi i; - STBIR_NO_UNROLL(encode); - stbir__simdf_load( e, encode ); - stbir__simdf_add( e, STBIR__CONSTF(STBIR_simd_point5), e ); - stbir__encode_simdf4_unflip( e ); - stbir__simdf_pack_to_8words( i, e, e ); // only use first 4 - stbir__simdi_store2( output-4, i ); - output += 4; - encode += 4; - } - output -= 4; - #endif - - #else - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( output <= end_output ) - { - float f; - STBIR_SIMD_NO_UNROLL(encode); - f = encode[stbir__encode_order0] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[0-4] = (unsigned short)f; - f = encode[stbir__encode_order1] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[1-4] = (unsigned short)f; - f = encode[stbir__encode_order2] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[2-4] = (unsigned short)f; - f = encode[stbir__encode_order3] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[3-4] = (unsigned short)f; - output += 4; - encode += 4; - } - output -= 4; - #endif - - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( output < end_output ) - { - float f; - STBIR_NO_UNROLL(encode); - f = encode[stbir__encode_order0] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[0] = (unsigned short)f; - #if stbir__coder_min_num >= 2 - f = encode[stbir__encode_order1] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[1] = (unsigned short)f; - #endif - #if stbir__coder_min_num >= 3 - f = encode[stbir__encode_order2] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[2] = (unsigned short)f; - #endif - output += stbir__coder_min_num; - encode += stbir__coder_min_num; - } - #endif -} - -static float * STBIR__CODER_NAME(stbir__decode_half_float_linear)( float * decodep, int width_times_channels, void const * inputp ) -{ - float STBIR_STREAMOUT_PTR( * ) decode = decodep; - float * decode_end = (float*) decode + width_times_channels; - stbir__FP16 const * input = (stbir__FP16 const *)inputp; - - #ifdef STBIR_SIMD - if ( width_times_channels >= 8 ) - { - stbir__FP16 const * end_input_m8 = input + width_times_channels - 8; - decode_end -= 8; - STBIR_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - STBIR_NO_UNROLL(decode); - - stbir__half_to_float_SIMD( decode, input ); - #ifdef stbir__decode_swizzle - #ifdef STBIR_SIMD8 - { - stbir__simdf8 of; - stbir__simdf8_load( of, decode ); - stbir__decode_simdf8_flip( of ); - stbir__simdf8_store( decode, of ); - } - #else - { - stbir__simdf of0,of1; - stbir__simdf_load( of0, decode ); - stbir__simdf_load( of1, decode+4 ); - stbir__decode_simdf4_flip( of0 ); - stbir__decode_simdf4_flip( of1 ); - stbir__simdf_store( decode, of0 ); - stbir__simdf_store( decode+4, of1 ); - } - #endif - #endif - decode += 8; - input += 8; - if ( decode <= decode_end ) - continue; - if ( decode == ( decode_end + 8 ) ) - break; - decode = decode_end; // backup and do last couple - input = end_input_m8; - } - return decode_end + 8; - } - #endif - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - decode += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( decode <= decode_end ) - { - STBIR_SIMD_NO_UNROLL(decode); - decode[0-4] = stbir__half_to_float(input[stbir__decode_order0]); - decode[1-4] = stbir__half_to_float(input[stbir__decode_order1]); - decode[2-4] = stbir__half_to_float(input[stbir__decode_order2]); - decode[3-4] = stbir__half_to_float(input[stbir__decode_order3]); - decode += 4; - input += 4; - } - decode -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( decode < decode_end ) - { - STBIR_NO_UNROLL(decode); - decode[0] = stbir__half_to_float(input[stbir__decode_order0]); - #if stbir__coder_min_num >= 2 - decode[1] = stbir__half_to_float(input[stbir__decode_order1]); - #endif - #if stbir__coder_min_num >= 3 - decode[2] = stbir__half_to_float(input[stbir__decode_order2]); - #endif - decode += stbir__coder_min_num; - input += stbir__coder_min_num; - } - #endif - return decode_end; -} - -static void STBIR__CODER_NAME( stbir__encode_half_float_linear )( void * outputp, int width_times_channels, float const * encode ) -{ - stbir__FP16 STBIR_SIMD_STREAMOUT_PTR( * ) output = (stbir__FP16*) outputp; - stbir__FP16 * end_output = ( (stbir__FP16*) output ) + width_times_channels; - - #ifdef STBIR_SIMD - if ( width_times_channels >= 8 ) - { - float const * end_encode_m8 = encode + width_times_channels - 8; - end_output -= 8; - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - STBIR_SIMD_NO_UNROLL(encode); - #ifdef stbir__decode_swizzle - #ifdef STBIR_SIMD8 - { - stbir__simdf8 of; - stbir__simdf8_load( of, encode ); - stbir__encode_simdf8_unflip( of ); - stbir__float_to_half_SIMD( output, (float*)&of ); - } - #else - { - stbir__simdf of[2]; - stbir__simdf_load( of[0], encode ); - stbir__simdf_load( of[1], encode+4 ); - stbir__encode_simdf4_unflip( of[0] ); - stbir__encode_simdf4_unflip( of[1] ); - stbir__float_to_half_SIMD( output, (float*)of ); - } - #endif - #else - stbir__float_to_half_SIMD( output, encode ); - #endif - encode += 8; - output += 8; - if ( output <= end_output ) - continue; - if ( output == ( end_output + 8 ) ) - break; - output = end_output; // backup and do last couple - encode = end_encode_m8; - } - return; - } - #endif - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( output <= end_output ) - { - STBIR_SIMD_NO_UNROLL(output); - output[0-4] = stbir__float_to_half(encode[stbir__encode_order0]); - output[1-4] = stbir__float_to_half(encode[stbir__encode_order1]); - output[2-4] = stbir__float_to_half(encode[stbir__encode_order2]); - output[3-4] = stbir__float_to_half(encode[stbir__encode_order3]); - output += 4; - encode += 4; - } - output -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( output < end_output ) - { - STBIR_NO_UNROLL(output); - output[0] = stbir__float_to_half(encode[stbir__encode_order0]); - #if stbir__coder_min_num >= 2 - output[1] = stbir__float_to_half(encode[stbir__encode_order1]); - #endif - #if stbir__coder_min_num >= 3 - output[2] = stbir__float_to_half(encode[stbir__encode_order2]); - #endif - output += stbir__coder_min_num; - encode += stbir__coder_min_num; - } - #endif -} - -static float * STBIR__CODER_NAME(stbir__decode_float_linear)( float * decodep, int width_times_channels, void const * inputp ) -{ - #ifdef stbir__decode_swizzle - float STBIR_STREAMOUT_PTR( * ) decode = decodep; - float * decode_end = (float*) decode + width_times_channels; - float const * input = (float const *)inputp; - - #ifdef STBIR_SIMD - if ( width_times_channels >= 16 ) - { - float const * end_input_m16 = input + width_times_channels - 16; - decode_end -= 16; - STBIR_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - STBIR_NO_UNROLL(decode); - #ifdef stbir__decode_swizzle - #ifdef STBIR_SIMD8 - { - stbir__simdf8 of0,of1; - stbir__simdf8_load( of0, input ); - stbir__simdf8_load( of1, input+8 ); - stbir__decode_simdf8_flip( of0 ); - stbir__decode_simdf8_flip( of1 ); - stbir__simdf8_store( decode, of0 ); - stbir__simdf8_store( decode+8, of1 ); - } - #else - { - stbir__simdf of0,of1,of2,of3; - stbir__simdf_load( of0, input ); - stbir__simdf_load( of1, input+4 ); - stbir__simdf_load( of2, input+8 ); - stbir__simdf_load( of3, input+12 ); - stbir__decode_simdf4_flip( of0 ); - stbir__decode_simdf4_flip( of1 ); - stbir__decode_simdf4_flip( of2 ); - stbir__decode_simdf4_flip( of3 ); - stbir__simdf_store( decode, of0 ); - stbir__simdf_store( decode+4, of1 ); - stbir__simdf_store( decode+8, of2 ); - stbir__simdf_store( decode+12, of3 ); - } - #endif - #endif - decode += 16; - input += 16; - if ( decode <= decode_end ) - continue; - if ( decode == ( decode_end + 16 ) ) - break; - decode = decode_end; // backup and do last couple - input = end_input_m16; - } - return decode_end + 16; - } - #endif - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - decode += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( decode <= decode_end ) - { - STBIR_SIMD_NO_UNROLL(decode); - decode[0-4] = input[stbir__decode_order0]; - decode[1-4] = input[stbir__decode_order1]; - decode[2-4] = input[stbir__decode_order2]; - decode[3-4] = input[stbir__decode_order3]; - decode += 4; - input += 4; - } - decode -= 4; - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( decode < decode_end ) - { - STBIR_NO_UNROLL(decode); - decode[0] = input[stbir__decode_order0]; - #if stbir__coder_min_num >= 2 - decode[1] = input[stbir__decode_order1]; - #endif - #if stbir__coder_min_num >= 3 - decode[2] = input[stbir__decode_order2]; - #endif - decode += stbir__coder_min_num; - input += stbir__coder_min_num; - } - #endif - return decode_end; - - #else - - if ( (void*)decodep != inputp ) - STBIR_MEMCPY( decodep, inputp, width_times_channels * sizeof( float ) ); - - return decodep + width_times_channels; - - #endif -} - -static void STBIR__CODER_NAME( stbir__encode_float_linear )( void * outputp, int width_times_channels, float const * encode ) -{ - #if !defined( STBIR_FLOAT_HIGH_CLAMP ) && !defined(STBIR_FLOAT_LO_CLAMP) && !defined(stbir__decode_swizzle) - - if ( (void*)outputp != (void*) encode ) - STBIR_MEMCPY( outputp, encode, width_times_channels * sizeof( float ) ); - - #else - - float STBIR_SIMD_STREAMOUT_PTR( * ) output = (float*) outputp; - float * end_output = ( (float*) output ) + width_times_channels; - - #ifdef STBIR_FLOAT_HIGH_CLAMP - #define stbir_scalar_hi_clamp( v ) if ( v > STBIR_FLOAT_HIGH_CLAMP ) v = STBIR_FLOAT_HIGH_CLAMP; - #else - #define stbir_scalar_hi_clamp( v ) - #endif - #ifdef STBIR_FLOAT_LOW_CLAMP - #define stbir_scalar_lo_clamp( v ) if ( v < STBIR_FLOAT_LOW_CLAMP ) v = STBIR_FLOAT_LOW_CLAMP; - #else - #define stbir_scalar_lo_clamp( v ) - #endif - - #ifdef STBIR_SIMD - - #ifdef STBIR_FLOAT_HIGH_CLAMP - const stbir__simdfX high_clamp = stbir__simdf_frepX(STBIR_FLOAT_HIGH_CLAMP); - #endif - #ifdef STBIR_FLOAT_LOW_CLAMP - const stbir__simdfX low_clamp = stbir__simdf_frepX(STBIR_FLOAT_LOW_CLAMP); - #endif - - if ( width_times_channels >= ( stbir__simdfX_float_count * 2 ) ) - { - float const * end_encode_m8 = encode + width_times_channels - ( stbir__simdfX_float_count * 2 ); - end_output -= ( stbir__simdfX_float_count * 2 ); - STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR - for(;;) - { - stbir__simdfX e0, e1; - STBIR_SIMD_NO_UNROLL(encode); - stbir__simdfX_load( e0, encode ); - stbir__simdfX_load( e1, encode+stbir__simdfX_float_count ); -#ifdef STBIR_FLOAT_HIGH_CLAMP - stbir__simdfX_min( e0, e0, high_clamp ); - stbir__simdfX_min( e1, e1, high_clamp ); -#endif -#ifdef STBIR_FLOAT_LOW_CLAMP - stbir__simdfX_max( e0, e0, low_clamp ); - stbir__simdfX_max( e1, e1, low_clamp ); -#endif - stbir__encode_simdfX_unflip( e0 ); - stbir__encode_simdfX_unflip( e1 ); - stbir__simdfX_store( output, e0 ); - stbir__simdfX_store( output+stbir__simdfX_float_count, e1 ); - encode += stbir__simdfX_float_count * 2; - output += stbir__simdfX_float_count * 2; - if ( output < end_output ) - continue; - if ( output == ( end_output + ( stbir__simdfX_float_count * 2 ) ) ) - break; - output = end_output; // backup and do last couple - encode = end_encode_m8; - } - return; - } - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_NO_UNROLL_LOOP_START - while( output <= end_output ) - { - stbir__simdf e0; - STBIR_NO_UNROLL(encode); - stbir__simdf_load( e0, encode ); -#ifdef STBIR_FLOAT_HIGH_CLAMP - stbir__simdf_min( e0, e0, high_clamp ); -#endif -#ifdef STBIR_FLOAT_LOW_CLAMP - stbir__simdf_max( e0, e0, low_clamp ); -#endif - stbir__encode_simdf4_unflip( e0 ); - stbir__simdf_store( output-4, e0 ); - output += 4; - encode += 4; - } - output -= 4; - #endif - - #else - - // try to do blocks of 4 when you can - #if stbir__coder_min_num != 3 // doesn't divide cleanly by four - output += 4; - STBIR_SIMD_NO_UNROLL_LOOP_START - while( output <= end_output ) - { - float e; - STBIR_SIMD_NO_UNROLL(encode); - e = encode[ stbir__encode_order0 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[0-4] = e; - e = encode[ stbir__encode_order1 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[1-4] = e; - e = encode[ stbir__encode_order2 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[2-4] = e; - e = encode[ stbir__encode_order3 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[3-4] = e; - output += 4; - encode += 4; - } - output -= 4; - - #endif - - #endif - - // do the remnants - #if stbir__coder_min_num < 4 - STBIR_NO_UNROLL_LOOP_START - while( output < end_output ) - { - float e; - STBIR_NO_UNROLL(encode); - e = encode[ stbir__encode_order0 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[0] = e; - #if stbir__coder_min_num >= 2 - e = encode[ stbir__encode_order1 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[1] = e; - #endif - #if stbir__coder_min_num >= 3 - e = encode[ stbir__encode_order2 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[2] = e; - #endif - output += stbir__coder_min_num; - encode += stbir__coder_min_num; - } - #endif - - #endif -} - -#undef stbir__decode_suffix -#undef stbir__decode_simdf8_flip -#undef stbir__decode_simdf4_flip -#undef stbir__decode_order0 -#undef stbir__decode_order1 -#undef stbir__decode_order2 -#undef stbir__decode_order3 -#undef stbir__encode_order0 -#undef stbir__encode_order1 -#undef stbir__encode_order2 -#undef stbir__encode_order3 -#undef stbir__encode_simdf8_unflip -#undef stbir__encode_simdf4_unflip -#undef stbir__encode_simdfX_unflip -#undef STBIR__CODER_NAME -#undef stbir__coder_min_num -#undef stbir__decode_swizzle -#undef stbir_scalar_hi_clamp -#undef stbir_scalar_lo_clamp -#undef STB_IMAGE_RESIZE_DO_CODERS - -#elif defined( STB_IMAGE_RESIZE_DO_VERTICALS) - -#ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#define STBIR_chans( start, end ) STBIR_strs_join14(start,STBIR__vertical_channels,end,_cont) -#else -#define STBIR_chans( start, end ) STBIR_strs_join1(start,STBIR__vertical_channels,end) -#endif - -#if STBIR__vertical_channels >= 1 -#define stbIF0( code ) code -#else -#define stbIF0( code ) -#endif -#if STBIR__vertical_channels >= 2 -#define stbIF1( code ) code -#else -#define stbIF1( code ) -#endif -#if STBIR__vertical_channels >= 3 -#define stbIF2( code ) code -#else -#define stbIF2( code ) -#endif -#if STBIR__vertical_channels >= 4 -#define stbIF3( code ) code -#else -#define stbIF3( code ) -#endif -#if STBIR__vertical_channels >= 5 -#define stbIF4( code ) code -#else -#define stbIF4( code ) -#endif -#if STBIR__vertical_channels >= 6 -#define stbIF5( code ) code -#else -#define stbIF5( code ) -#endif -#if STBIR__vertical_channels >= 7 -#define stbIF6( code ) code -#else -#define stbIF6( code ) -#endif -#if STBIR__vertical_channels >= 8 -#define stbIF7( code ) code -#else -#define stbIF7( code ) -#endif - -static void STBIR_chans( stbir__vertical_scatter_with_,_coeffs)( float ** outputs, float const * vertical_coefficients, float const * input, float const * input_end ) -{ - stbIF0( float STBIR_SIMD_STREAMOUT_PTR( * ) output0 = outputs[0]; float c0s = vertical_coefficients[0]; ) - stbIF1( float STBIR_SIMD_STREAMOUT_PTR( * ) output1 = outputs[1]; float c1s = vertical_coefficients[1]; ) - stbIF2( float STBIR_SIMD_STREAMOUT_PTR( * ) output2 = outputs[2]; float c2s = vertical_coefficients[2]; ) - stbIF3( float STBIR_SIMD_STREAMOUT_PTR( * ) output3 = outputs[3]; float c3s = vertical_coefficients[3]; ) - stbIF4( float STBIR_SIMD_STREAMOUT_PTR( * ) output4 = outputs[4]; float c4s = vertical_coefficients[4]; ) - stbIF5( float STBIR_SIMD_STREAMOUT_PTR( * ) output5 = outputs[5]; float c5s = vertical_coefficients[5]; ) - stbIF6( float STBIR_SIMD_STREAMOUT_PTR( * ) output6 = outputs[6]; float c6s = vertical_coefficients[6]; ) - stbIF7( float STBIR_SIMD_STREAMOUT_PTR( * ) output7 = outputs[7]; float c7s = vertical_coefficients[7]; ) - - #ifdef STBIR_SIMD - { - stbIF0(stbir__simdfX c0 = stbir__simdf_frepX( c0s ); ) - stbIF1(stbir__simdfX c1 = stbir__simdf_frepX( c1s ); ) - stbIF2(stbir__simdfX c2 = stbir__simdf_frepX( c2s ); ) - stbIF3(stbir__simdfX c3 = stbir__simdf_frepX( c3s ); ) - stbIF4(stbir__simdfX c4 = stbir__simdf_frepX( c4s ); ) - stbIF5(stbir__simdfX c5 = stbir__simdf_frepX( c5s ); ) - stbIF6(stbir__simdfX c6 = stbir__simdf_frepX( c6s ); ) - stbIF7(stbir__simdfX c7 = stbir__simdf_frepX( c7s ); ) - STBIR_SIMD_NO_UNROLL_LOOP_START - while ( ( (char*)input_end - (char*) input ) >= (16*stbir__simdfX_float_count) ) - { - stbir__simdfX o0, o1, o2, o3, r0, r1, r2, r3; - STBIR_SIMD_NO_UNROLL(output0); - - stbir__simdfX_load( r0, input ); stbir__simdfX_load( r1, input+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input+(3*stbir__simdfX_float_count) ); - - #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE - stbIF0( stbir__simdfX_load( o0, output0 ); stbir__simdfX_load( o1, output0+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output0+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output0+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c0 ); stbir__simdfX_madd( o1, o1, r1, c0 ); stbir__simdfX_madd( o2, o2, r2, c0 ); stbir__simdfX_madd( o3, o3, r3, c0 ); - stbir__simdfX_store( output0, o0 ); stbir__simdfX_store( output0+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output0+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output0+(3*stbir__simdfX_float_count), o3 ); ) - stbIF1( stbir__simdfX_load( o0, output1 ); stbir__simdfX_load( o1, output1+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output1+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output1+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c1 ); stbir__simdfX_madd( o1, o1, r1, c1 ); stbir__simdfX_madd( o2, o2, r2, c1 ); stbir__simdfX_madd( o3, o3, r3, c1 ); - stbir__simdfX_store( output1, o0 ); stbir__simdfX_store( output1+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output1+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output1+(3*stbir__simdfX_float_count), o3 ); ) - stbIF2( stbir__simdfX_load( o0, output2 ); stbir__simdfX_load( o1, output2+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output2+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output2+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c2 ); stbir__simdfX_madd( o1, o1, r1, c2 ); stbir__simdfX_madd( o2, o2, r2, c2 ); stbir__simdfX_madd( o3, o3, r3, c2 ); - stbir__simdfX_store( output2, o0 ); stbir__simdfX_store( output2+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output2+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output2+(3*stbir__simdfX_float_count), o3 ); ) - stbIF3( stbir__simdfX_load( o0, output3 ); stbir__simdfX_load( o1, output3+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output3+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output3+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c3 ); stbir__simdfX_madd( o1, o1, r1, c3 ); stbir__simdfX_madd( o2, o2, r2, c3 ); stbir__simdfX_madd( o3, o3, r3, c3 ); - stbir__simdfX_store( output3, o0 ); stbir__simdfX_store( output3+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output3+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output3+(3*stbir__simdfX_float_count), o3 ); ) - stbIF4( stbir__simdfX_load( o0, output4 ); stbir__simdfX_load( o1, output4+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output4+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output4+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c4 ); stbir__simdfX_madd( o1, o1, r1, c4 ); stbir__simdfX_madd( o2, o2, r2, c4 ); stbir__simdfX_madd( o3, o3, r3, c4 ); - stbir__simdfX_store( output4, o0 ); stbir__simdfX_store( output4+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output4+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output4+(3*stbir__simdfX_float_count), o3 ); ) - stbIF5( stbir__simdfX_load( o0, output5 ); stbir__simdfX_load( o1, output5+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output5+(2*stbir__simdfX_float_count)); stbir__simdfX_load( o3, output5+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c5 ); stbir__simdfX_madd( o1, o1, r1, c5 ); stbir__simdfX_madd( o2, o2, r2, c5 ); stbir__simdfX_madd( o3, o3, r3, c5 ); - stbir__simdfX_store( output5, o0 ); stbir__simdfX_store( output5+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output5+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output5+(3*stbir__simdfX_float_count), o3 ); ) - stbIF6( stbir__simdfX_load( o0, output6 ); stbir__simdfX_load( o1, output6+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output6+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output6+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c6 ); stbir__simdfX_madd( o1, o1, r1, c6 ); stbir__simdfX_madd( o2, o2, r2, c6 ); stbir__simdfX_madd( o3, o3, r3, c6 ); - stbir__simdfX_store( output6, o0 ); stbir__simdfX_store( output6+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output6+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output6+(3*stbir__simdfX_float_count), o3 ); ) - stbIF7( stbir__simdfX_load( o0, output7 ); stbir__simdfX_load( o1, output7+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output7+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output7+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c7 ); stbir__simdfX_madd( o1, o1, r1, c7 ); stbir__simdfX_madd( o2, o2, r2, c7 ); stbir__simdfX_madd( o3, o3, r3, c7 ); - stbir__simdfX_store( output7, o0 ); stbir__simdfX_store( output7+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output7+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output7+(3*stbir__simdfX_float_count), o3 ); ) - #else - stbIF0( stbir__simdfX_mult( o0, r0, c0 ); stbir__simdfX_mult( o1, r1, c0 ); stbir__simdfX_mult( o2, r2, c0 ); stbir__simdfX_mult( o3, r3, c0 ); - stbir__simdfX_store( output0, o0 ); stbir__simdfX_store( output0+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output0+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output0+(3*stbir__simdfX_float_count), o3 ); ) - stbIF1( stbir__simdfX_mult( o0, r0, c1 ); stbir__simdfX_mult( o1, r1, c1 ); stbir__simdfX_mult( o2, r2, c1 ); stbir__simdfX_mult( o3, r3, c1 ); - stbir__simdfX_store( output1, o0 ); stbir__simdfX_store( output1+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output1+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output1+(3*stbir__simdfX_float_count), o3 ); ) - stbIF2( stbir__simdfX_mult( o0, r0, c2 ); stbir__simdfX_mult( o1, r1, c2 ); stbir__simdfX_mult( o2, r2, c2 ); stbir__simdfX_mult( o3, r3, c2 ); - stbir__simdfX_store( output2, o0 ); stbir__simdfX_store( output2+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output2+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output2+(3*stbir__simdfX_float_count), o3 ); ) - stbIF3( stbir__simdfX_mult( o0, r0, c3 ); stbir__simdfX_mult( o1, r1, c3 ); stbir__simdfX_mult( o2, r2, c3 ); stbir__simdfX_mult( o3, r3, c3 ); - stbir__simdfX_store( output3, o0 ); stbir__simdfX_store( output3+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output3+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output3+(3*stbir__simdfX_float_count), o3 ); ) - stbIF4( stbir__simdfX_mult( o0, r0, c4 ); stbir__simdfX_mult( o1, r1, c4 ); stbir__simdfX_mult( o2, r2, c4 ); stbir__simdfX_mult( o3, r3, c4 ); - stbir__simdfX_store( output4, o0 ); stbir__simdfX_store( output4+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output4+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output4+(3*stbir__simdfX_float_count), o3 ); ) - stbIF5( stbir__simdfX_mult( o0, r0, c5 ); stbir__simdfX_mult( o1, r1, c5 ); stbir__simdfX_mult( o2, r2, c5 ); stbir__simdfX_mult( o3, r3, c5 ); - stbir__simdfX_store( output5, o0 ); stbir__simdfX_store( output5+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output5+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output5+(3*stbir__simdfX_float_count), o3 ); ) - stbIF6( stbir__simdfX_mult( o0, r0, c6 ); stbir__simdfX_mult( o1, r1, c6 ); stbir__simdfX_mult( o2, r2, c6 ); stbir__simdfX_mult( o3, r3, c6 ); - stbir__simdfX_store( output6, o0 ); stbir__simdfX_store( output6+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output6+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output6+(3*stbir__simdfX_float_count), o3 ); ) - stbIF7( stbir__simdfX_mult( o0, r0, c7 ); stbir__simdfX_mult( o1, r1, c7 ); stbir__simdfX_mult( o2, r2, c7 ); stbir__simdfX_mult( o3, r3, c7 ); - stbir__simdfX_store( output7, o0 ); stbir__simdfX_store( output7+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output7+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output7+(3*stbir__simdfX_float_count), o3 ); ) - #endif - - input += (4*stbir__simdfX_float_count); - stbIF0( output0 += (4*stbir__simdfX_float_count); ) stbIF1( output1 += (4*stbir__simdfX_float_count); ) stbIF2( output2 += (4*stbir__simdfX_float_count); ) stbIF3( output3 += (4*stbir__simdfX_float_count); ) stbIF4( output4 += (4*stbir__simdfX_float_count); ) stbIF5( output5 += (4*stbir__simdfX_float_count); ) stbIF6( output6 += (4*stbir__simdfX_float_count); ) stbIF7( output7 += (4*stbir__simdfX_float_count); ) - } - STBIR_SIMD_NO_UNROLL_LOOP_START - while ( ( (char*)input_end - (char*) input ) >= 16 ) - { - stbir__simdf o0, r0; - STBIR_SIMD_NO_UNROLL(output0); - - stbir__simdf_load( r0, input ); - - #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE - stbIF0( stbir__simdf_load( o0, output0 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c0 ) ); stbir__simdf_store( output0, o0 ); ) - stbIF1( stbir__simdf_load( o0, output1 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c1 ) ); stbir__simdf_store( output1, o0 ); ) - stbIF2( stbir__simdf_load( o0, output2 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c2 ) ); stbir__simdf_store( output2, o0 ); ) - stbIF3( stbir__simdf_load( o0, output3 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c3 ) ); stbir__simdf_store( output3, o0 ); ) - stbIF4( stbir__simdf_load( o0, output4 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c4 ) ); stbir__simdf_store( output4, o0 ); ) - stbIF5( stbir__simdf_load( o0, output5 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c5 ) ); stbir__simdf_store( output5, o0 ); ) - stbIF6( stbir__simdf_load( o0, output6 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c6 ) ); stbir__simdf_store( output6, o0 ); ) - stbIF7( stbir__simdf_load( o0, output7 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c7 ) ); stbir__simdf_store( output7, o0 ); ) - #else - stbIF0( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c0 ) ); stbir__simdf_store( output0, o0 ); ) - stbIF1( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c1 ) ); stbir__simdf_store( output1, o0 ); ) - stbIF2( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c2 ) ); stbir__simdf_store( output2, o0 ); ) - stbIF3( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c3 ) ); stbir__simdf_store( output3, o0 ); ) - stbIF4( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c4 ) ); stbir__simdf_store( output4, o0 ); ) - stbIF5( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c5 ) ); stbir__simdf_store( output5, o0 ); ) - stbIF6( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c6 ) ); stbir__simdf_store( output6, o0 ); ) - stbIF7( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c7 ) ); stbir__simdf_store( output7, o0 ); ) - #endif - - input += 4; - stbIF0( output0 += 4; ) stbIF1( output1 += 4; ) stbIF2( output2 += 4; ) stbIF3( output3 += 4; ) stbIF4( output4 += 4; ) stbIF5( output5 += 4; ) stbIF6( output6 += 4; ) stbIF7( output7 += 4; ) - } - } - #else - STBIR_NO_UNROLL_LOOP_START - while ( ( (char*)input_end - (char*) input ) >= 16 ) - { - float r0, r1, r2, r3; - STBIR_NO_UNROLL(input); - - r0 = input[0], r1 = input[1], r2 = input[2], r3 = input[3]; - - #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE - stbIF0( output0[0] += ( r0 * c0s ); output0[1] += ( r1 * c0s ); output0[2] += ( r2 * c0s ); output0[3] += ( r3 * c0s ); ) - stbIF1( output1[0] += ( r0 * c1s ); output1[1] += ( r1 * c1s ); output1[2] += ( r2 * c1s ); output1[3] += ( r3 * c1s ); ) - stbIF2( output2[0] += ( r0 * c2s ); output2[1] += ( r1 * c2s ); output2[2] += ( r2 * c2s ); output2[3] += ( r3 * c2s ); ) - stbIF3( output3[0] += ( r0 * c3s ); output3[1] += ( r1 * c3s ); output3[2] += ( r2 * c3s ); output3[3] += ( r3 * c3s ); ) - stbIF4( output4[0] += ( r0 * c4s ); output4[1] += ( r1 * c4s ); output4[2] += ( r2 * c4s ); output4[3] += ( r3 * c4s ); ) - stbIF5( output5[0] += ( r0 * c5s ); output5[1] += ( r1 * c5s ); output5[2] += ( r2 * c5s ); output5[3] += ( r3 * c5s ); ) - stbIF6( output6[0] += ( r0 * c6s ); output6[1] += ( r1 * c6s ); output6[2] += ( r2 * c6s ); output6[3] += ( r3 * c6s ); ) - stbIF7( output7[0] += ( r0 * c7s ); output7[1] += ( r1 * c7s ); output7[2] += ( r2 * c7s ); output7[3] += ( r3 * c7s ); ) - #else - stbIF0( output0[0] = ( r0 * c0s ); output0[1] = ( r1 * c0s ); output0[2] = ( r2 * c0s ); output0[3] = ( r3 * c0s ); ) - stbIF1( output1[0] = ( r0 * c1s ); output1[1] = ( r1 * c1s ); output1[2] = ( r2 * c1s ); output1[3] = ( r3 * c1s ); ) - stbIF2( output2[0] = ( r0 * c2s ); output2[1] = ( r1 * c2s ); output2[2] = ( r2 * c2s ); output2[3] = ( r3 * c2s ); ) - stbIF3( output3[0] = ( r0 * c3s ); output3[1] = ( r1 * c3s ); output3[2] = ( r2 * c3s ); output3[3] = ( r3 * c3s ); ) - stbIF4( output4[0] = ( r0 * c4s ); output4[1] = ( r1 * c4s ); output4[2] = ( r2 * c4s ); output4[3] = ( r3 * c4s ); ) - stbIF5( output5[0] = ( r0 * c5s ); output5[1] = ( r1 * c5s ); output5[2] = ( r2 * c5s ); output5[3] = ( r3 * c5s ); ) - stbIF6( output6[0] = ( r0 * c6s ); output6[1] = ( r1 * c6s ); output6[2] = ( r2 * c6s ); output6[3] = ( r3 * c6s ); ) - stbIF7( output7[0] = ( r0 * c7s ); output7[1] = ( r1 * c7s ); output7[2] = ( r2 * c7s ); output7[3] = ( r3 * c7s ); ) - #endif - - input += 4; - stbIF0( output0 += 4; ) stbIF1( output1 += 4; ) stbIF2( output2 += 4; ) stbIF3( output3 += 4; ) stbIF4( output4 += 4; ) stbIF5( output5 += 4; ) stbIF6( output6 += 4; ) stbIF7( output7 += 4; ) - } - #endif - STBIR_NO_UNROLL_LOOP_START - while ( input < input_end ) - { - float r = input[0]; - STBIR_NO_UNROLL(output0); - - #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE - stbIF0( output0[0] += ( r * c0s ); ) - stbIF1( output1[0] += ( r * c1s ); ) - stbIF2( output2[0] += ( r * c2s ); ) - stbIF3( output3[0] += ( r * c3s ); ) - stbIF4( output4[0] += ( r * c4s ); ) - stbIF5( output5[0] += ( r * c5s ); ) - stbIF6( output6[0] += ( r * c6s ); ) - stbIF7( output7[0] += ( r * c7s ); ) - #else - stbIF0( output0[0] = ( r * c0s ); ) - stbIF1( output1[0] = ( r * c1s ); ) - stbIF2( output2[0] = ( r * c2s ); ) - stbIF3( output3[0] = ( r * c3s ); ) - stbIF4( output4[0] = ( r * c4s ); ) - stbIF5( output5[0] = ( r * c5s ); ) - stbIF6( output6[0] = ( r * c6s ); ) - stbIF7( output7[0] = ( r * c7s ); ) - #endif - - ++input; - stbIF0( ++output0; ) stbIF1( ++output1; ) stbIF2( ++output2; ) stbIF3( ++output3; ) stbIF4( ++output4; ) stbIF5( ++output5; ) stbIF6( ++output6; ) stbIF7( ++output7; ) - } -} - -static void STBIR_chans( stbir__vertical_gather_with_,_coeffs)( float * outputp, float const * vertical_coefficients, float const ** inputs, float const * input0_end ) -{ - float STBIR_SIMD_STREAMOUT_PTR( * ) output = outputp; - - stbIF0( float const * input0 = inputs[0]; float c0s = vertical_coefficients[0]; ) - stbIF1( float const * input1 = inputs[1]; float c1s = vertical_coefficients[1]; ) - stbIF2( float const * input2 = inputs[2]; float c2s = vertical_coefficients[2]; ) - stbIF3( float const * input3 = inputs[3]; float c3s = vertical_coefficients[3]; ) - stbIF4( float const * input4 = inputs[4]; float c4s = vertical_coefficients[4]; ) - stbIF5( float const * input5 = inputs[5]; float c5s = vertical_coefficients[5]; ) - stbIF6( float const * input6 = inputs[6]; float c6s = vertical_coefficients[6]; ) - stbIF7( float const * input7 = inputs[7]; float c7s = vertical_coefficients[7]; ) - -#if ( STBIR__vertical_channels == 1 ) && !defined(STB_IMAGE_RESIZE_VERTICAL_CONTINUE) - // check single channel one weight - if ( ( c0s >= (1.0f-0.000001f) ) && ( c0s <= (1.0f+0.000001f) ) ) - { - STBIR_MEMCPY( output, input0, (char*)input0_end - (char*)input0 ); - return; - } -#endif - - #ifdef STBIR_SIMD - { - stbIF0(stbir__simdfX c0 = stbir__simdf_frepX( c0s ); ) - stbIF1(stbir__simdfX c1 = stbir__simdf_frepX( c1s ); ) - stbIF2(stbir__simdfX c2 = stbir__simdf_frepX( c2s ); ) - stbIF3(stbir__simdfX c3 = stbir__simdf_frepX( c3s ); ) - stbIF4(stbir__simdfX c4 = stbir__simdf_frepX( c4s ); ) - stbIF5(stbir__simdfX c5 = stbir__simdf_frepX( c5s ); ) - stbIF6(stbir__simdfX c6 = stbir__simdf_frepX( c6s ); ) - stbIF7(stbir__simdfX c7 = stbir__simdf_frepX( c7s ); ) - - STBIR_SIMD_NO_UNROLL_LOOP_START - while ( ( (char*)input0_end - (char*) input0 ) >= (16*stbir__simdfX_float_count) ) - { - stbir__simdfX o0, o1, o2, o3, r0, r1, r2, r3; - STBIR_SIMD_NO_UNROLL(output); - - // prefetch four loop iterations ahead (doesn't affect much for small resizes, but helps with big ones) - stbIF0( stbir__prefetch( input0 + (16*stbir__simdfX_float_count) ); ) - stbIF1( stbir__prefetch( input1 + (16*stbir__simdfX_float_count) ); ) - stbIF2( stbir__prefetch( input2 + (16*stbir__simdfX_float_count) ); ) - stbIF3( stbir__prefetch( input3 + (16*stbir__simdfX_float_count) ); ) - stbIF4( stbir__prefetch( input4 + (16*stbir__simdfX_float_count) ); ) - stbIF5( stbir__prefetch( input5 + (16*stbir__simdfX_float_count) ); ) - stbIF6( stbir__prefetch( input6 + (16*stbir__simdfX_float_count) ); ) - stbIF7( stbir__prefetch( input7 + (16*stbir__simdfX_float_count) ); ) - - #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE - stbIF0( stbir__simdfX_load( o0, output ); stbir__simdfX_load( o1, output+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output+(3*stbir__simdfX_float_count) ); - stbir__simdfX_load( r0, input0 ); stbir__simdfX_load( r1, input0+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input0+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input0+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c0 ); stbir__simdfX_madd( o1, o1, r1, c0 ); stbir__simdfX_madd( o2, o2, r2, c0 ); stbir__simdfX_madd( o3, o3, r3, c0 ); ) - #else - stbIF0( stbir__simdfX_load( r0, input0 ); stbir__simdfX_load( r1, input0+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input0+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input0+(3*stbir__simdfX_float_count) ); - stbir__simdfX_mult( o0, r0, c0 ); stbir__simdfX_mult( o1, r1, c0 ); stbir__simdfX_mult( o2, r2, c0 ); stbir__simdfX_mult( o3, r3, c0 ); ) - #endif - - stbIF1( stbir__simdfX_load( r0, input1 ); stbir__simdfX_load( r1, input1+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input1+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input1+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c1 ); stbir__simdfX_madd( o1, o1, r1, c1 ); stbir__simdfX_madd( o2, o2, r2, c1 ); stbir__simdfX_madd( o3, o3, r3, c1 ); ) - stbIF2( stbir__simdfX_load( r0, input2 ); stbir__simdfX_load( r1, input2+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input2+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input2+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c2 ); stbir__simdfX_madd( o1, o1, r1, c2 ); stbir__simdfX_madd( o2, o2, r2, c2 ); stbir__simdfX_madd( o3, o3, r3, c2 ); ) - stbIF3( stbir__simdfX_load( r0, input3 ); stbir__simdfX_load( r1, input3+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input3+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input3+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c3 ); stbir__simdfX_madd( o1, o1, r1, c3 ); stbir__simdfX_madd( o2, o2, r2, c3 ); stbir__simdfX_madd( o3, o3, r3, c3 ); ) - stbIF4( stbir__simdfX_load( r0, input4 ); stbir__simdfX_load( r1, input4+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input4+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input4+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c4 ); stbir__simdfX_madd( o1, o1, r1, c4 ); stbir__simdfX_madd( o2, o2, r2, c4 ); stbir__simdfX_madd( o3, o3, r3, c4 ); ) - stbIF5( stbir__simdfX_load( r0, input5 ); stbir__simdfX_load( r1, input5+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input5+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input5+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c5 ); stbir__simdfX_madd( o1, o1, r1, c5 ); stbir__simdfX_madd( o2, o2, r2, c5 ); stbir__simdfX_madd( o3, o3, r3, c5 ); ) - stbIF6( stbir__simdfX_load( r0, input6 ); stbir__simdfX_load( r1, input6+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input6+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input6+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c6 ); stbir__simdfX_madd( o1, o1, r1, c6 ); stbir__simdfX_madd( o2, o2, r2, c6 ); stbir__simdfX_madd( o3, o3, r3, c6 ); ) - stbIF7( stbir__simdfX_load( r0, input7 ); stbir__simdfX_load( r1, input7+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input7+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input7+(3*stbir__simdfX_float_count) ); - stbir__simdfX_madd( o0, o0, r0, c7 ); stbir__simdfX_madd( o1, o1, r1, c7 ); stbir__simdfX_madd( o2, o2, r2, c7 ); stbir__simdfX_madd( o3, o3, r3, c7 ); ) - - stbir__simdfX_store( output, o0 ); stbir__simdfX_store( output+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output+(3*stbir__simdfX_float_count), o3 ); - output += (4*stbir__simdfX_float_count); - stbIF0( input0 += (4*stbir__simdfX_float_count); ) stbIF1( input1 += (4*stbir__simdfX_float_count); ) stbIF2( input2 += (4*stbir__simdfX_float_count); ) stbIF3( input3 += (4*stbir__simdfX_float_count); ) stbIF4( input4 += (4*stbir__simdfX_float_count); ) stbIF5( input5 += (4*stbir__simdfX_float_count); ) stbIF6( input6 += (4*stbir__simdfX_float_count); ) stbIF7( input7 += (4*stbir__simdfX_float_count); ) - } - - STBIR_SIMD_NO_UNROLL_LOOP_START - while ( ( (char*)input0_end - (char*) input0 ) >= 16 ) - { - stbir__simdf o0, r0; - STBIR_SIMD_NO_UNROLL(output); - - #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE - stbIF0( stbir__simdf_load( o0, output ); stbir__simdf_load( r0, input0 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c0 ) ); ) - #else - stbIF0( stbir__simdf_load( r0, input0 ); stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c0 ) ); ) - #endif - stbIF1( stbir__simdf_load( r0, input1 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c1 ) ); ) - stbIF2( stbir__simdf_load( r0, input2 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c2 ) ); ) - stbIF3( stbir__simdf_load( r0, input3 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c3 ) ); ) - stbIF4( stbir__simdf_load( r0, input4 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c4 ) ); ) - stbIF5( stbir__simdf_load( r0, input5 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c5 ) ); ) - stbIF6( stbir__simdf_load( r0, input6 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c6 ) ); ) - stbIF7( stbir__simdf_load( r0, input7 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c7 ) ); ) - - stbir__simdf_store( output, o0 ); - output += 4; - stbIF0( input0 += 4; ) stbIF1( input1 += 4; ) stbIF2( input2 += 4; ) stbIF3( input3 += 4; ) stbIF4( input4 += 4; ) stbIF5( input5 += 4; ) stbIF6( input6 += 4; ) stbIF7( input7 += 4; ) - } - } - #else - STBIR_NO_UNROLL_LOOP_START - while ( ( (char*)input0_end - (char*) input0 ) >= 16 ) - { - float o0, o1, o2, o3; - STBIR_NO_UNROLL(output); - #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE - stbIF0( o0 = output[0] + input0[0] * c0s; o1 = output[1] + input0[1] * c0s; o2 = output[2] + input0[2] * c0s; o3 = output[3] + input0[3] * c0s; ) - #else - stbIF0( o0 = input0[0] * c0s; o1 = input0[1] * c0s; o2 = input0[2] * c0s; o3 = input0[3] * c0s; ) - #endif - stbIF1( o0 += input1[0] * c1s; o1 += input1[1] * c1s; o2 += input1[2] * c1s; o3 += input1[3] * c1s; ) - stbIF2( o0 += input2[0] * c2s; o1 += input2[1] * c2s; o2 += input2[2] * c2s; o3 += input2[3] * c2s; ) - stbIF3( o0 += input3[0] * c3s; o1 += input3[1] * c3s; o2 += input3[2] * c3s; o3 += input3[3] * c3s; ) - stbIF4( o0 += input4[0] * c4s; o1 += input4[1] * c4s; o2 += input4[2] * c4s; o3 += input4[3] * c4s; ) - stbIF5( o0 += input5[0] * c5s; o1 += input5[1] * c5s; o2 += input5[2] * c5s; o3 += input5[3] * c5s; ) - stbIF6( o0 += input6[0] * c6s; o1 += input6[1] * c6s; o2 += input6[2] * c6s; o3 += input6[3] * c6s; ) - stbIF7( o0 += input7[0] * c7s; o1 += input7[1] * c7s; o2 += input7[2] * c7s; o3 += input7[3] * c7s; ) - output[0] = o0; output[1] = o1; output[2] = o2; output[3] = o3; - output += 4; - stbIF0( input0 += 4; ) stbIF1( input1 += 4; ) stbIF2( input2 += 4; ) stbIF3( input3 += 4; ) stbIF4( input4 += 4; ) stbIF5( input5 += 4; ) stbIF6( input6 += 4; ) stbIF7( input7 += 4; ) - } - #endif - STBIR_NO_UNROLL_LOOP_START - while ( input0 < input0_end ) - { - float o0; - STBIR_NO_UNROLL(output); - #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE - stbIF0( o0 = output[0] + input0[0] * c0s; ) - #else - stbIF0( o0 = input0[0] * c0s; ) - #endif - stbIF1( o0 += input1[0] * c1s; ) - stbIF2( o0 += input2[0] * c2s; ) - stbIF3( o0 += input3[0] * c3s; ) - stbIF4( o0 += input4[0] * c4s; ) - stbIF5( o0 += input5[0] * c5s; ) - stbIF6( o0 += input6[0] * c6s; ) - stbIF7( o0 += input7[0] * c7s; ) - output[0] = o0; - ++output; - stbIF0( ++input0; ) stbIF1( ++input1; ) stbIF2( ++input2; ) stbIF3( ++input3; ) stbIF4( ++input4; ) stbIF5( ++input5; ) stbIF6( ++input6; ) stbIF7( ++input7; ) - } -} - -#undef stbIF0 -#undef stbIF1 -#undef stbIF2 -#undef stbIF3 -#undef stbIF4 -#undef stbIF5 -#undef stbIF6 -#undef stbIF7 -#undef STB_IMAGE_RESIZE_DO_VERTICALS -#undef STBIR__vertical_channels -#undef STB_IMAGE_RESIZE_DO_HORIZONTALS -#undef STBIR_strs_join24 -#undef STBIR_strs_join14 -#undef STBIR_chans -#ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#undef STB_IMAGE_RESIZE_VERTICAL_CONTINUE -#endif - -#else // !STB_IMAGE_RESIZE_DO_VERTICALS - -#define STBIR_chans( start, end ) STBIR_strs_join1(start,STBIR__horizontal_channels,end) - -#ifndef stbir__2_coeff_only -#define stbir__2_coeff_only() \ - stbir__1_coeff_only(); \ - stbir__1_coeff_remnant(1); -#endif - -#ifndef stbir__2_coeff_remnant -#define stbir__2_coeff_remnant( ofs ) \ - stbir__1_coeff_remnant(ofs); \ - stbir__1_coeff_remnant((ofs)+1); -#endif - -#ifndef stbir__3_coeff_only -#define stbir__3_coeff_only() \ - stbir__2_coeff_only(); \ - stbir__1_coeff_remnant(2); -#endif - -#ifndef stbir__3_coeff_remnant -#define stbir__3_coeff_remnant( ofs ) \ - stbir__2_coeff_remnant(ofs); \ - stbir__1_coeff_remnant((ofs)+2); -#endif - -#ifndef stbir__3_coeff_setup -#define stbir__3_coeff_setup() -#endif - -#ifndef stbir__4_coeff_start -#define stbir__4_coeff_start() \ - stbir__2_coeff_only(); \ - stbir__2_coeff_remnant(2); -#endif - -#ifndef stbir__4_coeff_continue_from_4 -#define stbir__4_coeff_continue_from_4( ofs ) \ - stbir__2_coeff_remnant(ofs); \ - stbir__2_coeff_remnant((ofs)+2); -#endif - -#ifndef stbir__store_output_tiny -#define stbir__store_output_tiny stbir__store_output -#endif - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_1_coeff)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__1_coeff_only(); - stbir__store_output_tiny(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_2_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__2_coeff_only(); - stbir__store_output_tiny(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_3_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__3_coeff_only(); - stbir__store_output_tiny(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_4_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__4_coeff_start(); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_5_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__4_coeff_start(); - stbir__1_coeff_remnant(4); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_6_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__4_coeff_start(); - stbir__2_coeff_remnant(4); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_7_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - stbir__3_coeff_setup(); - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - - stbir__4_coeff_start(); - stbir__3_coeff_remnant(4); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_8_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__4_coeff_start(); - stbir__4_coeff_continue_from_4(4); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_9_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__4_coeff_start(); - stbir__4_coeff_continue_from_4(4); - stbir__1_coeff_remnant(8); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_10_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__4_coeff_start(); - stbir__4_coeff_continue_from_4(4); - stbir__2_coeff_remnant(8); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_11_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - stbir__3_coeff_setup(); - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__4_coeff_start(); - stbir__4_coeff_continue_from_4(4); - stbir__3_coeff_remnant(8); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_12_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - float const * hc = horizontal_coefficients; - stbir__4_coeff_start(); - stbir__4_coeff_continue_from_4(4); - stbir__4_coeff_continue_from_4(8); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_n_coeffs_mod0 )( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - int n = ( ( horizontal_contributors->n1 - horizontal_contributors->n0 + 1 ) - 4 + 3 ) >> 2; - float const * hc = horizontal_coefficients; - - stbir__4_coeff_start(); - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - hc += 4; - decode += STBIR__horizontal_channels * 4; - stbir__4_coeff_continue_from_4( 0 ); - --n; - } while ( n > 0 ); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_n_coeffs_mod1 )( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - int n = ( ( horizontal_contributors->n1 - horizontal_contributors->n0 + 1 ) - 5 + 3 ) >> 2; - float const * hc = horizontal_coefficients; - - stbir__4_coeff_start(); - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - hc += 4; - decode += STBIR__horizontal_channels * 4; - stbir__4_coeff_continue_from_4( 0 ); - --n; - } while ( n > 0 ); - stbir__1_coeff_remnant( 4 ); - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_n_coeffs_mod2 )( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - int n = ( ( horizontal_contributors->n1 - horizontal_contributors->n0 + 1 ) - 6 + 3 ) >> 2; - float const * hc = horizontal_coefficients; - - stbir__4_coeff_start(); - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - hc += 4; - decode += STBIR__horizontal_channels * 4; - stbir__4_coeff_continue_from_4( 0 ); - --n; - } while ( n > 0 ); - stbir__2_coeff_remnant( 4 ); - - stbir__store_output(); - } while ( output < output_end ); -} - -static void STBIR_chans( stbir__horizontal_gather_,_channels_with_n_coeffs_mod3 )( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width ) -{ - float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels; - float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer; - stbir__3_coeff_setup(); - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels; - int n = ( ( horizontal_contributors->n1 - horizontal_contributors->n0 + 1 ) - 7 + 3 ) >> 2; - float const * hc = horizontal_coefficients; - - stbir__4_coeff_start(); - STBIR_SIMD_NO_UNROLL_LOOP_START - do { - hc += 4; - decode += STBIR__horizontal_channels * 4; - stbir__4_coeff_continue_from_4( 0 ); - --n; - } while ( n > 0 ); - stbir__3_coeff_remnant( 4 ); - - stbir__store_output(); - } while ( output < output_end ); -} - -static stbir__horizontal_gather_channels_func * STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_funcs)[4]= -{ - STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_mod0), - STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_mod1), - STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_mod2), - STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_mod3), -}; - -static stbir__horizontal_gather_channels_func * STBIR_chans(stbir__horizontal_gather_,_channels_funcs)[12]= -{ - STBIR_chans(stbir__horizontal_gather_,_channels_with_1_coeff), - STBIR_chans(stbir__horizontal_gather_,_channels_with_2_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_3_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_4_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_5_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_6_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_7_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_8_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_9_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_10_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_11_coeffs), - STBIR_chans(stbir__horizontal_gather_,_channels_with_12_coeffs), -}; - -#undef STBIR__horizontal_channels -#undef STB_IMAGE_RESIZE_DO_HORIZONTALS -#undef stbir__1_coeff_only -#undef stbir__1_coeff_remnant -#undef stbir__2_coeff_only -#undef stbir__2_coeff_remnant -#undef stbir__3_coeff_only -#undef stbir__3_coeff_remnant -#undef stbir__3_coeff_setup -#undef stbir__4_coeff_start -#undef stbir__4_coeff_continue_from_4 -#undef stbir__store_output -#undef stbir__store_output_tiny -#undef STBIR_chans - -#endif // HORIZONALS - -#undef STBIR_strs_join2 -#undef STBIR_strs_join1 - -#endif // STB_IMAGE_RESIZE_DO_HORIZONTALS/VERTICALS/CODERS - -/* ------------------------------------------------------------------------------- -This software is available under 2 licenses -- choose whichever you prefer. ------------------------------------------------------------------------------- -ALTERNATIVE A - MIT License -Copyright (c) 2017 Sean Barrett -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. ------------------------------------------------------------------------------- -ALTERNATIVE B - Public Domain (www.unlicense.org) -This is free and unencumbered software released into the public domain. -Anyone is free to copy, modify, publish, use, compile, sell, or distribute this -software, either in source code form or as a compiled binary, for any purpose, -commercial or non-commercial, and by any means. -In jurisdictions that recognize copyright laws, the author or authors of this -software dedicate any and all copyright interest in the software to the public -domain. We make this dedication for the benefit of the public at large and to -the detriment of our heirs and successors. We intend this dedication to be an -overt act of relinquishment in perpetuity of all present and future rights to -this software under copyright law. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------- -*/ diff --git a/src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h b/src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h deleted file mode 100644 index 5c848de0..00000000 --- a/src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h +++ /dev/null @@ -1,628 +0,0 @@ -// stb_rect_pack.h - v1.00 - public domain - rectangle packing -// Sean Barrett 2014 -// -// Useful for e.g. packing rectangular textures into an atlas. -// Does not do rotation. -// -// Not necessarily the awesomest packing method, but better than -// the totally naive one in stb_truetype (which is primarily what -// this is meant to replace). -// -// Has only had a few tests run, may have issues. -// -// More docs to come. -// -// No memory allocations; uses qsort() and assert() from stdlib. -// Can override those by defining STBRP_SORT and STBRP_ASSERT. -// -// This library currently uses the Skyline Bottom-Left algorithm. -// -// Please note: better rectangle packers are welcome! Please -// implement them to the same API, but with a different init -// function. -// -// Credits -// -// Library -// Sean Barrett -// Minor features -// Martins Mozeiko -// github:IntellectualKitty -// -// Bugfixes / warning fixes -// Jeremy Jaussaud -// Fabian Giesen -// -// Version history: -// -// 1.00 (2019-02-25) avoid small space waste; gracefully fail too-wide rectangles -// 0.99 (2019-02-07) warning fixes -// 0.11 (2017-03-03) return packing success/fail result -// 0.10 (2016-10-25) remove cast-away-const to avoid warnings -// 0.09 (2016-08-27) fix compiler warnings -// 0.08 (2015-09-13) really fix bug with empty rects (w=0 or h=0) -// 0.07 (2015-09-13) fix bug with empty rects (w=0 or h=0) -// 0.06 (2015-04-15) added STBRP_SORT to allow replacing qsort -// 0.05: added STBRP_ASSERT to allow replacing assert -// 0.04: fixed minor bug in STBRP_LARGE_RECTS support -// 0.01: initial release -// -// LICENSE -// -// See end of file for license information. - -////////////////////////////////////////////////////////////////////////////// -// -// INCLUDE SECTION -// - -#ifndef STB_INCLUDE_STB_RECT_PACK_H -#define STB_INCLUDE_STB_RECT_PACK_H - -#define STB_RECT_PACK_VERSION 1 - -#ifdef STBRP_STATIC -#define STBRP_DEF static -#else -#define STBRP_DEF extern -#endif - -#ifdef __cplusplus -extern "C" { -#endif - -typedef struct stbrp_context stbrp_context; -typedef struct stbrp_node stbrp_node; -typedef struct stbrp_rect stbrp_rect; - -#ifdef STBRP_LARGE_RECTS -typedef int stbrp_coord; -#else -typedef unsigned short stbrp_coord; -#endif - -STBRP_DEF int stbrp_pack_rects (stbrp_context *context, stbrp_rect *rects, int num_rects); -// Assign packed locations to rectangles. The rectangles are of type -// 'stbrp_rect' defined below, stored in the array 'rects', and there -// are 'num_rects' many of them. -// -// Rectangles which are successfully packed have the 'was_packed' flag -// set to a non-zero value and 'x' and 'y' store the minimum location -// on each axis (i.e. bottom-left in cartesian coordinates, top-left -// if you imagine y increasing downwards). Rectangles which do not fit -// have the 'was_packed' flag set to 0. -// -// You should not try to access the 'rects' array from another thread -// while this function is running, as the function temporarily reorders -// the array while it executes. -// -// To pack into another rectangle, you need to call stbrp_init_target -// again. To continue packing into the same rectangle, you can call -// this function again. Calling this multiple times with multiple rect -// arrays will probably produce worse packing results than calling it -// a single time with the full rectangle array, but the option is -// available. -// -// The function returns 1 if all of the rectangles were successfully -// packed and 0 otherwise. - -struct stbrp_rect -{ - // reserved for your use: - int id; - - // input: - stbrp_coord w, h; - - // output: - stbrp_coord x, y; - int was_packed; // non-zero if valid packing - -}; // 16 bytes, nominally - - -STBRP_DEF void stbrp_init_target (stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes); -// Initialize a rectangle packer to: -// pack a rectangle that is 'width' by 'height' in dimensions -// using temporary storage provided by the array 'nodes', which is 'num_nodes' long -// -// You must call this function every time you start packing into a new target. -// -// There is no "shutdown" function. The 'nodes' memory must stay valid for -// the following stbrp_pack_rects() call (or calls), but can be freed after -// the call (or calls) finish. -// -// Note: to guarantee best results, either: -// 1. make sure 'num_nodes' >= 'width' -// or 2. call stbrp_allow_out_of_mem() defined below with 'allow_out_of_mem = 1' -// -// If you don't do either of the above things, widths will be quantized to multiples -// of small integers to guarantee the algorithm doesn't run out of temporary storage. -// -// If you do #2, then the non-quantized algorithm will be used, but the algorithm -// may run out of temporary storage and be unable to pack some rectangles. - -STBRP_DEF void stbrp_setup_allow_out_of_mem (stbrp_context *context, int allow_out_of_mem); -// Optionally call this function after init but before doing any packing to -// change the handling of the out-of-temp-memory scenario, described above. -// If you call init again, this will be reset to the default (false). - - -STBRP_DEF void stbrp_setup_heuristic (stbrp_context *context, int heuristic); -// Optionally select which packing heuristic the library should use. Different -// heuristics will produce better/worse results for different data sets. -// If you call init again, this will be reset to the default. - -enum -{ - STBRP_HEURISTIC_Skyline_default=0, - STBRP_HEURISTIC_Skyline_BL_sortHeight = STBRP_HEURISTIC_Skyline_default, - STBRP_HEURISTIC_Skyline_BF_sortHeight -}; - - -////////////////////////////////////////////////////////////////////////////// -// -// the details of the following structures don't matter to you, but they must -// be visible so you can handle the memory allocations for them - -struct stbrp_node -{ - stbrp_coord x,y; - stbrp_node *next; -}; - -struct stbrp_context -{ - int width; - int height; - int align; - int init_mode; - int heuristic; - int num_nodes; - stbrp_node *active_head; - stbrp_node *free_head; - stbrp_node extra[2]; // we allocate two extra nodes so optimal user-node-count is 'width' not 'width+2' -}; - -#ifdef __cplusplus -} -#endif - -#endif - -////////////////////////////////////////////////////////////////////////////// -// -// IMPLEMENTATION SECTION -// - -#ifdef STB_RECT_PACK_IMPLEMENTATION -#ifndef STBRP_SORT -#include -#define STBRP_SORT qsort -#endif - -#ifndef STBRP_ASSERT -#include -#define STBRP_ASSERT assert -#endif - -#ifdef _MSC_VER -#define STBRP__NOTUSED(v) (void)(v) -#else -#define STBRP__NOTUSED(v) (void)sizeof(v) -#endif - -enum -{ - STBRP__INIT_skyline = 1 -}; - -STBRP_DEF void stbrp_setup_heuristic(stbrp_context *context, int heuristic) -{ - switch (context->init_mode) { - case STBRP__INIT_skyline: - STBRP_ASSERT(heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight || heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight); - context->heuristic = heuristic; - break; - default: - STBRP_ASSERT(0); - } -} - -STBRP_DEF void stbrp_setup_allow_out_of_mem(stbrp_context *context, int allow_out_of_mem) -{ - if (allow_out_of_mem) - // if it's ok to run out of memory, then don't bother aligning them; - // this gives better packing, but may fail due to OOM (even though - // the rectangles easily fit). @TODO a smarter approach would be to only - // quantize once we've hit OOM, then we could get rid of this parameter. - context->align = 1; - else { - // if it's not ok to run out of memory, then quantize the widths - // so that num_nodes is always enough nodes. - // - // I.e. num_nodes * align >= width - // align >= width / num_nodes - // align = ceil(width/num_nodes) - - context->align = (context->width + context->num_nodes-1) / context->num_nodes; - } -} - -STBRP_DEF void stbrp_init_target(stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes) -{ - int i; -#ifndef STBRP_LARGE_RECTS - STBRP_ASSERT(width <= 0xffff && height <= 0xffff); -#endif - - for (i=0; i < num_nodes-1; ++i) - nodes[i].next = &nodes[i+1]; - nodes[i].next = NULL; - context->init_mode = STBRP__INIT_skyline; - context->heuristic = STBRP_HEURISTIC_Skyline_default; - context->free_head = &nodes[0]; - context->active_head = &context->extra[0]; - context->width = width; - context->height = height; - context->num_nodes = num_nodes; - stbrp_setup_allow_out_of_mem(context, 0); - - // node 0 is the full width, node 1 is the sentinel (lets us not store width explicitly) - context->extra[0].x = 0; - context->extra[0].y = 0; - context->extra[0].next = &context->extra[1]; - context->extra[1].x = (stbrp_coord) width; -#ifdef STBRP_LARGE_RECTS - context->extra[1].y = (1<<30); -#else - context->extra[1].y = 65535; -#endif - context->extra[1].next = NULL; -} - -// find minimum y position if it starts at x1 -static int stbrp__skyline_find_min_y(stbrp_context *c, stbrp_node *first, int x0, int width, int *pwaste) -{ - stbrp_node *node = first; - int x1 = x0 + width; - int min_y, visited_width, waste_area; - - STBRP__NOTUSED(c); - - STBRP_ASSERT(first->x <= x0); - - #if 0 - // skip in case we're past the node - while (node->next->x <= x0) - ++node; - #else - STBRP_ASSERT(node->next->x > x0); // we ended up handling this in the caller for efficiency - #endif - - STBRP_ASSERT(node->x <= x0); - - min_y = 0; - waste_area = 0; - visited_width = 0; - while (node->x < x1) { - if (node->y > min_y) { - // raise min_y higher. - // we've accounted for all waste up to min_y, - // but we'll now add more waste for everything we've visted - waste_area += visited_width * (node->y - min_y); - min_y = node->y; - // the first time through, visited_width might be reduced - if (node->x < x0) - visited_width += node->next->x - x0; - else - visited_width += node->next->x - node->x; - } else { - // add waste area - int under_width = node->next->x - node->x; - if (under_width + visited_width > width) - under_width = width - visited_width; - waste_area += under_width * (min_y - node->y); - visited_width += under_width; - } - node = node->next; - } - - *pwaste = waste_area; - return min_y; -} - -typedef struct -{ - int x,y; - stbrp_node **prev_link; -} stbrp__findresult; - -static stbrp__findresult stbrp__skyline_find_best_pos(stbrp_context *c, int width, int height) -{ - int best_waste = (1<<30), best_x, best_y = (1 << 30); - stbrp__findresult fr; - stbrp_node **prev, *node, *tail, **best = NULL; - - // align to multiple of c->align - width = (width + c->align - 1); - width -= width % c->align; - STBRP_ASSERT(width % c->align == 0); - - // if it can't possibly fit, bail immediately - if (width > c->width || height > c->height) { - fr.prev_link = NULL; - fr.x = fr.y = 0; - return fr; - } - - node = c->active_head; - prev = &c->active_head; - while (node->x + width <= c->width) { - int y,waste; - y = stbrp__skyline_find_min_y(c, node, node->x, width, &waste); - if (c->heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight) { // actually just want to test BL - // bottom left - if (y < best_y) { - best_y = y; - best = prev; - } - } else { - // best-fit - if (y + height <= c->height) { - // can only use it if it first vertically - if (y < best_y || (y == best_y && waste < best_waste)) { - best_y = y; - best_waste = waste; - best = prev; - } - } - } - prev = &node->next; - node = node->next; - } - - best_x = (best == NULL) ? 0 : (*best)->x; - - // if doing best-fit (BF), we also have to try aligning right edge to each node position - // - // e.g, if fitting - // - // ____________________ - // |____________________| - // - // into - // - // | | - // | ____________| - // |____________| - // - // then right-aligned reduces waste, but bottom-left BL is always chooses left-aligned - // - // This makes BF take about 2x the time - - if (c->heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight) { - tail = c->active_head; - node = c->active_head; - prev = &c->active_head; - // find first node that's admissible - while (tail->x < width) - tail = tail->next; - while (tail) { - int xpos = tail->x - width; - int y,waste; - STBRP_ASSERT(xpos >= 0); - // find the left position that matches this - while (node->next->x <= xpos) { - prev = &node->next; - node = node->next; - } - STBRP_ASSERT(node->next->x > xpos && node->x <= xpos); - y = stbrp__skyline_find_min_y(c, node, xpos, width, &waste); - if (y + height <= c->height) { - if (y <= best_y) { - if (y < best_y || waste < best_waste || (waste==best_waste && xpos < best_x)) { - best_x = xpos; - STBRP_ASSERT(y <= best_y); - best_y = y; - best_waste = waste; - best = prev; - } - } - } - tail = tail->next; - } - } - - fr.prev_link = best; - fr.x = best_x; - fr.y = best_y; - return fr; -} - -static stbrp__findresult stbrp__skyline_pack_rectangle(stbrp_context *context, int width, int height) -{ - // find best position according to heuristic - stbrp__findresult res = stbrp__skyline_find_best_pos(context, width, height); - stbrp_node *node, *cur; - - // bail if: - // 1. it failed - // 2. the best node doesn't fit (we don't always check this) - // 3. we're out of memory - if (res.prev_link == NULL || res.y + height > context->height || context->free_head == NULL) { - res.prev_link = NULL; - return res; - } - - // on success, create new node - node = context->free_head; - node->x = (stbrp_coord) res.x; - node->y = (stbrp_coord) (res.y + height); - - context->free_head = node->next; - - // insert the new node into the right starting point, and - // let 'cur' point to the remaining nodes needing to be - // stiched back in - - cur = *res.prev_link; - if (cur->x < res.x) { - // preserve the existing one, so start testing with the next one - stbrp_node *next = cur->next; - cur->next = node; - cur = next; - } else { - *res.prev_link = node; - } - - // from here, traverse cur and free the nodes, until we get to one - // that shouldn't be freed - while (cur->next && cur->next->x <= res.x + width) { - stbrp_node *next = cur->next; - // move the current node to the free list - cur->next = context->free_head; - context->free_head = cur; - cur = next; - } - - // stitch the list back in - node->next = cur; - - if (cur->x < res.x + width) - cur->x = (stbrp_coord) (res.x + width); - -#ifdef _DEBUG - cur = context->active_head; - while (cur->x < context->width) { - STBRP_ASSERT(cur->x < cur->next->x); - cur = cur->next; - } - STBRP_ASSERT(cur->next == NULL); - - { - int count=0; - cur = context->active_head; - while (cur) { - cur = cur->next; - ++count; - } - cur = context->free_head; - while (cur) { - cur = cur->next; - ++count; - } - STBRP_ASSERT(count == context->num_nodes+2); - } -#endif - - return res; -} - -static int rect_height_compare(const void *a, const void *b) -{ - const stbrp_rect *p = (const stbrp_rect *) a; - const stbrp_rect *q = (const stbrp_rect *) b; - if (p->h > q->h) - return -1; - if (p->h < q->h) - return 1; - return (p->w > q->w) ? -1 : (p->w < q->w); -} - -static int rect_original_order(const void *a, const void *b) -{ - const stbrp_rect *p = (const stbrp_rect *) a; - const stbrp_rect *q = (const stbrp_rect *) b; - return (p->was_packed < q->was_packed) ? -1 : (p->was_packed > q->was_packed); -} - -#ifdef STBRP_LARGE_RECTS -#define STBRP__MAXVAL 0xffffffff -#else -#define STBRP__MAXVAL 0xffff -#endif - -STBRP_DEF int stbrp_pack_rects(stbrp_context *context, stbrp_rect *rects, int num_rects) -{ - int i, all_rects_packed = 1; - - // we use the 'was_packed' field internally to allow sorting/unsorting - for (i=0; i < num_rects; ++i) { - rects[i].was_packed = i; - } - - // sort according to heuristic - STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_height_compare); - - for (i=0; i < num_rects; ++i) { - if (rects[i].w == 0 || rects[i].h == 0) { - rects[i].x = rects[i].y = 0; // empty rect needs no space - } else { - stbrp__findresult fr = stbrp__skyline_pack_rectangle(context, rects[i].w, rects[i].h); - if (fr.prev_link) { - rects[i].x = (stbrp_coord) fr.x; - rects[i].y = (stbrp_coord) fr.y; - } else { - rects[i].x = rects[i].y = STBRP__MAXVAL; - } - } - } - - // unsort - STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_original_order); - - // set was_packed flags and all_rects_packed status - for (i=0; i < num_rects; ++i) { - rects[i].was_packed = !(rects[i].x == STBRP__MAXVAL && rects[i].y == STBRP__MAXVAL); - if (!rects[i].was_packed) - all_rects_packed = 0; - } - - // return the all_rects_packed status - return all_rects_packed; -} -#endif - -/* ------------------------------------------------------------------------------- -This software is available under 2 licenses -- choose whichever you prefer. ------------------------------------------------------------------------------- -ALTERNATIVE A - MIT License -Copyright (c) 2017 Sean Barrett -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. ------------------------------------------------------------------------------- -ALTERNATIVE B - Public Domain (www.unlicense.org) -This is free and unencumbered software released into the public domain. -Anyone is free to copy, modify, publish, use, compile, sell, or distribute this -software, either in source code form or as a compiled binary, for any purpose, -commercial or non-commercial, and by any means. -In jurisdictions that recognize copyright laws, the author or authors of this -software dedicate any and all copyright interest in the software to the public -domain. We make this dedication for the benefit of the public at large and to -the detriment of our heirs and successors. We intend this dedication to be an -overt act of relinquishment in perpetuity of all present and future rights to -this software under copyright law. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------- -*/ diff --git a/src/plugins/pixelart/src/deps/stbi/zstbi.c b/src/plugins/pixelart/src/deps/stbi/zstbi.c deleted file mode 100644 index af99e181..00000000 --- a/src/plugins/pixelart/src/deps/stbi/zstbi.c +++ /dev/null @@ -1,34 +0,0 @@ -#ifdef STBI_NO_STDLIB -// `wasm32-freestanding` has no libc. Route alloc + qsort through fizzy shims -// (see `fizzy_stbi_libc.c`) and stub out asserts so neither stb header pulls -// in `` / ``. -#include -extern void *fizzy_stbi_malloc(size_t size); -extern void fizzy_stbi_free(void *ptr); -extern void fizzy_stbi_qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *)); - -// stb_rect_pack: comparator-driven sort + asserts. -#define STBRP_SORT(base, nmemb, size, compar) fizzy_stbi_qsort((base), (nmemb), (size), (compar)) -#define STBRP_ASSERT(x) ((void)0) - -// stb_image_resize2: malloc/free + asserts. `user_data` is passthrough. -#define STBIR_MALLOC(size, user_data) ((void)(user_data), fizzy_stbi_malloc(size)) -#define STBIR_FREE(ptr, user_data) ((void)(user_data), fizzy_stbi_free(ptr)) -#define STBIR_ASSERT(x) ((void)0) -// Skip `` — use Clang/LLVM builtins available on wasm32. -#define STBIR_CEILF(x) __builtin_ceilf(x) -#define STBIR_FLOORF(x) __builtin_floorf(x) -// Skip `` — use the Clang builtin memcpy. Zig's wasm target provides -// `memcpy` as an intrinsic for any compilation unit that emits a memcpy. -#define STBIR_MEMCPY(dest, src, len) __builtin_memcpy((dest), (src), (len)) -#else -#include -#endif - -#define STB_RECT_PACK_IMPLEMENTATION -#include "stb_rect_pack.h" - -#define STB_IMAGE_RESIZE_IMPLEMENTATION -#define STBIR_DEFAULT_FILTER_UPSAMPLE STBIR_FILTER_POINT_SAMPLE -#define STBIR_DEFAULT_FILTER_DOWNSAMPLE STBIR_FILTER_POINT_SAMPLE -#include "stb_image_resize2.h" \ No newline at end of file diff --git a/src/plugins/pixelart/src/deps/stbi/zstbi.zig b/src/plugins/pixelart/src/deps/stbi/zstbi.zig deleted file mode 100644 index d968b707..00000000 --- a/src/plugins/pixelart/src/deps/stbi/zstbi.zig +++ /dev/null @@ -1,95 +0,0 @@ -pub const version = @import("std").SemanticVersion{ .major = 0, .minor = 9, .patch = 3 }; -const std = @import("std"); -const assert = std.debug.assert; - -pub const Rect = extern struct { - id: u32, - w: u16, - h: u16, - x: u16 = 0, - y: u16 = 0, - was_packed: i32 = 0, - - pub fn slice(self: Rect) [4]u32 { - return .{ - @intCast(self.x), - @intCast(self.y), - @intCast(self.w), - @intCast(self.h), - }; - } -}; - -pub const Node = extern struct { - x: u16, - y: u16, - next: [*c]Node, -}; - -pub const Context = extern struct { - width: i32, - height: i32, - @"align": i32, - init_mode: i32, - heuristic: i32, - num_nodes: i32, - active_head: [*c]Node, - free_head: [*c]Node, - extra: [2]Node, -}; - -pub const Heuristic = enum(u32) { - skyline_default, - skyline_bl_sort_height, - skyline_bf_sort_height, -}; - -pub fn initTarget(context: *Context, width: u32, height: u32, nodes: []Node) void { - stbrp_init_target(context, width, height, nodes.ptr, nodes.len); -} - -pub fn packRects(context: *Context, rects: []Rect) usize { - return @as(usize, @intCast(stbrp_pack_rects(context, rects.ptr, rects.len))); -} - -pub fn setupHeuristic(context: *Context, heuristic: Heuristic) void { - stbrp_setup_heuristic(context, @as(u32, @intCast(@intFromEnum(heuristic)))); -} - -pub extern fn stbrp_init_target(context: [*c]Context, width: u32, height: u32, nodes: [*c]Node, num_nodes: usize) void; -pub extern fn stbrp_pack_rects(context: [*c]Context, rects: [*c]Rect, num_rects: usize) usize; -pub extern fn stbrp_setup_allow_out_of_mem(context: [*c]Context, allow_out_of_mem: u32) void; -pub extern fn stbrp_setup_heuristic(context: [*c]Context, heuristic: u32) void; - -pub const stbir_pixel_layout = enum(i32) { - STBIR_1CHANNEL = 1, - STBIR_2CHANNEL = 2, - STBIR_RGB = 3, // 3-chan, with order specified (for channel flipping) - STBIR_BGR = 0, // 3-chan, with order specified (for channel flipping) - STBIR_4CHANNEL = 5, - - STBIR_RGBA = 4, // alpha formats, where alpha is NOT premultiplied into color channels - STBIR_BGRA = 6, - STBIR_ARGB = 7, - STBIR_ABGR = 8, - STBIR_RA = 9, - STBIR_AR = 10, - - STBIR_RGBA_PM = 11, // alpha formats, where alpha is premultiplied into color channels - STBIR_BGRA_PM = 12, - STBIR_ARGB_PM = 13, - STBIR_ABGR_PM = 14, - STBIR_RA_PM = 15, - STBIR_AR_PM = 16, -}; - -pub fn resize(input_pixels: [][4]u8, input_w: u32, input_h: u32, output_pixels: [][4]u8, output_w: u32, output_h: u32) ?[]u8 { - const input_slice = @as([*]u8, @ptrCast(input_pixels.ptr))[0..@intCast(input_w * input_h * 4)]; - const output_slice = @as([*]u8, @ptrCast(output_pixels.ptr))[0..@intCast(output_w * output_h * 4)]; - const output = stbir_resize_uint8_linear(input_slice.ptr, @intCast(input_w), @intCast(input_h), @intCast(input_w * 4), output_slice.ptr, @intCast(output_w), @intCast(output_h), @intCast(output_w * 4), .STBIR_RGBA); - if (output == null) return null; - return output_slice; -} - -pub extern fn stbir_resize_uint8_srgb(input_pixels: [*c]u8, input_w: i32, input_h: i32, input_stride_in_bytes: i32, output_pixels: [*c]u8, output_w: i32, output_h: i32, output_stride_in_bytes: i32, pixel_type: stbir_pixel_layout) [*c]u8; -pub extern fn stbir_resize_uint8_linear(input_pixels: [*c]u8, input_w: i32, input_h: i32, input_stride_in_bytes: i32, output_pixels: [*c]u8, output_w: i32, output_h: i32, output_stride_in_bytes: i32, pixel_type: stbir_pixel_layout) [*c]u8; diff --git a/src/plugins/pixelart/src/deps/zip/build.zig b/src/plugins/pixelart/src/deps/zip/build.zig deleted file mode 100644 index 98924c57..00000000 --- a/src/plugins/pixelart/src/deps/zip/build.zig +++ /dev/null @@ -1,41 +0,0 @@ -const builtin = @import("builtin"); -const std = @import("std"); - -pub fn build(_: *std.Build) !void {} - -pub const Package = struct { - module: *std.Build.Module, -}; - -pub fn package(b: *std.Build, _: struct {}) Package { - const module = b.createModule(.{ - .root_source_file = .{ .cwd_relative = thisDir() ++ "/zip.zig" }, - }); - return .{ .module = module }; -} - -const wasm_c_flags = [_][]const u8{ - "-fno-sanitize=undefined", - "-DFIZZY_ZIP_WASM", - "-DZIP_RAW_ENTRYNAME", -}; - -pub fn link(exe: *std.Build.Step.Compile) void { - exe.root_module.link_libc = true; - exe.root_module.addIncludePath(.{ .cwd_relative = thisDir() ++ "/src" }); - const c_flags = [_][]const u8{"-fno-sanitize=undefined"}; - exe.root_module.addCSourceFile(.{ .file = .{ .cwd_relative = thisDir() ++ "/src/zip.c" }, .flags = &c_flags }); -} - -/// In-memory zip read/write for wasm32-freestanding (no libc, no filesystem). -/// Uses DVUI's `dvui_c_alloc` via `fizzy_zip_libc.c`. -pub fn linkWasm(exe: *std.Build.Step.Compile) void { - exe.root_module.addIncludePath(.{ .cwd_relative = thisDir() ++ "/src" }); - exe.root_module.addCSourceFile(.{ .file = .{ .cwd_relative = thisDir() ++ "/fizzy_zip_libc.c" }, .flags = &wasm_c_flags }); - exe.root_module.addCSourceFile(.{ .file = .{ .cwd_relative = thisDir() ++ "/fizzy_zip_strings.c" }, .flags = &wasm_c_flags }); - exe.root_module.addCSourceFile(.{ .file = .{ .cwd_relative = thisDir() ++ "/src/zip.c" }, .flags = &wasm_c_flags }); -} - -inline fn thisDir() []const u8 { - return comptime std.fs.path.dirname(@src().file) orelse "."; -} diff --git a/src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c b/src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c deleted file mode 100644 index d2cbe827..00000000 --- a/src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c +++ /dev/null @@ -1,65 +0,0 @@ -// Heap shims for compiling zip.c / miniz on wasm32-freestanding. -// Routes C allocations to DVUI's exported allocator (same as stb on web). - -#include -#include - -extern void *memset(void *dest, int c, size_t n); -extern void *memcpy(void *dest, const void *src, size_t n); - -extern void *dvui_c_alloc(size_t size); -extern void dvui_c_free(void *ptr); -extern void *dvui_c_realloc_sized(void *ptr, size_t oldsize, size_t newsize); - -void *fizzy_zip_malloc(size_t size) { - return dvui_c_alloc(size); -} - -void fizzy_zip_free(void *ptr) { - dvui_c_free(ptr); -} - -// `dvui_c_realloc_sized` uses `oldsize` as the memcpy length when copying from -// the old buffer to the new one. Passing 0 would leave the new buffer's content -// uninitialized — miniz's zip archive grows via realloc, so a 0 here would -// corrupt the zip output (the bytes wouldn't even start with `PK\x03\x04`). -// -// DVUI's `dvui_c_alloc` stores the allocation's *total* byte count (user size -// + the 8-byte prefix) 8 bytes before the user pointer. We recover the -// user-visible size by reading that prefix and subtracting 8, then clamp the -// copy to `min(oldsize, newsize)` so a shrinking realloc never overruns the -// new buffer's user area. -void *fizzy_zip_realloc(void *ptr, size_t size) { - if (!ptr) { - return dvui_c_alloc(size); - } - uint64_t buflen; - memcpy(&buflen, (uint8_t *)ptr - 8, sizeof(uint64_t)); - size_t oldsize = (size_t)buflen - 8; - size_t copy = oldsize < size ? oldsize : size; - return dvui_c_realloc_sized(ptr, copy, size); -} - -void *fizzy_zip_calloc(size_t num, size_t size) { - const size_t total = num * size; - void *ptr = dvui_c_alloc(total); - if (ptr) { - memset(ptr, 0, total); - } - return ptr; -} - -extern size_t strlen(const char *s); -extern void *memcpy(void *dest, const void *src, size_t n); - -char *fizzy_strdup(const char *s) { - if (!s) { - return NULL; - } - const size_t n = strlen(s) + 1; - char *d = (char *)dvui_c_alloc(n); - if (d) { - memcpy(d, s, n); - } - return d; -} diff --git a/src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c b/src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c deleted file mode 100644 index 12168b47..00000000 --- a/src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c +++ /dev/null @@ -1,34 +0,0 @@ -// Minimal string routines for zip/miniz on wasm32-freestanding (no libc). - -#include - -extern void *memcpy(void *dest, const void *src, size_t n); - -int strcmp(const char *l, const char *r) { - for (; *l == *r && *l; l++, r++) {} - return *(const unsigned char *)l - *(const unsigned char *)r; -} - -size_t strlen(const char *s) { - const char *p = s; - while (*p) { - p++; - } - return (size_t)(p - s); -} - -int memcmp(const void *l, const void *r, size_t n) { - const unsigned char *a = l, *b = r; - for (; n; n--, a++, b++) { - if (*a != *b) { - return *a - *b; - } - } - return 0; -} - -char *strcpy(char *dest, const char *src) { - char *d = dest; - while ((*d++ = *src++)) {} - return dest; -} diff --git a/src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h b/src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h deleted file mode 100644 index 759b60bc..00000000 --- a/src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h +++ /dev/null @@ -1,42 +0,0 @@ -// Included first when compiling zip.c with -DFIZZY_ZIP_WASM (web / freestanding). -#pragma once - -#include -#include - -extern void *memcpy(void *dest, const void *src, size_t n); -extern void *memset(void *dest, int c, size_t n); -extern void *memmove(void *dest, const void *src, size_t n); - -extern int strcmp(const char *l, const char *r); -extern size_t strlen(const char *s); -extern int memcmp(const void *l, const void *r, size_t n); -extern char *strcpy(char *dest, const char *src); -extern char *fizzy_strdup(const char *s); - -extern void *fizzy_zip_malloc(size_t size); -extern void fizzy_zip_free(void *ptr); -extern void *fizzy_zip_realloc(void *ptr, size_t size); -extern void *fizzy_zip_calloc(size_t num, size_t size); - -#define malloc(SZ) fizzy_zip_malloc(SZ) -#define free(PTR) fizzy_zip_free(PTR) -#define calloc(N, SZ) fizzy_zip_calloc(N, SZ) -#define realloc(PTR, SZ) fizzy_zip_realloc(PTR, SZ) - -#define MINIZ_NO_STDIO -#define MINIZ_NO_TIME - -#define MZ_MALLOC(SZ) fizzy_zip_malloc(SZ) -#define MZ_FREE(PTR) fizzy_zip_free(PTR) -#define MZ_REALLOC(PTR, SZ) fizzy_zip_realloc(PTR, SZ) - -#ifndef assert -#define assert(EXPR) \ - do { \ - if (!(EXPR)) \ - __builtin_trap(); \ - } while (0) -#endif - -#include "miniz.h" diff --git a/src/plugins/pixelart/src/deps/zip/src/miniz.h b/src/plugins/pixelart/src/deps/zip/src/miniz.h deleted file mode 100644 index d6f15f89..00000000 --- a/src/plugins/pixelart/src/deps/zip/src/miniz.h +++ /dev/null @@ -1,10145 +0,0 @@ -#define MINIZ_EXPORT -/* miniz.c 2.2.0 - public domain deflate/inflate, zlib-subset, ZIP - reading/writing/appending, PNG writing See "unlicense" statement at the end - of this file. Rich Geldreich , last updated Oct. 13, - 2013 Implements RFC 1950: http://www.ietf.org/rfc/rfc1950.txt and RFC 1951: - http://www.ietf.org/rfc/rfc1951.txt - - Most API's defined in miniz.c are optional. For example, to disable the - archive related functions just define MINIZ_NO_ARCHIVE_APIS, or to get rid of - all stdio usage define MINIZ_NO_STDIO (see the list below for more macros). - - * Low-level Deflate/Inflate implementation notes: - - Compression: Use the "tdefl" API's. The compressor supports raw, static, - and dynamic blocks, lazy or greedy parsing, match length filtering, RLE-only, - and Huffman-only streams. It performs and compresses approximately as well as - zlib. - - Decompression: Use the "tinfl" API's. The entire decompressor is - implemented as a single function coroutine: see tinfl_decompress(). It - supports decompression into a 32KB (or larger power of 2) wrapping buffer, or - into a memory block large enough to hold the entire file. - - The low-level tdefl/tinfl API's do not make any use of dynamic memory - allocation. - - * zlib-style API notes: - - miniz.c implements a fairly large subset of zlib. There's enough - functionality present for it to be a drop-in zlib replacement in many apps: - The z_stream struct, optional memory allocation callbacks - deflateInit/deflateInit2/deflate/deflateReset/deflateEnd/deflateBound - inflateInit/inflateInit2/inflate/inflateReset/inflateEnd - compress, compress2, compressBound, uncompress - CRC-32, Adler-32 - Using modern, minimal code size, CPU cache friendly - routines. Supports raw deflate streams or standard zlib streams with adler-32 - checking. - - Limitations: - The callback API's are not implemented yet. No support for gzip headers or - zlib static dictionaries. I've tried to closely emulate zlib's various - flavors of stream flushing and return status codes, but there are no - guarantees that miniz.c pulls this off perfectly. - - * PNG writing: See the tdefl_write_image_to_png_file_in_memory() function, - originally written by Alex Evans. Supports 1-4 bytes/pixel images. - - * ZIP archive API notes: - - The ZIP archive API's where designed with simplicity and efficiency in - mind, with just enough abstraction to get the job done with minimal fuss. - There are simple API's to retrieve file information, read files from existing - archives, create new archives, append new files to existing archives, or - clone archive data from one archive to another. It supports archives located - in memory or the heap, on disk (using stdio.h), or you can specify custom - file read/write callbacks. - - - Archive reading: Just call this function to read a single file from a - disk archive: - - void *mz_zip_extract_archive_file_to_heap(const char *pZip_filename, const - char *pArchive_name, size_t *pSize, mz_uint zip_flags); - - For more complex cases, use the "mz_zip_reader" functions. Upon opening an - archive, the entire central directory is located and read as-is into memory, - and subsequent file access only occurs when reading individual files. - - - Archives file scanning: The simple way is to use this function to scan a - loaded archive for a specific file: - - int mz_zip_reader_locate_file(mz_zip_archive *pZip, const char *pName, - const char *pComment, mz_uint flags); - - The locate operation can optionally check file comments too, which (as one - example) can be used to identify multiple versions of the same file in an - archive. This function uses a simple linear search through the central - directory, so it's not very fast. - - Alternately, you can iterate through all the files in an archive (using - mz_zip_reader_get_num_files()) and retrieve detailed info on each file by - calling mz_zip_reader_file_stat(). - - - Archive creation: Use the "mz_zip_writer" functions. The ZIP writer - immediately writes compressed file data to disk and builds an exact image of - the central directory in memory. The central directory image is written all - at once at the end of the archive file when the archive is finalized. - - The archive writer can optionally align each file's local header and file - data to any power of 2 alignment, which can be useful when the archive will - be read from optical media. Also, the writer supports placing arbitrary data - blobs at the very beginning of ZIP archives. Archives written using either - feature are still readable by any ZIP tool. - - - Archive appending: The simple way to add a single file to an archive is - to call this function: - - mz_bool mz_zip_add_mem_to_archive_file_in_place(const char *pZip_filename, - const char *pArchive_name, const void *pBuf, size_t buf_size, const void - *pComment, mz_uint16 comment_size, mz_uint level_and_flags); - - The archive will be created if it doesn't already exist, otherwise it'll be - appended to. Note the appending is done in-place and is not an atomic - operation, so if something goes wrong during the operation it's possible the - archive could be left without a central directory (although the local file - headers and file data will be fine, so the archive will be recoverable). - - For more complex archive modification scenarios: - 1. The safest way is to use a mz_zip_reader to read the existing archive, - cloning only those bits you want to preserve into a new archive using using - the mz_zip_writer_add_from_zip_reader() function (which compiles the - compressed file data as-is). When you're done, delete the old archive and - rename the newly written archive, and you're done. This is safe but requires - a bunch of temporary disk space or heap memory. - - 2. Or, you can convert an mz_zip_reader in-place to an mz_zip_writer using - mz_zip_writer_init_from_reader(), append new files as needed, then finalize - the archive which will write an updated central directory to the original - archive. (This is basically what mz_zip_add_mem_to_archive_file_in_place() - does.) There's a possibility that the archive's central directory could be - lost with this method if anything goes wrong, though. - - - ZIP archive support limitations: - No spanning support. Extraction functions can only handle unencrypted, - stored or deflated files. Requires streams capable of seeking. - - * This is a header file library, like stb_image.c. To get only a header file, - either cut and paste the below header, or create miniz.h, #define - MINIZ_HEADER_FILE_ONLY, and then include miniz.c from it. - - * Important: For best perf. be sure to customize the below macros for your - target platform: #define MINIZ_USE_UNALIGNED_LOADS_AND_STORES 1 #define - MINIZ_LITTLE_ENDIAN 1 #define MINIZ_HAS_64BIT_REGISTERS 1 - - * On platforms using glibc, Be sure to "#define _LARGEFILE64_SOURCE 1" before - including miniz.c to ensure miniz uses the 64-bit variants: fopen64(), - stat64(), etc. Otherwise you won't be able to process large files (i.e. - 32-bit stat() fails for me on files > 0x7FFFFFFF bytes). -*/ -#pragma once - -/* Defines to completely disable specific portions of miniz.c: - If all macros here are defined the only functionality remaining will be - CRC-32, adler-32, tinfl, and tdefl. */ - -/* Define MINIZ_NO_STDIO to disable all usage and any functions which rely on - * stdio for file I/O. */ -/*#define MINIZ_NO_STDIO */ - -/* If MINIZ_NO_TIME is specified then the ZIP archive functions will not be able - * to get the current time, or */ -/* get/set file times, and the C run-time funcs that get/set times won't be - * called. */ -/* The current downside is the times written to your archives will be from 1979. - */ -/*#define MINIZ_NO_TIME */ - -/* Define MINIZ_NO_ARCHIVE_APIS to disable all ZIP archive API's. */ -/*#define MINIZ_NO_ARCHIVE_APIS */ - -/* Define MINIZ_NO_ARCHIVE_WRITING_APIS to disable all writing related ZIP - * archive API's. */ -/*#define MINIZ_NO_ARCHIVE_WRITING_APIS */ - -/* Define MINIZ_NO_ZLIB_APIS to remove all ZLIB-style compression/decompression - * API's. */ -/*#define MINIZ_NO_ZLIB_APIS */ - -/* Define MINIZ_NO_ZLIB_COMPATIBLE_NAME to disable zlib names, to prevent - * conflicts against stock zlib. */ -/*#define MINIZ_NO_ZLIB_COMPATIBLE_NAMES */ - -/* Define MINIZ_NO_MALLOC to disable all calls to malloc, free, and realloc. - Note if MINIZ_NO_MALLOC is defined then the user must always provide custom - user alloc/free/realloc callbacks to the zlib and archive API's, and a few - stand-alone helper API's which don't provide custom user functions (such as - tdefl_compress_mem_to_heap() and tinfl_decompress_mem_to_heap()) won't work. - */ -/*#define MINIZ_NO_MALLOC */ - -#if defined(__TINYC__) && (defined(__linux) || defined(__linux__)) -/* TODO: Work around "error: include file 'sys\utime.h' when compiling with tcc - * on Linux */ -#define MINIZ_NO_TIME -#endif - -#include - -#if !defined(MINIZ_NO_TIME) && !defined(MINIZ_NO_ARCHIVE_APIS) -#include -#endif - -#if defined(_M_IX86) || defined(_M_X64) || defined(__i386__) || \ - defined(__i386) || defined(__i486__) || defined(__i486) || \ - defined(i386) || defined(__ia64__) || defined(__x86_64__) -/* MINIZ_X86_OR_X64_CPU is only used to help set the below macros. */ -#define MINIZ_X86_OR_X64_CPU 1 -#else -#define MINIZ_X86_OR_X64_CPU 0 -#endif - -#if (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) || MINIZ_X86_OR_X64_CPU -/* Set MINIZ_LITTLE_ENDIAN to 1 if the processor is little endian. */ -#define MINIZ_LITTLE_ENDIAN 1 -#else -#define MINIZ_LITTLE_ENDIAN 0 -#endif - -/* Set MINIZ_USE_UNALIGNED_LOADS_AND_STORES only if not set */ -#if !defined(MINIZ_USE_UNALIGNED_LOADS_AND_STORES) -#if MINIZ_X86_OR_X64_CPU -/* Set MINIZ_USE_UNALIGNED_LOADS_AND_STORES to 1 on CPU's that permit efficient - * integer loads and stores from unaligned addresses. */ -#define MINIZ_USE_UNALIGNED_LOADS_AND_STORES 1 -#define MINIZ_UNALIGNED_USE_MEMCPY -#else -#define MINIZ_USE_UNALIGNED_LOADS_AND_STORES 0 -#endif -#endif - -#if defined(_M_X64) || defined(_WIN64) || defined(__MINGW64__) || \ - defined(_LP64) || defined(__LP64__) || defined(__ia64__) || \ - defined(__x86_64__) -/* Set MINIZ_HAS_64BIT_REGISTERS to 1 if operations on 64-bit integers are - * reasonably fast (and don't involve compiler generated calls to helper - * functions). */ -#define MINIZ_HAS_64BIT_REGISTERS 1 -#else -#define MINIZ_HAS_64BIT_REGISTERS 0 -#endif - -#ifdef __cplusplus -extern "C" { -#endif - -/* ------------------- zlib-style API Definitions. */ - -/* For more compatibility with zlib, miniz.c uses unsigned long for some - * parameters/struct members. Beware: mz_ulong can be either 32 or 64-bits! */ -typedef unsigned long mz_ulong; - -/* mz_free() internally uses the MZ_FREE() macro (which by default calls free() - * unless you've modified the MZ_MALLOC macro) to release a block allocated from - * the heap. */ -MINIZ_EXPORT void mz_free(void *p); - -#define MZ_ADLER32_INIT (1) -/* mz_adler32() returns the initial adler-32 value to use when called with - * ptr==NULL. */ -MINIZ_EXPORT mz_ulong mz_adler32(mz_ulong adler, const unsigned char *ptr, - size_t buf_len); - -#define MZ_CRC32_INIT (0) -/* mz_crc32() returns the initial CRC-32 value to use when called with - * ptr==NULL. */ -MINIZ_EXPORT mz_ulong mz_crc32(mz_ulong crc, const unsigned char *ptr, - size_t buf_len); - -/* Compression strategies. */ -enum { - MZ_DEFAULT_STRATEGY = 0, - MZ_FILTERED = 1, - MZ_HUFFMAN_ONLY = 2, - MZ_RLE = 3, - MZ_FIXED = 4 -}; - -/* Method */ -#define MZ_DEFLATED 8 - -/* Heap allocation callbacks. -Note that mz_alloc_func parameter types purposely differ from zlib's: items/size -is size_t, not unsigned long. */ -typedef void *(*mz_alloc_func)(void *opaque, size_t items, size_t size); -typedef void (*mz_free_func)(void *opaque, void *address); -typedef void *(*mz_realloc_func)(void *opaque, void *address, size_t items, - size_t size); - -/* Compression levels: 0-9 are the standard zlib-style levels, 10 is best - * possible compression (not zlib compatible, and may be very slow), - * MZ_DEFAULT_COMPRESSION=MZ_DEFAULT_LEVEL. */ -enum { - MZ_NO_COMPRESSION = 0, - MZ_BEST_SPEED = 1, - MZ_BEST_COMPRESSION = 9, - MZ_UBER_COMPRESSION = 10, - MZ_DEFAULT_LEVEL = 6, - MZ_DEFAULT_COMPRESSION = -1 -}; - -#define MZ_VERSION "10.2.0" -#define MZ_VERNUM 0xA100 -#define MZ_VER_MAJOR 10 -#define MZ_VER_MINOR 2 -#define MZ_VER_REVISION 0 -#define MZ_VER_SUBREVISION 0 - -#ifndef MINIZ_NO_ZLIB_APIS - -/* Flush values. For typical usage you only need MZ_NO_FLUSH and MZ_FINISH. The - * other values are for advanced use (refer to the zlib docs). */ -enum { - MZ_NO_FLUSH = 0, - MZ_PARTIAL_FLUSH = 1, - MZ_SYNC_FLUSH = 2, - MZ_FULL_FLUSH = 3, - MZ_FINISH = 4, - MZ_BLOCK = 5 -}; - -/* Return status codes. MZ_PARAM_ERROR is non-standard. */ -enum { - MZ_OK = 0, - MZ_STREAM_END = 1, - MZ_NEED_DICT = 2, - MZ_ERRNO = -1, - MZ_STREAM_ERROR = -2, - MZ_DATA_ERROR = -3, - MZ_MEM_ERROR = -4, - MZ_BUF_ERROR = -5, - MZ_VERSION_ERROR = -6, - MZ_PARAM_ERROR = -10000 -}; - -/* Window bits */ -#define MZ_DEFAULT_WINDOW_BITS 15 - -struct mz_internal_state; - -/* Compression/decompression stream struct. */ -typedef struct mz_stream_s { - const unsigned char *next_in; /* pointer to next byte to read */ - unsigned int avail_in; /* number of bytes available at next_in */ - mz_ulong total_in; /* total number of bytes consumed so far */ - - unsigned char *next_out; /* pointer to next byte to write */ - unsigned int avail_out; /* number of bytes that can be written to next_out */ - mz_ulong total_out; /* total number of bytes produced so far */ - - char *msg; /* error msg (unused) */ - struct mz_internal_state - *state; /* internal state, allocated by zalloc/zfree */ - - mz_alloc_func - zalloc; /* optional heap allocation function (defaults to malloc) */ - mz_free_func zfree; /* optional heap free function (defaults to free) */ - void *opaque; /* heap alloc function user pointer */ - - int data_type; /* data_type (unused) */ - mz_ulong adler; /* adler32 of the source or uncompressed data */ - mz_ulong reserved; /* not used */ -} mz_stream; - -typedef mz_stream *mz_streamp; - -/* Returns the version string of miniz.c. */ -MINIZ_EXPORT const char *mz_version(void); - -/* mz_deflateInit() initializes a compressor with default options: */ -/* Parameters: */ -/* pStream must point to an initialized mz_stream struct. */ -/* level must be between [MZ_NO_COMPRESSION, MZ_BEST_COMPRESSION]. */ -/* level 1 enables a specially optimized compression function that's been - * optimized purely for performance, not ratio. */ -/* (This special func. is currently only enabled when - * MINIZ_USE_UNALIGNED_LOADS_AND_STORES and MINIZ_LITTLE_ENDIAN are defined.) */ -/* Return values: */ -/* MZ_OK on success. */ -/* MZ_STREAM_ERROR if the stream is bogus. */ -/* MZ_PARAM_ERROR if the input parameters are bogus. */ -/* MZ_MEM_ERROR on out of memory. */ -MINIZ_EXPORT int mz_deflateInit(mz_streamp pStream, int level); - -/* mz_deflateInit2() is like mz_deflate(), except with more control: */ -/* Additional parameters: */ -/* method must be MZ_DEFLATED */ -/* window_bits must be MZ_DEFAULT_WINDOW_BITS (to wrap the deflate stream with - * zlib header/adler-32 footer) or -MZ_DEFAULT_WINDOW_BITS (raw deflate/no - * header or footer) */ -/* mem_level must be between [1, 9] (it's checked but ignored by miniz.c) */ -MINIZ_EXPORT int mz_deflateInit2(mz_streamp pStream, int level, int method, - int window_bits, int mem_level, int strategy); - -/* Quickly resets a compressor without having to reallocate anything. Same as - * calling mz_deflateEnd() followed by mz_deflateInit()/mz_deflateInit2(). */ -MINIZ_EXPORT int mz_deflateReset(mz_streamp pStream); - -/* mz_deflate() compresses the input to output, consuming as much of the input - * and producing as much output as possible. */ -/* Parameters: */ -/* pStream is the stream to read from and write to. You must initialize/update - * the next_in, avail_in, next_out, and avail_out members. */ -/* flush may be MZ_NO_FLUSH, MZ_PARTIAL_FLUSH/MZ_SYNC_FLUSH, MZ_FULL_FLUSH, or - * MZ_FINISH. */ -/* Return values: */ -/* MZ_OK on success (when flushing, or if more input is needed but not - * available, and/or there's more output to be written but the output buffer is - * full). */ -/* MZ_STREAM_END if all input has been consumed and all output bytes have been - * written. Don't call mz_deflate() on the stream anymore. */ -/* MZ_STREAM_ERROR if the stream is bogus. */ -/* MZ_PARAM_ERROR if one of the parameters is invalid. */ -/* MZ_BUF_ERROR if no forward progress is possible because the input and/or - * output buffers are empty. (Fill up the input buffer or free up some output - * space and try again.) */ -MINIZ_EXPORT int mz_deflate(mz_streamp pStream, int flush); - -/* mz_deflateEnd() deinitializes a compressor: */ -/* Return values: */ -/* MZ_OK on success. */ -/* MZ_STREAM_ERROR if the stream is bogus. */ -MINIZ_EXPORT int mz_deflateEnd(mz_streamp pStream); - -/* mz_deflateBound() returns a (very) conservative upper bound on the amount of - * data that could be generated by deflate(), assuming flush is set to only - * MZ_NO_FLUSH or MZ_FINISH. */ -MINIZ_EXPORT mz_ulong mz_deflateBound(mz_streamp pStream, mz_ulong source_len); - -/* Single-call compression functions mz_compress() and mz_compress2(): */ -/* Returns MZ_OK on success, or one of the error codes from mz_deflate() on - * failure. */ -MINIZ_EXPORT int mz_compress(unsigned char *pDest, mz_ulong *pDest_len, - const unsigned char *pSource, mz_ulong source_len); -MINIZ_EXPORT int mz_compress2(unsigned char *pDest, mz_ulong *pDest_len, - const unsigned char *pSource, mz_ulong source_len, - int level); - -/* mz_compressBound() returns a (very) conservative upper bound on the amount of - * data that could be generated by calling mz_compress(). */ -MINIZ_EXPORT mz_ulong mz_compressBound(mz_ulong source_len); - -/* Initializes a decompressor. */ -MINIZ_EXPORT int mz_inflateInit(mz_streamp pStream); - -/* mz_inflateInit2() is like mz_inflateInit() with an additional option that - * controls the window size and whether or not the stream has been wrapped with - * a zlib header/footer: */ -/* window_bits must be MZ_DEFAULT_WINDOW_BITS (to parse zlib header/footer) or - * -MZ_DEFAULT_WINDOW_BITS (raw deflate). */ -MINIZ_EXPORT int mz_inflateInit2(mz_streamp pStream, int window_bits); - -/* Quickly resets a compressor without having to reallocate anything. Same as - * calling mz_inflateEnd() followed by mz_inflateInit()/mz_inflateInit2(). */ -MINIZ_EXPORT int mz_inflateReset(mz_streamp pStream); - -/* Decompresses the input stream to the output, consuming only as much of the - * input as needed, and writing as much to the output as possible. */ -/* Parameters: */ -/* pStream is the stream to read from and write to. You must initialize/update - * the next_in, avail_in, next_out, and avail_out members. */ -/* flush may be MZ_NO_FLUSH, MZ_SYNC_FLUSH, or MZ_FINISH. */ -/* On the first call, if flush is MZ_FINISH it's assumed the input and output - * buffers are both sized large enough to decompress the entire stream in a - * single call (this is slightly faster). */ -/* MZ_FINISH implies that there are no more source bytes available beside - * what's already in the input buffer, and that the output buffer is large - * enough to hold the rest of the decompressed data. */ -/* Return values: */ -/* MZ_OK on success. Either more input is needed but not available, and/or - * there's more output to be written but the output buffer is full. */ -/* MZ_STREAM_END if all needed input has been consumed and all output bytes - * have been written. For zlib streams, the adler-32 of the decompressed data - * has also been verified. */ -/* MZ_STREAM_ERROR if the stream is bogus. */ -/* MZ_DATA_ERROR if the deflate stream is invalid. */ -/* MZ_PARAM_ERROR if one of the parameters is invalid. */ -/* MZ_BUF_ERROR if no forward progress is possible because the input buffer is - * empty but the inflater needs more input to continue, or if the output buffer - * is not large enough. Call mz_inflate() again */ -/* with more input data, or with more room in the output buffer (except when - * using single call decompression, described above). */ -MINIZ_EXPORT int mz_inflate(mz_streamp pStream, int flush); - -/* Deinitializes a decompressor. */ -MINIZ_EXPORT int mz_inflateEnd(mz_streamp pStream); - -/* Single-call decompression. */ -/* Returns MZ_OK on success, or one of the error codes from mz_inflate() on - * failure. */ -MINIZ_EXPORT int mz_uncompress(unsigned char *pDest, mz_ulong *pDest_len, - const unsigned char *pSource, - mz_ulong source_len); -MINIZ_EXPORT int mz_uncompress2(unsigned char *pDest, mz_ulong *pDest_len, - const unsigned char *pSource, - mz_ulong *pSource_len); - -/* Returns a string description of the specified error code, or NULL if the - * error code is invalid. */ -MINIZ_EXPORT const char *mz_error(int err); - -/* Redefine zlib-compatible names to miniz equivalents, so miniz.c can be used - * as a drop-in replacement for the subset of zlib that miniz.c supports. */ -/* Define MINIZ_NO_ZLIB_COMPATIBLE_NAMES to disable zlib-compatibility if you - * use zlib in the same project. */ -#ifndef MINIZ_NO_ZLIB_COMPATIBLE_NAMES -typedef unsigned char Byte; -typedef unsigned int uInt; -typedef mz_ulong uLong; -typedef Byte Bytef; -typedef uInt uIntf; -typedef char charf; -typedef int intf; -typedef void *voidpf; -typedef uLong uLongf; -typedef void *voidp; -typedef void *const voidpc; -#define Z_NULL 0 -#define Z_NO_FLUSH MZ_NO_FLUSH -#define Z_PARTIAL_FLUSH MZ_PARTIAL_FLUSH -#define Z_SYNC_FLUSH MZ_SYNC_FLUSH -#define Z_FULL_FLUSH MZ_FULL_FLUSH -#define Z_FINISH MZ_FINISH -#define Z_BLOCK MZ_BLOCK -#define Z_OK MZ_OK -#define Z_STREAM_END MZ_STREAM_END -#define Z_NEED_DICT MZ_NEED_DICT -#define Z_ERRNO MZ_ERRNO -#define Z_STREAM_ERROR MZ_STREAM_ERROR -#define Z_DATA_ERROR MZ_DATA_ERROR -#define Z_MEM_ERROR MZ_MEM_ERROR -#define Z_BUF_ERROR MZ_BUF_ERROR -#define Z_VERSION_ERROR MZ_VERSION_ERROR -#define Z_PARAM_ERROR MZ_PARAM_ERROR -#define Z_NO_COMPRESSION MZ_NO_COMPRESSION -#define Z_BEST_SPEED MZ_BEST_SPEED -#define Z_BEST_COMPRESSION MZ_BEST_COMPRESSION -#define Z_DEFAULT_COMPRESSION MZ_DEFAULT_COMPRESSION -#define Z_DEFAULT_STRATEGY MZ_DEFAULT_STRATEGY -#define Z_FILTERED MZ_FILTERED -#define Z_HUFFMAN_ONLY MZ_HUFFMAN_ONLY -#define Z_RLE MZ_RLE -#define Z_FIXED MZ_FIXED -#define Z_DEFLATED MZ_DEFLATED -#define Z_DEFAULT_WINDOW_BITS MZ_DEFAULT_WINDOW_BITS -#define alloc_func mz_alloc_func -#define free_func mz_free_func -#define internal_state mz_internal_state -#define z_stream mz_stream -#define deflateInit mz_deflateInit -#define deflateInit2 mz_deflateInit2 -#define deflateReset mz_deflateReset -#define deflate mz_deflate -#define deflateEnd mz_deflateEnd -#define deflateBound mz_deflateBound -#define compress mz_compress -#define compress2 mz_compress2 -#define compressBound mz_compressBound -#define inflateInit mz_inflateInit -#define inflateInit2 mz_inflateInit2 -#define inflateReset mz_inflateReset -#define inflate mz_inflate -#define inflateEnd mz_inflateEnd -#define uncompress mz_uncompress -#define uncompress2 mz_uncompress2 -#define crc32 mz_crc32 -#define adler32 mz_adler32 -#define MAX_WBITS 15 -#define MAX_MEM_LEVEL 9 -#define zError mz_error -#define ZLIB_VERSION MZ_VERSION -#define ZLIB_VERNUM MZ_VERNUM -#define ZLIB_VER_MAJOR MZ_VER_MAJOR -#define ZLIB_VER_MINOR MZ_VER_MINOR -#define ZLIB_VER_REVISION MZ_VER_REVISION -#define ZLIB_VER_SUBREVISION MZ_VER_SUBREVISION -#define zlibVersion mz_version -#define zlib_version mz_version() -#endif /* #ifndef MINIZ_NO_ZLIB_COMPATIBLE_NAMES */ - -#endif /* MINIZ_NO_ZLIB_APIS */ - -#ifdef __cplusplus -} -#endif - -#pragma once -#ifndef FIZZY_ZIP_WASM -#include -#endif -#include -#ifndef FIZZY_ZIP_WASM -#include -#include -#else -#include -extern void *memcpy(void *dest, const void *src, size_t n); -extern void *memset(void *dest, int c, size_t n); -extern void *memmove(void *dest, const void *src, size_t n); -#endif - -/* ------------------- Types and macros */ -typedef unsigned char mz_uint8; -typedef signed short mz_int16; -typedef unsigned short mz_uint16; -typedef unsigned int mz_uint32; -typedef unsigned int mz_uint; -typedef int64_t mz_int64; -typedef uint64_t mz_uint64; -typedef int mz_bool; - -#define MZ_FALSE (0) -#define MZ_TRUE (1) - -/* Works around MSVC's spammy "warning C4127: conditional expression is - * constant" message. */ -#ifdef _MSC_VER -#define MZ_MACRO_END while (0, 0) -#else -#define MZ_MACRO_END while (0) -#endif - -#ifdef MINIZ_NO_STDIO -#define MZ_FILE void * -#else -#include -#define MZ_FILE FILE -#endif /* #ifdef MINIZ_NO_STDIO */ - -#ifdef MINIZ_NO_TIME -typedef struct mz_dummy_time_t_tag { - int m_dummy; -} mz_dummy_time_t; -#define MZ_TIME_T mz_dummy_time_t -#else -#define MZ_TIME_T time_t -#endif - -#define MZ_ASSERT(x) assert(x) - -#ifdef MINIZ_NO_MALLOC -#define MZ_MALLOC(x) NULL -#define MZ_FREE(x) (void)x, ((void)0) -#define MZ_REALLOC(p, x) NULL -#else -#ifndef MZ_MALLOC -#define MZ_MALLOC(x) malloc(x) -#endif -#ifndef MZ_FREE -#define MZ_FREE(x) free(x) -#endif -#ifndef MZ_REALLOC -#define MZ_REALLOC(p, x) realloc(p, x) -#endif -#endif - -#define MZ_MAX(a, b) (((a) > (b)) ? (a) : (b)) -#define MZ_MIN(a, b) (((a) < (b)) ? (a) : (b)) -#define MZ_CLEAR_OBJ(obj) memset(&(obj), 0, sizeof(obj)) - -#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN -#define MZ_READ_LE16(p) *((const mz_uint16 *)(p)) -#define MZ_READ_LE32(p) *((const mz_uint32 *)(p)) -#else -#define MZ_READ_LE16(p) \ - ((mz_uint32)(((const mz_uint8 *)(p))[0]) | \ - ((mz_uint32)(((const mz_uint8 *)(p))[1]) << 8U)) -#define MZ_READ_LE32(p) \ - ((mz_uint32)(((const mz_uint8 *)(p))[0]) | \ - ((mz_uint32)(((const mz_uint8 *)(p))[1]) << 8U) | \ - ((mz_uint32)(((const mz_uint8 *)(p))[2]) << 16U) | \ - ((mz_uint32)(((const mz_uint8 *)(p))[3]) << 24U)) -#endif - -#define MZ_READ_LE64(p) \ - (((mz_uint64)MZ_READ_LE32(p)) | \ - (((mz_uint64)MZ_READ_LE32((const mz_uint8 *)(p) + sizeof(mz_uint32))) \ - << 32U)) - -#ifdef _MSC_VER -#define MZ_FORCEINLINE __forceinline -#elif defined(__GNUC__) -#define MZ_FORCEINLINE __inline__ __attribute__((__always_inline__)) -#else -#define MZ_FORCEINLINE inline -#endif - -#ifdef __cplusplus -extern "C" { -#endif - -extern MINIZ_EXPORT void *miniz_def_alloc_func(void *opaque, size_t items, - size_t size); -extern MINIZ_EXPORT void miniz_def_free_func(void *opaque, void *address); -extern MINIZ_EXPORT void *miniz_def_realloc_func(void *opaque, void *address, - size_t items, size_t size); - -#define MZ_UINT16_MAX (0xFFFFU) -#define MZ_UINT32_MAX (0xFFFFFFFFU) - -#ifdef __cplusplus -} -#endif -#pragma once - -#ifdef __cplusplus -extern "C" { -#endif -/* ------------------- Low-level Compression API Definitions */ - -/* Set TDEFL_LESS_MEMORY to 1 to use less memory (compression will be slightly - * slower, and raw/dynamic blocks will be output more frequently). */ -#define TDEFL_LESS_MEMORY 0 - -/* tdefl_init() compression flags logically OR'd together (low 12 bits contain - * the max. number of probes per dictionary search): */ -/* TDEFL_DEFAULT_MAX_PROBES: The compressor defaults to 128 dictionary probes - * per dictionary search. 0=Huffman only, 1=Huffman+LZ (fastest/crap - * compression), 4095=Huffman+LZ (slowest/best compression). */ -enum { - TDEFL_HUFFMAN_ONLY = 0, - TDEFL_DEFAULT_MAX_PROBES = 128, - TDEFL_MAX_PROBES_MASK = 0xFFF -}; - -/* TDEFL_WRITE_ZLIB_HEADER: If set, the compressor outputs a zlib header before - * the deflate data, and the Adler-32 of the source data at the end. Otherwise, - * you'll get raw deflate data. */ -/* TDEFL_COMPUTE_ADLER32: Always compute the adler-32 of the input data (even - * when not writing zlib headers). */ -/* TDEFL_GREEDY_PARSING_FLAG: Set to use faster greedy parsing, instead of more - * efficient lazy parsing. */ -/* TDEFL_NONDETERMINISTIC_PARSING_FLAG: Enable to decrease the compressor's - * initialization time to the minimum, but the output may vary from run to run - * given the same input (depending on the contents of memory). */ -/* TDEFL_RLE_MATCHES: Only look for RLE matches (matches with a distance of 1) - */ -/* TDEFL_FILTER_MATCHES: Discards matches <= 5 chars if enabled. */ -/* TDEFL_FORCE_ALL_STATIC_BLOCKS: Disable usage of optimized Huffman tables. */ -/* TDEFL_FORCE_ALL_RAW_BLOCKS: Only use raw (uncompressed) deflate blocks. */ -/* The low 12 bits are reserved to control the max # of hash probes per - * dictionary lookup (see TDEFL_MAX_PROBES_MASK). */ -enum { - TDEFL_WRITE_ZLIB_HEADER = 0x01000, - TDEFL_COMPUTE_ADLER32 = 0x02000, - TDEFL_GREEDY_PARSING_FLAG = 0x04000, - TDEFL_NONDETERMINISTIC_PARSING_FLAG = 0x08000, - TDEFL_RLE_MATCHES = 0x10000, - TDEFL_FILTER_MATCHES = 0x20000, - TDEFL_FORCE_ALL_STATIC_BLOCKS = 0x40000, - TDEFL_FORCE_ALL_RAW_BLOCKS = 0x80000 -}; - -/* High level compression functions: */ -/* tdefl_compress_mem_to_heap() compresses a block in memory to a heap block - * allocated via malloc(). */ -/* On entry: */ -/* pSrc_buf, src_buf_len: Pointer and size of source block to compress. */ -/* flags: The max match finder probes (default is 128) logically OR'd against - * the above flags. Higher probes are slower but improve compression. */ -/* On return: */ -/* Function returns a pointer to the compressed data, or NULL on failure. */ -/* *pOut_len will be set to the compressed data's size, which could be larger - * than src_buf_len on uncompressible data. */ -/* The caller must free() the returned block when it's no longer needed. */ -MINIZ_EXPORT void *tdefl_compress_mem_to_heap(const void *pSrc_buf, - size_t src_buf_len, - size_t *pOut_len, int flags); - -/* tdefl_compress_mem_to_mem() compresses a block in memory to another block in - * memory. */ -/* Returns 0 on failure. */ -MINIZ_EXPORT size_t tdefl_compress_mem_to_mem(void *pOut_buf, - size_t out_buf_len, - const void *pSrc_buf, - size_t src_buf_len, int flags); - -/* Compresses an image to a compressed PNG file in memory. */ -/* On entry: */ -/* pImage, w, h, and num_chans describe the image to compress. num_chans may be - * 1, 2, 3, or 4. */ -/* The image pitch in bytes per scanline will be w*num_chans. The leftmost - * pixel on the top scanline is stored first in memory. */ -/* level may range from [0,10], use MZ_NO_COMPRESSION, MZ_BEST_SPEED, - * MZ_BEST_COMPRESSION, etc. or a decent default is MZ_DEFAULT_LEVEL */ -/* If flip is true, the image will be flipped on the Y axis (useful for OpenGL - * apps). */ -/* On return: */ -/* Function returns a pointer to the compressed data, or NULL on failure. */ -/* *pLen_out will be set to the size of the PNG image file. */ -/* The caller must mz_free() the returned heap block (which will typically be - * larger than *pLen_out) when it's no longer needed. */ -MINIZ_EXPORT void * -tdefl_write_image_to_png_file_in_memory_ex(const void *pImage, int w, int h, - int num_chans, size_t *pLen_out, - mz_uint level, mz_bool flip); -MINIZ_EXPORT void *tdefl_write_image_to_png_file_in_memory(const void *pImage, - int w, int h, - int num_chans, - size_t *pLen_out); - -/* Output stream interface. The compressor uses this interface to write - * compressed data. It'll typically be called TDEFL_OUT_BUF_SIZE at a time. */ -typedef mz_bool (*tdefl_put_buf_func_ptr)(const void *pBuf, int len, - void *pUser); - -/* tdefl_compress_mem_to_output() compresses a block to an output stream. The - * above helpers use this function internally. */ -MINIZ_EXPORT mz_bool tdefl_compress_mem_to_output( - const void *pBuf, size_t buf_len, tdefl_put_buf_func_ptr pPut_buf_func, - void *pPut_buf_user, int flags); - -enum { - TDEFL_MAX_HUFF_TABLES = 3, - TDEFL_MAX_HUFF_SYMBOLS_0 = 288, - TDEFL_MAX_HUFF_SYMBOLS_1 = 32, - TDEFL_MAX_HUFF_SYMBOLS_2 = 19, - TDEFL_LZ_DICT_SIZE = 32768, - TDEFL_LZ_DICT_SIZE_MASK = TDEFL_LZ_DICT_SIZE - 1, - TDEFL_MIN_MATCH_LEN = 3, - TDEFL_MAX_MATCH_LEN = 258 -}; - -/* TDEFL_OUT_BUF_SIZE MUST be large enough to hold a single entire compressed - * output block (using static/fixed Huffman codes). */ -#if TDEFL_LESS_MEMORY -enum { - TDEFL_LZ_CODE_BUF_SIZE = 24 * 1024, - TDEFL_OUT_BUF_SIZE = (TDEFL_LZ_CODE_BUF_SIZE * 13) / 10, - TDEFL_MAX_HUFF_SYMBOLS = 288, - TDEFL_LZ_HASH_BITS = 12, - TDEFL_LEVEL1_HASH_SIZE_MASK = 4095, - TDEFL_LZ_HASH_SHIFT = (TDEFL_LZ_HASH_BITS + 2) / 3, - TDEFL_LZ_HASH_SIZE = 1 << TDEFL_LZ_HASH_BITS -}; -#else -enum { - TDEFL_LZ_CODE_BUF_SIZE = 64 * 1024, - TDEFL_OUT_BUF_SIZE = (TDEFL_LZ_CODE_BUF_SIZE * 13) / 10, - TDEFL_MAX_HUFF_SYMBOLS = 288, - TDEFL_LZ_HASH_BITS = 15, - TDEFL_LEVEL1_HASH_SIZE_MASK = 4095, - TDEFL_LZ_HASH_SHIFT = (TDEFL_LZ_HASH_BITS + 2) / 3, - TDEFL_LZ_HASH_SIZE = 1 << TDEFL_LZ_HASH_BITS -}; -#endif - -/* The low-level tdefl functions below may be used directly if the above helper - * functions aren't flexible enough. The low-level functions don't make any heap - * allocations, unlike the above helper functions. */ -typedef enum { - TDEFL_STATUS_BAD_PARAM = -2, - TDEFL_STATUS_PUT_BUF_FAILED = -1, - TDEFL_STATUS_OKAY = 0, - TDEFL_STATUS_DONE = 1 -} tdefl_status; - -/* Must map to MZ_NO_FLUSH, MZ_SYNC_FLUSH, etc. enums */ -typedef enum { - TDEFL_NO_FLUSH = 0, - TDEFL_SYNC_FLUSH = 2, - TDEFL_FULL_FLUSH = 3, - TDEFL_FINISH = 4 -} tdefl_flush; - -/* tdefl's compression state structure. */ -typedef struct { - tdefl_put_buf_func_ptr m_pPut_buf_func; - void *m_pPut_buf_user; - mz_uint m_flags, m_max_probes[2]; - int m_greedy_parsing; - mz_uint m_adler32, m_lookahead_pos, m_lookahead_size, m_dict_size; - mz_uint8 *m_pLZ_code_buf, *m_pLZ_flags, *m_pOutput_buf, *m_pOutput_buf_end; - mz_uint m_num_flags_left, m_total_lz_bytes, m_lz_code_buf_dict_pos, m_bits_in, - m_bit_buffer; - mz_uint m_saved_match_dist, m_saved_match_len, m_saved_lit, - m_output_flush_ofs, m_output_flush_remaining, m_finished, m_block_index, - m_wants_to_finish; - tdefl_status m_prev_return_status; - const void *m_pIn_buf; - void *m_pOut_buf; - size_t *m_pIn_buf_size, *m_pOut_buf_size; - tdefl_flush m_flush; - const mz_uint8 *m_pSrc; - size_t m_src_buf_left, m_out_buf_ofs; - mz_uint8 m_dict[TDEFL_LZ_DICT_SIZE + TDEFL_MAX_MATCH_LEN - 1]; - mz_uint16 m_huff_count[TDEFL_MAX_HUFF_TABLES][TDEFL_MAX_HUFF_SYMBOLS]; - mz_uint16 m_huff_codes[TDEFL_MAX_HUFF_TABLES][TDEFL_MAX_HUFF_SYMBOLS]; - mz_uint8 m_huff_code_sizes[TDEFL_MAX_HUFF_TABLES][TDEFL_MAX_HUFF_SYMBOLS]; - mz_uint8 m_lz_code_buf[TDEFL_LZ_CODE_BUF_SIZE]; - mz_uint16 m_next[TDEFL_LZ_DICT_SIZE]; - mz_uint16 m_hash[TDEFL_LZ_HASH_SIZE]; - mz_uint8 m_output_buf[TDEFL_OUT_BUF_SIZE]; -} tdefl_compressor; - -/* Initializes the compressor. */ -/* There is no corresponding deinit() function because the tdefl API's do not - * dynamically allocate memory. */ -/* pBut_buf_func: If NULL, output data will be supplied to the specified - * callback. In this case, the user should call the tdefl_compress_buffer() API - * for compression. */ -/* If pBut_buf_func is NULL the user should always call the tdefl_compress() - * API. */ -/* flags: See the above enums (TDEFL_HUFFMAN_ONLY, TDEFL_WRITE_ZLIB_HEADER, - * etc.) */ -MINIZ_EXPORT tdefl_status tdefl_init(tdefl_compressor *d, - tdefl_put_buf_func_ptr pPut_buf_func, - void *pPut_buf_user, int flags); - -/* Compresses a block of data, consuming as much of the specified input buffer - * as possible, and writing as much compressed data to the specified output - * buffer as possible. */ -MINIZ_EXPORT tdefl_status tdefl_compress(tdefl_compressor *d, - const void *pIn_buf, - size_t *pIn_buf_size, void *pOut_buf, - size_t *pOut_buf_size, - tdefl_flush flush); - -/* tdefl_compress_buffer() is only usable when the tdefl_init() is called with a - * non-NULL tdefl_put_buf_func_ptr. */ -/* tdefl_compress_buffer() always consumes the entire input buffer. */ -MINIZ_EXPORT tdefl_status tdefl_compress_buffer(tdefl_compressor *d, - const void *pIn_buf, - size_t in_buf_size, - tdefl_flush flush); - -MINIZ_EXPORT tdefl_status tdefl_get_prev_return_status(tdefl_compressor *d); -MINIZ_EXPORT mz_uint32 tdefl_get_adler32(tdefl_compressor *d); - -/* Create tdefl_compress() flags given zlib-style compression parameters. */ -/* level may range from [0,10] (where 10 is absolute max compression, but may be - * much slower on some files) */ -/* window_bits may be -15 (raw deflate) or 15 (zlib) */ -/* strategy may be either MZ_DEFAULT_STRATEGY, MZ_FILTERED, MZ_HUFFMAN_ONLY, - * MZ_RLE, or MZ_FIXED */ -MINIZ_EXPORT mz_uint tdefl_create_comp_flags_from_zip_params(int level, - int window_bits, - int strategy); - -#ifndef MINIZ_NO_MALLOC -/* Allocate the tdefl_compressor structure in C so that */ -/* non-C language bindings to tdefl_ API don't need to worry about */ -/* structure size and allocation mechanism. */ -MINIZ_EXPORT tdefl_compressor *tdefl_compressor_alloc(void); -MINIZ_EXPORT void tdefl_compressor_free(tdefl_compressor *pComp); -#endif - -#ifdef __cplusplus -} -#endif -#pragma once - -/* ------------------- Low-level Decompression API Definitions */ - -#ifdef __cplusplus -extern "C" { -#endif -/* Decompression flags used by tinfl_decompress(). */ -/* TINFL_FLAG_PARSE_ZLIB_HEADER: If set, the input has a valid zlib header and - * ends with an adler32 checksum (it's a valid zlib stream). Otherwise, the - * input is a raw deflate stream. */ -/* TINFL_FLAG_HAS_MORE_INPUT: If set, there are more input bytes available - * beyond the end of the supplied input buffer. If clear, the input buffer - * contains all remaining input. */ -/* TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF: If set, the output buffer is large - * enough to hold the entire decompressed stream. If clear, the output buffer is - * at least the size of the dictionary (typically 32KB). */ -/* TINFL_FLAG_COMPUTE_ADLER32: Force adler-32 checksum computation of the - * decompressed bytes. */ -enum { - TINFL_FLAG_PARSE_ZLIB_HEADER = 1, - TINFL_FLAG_HAS_MORE_INPUT = 2, - TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF = 4, - TINFL_FLAG_COMPUTE_ADLER32 = 8 -}; - -/* High level decompression functions: */ -/* tinfl_decompress_mem_to_heap() decompresses a block in memory to a heap block - * allocated via malloc(). */ -/* On entry: */ -/* pSrc_buf, src_buf_len: Pointer and size of the Deflate or zlib source data - * to decompress. */ -/* On return: */ -/* Function returns a pointer to the decompressed data, or NULL on failure. */ -/* *pOut_len will be set to the decompressed data's size, which could be larger - * than src_buf_len on uncompressible data. */ -/* The caller must call mz_free() on the returned block when it's no longer - * needed. */ -MINIZ_EXPORT void *tinfl_decompress_mem_to_heap(const void *pSrc_buf, - size_t src_buf_len, - size_t *pOut_len, int flags); - -/* tinfl_decompress_mem_to_mem() decompresses a block in memory to another block - * in memory. */ -/* Returns TINFL_DECOMPRESS_MEM_TO_MEM_FAILED on failure, or the number of bytes - * written on success. */ -#define TINFL_DECOMPRESS_MEM_TO_MEM_FAILED ((size_t)(-1)) -MINIZ_EXPORT size_t tinfl_decompress_mem_to_mem(void *pOut_buf, - size_t out_buf_len, - const void *pSrc_buf, - size_t src_buf_len, int flags); - -/* tinfl_decompress_mem_to_callback() decompresses a block in memory to an - * internal 32KB buffer, and a user provided callback function will be called to - * flush the buffer. */ -/* Returns 1 on success or 0 on failure. */ -typedef int (*tinfl_put_buf_func_ptr)(const void *pBuf, int len, void *pUser); -MINIZ_EXPORT int -tinfl_decompress_mem_to_callback(const void *pIn_buf, size_t *pIn_buf_size, - tinfl_put_buf_func_ptr pPut_buf_func, - void *pPut_buf_user, int flags); - -struct tinfl_decompressor_tag; -typedef struct tinfl_decompressor_tag tinfl_decompressor; - -#ifndef MINIZ_NO_MALLOC -/* Allocate the tinfl_decompressor structure in C so that */ -/* non-C language bindings to tinfl_ API don't need to worry about */ -/* structure size and allocation mechanism. */ -MINIZ_EXPORT tinfl_decompressor *tinfl_decompressor_alloc(void); -MINIZ_EXPORT void tinfl_decompressor_free(tinfl_decompressor *pDecomp); -#endif - -/* Max size of LZ dictionary. */ -#define TINFL_LZ_DICT_SIZE 32768 - -/* Return status. */ -typedef enum { - /* This flags indicates the inflator needs 1 or more input bytes to make - forward progress, but the caller is indicating that no more are available. - The compressed data */ - /* is probably corrupted. If you call the inflator again with more bytes it'll - try to continue processing the input but this is a BAD sign (either the - data is corrupted or you called it incorrectly). */ - /* If you call it again with no input you'll just get - TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS again. */ - TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS = -4, - - /* This flag indicates that one or more of the input parameters was obviously - bogus. (You can try calling it again, but if you get this error the calling - code is wrong.) */ - TINFL_STATUS_BAD_PARAM = -3, - - /* This flags indicate the inflator is finished but the adler32 check of the - uncompressed data didn't match. If you call it again it'll return - TINFL_STATUS_DONE. */ - TINFL_STATUS_ADLER32_MISMATCH = -2, - - /* This flags indicate the inflator has somehow failed (bad code, corrupted - input, etc.). If you call it again without resetting via tinfl_init() it - it'll just keep on returning the same status failure code. */ - TINFL_STATUS_FAILED = -1, - - /* Any status code less than TINFL_STATUS_DONE must indicate a failure. */ - - /* This flag indicates the inflator has returned every byte of uncompressed - data that it can, has consumed every byte that it needed, has successfully - reached the end of the deflate stream, and */ - /* if zlib headers and adler32 checking enabled that it has successfully - checked the uncompressed data's adler32. If you call it again you'll just - get TINFL_STATUS_DONE over and over again. */ - TINFL_STATUS_DONE = 0, - - /* This flag indicates the inflator MUST have more input data (even 1 byte) - before it can make any more forward progress, or you need to clear the - TINFL_FLAG_HAS_MORE_INPUT */ - /* flag on the next call if you don't have any more source data. If the source - data was somehow corrupted it's also possible (but unlikely) for the - inflator to keep on demanding input to */ - /* proceed, so be sure to properly set the TINFL_FLAG_HAS_MORE_INPUT flag. */ - TINFL_STATUS_NEEDS_MORE_INPUT = 1, - - /* This flag indicates the inflator definitely has 1 or more bytes of - uncompressed data available, but it cannot write this data into the output - buffer. */ - /* Note if the source compressed data was corrupted it's possible for the - inflator to return a lot of uncompressed data to the caller. I've been - assuming you know how much uncompressed data to expect */ - /* (either exact or worst case) and will stop calling the inflator and fail - after receiving too much. In pure streaming scenarios where you have no - idea how many bytes to expect this may not be possible */ - /* so I may need to add some code to address this. */ - TINFL_STATUS_HAS_MORE_OUTPUT = 2 -} tinfl_status; - -/* Initializes the decompressor to its initial state. */ -#define tinfl_init(r) \ - do { \ - (r)->m_state = 0; \ - } \ - MZ_MACRO_END -#define tinfl_get_adler32(r) (r)->m_check_adler32 - -/* Main low-level decompressor coroutine function. This is the only function - * actually needed for decompression. All the other functions are just - * high-level helpers for improved usability. */ -/* This is a universal API, i.e. it can be used as a building block to build any - * desired higher level decompression API. In the limit case, it can be called - * once per every byte input or output. */ -MINIZ_EXPORT tinfl_status tinfl_decompress( - tinfl_decompressor *r, const mz_uint8 *pIn_buf_next, size_t *pIn_buf_size, - mz_uint8 *pOut_buf_start, mz_uint8 *pOut_buf_next, size_t *pOut_buf_size, - const mz_uint32 decomp_flags); - -/* Internal/private bits follow. */ -enum { - TINFL_MAX_HUFF_TABLES = 3, - TINFL_MAX_HUFF_SYMBOLS_0 = 288, - TINFL_MAX_HUFF_SYMBOLS_1 = 32, - TINFL_MAX_HUFF_SYMBOLS_2 = 19, - TINFL_FAST_LOOKUP_BITS = 10, - TINFL_FAST_LOOKUP_SIZE = 1 << TINFL_FAST_LOOKUP_BITS -}; - -typedef struct { - mz_uint8 m_code_size[TINFL_MAX_HUFF_SYMBOLS_0]; - mz_int16 m_look_up[TINFL_FAST_LOOKUP_SIZE], - m_tree[TINFL_MAX_HUFF_SYMBOLS_0 * 2]; -} tinfl_huff_table; - -#if MINIZ_HAS_64BIT_REGISTERS -#define TINFL_USE_64BIT_BITBUF 1 -#else -#define TINFL_USE_64BIT_BITBUF 0 -#endif - -#if TINFL_USE_64BIT_BITBUF -typedef mz_uint64 tinfl_bit_buf_t; -#define TINFL_BITBUF_SIZE (64) -#else -typedef mz_uint32 tinfl_bit_buf_t; -#define TINFL_BITBUF_SIZE (32) -#endif - -struct tinfl_decompressor_tag { - mz_uint32 m_state, m_num_bits, m_zhdr0, m_zhdr1, m_z_adler32, m_final, m_type, - m_check_adler32, m_dist, m_counter, m_num_extra, - m_table_sizes[TINFL_MAX_HUFF_TABLES]; - tinfl_bit_buf_t m_bit_buf; - size_t m_dist_from_out_buf_start; - tinfl_huff_table m_tables[TINFL_MAX_HUFF_TABLES]; - mz_uint8 m_raw_header[4], - m_len_codes[TINFL_MAX_HUFF_SYMBOLS_0 + TINFL_MAX_HUFF_SYMBOLS_1 + 137]; -}; - -#ifdef __cplusplus -} -#endif - -#pragma once - -/* ------------------- ZIP archive reading/writing */ - -#ifndef MINIZ_NO_ARCHIVE_APIS - -#ifdef __cplusplus -extern "C" { -#endif - -enum { - /* Note: These enums can be reduced as needed to save memory or stack space - - they are pretty conservative. */ - MZ_ZIP_MAX_IO_BUF_SIZE = 8 * 1024, - MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE = 512, - MZ_ZIP_MAX_ARCHIVE_FILE_COMMENT_SIZE = 512 -}; - -typedef struct { - /* Central directory file index. */ - mz_uint32 m_file_index; - - /* Byte offset of this entry in the archive's central directory. Note we - * currently only support up to UINT_MAX or less bytes in the central dir. */ - mz_uint64 m_central_dir_ofs; - - /* These fields are copied directly from the zip's central dir. */ - mz_uint16 m_version_made_by; - mz_uint16 m_version_needed; - mz_uint16 m_bit_flag; - mz_uint16 m_method; - -#ifndef MINIZ_NO_TIME - MZ_TIME_T m_time; -#endif - - /* CRC-32 of uncompressed data. */ - mz_uint32 m_crc32; - - /* File's compressed size. */ - mz_uint64 m_comp_size; - - /* File's uncompressed size. Note, I've seen some old archives where directory - * entries had 512 bytes for their uncompressed sizes, but when you try to - * unpack them you actually get 0 bytes. */ - mz_uint64 m_uncomp_size; - - /* Zip internal and external file attributes. */ - mz_uint16 m_internal_attr; - mz_uint32 m_external_attr; - - /* Entry's local header file offset in bytes. */ - mz_uint64 m_local_header_ofs; - - /* Size of comment in bytes. */ - mz_uint32 m_comment_size; - - /* MZ_TRUE if the entry appears to be a directory. */ - mz_bool m_is_directory; - - /* MZ_TRUE if the entry uses encryption/strong encryption (which miniz_zip - * doesn't support) */ - mz_bool m_is_encrypted; - - /* MZ_TRUE if the file is not encrypted, a patch file, and if it uses a - * compression method we support. */ - mz_bool m_is_supported; - - /* Filename. If string ends in '/' it's a subdirectory entry. */ - /* Guaranteed to be zero terminated, may be truncated to fit. */ - char m_filename[MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE]; - - /* Comment field. */ - /* Guaranteed to be zero terminated, may be truncated to fit. */ - char m_comment[MZ_ZIP_MAX_ARCHIVE_FILE_COMMENT_SIZE]; - -} mz_zip_archive_file_stat; - -typedef size_t (*mz_file_read_func)(void *pOpaque, mz_uint64 file_ofs, - void *pBuf, size_t n); -typedef size_t (*mz_file_write_func)(void *pOpaque, mz_uint64 file_ofs, - const void *pBuf, size_t n); -typedef mz_bool (*mz_file_needs_keepalive)(void *pOpaque); - -struct mz_zip_internal_state_tag; -typedef struct mz_zip_internal_state_tag mz_zip_internal_state; - -typedef enum { - MZ_ZIP_MODE_INVALID = 0, - MZ_ZIP_MODE_READING = 1, - MZ_ZIP_MODE_WRITING = 2, - MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED = 3 -} mz_zip_mode; - -typedef enum { - MZ_ZIP_FLAG_CASE_SENSITIVE = 0x0100, - MZ_ZIP_FLAG_IGNORE_PATH = 0x0200, - MZ_ZIP_FLAG_COMPRESSED_DATA = 0x0400, - MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY = 0x0800, - MZ_ZIP_FLAG_VALIDATE_LOCATE_FILE_FLAG = - 0x1000, /* if enabled, mz_zip_reader_locate_file() will be called on each - file as its validated to ensure the func finds the file in the - central dir (intended for testing) */ - MZ_ZIP_FLAG_VALIDATE_HEADERS_ONLY = - 0x2000, /* validate the local headers, but don't decompress the entire - file and check the crc32 */ - MZ_ZIP_FLAG_WRITE_ZIP64 = - 0x4000, /* always use the zip64 file format, instead of the original zip - file format with automatic switch to zip64. Use as flags - parameter with mz_zip_writer_init*_v2 */ - MZ_ZIP_FLAG_WRITE_ALLOW_READING = 0x8000, - MZ_ZIP_FLAG_ASCII_FILENAME = 0x10000, - /*After adding a compressed file, seek back - to local file header and set the correct sizes*/ - MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE = 0x20000 -} mz_zip_flags; - -typedef enum { - MZ_ZIP_TYPE_INVALID = 0, - MZ_ZIP_TYPE_USER, - MZ_ZIP_TYPE_MEMORY, - MZ_ZIP_TYPE_HEAP, - MZ_ZIP_TYPE_FILE, - MZ_ZIP_TYPE_CFILE, - MZ_ZIP_TOTAL_TYPES -} mz_zip_type; - -/* miniz error codes. Be sure to update mz_zip_get_error_string() if you add or - * modify this enum. */ -typedef enum { - MZ_ZIP_NO_ERROR = 0, - MZ_ZIP_UNDEFINED_ERROR, - MZ_ZIP_TOO_MANY_FILES, - MZ_ZIP_FILE_TOO_LARGE, - MZ_ZIP_UNSUPPORTED_METHOD, - MZ_ZIP_UNSUPPORTED_ENCRYPTION, - MZ_ZIP_UNSUPPORTED_FEATURE, - MZ_ZIP_FAILED_FINDING_CENTRAL_DIR, - MZ_ZIP_NOT_AN_ARCHIVE, - MZ_ZIP_INVALID_HEADER_OR_CORRUPTED, - MZ_ZIP_UNSUPPORTED_MULTIDISK, - MZ_ZIP_DECOMPRESSION_FAILED, - MZ_ZIP_COMPRESSION_FAILED, - MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE, - MZ_ZIP_CRC_CHECK_FAILED, - MZ_ZIP_UNSUPPORTED_CDIR_SIZE, - MZ_ZIP_ALLOC_FAILED, - MZ_ZIP_FILE_OPEN_FAILED, - MZ_ZIP_FILE_CREATE_FAILED, - MZ_ZIP_FILE_WRITE_FAILED, - MZ_ZIP_FILE_READ_FAILED, - MZ_ZIP_FILE_CLOSE_FAILED, - MZ_ZIP_FILE_SEEK_FAILED, - MZ_ZIP_FILE_STAT_FAILED, - MZ_ZIP_INVALID_PARAMETER, - MZ_ZIP_INVALID_FILENAME, - MZ_ZIP_BUF_TOO_SMALL, - MZ_ZIP_INTERNAL_ERROR, - MZ_ZIP_FILE_NOT_FOUND, - MZ_ZIP_ARCHIVE_TOO_LARGE, - MZ_ZIP_VALIDATION_FAILED, - MZ_ZIP_WRITE_CALLBACK_FAILED, - MZ_ZIP_TOTAL_ERRORS -} mz_zip_error; - -typedef struct { - mz_uint64 m_archive_size; - mz_uint64 m_central_directory_file_ofs; - - /* We only support up to UINT32_MAX files in zip64 mode. */ - mz_uint32 m_total_files; - mz_zip_mode m_zip_mode; - mz_zip_type m_zip_type; - mz_zip_error m_last_error; - - mz_uint64 m_file_offset_alignment; - - mz_alloc_func m_pAlloc; - mz_free_func m_pFree; - mz_realloc_func m_pRealloc; - void *m_pAlloc_opaque; - - mz_file_read_func m_pRead; - mz_file_write_func m_pWrite; - mz_file_needs_keepalive m_pNeeds_keepalive; - void *m_pIO_opaque; - - mz_zip_internal_state *m_pState; - -} mz_zip_archive; - -typedef struct { - mz_zip_archive *pZip; - mz_uint flags; - - int status; -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - mz_uint file_crc32; -#endif - mz_uint64 read_buf_size, read_buf_ofs, read_buf_avail, comp_remaining, - out_buf_ofs, cur_file_ofs; - mz_zip_archive_file_stat file_stat; - void *pRead_buf; - void *pWrite_buf; - - size_t out_blk_remain; - - tinfl_decompressor inflator; - -} mz_zip_reader_extract_iter_state; - -/* -------- ZIP reading */ - -/* Inits a ZIP archive reader. */ -/* These functions read and validate the archive's central directory. */ -MINIZ_EXPORT mz_bool mz_zip_reader_init(mz_zip_archive *pZip, mz_uint64 size, - mz_uint flags); - -MINIZ_EXPORT mz_bool mz_zip_reader_init_mem(mz_zip_archive *pZip, - const void *pMem, size_t size, - mz_uint flags); - -#ifndef MINIZ_NO_STDIO -/* Read a archive from a disk file. */ -/* file_start_ofs is the file offset where the archive actually begins, or 0. */ -/* actual_archive_size is the true total size of the archive, which may be - * smaller than the file's actual size on disk. If zero the entire file is - * treated as the archive. */ -MINIZ_EXPORT mz_bool mz_zip_reader_init_file(mz_zip_archive *pZip, - const char *pFilename, - mz_uint32 flags); -MINIZ_EXPORT mz_bool mz_zip_reader_init_file_v2(mz_zip_archive *pZip, - const char *pFilename, - mz_uint flags, - mz_uint64 file_start_ofs, - mz_uint64 archive_size); -MINIZ_EXPORT mz_bool mz_zip_reader_init_file_v2_rpb(mz_zip_archive *pZip, - const char *pFilename, - mz_uint flags, - mz_uint64 file_start_ofs, - mz_uint64 archive_size); - -/* Read an archive from an already opened FILE, beginning at the current file - * position. */ -/* The archive is assumed to be archive_size bytes long. If archive_size is 0, - * then the entire rest of the file is assumed to contain the archive. */ -/* The FILE will NOT be closed when mz_zip_reader_end() is called. */ -MINIZ_EXPORT mz_bool mz_zip_reader_init_cfile(mz_zip_archive *pZip, - MZ_FILE *pFile, - mz_uint64 archive_size, - mz_uint flags); -#endif - -/* Ends archive reading, freeing all allocations, and closing the input archive - * file if mz_zip_reader_init_file() was used. */ -MINIZ_EXPORT mz_bool mz_zip_reader_end(mz_zip_archive *pZip); - -/* -------- ZIP reading or writing */ - -/* Clears a mz_zip_archive struct to all zeros. */ -/* Important: This must be done before passing the struct to any mz_zip - * functions. */ -MINIZ_EXPORT void mz_zip_zero_struct(mz_zip_archive *pZip); - -MINIZ_EXPORT mz_zip_mode mz_zip_get_mode(mz_zip_archive *pZip); -MINIZ_EXPORT mz_zip_type mz_zip_get_type(mz_zip_archive *pZip); - -/* Returns the total number of files in the archive. */ -MINIZ_EXPORT mz_uint mz_zip_reader_get_num_files(mz_zip_archive *pZip); - -MINIZ_EXPORT mz_uint64 mz_zip_get_archive_size(mz_zip_archive *pZip); -MINIZ_EXPORT mz_uint64 -mz_zip_get_archive_file_start_offset(mz_zip_archive *pZip); -MINIZ_EXPORT MZ_FILE *mz_zip_get_cfile(mz_zip_archive *pZip); - -/* Reads n bytes of raw archive data, starting at file offset file_ofs, to pBuf. - */ -MINIZ_EXPORT size_t mz_zip_read_archive_data(mz_zip_archive *pZip, - mz_uint64 file_ofs, void *pBuf, - size_t n); - -/* All mz_zip funcs set the m_last_error field in the mz_zip_archive struct. - * These functions retrieve/manipulate this field. */ -/* Note that the m_last_error functionality is not thread safe. */ -MINIZ_EXPORT mz_zip_error mz_zip_set_last_error(mz_zip_archive *pZip, - mz_zip_error err_num); -MINIZ_EXPORT mz_zip_error mz_zip_peek_last_error(mz_zip_archive *pZip); -MINIZ_EXPORT mz_zip_error mz_zip_clear_last_error(mz_zip_archive *pZip); -MINIZ_EXPORT mz_zip_error mz_zip_get_last_error(mz_zip_archive *pZip); -MINIZ_EXPORT const char *mz_zip_get_error_string(mz_zip_error mz_err); - -/* MZ_TRUE if the archive file entry is a directory entry. */ -MINIZ_EXPORT mz_bool mz_zip_reader_is_file_a_directory(mz_zip_archive *pZip, - mz_uint file_index); - -/* MZ_TRUE if the file is encrypted/strong encrypted. */ -MINIZ_EXPORT mz_bool mz_zip_reader_is_file_encrypted(mz_zip_archive *pZip, - mz_uint file_index); - -/* MZ_TRUE if the compression method is supported, and the file is not - * encrypted, and the file is not a compressed patch file. */ -MINIZ_EXPORT mz_bool mz_zip_reader_is_file_supported(mz_zip_archive *pZip, - mz_uint file_index); - -/* Retrieves the filename of an archive file entry. */ -/* Returns the number of bytes written to pFilename, or if filename_buf_size is - * 0 this function returns the number of bytes needed to fully store the - * filename. */ -MINIZ_EXPORT mz_uint mz_zip_reader_get_filename(mz_zip_archive *pZip, - mz_uint file_index, - char *pFilename, - mz_uint filename_buf_size); - -/* Attempts to locates a file in the archive's central directory. */ -/* Valid flags: MZ_ZIP_FLAG_CASE_SENSITIVE, MZ_ZIP_FLAG_IGNORE_PATH */ -/* Returns -1 if the file cannot be found. */ -MINIZ_EXPORT int mz_zip_reader_locate_file(mz_zip_archive *pZip, - const char *pName, - const char *pComment, mz_uint flags); -MINIZ_EXPORT mz_bool mz_zip_reader_locate_file_v2(mz_zip_archive *pZip, - const char *pName, - const char *pComment, - mz_uint flags, - mz_uint32 *file_index); - -/* Returns detailed information about an archive file entry. */ -MINIZ_EXPORT mz_bool mz_zip_reader_file_stat(mz_zip_archive *pZip, - mz_uint file_index, - mz_zip_archive_file_stat *pStat); - -/* MZ_TRUE if the file is in zip64 format. */ -/* A file is considered zip64 if it contained a zip64 end of central directory - * marker, or if it contained any zip64 extended file information fields in the - * central directory. */ -MINIZ_EXPORT mz_bool mz_zip_is_zip64(mz_zip_archive *pZip); - -/* Returns the total central directory size in bytes. */ -/* The current max supported size is <= MZ_UINT32_MAX. */ -MINIZ_EXPORT size_t mz_zip_get_central_dir_size(mz_zip_archive *pZip); - -/* Extracts a archive file to a memory buffer using no memory allocation. */ -/* There must be at least enough room on the stack to store the inflator's state - * (~34KB or so). */ -MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_mem_no_alloc( - mz_zip_archive *pZip, mz_uint file_index, void *pBuf, size_t buf_size, - mz_uint flags, void *pUser_read_buf, size_t user_read_buf_size); -MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_mem_no_alloc( - mz_zip_archive *pZip, const char *pFilename, void *pBuf, size_t buf_size, - mz_uint flags, void *pUser_read_buf, size_t user_read_buf_size); - -/* Extracts a archive file to a memory buffer. */ -MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_mem(mz_zip_archive *pZip, - mz_uint file_index, - void *pBuf, size_t buf_size, - mz_uint flags); -MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_mem(mz_zip_archive *pZip, - const char *pFilename, - void *pBuf, - size_t buf_size, - mz_uint flags); - -/* Extracts a archive file to a dynamically allocated heap buffer. */ -/* The memory will be allocated via the mz_zip_archive's alloc/realloc - * functions. */ -/* Returns NULL and sets the last error on failure. */ -MINIZ_EXPORT void *mz_zip_reader_extract_to_heap(mz_zip_archive *pZip, - mz_uint file_index, - size_t *pSize, mz_uint flags); -MINIZ_EXPORT void *mz_zip_reader_extract_file_to_heap(mz_zip_archive *pZip, - const char *pFilename, - size_t *pSize, - mz_uint flags); - -/* Extracts a archive file using a callback function to output the file's data. - */ -MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_callback( - mz_zip_archive *pZip, mz_uint file_index, mz_file_write_func pCallback, - void *pOpaque, mz_uint flags); -MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_callback( - mz_zip_archive *pZip, const char *pFilename, mz_file_write_func pCallback, - void *pOpaque, mz_uint flags); - -/* Extract a file iteratively */ -MINIZ_EXPORT mz_zip_reader_extract_iter_state * -mz_zip_reader_extract_iter_new(mz_zip_archive *pZip, mz_uint file_index, - mz_uint flags); -MINIZ_EXPORT mz_zip_reader_extract_iter_state * -mz_zip_reader_extract_file_iter_new(mz_zip_archive *pZip, const char *pFilename, - mz_uint flags); -MINIZ_EXPORT size_t mz_zip_reader_extract_iter_read( - mz_zip_reader_extract_iter_state *pState, void *pvBuf, size_t buf_size); -MINIZ_EXPORT mz_bool -mz_zip_reader_extract_iter_free(mz_zip_reader_extract_iter_state *pState); - -#ifndef MINIZ_NO_STDIO -/* Extracts a archive file to a disk file and sets its last accessed and - * modified times. */ -/* This function only extracts files, not archive directory records. */ -MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_file(mz_zip_archive *pZip, - mz_uint file_index, - const char *pDst_filename, - mz_uint flags); -MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_file( - mz_zip_archive *pZip, const char *pArchive_filename, - const char *pDst_filename, mz_uint flags); - -/* Extracts a archive file starting at the current position in the destination - * FILE stream. */ -MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_cfile(mz_zip_archive *pZip, - mz_uint file_index, - MZ_FILE *File, - mz_uint flags); -MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_cfile( - mz_zip_archive *pZip, const char *pArchive_filename, MZ_FILE *pFile, - mz_uint flags); -#endif - -#if 0 -/* TODO */ - typedef void *mz_zip_streaming_extract_state_ptr; - mz_zip_streaming_extract_state_ptr mz_zip_streaming_extract_begin(mz_zip_archive *pZip, mz_uint file_index, mz_uint flags); - uint64_t mz_zip_streaming_extract_get_size(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState); - uint64_t mz_zip_streaming_extract_get_cur_ofs(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState); - mz_bool mz_zip_streaming_extract_seek(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState, uint64_t new_ofs); - size_t mz_zip_streaming_extract_read(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState, void *pBuf, size_t buf_size); - mz_bool mz_zip_streaming_extract_end(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState); -#endif - -/* This function compares the archive's local headers, the optional local zip64 - * extended information block, and the optional descriptor following the - * compressed data vs. the data in the central directory. */ -/* It also validates that each file can be successfully uncompressed unless the - * MZ_ZIP_FLAG_VALIDATE_HEADERS_ONLY is specified. */ -MINIZ_EXPORT mz_bool mz_zip_validate_file(mz_zip_archive *pZip, - mz_uint file_index, mz_uint flags); - -/* Validates an entire archive by calling mz_zip_validate_file() on each file. - */ -MINIZ_EXPORT mz_bool mz_zip_validate_archive(mz_zip_archive *pZip, - mz_uint flags); - -/* Misc utils/helpers, valid for ZIP reading or writing */ -MINIZ_EXPORT mz_bool mz_zip_validate_mem_archive(const void *pMem, size_t size, - mz_uint flags, - mz_zip_error *pErr); -MINIZ_EXPORT mz_bool mz_zip_validate_file_archive(const char *pFilename, - mz_uint flags, - mz_zip_error *pErr); - -/* Universal end function - calls either mz_zip_reader_end() or - * mz_zip_writer_end(). */ -MINIZ_EXPORT mz_bool mz_zip_end(mz_zip_archive *pZip); - -/* -------- ZIP writing */ - -#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS - -/* Inits a ZIP archive writer. */ -/*Set pZip->m_pWrite (and pZip->m_pIO_opaque) before calling mz_zip_writer_init - * or mz_zip_writer_init_v2*/ -/*The output is streamable, i.e. file_ofs in mz_file_write_func always increases - * only by n*/ -MINIZ_EXPORT mz_bool mz_zip_writer_init(mz_zip_archive *pZip, - mz_uint64 existing_size); -MINIZ_EXPORT mz_bool mz_zip_writer_init_v2(mz_zip_archive *pZip, - mz_uint64 existing_size, - mz_uint flags); - -MINIZ_EXPORT mz_bool mz_zip_writer_init_heap( - mz_zip_archive *pZip, size_t size_to_reserve_at_beginning, - size_t initial_allocation_size); -MINIZ_EXPORT mz_bool mz_zip_writer_init_heap_v2( - mz_zip_archive *pZip, size_t size_to_reserve_at_beginning, - size_t initial_allocation_size, mz_uint flags); - -#ifndef MINIZ_NO_STDIO -MINIZ_EXPORT mz_bool -mz_zip_writer_init_file(mz_zip_archive *pZip, const char *pFilename, - mz_uint64 size_to_reserve_at_beginning); -MINIZ_EXPORT mz_bool mz_zip_writer_init_file_v2( - mz_zip_archive *pZip, const char *pFilename, - mz_uint64 size_to_reserve_at_beginning, mz_uint flags); -MINIZ_EXPORT mz_bool mz_zip_writer_init_cfile(mz_zip_archive *pZip, - MZ_FILE *pFile, mz_uint flags); -#endif - -/* Converts a ZIP archive reader object into a writer object, to allow efficient - * in-place file appends to occur on an existing archive. */ -/* For archives opened using mz_zip_reader_init_file, pFilename must be the - * archive's filename so it can be reopened for writing. If the file can't be - * reopened, mz_zip_reader_end() will be called. */ -/* For archives opened using mz_zip_reader_init_mem, the memory block must be - * growable using the realloc callback (which defaults to realloc unless you've - * overridden it). */ -/* Finally, for archives opened using mz_zip_reader_init, the mz_zip_archive's - * user provided m_pWrite function cannot be NULL. */ -/* Note: In-place archive modification is not recommended unless you know what - * you're doing, because if execution stops or something goes wrong before */ -/* the archive is finalized the file's central directory will be hosed. */ -MINIZ_EXPORT mz_bool mz_zip_writer_init_from_reader(mz_zip_archive *pZip, - const char *pFilename); -MINIZ_EXPORT mz_bool mz_zip_writer_init_from_reader_v2(mz_zip_archive *pZip, - const char *pFilename, - mz_uint flags); -MINIZ_EXPORT mz_bool mz_zip_writer_init_from_reader_v2_noreopen( - mz_zip_archive *pZip, const char *pFilename, mz_uint flags); - -/* Adds the contents of a memory buffer to an archive. These functions record - * the current local time into the archive. */ -/* To add a directory entry, call this method with an archive name ending in a - * forwardslash with an empty buffer. */ -/* level_and_flags - compression level (0-10, see MZ_BEST_SPEED, - * MZ_BEST_COMPRESSION, etc.) logically OR'd with zero or more mz_zip_flags, or - * just set to MZ_DEFAULT_COMPRESSION. */ -MINIZ_EXPORT mz_bool mz_zip_writer_add_mem(mz_zip_archive *pZip, - const char *pArchive_name, - const void *pBuf, size_t buf_size, - mz_uint level_and_flags); - -/* Like mz_zip_writer_add_mem(), except you can specify a file comment field, - * and optionally supply the function with already compressed data. */ -/* uncomp_size/uncomp_crc32 are only used if the MZ_ZIP_FLAG_COMPRESSED_DATA - * flag is specified. */ -MINIZ_EXPORT mz_bool mz_zip_writer_add_mem_ex( - mz_zip_archive *pZip, const char *pArchive_name, const void *pBuf, - size_t buf_size, const void *pComment, mz_uint16 comment_size, - mz_uint level_and_flags, mz_uint64 uncomp_size, mz_uint32 uncomp_crc32); - -MINIZ_EXPORT mz_bool mz_zip_writer_add_mem_ex_v2( - mz_zip_archive *pZip, const char *pArchive_name, const void *pBuf, - size_t buf_size, const void *pComment, mz_uint16 comment_size, - mz_uint level_and_flags, mz_uint64 uncomp_size, mz_uint32 uncomp_crc32, - MZ_TIME_T *last_modified, const char *user_extra_data_local, - mz_uint user_extra_data_local_len, const char *user_extra_data_central, - mz_uint user_extra_data_central_len); - -/* Adds the contents of a file to an archive. This function also records the - * disk file's modified time into the archive. */ -/* File data is supplied via a read callback function. User - * mz_zip_writer_add_(c)file to add a file directly.*/ -MINIZ_EXPORT mz_bool mz_zip_writer_add_read_buf_callback( - mz_zip_archive *pZip, const char *pArchive_name, - mz_file_read_func read_callback, void *callback_opaque, mz_uint64 max_size, - const MZ_TIME_T *pFile_time, const void *pComment, mz_uint16 comment_size, - mz_uint level_and_flags, mz_uint32 ext_attributes, - const char *user_extra_data_local, mz_uint user_extra_data_local_len, - const char *user_extra_data_central, mz_uint user_extra_data_central_len); - -#ifndef MINIZ_NO_STDIO -/* Adds the contents of a disk file to an archive. This function also records - * the disk file's modified time into the archive. */ -/* level_and_flags - compression level (0-10, see MZ_BEST_SPEED, - * MZ_BEST_COMPRESSION, etc.) logically OR'd with zero or more mz_zip_flags, or - * just set to MZ_DEFAULT_COMPRESSION. */ -MINIZ_EXPORT mz_bool mz_zip_writer_add_file( - mz_zip_archive *pZip, const char *pArchive_name, const char *pSrc_filename, - const void *pComment, mz_uint16 comment_size, mz_uint level_and_flags, - mz_uint32 ext_attributes); - -/* Like mz_zip_writer_add_file(), except the file data is read from the - * specified FILE stream. */ -MINIZ_EXPORT mz_bool mz_zip_writer_add_cfile( - mz_zip_archive *pZip, const char *pArchive_name, MZ_FILE *pSrc_file, - mz_uint64 max_size, const MZ_TIME_T *pFile_time, const void *pComment, - mz_uint16 comment_size, mz_uint level_and_flags, mz_uint32 ext_attributes, - const char *user_extra_data_local, mz_uint user_extra_data_local_len, - const char *user_extra_data_central, mz_uint user_extra_data_central_len); -#endif - -/* Adds a file to an archive by fully cloning the data from another archive. */ -/* This function fully clones the source file's compressed data (no - * recompression), along with its full filename, extra data (it may add or - * modify the zip64 local header extra data field), and the optional descriptor - * following the compressed data. */ -MINIZ_EXPORT mz_bool mz_zip_writer_add_from_zip_reader( - mz_zip_archive *pZip, mz_zip_archive *pSource_zip, mz_uint src_file_index); - -/* Finalizes the archive by writing the central directory records followed by - * the end of central directory record. */ -/* After an archive is finalized, the only valid call on the mz_zip_archive - * struct is mz_zip_writer_end(). */ -/* An archive must be manually finalized by calling this function for it to be - * valid. */ -MINIZ_EXPORT mz_bool mz_zip_writer_finalize_archive(mz_zip_archive *pZip); - -/* Finalizes a heap archive, returning a pointer to the heap block and its size. - */ -/* The heap block will be allocated using the mz_zip_archive's alloc/realloc - * callbacks. */ -MINIZ_EXPORT mz_bool mz_zip_writer_finalize_heap_archive(mz_zip_archive *pZip, - void **ppBuf, - size_t *pSize); - -/* Ends archive writing, freeing all allocations, and closing the output file if - * mz_zip_writer_init_file() was used. */ -/* Note for the archive to be valid, it *must* have been finalized before ending - * (this function will not do it for you). */ -MINIZ_EXPORT mz_bool mz_zip_writer_end(mz_zip_archive *pZip); - -/* -------- Misc. high-level helper functions: */ - -/* mz_zip_add_mem_to_archive_file_in_place() efficiently (but not atomically) - * appends a memory blob to a ZIP archive. */ -/* Note this is NOT a fully safe operation. If it crashes or dies in some way - * your archive can be left in a screwed up state (without a central directory). - */ -/* level_and_flags - compression level (0-10, see MZ_BEST_SPEED, - * MZ_BEST_COMPRESSION, etc.) logically OR'd with zero or more mz_zip_flags, or - * just set to MZ_DEFAULT_COMPRESSION. */ -/* TODO: Perhaps add an option to leave the existing central dir in place in - * case the add dies? We could then truncate the file (so the old central dir - * would be at the end) if something goes wrong. */ -MINIZ_EXPORT mz_bool mz_zip_add_mem_to_archive_file_in_place( - const char *pZip_filename, const char *pArchive_name, const void *pBuf, - size_t buf_size, const void *pComment, mz_uint16 comment_size, - mz_uint level_and_flags); -MINIZ_EXPORT mz_bool mz_zip_add_mem_to_archive_file_in_place_v2( - const char *pZip_filename, const char *pArchive_name, const void *pBuf, - size_t buf_size, const void *pComment, mz_uint16 comment_size, - mz_uint level_and_flags, mz_zip_error *pErr); - -/* Reads a single file from an archive into a heap block. */ -/* If pComment is not NULL, only the file with the specified comment will be - * extracted. */ -/* Returns NULL on failure. */ -MINIZ_EXPORT void * -mz_zip_extract_archive_file_to_heap(const char *pZip_filename, - const char *pArchive_name, size_t *pSize, - mz_uint flags); -MINIZ_EXPORT void *mz_zip_extract_archive_file_to_heap_v2( - const char *pZip_filename, const char *pArchive_name, const char *pComment, - size_t *pSize, mz_uint flags, mz_zip_error *pErr); - -#endif /* #ifndef MINIZ_NO_ARCHIVE_WRITING_APIS */ - -#ifdef __cplusplus -} -#endif - -#endif /* MINIZ_NO_ARCHIVE_APIS */ -/************************************************************************** - * - * Copyright 2013-2014 RAD Game Tools and Valve Software - * Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC - * All Rights Reserved. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - **************************************************************************/ - -typedef unsigned char mz_validate_uint16[sizeof(mz_uint16) == 2 ? 1 : -1]; -typedef unsigned char mz_validate_uint32[sizeof(mz_uint32) == 4 ? 1 : -1]; -typedef unsigned char mz_validate_uint64[sizeof(mz_uint64) == 8 ? 1 : -1]; - -#ifdef __cplusplus -extern "C" { -#endif - -/* ------------------- zlib-style API's */ - -mz_ulong mz_adler32(mz_ulong adler, const unsigned char *ptr, size_t buf_len) { - mz_uint32 i, s1 = (mz_uint32)(adler & 0xffff), s2 = (mz_uint32)(adler >> 16); - size_t block_len = buf_len % 5552; - if (!ptr) - return MZ_ADLER32_INIT; - while (buf_len) { - for (i = 0; i + 7 < block_len; i += 8, ptr += 8) { - s1 += ptr[0], s2 += s1; - s1 += ptr[1], s2 += s1; - s1 += ptr[2], s2 += s1; - s1 += ptr[3], s2 += s1; - s1 += ptr[4], s2 += s1; - s1 += ptr[5], s2 += s1; - s1 += ptr[6], s2 += s1; - s1 += ptr[7], s2 += s1; - } - for (; i < block_len; ++i) - s1 += *ptr++, s2 += s1; - s1 %= 65521U, s2 %= 65521U; - buf_len -= block_len; - block_len = 5552; - } - return (s2 << 16) + s1; -} - -/* Karl Malbrain's compact CRC-32. See "A compact CCITT crc16 and crc32 C - * implementation that balances processor cache usage against speed": - * http://www.geocities.com/malbrain/ */ -#if 0 - mz_ulong mz_crc32(mz_ulong crc, const mz_uint8 *ptr, size_t buf_len) - { - static const mz_uint32 s_crc32[16] = { 0, 0x1db71064, 0x3b6e20c8, 0x26d930ac, 0x76dc4190, 0x6b6b51f4, 0x4db26158, 0x5005713c, - 0xedb88320, 0xf00f9344, 0xd6d6a3e8, 0xcb61b38c, 0x9b64c2b0, 0x86d3d2d4, 0xa00ae278, 0xbdbdf21c }; - mz_uint32 crcu32 = (mz_uint32)crc; - if (!ptr) - return MZ_CRC32_INIT; - crcu32 = ~crcu32; - while (buf_len--) - { - mz_uint8 b = *ptr++; - crcu32 = (crcu32 >> 4) ^ s_crc32[(crcu32 & 0xF) ^ (b & 0xF)]; - crcu32 = (crcu32 >> 4) ^ s_crc32[(crcu32 & 0xF) ^ (b >> 4)]; - } - return ~crcu32; - } -#elif defined(USE_EXTERNAL_MZCRC) -/* If USE_EXTERNAL_CRC is defined, an external module will export the - * mz_crc32() symbol for us to use, e.g. an SSE-accelerated version. - * Depending on the impl, it may be necessary to ~ the input/output crc values. - */ -mz_ulong mz_crc32(mz_ulong crc, const mz_uint8 *ptr, size_t buf_len); -#else -/* Faster, but larger CPU cache footprint. - */ -mz_ulong mz_crc32(mz_ulong crc, const mz_uint8 *ptr, size_t buf_len) { - static const mz_uint32 s_crc_table[256] = { - 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F, - 0xE963A535, 0x9E6495A3, 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, - 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, 0x1DB71064, 0x6AB020F2, - 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7, - 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9, - 0xFA0F3D63, 0x8D080DF5, 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, - 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, 0x35B5A8FA, 0x42B2986C, - 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59, - 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423, - 0xCFBA9599, 0xB8BDA50F, 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, - 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, 0x76DC4190, 0x01DB7106, - 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433, - 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D, - 0x91646C97, 0xE6635C01, 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, - 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, 0x65B0D9C6, 0x12B7E950, - 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65, - 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7, - 0xA4D1C46D, 0xD3D6F4FB, 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, - 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, 0x5005713C, 0x270241AA, - 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F, - 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81, - 0xB7BD5C3B, 0xC0BA6CAD, 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A, - 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, 0xE3630B12, 0x94643B84, - 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1, - 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB, - 0x196C3671, 0x6E6B06E7, 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, - 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, 0xD6D6A3E8, 0xA1D1937E, - 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B, - 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55, - 0x316E8EEF, 0x4669BE79, 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, - 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, 0xC5BA3BBE, 0xB2BD0B28, - 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D, - 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F, - 0x72076785, 0x05005713, 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38, - 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, 0x86D3D2D4, 0xF1D4E242, - 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777, - 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69, - 0x616BFFD3, 0x166CCF45, 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, - 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, 0xAED16A4A, 0xD9D65ADC, - 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9, - 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693, - 0x54DE5729, 0x23D967BF, 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, - 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D}; - - mz_uint32 crc32 = (mz_uint32)crc ^ 0xFFFFFFFF; - const mz_uint8 *pByte_buf = (const mz_uint8 *)ptr; - - while (buf_len >= 4) { - crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[0]) & 0xFF]; - crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[1]) & 0xFF]; - crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[2]) & 0xFF]; - crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[3]) & 0xFF]; - pByte_buf += 4; - buf_len -= 4; - } - - while (buf_len) { - crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[0]) & 0xFF]; - ++pByte_buf; - --buf_len; - } - - return ~crc32; -} -#endif - -void mz_free(void *p) { MZ_FREE(p); } - -MINIZ_EXPORT void *miniz_def_alloc_func(void *opaque, size_t items, - size_t size) { - (void)opaque, (void)items, (void)size; - return MZ_MALLOC(items * size); -} -MINIZ_EXPORT void miniz_def_free_func(void *opaque, void *address) { - (void)opaque, (void)address; - MZ_FREE(address); -} -MINIZ_EXPORT void *miniz_def_realloc_func(void *opaque, void *address, - size_t items, size_t size) { - (void)opaque, (void)address, (void)items, (void)size; - return MZ_REALLOC(address, items * size); -} - -const char *mz_version(void) { return MZ_VERSION; } - -#ifndef MINIZ_NO_ZLIB_APIS - -int mz_deflateInit(mz_streamp pStream, int level) { - return mz_deflateInit2(pStream, level, MZ_DEFLATED, MZ_DEFAULT_WINDOW_BITS, 9, - MZ_DEFAULT_STRATEGY); -} - -int mz_deflateInit2(mz_streamp pStream, int level, int method, int window_bits, - int mem_level, int strategy) { - tdefl_compressor *pComp; - mz_uint comp_flags = - TDEFL_COMPUTE_ADLER32 | - tdefl_create_comp_flags_from_zip_params(level, window_bits, strategy); - - if (!pStream) - return MZ_STREAM_ERROR; - if ((method != MZ_DEFLATED) || ((mem_level < 1) || (mem_level > 9)) || - ((window_bits != MZ_DEFAULT_WINDOW_BITS) && - (-window_bits != MZ_DEFAULT_WINDOW_BITS))) - return MZ_PARAM_ERROR; - - pStream->data_type = 0; - pStream->adler = MZ_ADLER32_INIT; - pStream->msg = NULL; - pStream->reserved = 0; - pStream->total_in = 0; - pStream->total_out = 0; - if (!pStream->zalloc) - pStream->zalloc = miniz_def_alloc_func; - if (!pStream->zfree) - pStream->zfree = miniz_def_free_func; - - pComp = (tdefl_compressor *)pStream->zalloc(pStream->opaque, 1, - sizeof(tdefl_compressor)); - if (!pComp) - return MZ_MEM_ERROR; - - pStream->state = (struct mz_internal_state *)pComp; - - if (tdefl_init(pComp, NULL, NULL, comp_flags) != TDEFL_STATUS_OKAY) { - mz_deflateEnd(pStream); - return MZ_PARAM_ERROR; - } - - return MZ_OK; -} - -int mz_deflateReset(mz_streamp pStream) { - if ((!pStream) || (!pStream->state) || (!pStream->zalloc) || - (!pStream->zfree)) - return MZ_STREAM_ERROR; - pStream->total_in = pStream->total_out = 0; - tdefl_init((tdefl_compressor *)pStream->state, NULL, NULL, - ((tdefl_compressor *)pStream->state)->m_flags); - return MZ_OK; -} - -int mz_deflate(mz_streamp pStream, int flush) { - size_t in_bytes, out_bytes; - mz_ulong orig_total_in, orig_total_out; - int mz_status = MZ_OK; - - if ((!pStream) || (!pStream->state) || (flush < 0) || (flush > MZ_FINISH) || - (!pStream->next_out)) - return MZ_STREAM_ERROR; - if (!pStream->avail_out) - return MZ_BUF_ERROR; - - if (flush == MZ_PARTIAL_FLUSH) - flush = MZ_SYNC_FLUSH; - - if (((tdefl_compressor *)pStream->state)->m_prev_return_status == - TDEFL_STATUS_DONE) - return (flush == MZ_FINISH) ? MZ_STREAM_END : MZ_BUF_ERROR; - - orig_total_in = pStream->total_in; - orig_total_out = pStream->total_out; - for (;;) { - tdefl_status defl_status; - in_bytes = pStream->avail_in; - out_bytes = pStream->avail_out; - - defl_status = tdefl_compress((tdefl_compressor *)pStream->state, - pStream->next_in, &in_bytes, pStream->next_out, - &out_bytes, (tdefl_flush)flush); - pStream->next_in += (mz_uint)in_bytes; - pStream->avail_in -= (mz_uint)in_bytes; - pStream->total_in += (mz_uint)in_bytes; - pStream->adler = tdefl_get_adler32((tdefl_compressor *)pStream->state); - - pStream->next_out += (mz_uint)out_bytes; - pStream->avail_out -= (mz_uint)out_bytes; - pStream->total_out += (mz_uint)out_bytes; - - if (defl_status < 0) { - mz_status = MZ_STREAM_ERROR; - break; - } else if (defl_status == TDEFL_STATUS_DONE) { - mz_status = MZ_STREAM_END; - break; - } else if (!pStream->avail_out) - break; - else if ((!pStream->avail_in) && (flush != MZ_FINISH)) { - if ((flush) || (pStream->total_in != orig_total_in) || - (pStream->total_out != orig_total_out)) - break; - return MZ_BUF_ERROR; /* Can't make forward progress without some input. - */ - } - } - return mz_status; -} - -int mz_deflateEnd(mz_streamp pStream) { - if (!pStream) - return MZ_STREAM_ERROR; - if (pStream->state) { - pStream->zfree(pStream->opaque, pStream->state); - pStream->state = NULL; - } - return MZ_OK; -} - -mz_ulong mz_deflateBound(mz_streamp pStream, mz_ulong source_len) { - (void)pStream; - /* This is really over conservative. (And lame, but it's actually pretty - * tricky to compute a true upper bound given the way tdefl's blocking works.) - */ - return MZ_MAX(128 + (source_len * 110) / 100, - 128 + source_len + ((source_len / (31 * 1024)) + 1) * 5); -} - -int mz_compress2(unsigned char *pDest, mz_ulong *pDest_len, - const unsigned char *pSource, mz_ulong source_len, int level) { - int status; - mz_stream stream; - memset(&stream, 0, sizeof(stream)); - - /* In case mz_ulong is 64-bits (argh I hate longs). */ - if ((source_len | *pDest_len) > 0xFFFFFFFFU) - return MZ_PARAM_ERROR; - - stream.next_in = pSource; - stream.avail_in = (mz_uint32)source_len; - stream.next_out = pDest; - stream.avail_out = (mz_uint32)*pDest_len; - - status = mz_deflateInit(&stream, level); - if (status != MZ_OK) - return status; - - status = mz_deflate(&stream, MZ_FINISH); - if (status != MZ_STREAM_END) { - mz_deflateEnd(&stream); - return (status == MZ_OK) ? MZ_BUF_ERROR : status; - } - - *pDest_len = stream.total_out; - return mz_deflateEnd(&stream); -} - -int mz_compress(unsigned char *pDest, mz_ulong *pDest_len, - const unsigned char *pSource, mz_ulong source_len) { - return mz_compress2(pDest, pDest_len, pSource, source_len, - MZ_DEFAULT_COMPRESSION); -} - -mz_ulong mz_compressBound(mz_ulong source_len) { - return mz_deflateBound(NULL, source_len); -} - -typedef struct { - tinfl_decompressor m_decomp; - mz_uint m_dict_ofs, m_dict_avail, m_first_call, m_has_flushed; - int m_window_bits; - mz_uint8 m_dict[TINFL_LZ_DICT_SIZE]; - tinfl_status m_last_status; -} inflate_state; - -int mz_inflateInit2(mz_streamp pStream, int window_bits) { - inflate_state *pDecomp; - if (!pStream) - return MZ_STREAM_ERROR; - if ((window_bits != MZ_DEFAULT_WINDOW_BITS) && - (-window_bits != MZ_DEFAULT_WINDOW_BITS)) - return MZ_PARAM_ERROR; - - pStream->data_type = 0; - pStream->adler = 0; - pStream->msg = NULL; - pStream->total_in = 0; - pStream->total_out = 0; - pStream->reserved = 0; - if (!pStream->zalloc) - pStream->zalloc = miniz_def_alloc_func; - if (!pStream->zfree) - pStream->zfree = miniz_def_free_func; - - pDecomp = (inflate_state *)pStream->zalloc(pStream->opaque, 1, - sizeof(inflate_state)); - if (!pDecomp) - return MZ_MEM_ERROR; - - pStream->state = (struct mz_internal_state *)pDecomp; - - tinfl_init(&pDecomp->m_decomp); - pDecomp->m_dict_ofs = 0; - pDecomp->m_dict_avail = 0; - pDecomp->m_last_status = TINFL_STATUS_NEEDS_MORE_INPUT; - pDecomp->m_first_call = 1; - pDecomp->m_has_flushed = 0; - pDecomp->m_window_bits = window_bits; - - return MZ_OK; -} - -int mz_inflateInit(mz_streamp pStream) { - return mz_inflateInit2(pStream, MZ_DEFAULT_WINDOW_BITS); -} - -int mz_inflateReset(mz_streamp pStream) { - inflate_state *pDecomp; - if (!pStream) - return MZ_STREAM_ERROR; - - pStream->data_type = 0; - pStream->adler = 0; - pStream->msg = NULL; - pStream->total_in = 0; - pStream->total_out = 0; - pStream->reserved = 0; - - pDecomp = (inflate_state *)pStream->state; - - tinfl_init(&pDecomp->m_decomp); - pDecomp->m_dict_ofs = 0; - pDecomp->m_dict_avail = 0; - pDecomp->m_last_status = TINFL_STATUS_NEEDS_MORE_INPUT; - pDecomp->m_first_call = 1; - pDecomp->m_has_flushed = 0; - /* pDecomp->m_window_bits = window_bits */; - - return MZ_OK; -} - -int mz_inflate(mz_streamp pStream, int flush) { - inflate_state *pState; - mz_uint n, first_call, decomp_flags = TINFL_FLAG_COMPUTE_ADLER32; - size_t in_bytes, out_bytes, orig_avail_in; - tinfl_status status; - - if ((!pStream) || (!pStream->state)) - return MZ_STREAM_ERROR; - if (flush == MZ_PARTIAL_FLUSH) - flush = MZ_SYNC_FLUSH; - if ((flush) && (flush != MZ_SYNC_FLUSH) && (flush != MZ_FINISH)) - return MZ_STREAM_ERROR; - - pState = (inflate_state *)pStream->state; - if (pState->m_window_bits > 0) - decomp_flags |= TINFL_FLAG_PARSE_ZLIB_HEADER; - orig_avail_in = pStream->avail_in; - - first_call = pState->m_first_call; - pState->m_first_call = 0; - if (pState->m_last_status < 0) - return MZ_DATA_ERROR; - - if (pState->m_has_flushed && (flush != MZ_FINISH)) - return MZ_STREAM_ERROR; - pState->m_has_flushed |= (flush == MZ_FINISH); - - if ((flush == MZ_FINISH) && (first_call)) { - /* MZ_FINISH on the first call implies that the input and output buffers are - * large enough to hold the entire compressed/decompressed file. */ - decomp_flags |= TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF; - in_bytes = pStream->avail_in; - out_bytes = pStream->avail_out; - status = tinfl_decompress(&pState->m_decomp, pStream->next_in, &in_bytes, - pStream->next_out, pStream->next_out, &out_bytes, - decomp_flags); - pState->m_last_status = status; - pStream->next_in += (mz_uint)in_bytes; - pStream->avail_in -= (mz_uint)in_bytes; - pStream->total_in += (mz_uint)in_bytes; - pStream->adler = tinfl_get_adler32(&pState->m_decomp); - pStream->next_out += (mz_uint)out_bytes; - pStream->avail_out -= (mz_uint)out_bytes; - pStream->total_out += (mz_uint)out_bytes; - - if (status < 0) - return MZ_DATA_ERROR; - else if (status != TINFL_STATUS_DONE) { - pState->m_last_status = TINFL_STATUS_FAILED; - return MZ_BUF_ERROR; - } - return MZ_STREAM_END; - } - /* flush != MZ_FINISH then we must assume there's more input. */ - if (flush != MZ_FINISH) - decomp_flags |= TINFL_FLAG_HAS_MORE_INPUT; - - if (pState->m_dict_avail) { - n = MZ_MIN(pState->m_dict_avail, pStream->avail_out); - memcpy(pStream->next_out, pState->m_dict + pState->m_dict_ofs, n); - pStream->next_out += n; - pStream->avail_out -= n; - pStream->total_out += n; - pState->m_dict_avail -= n; - pState->m_dict_ofs = (pState->m_dict_ofs + n) & (TINFL_LZ_DICT_SIZE - 1); - return ((pState->m_last_status == TINFL_STATUS_DONE) && - (!pState->m_dict_avail)) - ? MZ_STREAM_END - : MZ_OK; - } - - for (;;) { - in_bytes = pStream->avail_in; - out_bytes = TINFL_LZ_DICT_SIZE - pState->m_dict_ofs; - - status = tinfl_decompress( - &pState->m_decomp, pStream->next_in, &in_bytes, pState->m_dict, - pState->m_dict + pState->m_dict_ofs, &out_bytes, decomp_flags); - pState->m_last_status = status; - - pStream->next_in += (mz_uint)in_bytes; - pStream->avail_in -= (mz_uint)in_bytes; - pStream->total_in += (mz_uint)in_bytes; - pStream->adler = tinfl_get_adler32(&pState->m_decomp); - - pState->m_dict_avail = (mz_uint)out_bytes; - - n = MZ_MIN(pState->m_dict_avail, pStream->avail_out); - memcpy(pStream->next_out, pState->m_dict + pState->m_dict_ofs, n); - pStream->next_out += n; - pStream->avail_out -= n; - pStream->total_out += n; - pState->m_dict_avail -= n; - pState->m_dict_ofs = (pState->m_dict_ofs + n) & (TINFL_LZ_DICT_SIZE - 1); - - if (status < 0) - return MZ_DATA_ERROR; /* Stream is corrupted (there could be some - uncompressed data left in the output dictionary - - oh well). */ - else if ((status == TINFL_STATUS_NEEDS_MORE_INPUT) && (!orig_avail_in)) - return MZ_BUF_ERROR; /* Signal caller that we can't make forward progress - without supplying more input or by setting flush - to MZ_FINISH. */ - else if (flush == MZ_FINISH) { - /* The output buffer MUST be large to hold the remaining uncompressed data - * when flush==MZ_FINISH. */ - if (status == TINFL_STATUS_DONE) - return pState->m_dict_avail ? MZ_BUF_ERROR : MZ_STREAM_END; - /* status here must be TINFL_STATUS_HAS_MORE_OUTPUT, which means there's - * at least 1 more byte on the way. If there's no more room left in the - * output buffer then something is wrong. */ - else if (!pStream->avail_out) - return MZ_BUF_ERROR; - } else if ((status == TINFL_STATUS_DONE) || (!pStream->avail_in) || - (!pStream->avail_out) || (pState->m_dict_avail)) - break; - } - - return ((status == TINFL_STATUS_DONE) && (!pState->m_dict_avail)) - ? MZ_STREAM_END - : MZ_OK; -} - -int mz_inflateEnd(mz_streamp pStream) { - if (!pStream) - return MZ_STREAM_ERROR; - if (pStream->state) { - pStream->zfree(pStream->opaque, pStream->state); - pStream->state = NULL; - } - return MZ_OK; -} -int mz_uncompress2(unsigned char *pDest, mz_ulong *pDest_len, - const unsigned char *pSource, mz_ulong *pSource_len) { - mz_stream stream; - int status; - memset(&stream, 0, sizeof(stream)); - - /* In case mz_ulong is 64-bits (argh I hate longs). */ - if ((*pSource_len | *pDest_len) > 0xFFFFFFFFU) - return MZ_PARAM_ERROR; - - stream.next_in = pSource; - stream.avail_in = (mz_uint32)*pSource_len; - stream.next_out = pDest; - stream.avail_out = (mz_uint32)*pDest_len; - - status = mz_inflateInit(&stream); - if (status != MZ_OK) - return status; - - status = mz_inflate(&stream, MZ_FINISH); - *pSource_len = *pSource_len - stream.avail_in; - if (status != MZ_STREAM_END) { - mz_inflateEnd(&stream); - return ((status == MZ_BUF_ERROR) && (!stream.avail_in)) ? MZ_DATA_ERROR - : status; - } - *pDest_len = stream.total_out; - - return mz_inflateEnd(&stream); -} - -int mz_uncompress(unsigned char *pDest, mz_ulong *pDest_len, - const unsigned char *pSource, mz_ulong source_len) { - return mz_uncompress2(pDest, pDest_len, pSource, &source_len); -} - -const char *mz_error(int err) { - static struct { - int m_err; - const char *m_pDesc; - } s_error_descs[] = {{MZ_OK, ""}, - {MZ_STREAM_END, "stream end"}, - {MZ_NEED_DICT, "need dictionary"}, - {MZ_ERRNO, "file error"}, - {MZ_STREAM_ERROR, "stream error"}, - {MZ_DATA_ERROR, "data error"}, - {MZ_MEM_ERROR, "out of memory"}, - {MZ_BUF_ERROR, "buf error"}, - {MZ_VERSION_ERROR, "version error"}, - {MZ_PARAM_ERROR, "parameter error"}}; - mz_uint i; - for (i = 0; i < sizeof(s_error_descs) / sizeof(s_error_descs[0]); ++i) - if (s_error_descs[i].m_err == err) - return s_error_descs[i].m_pDesc; - return NULL; -} - -#endif /*MINIZ_NO_ZLIB_APIS */ - -#ifdef __cplusplus -} -#endif - -/* - This is free and unencumbered software released into the public domain. - - Anyone is free to copy, modify, publish, use, compile, sell, or - distribute this software, either in source code form or as a compiled - binary, for any purpose, commercial or non-commercial, and by any - means. - - In jurisdictions that recognize copyright laws, the author or authors - of this software dedicate any and all copyright interest in the - software to the public domain. We make this dedication for the benefit - of the public at large and to the detriment of our heirs and - successors. We intend this dedication to be an overt act of - relinquishment in perpetuity of all present and future rights to this - software under copyright law. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR - OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, - ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. - - For more information, please refer to -*/ -/************************************************************************** - * - * Copyright 2013-2014 RAD Game Tools and Valve Software - * Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC - * All Rights Reserved. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - **************************************************************************/ - -#ifdef __cplusplus -extern "C" { -#endif - -/* ------------------- Low-level Compression (independent from all decompression - * API's) */ - -/* Purposely making these tables static for faster init and thread safety. */ -static const mz_uint16 s_tdefl_len_sym[256] = { - 257, 258, 259, 260, 261, 262, 263, 264, 265, 265, 266, 266, 267, 267, 268, - 268, 269, 269, 269, 269, 270, 270, 270, 270, 271, 271, 271, 271, 272, 272, - 272, 272, 273, 273, 273, 273, 273, 273, 273, 273, 274, 274, 274, 274, 274, - 274, 274, 274, 275, 275, 275, 275, 275, 275, 275, 275, 276, 276, 276, 276, - 276, 276, 276, 276, 277, 277, 277, 277, 277, 277, 277, 277, 277, 277, 277, - 277, 277, 277, 277, 277, 278, 278, 278, 278, 278, 278, 278, 278, 278, 278, - 278, 278, 278, 278, 278, 278, 279, 279, 279, 279, 279, 279, 279, 279, 279, - 279, 279, 279, 279, 279, 279, 279, 280, 280, 280, 280, 280, 280, 280, 280, - 280, 280, 280, 280, 280, 280, 280, 280, 281, 281, 281, 281, 281, 281, 281, - 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, - 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 282, 282, 282, 282, 282, - 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, - 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 283, 283, 283, - 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, - 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 284, - 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, - 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, - 285}; - -static const mz_uint8 s_tdefl_len_extra[256] = { - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, - 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, - 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, - 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, - 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, - 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, - 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, - 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, - 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, - 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0}; - -static const mz_uint8 s_tdefl_small_dist_sym[512] = { - 0, 1, 2, 3, 4, 4, 5, 5, 6, 6, 6, 6, 7, 7, 7, 7, 8, 8, 8, - 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 10, 10, - 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 11, 11, 11, - 11, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, - 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, - 12, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, - 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 14, 14, 14, 14, 14, - 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, - 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, - 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, - 14, 14, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, - 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, - 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, - 15, 15, 15, 15, 15, 15, 15, 15, 15, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, - 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, - 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, - 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, - 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, - 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, - 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, - 16, 16, 16, 16, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, - 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, - 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, - 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, - 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, - 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, - 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17}; - -static const mz_uint8 s_tdefl_small_dist_extra[512] = { - 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, - 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, - 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, - 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, - 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, - 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, - 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, - 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, - 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, - 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7}; - -static const mz_uint8 s_tdefl_large_dist_sym[128] = { - 0, 0, 18, 19, 20, 20, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24, - 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, - 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27, - 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, - 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, - 28, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, - 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29}; - -static const mz_uint8 s_tdefl_large_dist_extra[128] = { - 0, 0, 8, 8, 9, 9, 9, 9, 10, 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, - 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 12, 12, - 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, - 12, 12, 12, 12, 12, 12, 12, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, - 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, - 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, - 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13}; - -/* Radix sorts tdefl_sym_freq[] array by 16-bit key m_key. Returns ptr to sorted - * values. */ -typedef struct { - mz_uint16 m_key, m_sym_index; -} tdefl_sym_freq; -static tdefl_sym_freq *tdefl_radix_sort_syms(mz_uint num_syms, - tdefl_sym_freq *pSyms0, - tdefl_sym_freq *pSyms1) { - mz_uint32 total_passes = 2, pass_shift, pass, i, hist[256 * 2]; - tdefl_sym_freq *pCur_syms = pSyms0, *pNew_syms = pSyms1; - MZ_CLEAR_OBJ(hist); - for (i = 0; i < num_syms; i++) { - mz_uint freq = pSyms0[i].m_key; - hist[freq & 0xFF]++; - hist[256 + ((freq >> 8) & 0xFF)]++; - } - while ((total_passes > 1) && (num_syms == hist[(total_passes - 1) * 256])) - total_passes--; - for (pass_shift = 0, pass = 0; pass < total_passes; pass++, pass_shift += 8) { - const mz_uint32 *pHist = &hist[pass << 8]; - mz_uint offsets[256], cur_ofs = 0; - for (i = 0; i < 256; i++) { - offsets[i] = cur_ofs; - cur_ofs += pHist[i]; - } - for (i = 0; i < num_syms; i++) - pNew_syms[offsets[(pCur_syms[i].m_key >> pass_shift) & 0xFF]++] = - pCur_syms[i]; - { - tdefl_sym_freq *t = pCur_syms; - pCur_syms = pNew_syms; - pNew_syms = t; - } - } - return pCur_syms; -} - -/* tdefl_calculate_minimum_redundancy() originally written by: Alistair Moffat, - * alistair@cs.mu.oz.au, Jyrki Katajainen, jyrki@diku.dk, November 1996. */ -static void tdefl_calculate_minimum_redundancy(tdefl_sym_freq *A, int n) { - int root, leaf, next, avbl, used, dpth; - if (n == 0) - return; - else if (n == 1) { - A[0].m_key = 1; - return; - } - A[0].m_key += A[1].m_key; - root = 0; - leaf = 2; - for (next = 1; next < n - 1; next++) { - if (leaf >= n || A[root].m_key < A[leaf].m_key) { - A[next].m_key = A[root].m_key; - A[root++].m_key = (mz_uint16)next; - } else - A[next].m_key = A[leaf++].m_key; - if (leaf >= n || (root < next && A[root].m_key < A[leaf].m_key)) { - A[next].m_key = (mz_uint16)(A[next].m_key + A[root].m_key); - A[root++].m_key = (mz_uint16)next; - } else - A[next].m_key = (mz_uint16)(A[next].m_key + A[leaf++].m_key); - } - A[n - 2].m_key = 0; - for (next = n - 3; next >= 0; next--) - A[next].m_key = A[A[next].m_key].m_key + 1; - avbl = 1; - used = dpth = 0; - root = n - 2; - next = n - 1; - while (avbl > 0) { - while (root >= 0 && (int)A[root].m_key == dpth) { - used++; - root--; - } - while (avbl > used) { - A[next--].m_key = (mz_uint16)(dpth); - avbl--; - } - avbl = 2 * used; - dpth++; - used = 0; - } -} - -/* Limits canonical Huffman code table's max code size. */ -enum { TDEFL_MAX_SUPPORTED_HUFF_CODESIZE = 32 }; -static void tdefl_huffman_enforce_max_code_size(int *pNum_codes, - int code_list_len, - int max_code_size) { - int i; - mz_uint32 total = 0; - if (code_list_len <= 1) - return; - for (i = max_code_size + 1; i <= TDEFL_MAX_SUPPORTED_HUFF_CODESIZE; i++) - pNum_codes[max_code_size] += pNum_codes[i]; - for (i = max_code_size; i > 0; i--) - total += (((mz_uint32)pNum_codes[i]) << (max_code_size - i)); - while (total != (1UL << max_code_size)) { - pNum_codes[max_code_size]--; - for (i = max_code_size - 1; i > 0; i--) - if (pNum_codes[i]) { - pNum_codes[i]--; - pNum_codes[i + 1] += 2; - break; - } - total--; - } -} - -static void tdefl_optimize_huffman_table(tdefl_compressor *d, int table_num, - int table_len, int code_size_limit, - int static_table) { - int i, j, l, num_codes[1 + TDEFL_MAX_SUPPORTED_HUFF_CODESIZE]; - mz_uint next_code[TDEFL_MAX_SUPPORTED_HUFF_CODESIZE + 1]; - MZ_CLEAR_OBJ(num_codes); - if (static_table) { - for (i = 0; i < table_len; i++) - num_codes[d->m_huff_code_sizes[table_num][i]]++; - } else { - tdefl_sym_freq syms0[TDEFL_MAX_HUFF_SYMBOLS], syms1[TDEFL_MAX_HUFF_SYMBOLS], - *pSyms; - int num_used_syms = 0; - const mz_uint16 *pSym_count = &d->m_huff_count[table_num][0]; - for (i = 0; i < table_len; i++) - if (pSym_count[i]) { - syms0[num_used_syms].m_key = (mz_uint16)pSym_count[i]; - syms0[num_used_syms++].m_sym_index = (mz_uint16)i; - } - - pSyms = tdefl_radix_sort_syms(num_used_syms, syms0, syms1); - tdefl_calculate_minimum_redundancy(pSyms, num_used_syms); - - for (i = 0; i < num_used_syms; i++) - num_codes[pSyms[i].m_key]++; - - tdefl_huffman_enforce_max_code_size(num_codes, num_used_syms, - code_size_limit); - - MZ_CLEAR_OBJ(d->m_huff_code_sizes[table_num]); - MZ_CLEAR_OBJ(d->m_huff_codes[table_num]); - for (i = 1, j = num_used_syms; i <= code_size_limit; i++) - for (l = num_codes[i]; l > 0; l--) - d->m_huff_code_sizes[table_num][pSyms[--j].m_sym_index] = (mz_uint8)(i); - } - - next_code[1] = 0; - for (j = 0, i = 2; i <= code_size_limit; i++) - next_code[i] = j = ((j + num_codes[i - 1]) << 1); - - for (i = 0; i < table_len; i++) { - mz_uint rev_code = 0, code, code_size; - if ((code_size = d->m_huff_code_sizes[table_num][i]) == 0) - continue; - code = next_code[code_size]++; - for (l = code_size; l > 0; l--, code >>= 1) - rev_code = (rev_code << 1) | (code & 1); - d->m_huff_codes[table_num][i] = (mz_uint16)rev_code; - } -} - -#define TDEFL_PUT_BITS(b, l) \ - do { \ - mz_uint bits = b; \ - mz_uint len = l; \ - MZ_ASSERT(bits <= ((1U << len) - 1U)); \ - d->m_bit_buffer |= (bits << d->m_bits_in); \ - d->m_bits_in += len; \ - while (d->m_bits_in >= 8) { \ - if (d->m_pOutput_buf < d->m_pOutput_buf_end) \ - *d->m_pOutput_buf++ = (mz_uint8)(d->m_bit_buffer); \ - d->m_bit_buffer >>= 8; \ - d->m_bits_in -= 8; \ - } \ - } \ - MZ_MACRO_END - -#define TDEFL_RLE_PREV_CODE_SIZE() \ - { \ - if (rle_repeat_count) { \ - if (rle_repeat_count < 3) { \ - d->m_huff_count[2][prev_code_size] = \ - (mz_uint16)(d->m_huff_count[2][prev_code_size] + \ - rle_repeat_count); \ - while (rle_repeat_count--) \ - packed_code_sizes[num_packed_code_sizes++] = prev_code_size; \ - } else { \ - d->m_huff_count[2][16] = (mz_uint16)(d->m_huff_count[2][16] + 1); \ - packed_code_sizes[num_packed_code_sizes++] = 16; \ - packed_code_sizes[num_packed_code_sizes++] = \ - (mz_uint8)(rle_repeat_count - 3); \ - } \ - rle_repeat_count = 0; \ - } \ - } - -#define TDEFL_RLE_ZERO_CODE_SIZE() \ - { \ - if (rle_z_count) { \ - if (rle_z_count < 3) { \ - d->m_huff_count[2][0] = \ - (mz_uint16)(d->m_huff_count[2][0] + rle_z_count); \ - while (rle_z_count--) \ - packed_code_sizes[num_packed_code_sizes++] = 0; \ - } else if (rle_z_count <= 10) { \ - d->m_huff_count[2][17] = (mz_uint16)(d->m_huff_count[2][17] + 1); \ - packed_code_sizes[num_packed_code_sizes++] = 17; \ - packed_code_sizes[num_packed_code_sizes++] = \ - (mz_uint8)(rle_z_count - 3); \ - } else { \ - d->m_huff_count[2][18] = (mz_uint16)(d->m_huff_count[2][18] + 1); \ - packed_code_sizes[num_packed_code_sizes++] = 18; \ - packed_code_sizes[num_packed_code_sizes++] = \ - (mz_uint8)(rle_z_count - 11); \ - } \ - rle_z_count = 0; \ - } \ - } - -static mz_uint8 s_tdefl_packed_code_size_syms_swizzle[] = { - 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15}; - -static void tdefl_start_dynamic_block(tdefl_compressor *d) { - int num_lit_codes, num_dist_codes, num_bit_lengths; - mz_uint i, total_code_sizes_to_pack, num_packed_code_sizes, rle_z_count, - rle_repeat_count, packed_code_sizes_index; - mz_uint8 - code_sizes_to_pack[TDEFL_MAX_HUFF_SYMBOLS_0 + TDEFL_MAX_HUFF_SYMBOLS_1], - packed_code_sizes[TDEFL_MAX_HUFF_SYMBOLS_0 + TDEFL_MAX_HUFF_SYMBOLS_1], - prev_code_size = 0xFF; - - d->m_huff_count[0][256] = 1; - - tdefl_optimize_huffman_table(d, 0, TDEFL_MAX_HUFF_SYMBOLS_0, 15, MZ_FALSE); - tdefl_optimize_huffman_table(d, 1, TDEFL_MAX_HUFF_SYMBOLS_1, 15, MZ_FALSE); - - for (num_lit_codes = 286; num_lit_codes > 257; num_lit_codes--) - if (d->m_huff_code_sizes[0][num_lit_codes - 1]) - break; - for (num_dist_codes = 30; num_dist_codes > 1; num_dist_codes--) - if (d->m_huff_code_sizes[1][num_dist_codes - 1]) - break; - - memcpy(code_sizes_to_pack, &d->m_huff_code_sizes[0][0], num_lit_codes); - memcpy(code_sizes_to_pack + num_lit_codes, &d->m_huff_code_sizes[1][0], - num_dist_codes); - total_code_sizes_to_pack = num_lit_codes + num_dist_codes; - num_packed_code_sizes = 0; - rle_z_count = 0; - rle_repeat_count = 0; - - memset(&d->m_huff_count[2][0], 0, - sizeof(d->m_huff_count[2][0]) * TDEFL_MAX_HUFF_SYMBOLS_2); - for (i = 0; i < total_code_sizes_to_pack; i++) { - mz_uint8 code_size = code_sizes_to_pack[i]; - if (!code_size) { - TDEFL_RLE_PREV_CODE_SIZE(); - if (++rle_z_count == 138) { - TDEFL_RLE_ZERO_CODE_SIZE(); - } - } else { - TDEFL_RLE_ZERO_CODE_SIZE(); - if (code_size != prev_code_size) { - TDEFL_RLE_PREV_CODE_SIZE(); - d->m_huff_count[2][code_size] = - (mz_uint16)(d->m_huff_count[2][code_size] + 1); - packed_code_sizes[num_packed_code_sizes++] = code_size; - } else if (++rle_repeat_count == 6) { - TDEFL_RLE_PREV_CODE_SIZE(); - } - } - prev_code_size = code_size; - } - if (rle_repeat_count) { - TDEFL_RLE_PREV_CODE_SIZE(); - } else { - TDEFL_RLE_ZERO_CODE_SIZE(); - } - - tdefl_optimize_huffman_table(d, 2, TDEFL_MAX_HUFF_SYMBOLS_2, 7, MZ_FALSE); - - TDEFL_PUT_BITS(2, 2); - - TDEFL_PUT_BITS(num_lit_codes - 257, 5); - TDEFL_PUT_BITS(num_dist_codes - 1, 5); - - for (num_bit_lengths = 18; num_bit_lengths >= 0; num_bit_lengths--) - if (d->m_huff_code_sizes - [2][s_tdefl_packed_code_size_syms_swizzle[num_bit_lengths]]) - break; - num_bit_lengths = MZ_MAX(4, (num_bit_lengths + 1)); - TDEFL_PUT_BITS(num_bit_lengths - 4, 4); - for (i = 0; (int)i < num_bit_lengths; i++) - TDEFL_PUT_BITS( - d->m_huff_code_sizes[2][s_tdefl_packed_code_size_syms_swizzle[i]], 3); - - for (packed_code_sizes_index = 0; - packed_code_sizes_index < num_packed_code_sizes;) { - mz_uint code = packed_code_sizes[packed_code_sizes_index++]; - MZ_ASSERT(code < TDEFL_MAX_HUFF_SYMBOLS_2); - TDEFL_PUT_BITS(d->m_huff_codes[2][code], d->m_huff_code_sizes[2][code]); - if (code >= 16) - TDEFL_PUT_BITS(packed_code_sizes[packed_code_sizes_index++], - "\02\03\07"[code - 16]); - } -} - -static void tdefl_start_static_block(tdefl_compressor *d) { - mz_uint i; - mz_uint8 *p = &d->m_huff_code_sizes[0][0]; - - for (i = 0; i <= 143; ++i) - *p++ = 8; - for (; i <= 255; ++i) - *p++ = 9; - for (; i <= 279; ++i) - *p++ = 7; - for (; i <= 287; ++i) - *p++ = 8; - - memset(d->m_huff_code_sizes[1], 5, 32); - - tdefl_optimize_huffman_table(d, 0, 288, 15, MZ_TRUE); - tdefl_optimize_huffman_table(d, 1, 32, 15, MZ_TRUE); - - TDEFL_PUT_BITS(1, 2); -} - -static const mz_uint mz_bitmasks[17] = { - 0x0000, 0x0001, 0x0003, 0x0007, 0x000F, 0x001F, 0x003F, 0x007F, 0x00FF, - 0x01FF, 0x03FF, 0x07FF, 0x0FFF, 0x1FFF, 0x3FFF, 0x7FFF, 0xFFFF}; - -#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN && \ - MINIZ_HAS_64BIT_REGISTERS -static mz_bool tdefl_compress_lz_codes(tdefl_compressor *d) { - mz_uint flags; - mz_uint8 *pLZ_codes; - mz_uint8 *pOutput_buf = d->m_pOutput_buf; - mz_uint8 *pLZ_code_buf_end = d->m_pLZ_code_buf; - mz_uint64 bit_buffer = d->m_bit_buffer; - mz_uint bits_in = d->m_bits_in; - -#define TDEFL_PUT_BITS_FAST(b, l) \ - { \ - bit_buffer |= (((mz_uint64)(b)) << bits_in); \ - bits_in += (l); \ - } - - flags = 1; - for (pLZ_codes = d->m_lz_code_buf; pLZ_codes < pLZ_code_buf_end; - flags >>= 1) { - if (flags == 1) - flags = *pLZ_codes++ | 0x100; - - if (flags & 1) { - mz_uint s0, s1, n0, n1, sym, num_extra_bits; - mz_uint match_len = pLZ_codes[0], - match_dist = *(const mz_uint16 *)(pLZ_codes + 1); - pLZ_codes += 3; - - MZ_ASSERT(d->m_huff_code_sizes[0][s_tdefl_len_sym[match_len]]); - TDEFL_PUT_BITS_FAST(d->m_huff_codes[0][s_tdefl_len_sym[match_len]], - d->m_huff_code_sizes[0][s_tdefl_len_sym[match_len]]); - TDEFL_PUT_BITS_FAST(match_len & mz_bitmasks[s_tdefl_len_extra[match_len]], - s_tdefl_len_extra[match_len]); - - /* This sequence coaxes MSVC into using cmov's vs. jmp's. */ - s0 = s_tdefl_small_dist_sym[match_dist & 511]; - n0 = s_tdefl_small_dist_extra[match_dist & 511]; - s1 = s_tdefl_large_dist_sym[match_dist >> 8]; - n1 = s_tdefl_large_dist_extra[match_dist >> 8]; - sym = (match_dist < 512) ? s0 : s1; - num_extra_bits = (match_dist < 512) ? n0 : n1; - - MZ_ASSERT(d->m_huff_code_sizes[1][sym]); - TDEFL_PUT_BITS_FAST(d->m_huff_codes[1][sym], - d->m_huff_code_sizes[1][sym]); - TDEFL_PUT_BITS_FAST(match_dist & mz_bitmasks[num_extra_bits], - num_extra_bits); - } else { - mz_uint lit = *pLZ_codes++; - MZ_ASSERT(d->m_huff_code_sizes[0][lit]); - TDEFL_PUT_BITS_FAST(d->m_huff_codes[0][lit], - d->m_huff_code_sizes[0][lit]); - - if (((flags & 2) == 0) && (pLZ_codes < pLZ_code_buf_end)) { - flags >>= 1; - lit = *pLZ_codes++; - MZ_ASSERT(d->m_huff_code_sizes[0][lit]); - TDEFL_PUT_BITS_FAST(d->m_huff_codes[0][lit], - d->m_huff_code_sizes[0][lit]); - - if (((flags & 2) == 0) && (pLZ_codes < pLZ_code_buf_end)) { - flags >>= 1; - lit = *pLZ_codes++; - MZ_ASSERT(d->m_huff_code_sizes[0][lit]); - TDEFL_PUT_BITS_FAST(d->m_huff_codes[0][lit], - d->m_huff_code_sizes[0][lit]); - } - } - } - - if (pOutput_buf >= d->m_pOutput_buf_end) - return MZ_FALSE; - - *(mz_uint64 *)pOutput_buf = bit_buffer; - pOutput_buf += (bits_in >> 3); - bit_buffer >>= (bits_in & ~7); - bits_in &= 7; - } - -#undef TDEFL_PUT_BITS_FAST - - d->m_pOutput_buf = pOutput_buf; - d->m_bits_in = 0; - d->m_bit_buffer = 0; - - while (bits_in) { - mz_uint32 n = MZ_MIN(bits_in, 16); - TDEFL_PUT_BITS((mz_uint)bit_buffer & mz_bitmasks[n], n); - bit_buffer >>= n; - bits_in -= n; - } - - TDEFL_PUT_BITS(d->m_huff_codes[0][256], d->m_huff_code_sizes[0][256]); - - return (d->m_pOutput_buf < d->m_pOutput_buf_end); -} -#else -static mz_bool tdefl_compress_lz_codes(tdefl_compressor *d) { - mz_uint flags; - mz_uint8 *pLZ_codes; - - flags = 1; - for (pLZ_codes = d->m_lz_code_buf; pLZ_codes < d->m_pLZ_code_buf; - flags >>= 1) { - if (flags == 1) - flags = *pLZ_codes++ | 0x100; - if (flags & 1) { - mz_uint sym, num_extra_bits; - mz_uint match_len = pLZ_codes[0], - match_dist = (pLZ_codes[1] | (pLZ_codes[2] << 8)); - pLZ_codes += 3; - - MZ_ASSERT(d->m_huff_code_sizes[0][s_tdefl_len_sym[match_len]]); - TDEFL_PUT_BITS(d->m_huff_codes[0][s_tdefl_len_sym[match_len]], - d->m_huff_code_sizes[0][s_tdefl_len_sym[match_len]]); - TDEFL_PUT_BITS(match_len & mz_bitmasks[s_tdefl_len_extra[match_len]], - s_tdefl_len_extra[match_len]); - - if (match_dist < 512) { - sym = s_tdefl_small_dist_sym[match_dist]; - num_extra_bits = s_tdefl_small_dist_extra[match_dist]; - } else { - sym = s_tdefl_large_dist_sym[match_dist >> 8]; - num_extra_bits = s_tdefl_large_dist_extra[match_dist >> 8]; - } - MZ_ASSERT(d->m_huff_code_sizes[1][sym]); - TDEFL_PUT_BITS(d->m_huff_codes[1][sym], d->m_huff_code_sizes[1][sym]); - TDEFL_PUT_BITS(match_dist & mz_bitmasks[num_extra_bits], num_extra_bits); - } else { - mz_uint lit = *pLZ_codes++; - MZ_ASSERT(d->m_huff_code_sizes[0][lit]); - TDEFL_PUT_BITS(d->m_huff_codes[0][lit], d->m_huff_code_sizes[0][lit]); - } - } - - TDEFL_PUT_BITS(d->m_huff_codes[0][256], d->m_huff_code_sizes[0][256]); - - return (d->m_pOutput_buf < d->m_pOutput_buf_end); -} -#endif /* MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN && \ - MINIZ_HAS_64BIT_REGISTERS */ - -static mz_bool tdefl_compress_block(tdefl_compressor *d, mz_bool static_block) { - if (static_block) - tdefl_start_static_block(d); - else - tdefl_start_dynamic_block(d); - return tdefl_compress_lz_codes(d); -} - -static int tdefl_flush_block(tdefl_compressor *d, int flush) { - mz_uint saved_bit_buf, saved_bits_in; - mz_uint8 *pSaved_output_buf; - mz_bool comp_block_succeeded = MZ_FALSE; - int n, use_raw_block = - ((d->m_flags & TDEFL_FORCE_ALL_RAW_BLOCKS) != 0) && - (d->m_lookahead_pos - d->m_lz_code_buf_dict_pos) <= d->m_dict_size; - mz_uint8 *pOutput_buf_start = - ((d->m_pPut_buf_func == NULL) && - ((*d->m_pOut_buf_size - d->m_out_buf_ofs) >= TDEFL_OUT_BUF_SIZE)) - ? ((mz_uint8 *)d->m_pOut_buf + d->m_out_buf_ofs) - : d->m_output_buf; - - d->m_pOutput_buf = pOutput_buf_start; - d->m_pOutput_buf_end = d->m_pOutput_buf + TDEFL_OUT_BUF_SIZE - 16; - - MZ_ASSERT(!d->m_output_flush_remaining); - d->m_output_flush_ofs = 0; - d->m_output_flush_remaining = 0; - - *d->m_pLZ_flags = (mz_uint8)(*d->m_pLZ_flags >> d->m_num_flags_left); - d->m_pLZ_code_buf -= (d->m_num_flags_left == 8); - - if ((d->m_flags & TDEFL_WRITE_ZLIB_HEADER) && (!d->m_block_index)) { - TDEFL_PUT_BITS(0x78, 8); - TDEFL_PUT_BITS(0x01, 8); - } - - TDEFL_PUT_BITS(flush == TDEFL_FINISH, 1); - - pSaved_output_buf = d->m_pOutput_buf; - saved_bit_buf = d->m_bit_buffer; - saved_bits_in = d->m_bits_in; - - if (!use_raw_block) - comp_block_succeeded = - tdefl_compress_block(d, (d->m_flags & TDEFL_FORCE_ALL_STATIC_BLOCKS) || - (d->m_total_lz_bytes < 48)); - - /* If the block gets expanded, forget the current contents of the output - * buffer and send a raw block instead. */ - if (((use_raw_block) || - ((d->m_total_lz_bytes) && ((d->m_pOutput_buf - pSaved_output_buf + 1U) >= - d->m_total_lz_bytes))) && - ((d->m_lookahead_pos - d->m_lz_code_buf_dict_pos) <= d->m_dict_size)) { - mz_uint i; - d->m_pOutput_buf = pSaved_output_buf; - d->m_bit_buffer = saved_bit_buf, d->m_bits_in = saved_bits_in; - TDEFL_PUT_BITS(0, 2); - if (d->m_bits_in) { - TDEFL_PUT_BITS(0, 8 - d->m_bits_in); - } - for (i = 2; i; --i, d->m_total_lz_bytes ^= 0xFFFF) { - TDEFL_PUT_BITS(d->m_total_lz_bytes & 0xFFFF, 16); - } - for (i = 0; i < d->m_total_lz_bytes; ++i) { - TDEFL_PUT_BITS( - d->m_dict[(d->m_lz_code_buf_dict_pos + i) & TDEFL_LZ_DICT_SIZE_MASK], - 8); - } - } - /* Check for the extremely unlikely (if not impossible) case of the compressed - block not fitting into the output buffer when using dynamic codes. */ - else if (!comp_block_succeeded) { - d->m_pOutput_buf = pSaved_output_buf; - d->m_bit_buffer = saved_bit_buf, d->m_bits_in = saved_bits_in; - tdefl_compress_block(d, MZ_TRUE); - } - - if (flush) { - if (flush == TDEFL_FINISH) { - if (d->m_bits_in) { - TDEFL_PUT_BITS(0, 8 - d->m_bits_in); - } - if (d->m_flags & TDEFL_WRITE_ZLIB_HEADER) { - mz_uint i, a = d->m_adler32; - for (i = 0; i < 4; i++) { - TDEFL_PUT_BITS((a >> 24) & 0xFF, 8); - a <<= 8; - } - } - } else { - mz_uint i, z = 0; - TDEFL_PUT_BITS(0, 3); - if (d->m_bits_in) { - TDEFL_PUT_BITS(0, 8 - d->m_bits_in); - } - for (i = 2; i; --i, z ^= 0xFFFF) { - TDEFL_PUT_BITS(z & 0xFFFF, 16); - } - } - } - - MZ_ASSERT(d->m_pOutput_buf < d->m_pOutput_buf_end); - - memset(&d->m_huff_count[0][0], 0, - sizeof(d->m_huff_count[0][0]) * TDEFL_MAX_HUFF_SYMBOLS_0); - memset(&d->m_huff_count[1][0], 0, - sizeof(d->m_huff_count[1][0]) * TDEFL_MAX_HUFF_SYMBOLS_1); - - d->m_pLZ_code_buf = d->m_lz_code_buf + 1; - d->m_pLZ_flags = d->m_lz_code_buf; - d->m_num_flags_left = 8; - d->m_lz_code_buf_dict_pos += d->m_total_lz_bytes; - d->m_total_lz_bytes = 0; - d->m_block_index++; - - if ((n = (int)(d->m_pOutput_buf - pOutput_buf_start)) != 0) { - if (d->m_pPut_buf_func) { - *d->m_pIn_buf_size = d->m_pSrc - (const mz_uint8 *)d->m_pIn_buf; - if (!(*d->m_pPut_buf_func)(d->m_output_buf, n, d->m_pPut_buf_user)) - return (d->m_prev_return_status = TDEFL_STATUS_PUT_BUF_FAILED); - } else if (pOutput_buf_start == d->m_output_buf) { - int bytes_to_copy = (int)MZ_MIN( - (size_t)n, (size_t)(*d->m_pOut_buf_size - d->m_out_buf_ofs)); - memcpy((mz_uint8 *)d->m_pOut_buf + d->m_out_buf_ofs, d->m_output_buf, - bytes_to_copy); - d->m_out_buf_ofs += bytes_to_copy; - if ((n -= bytes_to_copy) != 0) { - d->m_output_flush_ofs = bytes_to_copy; - d->m_output_flush_remaining = n; - } - } else { - d->m_out_buf_ofs += n; - } - } - - return d->m_output_flush_remaining; -} - -#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES -#ifdef MINIZ_UNALIGNED_USE_MEMCPY -static mz_uint16 TDEFL_READ_UNALIGNED_WORD(const mz_uint8 *p) { - mz_uint16 ret; - memcpy(&ret, p, sizeof(mz_uint16)); - return ret; -} -static mz_uint16 TDEFL_READ_UNALIGNED_WORD2(const mz_uint16 *p) { - mz_uint16 ret; - memcpy(&ret, p, sizeof(mz_uint16)); - return ret; -} -#else -#define TDEFL_READ_UNALIGNED_WORD(p) *(const mz_uint16 *)(p) -#define TDEFL_READ_UNALIGNED_WORD2(p) *(const mz_uint16 *)(p) -#endif -static MZ_FORCEINLINE void -tdefl_find_match(tdefl_compressor *d, mz_uint lookahead_pos, mz_uint max_dist, - mz_uint max_match_len, mz_uint *pMatch_dist, - mz_uint *pMatch_len) { - mz_uint dist, pos = lookahead_pos & TDEFL_LZ_DICT_SIZE_MASK, - match_len = *pMatch_len, probe_pos = pos, next_probe_pos, - probe_len; - mz_uint num_probes_left = d->m_max_probes[match_len >= 32]; - const mz_uint16 *s = (const mz_uint16 *)(d->m_dict + pos), *p, *q; - mz_uint16 c01 = TDEFL_READ_UNALIGNED_WORD(&d->m_dict[pos + match_len - 1]), - s01 = TDEFL_READ_UNALIGNED_WORD2(s); - MZ_ASSERT(max_match_len <= TDEFL_MAX_MATCH_LEN); - if (max_match_len <= match_len) - return; - for (;;) { - for (;;) { - if (--num_probes_left == 0) - return; -#define TDEFL_PROBE \ - next_probe_pos = d->m_next[probe_pos]; \ - if ((!next_probe_pos) || \ - ((dist = (mz_uint16)(lookahead_pos - next_probe_pos)) > max_dist)) \ - return; \ - probe_pos = next_probe_pos & TDEFL_LZ_DICT_SIZE_MASK; \ - if (TDEFL_READ_UNALIGNED_WORD(&d->m_dict[probe_pos + match_len - 1]) == c01) \ - break; - TDEFL_PROBE; - TDEFL_PROBE; - TDEFL_PROBE; - } - if (!dist) - break; - q = (const mz_uint16 *)(d->m_dict + probe_pos); - if (TDEFL_READ_UNALIGNED_WORD2(q) != s01) - continue; - p = s; - probe_len = 32; - do { - } while ( - (TDEFL_READ_UNALIGNED_WORD2(++p) == TDEFL_READ_UNALIGNED_WORD2(++q)) && - (TDEFL_READ_UNALIGNED_WORD2(++p) == TDEFL_READ_UNALIGNED_WORD2(++q)) && - (TDEFL_READ_UNALIGNED_WORD2(++p) == TDEFL_READ_UNALIGNED_WORD2(++q)) && - (TDEFL_READ_UNALIGNED_WORD2(++p) == TDEFL_READ_UNALIGNED_WORD2(++q)) && - (--probe_len > 0)); - if (!probe_len) { - *pMatch_dist = dist; - *pMatch_len = MZ_MIN(max_match_len, (mz_uint)TDEFL_MAX_MATCH_LEN); - break; - } else if ((probe_len = ((mz_uint)(p - s) * 2) + - (mz_uint)(*(const mz_uint8 *)p == - *(const mz_uint8 *)q)) > match_len) { - *pMatch_dist = dist; - if ((*pMatch_len = match_len = MZ_MIN(max_match_len, probe_len)) == - max_match_len) - break; - c01 = TDEFL_READ_UNALIGNED_WORD(&d->m_dict[pos + match_len - 1]); - } - } -} -#else -static MZ_FORCEINLINE void -tdefl_find_match(tdefl_compressor *d, mz_uint lookahead_pos, mz_uint max_dist, - mz_uint max_match_len, mz_uint *pMatch_dist, - mz_uint *pMatch_len) { - mz_uint dist, pos = lookahead_pos & TDEFL_LZ_DICT_SIZE_MASK, - match_len = *pMatch_len, probe_pos = pos, next_probe_pos, - probe_len; - mz_uint num_probes_left = d->m_max_probes[match_len >= 32]; - const mz_uint8 *s = d->m_dict + pos, *p, *q; - mz_uint8 c0 = d->m_dict[pos + match_len], c1 = d->m_dict[pos + match_len - 1]; - MZ_ASSERT(max_match_len <= TDEFL_MAX_MATCH_LEN); - if (max_match_len <= match_len) - return; - for (;;) { - for (;;) { - if (--num_probes_left == 0) - return; -#define TDEFL_PROBE \ - next_probe_pos = d->m_next[probe_pos]; \ - if ((!next_probe_pos) || \ - ((dist = (mz_uint16)(lookahead_pos - next_probe_pos)) > max_dist)) \ - return; \ - probe_pos = next_probe_pos & TDEFL_LZ_DICT_SIZE_MASK; \ - if ((d->m_dict[probe_pos + match_len] == c0) && \ - (d->m_dict[probe_pos + match_len - 1] == c1)) \ - break; - TDEFL_PROBE; - TDEFL_PROBE; - TDEFL_PROBE; - } - if (!dist) - break; - p = s; - q = d->m_dict + probe_pos; - for (probe_len = 0; probe_len < max_match_len; probe_len++) - if (*p++ != *q++) - break; - if (probe_len > match_len) { - *pMatch_dist = dist; - if ((*pMatch_len = match_len = probe_len) == max_match_len) - return; - c0 = d->m_dict[pos + match_len]; - c1 = d->m_dict[pos + match_len - 1]; - } - } -} -#endif /* #if MINIZ_USE_UNALIGNED_LOADS_AND_STORES */ - -#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN -#ifdef MINIZ_UNALIGNED_USE_MEMCPY -static mz_uint32 TDEFL_READ_UNALIGNED_WORD32(const mz_uint8 *p) { - mz_uint32 ret; - memcpy(&ret, p, sizeof(mz_uint32)); - return ret; -} -#else -#define TDEFL_READ_UNALIGNED_WORD32(p) *(const mz_uint32 *)(p) -#endif -static mz_bool tdefl_compress_fast(tdefl_compressor *d) { - /* Faster, minimally featured LZRW1-style match+parse loop with better - * register utilization. Intended for applications where raw throughput is - * valued more highly than ratio. */ - mz_uint lookahead_pos = d->m_lookahead_pos, - lookahead_size = d->m_lookahead_size, dict_size = d->m_dict_size, - total_lz_bytes = d->m_total_lz_bytes, - num_flags_left = d->m_num_flags_left; - mz_uint8 *pLZ_code_buf = d->m_pLZ_code_buf, *pLZ_flags = d->m_pLZ_flags; - mz_uint cur_pos = lookahead_pos & TDEFL_LZ_DICT_SIZE_MASK; - - while ((d->m_src_buf_left) || ((d->m_flush) && (lookahead_size))) { - const mz_uint TDEFL_COMP_FAST_LOOKAHEAD_SIZE = 4096; - mz_uint dst_pos = - (lookahead_pos + lookahead_size) & TDEFL_LZ_DICT_SIZE_MASK; - mz_uint num_bytes_to_process = (mz_uint)MZ_MIN( - d->m_src_buf_left, TDEFL_COMP_FAST_LOOKAHEAD_SIZE - lookahead_size); - d->m_src_buf_left -= num_bytes_to_process; - lookahead_size += num_bytes_to_process; - - while (num_bytes_to_process) { - mz_uint32 n = MZ_MIN(TDEFL_LZ_DICT_SIZE - dst_pos, num_bytes_to_process); - memcpy(d->m_dict + dst_pos, d->m_pSrc, n); - if (dst_pos < (TDEFL_MAX_MATCH_LEN - 1)) - memcpy(d->m_dict + TDEFL_LZ_DICT_SIZE + dst_pos, d->m_pSrc, - MZ_MIN(n, (TDEFL_MAX_MATCH_LEN - 1) - dst_pos)); - d->m_pSrc += n; - dst_pos = (dst_pos + n) & TDEFL_LZ_DICT_SIZE_MASK; - num_bytes_to_process -= n; - } - - dict_size = MZ_MIN(TDEFL_LZ_DICT_SIZE - lookahead_size, dict_size); - if ((!d->m_flush) && (lookahead_size < TDEFL_COMP_FAST_LOOKAHEAD_SIZE)) - break; - - while (lookahead_size >= 4) { - mz_uint cur_match_dist, cur_match_len = 1; - mz_uint8 *pCur_dict = d->m_dict + cur_pos; - mz_uint first_trigram = TDEFL_READ_UNALIGNED_WORD32(pCur_dict) & 0xFFFFFF; - mz_uint hash = - (first_trigram ^ (first_trigram >> (24 - (TDEFL_LZ_HASH_BITS - 8)))) & - TDEFL_LEVEL1_HASH_SIZE_MASK; - mz_uint probe_pos = d->m_hash[hash]; - d->m_hash[hash] = (mz_uint16)lookahead_pos; - - if (((cur_match_dist = (mz_uint16)(lookahead_pos - probe_pos)) <= - dict_size) && - ((TDEFL_READ_UNALIGNED_WORD32( - d->m_dict + (probe_pos &= TDEFL_LZ_DICT_SIZE_MASK)) & - 0xFFFFFF) == first_trigram)) { - const mz_uint16 *p = (const mz_uint16 *)pCur_dict; - const mz_uint16 *q = (const mz_uint16 *)(d->m_dict + probe_pos); - mz_uint32 probe_len = 32; - do { - } while ((TDEFL_READ_UNALIGNED_WORD2(++p) == - TDEFL_READ_UNALIGNED_WORD2(++q)) && - (TDEFL_READ_UNALIGNED_WORD2(++p) == - TDEFL_READ_UNALIGNED_WORD2(++q)) && - (TDEFL_READ_UNALIGNED_WORD2(++p) == - TDEFL_READ_UNALIGNED_WORD2(++q)) && - (TDEFL_READ_UNALIGNED_WORD2(++p) == - TDEFL_READ_UNALIGNED_WORD2(++q)) && - (--probe_len > 0)); - cur_match_len = ((mz_uint)(p - (const mz_uint16 *)pCur_dict) * 2) + - (mz_uint)(*(const mz_uint8 *)p == *(const mz_uint8 *)q); - if (!probe_len) - cur_match_len = cur_match_dist ? TDEFL_MAX_MATCH_LEN : 0; - - if ((cur_match_len < TDEFL_MIN_MATCH_LEN) || - ((cur_match_len == TDEFL_MIN_MATCH_LEN) && - (cur_match_dist >= 8U * 1024U))) { - cur_match_len = 1; - *pLZ_code_buf++ = (mz_uint8)first_trigram; - *pLZ_flags = (mz_uint8)(*pLZ_flags >> 1); - d->m_huff_count[0][(mz_uint8)first_trigram]++; - } else { - mz_uint32 s0, s1; - cur_match_len = MZ_MIN(cur_match_len, lookahead_size); - - MZ_ASSERT((cur_match_len >= TDEFL_MIN_MATCH_LEN) && - (cur_match_dist >= 1) && - (cur_match_dist <= TDEFL_LZ_DICT_SIZE)); - - cur_match_dist--; - - pLZ_code_buf[0] = (mz_uint8)(cur_match_len - TDEFL_MIN_MATCH_LEN); -#ifdef MINIZ_UNALIGNED_USE_MEMCPY - memcpy(&pLZ_code_buf[1], &cur_match_dist, sizeof(cur_match_dist)); -#else - *(mz_uint16 *)(&pLZ_code_buf[1]) = (mz_uint16)cur_match_dist; -#endif - pLZ_code_buf += 3; - *pLZ_flags = (mz_uint8)((*pLZ_flags >> 1) | 0x80); - - s0 = s_tdefl_small_dist_sym[cur_match_dist & 511]; - s1 = s_tdefl_large_dist_sym[cur_match_dist >> 8]; - d->m_huff_count[1][(cur_match_dist < 512) ? s0 : s1]++; - - d->m_huff_count[0][s_tdefl_len_sym[cur_match_len - - TDEFL_MIN_MATCH_LEN]]++; - } - } else { - *pLZ_code_buf++ = (mz_uint8)first_trigram; - *pLZ_flags = (mz_uint8)(*pLZ_flags >> 1); - d->m_huff_count[0][(mz_uint8)first_trigram]++; - } - - if (--num_flags_left == 0) { - num_flags_left = 8; - pLZ_flags = pLZ_code_buf++; - } - - total_lz_bytes += cur_match_len; - lookahead_pos += cur_match_len; - dict_size = - MZ_MIN(dict_size + cur_match_len, (mz_uint)TDEFL_LZ_DICT_SIZE); - cur_pos = (cur_pos + cur_match_len) & TDEFL_LZ_DICT_SIZE_MASK; - MZ_ASSERT(lookahead_size >= cur_match_len); - lookahead_size -= cur_match_len; - - if (pLZ_code_buf > &d->m_lz_code_buf[TDEFL_LZ_CODE_BUF_SIZE - 8]) { - int n; - d->m_lookahead_pos = lookahead_pos; - d->m_lookahead_size = lookahead_size; - d->m_dict_size = dict_size; - d->m_total_lz_bytes = total_lz_bytes; - d->m_pLZ_code_buf = pLZ_code_buf; - d->m_pLZ_flags = pLZ_flags; - d->m_num_flags_left = num_flags_left; - if ((n = tdefl_flush_block(d, 0)) != 0) - return (n < 0) ? MZ_FALSE : MZ_TRUE; - total_lz_bytes = d->m_total_lz_bytes; - pLZ_code_buf = d->m_pLZ_code_buf; - pLZ_flags = d->m_pLZ_flags; - num_flags_left = d->m_num_flags_left; - } - } - - while (lookahead_size) { - mz_uint8 lit = d->m_dict[cur_pos]; - - total_lz_bytes++; - *pLZ_code_buf++ = lit; - *pLZ_flags = (mz_uint8)(*pLZ_flags >> 1); - if (--num_flags_left == 0) { - num_flags_left = 8; - pLZ_flags = pLZ_code_buf++; - } - - d->m_huff_count[0][lit]++; - - lookahead_pos++; - dict_size = MZ_MIN(dict_size + 1, (mz_uint)TDEFL_LZ_DICT_SIZE); - cur_pos = (cur_pos + 1) & TDEFL_LZ_DICT_SIZE_MASK; - lookahead_size--; - - if (pLZ_code_buf > &d->m_lz_code_buf[TDEFL_LZ_CODE_BUF_SIZE - 8]) { - int n; - d->m_lookahead_pos = lookahead_pos; - d->m_lookahead_size = lookahead_size; - d->m_dict_size = dict_size; - d->m_total_lz_bytes = total_lz_bytes; - d->m_pLZ_code_buf = pLZ_code_buf; - d->m_pLZ_flags = pLZ_flags; - d->m_num_flags_left = num_flags_left; - if ((n = tdefl_flush_block(d, 0)) != 0) - return (n < 0) ? MZ_FALSE : MZ_TRUE; - total_lz_bytes = d->m_total_lz_bytes; - pLZ_code_buf = d->m_pLZ_code_buf; - pLZ_flags = d->m_pLZ_flags; - num_flags_left = d->m_num_flags_left; - } - } - } - - d->m_lookahead_pos = lookahead_pos; - d->m_lookahead_size = lookahead_size; - d->m_dict_size = dict_size; - d->m_total_lz_bytes = total_lz_bytes; - d->m_pLZ_code_buf = pLZ_code_buf; - d->m_pLZ_flags = pLZ_flags; - d->m_num_flags_left = num_flags_left; - return MZ_TRUE; -} -#endif /* MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN */ - -static MZ_FORCEINLINE void tdefl_record_literal(tdefl_compressor *d, - mz_uint8 lit) { - d->m_total_lz_bytes++; - *d->m_pLZ_code_buf++ = lit; - *d->m_pLZ_flags = (mz_uint8)(*d->m_pLZ_flags >> 1); - if (--d->m_num_flags_left == 0) { - d->m_num_flags_left = 8; - d->m_pLZ_flags = d->m_pLZ_code_buf++; - } - d->m_huff_count[0][lit]++; -} - -static MZ_FORCEINLINE void -tdefl_record_match(tdefl_compressor *d, mz_uint match_len, mz_uint match_dist) { - mz_uint32 s0, s1; - - MZ_ASSERT((match_len >= TDEFL_MIN_MATCH_LEN) && (match_dist >= 1) && - (match_dist <= TDEFL_LZ_DICT_SIZE)); - - d->m_total_lz_bytes += match_len; - - d->m_pLZ_code_buf[0] = (mz_uint8)(match_len - TDEFL_MIN_MATCH_LEN); - - match_dist -= 1; - d->m_pLZ_code_buf[1] = (mz_uint8)(match_dist & 0xFF); - d->m_pLZ_code_buf[2] = (mz_uint8)(match_dist >> 8); - d->m_pLZ_code_buf += 3; - - *d->m_pLZ_flags = (mz_uint8)((*d->m_pLZ_flags >> 1) | 0x80); - if (--d->m_num_flags_left == 0) { - d->m_num_flags_left = 8; - d->m_pLZ_flags = d->m_pLZ_code_buf++; - } - - s0 = s_tdefl_small_dist_sym[match_dist & 511]; - s1 = s_tdefl_large_dist_sym[(match_dist >> 8) & 127]; - d->m_huff_count[1][(match_dist < 512) ? s0 : s1]++; - d->m_huff_count[0][s_tdefl_len_sym[match_len - TDEFL_MIN_MATCH_LEN]]++; -} - -static mz_bool tdefl_compress_normal(tdefl_compressor *d) { - const mz_uint8 *pSrc = d->m_pSrc; - size_t src_buf_left = d->m_src_buf_left; - tdefl_flush flush = d->m_flush; - - while ((src_buf_left) || ((flush) && (d->m_lookahead_size))) { - mz_uint len_to_move, cur_match_dist, cur_match_len, cur_pos; - /* Update dictionary and hash chains. Keeps the lookahead size equal to - * TDEFL_MAX_MATCH_LEN. */ - if ((d->m_lookahead_size + d->m_dict_size) >= (TDEFL_MIN_MATCH_LEN - 1)) { - mz_uint dst_pos = (d->m_lookahead_pos + d->m_lookahead_size) & - TDEFL_LZ_DICT_SIZE_MASK, - ins_pos = d->m_lookahead_pos + d->m_lookahead_size - 2; - mz_uint hash = (d->m_dict[ins_pos & TDEFL_LZ_DICT_SIZE_MASK] - << TDEFL_LZ_HASH_SHIFT) ^ - d->m_dict[(ins_pos + 1) & TDEFL_LZ_DICT_SIZE_MASK]; - mz_uint num_bytes_to_process = (mz_uint)MZ_MIN( - src_buf_left, TDEFL_MAX_MATCH_LEN - d->m_lookahead_size); - const mz_uint8 *pSrc_end = pSrc + num_bytes_to_process; - src_buf_left -= num_bytes_to_process; - d->m_lookahead_size += num_bytes_to_process; - while (pSrc != pSrc_end) { - mz_uint8 c = *pSrc++; - d->m_dict[dst_pos] = c; - if (dst_pos < (TDEFL_MAX_MATCH_LEN - 1)) - d->m_dict[TDEFL_LZ_DICT_SIZE + dst_pos] = c; - hash = ((hash << TDEFL_LZ_HASH_SHIFT) ^ c) & (TDEFL_LZ_HASH_SIZE - 1); - d->m_next[ins_pos & TDEFL_LZ_DICT_SIZE_MASK] = d->m_hash[hash]; - d->m_hash[hash] = (mz_uint16)(ins_pos); - dst_pos = (dst_pos + 1) & TDEFL_LZ_DICT_SIZE_MASK; - ins_pos++; - } - } else { - while ((src_buf_left) && (d->m_lookahead_size < TDEFL_MAX_MATCH_LEN)) { - mz_uint8 c = *pSrc++; - mz_uint dst_pos = (d->m_lookahead_pos + d->m_lookahead_size) & - TDEFL_LZ_DICT_SIZE_MASK; - src_buf_left--; - d->m_dict[dst_pos] = c; - if (dst_pos < (TDEFL_MAX_MATCH_LEN - 1)) - d->m_dict[TDEFL_LZ_DICT_SIZE + dst_pos] = c; - if ((++d->m_lookahead_size + d->m_dict_size) >= TDEFL_MIN_MATCH_LEN) { - mz_uint ins_pos = d->m_lookahead_pos + (d->m_lookahead_size - 1) - 2; - mz_uint hash = ((d->m_dict[ins_pos & TDEFL_LZ_DICT_SIZE_MASK] - << (TDEFL_LZ_HASH_SHIFT * 2)) ^ - (d->m_dict[(ins_pos + 1) & TDEFL_LZ_DICT_SIZE_MASK] - << TDEFL_LZ_HASH_SHIFT) ^ - c) & - (TDEFL_LZ_HASH_SIZE - 1); - d->m_next[ins_pos & TDEFL_LZ_DICT_SIZE_MASK] = d->m_hash[hash]; - d->m_hash[hash] = (mz_uint16)(ins_pos); - } - } - } - d->m_dict_size = - MZ_MIN(TDEFL_LZ_DICT_SIZE - d->m_lookahead_size, d->m_dict_size); - if ((!flush) && (d->m_lookahead_size < TDEFL_MAX_MATCH_LEN)) - break; - - /* Simple lazy/greedy parsing state machine. */ - len_to_move = 1; - cur_match_dist = 0; - cur_match_len = - d->m_saved_match_len ? d->m_saved_match_len : (TDEFL_MIN_MATCH_LEN - 1); - cur_pos = d->m_lookahead_pos & TDEFL_LZ_DICT_SIZE_MASK; - if (d->m_flags & (TDEFL_RLE_MATCHES | TDEFL_FORCE_ALL_RAW_BLOCKS)) { - if ((d->m_dict_size) && (!(d->m_flags & TDEFL_FORCE_ALL_RAW_BLOCKS))) { - mz_uint8 c = d->m_dict[(cur_pos - 1) & TDEFL_LZ_DICT_SIZE_MASK]; - cur_match_len = 0; - while (cur_match_len < d->m_lookahead_size) { - if (d->m_dict[cur_pos + cur_match_len] != c) - break; - cur_match_len++; - } - if (cur_match_len < TDEFL_MIN_MATCH_LEN) - cur_match_len = 0; - else - cur_match_dist = 1; - } - } else { - tdefl_find_match(d, d->m_lookahead_pos, d->m_dict_size, - d->m_lookahead_size, &cur_match_dist, &cur_match_len); - } - if (((cur_match_len == TDEFL_MIN_MATCH_LEN) && - (cur_match_dist >= 8U * 1024U)) || - (cur_pos == cur_match_dist) || - ((d->m_flags & TDEFL_FILTER_MATCHES) && (cur_match_len <= 5))) { - cur_match_dist = cur_match_len = 0; - } - if (d->m_saved_match_len) { - if (cur_match_len > d->m_saved_match_len) { - tdefl_record_literal(d, (mz_uint8)d->m_saved_lit); - if (cur_match_len >= 128) { - tdefl_record_match(d, cur_match_len, cur_match_dist); - d->m_saved_match_len = 0; - len_to_move = cur_match_len; - } else { - d->m_saved_lit = d->m_dict[cur_pos]; - d->m_saved_match_dist = cur_match_dist; - d->m_saved_match_len = cur_match_len; - } - } else { - tdefl_record_match(d, d->m_saved_match_len, d->m_saved_match_dist); - len_to_move = d->m_saved_match_len - 1; - d->m_saved_match_len = 0; - } - } else if (!cur_match_dist) - tdefl_record_literal(d, - d->m_dict[MZ_MIN(cur_pos, sizeof(d->m_dict) - 1)]); - else if ((d->m_greedy_parsing) || (d->m_flags & TDEFL_RLE_MATCHES) || - (cur_match_len >= 128)) { - tdefl_record_match(d, cur_match_len, cur_match_dist); - len_to_move = cur_match_len; - } else { - d->m_saved_lit = d->m_dict[MZ_MIN(cur_pos, sizeof(d->m_dict) - 1)]; - d->m_saved_match_dist = cur_match_dist; - d->m_saved_match_len = cur_match_len; - } - /* Move the lookahead forward by len_to_move bytes. */ - d->m_lookahead_pos += len_to_move; - MZ_ASSERT(d->m_lookahead_size >= len_to_move); - d->m_lookahead_size -= len_to_move; - d->m_dict_size = - MZ_MIN(d->m_dict_size + len_to_move, (mz_uint)TDEFL_LZ_DICT_SIZE); - /* Check if it's time to flush the current LZ codes to the internal output - * buffer. */ - if ((d->m_pLZ_code_buf > &d->m_lz_code_buf[TDEFL_LZ_CODE_BUF_SIZE - 8]) || - ((d->m_total_lz_bytes > 31 * 1024) && - (((((mz_uint)(d->m_pLZ_code_buf - d->m_lz_code_buf) * 115) >> 7) >= - d->m_total_lz_bytes) || - (d->m_flags & TDEFL_FORCE_ALL_RAW_BLOCKS)))) { - int n; - d->m_pSrc = pSrc; - d->m_src_buf_left = src_buf_left; - if ((n = tdefl_flush_block(d, 0)) != 0) - return (n < 0) ? MZ_FALSE : MZ_TRUE; - } - } - - d->m_pSrc = pSrc; - d->m_src_buf_left = src_buf_left; - return MZ_TRUE; -} - -static tdefl_status tdefl_flush_output_buffer(tdefl_compressor *d) { - if (d->m_pIn_buf_size) { - *d->m_pIn_buf_size = d->m_pSrc - (const mz_uint8 *)d->m_pIn_buf; - } - - if (d->m_pOut_buf_size) { - size_t n = MZ_MIN(*d->m_pOut_buf_size - d->m_out_buf_ofs, - d->m_output_flush_remaining); - memcpy((mz_uint8 *)d->m_pOut_buf + d->m_out_buf_ofs, - d->m_output_buf + d->m_output_flush_ofs, n); - d->m_output_flush_ofs += (mz_uint)n; - d->m_output_flush_remaining -= (mz_uint)n; - d->m_out_buf_ofs += n; - - *d->m_pOut_buf_size = d->m_out_buf_ofs; - } - - return (d->m_finished && !d->m_output_flush_remaining) ? TDEFL_STATUS_DONE - : TDEFL_STATUS_OKAY; -} - -tdefl_status tdefl_compress(tdefl_compressor *d, const void *pIn_buf, - size_t *pIn_buf_size, void *pOut_buf, - size_t *pOut_buf_size, tdefl_flush flush) { - if (!d) { - if (pIn_buf_size) - *pIn_buf_size = 0; - if (pOut_buf_size) - *pOut_buf_size = 0; - return TDEFL_STATUS_BAD_PARAM; - } - - d->m_pIn_buf = pIn_buf; - d->m_pIn_buf_size = pIn_buf_size; - d->m_pOut_buf = pOut_buf; - d->m_pOut_buf_size = pOut_buf_size; - d->m_pSrc = (const mz_uint8 *)(pIn_buf); - d->m_src_buf_left = pIn_buf_size ? *pIn_buf_size : 0; - d->m_out_buf_ofs = 0; - d->m_flush = flush; - - if (((d->m_pPut_buf_func != NULL) == - ((pOut_buf != NULL) || (pOut_buf_size != NULL))) || - (d->m_prev_return_status != TDEFL_STATUS_OKAY) || - (d->m_wants_to_finish && (flush != TDEFL_FINISH)) || - (pIn_buf_size && *pIn_buf_size && !pIn_buf) || - (pOut_buf_size && *pOut_buf_size && !pOut_buf)) { - if (pIn_buf_size) - *pIn_buf_size = 0; - if (pOut_buf_size) - *pOut_buf_size = 0; - return (d->m_prev_return_status = TDEFL_STATUS_BAD_PARAM); - } - d->m_wants_to_finish |= (flush == TDEFL_FINISH); - - if ((d->m_output_flush_remaining) || (d->m_finished)) - return (d->m_prev_return_status = tdefl_flush_output_buffer(d)); - -#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN - if (((d->m_flags & TDEFL_MAX_PROBES_MASK) == 1) && - ((d->m_flags & TDEFL_GREEDY_PARSING_FLAG) != 0) && - ((d->m_flags & (TDEFL_FILTER_MATCHES | TDEFL_FORCE_ALL_RAW_BLOCKS | - TDEFL_RLE_MATCHES)) == 0)) { - if (!tdefl_compress_fast(d)) - return d->m_prev_return_status; - } else -#endif /* #if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN */ - { - if (!tdefl_compress_normal(d)) - return d->m_prev_return_status; - } - - if ((d->m_flags & (TDEFL_WRITE_ZLIB_HEADER | TDEFL_COMPUTE_ADLER32)) && - (pIn_buf)) - d->m_adler32 = - (mz_uint32)mz_adler32(d->m_adler32, (const mz_uint8 *)pIn_buf, - d->m_pSrc - (const mz_uint8 *)pIn_buf); - - if ((flush) && (!d->m_lookahead_size) && (!d->m_src_buf_left) && - (!d->m_output_flush_remaining)) { - if (tdefl_flush_block(d, flush) < 0) - return d->m_prev_return_status; - d->m_finished = (flush == TDEFL_FINISH); - if (flush == TDEFL_FULL_FLUSH) { - MZ_CLEAR_OBJ(d->m_hash); - MZ_CLEAR_OBJ(d->m_next); - d->m_dict_size = 0; - } - } - - return (d->m_prev_return_status = tdefl_flush_output_buffer(d)); -} - -tdefl_status tdefl_compress_buffer(tdefl_compressor *d, const void *pIn_buf, - size_t in_buf_size, tdefl_flush flush) { - MZ_ASSERT(d->m_pPut_buf_func); - return tdefl_compress(d, pIn_buf, &in_buf_size, NULL, NULL, flush); -} - -tdefl_status tdefl_init(tdefl_compressor *d, - tdefl_put_buf_func_ptr pPut_buf_func, - void *pPut_buf_user, int flags) { - d->m_pPut_buf_func = pPut_buf_func; - d->m_pPut_buf_user = pPut_buf_user; - d->m_flags = (mz_uint)(flags); - d->m_max_probes[0] = 1 + ((flags & 0xFFF) + 2) / 3; - d->m_greedy_parsing = (flags & TDEFL_GREEDY_PARSING_FLAG) != 0; - d->m_max_probes[1] = 1 + (((flags & 0xFFF) >> 2) + 2) / 3; - if (!(flags & TDEFL_NONDETERMINISTIC_PARSING_FLAG)) - MZ_CLEAR_OBJ(d->m_hash); - d->m_lookahead_pos = d->m_lookahead_size = d->m_dict_size = - d->m_total_lz_bytes = d->m_lz_code_buf_dict_pos = d->m_bits_in = 0; - d->m_output_flush_ofs = d->m_output_flush_remaining = d->m_finished = - d->m_block_index = d->m_bit_buffer = d->m_wants_to_finish = 0; - d->m_pLZ_code_buf = d->m_lz_code_buf + 1; - d->m_pLZ_flags = d->m_lz_code_buf; - *d->m_pLZ_flags = 0; - d->m_num_flags_left = 8; - d->m_pOutput_buf = d->m_output_buf; - d->m_pOutput_buf_end = d->m_output_buf; - d->m_prev_return_status = TDEFL_STATUS_OKAY; - d->m_saved_match_dist = d->m_saved_match_len = d->m_saved_lit = 0; - d->m_adler32 = 1; - d->m_pIn_buf = NULL; - d->m_pOut_buf = NULL; - d->m_pIn_buf_size = NULL; - d->m_pOut_buf_size = NULL; - d->m_flush = TDEFL_NO_FLUSH; - d->m_pSrc = NULL; - d->m_src_buf_left = 0; - d->m_out_buf_ofs = 0; - if (!(flags & TDEFL_NONDETERMINISTIC_PARSING_FLAG)) - MZ_CLEAR_OBJ(d->m_dict); - memset(&d->m_huff_count[0][0], 0, - sizeof(d->m_huff_count[0][0]) * TDEFL_MAX_HUFF_SYMBOLS_0); - memset(&d->m_huff_count[1][0], 0, - sizeof(d->m_huff_count[1][0]) * TDEFL_MAX_HUFF_SYMBOLS_1); - return TDEFL_STATUS_OKAY; -} - -tdefl_status tdefl_get_prev_return_status(tdefl_compressor *d) { - return d->m_prev_return_status; -} - -mz_uint32 tdefl_get_adler32(tdefl_compressor *d) { return d->m_adler32; } - -mz_bool tdefl_compress_mem_to_output(const void *pBuf, size_t buf_len, - tdefl_put_buf_func_ptr pPut_buf_func, - void *pPut_buf_user, int flags) { - tdefl_compressor *pComp; - mz_bool succeeded; - if (((buf_len) && (!pBuf)) || (!pPut_buf_func)) - return MZ_FALSE; - pComp = (tdefl_compressor *)MZ_MALLOC(sizeof(tdefl_compressor)); - if (!pComp) - return MZ_FALSE; - succeeded = (tdefl_init(pComp, pPut_buf_func, pPut_buf_user, flags) == - TDEFL_STATUS_OKAY); - succeeded = - succeeded && (tdefl_compress_buffer(pComp, pBuf, buf_len, TDEFL_FINISH) == - TDEFL_STATUS_DONE); - MZ_FREE(pComp); - return succeeded; -} - -typedef struct { - size_t m_size, m_capacity; - mz_uint8 *m_pBuf; - mz_bool m_expandable; -} tdefl_output_buffer; - -static mz_bool tdefl_output_buffer_putter(const void *pBuf, int len, - void *pUser) { - tdefl_output_buffer *p = (tdefl_output_buffer *)pUser; - size_t new_size = p->m_size + len; - if (new_size > p->m_capacity) { - size_t new_capacity = p->m_capacity; - mz_uint8 *pNew_buf; - if (!p->m_expandable) - return MZ_FALSE; - do { - new_capacity = MZ_MAX(128U, new_capacity << 1U); - } while (new_size > new_capacity); - pNew_buf = (mz_uint8 *)MZ_REALLOC(p->m_pBuf, new_capacity); - if (!pNew_buf) - return MZ_FALSE; - p->m_pBuf = pNew_buf; - p->m_capacity = new_capacity; - } - memcpy((mz_uint8 *)p->m_pBuf + p->m_size, pBuf, len); - p->m_size = new_size; - return MZ_TRUE; -} - -void *tdefl_compress_mem_to_heap(const void *pSrc_buf, size_t src_buf_len, - size_t *pOut_len, int flags) { - tdefl_output_buffer out_buf; - MZ_CLEAR_OBJ(out_buf); - if (!pOut_len) - return MZ_FALSE; - else - *pOut_len = 0; - out_buf.m_expandable = MZ_TRUE; - if (!tdefl_compress_mem_to_output( - pSrc_buf, src_buf_len, tdefl_output_buffer_putter, &out_buf, flags)) - return NULL; - *pOut_len = out_buf.m_size; - return out_buf.m_pBuf; -} - -size_t tdefl_compress_mem_to_mem(void *pOut_buf, size_t out_buf_len, - const void *pSrc_buf, size_t src_buf_len, - int flags) { - tdefl_output_buffer out_buf; - MZ_CLEAR_OBJ(out_buf); - if (!pOut_buf) - return 0; - out_buf.m_pBuf = (mz_uint8 *)pOut_buf; - out_buf.m_capacity = out_buf_len; - if (!tdefl_compress_mem_to_output( - pSrc_buf, src_buf_len, tdefl_output_buffer_putter, &out_buf, flags)) - return 0; - return out_buf.m_size; -} - -static const mz_uint s_tdefl_num_probes[11] = {0, 1, 6, 32, 16, 32, - 128, 256, 512, 768, 1500}; - -/* level may actually range from [0,10] (10 is a "hidden" max level, where we - * want a bit more compression and it's fine if throughput to fall off a cliff - * on some files). */ -mz_uint tdefl_create_comp_flags_from_zip_params(int level, int window_bits, - int strategy) { - mz_uint comp_flags = - s_tdefl_num_probes[(level >= 0) ? MZ_MIN(10, level) : MZ_DEFAULT_LEVEL] | - ((level <= 3) ? TDEFL_GREEDY_PARSING_FLAG : 0); - if (window_bits > 0) - comp_flags |= TDEFL_WRITE_ZLIB_HEADER; - - if (!level) - comp_flags |= TDEFL_FORCE_ALL_RAW_BLOCKS; - else if (strategy == MZ_FILTERED) - comp_flags |= TDEFL_FILTER_MATCHES; - else if (strategy == MZ_HUFFMAN_ONLY) - comp_flags &= ~TDEFL_MAX_PROBES_MASK; - else if (strategy == MZ_FIXED) - comp_flags |= TDEFL_FORCE_ALL_STATIC_BLOCKS; - else if (strategy == MZ_RLE) - comp_flags |= TDEFL_RLE_MATCHES; - - return comp_flags; -} - -#ifdef _MSC_VER -#pragma warning(push) -#pragma warning(disable : 4204) /* nonstandard extension used : non-constant \ - aggregate initializer (also supported by \ - GNU C and C99, so no big deal) */ -#endif - -/* Simple PNG writer function by Alex Evans, 2011. Released into the public - domain: https://gist.github.com/908299, more context at - http://altdevblogaday.org/2011/04/06/a-smaller-jpg-encoder/. - This is actually a modification of Alex's original code so PNG files generated - by this function pass pngcheck. */ -void *tdefl_write_image_to_png_file_in_memory_ex(const void *pImage, int w, - int h, int num_chans, - size_t *pLen_out, - mz_uint level, mz_bool flip) { - /* Using a local copy of this array here in case MINIZ_NO_ZLIB_APIS was - * defined. */ - static const mz_uint s_tdefl_png_num_probes[11] = { - 0, 1, 6, 32, 16, 32, 128, 256, 512, 768, 1500}; - tdefl_compressor *pComp = - (tdefl_compressor *)MZ_MALLOC(sizeof(tdefl_compressor)); - tdefl_output_buffer out_buf; - int i, bpl = w * num_chans, y, z; - mz_uint32 c; - *pLen_out = 0; - if (!pComp) - return NULL; - MZ_CLEAR_OBJ(out_buf); - out_buf.m_expandable = MZ_TRUE; - out_buf.m_capacity = 57 + MZ_MAX(64, (1 + bpl) * h); - if (NULL == (out_buf.m_pBuf = (mz_uint8 *)MZ_MALLOC(out_buf.m_capacity))) { - MZ_FREE(pComp); - return NULL; - } - /* write dummy header */ - for (z = 41; z; --z) - tdefl_output_buffer_putter(&z, 1, &out_buf); - /* compress image data */ - tdefl_init(pComp, tdefl_output_buffer_putter, &out_buf, - s_tdefl_png_num_probes[MZ_MIN(10, level)] | - TDEFL_WRITE_ZLIB_HEADER); - for (y = 0; y < h; ++y) { - tdefl_compress_buffer(pComp, &z, 1, TDEFL_NO_FLUSH); - tdefl_compress_buffer(pComp, - (mz_uint8 *)pImage + (flip ? (h - 1 - y) : y) * bpl, - bpl, TDEFL_NO_FLUSH); - } - if (tdefl_compress_buffer(pComp, NULL, 0, TDEFL_FINISH) != - TDEFL_STATUS_DONE) { - MZ_FREE(pComp); - MZ_FREE(out_buf.m_pBuf); - return NULL; - } - /* write real header */ - *pLen_out = out_buf.m_size - 41; - { - static const mz_uint8 chans[] = {0x00, 0x00, 0x04, 0x02, 0x06}; - mz_uint8 pnghdr[41] = {0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, - 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x49, 0x44, 0x41, 0x54}; - pnghdr[18] = (mz_uint8)(w >> 8); - pnghdr[19] = (mz_uint8)w; - pnghdr[22] = (mz_uint8)(h >> 8); - pnghdr[23] = (mz_uint8)h; - pnghdr[25] = chans[num_chans]; - pnghdr[33] = (mz_uint8)(*pLen_out >> 24); - pnghdr[34] = (mz_uint8)(*pLen_out >> 16); - pnghdr[35] = (mz_uint8)(*pLen_out >> 8); - pnghdr[36] = (mz_uint8)*pLen_out; - c = (mz_uint32)mz_crc32(MZ_CRC32_INIT, pnghdr + 12, 17); - for (i = 0; i < 4; ++i, c <<= 8) - ((mz_uint8 *)(pnghdr + 29))[i] = (mz_uint8)(c >> 24); - memcpy(out_buf.m_pBuf, pnghdr, 41); - } - /* write footer (IDAT CRC-32, followed by IEND chunk) */ - if (!tdefl_output_buffer_putter( - "\0\0\0\0\0\0\0\0\x49\x45\x4e\x44\xae\x42\x60\x82", 16, &out_buf)) { - *pLen_out = 0; - MZ_FREE(pComp); - MZ_FREE(out_buf.m_pBuf); - return NULL; - } - c = (mz_uint32)mz_crc32(MZ_CRC32_INIT, out_buf.m_pBuf + 41 - 4, - *pLen_out + 4); - for (i = 0; i < 4; ++i, c <<= 8) - (out_buf.m_pBuf + out_buf.m_size - 16)[i] = (mz_uint8)(c >> 24); - /* compute final size of file, grab compressed data buffer and return */ - *pLen_out += 57; - MZ_FREE(pComp); - return out_buf.m_pBuf; -} -void *tdefl_write_image_to_png_file_in_memory(const void *pImage, int w, int h, - int num_chans, size_t *pLen_out) { - /* Level 6 corresponds to TDEFL_DEFAULT_MAX_PROBES or MZ_DEFAULT_LEVEL (but we - * can't depend on MZ_DEFAULT_LEVEL being available in case the zlib API's - * where #defined out) */ - return tdefl_write_image_to_png_file_in_memory_ex(pImage, w, h, num_chans, - pLen_out, 6, MZ_FALSE); -} - -#ifndef MINIZ_NO_MALLOC -/* Allocate the tdefl_compressor and tinfl_decompressor structures in C so that - */ -/* non-C language bindings to tdefL_ and tinfl_ API don't need to worry about */ -/* structure size and allocation mechanism. */ -tdefl_compressor *tdefl_compressor_alloc(void) { - return (tdefl_compressor *)MZ_MALLOC(sizeof(tdefl_compressor)); -} - -void tdefl_compressor_free(tdefl_compressor *pComp) { MZ_FREE(pComp); } -#endif - -#ifdef _MSC_VER -#pragma warning(pop) -#endif - -#ifdef __cplusplus -} -#endif -/************************************************************************** - * - * Copyright 2013-2014 RAD Game Tools and Valve Software - * Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC - * All Rights Reserved. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - **************************************************************************/ - -#ifdef __cplusplus -extern "C" { -#endif - -/* ------------------- Low-level Decompression (completely independent from all - * compression API's) */ - -#define TINFL_MEMCPY(d, s, l) memcpy(d, s, l) -#define TINFL_MEMSET(p, c, l) memset(p, c, l) - -#define TINFL_CR_BEGIN \ - switch (r->m_state) { \ - case 0: -#define TINFL_CR_RETURN(state_index, result) \ - do { \ - status = result; \ - r->m_state = state_index; \ - goto common_exit; \ - case state_index:; \ - } \ - MZ_MACRO_END -#define TINFL_CR_RETURN_FOREVER(state_index, result) \ - do { \ - for (;;) { \ - TINFL_CR_RETURN(state_index, result); \ - } \ - } \ - MZ_MACRO_END -#define TINFL_CR_FINISH } - -#define TINFL_GET_BYTE(state_index, c) \ - do { \ - while (pIn_buf_cur >= pIn_buf_end) { \ - TINFL_CR_RETURN(state_index, \ - (decomp_flags & TINFL_FLAG_HAS_MORE_INPUT) \ - ? TINFL_STATUS_NEEDS_MORE_INPUT \ - : TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS); \ - } \ - c = *pIn_buf_cur++; \ - } \ - MZ_MACRO_END - -#define TINFL_NEED_BITS(state_index, n) \ - do { \ - mz_uint c; \ - TINFL_GET_BYTE(state_index, c); \ - bit_buf |= (((tinfl_bit_buf_t)c) << num_bits); \ - num_bits += 8; \ - } while (num_bits < (mz_uint)(n)) -#define TINFL_SKIP_BITS(state_index, n) \ - do { \ - if (num_bits < (mz_uint)(n)) { \ - TINFL_NEED_BITS(state_index, n); \ - } \ - bit_buf >>= (n); \ - num_bits -= (n); \ - } \ - MZ_MACRO_END -#define TINFL_GET_BITS(state_index, b, n) \ - do { \ - if (num_bits < (mz_uint)(n)) { \ - TINFL_NEED_BITS(state_index, n); \ - } \ - b = bit_buf & ((1 << (n)) - 1); \ - bit_buf >>= (n); \ - num_bits -= (n); \ - } \ - MZ_MACRO_END - -/* TINFL_HUFF_BITBUF_FILL() is only used rarely, when the number of bytes - * remaining in the input buffer falls below 2. */ -/* It reads just enough bytes from the input stream that are needed to decode - * the next Huffman code (and absolutely no more). It works by trying to fully - * decode a */ -/* Huffman code by using whatever bits are currently present in the bit buffer. - * If this fails, it reads another byte, and tries again until it succeeds or - * until the */ -/* bit buffer contains >=15 bits (deflate's max. Huffman code size). */ -#define TINFL_HUFF_BITBUF_FILL(state_index, pHuff) \ - do { \ - temp = (pHuff)->m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]; \ - if (temp >= 0) { \ - code_len = temp >> 9; \ - if ((code_len) && (num_bits >= code_len)) \ - break; \ - } else if (num_bits > TINFL_FAST_LOOKUP_BITS) { \ - code_len = TINFL_FAST_LOOKUP_BITS; \ - do { \ - temp = (pHuff)->m_tree[~temp + ((bit_buf >> code_len++) & 1)]; \ - } while ((temp < 0) && (num_bits >= (code_len + 1))); \ - if (temp >= 0) \ - break; \ - } \ - TINFL_GET_BYTE(state_index, c); \ - bit_buf |= (((tinfl_bit_buf_t)c) << num_bits); \ - num_bits += 8; \ - } while (num_bits < 15); - -/* TINFL_HUFF_DECODE() decodes the next Huffman coded symbol. It's more complex - * than you would initially expect because the zlib API expects the decompressor - * to never read */ -/* beyond the final byte of the deflate stream. (In other words, when this macro - * wants to read another byte from the input, it REALLY needs another byte in - * order to fully */ -/* decode the next Huffman code.) Handling this properly is particularly - * important on raw deflate (non-zlib) streams, which aren't followed by a byte - * aligned adler-32. */ -/* The slow path is only executed at the very end of the input buffer. */ -/* v1.16: The original macro handled the case at the very end of the passed-in - * input buffer, but we also need to handle the case where the user passes in - * 1+zillion bytes */ -/* following the deflate data and our non-conservative read-ahead path won't - * kick in here on this code. This is much trickier. */ -#define TINFL_HUFF_DECODE(state_index, sym, pHuff) \ - do { \ - int temp; \ - mz_uint code_len, c; \ - if (num_bits < 15) { \ - if ((pIn_buf_end - pIn_buf_cur) < 2) { \ - TINFL_HUFF_BITBUF_FILL(state_index, pHuff); \ - } else { \ - bit_buf |= (((tinfl_bit_buf_t)pIn_buf_cur[0]) << num_bits) | \ - (((tinfl_bit_buf_t)pIn_buf_cur[1]) << (num_bits + 8)); \ - pIn_buf_cur += 2; \ - num_bits += 16; \ - } \ - } \ - if ((temp = (pHuff)->m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]) >= \ - 0) \ - code_len = temp >> 9, temp &= 511; \ - else { \ - code_len = TINFL_FAST_LOOKUP_BITS; \ - do { \ - temp = (pHuff)->m_tree[~temp + ((bit_buf >> code_len++) & 1)]; \ - } while (temp < 0); \ - } \ - sym = temp; \ - bit_buf >>= code_len; \ - num_bits -= code_len; \ - } \ - MZ_MACRO_END - -tinfl_status tinfl_decompress(tinfl_decompressor *r, - const mz_uint8 *pIn_buf_next, - size_t *pIn_buf_size, mz_uint8 *pOut_buf_start, - mz_uint8 *pOut_buf_next, size_t *pOut_buf_size, - const mz_uint32 decomp_flags) { - static const int s_length_base[31] = { - 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, - 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, 0, 0}; - static const int s_length_extra[31] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, - 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, - 4, 4, 5, 5, 5, 5, 0, 0, 0}; - static const int s_dist_base[32] = { - 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, - 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, - 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577, 0, 0}; - static const int s_dist_extra[32] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, - 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, - 9, 9, 10, 10, 11, 11, 12, 12, 13, 13}; - static const mz_uint8 s_length_dezigzag[19] = { - 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15}; - static const int s_min_table_sizes[3] = {257, 1, 4}; - - tinfl_status status = TINFL_STATUS_FAILED; - mz_uint32 num_bits, dist, counter, num_extra; - tinfl_bit_buf_t bit_buf; - const mz_uint8 *pIn_buf_cur = pIn_buf_next, *const pIn_buf_end = - pIn_buf_next + *pIn_buf_size; - mz_uint8 *pOut_buf_cur = pOut_buf_next, *const pOut_buf_end = - pOut_buf_next + *pOut_buf_size; - size_t out_buf_size_mask = - (decomp_flags & TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF) - ? (size_t)-1 - : ((pOut_buf_next - pOut_buf_start) + *pOut_buf_size) - 1, - dist_from_out_buf_start; - - /* Ensure the output buffer's size is a power of 2, unless the output buffer - * is large enough to hold the entire output file (in which case it doesn't - * matter). */ - if (((out_buf_size_mask + 1) & out_buf_size_mask) || - (pOut_buf_next < pOut_buf_start)) { - *pIn_buf_size = *pOut_buf_size = 0; - return TINFL_STATUS_BAD_PARAM; - } - - num_bits = r->m_num_bits; - bit_buf = r->m_bit_buf; - dist = r->m_dist; - counter = r->m_counter; - num_extra = r->m_num_extra; - dist_from_out_buf_start = r->m_dist_from_out_buf_start; - TINFL_CR_BEGIN - - bit_buf = num_bits = dist = counter = num_extra = r->m_zhdr0 = r->m_zhdr1 = 0; - r->m_z_adler32 = r->m_check_adler32 = 1; - if (decomp_flags & TINFL_FLAG_PARSE_ZLIB_HEADER) { - TINFL_GET_BYTE(1, r->m_zhdr0); - TINFL_GET_BYTE(2, r->m_zhdr1); - counter = (((r->m_zhdr0 * 256 + r->m_zhdr1) % 31 != 0) || - (r->m_zhdr1 & 32) || ((r->m_zhdr0 & 15) != 8)); - if (!(decomp_flags & TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF)) - counter |= (((1U << (8U + (r->m_zhdr0 >> 4))) > 32768U) || - ((out_buf_size_mask + 1) < - (size_t)(1U << (8U + (r->m_zhdr0 >> 4))))); - if (counter) { - TINFL_CR_RETURN_FOREVER(36, TINFL_STATUS_FAILED); - } - } - - do { - TINFL_GET_BITS(3, r->m_final, 3); - r->m_type = r->m_final >> 1; - if (r->m_type == 0) { - TINFL_SKIP_BITS(5, num_bits & 7); - for (counter = 0; counter < 4; ++counter) { - if (num_bits) - TINFL_GET_BITS(6, r->m_raw_header[counter], 8); - else - TINFL_GET_BYTE(7, r->m_raw_header[counter]); - } - if ((counter = (r->m_raw_header[0] | (r->m_raw_header[1] << 8))) != - (mz_uint)(0xFFFF ^ - (r->m_raw_header[2] | (r->m_raw_header[3] << 8)))) { - TINFL_CR_RETURN_FOREVER(39, TINFL_STATUS_FAILED); - } - while ((counter) && (num_bits)) { - TINFL_GET_BITS(51, dist, 8); - while (pOut_buf_cur >= pOut_buf_end) { - TINFL_CR_RETURN(52, TINFL_STATUS_HAS_MORE_OUTPUT); - } - *pOut_buf_cur++ = (mz_uint8)dist; - counter--; - } - while (counter) { - size_t n; - while (pOut_buf_cur >= pOut_buf_end) { - TINFL_CR_RETURN(9, TINFL_STATUS_HAS_MORE_OUTPUT); - } - while (pIn_buf_cur >= pIn_buf_end) { - TINFL_CR_RETURN(38, (decomp_flags & TINFL_FLAG_HAS_MORE_INPUT) - ? TINFL_STATUS_NEEDS_MORE_INPUT - : TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS); - } - n = MZ_MIN(MZ_MIN((size_t)(pOut_buf_end - pOut_buf_cur), - (size_t)(pIn_buf_end - pIn_buf_cur)), - counter); - TINFL_MEMCPY(pOut_buf_cur, pIn_buf_cur, n); - pIn_buf_cur += n; - pOut_buf_cur += n; - counter -= (mz_uint)n; - } - } else if (r->m_type == 3) { - TINFL_CR_RETURN_FOREVER(10, TINFL_STATUS_FAILED); - } else { - if (r->m_type == 1) { - mz_uint8 *p = r->m_tables[0].m_code_size; - mz_uint i; - r->m_table_sizes[0] = 288; - r->m_table_sizes[1] = 32; - TINFL_MEMSET(r->m_tables[1].m_code_size, 5, 32); - for (i = 0; i <= 143; ++i) - *p++ = 8; - for (; i <= 255; ++i) - *p++ = 9; - for (; i <= 279; ++i) - *p++ = 7; - for (; i <= 287; ++i) - *p++ = 8; - } else { - for (counter = 0; counter < 3; counter++) { - TINFL_GET_BITS(11, r->m_table_sizes[counter], "\05\05\04"[counter]); - r->m_table_sizes[counter] += s_min_table_sizes[counter]; - } - MZ_CLEAR_OBJ(r->m_tables[2].m_code_size); - for (counter = 0; counter < r->m_table_sizes[2]; counter++) { - mz_uint s; - TINFL_GET_BITS(14, s, 3); - r->m_tables[2].m_code_size[s_length_dezigzag[counter]] = (mz_uint8)s; - } - r->m_table_sizes[2] = 19; - } - for (; (int)r->m_type >= 0; r->m_type--) { - int tree_next, tree_cur; - tinfl_huff_table *pTable; - mz_uint i, j, used_syms, total, sym_index, next_code[17], - total_syms[16]; - pTable = &r->m_tables[r->m_type]; - MZ_CLEAR_OBJ(total_syms); - MZ_CLEAR_OBJ(pTable->m_look_up); - MZ_CLEAR_OBJ(pTable->m_tree); - for (i = 0; i < r->m_table_sizes[r->m_type]; ++i) - total_syms[pTable->m_code_size[i]]++; - used_syms = 0, total = 0; - next_code[0] = next_code[1] = 0; - for (i = 1; i <= 15; ++i) { - used_syms += total_syms[i]; - next_code[i + 1] = (total = ((total + total_syms[i]) << 1)); - } - if ((65536 != total) && (used_syms > 1)) { - TINFL_CR_RETURN_FOREVER(35, TINFL_STATUS_FAILED); - } - for (tree_next = -1, sym_index = 0; - sym_index < r->m_table_sizes[r->m_type]; ++sym_index) { - mz_uint rev_code = 0, l, cur_code, - code_size = pTable->m_code_size[sym_index]; - - if (!code_size) - continue; - cur_code = next_code[code_size]++; - for (l = code_size; l > 0; l--, cur_code >>= 1) - rev_code = (rev_code << 1) | (cur_code & 1); - if (code_size <= TINFL_FAST_LOOKUP_BITS) { - mz_int16 k = (mz_int16)((code_size << 9) | sym_index); - while (rev_code < TINFL_FAST_LOOKUP_SIZE) { - pTable->m_look_up[rev_code] = k; - rev_code += (1 << code_size); - } - continue; - } - if (0 == - (tree_cur = pTable->m_look_up[rev_code & - (TINFL_FAST_LOOKUP_SIZE - 1)])) { - pTable->m_look_up[rev_code & (TINFL_FAST_LOOKUP_SIZE - 1)] = - (mz_int16)tree_next; - tree_cur = tree_next; - tree_next -= 2; - } - rev_code >>= (TINFL_FAST_LOOKUP_BITS - 1); - for (j = code_size; j > (TINFL_FAST_LOOKUP_BITS + 1); j--) { - tree_cur -= ((rev_code >>= 1) & 1); - if (!pTable->m_tree[-tree_cur - 1]) { - pTable->m_tree[-tree_cur - 1] = (mz_int16)tree_next; - tree_cur = tree_next; - tree_next -= 2; - } else - tree_cur = pTable->m_tree[-tree_cur - 1]; - } - tree_cur -= ((rev_code >>= 1) & 1); - (void)rev_code; // unused - pTable->m_tree[-tree_cur - 1] = (mz_int16)sym_index; - } - if (r->m_type == 2) { - for (counter = 0; - counter < (r->m_table_sizes[0] + r->m_table_sizes[1]);) { - mz_uint s; - TINFL_HUFF_DECODE(16, dist, &r->m_tables[2]); - if (dist < 16) { - r->m_len_codes[counter++] = (mz_uint8)dist; - continue; - } - if ((dist == 16) && (!counter)) { - TINFL_CR_RETURN_FOREVER(17, TINFL_STATUS_FAILED); - } - num_extra = "\02\03\07"[dist - 16]; - TINFL_GET_BITS(18, s, num_extra); - s += "\03\03\013"[dist - 16]; - TINFL_MEMSET(r->m_len_codes + counter, - (dist == 16) ? r->m_len_codes[counter - 1] : 0, s); - counter += s; - } - if ((r->m_table_sizes[0] + r->m_table_sizes[1]) != counter) { - TINFL_CR_RETURN_FOREVER(21, TINFL_STATUS_FAILED); - } - TINFL_MEMCPY(r->m_tables[0].m_code_size, r->m_len_codes, - r->m_table_sizes[0]); - TINFL_MEMCPY(r->m_tables[1].m_code_size, - r->m_len_codes + r->m_table_sizes[0], - r->m_table_sizes[1]); - } - } - for (;;) { - mz_uint8 *pSrc; - for (;;) { - if (((pIn_buf_end - pIn_buf_cur) < 4) || - ((pOut_buf_end - pOut_buf_cur) < 2)) { - TINFL_HUFF_DECODE(23, counter, &r->m_tables[0]); - if (counter >= 256) - break; - while (pOut_buf_cur >= pOut_buf_end) { - TINFL_CR_RETURN(24, TINFL_STATUS_HAS_MORE_OUTPUT); - } - *pOut_buf_cur++ = (mz_uint8)counter; - } else { - int sym2; - mz_uint code_len; -#if TINFL_USE_64BIT_BITBUF - if (num_bits < 30) { - bit_buf |= - (((tinfl_bit_buf_t)MZ_READ_LE32(pIn_buf_cur)) << num_bits); - pIn_buf_cur += 4; - num_bits += 32; - } -#else - if (num_bits < 15) { - bit_buf |= - (((tinfl_bit_buf_t)MZ_READ_LE16(pIn_buf_cur)) << num_bits); - pIn_buf_cur += 2; - num_bits += 16; - } -#endif - if ((sym2 = - r->m_tables[0] - .m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]) >= - 0) - code_len = sym2 >> 9; - else { - code_len = TINFL_FAST_LOOKUP_BITS; - do { - sym2 = r->m_tables[0] - .m_tree[~sym2 + ((bit_buf >> code_len++) & 1)]; - } while (sym2 < 0); - } - counter = sym2; - bit_buf >>= code_len; - num_bits -= code_len; - if (counter & 256) - break; - -#if !TINFL_USE_64BIT_BITBUF - if (num_bits < 15) { - bit_buf |= - (((tinfl_bit_buf_t)MZ_READ_LE16(pIn_buf_cur)) << num_bits); - pIn_buf_cur += 2; - num_bits += 16; - } -#endif - if ((sym2 = - r->m_tables[0] - .m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]) >= - 0) - code_len = sym2 >> 9; - else { - code_len = TINFL_FAST_LOOKUP_BITS; - do { - sym2 = r->m_tables[0] - .m_tree[~sym2 + ((bit_buf >> code_len++) & 1)]; - } while (sym2 < 0); - } - bit_buf >>= code_len; - num_bits -= code_len; - - pOut_buf_cur[0] = (mz_uint8)counter; - if (sym2 & 256) { - pOut_buf_cur++; - counter = sym2; - break; - } - pOut_buf_cur[1] = (mz_uint8)sym2; - pOut_buf_cur += 2; - } - } - if ((counter &= 511) == 256) - break; - - num_extra = s_length_extra[counter - 257]; - counter = s_length_base[counter - 257]; - if (num_extra) { - mz_uint extra_bits; - TINFL_GET_BITS(25, extra_bits, num_extra); - counter += extra_bits; - } - - TINFL_HUFF_DECODE(26, dist, &r->m_tables[1]); - num_extra = s_dist_extra[dist]; - dist = s_dist_base[dist]; - if (num_extra) { - mz_uint extra_bits; - TINFL_GET_BITS(27, extra_bits, num_extra); - dist += extra_bits; - } - - dist_from_out_buf_start = pOut_buf_cur - pOut_buf_start; - if ((dist == 0 || dist > dist_from_out_buf_start || - dist_from_out_buf_start == 0) && - (decomp_flags & TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF)) { - TINFL_CR_RETURN_FOREVER(37, TINFL_STATUS_FAILED); - } - - pSrc = pOut_buf_start + - ((dist_from_out_buf_start - dist) & out_buf_size_mask); - - if ((MZ_MAX(pOut_buf_cur, pSrc) + counter) > pOut_buf_end) { - while (counter--) { - while (pOut_buf_cur >= pOut_buf_end) { - TINFL_CR_RETURN(53, TINFL_STATUS_HAS_MORE_OUTPUT); - } - *pOut_buf_cur++ = - pOut_buf_start[(dist_from_out_buf_start++ - dist) & - out_buf_size_mask]; - } - continue; - } -#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES - else if ((counter >= 9) && (counter <= dist)) { - const mz_uint8 *pSrc_end = pSrc + (counter & ~7); - do { -#ifdef MINIZ_UNALIGNED_USE_MEMCPY - memcpy(pOut_buf_cur, pSrc, sizeof(mz_uint32) * 2); -#else - ((mz_uint32 *)pOut_buf_cur)[0] = ((const mz_uint32 *)pSrc)[0]; - ((mz_uint32 *)pOut_buf_cur)[1] = ((const mz_uint32 *)pSrc)[1]; -#endif - pOut_buf_cur += 8; - } while ((pSrc += 8) < pSrc_end); - if ((counter &= 7) < 3) { - if (counter) { - pOut_buf_cur[0] = pSrc[0]; - if (counter > 1) - pOut_buf_cur[1] = pSrc[1]; - pOut_buf_cur += counter; - } - continue; - } - } -#endif - while (counter > 2) { - pOut_buf_cur[0] = pSrc[0]; - pOut_buf_cur[1] = pSrc[1]; - pOut_buf_cur[2] = pSrc[2]; - pOut_buf_cur += 3; - pSrc += 3; - counter -= 3; - } - if (counter > 0) { - pOut_buf_cur[0] = pSrc[0]; - if (counter > 1) - pOut_buf_cur[1] = pSrc[1]; - pOut_buf_cur += counter; - } - } - } - } while (!(r->m_final & 1)); - - /* Ensure byte alignment and put back any bytes from the bitbuf if we've - * looked ahead too far on gzip, or other Deflate streams followed by - * arbitrary data. */ - /* I'm being super conservative here. A number of simplifications can be made - * to the byte alignment part, and the Adler32 check shouldn't ever need to - * worry about reading from the bitbuf now. */ - TINFL_SKIP_BITS(32, num_bits & 7); - while ((pIn_buf_cur > pIn_buf_next) && (num_bits >= 8)) { - --pIn_buf_cur; - num_bits -= 8; - } - bit_buf &= (tinfl_bit_buf_t)((((mz_uint64)1) << num_bits) - (mz_uint64)1); - MZ_ASSERT(!num_bits); /* if this assert fires then we've read beyond the end - of non-deflate/zlib streams with following data (such - as gzip streams). */ - - if (decomp_flags & TINFL_FLAG_PARSE_ZLIB_HEADER) { - for (counter = 0; counter < 4; ++counter) { - mz_uint s; - if (num_bits) - TINFL_GET_BITS(41, s, 8); - else - TINFL_GET_BYTE(42, s); - r->m_z_adler32 = (r->m_z_adler32 << 8) | s; - } - } - TINFL_CR_RETURN_FOREVER(34, TINFL_STATUS_DONE); - - TINFL_CR_FINISH - -common_exit: - /* As long as we aren't telling the caller that we NEED more input to make - * forward progress: */ - /* Put back any bytes from the bitbuf in case we've looked ahead too far on - * gzip, or other Deflate streams followed by arbitrary data. */ - /* We need to be very careful here to NOT push back any bytes we definitely - * know we need to make forward progress, though, or we'll lock the caller up - * into an inf loop. */ - if ((status != TINFL_STATUS_NEEDS_MORE_INPUT) && - (status != TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS)) { - while ((pIn_buf_cur > pIn_buf_next) && (num_bits >= 8)) { - --pIn_buf_cur; - num_bits -= 8; - } - } - r->m_num_bits = num_bits; - r->m_bit_buf = - bit_buf & (tinfl_bit_buf_t)((((mz_uint64)1) << num_bits) - (mz_uint64)1); - r->m_dist = dist; - r->m_counter = counter; - r->m_num_extra = num_extra; - r->m_dist_from_out_buf_start = dist_from_out_buf_start; - *pIn_buf_size = pIn_buf_cur - pIn_buf_next; - *pOut_buf_size = pOut_buf_cur - pOut_buf_next; - if ((decomp_flags & - (TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_COMPUTE_ADLER32)) && - (status >= 0)) { - const mz_uint8 *ptr = pOut_buf_next; - size_t buf_len = *pOut_buf_size; - mz_uint32 i, s1 = r->m_check_adler32 & 0xffff, - s2 = r->m_check_adler32 >> 16; - size_t block_len = buf_len % 5552; - while (buf_len) { - for (i = 0; i + 7 < block_len; i += 8, ptr += 8) { - s1 += ptr[0], s2 += s1; - s1 += ptr[1], s2 += s1; - s1 += ptr[2], s2 += s1; - s1 += ptr[3], s2 += s1; - s1 += ptr[4], s2 += s1; - s1 += ptr[5], s2 += s1; - s1 += ptr[6], s2 += s1; - s1 += ptr[7], s2 += s1; - } - for (; i < block_len; ++i) - s1 += *ptr++, s2 += s1; - s1 %= 65521U, s2 %= 65521U; - buf_len -= block_len; - block_len = 5552; - } - r->m_check_adler32 = (s2 << 16) + s1; - if ((status == TINFL_STATUS_DONE) && - (decomp_flags & TINFL_FLAG_PARSE_ZLIB_HEADER) && - (r->m_check_adler32 != r->m_z_adler32)) - status = TINFL_STATUS_ADLER32_MISMATCH; - } - return status; -} - -/* Higher level helper functions. */ -void *tinfl_decompress_mem_to_heap(const void *pSrc_buf, size_t src_buf_len, - size_t *pOut_len, int flags) { - tinfl_decompressor decomp; - void *pBuf = NULL, *pNew_buf; - size_t src_buf_ofs = 0, out_buf_capacity = 0; - *pOut_len = 0; - tinfl_init(&decomp); - for (;;) { - size_t src_buf_size = src_buf_len - src_buf_ofs, - dst_buf_size = out_buf_capacity - *pOut_len, new_out_buf_capacity; - tinfl_status status = tinfl_decompress( - &decomp, (const mz_uint8 *)pSrc_buf + src_buf_ofs, &src_buf_size, - (mz_uint8 *)pBuf, pBuf ? (mz_uint8 *)pBuf + *pOut_len : NULL, - &dst_buf_size, - (flags & ~TINFL_FLAG_HAS_MORE_INPUT) | - TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF); - if ((status < 0) || (status == TINFL_STATUS_NEEDS_MORE_INPUT)) { - MZ_FREE(pBuf); - *pOut_len = 0; - return NULL; - } - src_buf_ofs += src_buf_size; - *pOut_len += dst_buf_size; - if (status == TINFL_STATUS_DONE) - break; - new_out_buf_capacity = out_buf_capacity * 2; - if (new_out_buf_capacity < 128) - new_out_buf_capacity = 128; - pNew_buf = MZ_REALLOC(pBuf, new_out_buf_capacity); - if (!pNew_buf) { - MZ_FREE(pBuf); - *pOut_len = 0; - return NULL; - } - pBuf = pNew_buf; - out_buf_capacity = new_out_buf_capacity; - } - return pBuf; -} - -size_t tinfl_decompress_mem_to_mem(void *pOut_buf, size_t out_buf_len, - const void *pSrc_buf, size_t src_buf_len, - int flags) { - tinfl_decompressor decomp; - tinfl_status status; - tinfl_init(&decomp); - status = - tinfl_decompress(&decomp, (const mz_uint8 *)pSrc_buf, &src_buf_len, - (mz_uint8 *)pOut_buf, (mz_uint8 *)pOut_buf, &out_buf_len, - (flags & ~TINFL_FLAG_HAS_MORE_INPUT) | - TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF); - return (status != TINFL_STATUS_DONE) ? TINFL_DECOMPRESS_MEM_TO_MEM_FAILED - : out_buf_len; -} - -int tinfl_decompress_mem_to_callback(const void *pIn_buf, size_t *pIn_buf_size, - tinfl_put_buf_func_ptr pPut_buf_func, - void *pPut_buf_user, int flags) { - int result = 0; - tinfl_decompressor decomp; - mz_uint8 *pDict = (mz_uint8 *)MZ_MALLOC(TINFL_LZ_DICT_SIZE); - size_t in_buf_ofs = 0, dict_ofs = 0; - if (!pDict) - return TINFL_STATUS_FAILED; - tinfl_init(&decomp); - for (;;) { - size_t in_buf_size = *pIn_buf_size - in_buf_ofs, - dst_buf_size = TINFL_LZ_DICT_SIZE - dict_ofs; - tinfl_status status = - tinfl_decompress(&decomp, (const mz_uint8 *)pIn_buf + in_buf_ofs, - &in_buf_size, pDict, pDict + dict_ofs, &dst_buf_size, - (flags & ~(TINFL_FLAG_HAS_MORE_INPUT | - TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF))); - in_buf_ofs += in_buf_size; - if ((dst_buf_size) && - (!(*pPut_buf_func)(pDict + dict_ofs, (int)dst_buf_size, pPut_buf_user))) - break; - if (status != TINFL_STATUS_HAS_MORE_OUTPUT) { - result = (status == TINFL_STATUS_DONE); - break; - } - dict_ofs = (dict_ofs + dst_buf_size) & (TINFL_LZ_DICT_SIZE - 1); - } - MZ_FREE(pDict); - *pIn_buf_size = in_buf_ofs; - return result; -} - -#ifndef MINIZ_NO_MALLOC -tinfl_decompressor *tinfl_decompressor_alloc(void) { - tinfl_decompressor *pDecomp = - (tinfl_decompressor *)MZ_MALLOC(sizeof(tinfl_decompressor)); - if (pDecomp) - tinfl_init(pDecomp); - return pDecomp; -} - -void tinfl_decompressor_free(tinfl_decompressor *pDecomp) { MZ_FREE(pDecomp); } -#endif - -#ifdef __cplusplus -} -#endif -/************************************************************************** - * - * Copyright 2013-2014 RAD Game Tools and Valve Software - * Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC - * Copyright 2016 Martin Raiber - * All Rights Reserved. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - **************************************************************************/ - -#ifndef MINIZ_NO_ARCHIVE_APIS - -#ifdef __cplusplus -extern "C" { -#endif - -/* ------------------- .ZIP archive reading */ - -#ifdef MINIZ_NO_STDIO -#define MZ_FILE void * -#else -#include - -#if defined(_MSC_VER) -#include -#ifndef MINIZ_NO_TIME -#include -#endif -static wchar_t *str2wstr(const char *str) { - size_t len = strlen(str) + 1; - wchar_t *wstr = (wchar_t *)malloc(len * sizeof(wchar_t)); - MultiByteToWideChar(CP_UTF8, 0, str, (int)(len * sizeof(char)), wstr, - (int)len); - return wstr; -} - -static FILE *mz_fopen(const char *pFilename, const char *pMode) { - FILE *pFile = NULL; - wchar_t *wFilename = str2wstr(pFilename); - wchar_t *wMode = str2wstr(pMode); - -#ifdef ZIP_ENABLE_SHARABLE_FILE_OPEN - pFile = _wfopen(wFilename, wMode); -#else - _wfopen_s(&pFile, wFilename, wMode); -#endif - free(wFilename); - free(wMode); - - return pFile; -} - -static FILE *mz_freopen(const char *pPath, const char *pMode, FILE *pStream) { - FILE *pFile = NULL; - int res = 0; - - wchar_t *wPath = str2wstr(pPath); - wchar_t *wMode = str2wstr(pMode); - -#ifdef ZIP_ENABLE_SHARABLE_FILE_OPEN - pFile = _wfreopen(wPath, wMode, pStream); -#else - res = _wfreopen_s(&pFile, wPath, wMode, pStream); -#endif - - free(wPath); - free(wMode); - -#ifndef ZIP_ENABLE_SHARABLE_FILE_OPEN - if (res) { - return NULL; - } -#endif - - return pFile; -} - -static int mz_stat(const char *pPath, struct _stat64 *buffer) { - wchar_t *wPath = str2wstr(pPath); - int res = _wstat64(wPath, buffer); - - free(wPath); - - return res; -} - -static int mz_mkdir(const char *pDirname) { - wchar_t *wDirname = str2wstr(pDirname); - int res = _wmkdir(wDirname); - - free(wDirname); - - return res; -} - -#define MZ_FOPEN mz_fopen -#define MZ_FCLOSE fclose -#define MZ_FREAD fread -#define MZ_FWRITE fwrite -#define MZ_FTELL64 _ftelli64 -#define MZ_FSEEK64 _fseeki64 -#define MZ_FILE_STAT_STRUCT _stat64 -#define MZ_FILE_STAT mz_stat -#define MZ_FFLUSH fflush -#define MZ_FREOPEN mz_freopen -#define MZ_DELETE_FILE remove -#define MZ_MKDIR(d) mz_mkdir(d) - -#elif defined(__MINGW32__) || defined(__MINGW64__) -#include -#ifndef MINIZ_NO_TIME -#include -#endif - -#define MZ_FOPEN(f, m) fopen(f, m) -#define MZ_FCLOSE fclose -#define MZ_FREAD fread -#define MZ_FWRITE fwrite -#define MZ_FTELL64 ftell -#define MZ_FSEEK64 fseek -#define MZ_FILE_STAT_STRUCT stat -#define MZ_FILE_STAT stat -#define MZ_FFLUSH fflush -#define MZ_FREOPEN(f, m, s) freopen(f, m, s) -#define MZ_DELETE_FILE remove -#define MZ_MKDIR(d) _mkdir(d) - -#elif defined(__TINYC__) -#ifndef MINIZ_NO_TIME -#include -#endif - -#define MZ_FOPEN(f, m) fopen(f, m) -#define MZ_FCLOSE fclose -#define MZ_FREAD fread -#define MZ_FWRITE fwrite -#define MZ_FTELL64 ftell -#define MZ_FSEEK64 fseek -#define MZ_FILE_STAT_STRUCT stat -#define MZ_FILE_STAT stat -#define MZ_FFLUSH fflush -#define MZ_FREOPEN(f, m, s) freopen(f, m, s) -#define MZ_DELETE_FILE remove -#if defined(_WIN32) || defined(_WIN64) -#define MZ_MKDIR(d) _mkdir(d) -#else -#define MZ_MKDIR(d) mkdir(d, 0755) -#endif - -#elif defined(__USE_LARGEFILE64) /* gcc, clang */ -#ifndef MINIZ_NO_TIME -#include -#endif - -#define MZ_FOPEN(f, m) fopen64(f, m) -#define MZ_FCLOSE fclose -#define MZ_FREAD fread -#define MZ_FWRITE fwrite -#define MZ_FTELL64 ftello64 -#define MZ_FSEEK64 fseeko64 -#define MZ_FILE_STAT_STRUCT stat64 -#define MZ_FILE_STAT stat64 -#define MZ_FFLUSH fflush -#define MZ_FREOPEN(p, m, s) freopen64(p, m, s) -#define MZ_DELETE_FILE remove -#define MZ_MKDIR(d) mkdir(d, 0755) - -#elif defined(__APPLE__) -#ifndef MINIZ_NO_TIME -#include -#endif - -#define MZ_FOPEN(f, m) fopen(f, m) -#define MZ_FCLOSE fclose -#define MZ_FREAD fread -#define MZ_FWRITE fwrite -#define MZ_FTELL64 ftello -#define MZ_FSEEK64 fseeko -#define MZ_FILE_STAT_STRUCT stat -#define MZ_FILE_STAT stat -#define MZ_FFLUSH fflush -#define MZ_FREOPEN(p, m, s) freopen(p, m, s) -#define MZ_DELETE_FILE remove -#define MZ_MKDIR(d) mkdir(d, 0755) - -#else -#pragma message( \ - "Using fopen, ftello, fseeko, stat() etc. path for file I/O - this path may not support large files.") -#ifndef MINIZ_NO_TIME -#include -#endif - -#define MZ_FOPEN(f, m) fopen(f, m) -#define MZ_FCLOSE fclose -#define MZ_FREAD fread -#define MZ_FWRITE fwrite -#ifdef __STRICT_ANSI__ -#define MZ_FTELL64 ftell -#define MZ_FSEEK64 fseek -#else -#define MZ_FTELL64 ftello -#define MZ_FSEEK64 fseeko -#endif -#define MZ_FILE_STAT_STRUCT stat -#define MZ_FILE_STAT stat -#define MZ_FFLUSH fflush -#define MZ_FREOPEN(f, m, s) freopen(f, m, s) -#define MZ_DELETE_FILE remove -#define MZ_MKDIR(d) mkdir(d, 0755) - -#endif /* #ifdef _MSC_VER */ -#endif /* #ifdef MINIZ_NO_STDIO */ - -#ifndef CHMOD -// Upon successful completion, a value of 0 is returned. -// Otherwise, a value of -1 is returned and errno is set to indicate the error. -// int chmod(const char *path, mode_t mode); -#define CHMOD(f, m) chmod(f, m) -#endif - -#define MZ_TOLOWER(c) ((((c) >= 'A') && ((c) <= 'Z')) ? ((c) - 'A' + 'a') : (c)) - -/* Various ZIP archive enums. To completely avoid cross platform compiler - * alignment and platform endian issues, miniz.c doesn't use structs for any of - * this stuff. */ -enum { - /* ZIP archive identifiers and record sizes */ - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIG = 0x06054b50, - MZ_ZIP_CENTRAL_DIR_HEADER_SIG = 0x02014b50, - MZ_ZIP_LOCAL_DIR_HEADER_SIG = 0x04034b50, - MZ_ZIP_LOCAL_DIR_HEADER_SIZE = 30, - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE = 46, - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE = 22, - - /* ZIP64 archive identifier and record sizes */ - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIG = 0x06064b50, - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIG = 0x07064b50, - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE = 56, - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE = 20, - MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID = 0x0001, - MZ_ZIP_DATA_DESCRIPTOR_ID = 0x08074b50, - MZ_ZIP_DATA_DESCRIPTER_SIZE64 = 24, - MZ_ZIP_DATA_DESCRIPTER_SIZE32 = 16, - - /* Central directory header record offsets */ - MZ_ZIP_CDH_SIG_OFS = 0, - MZ_ZIP_CDH_VERSION_MADE_BY_OFS = 4, - MZ_ZIP_CDH_VERSION_NEEDED_OFS = 6, - MZ_ZIP_CDH_BIT_FLAG_OFS = 8, - MZ_ZIP_CDH_METHOD_OFS = 10, - MZ_ZIP_CDH_FILE_TIME_OFS = 12, - MZ_ZIP_CDH_FILE_DATE_OFS = 14, - MZ_ZIP_CDH_CRC32_OFS = 16, - MZ_ZIP_CDH_COMPRESSED_SIZE_OFS = 20, - MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS = 24, - MZ_ZIP_CDH_FILENAME_LEN_OFS = 28, - MZ_ZIP_CDH_EXTRA_LEN_OFS = 30, - MZ_ZIP_CDH_COMMENT_LEN_OFS = 32, - MZ_ZIP_CDH_DISK_START_OFS = 34, - MZ_ZIP_CDH_INTERNAL_ATTR_OFS = 36, - MZ_ZIP_CDH_EXTERNAL_ATTR_OFS = 38, - MZ_ZIP_CDH_LOCAL_HEADER_OFS = 42, - - /* Local directory header offsets */ - MZ_ZIP_LDH_SIG_OFS = 0, - MZ_ZIP_LDH_VERSION_NEEDED_OFS = 4, - MZ_ZIP_LDH_BIT_FLAG_OFS = 6, - MZ_ZIP_LDH_METHOD_OFS = 8, - MZ_ZIP_LDH_FILE_TIME_OFS = 10, - MZ_ZIP_LDH_FILE_DATE_OFS = 12, - MZ_ZIP_LDH_CRC32_OFS = 14, - MZ_ZIP_LDH_COMPRESSED_SIZE_OFS = 18, - MZ_ZIP_LDH_DECOMPRESSED_SIZE_OFS = 22, - MZ_ZIP_LDH_FILENAME_LEN_OFS = 26, - MZ_ZIP_LDH_EXTRA_LEN_OFS = 28, - MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR = 1 << 3, - - /* End of central directory offsets */ - MZ_ZIP_ECDH_SIG_OFS = 0, - MZ_ZIP_ECDH_NUM_THIS_DISK_OFS = 4, - MZ_ZIP_ECDH_NUM_DISK_CDIR_OFS = 6, - MZ_ZIP_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS = 8, - MZ_ZIP_ECDH_CDIR_TOTAL_ENTRIES_OFS = 10, - MZ_ZIP_ECDH_CDIR_SIZE_OFS = 12, - MZ_ZIP_ECDH_CDIR_OFS_OFS = 16, - MZ_ZIP_ECDH_COMMENT_SIZE_OFS = 20, - - /* ZIP64 End of central directory locator offsets */ - MZ_ZIP64_ECDL_SIG_OFS = 0, /* 4 bytes */ - MZ_ZIP64_ECDL_NUM_DISK_CDIR_OFS = 4, /* 4 bytes */ - MZ_ZIP64_ECDL_REL_OFS_TO_ZIP64_ECDR_OFS = 8, /* 8 bytes */ - MZ_ZIP64_ECDL_TOTAL_NUMBER_OF_DISKS_OFS = 16, /* 4 bytes */ - - /* ZIP64 End of central directory header offsets */ - MZ_ZIP64_ECDH_SIG_OFS = 0, /* 4 bytes */ - MZ_ZIP64_ECDH_SIZE_OF_RECORD_OFS = 4, /* 8 bytes */ - MZ_ZIP64_ECDH_VERSION_MADE_BY_OFS = 12, /* 2 bytes */ - MZ_ZIP64_ECDH_VERSION_NEEDED_OFS = 14, /* 2 bytes */ - MZ_ZIP64_ECDH_NUM_THIS_DISK_OFS = 16, /* 4 bytes */ - MZ_ZIP64_ECDH_NUM_DISK_CDIR_OFS = 20, /* 4 bytes */ - MZ_ZIP64_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS = 24, /* 8 bytes */ - MZ_ZIP64_ECDH_CDIR_TOTAL_ENTRIES_OFS = 32, /* 8 bytes */ - MZ_ZIP64_ECDH_CDIR_SIZE_OFS = 40, /* 8 bytes */ - MZ_ZIP64_ECDH_CDIR_OFS_OFS = 48, /* 8 bytes */ - MZ_ZIP_VERSION_MADE_BY_DOS_FILESYSTEM_ID = 0, - MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG = 0x10, - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED = 1, - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG = 32, - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION = 64, - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_LOCAL_DIR_IS_MASKED = 8192, - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8 = 1 << 11 -}; - -typedef struct { - void *m_p; - size_t m_size, m_capacity; - mz_uint m_element_size; -} mz_zip_array; - -struct mz_zip_internal_state_tag { - mz_zip_array m_central_dir; - mz_zip_array m_central_dir_offsets; - mz_zip_array m_sorted_central_dir_offsets; - - /* The flags passed in when the archive is initially opened. */ - uint32_t m_init_flags; - - /* MZ_TRUE if the archive has a zip64 end of central directory headers, etc. - */ - mz_bool m_zip64; - - /* MZ_TRUE if we found zip64 extended info in the central directory (m_zip64 - * will also be slammed to true too, even if we didn't find a zip64 end of - * central dir header, etc.) */ - mz_bool m_zip64_has_extended_info_fields; - - /* These fields are used by the file, FILE, memory, and memory/heap read/write - * helpers. */ - MZ_FILE *m_pFile; - mz_uint64 m_file_archive_start_ofs; - - void *m_pMem; - size_t m_mem_size; - size_t m_mem_capacity; -}; - -#define MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(array_ptr, element_size) \ - (array_ptr)->m_element_size = element_size - -#if defined(DEBUG) || defined(_DEBUG) -static MZ_FORCEINLINE mz_uint -mz_zip_array_range_check(const mz_zip_array *pArray, mz_uint index) { - MZ_ASSERT(index < pArray->m_size); - return index; -} -#define MZ_ZIP_ARRAY_ELEMENT(array_ptr, element_type, index) \ - ((element_type *)((array_ptr) \ - ->m_p))[mz_zip_array_range_check(array_ptr, index)] -#else -#define MZ_ZIP_ARRAY_ELEMENT(array_ptr, element_type, index) \ - ((element_type *)((array_ptr)->m_p))[index] -#endif - -static MZ_FORCEINLINE void mz_zip_array_init(mz_zip_array *pArray, - mz_uint32 element_size) { - memset(pArray, 0, sizeof(mz_zip_array)); - pArray->m_element_size = element_size; -} - -static MZ_FORCEINLINE void mz_zip_array_clear(mz_zip_archive *pZip, - mz_zip_array *pArray) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pArray->m_p); - memset(pArray, 0, sizeof(mz_zip_array)); -} - -static mz_bool mz_zip_array_ensure_capacity(mz_zip_archive *pZip, - mz_zip_array *pArray, - size_t min_new_capacity, - mz_uint growing) { - void *pNew_p; - size_t new_capacity = min_new_capacity; - MZ_ASSERT(pArray->m_element_size); - if (pArray->m_capacity >= min_new_capacity) - return MZ_TRUE; - if (growing) { - new_capacity = MZ_MAX(1, pArray->m_capacity); - while (new_capacity < min_new_capacity) - new_capacity *= 2; - } - if (NULL == (pNew_p = pZip->m_pRealloc(pZip->m_pAlloc_opaque, pArray->m_p, - pArray->m_element_size, new_capacity))) - return MZ_FALSE; - pArray->m_p = pNew_p; - pArray->m_capacity = new_capacity; - return MZ_TRUE; -} - -static MZ_FORCEINLINE mz_bool mz_zip_array_reserve(mz_zip_archive *pZip, - mz_zip_array *pArray, - size_t new_capacity, - mz_uint growing) { - if (new_capacity > pArray->m_capacity) { - if (!mz_zip_array_ensure_capacity(pZip, pArray, new_capacity, growing)) - return MZ_FALSE; - } - return MZ_TRUE; -} - -static MZ_FORCEINLINE mz_bool mz_zip_array_resize(mz_zip_archive *pZip, - mz_zip_array *pArray, - size_t new_size, - mz_uint growing) { - if (new_size > pArray->m_capacity) { - if (!mz_zip_array_ensure_capacity(pZip, pArray, new_size, growing)) - return MZ_FALSE; - } - pArray->m_size = new_size; - return MZ_TRUE; -} - -static MZ_FORCEINLINE mz_bool mz_zip_array_ensure_room(mz_zip_archive *pZip, - mz_zip_array *pArray, - size_t n) { - return mz_zip_array_reserve(pZip, pArray, pArray->m_size + n, MZ_TRUE); -} - -static MZ_FORCEINLINE mz_bool mz_zip_array_push_back(mz_zip_archive *pZip, - mz_zip_array *pArray, - const void *pElements, - size_t n) { - size_t orig_size = pArray->m_size; - if (!mz_zip_array_resize(pZip, pArray, orig_size + n, MZ_TRUE)) - return MZ_FALSE; - if (n > 0) - memcpy((mz_uint8 *)pArray->m_p + orig_size * pArray->m_element_size, - pElements, n * pArray->m_element_size); - return MZ_TRUE; -} - -#ifndef MINIZ_NO_TIME -static MZ_TIME_T mz_zip_dos_to_time_t(int dos_time, int dos_date) { - struct tm tm; - memset(&tm, 0, sizeof(tm)); - tm.tm_isdst = -1; - tm.tm_year = ((dos_date >> 9) & 127) + 1980 - 1900; - tm.tm_mon = ((dos_date >> 5) & 15) - 1; - tm.tm_mday = dos_date & 31; - tm.tm_hour = (dos_time >> 11) & 31; - tm.tm_min = (dos_time >> 5) & 63; - tm.tm_sec = (dos_time << 1) & 62; - return mktime(&tm); -} - -#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS -static void mz_zip_time_t_to_dos_time(MZ_TIME_T time, mz_uint16 *pDOS_time, - mz_uint16 *pDOS_date) { -#ifdef _MSC_VER - struct tm tm_struct; - struct tm *tm = &tm_struct; - errno_t err = localtime_s(tm, &time); - if (err) { - *pDOS_date = 0; - *pDOS_time = 0; - return; - } -#else - struct tm *tm = localtime(&time); -#endif /* #ifdef _MSC_VER */ - - *pDOS_time = (mz_uint16)(((tm->tm_hour) << 11) + ((tm->tm_min) << 5) + - ((tm->tm_sec) >> 1)); - *pDOS_date = (mz_uint16)(((tm->tm_year + 1900 - 1980) << 9) + - ((tm->tm_mon + 1) << 5) + tm->tm_mday); -} -#endif /* MINIZ_NO_ARCHIVE_WRITING_APIS */ - -#ifndef MINIZ_NO_STDIO -#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS -static mz_bool mz_zip_get_file_modified_time(const char *pFilename, - MZ_TIME_T *pTime) { - struct MZ_FILE_STAT_STRUCT file_stat; - - /* On Linux with x86 glibc, this call will fail on large files (I think >= - * 0x80000000 bytes) unless you compiled with _LARGEFILE64_SOURCE. Argh. */ - if (MZ_FILE_STAT(pFilename, &file_stat) != 0) - return MZ_FALSE; - - *pTime = file_stat.st_mtime; - - return MZ_TRUE; -} -#endif /* #ifndef MINIZ_NO_ARCHIVE_WRITING_APIS*/ - -static mz_bool mz_zip_set_file_times(const char *pFilename, - MZ_TIME_T access_time, - MZ_TIME_T modified_time) { - struct utimbuf t; - - memset(&t, 0, sizeof(t)); - t.actime = access_time; - t.modtime = modified_time; - - return !utime(pFilename, &t); -} -#endif /* #ifndef MINIZ_NO_STDIO */ -#endif /* #ifndef MINIZ_NO_TIME */ - -static MZ_FORCEINLINE mz_bool mz_zip_set_error(mz_zip_archive *pZip, - mz_zip_error err_num) { - if (pZip) - pZip->m_last_error = err_num; - return MZ_FALSE; -} - -static mz_bool mz_zip_reader_init_internal(mz_zip_archive *pZip, - mz_uint flags) { - (void)flags; - if ((!pZip) || (pZip->m_pState) || (pZip->m_zip_mode != MZ_ZIP_MODE_INVALID)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (!pZip->m_pAlloc) - pZip->m_pAlloc = miniz_def_alloc_func; - if (!pZip->m_pFree) - pZip->m_pFree = miniz_def_free_func; - if (!pZip->m_pRealloc) - pZip->m_pRealloc = miniz_def_realloc_func; - - pZip->m_archive_size = 0; - pZip->m_central_directory_file_ofs = 0; - pZip->m_total_files = 0; - pZip->m_last_error = MZ_ZIP_NO_ERROR; - - if (NULL == (pZip->m_pState = (mz_zip_internal_state *)pZip->m_pAlloc( - pZip->m_pAlloc_opaque, 1, sizeof(mz_zip_internal_state)))) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - memset(pZip->m_pState, 0, sizeof(mz_zip_internal_state)); - MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_central_dir, - sizeof(mz_uint8)); - MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_central_dir_offsets, - sizeof(mz_uint32)); - MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_sorted_central_dir_offsets, - sizeof(mz_uint32)); - pZip->m_pState->m_init_flags = flags; - pZip->m_pState->m_zip64 = MZ_FALSE; - pZip->m_pState->m_zip64_has_extended_info_fields = MZ_FALSE; - - pZip->m_zip_mode = MZ_ZIP_MODE_READING; - - return MZ_TRUE; -} - -static MZ_FORCEINLINE mz_bool -mz_zip_reader_filename_less(const mz_zip_array *pCentral_dir_array, - const mz_zip_array *pCentral_dir_offsets, - mz_uint l_index, mz_uint r_index) { - const mz_uint8 *pL = &MZ_ZIP_ARRAY_ELEMENT( - pCentral_dir_array, mz_uint8, - MZ_ZIP_ARRAY_ELEMENT(pCentral_dir_offsets, mz_uint32, - l_index)), - *pE; - const mz_uint8 *pR = &MZ_ZIP_ARRAY_ELEMENT( - pCentral_dir_array, mz_uint8, - MZ_ZIP_ARRAY_ELEMENT(pCentral_dir_offsets, mz_uint32, r_index)); - mz_uint l_len = MZ_READ_LE16(pL + MZ_ZIP_CDH_FILENAME_LEN_OFS), - r_len = MZ_READ_LE16(pR + MZ_ZIP_CDH_FILENAME_LEN_OFS); - mz_uint8 l = 0, r = 0; - pL += MZ_ZIP_CENTRAL_DIR_HEADER_SIZE; - pR += MZ_ZIP_CENTRAL_DIR_HEADER_SIZE; - pE = pL + MZ_MIN(l_len, r_len); - while (pL < pE) { - if ((l = MZ_TOLOWER(*pL)) != (r = MZ_TOLOWER(*pR))) - break; - pL++; - pR++; - } - return (pL == pE) ? (l_len < r_len) : (l < r); -} - -#define MZ_SWAP_UINT32(a, b) \ - do { \ - mz_uint32 t = a; \ - a = b; \ - b = t; \ - } \ - MZ_MACRO_END - -/* Heap sort of lowercased filenames, used to help accelerate plain central - * directory searches by mz_zip_reader_locate_file(). (Could also use qsort(), - * but it could allocate memory.) */ -static void -mz_zip_reader_sort_central_dir_offsets_by_filename(mz_zip_archive *pZip) { - mz_zip_internal_state *pState = pZip->m_pState; - const mz_zip_array *pCentral_dir_offsets = &pState->m_central_dir_offsets; - const mz_zip_array *pCentral_dir = &pState->m_central_dir; - mz_uint32 *pIndices; - mz_uint32 start, end; - const mz_uint32 size = pZip->m_total_files; - - if (size <= 1U) - return; - - pIndices = &MZ_ZIP_ARRAY_ELEMENT(&pState->m_sorted_central_dir_offsets, - mz_uint32, 0); - - start = (size - 2U) >> 1U; - for (;;) { - mz_uint64 child, root = start; - for (;;) { - if ((child = (root << 1U) + 1U) >= size) - break; - child += (((child + 1U) < size) && - (mz_zip_reader_filename_less(pCentral_dir, pCentral_dir_offsets, - pIndices[child], - pIndices[child + 1U]))); - if (!mz_zip_reader_filename_less(pCentral_dir, pCentral_dir_offsets, - pIndices[root], pIndices[child])) - break; - MZ_SWAP_UINT32(pIndices[root], pIndices[child]); - root = child; - } - if (!start) - break; - start--; - } - - end = size - 1; - while (end > 0) { - mz_uint64 child, root = 0; - MZ_SWAP_UINT32(pIndices[end], pIndices[0]); - for (;;) { - if ((child = (root << 1U) + 1U) >= end) - break; - child += - (((child + 1U) < end) && - mz_zip_reader_filename_less(pCentral_dir, pCentral_dir_offsets, - pIndices[child], pIndices[child + 1U])); - if (!mz_zip_reader_filename_less(pCentral_dir, pCentral_dir_offsets, - pIndices[root], pIndices[child])) - break; - MZ_SWAP_UINT32(pIndices[root], pIndices[child]); - root = child; - } - end--; - } -} - -static mz_bool mz_zip_reader_locate_header_sig(mz_zip_archive *pZip, - mz_uint32 record_sig, - mz_uint32 record_size, - mz_int64 *pOfs) { - mz_int64 cur_file_ofs; - mz_uint32 buf_u32[4096 / sizeof(mz_uint32)]; - mz_uint8 *pBuf = (mz_uint8 *)buf_u32; - - /* Basic sanity checks - reject files which are too small */ - if (pZip->m_archive_size < record_size) - return MZ_FALSE; - - /* Find the record by scanning the file from the end towards the beginning. */ - cur_file_ofs = - MZ_MAX((mz_int64)pZip->m_archive_size - (mz_int64)sizeof(buf_u32), 0); - for (;;) { - int i, - n = (int)MZ_MIN(sizeof(buf_u32), pZip->m_archive_size - cur_file_ofs); - - if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pBuf, n) != (mz_uint)n) - return MZ_FALSE; - - for (i = n - 4; i >= 0; --i) { - mz_uint s = MZ_READ_LE32(pBuf + i); - if (s == record_sig) { - if ((pZip->m_archive_size - (cur_file_ofs + i)) >= record_size) - break; - } - } - - if (i >= 0) { - cur_file_ofs += i; - break; - } - - /* Give up if we've searched the entire file, or we've gone back "too far" - * (~64kb) */ - if ((!cur_file_ofs) || ((pZip->m_archive_size - cur_file_ofs) >= - (MZ_UINT16_MAX + record_size))) - return MZ_FALSE; - - cur_file_ofs = MZ_MAX(cur_file_ofs - (sizeof(buf_u32) - 3), 0); - } - - *pOfs = cur_file_ofs; - return MZ_TRUE; -} - -static mz_bool mz_zip_reader_read_central_dir(mz_zip_archive *pZip, - mz_uint flags) { - mz_uint cdir_size = 0, cdir_entries_on_this_disk = 0, num_this_disk = 0, - cdir_disk_index = 0; - mz_uint64 cdir_ofs = 0; - mz_int64 cur_file_ofs = 0; - const mz_uint8 *p; - - mz_uint32 buf_u32[4096 / sizeof(mz_uint32)]; - mz_uint8 *pBuf = (mz_uint8 *)buf_u32; - mz_bool sort_central_dir = - ((flags & MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY) == 0); - mz_uint32 zip64_end_of_central_dir_locator_u32 - [(MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE + sizeof(mz_uint32) - 1) / - sizeof(mz_uint32)]; - mz_uint8 *pZip64_locator = (mz_uint8 *)zip64_end_of_central_dir_locator_u32; - - mz_uint32 zip64_end_of_central_dir_header_u32 - [(MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) / - sizeof(mz_uint32)]; - mz_uint8 *pZip64_end_of_central_dir = - (mz_uint8 *)zip64_end_of_central_dir_header_u32; - - mz_uint64 zip64_end_of_central_dir_ofs = 0; - - /* Basic sanity checks - reject files which are too small, and check the first - * 4 bytes of the file to make sure a local header is there. */ - if (pZip->m_archive_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE); - - if (!mz_zip_reader_locate_header_sig( - pZip, MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIG, - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE, &cur_file_ofs)) - return mz_zip_set_error(pZip, MZ_ZIP_FAILED_FINDING_CENTRAL_DIR); - - /* Read and verify the end of central directory record. */ - if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pBuf, - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) != - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - - if (MZ_READ_LE32(pBuf + MZ_ZIP_ECDH_SIG_OFS) != - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIG) - return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE); - - if (cur_file_ofs >= (MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE + - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE)) { - if (pZip->m_pRead(pZip->m_pIO_opaque, - cur_file_ofs - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE, - pZip64_locator, - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE) == - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE) { - if (MZ_READ_LE32(pZip64_locator + MZ_ZIP64_ECDL_SIG_OFS) == - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIG) { - zip64_end_of_central_dir_ofs = MZ_READ_LE64( - pZip64_locator + MZ_ZIP64_ECDL_REL_OFS_TO_ZIP64_ECDR_OFS); - if (zip64_end_of_central_dir_ofs > - (pZip->m_archive_size - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE)) - return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE); - - if (pZip->m_pRead(pZip->m_pIO_opaque, zip64_end_of_central_dir_ofs, - pZip64_end_of_central_dir, - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE) == - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE) { - if (MZ_READ_LE32(pZip64_end_of_central_dir + MZ_ZIP64_ECDH_SIG_OFS) == - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIG) { - pZip->m_pState->m_zip64 = MZ_TRUE; - } - } - } - } - } - - pZip->m_total_files = MZ_READ_LE16(pBuf + MZ_ZIP_ECDH_CDIR_TOTAL_ENTRIES_OFS); - cdir_entries_on_this_disk = - MZ_READ_LE16(pBuf + MZ_ZIP_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS); - num_this_disk = MZ_READ_LE16(pBuf + MZ_ZIP_ECDH_NUM_THIS_DISK_OFS); - cdir_disk_index = MZ_READ_LE16(pBuf + MZ_ZIP_ECDH_NUM_DISK_CDIR_OFS); - cdir_size = MZ_READ_LE32(pBuf + MZ_ZIP_ECDH_CDIR_SIZE_OFS); - cdir_ofs = MZ_READ_LE32(pBuf + MZ_ZIP_ECDH_CDIR_OFS_OFS); - - if (pZip->m_pState->m_zip64) { - mz_uint32 zip64_total_num_of_disks = - MZ_READ_LE32(pZip64_locator + MZ_ZIP64_ECDL_TOTAL_NUMBER_OF_DISKS_OFS); - mz_uint64 zip64_cdir_total_entries = MZ_READ_LE64( - pZip64_end_of_central_dir + MZ_ZIP64_ECDH_CDIR_TOTAL_ENTRIES_OFS); - mz_uint64 zip64_cdir_total_entries_on_this_disk = MZ_READ_LE64( - pZip64_end_of_central_dir + MZ_ZIP64_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS); - mz_uint64 zip64_size_of_end_of_central_dir_record = MZ_READ_LE64( - pZip64_end_of_central_dir + MZ_ZIP64_ECDH_SIZE_OF_RECORD_OFS); - mz_uint64 zip64_size_of_central_directory = - MZ_READ_LE64(pZip64_end_of_central_dir + MZ_ZIP64_ECDH_CDIR_SIZE_OFS); - - if (zip64_size_of_end_of_central_dir_record < - (MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE - 12)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - if (zip64_total_num_of_disks != 1U) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_MULTIDISK); - - /* Check for miniz's practical limits */ - if (zip64_cdir_total_entries > MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - - pZip->m_total_files = (mz_uint32)zip64_cdir_total_entries; - - if (zip64_cdir_total_entries_on_this_disk > MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - - cdir_entries_on_this_disk = - (mz_uint32)zip64_cdir_total_entries_on_this_disk; - - /* Check for miniz's current practical limits (sorry, this should be enough - * for millions of files) */ - if (zip64_size_of_central_directory > MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE); - - cdir_size = (mz_uint32)zip64_size_of_central_directory; - - num_this_disk = MZ_READ_LE32(pZip64_end_of_central_dir + - MZ_ZIP64_ECDH_NUM_THIS_DISK_OFS); - - cdir_disk_index = MZ_READ_LE32(pZip64_end_of_central_dir + - MZ_ZIP64_ECDH_NUM_DISK_CDIR_OFS); - - cdir_ofs = - MZ_READ_LE64(pZip64_end_of_central_dir + MZ_ZIP64_ECDH_CDIR_OFS_OFS); - } - - if (pZip->m_total_files != cdir_entries_on_this_disk) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_MULTIDISK); - - if (((num_this_disk | cdir_disk_index) != 0) && - ((num_this_disk != 1) || (cdir_disk_index != 1))) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_MULTIDISK); - - if (cdir_size < pZip->m_total_files * MZ_ZIP_CENTRAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - if ((cdir_ofs + (mz_uint64)cdir_size) > pZip->m_archive_size) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - pZip->m_central_directory_file_ofs = cdir_ofs; - - if (pZip->m_total_files) { - mz_uint i, n; - /* Read the entire central directory into a heap block, and allocate another - * heap block to hold the unsorted central dir file record offsets, and - * possibly another to hold the sorted indices. */ - if ((!mz_zip_array_resize(pZip, &pZip->m_pState->m_central_dir, cdir_size, - MZ_FALSE)) || - (!mz_zip_array_resize(pZip, &pZip->m_pState->m_central_dir_offsets, - pZip->m_total_files, MZ_FALSE))) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - if (sort_central_dir) { - if (!mz_zip_array_resize(pZip, - &pZip->m_pState->m_sorted_central_dir_offsets, - pZip->m_total_files, MZ_FALSE)) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - if (pZip->m_pRead(pZip->m_pIO_opaque, cdir_ofs, - pZip->m_pState->m_central_dir.m_p, - cdir_size) != cdir_size) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - - /* Now create an index into the central directory file records, do some - * basic sanity checking on each record */ - p = (const mz_uint8 *)pZip->m_pState->m_central_dir.m_p; - for (n = cdir_size, i = 0; i < pZip->m_total_files; ++i) { - mz_uint total_header_size, disk_index, bit_flags, filename_size, - ext_data_size; - mz_uint64 comp_size, decomp_size, local_header_ofs; - - if ((n < MZ_ZIP_CENTRAL_DIR_HEADER_SIZE) || - (MZ_READ_LE32(p) != MZ_ZIP_CENTRAL_DIR_HEADER_SIG)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_central_dir_offsets, mz_uint32, - i) = - (mz_uint32)(p - (const mz_uint8 *)pZip->m_pState->m_central_dir.m_p); - - if (sort_central_dir) - MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_sorted_central_dir_offsets, - mz_uint32, i) = i; - - comp_size = MZ_READ_LE32(p + MZ_ZIP_CDH_COMPRESSED_SIZE_OFS); - decomp_size = MZ_READ_LE32(p + MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS); - local_header_ofs = MZ_READ_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS); - filename_size = MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS); - ext_data_size = MZ_READ_LE16(p + MZ_ZIP_CDH_EXTRA_LEN_OFS); - - if ((!pZip->m_pState->m_zip64_has_extended_info_fields) && - (ext_data_size) && - (MZ_MAX(MZ_MAX(comp_size, decomp_size), local_header_ofs) == - MZ_UINT32_MAX)) { - /* Attempt to find zip64 extended information field in the entry's extra - * data */ - mz_uint32 extra_size_remaining = ext_data_size; - - if (extra_size_remaining) { - const mz_uint8 *pExtra_data; - void *buf = NULL; - - if (MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + filename_size + ext_data_size > - n) { - buf = MZ_MALLOC(ext_data_size); - if (buf == NULL) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - if (pZip->m_pRead(pZip->m_pIO_opaque, - cdir_ofs + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + - filename_size, - buf, ext_data_size) != ext_data_size) { - MZ_FREE(buf); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - } - - pExtra_data = (mz_uint8 *)buf; - } else { - pExtra_data = p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + filename_size; - } - - do { - mz_uint32 field_id; - mz_uint32 field_data_size; - - if (extra_size_remaining < (sizeof(mz_uint16) * 2)) { - MZ_FREE(buf); - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - } - - field_id = MZ_READ_LE16(pExtra_data); - field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16)); - - if ((field_data_size + sizeof(mz_uint16) * 2) > - extra_size_remaining) { - MZ_FREE(buf); - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - } - - if (field_id == MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) { - /* Ok, the archive didn't have any zip64 headers but it uses a - * zip64 extended information field so mark it as zip64 anyway - * (this can occur with infozip's zip util when it reads - * compresses files from stdin). */ - pZip->m_pState->m_zip64 = MZ_TRUE; - pZip->m_pState->m_zip64_has_extended_info_fields = MZ_TRUE; - break; - } - - pExtra_data += sizeof(mz_uint16) * 2 + field_data_size; - extra_size_remaining = - extra_size_remaining - sizeof(mz_uint16) * 2 - field_data_size; - } while (extra_size_remaining); - - MZ_FREE(buf); - } - } - - /* I've seen archives that aren't marked as zip64 that uses zip64 ext - * data, argh */ - if ((comp_size != MZ_UINT32_MAX) && (decomp_size != MZ_UINT32_MAX)) { - if (((!MZ_READ_LE32(p + MZ_ZIP_CDH_METHOD_OFS)) && - (decomp_size != comp_size)) || - (decomp_size && !comp_size)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - } - - disk_index = MZ_READ_LE16(p + MZ_ZIP_CDH_DISK_START_OFS); - if ((disk_index == MZ_UINT16_MAX) || - ((disk_index != num_this_disk) && (disk_index != 1))) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_MULTIDISK); - - if (comp_size != MZ_UINT32_MAX) { - if (((mz_uint64)MZ_READ_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS) + - MZ_ZIP_LOCAL_DIR_HEADER_SIZE + comp_size) > pZip->m_archive_size) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - } - - bit_flags = MZ_READ_LE16(p + MZ_ZIP_CDH_BIT_FLAG_OFS); - if (bit_flags & MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_LOCAL_DIR_IS_MASKED) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION); - - if ((total_header_size = MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + - MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS) + - MZ_READ_LE16(p + MZ_ZIP_CDH_EXTRA_LEN_OFS) + - MZ_READ_LE16(p + MZ_ZIP_CDH_COMMENT_LEN_OFS)) > - n) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - n -= total_header_size; - p += total_header_size; - } - } - - if (sort_central_dir) - mz_zip_reader_sort_central_dir_offsets_by_filename(pZip); - - return MZ_TRUE; -} - -void mz_zip_zero_struct(mz_zip_archive *pZip) { - if (pZip) - MZ_CLEAR_OBJ(*pZip); -} - -static mz_bool mz_zip_reader_end_internal(mz_zip_archive *pZip, - mz_bool set_last_error) { - mz_bool status = MZ_TRUE; - - if (!pZip) - return MZ_FALSE; - - if ((!pZip->m_pState) || (!pZip->m_pAlloc) || (!pZip->m_pFree) || - (pZip->m_zip_mode != MZ_ZIP_MODE_READING)) { - if (set_last_error) - pZip->m_last_error = MZ_ZIP_INVALID_PARAMETER; - - return MZ_FALSE; - } - - if (pZip->m_pState) { - mz_zip_internal_state *pState = pZip->m_pState; - pZip->m_pState = NULL; - - mz_zip_array_clear(pZip, &pState->m_central_dir); - mz_zip_array_clear(pZip, &pState->m_central_dir_offsets); - mz_zip_array_clear(pZip, &pState->m_sorted_central_dir_offsets); - -#ifndef MINIZ_NO_STDIO - if (pState->m_pFile) { - if (pZip->m_zip_type == MZ_ZIP_TYPE_FILE) { - if (MZ_FCLOSE(pState->m_pFile) == EOF) { - if (set_last_error) - pZip->m_last_error = MZ_ZIP_FILE_CLOSE_FAILED; - status = MZ_FALSE; - } - } - pState->m_pFile = NULL; - } -#endif /* #ifndef MINIZ_NO_STDIO */ - - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - } - pZip->m_zip_mode = MZ_ZIP_MODE_INVALID; - - return status; -} - -mz_bool mz_zip_reader_end(mz_zip_archive *pZip) { - return mz_zip_reader_end_internal(pZip, MZ_TRUE); -} -mz_bool mz_zip_reader_init(mz_zip_archive *pZip, mz_uint64 size, - mz_uint flags) { - if ((!pZip) || (!pZip->m_pRead)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (!mz_zip_reader_init_internal(pZip, flags)) - return MZ_FALSE; - - pZip->m_zip_type = MZ_ZIP_TYPE_USER; - pZip->m_archive_size = size; - - if (!mz_zip_reader_read_central_dir(pZip, flags)) { - mz_zip_reader_end_internal(pZip, MZ_FALSE); - return MZ_FALSE; - } - - return MZ_TRUE; -} - -static size_t mz_zip_mem_read_func(void *pOpaque, mz_uint64 file_ofs, - void *pBuf, size_t n) { - mz_zip_archive *pZip = (mz_zip_archive *)pOpaque; - size_t s = (file_ofs >= pZip->m_archive_size) - ? 0 - : (size_t)MZ_MIN(pZip->m_archive_size - file_ofs, n); - memcpy(pBuf, (const mz_uint8 *)pZip->m_pState->m_pMem + file_ofs, s); - return s; -} - -mz_bool mz_zip_reader_init_mem(mz_zip_archive *pZip, const void *pMem, - size_t size, mz_uint flags) { - if (!pMem) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE); - - if (!mz_zip_reader_init_internal(pZip, flags)) - return MZ_FALSE; - - pZip->m_zip_type = MZ_ZIP_TYPE_MEMORY; - pZip->m_archive_size = size; - pZip->m_pRead = mz_zip_mem_read_func; - pZip->m_pIO_opaque = pZip; - pZip->m_pNeeds_keepalive = NULL; - -#ifdef __cplusplus - pZip->m_pState->m_pMem = const_cast(pMem); -#else - pZip->m_pState->m_pMem = (void *)pMem; -#endif - - pZip->m_pState->m_mem_size = size; - - if (!mz_zip_reader_read_central_dir(pZip, flags)) { - mz_zip_reader_end_internal(pZip, MZ_FALSE); - return MZ_FALSE; - } - - return MZ_TRUE; -} - -#ifndef MINIZ_NO_STDIO -static size_t mz_zip_file_read_func(void *pOpaque, mz_uint64 file_ofs, - void *pBuf, size_t n) { - mz_zip_archive *pZip = (mz_zip_archive *)pOpaque; - mz_int64 cur_ofs = MZ_FTELL64(pZip->m_pState->m_pFile); - - file_ofs += pZip->m_pState->m_file_archive_start_ofs; - - if (((mz_int64)file_ofs < 0) || - (((cur_ofs != (mz_int64)file_ofs)) && - (MZ_FSEEK64(pZip->m_pState->m_pFile, (mz_int64)file_ofs, SEEK_SET)))) - return 0; - - return MZ_FREAD(pBuf, 1, n, pZip->m_pState->m_pFile); -} - -mz_bool mz_zip_reader_init_file(mz_zip_archive *pZip, const char *pFilename, - mz_uint32 flags) { - return mz_zip_reader_init_file_v2(pZip, pFilename, flags, 0, 0); -} - -mz_bool mz_zip_reader_init_file_v2(mz_zip_archive *pZip, const char *pFilename, - mz_uint flags, mz_uint64 file_start_ofs, - mz_uint64 archive_size) { - mz_uint64 file_size; - MZ_FILE *pFile; - - if ((!pZip) || (!pFilename) || - ((archive_size) && - (archive_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE))) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pFile = MZ_FOPEN(pFilename, "rb"); - if (!pFile) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED); - - file_size = archive_size; - if (!file_size) { - if (MZ_FSEEK64(pFile, 0, SEEK_END)) { - MZ_FCLOSE(pFile); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_SEEK_FAILED); - } - - file_size = MZ_FTELL64(pFile); - } - - /* TODO: Better sanity check archive_size and the # of actual remaining bytes - */ - - if (file_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) { - MZ_FCLOSE(pFile); - return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE); - } - - if (!mz_zip_reader_init_internal(pZip, flags)) { - MZ_FCLOSE(pFile); - return MZ_FALSE; - } - - pZip->m_zip_type = MZ_ZIP_TYPE_FILE; - pZip->m_pRead = mz_zip_file_read_func; - pZip->m_pIO_opaque = pZip; - pZip->m_pState->m_pFile = pFile; - pZip->m_archive_size = file_size; - pZip->m_pState->m_file_archive_start_ofs = file_start_ofs; - - if (!mz_zip_reader_read_central_dir(pZip, flags)) { - mz_zip_reader_end_internal(pZip, MZ_FALSE); - return MZ_FALSE; - } - - return MZ_TRUE; -} - -mz_bool mz_zip_reader_init_file_v2_rpb(mz_zip_archive *pZip, - const char *pFilename, mz_uint flags, - mz_uint64 file_start_ofs, - mz_uint64 archive_size) { - mz_uint64 file_size; - MZ_FILE *pFile; - - if ((!pZip) || (!pFilename) || - ((archive_size) && - (archive_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE))) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pFile = MZ_FOPEN(pFilename, "r+b"); - if (!pFile) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED); - - file_size = archive_size; - if (!file_size) { - if (MZ_FSEEK64(pFile, 0, SEEK_END)) { - MZ_FCLOSE(pFile); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_SEEK_FAILED); - } - - file_size = MZ_FTELL64(pFile); - } - - /* TODO: Better sanity check archive_size and the # of actual remaining bytes - */ - - if (file_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) { - MZ_FCLOSE(pFile); - return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE); - } - - if (!mz_zip_reader_init_internal(pZip, flags)) { - MZ_FCLOSE(pFile); - return MZ_FALSE; - } - - pZip->m_zip_type = MZ_ZIP_TYPE_FILE; - pZip->m_pRead = mz_zip_file_read_func; - pZip->m_pIO_opaque = pZip; - pZip->m_pState->m_pFile = pFile; - pZip->m_archive_size = file_size; - pZip->m_pState->m_file_archive_start_ofs = file_start_ofs; - - if (!mz_zip_reader_read_central_dir(pZip, flags)) { - mz_zip_reader_end_internal(pZip, MZ_FALSE); - return MZ_FALSE; - } - - return MZ_TRUE; -} - -mz_bool mz_zip_reader_init_cfile(mz_zip_archive *pZip, MZ_FILE *pFile, - mz_uint64 archive_size, mz_uint flags) { - mz_uint64 cur_file_ofs; - - if ((!pZip) || (!pFile)) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED); - - cur_file_ofs = MZ_FTELL64(pFile); - - if (!archive_size) { - if (MZ_FSEEK64(pFile, 0, SEEK_END)) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_SEEK_FAILED); - - archive_size = MZ_FTELL64(pFile) - cur_file_ofs; - - if (archive_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE); - } - - if (!mz_zip_reader_init_internal(pZip, flags)) - return MZ_FALSE; - - pZip->m_zip_type = MZ_ZIP_TYPE_CFILE; - pZip->m_pRead = mz_zip_file_read_func; - - pZip->m_pIO_opaque = pZip; - pZip->m_pState->m_pFile = pFile; - pZip->m_archive_size = archive_size; - pZip->m_pState->m_file_archive_start_ofs = cur_file_ofs; - - if (!mz_zip_reader_read_central_dir(pZip, flags)) { - mz_zip_reader_end_internal(pZip, MZ_FALSE); - return MZ_FALSE; - } - - return MZ_TRUE; -} - -#endif /* #ifndef MINIZ_NO_STDIO */ - -static MZ_FORCEINLINE const mz_uint8 *mz_zip_get_cdh(mz_zip_archive *pZip, - mz_uint file_index) { - if ((!pZip) || (!pZip->m_pState) || (file_index >= pZip->m_total_files)) - return NULL; - return &MZ_ZIP_ARRAY_ELEMENT( - &pZip->m_pState->m_central_dir, mz_uint8, - MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_central_dir_offsets, mz_uint32, - file_index)); -} - -mz_bool mz_zip_reader_is_file_encrypted(mz_zip_archive *pZip, - mz_uint file_index) { - mz_uint m_bit_flag; - const mz_uint8 *p = mz_zip_get_cdh(pZip, file_index); - if (!p) { - mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - return MZ_FALSE; - } - - m_bit_flag = MZ_READ_LE16(p + MZ_ZIP_CDH_BIT_FLAG_OFS); - return (m_bit_flag & - (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED | - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION)) != 0; -} - -mz_bool mz_zip_reader_is_file_supported(mz_zip_archive *pZip, - mz_uint file_index) { - mz_uint bit_flag; - mz_uint method; - - const mz_uint8 *p = mz_zip_get_cdh(pZip, file_index); - if (!p) { - mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - return MZ_FALSE; - } - - method = MZ_READ_LE16(p + MZ_ZIP_CDH_METHOD_OFS); - bit_flag = MZ_READ_LE16(p + MZ_ZIP_CDH_BIT_FLAG_OFS); - - if ((method != 0) && (method != MZ_DEFLATED)) { - mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD); - return MZ_FALSE; - } - - if (bit_flag & (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED | - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION)) { - mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION); - return MZ_FALSE; - } - - if (bit_flag & MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG) { - mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_FEATURE); - return MZ_FALSE; - } - - return MZ_TRUE; -} - -mz_bool mz_zip_reader_is_file_a_directory(mz_zip_archive *pZip, - mz_uint file_index) { - mz_uint filename_len, attribute_mapping_id, external_attr; - const mz_uint8 *p = mz_zip_get_cdh(pZip, file_index); - if (!p) { - mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - return MZ_FALSE; - } - - filename_len = MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS); - if (filename_len) { - if (*(p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + filename_len - 1) == '/') - return MZ_TRUE; - } - - /* Bugfix: This code was also checking if the internal attribute was non-zero, - * which wasn't correct. */ - /* Most/all zip writers (hopefully) set DOS file/directory attributes in the - * low 16-bits, so check for the DOS directory flag and ignore the source OS - * ID in the created by field. */ - /* FIXME: Remove this check? Is it necessary - we already check the filename. - */ - attribute_mapping_id = MZ_READ_LE16(p + MZ_ZIP_CDH_VERSION_MADE_BY_OFS) >> 8; - (void)attribute_mapping_id; - - external_attr = MZ_READ_LE32(p + MZ_ZIP_CDH_EXTERNAL_ATTR_OFS); - if ((external_attr & MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG) != 0) { - return MZ_TRUE; - } - - return MZ_FALSE; -} - -static mz_bool mz_zip_file_stat_internal(mz_zip_archive *pZip, - mz_uint file_index, - const mz_uint8 *pCentral_dir_header, - mz_zip_archive_file_stat *pStat, - mz_bool *pFound_zip64_extra_data) { - mz_uint n; - const mz_uint8 *p = pCentral_dir_header; - - if (pFound_zip64_extra_data) - *pFound_zip64_extra_data = MZ_FALSE; - - if ((!p) || (!pStat)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - /* Extract fields from the central directory record. */ - pStat->m_file_index = file_index; - pStat->m_central_dir_ofs = MZ_ZIP_ARRAY_ELEMENT( - &pZip->m_pState->m_central_dir_offsets, mz_uint32, file_index); - pStat->m_version_made_by = MZ_READ_LE16(p + MZ_ZIP_CDH_VERSION_MADE_BY_OFS); - pStat->m_version_needed = MZ_READ_LE16(p + MZ_ZIP_CDH_VERSION_NEEDED_OFS); - pStat->m_bit_flag = MZ_READ_LE16(p + MZ_ZIP_CDH_BIT_FLAG_OFS); - pStat->m_method = MZ_READ_LE16(p + MZ_ZIP_CDH_METHOD_OFS); -#ifndef MINIZ_NO_TIME - pStat->m_time = - mz_zip_dos_to_time_t(MZ_READ_LE16(p + MZ_ZIP_CDH_FILE_TIME_OFS), - MZ_READ_LE16(p + MZ_ZIP_CDH_FILE_DATE_OFS)); -#endif - pStat->m_crc32 = MZ_READ_LE32(p + MZ_ZIP_CDH_CRC32_OFS); - pStat->m_comp_size = MZ_READ_LE32(p + MZ_ZIP_CDH_COMPRESSED_SIZE_OFS); - pStat->m_uncomp_size = MZ_READ_LE32(p + MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS); - pStat->m_internal_attr = MZ_READ_LE16(p + MZ_ZIP_CDH_INTERNAL_ATTR_OFS); - pStat->m_external_attr = MZ_READ_LE32(p + MZ_ZIP_CDH_EXTERNAL_ATTR_OFS); - pStat->m_local_header_ofs = MZ_READ_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS); - - /* Copy as much of the filename and comment as possible. */ - n = MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS); - n = MZ_MIN(n, MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE - 1); - memcpy(pStat->m_filename, p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE, n); - pStat->m_filename[n] = '\0'; - - n = MZ_READ_LE16(p + MZ_ZIP_CDH_COMMENT_LEN_OFS); - n = MZ_MIN(n, MZ_ZIP_MAX_ARCHIVE_FILE_COMMENT_SIZE - 1); - pStat->m_comment_size = n; - memcpy(pStat->m_comment, - p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + - MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS) + - MZ_READ_LE16(p + MZ_ZIP_CDH_EXTRA_LEN_OFS), - n); - pStat->m_comment[n] = '\0'; - - /* Set some flags for convienance */ - pStat->m_is_directory = mz_zip_reader_is_file_a_directory(pZip, file_index); - pStat->m_is_encrypted = mz_zip_reader_is_file_encrypted(pZip, file_index); - pStat->m_is_supported = mz_zip_reader_is_file_supported(pZip, file_index); - - /* See if we need to read any zip64 extended information fields. */ - /* Confusingly, these zip64 fields can be present even on non-zip64 archives - * (Debian zip on a huge files from stdin piped to stdout creates them). */ - if (MZ_MAX(MZ_MAX(pStat->m_comp_size, pStat->m_uncomp_size), - pStat->m_local_header_ofs) == MZ_UINT32_MAX) { - /* Attempt to find zip64 extended information field in the entry's extra - * data */ - mz_uint32 extra_size_remaining = MZ_READ_LE16(p + MZ_ZIP_CDH_EXTRA_LEN_OFS); - - if (extra_size_remaining) { - const mz_uint8 *pExtra_data = - p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + - MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS); - - do { - mz_uint32 field_id; - mz_uint32 field_data_size; - - if (extra_size_remaining < (sizeof(mz_uint16) * 2)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - field_id = MZ_READ_LE16(pExtra_data); - field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16)); - - if ((field_data_size + sizeof(mz_uint16) * 2) > extra_size_remaining) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - if (field_id == MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) { - const mz_uint8 *pField_data = pExtra_data + sizeof(mz_uint16) * 2; - mz_uint32 field_data_remaining = field_data_size; - - if (pFound_zip64_extra_data) - *pFound_zip64_extra_data = MZ_TRUE; - - if (pStat->m_uncomp_size == MZ_UINT32_MAX) { - if (field_data_remaining < sizeof(mz_uint64)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - pStat->m_uncomp_size = MZ_READ_LE64(pField_data); - pField_data += sizeof(mz_uint64); - field_data_remaining -= sizeof(mz_uint64); - } - - if (pStat->m_comp_size == MZ_UINT32_MAX) { - if (field_data_remaining < sizeof(mz_uint64)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - pStat->m_comp_size = MZ_READ_LE64(pField_data); - pField_data += sizeof(mz_uint64); - field_data_remaining -= sizeof(mz_uint64); - } - - if (pStat->m_local_header_ofs == MZ_UINT32_MAX) { - if (field_data_remaining < sizeof(mz_uint64)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - pStat->m_local_header_ofs = MZ_READ_LE64(pField_data); - pField_data += sizeof(mz_uint64); - (void)pField_data; // unused - - field_data_remaining -= sizeof(mz_uint64); - (void)field_data_remaining; // unused - } - - break; - } - - pExtra_data += sizeof(mz_uint16) * 2 + field_data_size; - extra_size_remaining = - extra_size_remaining - sizeof(mz_uint16) * 2 - field_data_size; - } while (extra_size_remaining); - } - } - - return MZ_TRUE; -} - -static MZ_FORCEINLINE mz_bool mz_zip_string_equal(const char *pA, - const char *pB, mz_uint len, - mz_uint flags) { - mz_uint i; - if (flags & MZ_ZIP_FLAG_CASE_SENSITIVE) - return 0 == memcmp(pA, pB, len); - for (i = 0; i < len; ++i) - if (MZ_TOLOWER(pA[i]) != MZ_TOLOWER(pB[i])) - return MZ_FALSE; - return MZ_TRUE; -} - -static MZ_FORCEINLINE int -mz_zip_filename_compare(const mz_zip_array *pCentral_dir_array, - const mz_zip_array *pCentral_dir_offsets, - mz_uint l_index, const char *pR, mz_uint r_len) { - const mz_uint8 *pL = &MZ_ZIP_ARRAY_ELEMENT( - pCentral_dir_array, mz_uint8, - MZ_ZIP_ARRAY_ELEMENT(pCentral_dir_offsets, mz_uint32, - l_index)), - *pE; - mz_uint l_len = MZ_READ_LE16(pL + MZ_ZIP_CDH_FILENAME_LEN_OFS); - mz_uint8 l = 0, r = 0; - pL += MZ_ZIP_CENTRAL_DIR_HEADER_SIZE; - pE = pL + MZ_MIN(l_len, r_len); - while (pL < pE) { - if ((l = MZ_TOLOWER(*pL)) != (r = MZ_TOLOWER(*pR))) - break; - pL++; - pR++; - } - return (pL == pE) ? (int)(l_len - r_len) : (l - r); -} - -static mz_bool mz_zip_locate_file_binary_search(mz_zip_archive *pZip, - const char *pFilename, - mz_uint32 *pIndex) { - mz_zip_internal_state *pState = pZip->m_pState; - const mz_zip_array *pCentral_dir_offsets = &pState->m_central_dir_offsets; - const mz_zip_array *pCentral_dir = &pState->m_central_dir; - mz_uint32 *pIndices = &MZ_ZIP_ARRAY_ELEMENT( - &pState->m_sorted_central_dir_offsets, mz_uint32, 0); - const uint32_t size = pZip->m_total_files; - const mz_uint filename_len = (mz_uint)strlen(pFilename); - - if (pIndex) - *pIndex = 0; - - if (size) { - /* yes I could use uint32_t's, but then we would have to add some special - * case checks in the loop, argh, and */ - /* honestly the major expense here on 32-bit CPU's will still be the - * filename compare */ - mz_int64 l = 0, h = (mz_int64)size - 1; - - while (l <= h) { - mz_int64 m = l + ((h - l) >> 1); - uint32_t file_index = pIndices[(uint32_t)m]; - - int comp = mz_zip_filename_compare(pCentral_dir, pCentral_dir_offsets, - file_index, pFilename, filename_len); - if (!comp) { - if (pIndex) - *pIndex = file_index; - return MZ_TRUE; - } else if (comp < 0) - l = m + 1; - else - h = m - 1; - } - } - - return mz_zip_set_error(pZip, MZ_ZIP_FILE_NOT_FOUND); -} - -int mz_zip_reader_locate_file(mz_zip_archive *pZip, const char *pName, - const char *pComment, mz_uint flags) { - mz_uint32 index; - if (!mz_zip_reader_locate_file_v2(pZip, pName, pComment, flags, &index)) - return -1; - else - return (int)index; -} - -mz_bool mz_zip_reader_locate_file_v2(mz_zip_archive *pZip, const char *pName, - const char *pComment, mz_uint flags, - mz_uint32 *pIndex) { - mz_uint file_index; - size_t name_len, comment_len; - - if (pIndex) - *pIndex = 0; - - if ((!pZip) || (!pZip->m_pState) || (!pName)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - /* See if we can use a binary search */ - if (((pZip->m_pState->m_init_flags & - MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY) == 0) && - (pZip->m_zip_mode == MZ_ZIP_MODE_READING) && - ((flags & (MZ_ZIP_FLAG_IGNORE_PATH | MZ_ZIP_FLAG_CASE_SENSITIVE)) == 0) && - (!pComment) && (pZip->m_pState->m_sorted_central_dir_offsets.m_size)) { - return mz_zip_locate_file_binary_search(pZip, pName, pIndex); - } - - /* Locate the entry by scanning the entire central directory */ - name_len = strlen(pName); - if (name_len > MZ_UINT16_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - comment_len = pComment ? strlen(pComment) : 0; - if (comment_len > MZ_UINT16_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - for (file_index = 0; file_index < pZip->m_total_files; file_index++) { - const mz_uint8 *pHeader = &MZ_ZIP_ARRAY_ELEMENT( - &pZip->m_pState->m_central_dir, mz_uint8, - MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_central_dir_offsets, mz_uint32, - file_index)); - mz_uint filename_len = MZ_READ_LE16(pHeader + MZ_ZIP_CDH_FILENAME_LEN_OFS); - const char *pFilename = - (const char *)pHeader + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE; - if (filename_len < name_len) - continue; - if (comment_len) { - mz_uint file_extra_len = MZ_READ_LE16(pHeader + MZ_ZIP_CDH_EXTRA_LEN_OFS), - file_comment_len = - MZ_READ_LE16(pHeader + MZ_ZIP_CDH_COMMENT_LEN_OFS); - const char *pFile_comment = pFilename + filename_len + file_extra_len; - if ((file_comment_len != comment_len) || - (!mz_zip_string_equal(pComment, pFile_comment, file_comment_len, - flags))) - continue; - } - if ((flags & MZ_ZIP_FLAG_IGNORE_PATH) && (filename_len)) { - int ofs = filename_len - 1; - do { - if ((pFilename[ofs] == '/') || (pFilename[ofs] == '\\') || - (pFilename[ofs] == ':')) - break; - } while (--ofs >= 0); - ofs++; - pFilename += ofs; - filename_len -= ofs; - } - if ((filename_len == name_len) && - (mz_zip_string_equal(pName, pFilename, filename_len, flags))) { - if (pIndex) - *pIndex = file_index; - return MZ_TRUE; - } - } - - return mz_zip_set_error(pZip, MZ_ZIP_FILE_NOT_FOUND); -} - -static mz_bool mz_zip_reader_extract_to_mem_no_alloc1( - mz_zip_archive *pZip, mz_uint file_index, void *pBuf, size_t buf_size, - mz_uint flags, void *pUser_read_buf, size_t user_read_buf_size, - const mz_zip_archive_file_stat *st) { - int status = TINFL_STATUS_DONE; - mz_uint64 needed_size, cur_file_ofs, comp_remaining, - out_buf_ofs = 0, read_buf_size, read_buf_ofs = 0, read_buf_avail; - mz_zip_archive_file_stat file_stat; - void *pRead_buf; - mz_uint32 - local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) / - sizeof(mz_uint32)]; - mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32; - tinfl_decompressor inflator; - - if ((!pZip) || (!pZip->m_pState) || ((buf_size) && (!pBuf)) || - ((user_read_buf_size) && (!pUser_read_buf)) || (!pZip->m_pRead)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (st) { - file_stat = *st; - } else if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat)) - return MZ_FALSE; - - /* A directory or zero length file */ - if ((file_stat.m_is_directory) || (!file_stat.m_comp_size)) - return MZ_TRUE; - - /* Encryption and patch files are not supported. */ - if (file_stat.m_bit_flag & - (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED | - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION | - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG)) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION); - - /* This function only supports decompressing stored and deflate. */ - if ((!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) && (file_stat.m_method != 0) && - (file_stat.m_method != MZ_DEFLATED)) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD); - - /* Ensure supplied output buffer is large enough. */ - needed_size = (flags & MZ_ZIP_FLAG_COMPRESSED_DATA) ? file_stat.m_comp_size - : file_stat.m_uncomp_size; - if (buf_size < needed_size) - return mz_zip_set_error(pZip, MZ_ZIP_BUF_TOO_SMALL); - - /* Read and parse the local directory entry. */ - cur_file_ofs = file_stat.m_local_header_ofs; - if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pLocal_header, - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) != - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - - if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - cur_file_ofs += MZ_ZIP_LOCAL_DIR_HEADER_SIZE + - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS) + - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS); - if ((cur_file_ofs + file_stat.m_comp_size) > pZip->m_archive_size) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - if ((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) || (!file_stat.m_method)) { - /* The file is stored or the caller has requested the compressed data. */ - if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pBuf, - (size_t)needed_size) != needed_size) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - if ((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) == 0) { - if (mz_crc32(MZ_CRC32_INIT, (const mz_uint8 *)pBuf, - (size_t)file_stat.m_uncomp_size) != file_stat.m_crc32) - return mz_zip_set_error(pZip, MZ_ZIP_CRC_CHECK_FAILED); - } -#endif - - return MZ_TRUE; - } - - /* Decompress the file either directly from memory or from a file input - * buffer. */ - tinfl_init(&inflator); - - if (pZip->m_pState->m_pMem) { - /* Read directly from the archive in memory. */ - pRead_buf = (mz_uint8 *)pZip->m_pState->m_pMem + cur_file_ofs; - read_buf_size = read_buf_avail = file_stat.m_comp_size; - comp_remaining = 0; - } else if (pUser_read_buf) { - /* Use a user provided read buffer. */ - if (!user_read_buf_size) - return MZ_FALSE; - pRead_buf = (mz_uint8 *)pUser_read_buf; - read_buf_size = user_read_buf_size; - read_buf_avail = 0; - comp_remaining = file_stat.m_comp_size; - } else { - /* Temporarily allocate a read buffer. */ - read_buf_size = - MZ_MIN(file_stat.m_comp_size, (mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE); - if (((sizeof(size_t) == sizeof(mz_uint32))) && (read_buf_size > 0x7FFFFFFF)) - return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - - if (NULL == (pRead_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1, - (size_t)read_buf_size))) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - read_buf_avail = 0; - comp_remaining = file_stat.m_comp_size; - } - - do { - /* The size_t cast here should be OK because we've verified that the output - * buffer is >= file_stat.m_uncomp_size above */ - size_t in_buf_size, - out_buf_size = (size_t)(file_stat.m_uncomp_size - out_buf_ofs); - if ((!read_buf_avail) && (!pZip->m_pState->m_pMem)) { - read_buf_avail = MZ_MIN(read_buf_size, comp_remaining); - if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pRead_buf, - (size_t)read_buf_avail) != read_buf_avail) { - status = TINFL_STATUS_FAILED; - mz_zip_set_error(pZip, MZ_ZIP_DECOMPRESSION_FAILED); - break; - } - cur_file_ofs += read_buf_avail; - comp_remaining -= read_buf_avail; - read_buf_ofs = 0; - } - in_buf_size = (size_t)read_buf_avail; - status = tinfl_decompress( - &inflator, (mz_uint8 *)pRead_buf + read_buf_ofs, &in_buf_size, - (mz_uint8 *)pBuf, (mz_uint8 *)pBuf + out_buf_ofs, &out_buf_size, - TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF | - (comp_remaining ? TINFL_FLAG_HAS_MORE_INPUT : 0)); - read_buf_avail -= in_buf_size; - read_buf_ofs += in_buf_size; - out_buf_ofs += out_buf_size; - } while (status == TINFL_STATUS_NEEDS_MORE_INPUT); - - if (status == TINFL_STATUS_DONE) { - /* Make sure the entire file was decompressed, and check its CRC. */ - if (out_buf_ofs != file_stat.m_uncomp_size) { - mz_zip_set_error(pZip, MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE); - status = TINFL_STATUS_FAILED; - } -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - else if (mz_crc32(MZ_CRC32_INIT, (const mz_uint8 *)pBuf, - (size_t)file_stat.m_uncomp_size) != file_stat.m_crc32) { - mz_zip_set_error(pZip, MZ_ZIP_CRC_CHECK_FAILED); - status = TINFL_STATUS_FAILED; - } -#endif - } - - if ((!pZip->m_pState->m_pMem) && (!pUser_read_buf)) - pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf); - - return status == TINFL_STATUS_DONE; -} - -mz_bool mz_zip_reader_extract_to_mem_no_alloc(mz_zip_archive *pZip, - mz_uint file_index, void *pBuf, - size_t buf_size, mz_uint flags, - void *pUser_read_buf, - size_t user_read_buf_size) { - return mz_zip_reader_extract_to_mem_no_alloc1(pZip, file_index, pBuf, - buf_size, flags, pUser_read_buf, - user_read_buf_size, NULL); -} - -mz_bool mz_zip_reader_extract_file_to_mem_no_alloc( - mz_zip_archive *pZip, const char *pFilename, void *pBuf, size_t buf_size, - mz_uint flags, void *pUser_read_buf, size_t user_read_buf_size) { - mz_uint32 file_index; - if (!mz_zip_reader_locate_file_v2(pZip, pFilename, NULL, flags, &file_index)) - return MZ_FALSE; - return mz_zip_reader_extract_to_mem_no_alloc(pZip, file_index, pBuf, buf_size, - flags, pUser_read_buf, - user_read_buf_size); -} - -mz_bool mz_zip_reader_extract_to_mem(mz_zip_archive *pZip, mz_uint file_index, - void *pBuf, size_t buf_size, - mz_uint flags) { - return mz_zip_reader_extract_to_mem_no_alloc(pZip, file_index, pBuf, buf_size, - flags, NULL, 0); -} - -mz_bool mz_zip_reader_extract_file_to_mem(mz_zip_archive *pZip, - const char *pFilename, void *pBuf, - size_t buf_size, mz_uint flags) { - return mz_zip_reader_extract_file_to_mem_no_alloc(pZip, pFilename, pBuf, - buf_size, flags, NULL, 0); -} - -void *mz_zip_reader_extract_to_heap(mz_zip_archive *pZip, mz_uint file_index, - size_t *pSize, mz_uint flags) { - mz_zip_archive_file_stat file_stat; - mz_uint64 alloc_size; - void *pBuf; - - if (pSize) - *pSize = 0; - - if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat)) - return NULL; - - alloc_size = (flags & MZ_ZIP_FLAG_COMPRESSED_DATA) ? file_stat.m_comp_size - : file_stat.m_uncomp_size; - if (((sizeof(size_t) == sizeof(mz_uint32))) && (alloc_size > 0x7FFFFFFF)) { - mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - return NULL; - } - - if (NULL == - (pBuf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1, (size_t)alloc_size))) { - mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - return NULL; - } - - if (!mz_zip_reader_extract_to_mem_no_alloc1(pZip, file_index, pBuf, - (size_t)alloc_size, flags, NULL, - 0, &file_stat)) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf); - return NULL; - } - - if (pSize) - *pSize = (size_t)alloc_size; - return pBuf; -} - -void *mz_zip_reader_extract_file_to_heap(mz_zip_archive *pZip, - const char *pFilename, size_t *pSize, - mz_uint flags) { - mz_uint32 file_index; - if (!mz_zip_reader_locate_file_v2(pZip, pFilename, NULL, flags, - &file_index)) { - if (pSize) - *pSize = 0; - return MZ_FALSE; - } - return mz_zip_reader_extract_to_heap(pZip, file_index, pSize, flags); -} - -mz_bool mz_zip_reader_extract_to_callback(mz_zip_archive *pZip, - mz_uint file_index, - mz_file_write_func pCallback, - void *pOpaque, mz_uint flags) { - int status = TINFL_STATUS_DONE; -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - mz_uint file_crc32 = MZ_CRC32_INIT; -#endif - mz_uint64 read_buf_size, read_buf_ofs = 0, read_buf_avail, comp_remaining, - out_buf_ofs = 0, cur_file_ofs; - mz_zip_archive_file_stat file_stat; - void *pRead_buf = NULL; - void *pWrite_buf = NULL; - mz_uint32 - local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) / - sizeof(mz_uint32)]; - mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32; - - if ((!pZip) || (!pZip->m_pState) || (!pCallback) || (!pZip->m_pRead)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat)) - return MZ_FALSE; - - /* A directory or zero length file */ - if (file_stat.m_is_directory || (!file_stat.m_comp_size)) - return MZ_TRUE; - - /* Encryption and patch files are not supported. */ - if (file_stat.m_bit_flag & - (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED | - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION | - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG)) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION); - - /* This function only supports decompressing stored and deflate. */ - if ((!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) && (file_stat.m_method != 0) && - (file_stat.m_method != MZ_DEFLATED)) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD); - - /* Read and do some minimal validation of the local directory entry (this - * doesn't crack the zip64 stuff, which we already have from the central dir) - */ - cur_file_ofs = file_stat.m_local_header_ofs; - if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pLocal_header, - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) != - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - - if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - cur_file_ofs += MZ_ZIP_LOCAL_DIR_HEADER_SIZE + - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS) + - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS); - if ((cur_file_ofs + file_stat.m_comp_size) > pZip->m_archive_size) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - /* Decompress the file either directly from memory or from a file input - * buffer. */ - if (pZip->m_pState->m_pMem) { - pRead_buf = (mz_uint8 *)pZip->m_pState->m_pMem + cur_file_ofs; - read_buf_size = read_buf_avail = file_stat.m_comp_size; - comp_remaining = 0; - } else { - read_buf_size = - MZ_MIN(file_stat.m_comp_size, (mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE); - if (NULL == (pRead_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1, - (size_t)read_buf_size))) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - read_buf_avail = 0; - comp_remaining = file_stat.m_comp_size; - } - - if ((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) || (!file_stat.m_method)) { - /* The file is stored or the caller has requested the compressed data. */ - if (pZip->m_pState->m_pMem) { - if (((sizeof(size_t) == sizeof(mz_uint32))) && - (file_stat.m_comp_size > MZ_UINT32_MAX)) - return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - - if (pCallback(pOpaque, out_buf_ofs, pRead_buf, - (size_t)file_stat.m_comp_size) != file_stat.m_comp_size) { - mz_zip_set_error(pZip, MZ_ZIP_WRITE_CALLBACK_FAILED); - status = TINFL_STATUS_FAILED; - } else if (!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) { -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - file_crc32 = - (mz_uint32)mz_crc32(file_crc32, (const mz_uint8 *)pRead_buf, - (size_t)file_stat.m_comp_size); -#endif - } - - cur_file_ofs += file_stat.m_comp_size; - out_buf_ofs += file_stat.m_comp_size; - comp_remaining = 0; - } else { - while (comp_remaining) { - read_buf_avail = MZ_MIN(read_buf_size, comp_remaining); - if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pRead_buf, - (size_t)read_buf_avail) != read_buf_avail) { - mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - status = TINFL_STATUS_FAILED; - break; - } - -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - if (!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) { - file_crc32 = (mz_uint32)mz_crc32( - file_crc32, (const mz_uint8 *)pRead_buf, (size_t)read_buf_avail); - } -#endif - - if (pCallback(pOpaque, out_buf_ofs, pRead_buf, - (size_t)read_buf_avail) != read_buf_avail) { - mz_zip_set_error(pZip, MZ_ZIP_WRITE_CALLBACK_FAILED); - status = TINFL_STATUS_FAILED; - break; - } - - cur_file_ofs += read_buf_avail; - out_buf_ofs += read_buf_avail; - comp_remaining -= read_buf_avail; - } - } - } else { - tinfl_decompressor inflator; - tinfl_init(&inflator); - - if (NULL == (pWrite_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1, - TINFL_LZ_DICT_SIZE))) { - mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - status = TINFL_STATUS_FAILED; - } else { - do { - mz_uint8 *pWrite_buf_cur = - (mz_uint8 *)pWrite_buf + (out_buf_ofs & (TINFL_LZ_DICT_SIZE - 1)); - size_t in_buf_size, - out_buf_size = - TINFL_LZ_DICT_SIZE - (out_buf_ofs & (TINFL_LZ_DICT_SIZE - 1)); - if ((!read_buf_avail) && (!pZip->m_pState->m_pMem)) { - read_buf_avail = MZ_MIN(read_buf_size, comp_remaining); - if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pRead_buf, - (size_t)read_buf_avail) != read_buf_avail) { - mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - status = TINFL_STATUS_FAILED; - break; - } - cur_file_ofs += read_buf_avail; - comp_remaining -= read_buf_avail; - read_buf_ofs = 0; - } - - in_buf_size = (size_t)read_buf_avail; - status = tinfl_decompress( - &inflator, (const mz_uint8 *)pRead_buf + read_buf_ofs, &in_buf_size, - (mz_uint8 *)pWrite_buf, pWrite_buf_cur, &out_buf_size, - comp_remaining ? TINFL_FLAG_HAS_MORE_INPUT : 0); - read_buf_avail -= in_buf_size; - read_buf_ofs += in_buf_size; - - if (out_buf_size) { - if (pCallback(pOpaque, out_buf_ofs, pWrite_buf_cur, out_buf_size) != - out_buf_size) { - mz_zip_set_error(pZip, MZ_ZIP_WRITE_CALLBACK_FAILED); - status = TINFL_STATUS_FAILED; - break; - } - -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - file_crc32 = - (mz_uint32)mz_crc32(file_crc32, pWrite_buf_cur, out_buf_size); -#endif - if ((out_buf_ofs += out_buf_size) > file_stat.m_uncomp_size) { - mz_zip_set_error(pZip, MZ_ZIP_DECOMPRESSION_FAILED); - status = TINFL_STATUS_FAILED; - break; - } - } - } while ((status == TINFL_STATUS_NEEDS_MORE_INPUT) || - (status == TINFL_STATUS_HAS_MORE_OUTPUT)); - } - } - - if ((status == TINFL_STATUS_DONE) && - (!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA))) { - /* Make sure the entire file was decompressed, and check its CRC. */ - if (out_buf_ofs != file_stat.m_uncomp_size) { - mz_zip_set_error(pZip, MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE); - status = TINFL_STATUS_FAILED; - } -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - else if (file_crc32 != file_stat.m_crc32) { - mz_zip_set_error(pZip, MZ_ZIP_DECOMPRESSION_FAILED); - status = TINFL_STATUS_FAILED; - } -#endif - } - - if (!pZip->m_pState->m_pMem) - pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf); - - if (pWrite_buf) - pZip->m_pFree(pZip->m_pAlloc_opaque, pWrite_buf); - - return status == TINFL_STATUS_DONE; -} - -mz_bool mz_zip_reader_extract_file_to_callback(mz_zip_archive *pZip, - const char *pFilename, - mz_file_write_func pCallback, - void *pOpaque, mz_uint flags) { - mz_uint32 file_index; - if (!mz_zip_reader_locate_file_v2(pZip, pFilename, NULL, flags, &file_index)) - return MZ_FALSE; - - return mz_zip_reader_extract_to_callback(pZip, file_index, pCallback, pOpaque, - flags); -} - -mz_zip_reader_extract_iter_state * -mz_zip_reader_extract_iter_new(mz_zip_archive *pZip, mz_uint file_index, - mz_uint flags) { - mz_zip_reader_extract_iter_state *pState; - mz_uint32 - local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) / - sizeof(mz_uint32)]; - mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32; - - /* Argument sanity check */ - if ((!pZip) || (!pZip->m_pState)) - return NULL; - - /* Allocate an iterator status structure */ - pState = (mz_zip_reader_extract_iter_state *)pZip->m_pAlloc( - pZip->m_pAlloc_opaque, 1, sizeof(mz_zip_reader_extract_iter_state)); - if (!pState) { - mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - return NULL; - } - - /* Fetch file details */ - if (!mz_zip_reader_file_stat(pZip, file_index, &pState->file_stat)) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - return NULL; - } - - /* Encryption and patch files are not supported. */ - if (pState->file_stat.m_bit_flag & - (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED | - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION | - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG)) { - mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION); - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - return NULL; - } - - /* This function only supports decompressing stored and deflate. */ - if ((!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) && - (pState->file_stat.m_method != 0) && - (pState->file_stat.m_method != MZ_DEFLATED)) { - mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD); - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - return NULL; - } - - /* Init state - save args */ - pState->pZip = pZip; - pState->flags = flags; - - /* Init state - reset variables to defaults */ - pState->status = TINFL_STATUS_DONE; -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - pState->file_crc32 = MZ_CRC32_INIT; -#endif - pState->read_buf_ofs = 0; - pState->out_buf_ofs = 0; - pState->pRead_buf = NULL; - pState->pWrite_buf = NULL; - pState->out_blk_remain = 0; - - /* Read and parse the local directory entry. */ - pState->cur_file_ofs = pState->file_stat.m_local_header_ofs; - if (pZip->m_pRead(pZip->m_pIO_opaque, pState->cur_file_ofs, pLocal_header, - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) != - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) { - mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - return NULL; - } - - if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG) { - mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - return NULL; - } - - pState->cur_file_ofs += - MZ_ZIP_LOCAL_DIR_HEADER_SIZE + - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS) + - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS); - if ((pState->cur_file_ofs + pState->file_stat.m_comp_size) > - pZip->m_archive_size) { - mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - return NULL; - } - - /* Decompress the file either directly from memory or from a file input - * buffer. */ - if (pZip->m_pState->m_pMem) { - pState->pRead_buf = - (mz_uint8 *)pZip->m_pState->m_pMem + pState->cur_file_ofs; - pState->read_buf_size = pState->read_buf_avail = - pState->file_stat.m_comp_size; - pState->comp_remaining = pState->file_stat.m_comp_size; - } else { - if (!((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) || - (!pState->file_stat.m_method))) { - /* Decompression required, therefore intermediate read buffer required */ - pState->read_buf_size = MZ_MIN(pState->file_stat.m_comp_size, - (mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE); - if (NULL == - (pState->pRead_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1, - (size_t)pState->read_buf_size))) { - mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - return NULL; - } - } else { - /* Decompression not required - we will be reading directly into user - * buffer, no temp buf required */ - pState->read_buf_size = 0; - } - pState->read_buf_avail = 0; - pState->comp_remaining = pState->file_stat.m_comp_size; - } - - if (!((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) || - (!pState->file_stat.m_method))) { - /* Decompression required, init decompressor */ - tinfl_init(&pState->inflator); - - /* Allocate write buffer */ - if (NULL == (pState->pWrite_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1, - TINFL_LZ_DICT_SIZE))) { - mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - if (pState->pRead_buf) - pZip->m_pFree(pZip->m_pAlloc_opaque, pState->pRead_buf); - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - return NULL; - } - } - - return pState; -} - -mz_zip_reader_extract_iter_state * -mz_zip_reader_extract_file_iter_new(mz_zip_archive *pZip, const char *pFilename, - mz_uint flags) { - mz_uint32 file_index; - - /* Locate file index by name */ - if (!mz_zip_reader_locate_file_v2(pZip, pFilename, NULL, flags, &file_index)) - return NULL; - - /* Construct iterator */ - return mz_zip_reader_extract_iter_new(pZip, file_index, flags); -} - -size_t mz_zip_reader_extract_iter_read(mz_zip_reader_extract_iter_state *pState, - void *pvBuf, size_t buf_size) { - size_t copied_to_caller = 0; - - /* Argument sanity check */ - if ((!pState) || (!pState->pZip) || (!pState->pZip->m_pState) || (!pvBuf)) - return 0; - - if ((pState->flags & MZ_ZIP_FLAG_COMPRESSED_DATA) || - (!pState->file_stat.m_method)) { - /* The file is stored or the caller has requested the compressed data, calc - * amount to return. */ - copied_to_caller = (size_t)MZ_MIN(buf_size, pState->comp_remaining); - - /* Zip is in memory....or requires reading from a file? */ - if (pState->pZip->m_pState->m_pMem) { - /* Copy data to caller's buffer */ - memcpy(pvBuf, pState->pRead_buf, copied_to_caller); - pState->pRead_buf = ((mz_uint8 *)pState->pRead_buf) + copied_to_caller; - } else { - /* Read directly into caller's buffer */ - if (pState->pZip->m_pRead(pState->pZip->m_pIO_opaque, - pState->cur_file_ofs, pvBuf, - copied_to_caller) != copied_to_caller) { - /* Failed to read all that was asked for, flag failure and alert user */ - mz_zip_set_error(pState->pZip, MZ_ZIP_FILE_READ_FAILED); - pState->status = TINFL_STATUS_FAILED; - copied_to_caller = 0; - } - } - -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - /* Compute CRC if not returning compressed data only */ - if (!(pState->flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) - pState->file_crc32 = (mz_uint32)mz_crc32( - pState->file_crc32, (const mz_uint8 *)pvBuf, copied_to_caller); -#endif - - /* Advance offsets, dec counters */ - pState->cur_file_ofs += copied_to_caller; - pState->out_buf_ofs += copied_to_caller; - pState->comp_remaining -= copied_to_caller; - } else { - do { - /* Calc ptr to write buffer - given current output pos and block size */ - mz_uint8 *pWrite_buf_cur = - (mz_uint8 *)pState->pWrite_buf + - (pState->out_buf_ofs & (TINFL_LZ_DICT_SIZE - 1)); - - /* Calc max output size - given current output pos and block size */ - size_t in_buf_size, - out_buf_size = TINFL_LZ_DICT_SIZE - - (pState->out_buf_ofs & (TINFL_LZ_DICT_SIZE - 1)); - - if (!pState->out_blk_remain) { - /* Read more data from file if none available (and reading from file) */ - if ((!pState->read_buf_avail) && (!pState->pZip->m_pState->m_pMem)) { - /* Calc read size */ - pState->read_buf_avail = - MZ_MIN(pState->read_buf_size, pState->comp_remaining); - if (pState->pZip->m_pRead(pState->pZip->m_pIO_opaque, - pState->cur_file_ofs, pState->pRead_buf, - (size_t)pState->read_buf_avail) != - pState->read_buf_avail) { - mz_zip_set_error(pState->pZip, MZ_ZIP_FILE_READ_FAILED); - pState->status = TINFL_STATUS_FAILED; - break; - } - - /* Advance offsets, dec counters */ - pState->cur_file_ofs += pState->read_buf_avail; - pState->comp_remaining -= pState->read_buf_avail; - pState->read_buf_ofs = 0; - } - - /* Perform decompression */ - in_buf_size = (size_t)pState->read_buf_avail; - pState->status = tinfl_decompress( - &pState->inflator, - (const mz_uint8 *)pState->pRead_buf + pState->read_buf_ofs, - &in_buf_size, (mz_uint8 *)pState->pWrite_buf, pWrite_buf_cur, - &out_buf_size, - pState->comp_remaining ? TINFL_FLAG_HAS_MORE_INPUT : 0); - pState->read_buf_avail -= in_buf_size; - pState->read_buf_ofs += in_buf_size; - - /* Update current output block size remaining */ - pState->out_blk_remain = out_buf_size; - } - - if (pState->out_blk_remain) { - /* Calc amount to return. */ - size_t to_copy = - MZ_MIN((buf_size - copied_to_caller), pState->out_blk_remain); - - /* Copy data to caller's buffer */ - memcpy((uint8_t *)pvBuf + copied_to_caller, pWrite_buf_cur, to_copy); - -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - /* Perform CRC */ - pState->file_crc32 = - (mz_uint32)mz_crc32(pState->file_crc32, pWrite_buf_cur, to_copy); -#endif - - /* Decrement data consumed from block */ - pState->out_blk_remain -= to_copy; - - /* Inc output offset, while performing sanity check */ - if ((pState->out_buf_ofs += to_copy) > - pState->file_stat.m_uncomp_size) { - mz_zip_set_error(pState->pZip, MZ_ZIP_DECOMPRESSION_FAILED); - pState->status = TINFL_STATUS_FAILED; - break; - } - - /* Increment counter of data copied to caller */ - copied_to_caller += to_copy; - } - } while ((copied_to_caller < buf_size) && - ((pState->status == TINFL_STATUS_NEEDS_MORE_INPUT) || - (pState->status == TINFL_STATUS_HAS_MORE_OUTPUT))); - } - - /* Return how many bytes were copied into user buffer */ - return copied_to_caller; -} - -mz_bool -mz_zip_reader_extract_iter_free(mz_zip_reader_extract_iter_state *pState) { - int status; - - /* Argument sanity check */ - if ((!pState) || (!pState->pZip) || (!pState->pZip->m_pState)) - return MZ_FALSE; - - /* Was decompression completed and requested? */ - if ((pState->status == TINFL_STATUS_DONE) && - (!(pState->flags & MZ_ZIP_FLAG_COMPRESSED_DATA))) { - /* Make sure the entire file was decompressed, and check its CRC. */ - if (pState->out_buf_ofs != pState->file_stat.m_uncomp_size) { - mz_zip_set_error(pState->pZip, MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE); - pState->status = TINFL_STATUS_FAILED; - } -#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS - else if (pState->file_crc32 != pState->file_stat.m_crc32) { - mz_zip_set_error(pState->pZip, MZ_ZIP_DECOMPRESSION_FAILED); - pState->status = TINFL_STATUS_FAILED; - } -#endif - } - - /* Free buffers */ - if (!pState->pZip->m_pState->m_pMem) - pState->pZip->m_pFree(pState->pZip->m_pAlloc_opaque, pState->pRead_buf); - if (pState->pWrite_buf) - pState->pZip->m_pFree(pState->pZip->m_pAlloc_opaque, pState->pWrite_buf); - - /* Save status */ - status = pState->status; - - /* Free context */ - pState->pZip->m_pFree(pState->pZip->m_pAlloc_opaque, pState); - - return status == TINFL_STATUS_DONE; -} - -#ifndef MINIZ_NO_STDIO -static size_t mz_zip_file_write_callback(void *pOpaque, mz_uint64 ofs, - const void *pBuf, size_t n) { - (void)ofs; - - return MZ_FWRITE(pBuf, 1, n, (MZ_FILE *)pOpaque); -} - -mz_bool mz_zip_reader_extract_to_file(mz_zip_archive *pZip, mz_uint file_index, - const char *pDst_filename, - mz_uint flags) { - mz_bool status; - mz_zip_archive_file_stat file_stat; - MZ_FILE *pFile; - - if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat)) - return MZ_FALSE; - - if (file_stat.m_is_directory || (!file_stat.m_is_supported)) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_FEATURE); - - pFile = MZ_FOPEN(pDst_filename, "wb"); - if (!pFile) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED); - - status = mz_zip_reader_extract_to_callback( - pZip, file_index, mz_zip_file_write_callback, pFile, flags); - - if (MZ_FCLOSE(pFile) == EOF) { - if (status) - mz_zip_set_error(pZip, MZ_ZIP_FILE_CLOSE_FAILED); - - status = MZ_FALSE; - } - -#if !defined(MINIZ_NO_TIME) && !defined(MINIZ_NO_STDIO) - if (status) - mz_zip_set_file_times(pDst_filename, file_stat.m_time, file_stat.m_time); -#endif - - return status; -} - -mz_bool mz_zip_reader_extract_file_to_file(mz_zip_archive *pZip, - const char *pArchive_filename, - const char *pDst_filename, - mz_uint flags) { - mz_uint32 file_index; - if (!mz_zip_reader_locate_file_v2(pZip, pArchive_filename, NULL, flags, - &file_index)) - return MZ_FALSE; - - return mz_zip_reader_extract_to_file(pZip, file_index, pDst_filename, flags); -} - -mz_bool mz_zip_reader_extract_to_cfile(mz_zip_archive *pZip, mz_uint file_index, - MZ_FILE *pFile, mz_uint flags) { - mz_zip_archive_file_stat file_stat; - - if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat)) - return MZ_FALSE; - - if (file_stat.m_is_directory || (!file_stat.m_is_supported)) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_FEATURE); - - return mz_zip_reader_extract_to_callback( - pZip, file_index, mz_zip_file_write_callback, pFile, flags); -} - -mz_bool mz_zip_reader_extract_file_to_cfile(mz_zip_archive *pZip, - const char *pArchive_filename, - MZ_FILE *pFile, mz_uint flags) { - mz_uint32 file_index; - if (!mz_zip_reader_locate_file_v2(pZip, pArchive_filename, NULL, flags, - &file_index)) - return MZ_FALSE; - - return mz_zip_reader_extract_to_cfile(pZip, file_index, pFile, flags); -} -#endif /* #ifndef MINIZ_NO_STDIO */ - -static size_t mz_zip_compute_crc32_callback(void *pOpaque, mz_uint64 file_ofs, - const void *pBuf, size_t n) { - mz_uint32 *p = (mz_uint32 *)pOpaque; - (void)file_ofs; - *p = (mz_uint32)mz_crc32(*p, (const mz_uint8 *)pBuf, n); - return n; -} - -mz_bool mz_zip_validate_file(mz_zip_archive *pZip, mz_uint file_index, - mz_uint flags) { - mz_zip_archive_file_stat file_stat; - mz_zip_internal_state *pState; - const mz_uint8 *pCentral_dir_header; - mz_bool found_zip64_ext_data_in_cdir = MZ_FALSE; - mz_bool found_zip64_ext_data_in_ldir = MZ_FALSE; - mz_uint32 - local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) / - sizeof(mz_uint32)]; - mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32; - mz_uint64 local_header_ofs = 0; - mz_uint32 local_header_filename_len, local_header_extra_len, - local_header_crc32; - mz_uint64 local_header_comp_size, local_header_uncomp_size; - mz_uint32 uncomp_crc32 = MZ_CRC32_INIT; - mz_bool has_data_descriptor; - mz_uint32 local_header_bit_flags; - - mz_zip_array file_data_array; - mz_zip_array_init(&file_data_array, 1); - - if ((!pZip) || (!pZip->m_pState) || (!pZip->m_pAlloc) || (!pZip->m_pFree) || - (!pZip->m_pRead)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (file_index > pZip->m_total_files) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pState = pZip->m_pState; - - pCentral_dir_header = mz_zip_get_cdh(pZip, file_index); - - if (!mz_zip_file_stat_internal(pZip, file_index, pCentral_dir_header, - &file_stat, &found_zip64_ext_data_in_cdir)) - return MZ_FALSE; - - /* A directory or zero length file */ - if (file_stat.m_is_directory || (!file_stat.m_uncomp_size)) - return MZ_TRUE; - - /* Encryption and patch files are not supported. */ - if (file_stat.m_is_encrypted) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION); - - /* This function only supports stored and deflate. */ - if ((file_stat.m_method != 0) && (file_stat.m_method != MZ_DEFLATED)) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD); - - if (!file_stat.m_is_supported) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_FEATURE); - - /* Read and parse the local directory entry. */ - local_header_ofs = file_stat.m_local_header_ofs; - if (pZip->m_pRead(pZip->m_pIO_opaque, local_header_ofs, pLocal_header, - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) != - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - - if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - local_header_filename_len = - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS); - local_header_extra_len = - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS); - local_header_comp_size = - MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_COMPRESSED_SIZE_OFS); - local_header_uncomp_size = - MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_DECOMPRESSED_SIZE_OFS); - local_header_crc32 = MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_CRC32_OFS); - local_header_bit_flags = - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_BIT_FLAG_OFS); - has_data_descriptor = (local_header_bit_flags & 8) != 0; - - if (local_header_filename_len != strlen(file_stat.m_filename)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - if ((local_header_ofs + MZ_ZIP_LOCAL_DIR_HEADER_SIZE + - local_header_filename_len + local_header_extra_len + - file_stat.m_comp_size) > pZip->m_archive_size) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - if (!mz_zip_array_resize( - pZip, &file_data_array, - MZ_MAX(local_header_filename_len, local_header_extra_len), - MZ_FALSE)) { - mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - goto handle_failure; - } - - if (local_header_filename_len) { - if (pZip->m_pRead(pZip->m_pIO_opaque, - local_header_ofs + MZ_ZIP_LOCAL_DIR_HEADER_SIZE, - file_data_array.m_p, - local_header_filename_len) != local_header_filename_len) { - mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - goto handle_failure; - } - - /* I've seen 1 archive that had the same pathname, but used backslashes in - * the local dir and forward slashes in the central dir. Do we care about - * this? For now, this case will fail validation. */ - if (memcmp(file_stat.m_filename, file_data_array.m_p, - local_header_filename_len) != 0) { - mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED); - goto handle_failure; - } - } - - if ((local_header_extra_len) && - ((local_header_comp_size == MZ_UINT32_MAX) || - (local_header_uncomp_size == MZ_UINT32_MAX))) { - mz_uint32 extra_size_remaining = local_header_extra_len; - const mz_uint8 *pExtra_data = (const mz_uint8 *)file_data_array.m_p; - - if (pZip->m_pRead(pZip->m_pIO_opaque, - local_header_ofs + MZ_ZIP_LOCAL_DIR_HEADER_SIZE + - local_header_filename_len, - file_data_array.m_p, - local_header_extra_len) != local_header_extra_len) { - mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - goto handle_failure; - } - - do { - mz_uint32 field_id, field_data_size, field_total_size; - - if (extra_size_remaining < (sizeof(mz_uint16) * 2)) { - mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - goto handle_failure; - } - - field_id = MZ_READ_LE16(pExtra_data); - field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16)); - field_total_size = field_data_size + sizeof(mz_uint16) * 2; - - if (field_total_size > extra_size_remaining) { - mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - goto handle_failure; - } - - if (field_id == MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) { - const mz_uint8 *pSrc_field_data = pExtra_data + sizeof(mz_uint32); - - if (field_data_size < sizeof(mz_uint64) * 2) { - mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - goto handle_failure; - } - - local_header_uncomp_size = MZ_READ_LE64(pSrc_field_data); - local_header_comp_size = - MZ_READ_LE64(pSrc_field_data + sizeof(mz_uint64)); - - found_zip64_ext_data_in_ldir = MZ_TRUE; - break; - } - - pExtra_data += field_total_size; - extra_size_remaining -= field_total_size; - } while (extra_size_remaining); - } - - /* TODO: parse local header extra data when local_header_comp_size is - * 0xFFFFFFFF! (big_descriptor.zip) */ - /* I've seen zips in the wild with the data descriptor bit set, but proper - * local header values and bogus data descriptors */ - if ((has_data_descriptor) && (!local_header_comp_size) && - (!local_header_crc32)) { - mz_uint8 descriptor_buf[32]; - mz_bool has_id; - const mz_uint8 *pSrc; - mz_uint32 file_crc32; - mz_uint64 comp_size = 0, uncomp_size = 0; - - mz_uint32 num_descriptor_uint32s = - ((pState->m_zip64) || (found_zip64_ext_data_in_ldir)) ? 6 : 4; - - if (pZip->m_pRead(pZip->m_pIO_opaque, - local_header_ofs + MZ_ZIP_LOCAL_DIR_HEADER_SIZE + - local_header_filename_len + local_header_extra_len + - file_stat.m_comp_size, - descriptor_buf, - sizeof(mz_uint32) * num_descriptor_uint32s) != - (sizeof(mz_uint32) * num_descriptor_uint32s)) { - mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - goto handle_failure; - } - - has_id = (MZ_READ_LE32(descriptor_buf) == MZ_ZIP_DATA_DESCRIPTOR_ID); - pSrc = has_id ? (descriptor_buf + sizeof(mz_uint32)) : descriptor_buf; - - file_crc32 = MZ_READ_LE32(pSrc); - - if ((pState->m_zip64) || (found_zip64_ext_data_in_ldir)) { - comp_size = MZ_READ_LE64(pSrc + sizeof(mz_uint32)); - uncomp_size = MZ_READ_LE64(pSrc + sizeof(mz_uint32) + sizeof(mz_uint64)); - } else { - comp_size = MZ_READ_LE32(pSrc + sizeof(mz_uint32)); - uncomp_size = MZ_READ_LE32(pSrc + sizeof(mz_uint32) + sizeof(mz_uint32)); - } - - if ((file_crc32 != file_stat.m_crc32) || - (comp_size != file_stat.m_comp_size) || - (uncomp_size != file_stat.m_uncomp_size)) { - mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED); - goto handle_failure; - } - } else { - if ((local_header_crc32 != file_stat.m_crc32) || - (local_header_comp_size != file_stat.m_comp_size) || - (local_header_uncomp_size != file_stat.m_uncomp_size)) { - mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED); - goto handle_failure; - } - } - - mz_zip_array_clear(pZip, &file_data_array); - - if ((flags & MZ_ZIP_FLAG_VALIDATE_HEADERS_ONLY) == 0) { - if (!mz_zip_reader_extract_to_callback( - pZip, file_index, mz_zip_compute_crc32_callback, &uncomp_crc32, 0)) - return MZ_FALSE; - - /* 1 more check to be sure, although the extract checks too. */ - if (uncomp_crc32 != file_stat.m_crc32) { - mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED); - return MZ_FALSE; - } - } - - return MZ_TRUE; - -handle_failure: - mz_zip_array_clear(pZip, &file_data_array); - return MZ_FALSE; -} - -mz_bool mz_zip_validate_archive(mz_zip_archive *pZip, mz_uint flags) { - mz_zip_internal_state *pState; - uint32_t i; - - if ((!pZip) || (!pZip->m_pState) || (!pZip->m_pAlloc) || (!pZip->m_pFree) || - (!pZip->m_pRead)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pState = pZip->m_pState; - - /* Basic sanity checks */ - if (!pState->m_zip64) { - if (pZip->m_total_files > MZ_UINT16_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - - if (pZip->m_archive_size > MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - } else { - if (pZip->m_total_files >= MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - - if (pState->m_central_dir.m_size >= MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - } - - for (i = 0; i < pZip->m_total_files; i++) { - if (MZ_ZIP_FLAG_VALIDATE_LOCATE_FILE_FLAG & flags) { - mz_uint32 found_index; - mz_zip_archive_file_stat stat; - - if (!mz_zip_reader_file_stat(pZip, i, &stat)) - return MZ_FALSE; - - if (!mz_zip_reader_locate_file_v2(pZip, stat.m_filename, NULL, 0, - &found_index)) - return MZ_FALSE; - - /* This check can fail if there are duplicate filenames in the archive - * (which we don't check for when writing - that's up to the user) */ - if (found_index != i) - return mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED); - } - - if (!mz_zip_validate_file(pZip, i, flags)) - return MZ_FALSE; - } - - return MZ_TRUE; -} - -mz_bool mz_zip_validate_mem_archive(const void *pMem, size_t size, - mz_uint flags, mz_zip_error *pErr) { - mz_bool success = MZ_TRUE; - mz_zip_archive zip; - mz_zip_error actual_err = MZ_ZIP_NO_ERROR; - - if ((!pMem) || (!size)) { - if (pErr) - *pErr = MZ_ZIP_INVALID_PARAMETER; - return MZ_FALSE; - } - - mz_zip_zero_struct(&zip); - - if (!mz_zip_reader_init_mem(&zip, pMem, size, flags)) { - if (pErr) - *pErr = zip.m_last_error; - return MZ_FALSE; - } - - if (!mz_zip_validate_archive(&zip, flags)) { - actual_err = zip.m_last_error; - success = MZ_FALSE; - } - - if (!mz_zip_reader_end_internal(&zip, success)) { - if (!actual_err) - actual_err = zip.m_last_error; - success = MZ_FALSE; - } - - if (pErr) - *pErr = actual_err; - - return success; -} - -#ifndef MINIZ_NO_STDIO -mz_bool mz_zip_validate_file_archive(const char *pFilename, mz_uint flags, - mz_zip_error *pErr) { - mz_bool success = MZ_TRUE; - mz_zip_archive zip; - mz_zip_error actual_err = MZ_ZIP_NO_ERROR; - - if (!pFilename) { - if (pErr) - *pErr = MZ_ZIP_INVALID_PARAMETER; - return MZ_FALSE; - } - - mz_zip_zero_struct(&zip); - - if (!mz_zip_reader_init_file_v2(&zip, pFilename, flags, 0, 0)) { - if (pErr) - *pErr = zip.m_last_error; - return MZ_FALSE; - } - - if (!mz_zip_validate_archive(&zip, flags)) { - actual_err = zip.m_last_error; - success = MZ_FALSE; - } - - if (!mz_zip_reader_end_internal(&zip, success)) { - if (!actual_err) - actual_err = zip.m_last_error; - success = MZ_FALSE; - } - - if (pErr) - *pErr = actual_err; - - return success; -} -#endif /* #ifndef MINIZ_NO_STDIO */ - -/* ------------------- .ZIP archive writing */ - -#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS - -static MZ_FORCEINLINE void mz_write_le16(mz_uint8 *p, mz_uint16 v) { - p[0] = (mz_uint8)v; - p[1] = (mz_uint8)(v >> 8); -} -static MZ_FORCEINLINE void mz_write_le32(mz_uint8 *p, mz_uint32 v) { - p[0] = (mz_uint8)v; - p[1] = (mz_uint8)(v >> 8); - p[2] = (mz_uint8)(v >> 16); - p[3] = (mz_uint8)(v >> 24); -} -static MZ_FORCEINLINE void mz_write_le64(mz_uint8 *p, mz_uint64 v) { - mz_write_le32(p, (mz_uint32)v); - mz_write_le32(p + sizeof(mz_uint32), (mz_uint32)(v >> 32)); -} - -#define MZ_WRITE_LE16(p, v) mz_write_le16((mz_uint8 *)(p), (mz_uint16)(v)) -#define MZ_WRITE_LE32(p, v) mz_write_le32((mz_uint8 *)(p), (mz_uint32)(v)) -#define MZ_WRITE_LE64(p, v) mz_write_le64((mz_uint8 *)(p), (mz_uint64)(v)) - -static size_t mz_zip_heap_write_func(void *pOpaque, mz_uint64 file_ofs, - const void *pBuf, size_t n) { - mz_zip_archive *pZip = (mz_zip_archive *)pOpaque; - mz_zip_internal_state *pState = pZip->m_pState; - mz_uint64 new_size = MZ_MAX(file_ofs + n, pState->m_mem_size); - - if (!n) - return 0; - - /* An allocation this big is likely to just fail on 32-bit systems, so don't - * even go there. */ - if ((sizeof(size_t) == sizeof(mz_uint32)) && (new_size > 0x7FFFFFFF)) { - mz_zip_set_error(pZip, MZ_ZIP_FILE_TOO_LARGE); - return 0; - } - - if (new_size > pState->m_mem_capacity) { - void *pNew_block; - size_t new_capacity = MZ_MAX(64, pState->m_mem_capacity); - - while (new_capacity < new_size) - new_capacity *= 2; - - if (NULL == (pNew_block = pZip->m_pRealloc( - pZip->m_pAlloc_opaque, pState->m_pMem, 1, new_capacity))) { - mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - return 0; - } - - pState->m_pMem = pNew_block; - pState->m_mem_capacity = new_capacity; - } - memcpy((mz_uint8 *)pState->m_pMem + file_ofs, pBuf, n); - pState->m_mem_size = (size_t)new_size; - return n; -} - -static mz_bool mz_zip_writer_end_internal(mz_zip_archive *pZip, - mz_bool set_last_error) { - mz_zip_internal_state *pState; - mz_bool status = MZ_TRUE; - - if ((!pZip) || (!pZip->m_pState) || (!pZip->m_pAlloc) || (!pZip->m_pFree) || - ((pZip->m_zip_mode != MZ_ZIP_MODE_WRITING) && - (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED))) { - if (set_last_error) - mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - return MZ_FALSE; - } - - pState = pZip->m_pState; - pZip->m_pState = NULL; - mz_zip_array_clear(pZip, &pState->m_central_dir); - mz_zip_array_clear(pZip, &pState->m_central_dir_offsets); - mz_zip_array_clear(pZip, &pState->m_sorted_central_dir_offsets); - -#ifndef MINIZ_NO_STDIO - if (pState->m_pFile) { - if (pZip->m_zip_type == MZ_ZIP_TYPE_FILE) { - if (MZ_FCLOSE(pState->m_pFile) == EOF) { - if (set_last_error) - mz_zip_set_error(pZip, MZ_ZIP_FILE_CLOSE_FAILED); - status = MZ_FALSE; - } - } - - pState->m_pFile = NULL; - } -#endif /* #ifndef MINIZ_NO_STDIO */ - - if ((pZip->m_pWrite == mz_zip_heap_write_func) && (pState->m_pMem)) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pState->m_pMem); - pState->m_pMem = NULL; - } - - pZip->m_pFree(pZip->m_pAlloc_opaque, pState); - pZip->m_zip_mode = MZ_ZIP_MODE_INVALID; - return status; -} - -mz_bool mz_zip_writer_init_v2(mz_zip_archive *pZip, mz_uint64 existing_size, - mz_uint flags) { - mz_bool zip64 = (flags & MZ_ZIP_FLAG_WRITE_ZIP64) != 0; - - if ((!pZip) || (pZip->m_pState) || (!pZip->m_pWrite) || - (pZip->m_zip_mode != MZ_ZIP_MODE_INVALID)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING) { - if (!pZip->m_pRead) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - } - - if (pZip->m_file_offset_alignment) { - /* Ensure user specified file offset alignment is a power of 2. */ - if (pZip->m_file_offset_alignment & (pZip->m_file_offset_alignment - 1)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - } - - if (!pZip->m_pAlloc) - pZip->m_pAlloc = miniz_def_alloc_func; - if (!pZip->m_pFree) - pZip->m_pFree = miniz_def_free_func; - if (!pZip->m_pRealloc) - pZip->m_pRealloc = miniz_def_realloc_func; - - pZip->m_archive_size = existing_size; - pZip->m_central_directory_file_ofs = 0; - pZip->m_total_files = 0; - - if (NULL == (pZip->m_pState = (mz_zip_internal_state *)pZip->m_pAlloc( - pZip->m_pAlloc_opaque, 1, sizeof(mz_zip_internal_state)))) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - memset(pZip->m_pState, 0, sizeof(mz_zip_internal_state)); - - MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_central_dir, - sizeof(mz_uint8)); - MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_central_dir_offsets, - sizeof(mz_uint32)); - MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_sorted_central_dir_offsets, - sizeof(mz_uint32)); - - pZip->m_pState->m_zip64 = zip64; - pZip->m_pState->m_zip64_has_extended_info_fields = zip64; - - pZip->m_zip_type = MZ_ZIP_TYPE_USER; - pZip->m_zip_mode = MZ_ZIP_MODE_WRITING; - - return MZ_TRUE; -} - -mz_bool mz_zip_writer_init(mz_zip_archive *pZip, mz_uint64 existing_size) { - return mz_zip_writer_init_v2(pZip, existing_size, 0); -} - -mz_bool mz_zip_writer_init_heap_v2(mz_zip_archive *pZip, - size_t size_to_reserve_at_beginning, - size_t initial_allocation_size, - mz_uint flags) { - pZip->m_pWrite = mz_zip_heap_write_func; - pZip->m_pNeeds_keepalive = NULL; - - if (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING) - pZip->m_pRead = mz_zip_mem_read_func; - - pZip->m_pIO_opaque = pZip; - - if (!mz_zip_writer_init_v2(pZip, size_to_reserve_at_beginning, flags)) - return MZ_FALSE; - - pZip->m_zip_type = MZ_ZIP_TYPE_HEAP; - - if (0 != (initial_allocation_size = MZ_MAX(initial_allocation_size, - size_to_reserve_at_beginning))) { - if (NULL == (pZip->m_pState->m_pMem = pZip->m_pAlloc( - pZip->m_pAlloc_opaque, 1, initial_allocation_size))) { - mz_zip_writer_end_internal(pZip, MZ_FALSE); - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - pZip->m_pState->m_mem_capacity = initial_allocation_size; - } - - return MZ_TRUE; -} - -mz_bool mz_zip_writer_init_heap(mz_zip_archive *pZip, - size_t size_to_reserve_at_beginning, - size_t initial_allocation_size) { - return mz_zip_writer_init_heap_v2(pZip, size_to_reserve_at_beginning, - initial_allocation_size, 0); -} - -#ifndef MINIZ_NO_STDIO -static size_t mz_zip_file_write_func(void *pOpaque, mz_uint64 file_ofs, - const void *pBuf, size_t n) { - mz_zip_archive *pZip = (mz_zip_archive *)pOpaque; - mz_int64 cur_ofs = MZ_FTELL64(pZip->m_pState->m_pFile); - - file_ofs += pZip->m_pState->m_file_archive_start_ofs; - - if (((mz_int64)file_ofs < 0) || - (((cur_ofs != (mz_int64)file_ofs)) && - (MZ_FSEEK64(pZip->m_pState->m_pFile, (mz_int64)file_ofs, SEEK_SET)))) { - mz_zip_set_error(pZip, MZ_ZIP_FILE_SEEK_FAILED); - return 0; - } - - return MZ_FWRITE(pBuf, 1, n, pZip->m_pState->m_pFile); -} - -mz_bool mz_zip_writer_init_file(mz_zip_archive *pZip, const char *pFilename, - mz_uint64 size_to_reserve_at_beginning) { - return mz_zip_writer_init_file_v2(pZip, pFilename, - size_to_reserve_at_beginning, 0); -} - -mz_bool mz_zip_writer_init_file_v2(mz_zip_archive *pZip, const char *pFilename, - mz_uint64 size_to_reserve_at_beginning, - mz_uint flags) { - MZ_FILE *pFile; - - pZip->m_pWrite = mz_zip_file_write_func; - pZip->m_pNeeds_keepalive = NULL; - - if (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING) - pZip->m_pRead = mz_zip_file_read_func; - - pZip->m_pIO_opaque = pZip; - - if (!mz_zip_writer_init_v2(pZip, size_to_reserve_at_beginning, flags)) - return MZ_FALSE; - - if (NULL == (pFile = MZ_FOPEN( - pFilename, - (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING) ? "w+b" : "wb"))) { - mz_zip_writer_end(pZip); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED); - } - - pZip->m_pState->m_pFile = pFile; - pZip->m_zip_type = MZ_ZIP_TYPE_FILE; - - if (size_to_reserve_at_beginning) { - mz_uint64 cur_ofs = 0; - char buf[4096]; - - MZ_CLEAR_OBJ(buf); - - do { - size_t n = (size_t)MZ_MIN(sizeof(buf), size_to_reserve_at_beginning); - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_ofs, buf, n) != n) { - mz_zip_writer_end(pZip); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - cur_ofs += n; - size_to_reserve_at_beginning -= n; - } while (size_to_reserve_at_beginning); - } - - return MZ_TRUE; -} - -mz_bool mz_zip_writer_init_cfile(mz_zip_archive *pZip, MZ_FILE *pFile, - mz_uint flags) { - pZip->m_pWrite = mz_zip_file_write_func; - pZip->m_pNeeds_keepalive = NULL; - - if (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING) - pZip->m_pRead = mz_zip_file_read_func; - - pZip->m_pIO_opaque = pZip; - - if (!mz_zip_writer_init_v2(pZip, 0, flags)) - return MZ_FALSE; - - pZip->m_pState->m_pFile = pFile; - pZip->m_pState->m_file_archive_start_ofs = - MZ_FTELL64(pZip->m_pState->m_pFile); - pZip->m_zip_type = MZ_ZIP_TYPE_CFILE; - - return MZ_TRUE; -} -#endif /* #ifndef MINIZ_NO_STDIO */ - -mz_bool mz_zip_writer_init_from_reader_v2(mz_zip_archive *pZip, - const char *pFilename, - mz_uint flags) { - mz_zip_internal_state *pState; - - if ((!pZip) || (!pZip->m_pState) || (pZip->m_zip_mode != MZ_ZIP_MODE_READING)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (flags & MZ_ZIP_FLAG_WRITE_ZIP64) { - /* We don't support converting a non-zip64 file to zip64 - this seems like - * more trouble than it's worth. (What about the existing 32-bit data - * descriptors that could follow the compressed data?) */ - if (!pZip->m_pState->m_zip64) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - } - - /* No sense in trying to write to an archive that's already at the support max - * size */ - if (pZip->m_pState->m_zip64) { - if (pZip->m_total_files == MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - } else { - if (pZip->m_total_files == MZ_UINT16_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - - if ((pZip->m_archive_size + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) > MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_TOO_LARGE); - } - - pState = pZip->m_pState; - - if (pState->m_pFile) { -#ifdef MINIZ_NO_STDIO - (void)pFilename; - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); -#else - if (pZip->m_pIO_opaque != pZip) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (pZip->m_zip_type == MZ_ZIP_TYPE_FILE) { - if (!pFilename) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - /* Archive is being read from stdio and was originally opened only for - * reading. Try to reopen as writable. */ - if (NULL == - (pState->m_pFile = MZ_FREOPEN(pFilename, "r+b", pState->m_pFile))) { - /* The mz_zip_archive is now in a bogus state because pState->m_pFile is - * NULL, so just close it. */ - mz_zip_reader_end_internal(pZip, MZ_FALSE); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED); - } - } - - pZip->m_pWrite = mz_zip_file_write_func; - pZip->m_pNeeds_keepalive = NULL; -#endif /* #ifdef MINIZ_NO_STDIO */ - } else if (pState->m_pMem) { - /* Archive lives in a memory block. Assume it's from the heap that we can - * resize using the realloc callback. */ - if (pZip->m_pIO_opaque != pZip) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pState->m_mem_capacity = pState->m_mem_size; - pZip->m_pWrite = mz_zip_heap_write_func; - pZip->m_pNeeds_keepalive = NULL; - } - /* Archive is being read via a user provided read function - make sure the - user has specified a write function too. */ - else if (!pZip->m_pWrite) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - /* Start writing new files at the archive's current central directory - * location. */ - /* TODO: We could add a flag that lets the user start writing immediately - * AFTER the existing central dir - this would be safer. */ - pZip->m_archive_size = pZip->m_central_directory_file_ofs; - pZip->m_central_directory_file_ofs = 0; - - /* Clear the sorted central dir offsets, they aren't useful or maintained now. - */ - /* Even though we're now in write mode, files can still be extracted and - * verified, but file locates will be slow. */ - /* TODO: We could easily maintain the sorted central directory offsets. */ - mz_zip_array_clear(pZip, &pZip->m_pState->m_sorted_central_dir_offsets); - - pZip->m_zip_mode = MZ_ZIP_MODE_WRITING; - - return MZ_TRUE; -} - -mz_bool mz_zip_writer_init_from_reader_v2_noreopen(mz_zip_archive *pZip, - const char *pFilename, - mz_uint flags) { - mz_zip_internal_state *pState; - - if ((!pZip) || (!pZip->m_pState) || (pZip->m_zip_mode != MZ_ZIP_MODE_READING)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (flags & MZ_ZIP_FLAG_WRITE_ZIP64) { - /* We don't support converting a non-zip64 file to zip64 - this seems like - * more trouble than it's worth. (What about the existing 32-bit data - * descriptors that could follow the compressed data?) */ - if (!pZip->m_pState->m_zip64) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - } - - /* No sense in trying to write to an archive that's already at the support max - * size */ - if (pZip->m_pState->m_zip64) { - if (pZip->m_total_files == MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - } else { - if (pZip->m_total_files == MZ_UINT16_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - - if ((pZip->m_archive_size + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) > MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_TOO_LARGE); - } - - pState = pZip->m_pState; - - if (pState->m_pFile) { -#ifdef MINIZ_NO_STDIO - (void)pFilename; - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); -#else - if (pZip->m_pIO_opaque != pZip) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (pZip->m_zip_type == MZ_ZIP_TYPE_FILE) { - if (!pFilename) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - } - - pZip->m_pWrite = mz_zip_file_write_func; - pZip->m_pNeeds_keepalive = NULL; -#endif /* #ifdef MINIZ_NO_STDIO */ - } else if (pState->m_pMem) { - /* Archive lives in a memory block. Assume it's from the heap that we can - * resize using the realloc callback. */ - if (pZip->m_pIO_opaque != pZip) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pState->m_mem_capacity = pState->m_mem_size; - pZip->m_pWrite = mz_zip_heap_write_func; - pZip->m_pNeeds_keepalive = NULL; - } - /* Archive is being read via a user provided read function - make sure the - user has specified a write function too. */ - else if (!pZip->m_pWrite) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - /* Start writing new files at the archive's current central directory - * location. */ - /* TODO: We could add a flag that lets the user start writing immediately - * AFTER the existing central dir - this would be safer. */ - pZip->m_archive_size = pZip->m_central_directory_file_ofs; - pZip->m_central_directory_file_ofs = 0; - - /* Clear the sorted central dir offsets, they aren't useful or maintained now. - */ - /* Even though we're now in write mode, files can still be extracted and - * verified, but file locates will be slow. */ - /* TODO: We could easily maintain the sorted central directory offsets. */ - mz_zip_array_clear(pZip, &pZip->m_pState->m_sorted_central_dir_offsets); - - pZip->m_zip_mode = MZ_ZIP_MODE_WRITING; - - return MZ_TRUE; -} - -mz_bool mz_zip_writer_init_from_reader(mz_zip_archive *pZip, - const char *pFilename) { - return mz_zip_writer_init_from_reader_v2(pZip, pFilename, 0); -} - -/* TODO: pArchive_name is a terrible name here! */ -mz_bool mz_zip_writer_add_mem(mz_zip_archive *pZip, const char *pArchive_name, - const void *pBuf, size_t buf_size, - mz_uint level_and_flags) { - return mz_zip_writer_add_mem_ex(pZip, pArchive_name, pBuf, buf_size, NULL, 0, - level_and_flags, 0, 0); -} - -typedef struct { - mz_zip_archive *m_pZip; - mz_uint64 m_cur_archive_file_ofs; - mz_uint64 m_comp_size; -} mz_zip_writer_add_state; - -static mz_bool mz_zip_writer_add_put_buf_callback(const void *pBuf, int len, - void *pUser) { - mz_zip_writer_add_state *pState = (mz_zip_writer_add_state *)pUser; - if ((int)pState->m_pZip->m_pWrite(pState->m_pZip->m_pIO_opaque, - pState->m_cur_archive_file_ofs, pBuf, - len) != len) - return MZ_FALSE; - - pState->m_cur_archive_file_ofs += len; - pState->m_comp_size += len; - return MZ_TRUE; -} - -#define MZ_ZIP64_MAX_LOCAL_EXTRA_FIELD_SIZE \ - (sizeof(mz_uint16) * 2 + sizeof(mz_uint64) * 2) -#define MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE \ - (sizeof(mz_uint16) * 2 + sizeof(mz_uint64) * 3) -static mz_uint32 -mz_zip_writer_create_zip64_extra_data(mz_uint8 *pBuf, mz_uint64 *pUncomp_size, - mz_uint64 *pComp_size, - mz_uint64 *pLocal_header_ofs) { - mz_uint8 *pDst = pBuf; - mz_uint32 field_size = 0; - - MZ_WRITE_LE16(pDst + 0, MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID); - MZ_WRITE_LE16(pDst + 2, 0); - pDst += sizeof(mz_uint16) * 2; - - if (pUncomp_size) { - MZ_WRITE_LE64(pDst, *pUncomp_size); - pDst += sizeof(mz_uint64); - field_size += sizeof(mz_uint64); - } - - if (pComp_size) { - MZ_WRITE_LE64(pDst, *pComp_size); - pDst += sizeof(mz_uint64); - field_size += sizeof(mz_uint64); - } - - if (pLocal_header_ofs) { - MZ_WRITE_LE64(pDst, *pLocal_header_ofs); - pDst += sizeof(mz_uint64); - field_size += sizeof(mz_uint64); - } - - MZ_WRITE_LE16(pBuf + 2, field_size); - - return (mz_uint32)(pDst - pBuf); -} - -static mz_bool mz_zip_writer_create_local_dir_header( - mz_zip_archive *pZip, mz_uint8 *pDst, mz_uint16 filename_size, - mz_uint16 extra_size, mz_uint64 uncomp_size, mz_uint64 comp_size, - mz_uint32 uncomp_crc32, mz_uint16 method, mz_uint16 bit_flags, - mz_uint16 dos_time, mz_uint16 dos_date) { - (void)pZip; - memset(pDst, 0, MZ_ZIP_LOCAL_DIR_HEADER_SIZE); - MZ_WRITE_LE32(pDst + MZ_ZIP_LDH_SIG_OFS, MZ_ZIP_LOCAL_DIR_HEADER_SIG); - MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_VERSION_NEEDED_OFS, method ? 20 : 0); - MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_BIT_FLAG_OFS, bit_flags); - MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_METHOD_OFS, method); - MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_FILE_TIME_OFS, dos_time); - MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_FILE_DATE_OFS, dos_date); - MZ_WRITE_LE32(pDst + MZ_ZIP_LDH_CRC32_OFS, uncomp_crc32); - MZ_WRITE_LE32(pDst + MZ_ZIP_LDH_COMPRESSED_SIZE_OFS, - MZ_MIN(comp_size, MZ_UINT32_MAX)); - MZ_WRITE_LE32(pDst + MZ_ZIP_LDH_DECOMPRESSED_SIZE_OFS, - MZ_MIN(uncomp_size, MZ_UINT32_MAX)); - MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_FILENAME_LEN_OFS, filename_size); - MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_EXTRA_LEN_OFS, extra_size); - return MZ_TRUE; -} - -static mz_bool mz_zip_writer_create_central_dir_header( - mz_zip_archive *pZip, mz_uint8 *pDst, mz_uint16 filename_size, - mz_uint16 extra_size, mz_uint16 comment_size, mz_uint64 uncomp_size, - mz_uint64 comp_size, mz_uint32 uncomp_crc32, mz_uint16 method, - mz_uint16 bit_flags, mz_uint16 dos_time, mz_uint16 dos_date, - mz_uint64 local_header_ofs, mz_uint32 ext_attributes) { - (void)pZip; - memset(pDst, 0, MZ_ZIP_CENTRAL_DIR_HEADER_SIZE); - MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_SIG_OFS, MZ_ZIP_CENTRAL_DIR_HEADER_SIG); - MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_VERSION_NEEDED_OFS, method ? 20 : 0); - MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_BIT_FLAG_OFS, bit_flags); - MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_METHOD_OFS, method); - MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_FILE_TIME_OFS, dos_time); - MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_FILE_DATE_OFS, dos_date); - MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_CRC32_OFS, uncomp_crc32); - MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_COMPRESSED_SIZE_OFS, - MZ_MIN(comp_size, MZ_UINT32_MAX)); - MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS, - MZ_MIN(uncomp_size, MZ_UINT32_MAX)); - MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_FILENAME_LEN_OFS, filename_size); - MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_EXTRA_LEN_OFS, extra_size); - MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_COMMENT_LEN_OFS, comment_size); - MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_EXTERNAL_ATTR_OFS, ext_attributes); - MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_LOCAL_HEADER_OFS, - MZ_MIN(local_header_ofs, MZ_UINT32_MAX)); - return MZ_TRUE; -} - -static mz_bool mz_zip_writer_add_to_central_dir( - mz_zip_archive *pZip, const char *pFilename, mz_uint16 filename_size, - const void *pExtra, mz_uint16 extra_size, const void *pComment, - mz_uint16 comment_size, mz_uint64 uncomp_size, mz_uint64 comp_size, - mz_uint32 uncomp_crc32, mz_uint16 method, mz_uint16 bit_flags, - mz_uint16 dos_time, mz_uint16 dos_date, mz_uint64 local_header_ofs, - mz_uint32 ext_attributes, const char *user_extra_data, - mz_uint user_extra_data_len) { - mz_zip_internal_state *pState = pZip->m_pState; - mz_uint32 central_dir_ofs = (mz_uint32)pState->m_central_dir.m_size; - size_t orig_central_dir_size = pState->m_central_dir.m_size; - mz_uint8 central_dir_header[MZ_ZIP_CENTRAL_DIR_HEADER_SIZE]; - - if (!pZip->m_pState->m_zip64) { - if (local_header_ofs > 0xFFFFFFFF) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_TOO_LARGE); - } - - /* miniz doesn't support central dirs >= MZ_UINT32_MAX bytes yet */ - if (((mz_uint64)pState->m_central_dir.m_size + - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + filename_size + extra_size + - user_extra_data_len + comment_size) >= MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE); - - if (!mz_zip_writer_create_central_dir_header( - pZip, central_dir_header, filename_size, - (mz_uint16)(extra_size + user_extra_data_len), comment_size, - uncomp_size, comp_size, uncomp_crc32, method, bit_flags, dos_time, - dos_date, local_header_ofs, ext_attributes)) - return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - - if ((!mz_zip_array_push_back(pZip, &pState->m_central_dir, central_dir_header, - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE)) || - (!mz_zip_array_push_back(pZip, &pState->m_central_dir, pFilename, - filename_size)) || - (!mz_zip_array_push_back(pZip, &pState->m_central_dir, pExtra, - extra_size)) || - (!mz_zip_array_push_back(pZip, &pState->m_central_dir, user_extra_data, - user_extra_data_len)) || - (!mz_zip_array_push_back(pZip, &pState->m_central_dir, pComment, - comment_size)) || - (!mz_zip_array_push_back(pZip, &pState->m_central_dir_offsets, - ¢ral_dir_ofs, 1))) { - /* Try to resize the central directory array back into its original state. - */ - mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size, - MZ_FALSE); - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - return MZ_TRUE; -} - -static mz_bool mz_zip_writer_validate_archive_name(const char *pArchive_name) { - /* Basic ZIP archive filename validity checks: Valid filenames cannot start - * with a forward slash, cannot contain a drive letter, and cannot use - * DOS-style backward slashes. */ - if (*pArchive_name == '/') - return MZ_FALSE; - - /* Making sure the name does not contain drive letters or DOS style backward - * slashes is the responsibility of the program using miniz*/ - - return MZ_TRUE; -} - -static mz_uint -mz_zip_writer_compute_padding_needed_for_file_alignment(mz_zip_archive *pZip) { - mz_uint32 n; - if (!pZip->m_file_offset_alignment) - return 0; - n = (mz_uint32)(pZip->m_archive_size & (pZip->m_file_offset_alignment - 1)); - return (mz_uint)((pZip->m_file_offset_alignment - n) & - (pZip->m_file_offset_alignment - 1)); -} - -static mz_bool mz_zip_writer_write_zeros(mz_zip_archive *pZip, - mz_uint64 cur_file_ofs, mz_uint32 n) { - char buf[4096]; - memset(buf, 0, MZ_MIN(sizeof(buf), n)); - while (n) { - mz_uint32 s = MZ_MIN(sizeof(buf), n); - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_file_ofs, buf, s) != s) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_file_ofs += s; - n -= s; - } - return MZ_TRUE; -} - -mz_bool mz_zip_writer_add_mem_ex(mz_zip_archive *pZip, - const char *pArchive_name, const void *pBuf, - size_t buf_size, const void *pComment, - mz_uint16 comment_size, - mz_uint level_and_flags, mz_uint64 uncomp_size, - mz_uint32 uncomp_crc32) { - return mz_zip_writer_add_mem_ex_v2( - pZip, pArchive_name, pBuf, buf_size, pComment, comment_size, - level_and_flags, uncomp_size, uncomp_crc32, NULL, NULL, 0, NULL, 0); -} - -mz_bool mz_zip_writer_add_mem_ex_v2( - mz_zip_archive *pZip, const char *pArchive_name, const void *pBuf, - size_t buf_size, const void *pComment, mz_uint16 comment_size, - mz_uint level_and_flags, mz_uint64 uncomp_size, mz_uint32 uncomp_crc32, - MZ_TIME_T *last_modified, const char *user_extra_data, - mz_uint user_extra_data_len, const char *user_extra_data_central, - mz_uint user_extra_data_central_len) { - mz_uint16 method = 0, dos_time = 0, dos_date = 0; - mz_uint level, ext_attributes = 0, num_alignment_padding_bytes; - mz_uint64 local_dir_header_ofs = 0, cur_archive_file_ofs = 0, comp_size = 0; - size_t archive_name_size; - mz_uint8 local_dir_header[MZ_ZIP_LOCAL_DIR_HEADER_SIZE]; - tdefl_compressor *pComp = NULL; - mz_bool store_data_uncompressed; - mz_zip_internal_state *pState; - mz_uint8 *pExtra_data = NULL; - mz_uint32 extra_size = 0; - mz_uint8 extra_data[MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE]; - mz_uint16 bit_flags = 0; - - if ((int)level_and_flags < 0) - level_and_flags = MZ_DEFAULT_LEVEL; - - if (uncomp_size || - (buf_size && !(level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA))) - bit_flags |= MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR; - - if (!(level_and_flags & MZ_ZIP_FLAG_ASCII_FILENAME)) - bit_flags |= MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8; - - level = level_and_flags & 0xF; - store_data_uncompressed = - ((!level) || (level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA)); - - if ((!pZip) || (!pZip->m_pState) || - (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING) || ((buf_size) && (!pBuf)) || - (!pArchive_name) || ((comment_size) && (!pComment)) || - (level > MZ_UBER_COMPRESSION)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pState = pZip->m_pState; - local_dir_header_ofs = pZip->m_archive_size; - cur_archive_file_ofs = pZip->m_archive_size; - - if (pState->m_zip64) { - if (pZip->m_total_files == MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - } else { - if (pZip->m_total_files == MZ_UINT16_MAX) { - pState->m_zip64 = MZ_TRUE; - /*return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); */ - } - if ((buf_size > 0xFFFFFFFF) || (uncomp_size > 0xFFFFFFFF)) { - pState->m_zip64 = MZ_TRUE; - /*return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); */ - } - } - - if ((!(level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) && (uncomp_size)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (!mz_zip_writer_validate_archive_name(pArchive_name)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_FILENAME); - -#ifndef MINIZ_NO_TIME - if (last_modified != NULL) { - mz_zip_time_t_to_dos_time(*last_modified, &dos_time, &dos_date); - } else { - MZ_TIME_T cur_time; - time(&cur_time); - mz_zip_time_t_to_dos_time(cur_time, &dos_time, &dos_date); - } -#endif /* #ifndef MINIZ_NO_TIME */ - - if (!(level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) { - uncomp_crc32 = - (mz_uint32)mz_crc32(MZ_CRC32_INIT, (const mz_uint8 *)pBuf, buf_size); - uncomp_size = buf_size; - if (uncomp_size <= 3) { - level = 0; - store_data_uncompressed = MZ_TRUE; - } - } - - archive_name_size = strlen(pArchive_name); - if (archive_name_size > MZ_UINT16_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_FILENAME); - - num_alignment_padding_bytes = - mz_zip_writer_compute_padding_needed_for_file_alignment(pZip); - - /* miniz doesn't support central dirs >= MZ_UINT32_MAX bytes yet */ - if (((mz_uint64)pState->m_central_dir.m_size + - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size + - MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE + comment_size) >= MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE); - - if (!pState->m_zip64) { - /* Bail early if the archive would obviously become too large */ - if ((pZip->m_archive_size + num_alignment_padding_bytes + - MZ_ZIP_LOCAL_DIR_HEADER_SIZE + archive_name_size + - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size + comment_size + - user_extra_data_len + pState->m_central_dir.m_size + - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE + user_extra_data_central_len + - MZ_ZIP_DATA_DESCRIPTER_SIZE32) > 0xFFFFFFFF) { - pState->m_zip64 = MZ_TRUE; - /*return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); */ - } - } - - if ((archive_name_size) && (pArchive_name[archive_name_size - 1] == '/')) { - /* Set DOS Subdirectory attribute bit. */ - ext_attributes |= MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG; - - /* Subdirectories cannot contain data. */ - if ((buf_size) || (uncomp_size)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - } - - /* Try to do any allocations before writing to the archive, so if an - * allocation fails the file remains unmodified. (A good idea if we're doing - * an in-place modification.) */ - if ((!mz_zip_array_ensure_room( - pZip, &pState->m_central_dir, - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size + comment_size + - (pState->m_zip64 ? MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE : 0))) || - (!mz_zip_array_ensure_room(pZip, &pState->m_central_dir_offsets, 1))) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - if ((!store_data_uncompressed) && (buf_size)) { - if (NULL == (pComp = (tdefl_compressor *)pZip->m_pAlloc( - pZip->m_pAlloc_opaque, 1, sizeof(tdefl_compressor)))) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - if (!mz_zip_writer_write_zeros(pZip, cur_archive_file_ofs, - num_alignment_padding_bytes)) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pComp); - return MZ_FALSE; - } - - local_dir_header_ofs += num_alignment_padding_bytes; - if (pZip->m_file_offset_alignment) { - MZ_ASSERT((local_dir_header_ofs & (pZip->m_file_offset_alignment - 1)) == - 0); - } - cur_archive_file_ofs += num_alignment_padding_bytes; - - MZ_CLEAR_OBJ(local_dir_header); - - if (!store_data_uncompressed || - (level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) { - method = MZ_DEFLATED; - } - - if (pState->m_zip64) { - if (uncomp_size >= MZ_UINT32_MAX || local_dir_header_ofs >= MZ_UINT32_MAX) { - pExtra_data = extra_data; - extra_size = mz_zip_writer_create_zip64_extra_data( - extra_data, (uncomp_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL, - (uncomp_size >= MZ_UINT32_MAX) ? &comp_size : NULL, - (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs - : NULL); - } - - if (!mz_zip_writer_create_local_dir_header( - pZip, local_dir_header, (mz_uint16)archive_name_size, - (mz_uint16)(extra_size + user_extra_data_len), 0, 0, 0, method, - bit_flags, dos_time, dos_date)) - return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, local_dir_header_ofs, - local_dir_header, - sizeof(local_dir_header)) != sizeof(local_dir_header)) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_archive_file_ofs += sizeof(local_dir_header); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pArchive_name, - archive_name_size) != archive_name_size) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pComp); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - cur_archive_file_ofs += archive_name_size; - - if (pExtra_data != NULL) { - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, extra_data, - extra_size) != extra_size) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_archive_file_ofs += extra_size; - } - } else { - if ((comp_size > MZ_UINT32_MAX) || (cur_archive_file_ofs > MZ_UINT32_MAX)) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - if (!mz_zip_writer_create_local_dir_header( - pZip, local_dir_header, (mz_uint16)archive_name_size, - (mz_uint16)user_extra_data_len, 0, 0, 0, method, bit_flags, - dos_time, dos_date)) - return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, local_dir_header_ofs, - local_dir_header, - sizeof(local_dir_header)) != sizeof(local_dir_header)) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_archive_file_ofs += sizeof(local_dir_header); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pArchive_name, - archive_name_size) != archive_name_size) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pComp); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - cur_archive_file_ofs += archive_name_size; - } - - if (user_extra_data_len > 0) { - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, - user_extra_data, - user_extra_data_len) != user_extra_data_len) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_archive_file_ofs += user_extra_data_len; - } - - if (store_data_uncompressed) { - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pBuf, - buf_size) != buf_size) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pComp); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - - cur_archive_file_ofs += buf_size; - comp_size = buf_size; - } else if (buf_size) { - mz_zip_writer_add_state state; - - state.m_pZip = pZip; - state.m_cur_archive_file_ofs = cur_archive_file_ofs; - state.m_comp_size = 0; - - if ((tdefl_init(pComp, mz_zip_writer_add_put_buf_callback, &state, - tdefl_create_comp_flags_from_zip_params( - level, -15, MZ_DEFAULT_STRATEGY)) != - TDEFL_STATUS_OKAY) || - (tdefl_compress_buffer(pComp, pBuf, buf_size, TDEFL_FINISH) != - TDEFL_STATUS_DONE)) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pComp); - return mz_zip_set_error(pZip, MZ_ZIP_COMPRESSION_FAILED); - } - - comp_size = state.m_comp_size; - cur_archive_file_ofs = state.m_cur_archive_file_ofs; - } - - pZip->m_pFree(pZip->m_pAlloc_opaque, pComp); - pComp = NULL; - - if (uncomp_size) { - mz_uint8 local_dir_footer[MZ_ZIP_DATA_DESCRIPTER_SIZE64]; - mz_uint32 local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE32; - - MZ_ASSERT(bit_flags & MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR); - - MZ_WRITE_LE32(local_dir_footer + 0, MZ_ZIP_DATA_DESCRIPTOR_ID); - MZ_WRITE_LE32(local_dir_footer + 4, uncomp_crc32); - if (pExtra_data == NULL) { - if (comp_size > MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - - MZ_WRITE_LE32(local_dir_footer + 8, comp_size); - MZ_WRITE_LE32(local_dir_footer + 12, uncomp_size); - } else { - MZ_WRITE_LE64(local_dir_footer + 8, comp_size); - MZ_WRITE_LE64(local_dir_footer + 16, uncomp_size); - local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE64; - } - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, - local_dir_footer, - local_dir_footer_size) != local_dir_footer_size) - return MZ_FALSE; - - cur_archive_file_ofs += local_dir_footer_size; - } - - if (pExtra_data != NULL) { - extra_size = mz_zip_writer_create_zip64_extra_data( - extra_data, (uncomp_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL, - (uncomp_size >= MZ_UINT32_MAX) ? &comp_size : NULL, - (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs : NULL); - } - - if (!mz_zip_writer_add_to_central_dir( - pZip, pArchive_name, (mz_uint16)archive_name_size, pExtra_data, - (mz_uint16)extra_size, pComment, comment_size, uncomp_size, comp_size, - uncomp_crc32, method, bit_flags, dos_time, dos_date, - local_dir_header_ofs, ext_attributes, user_extra_data_central, - user_extra_data_central_len)) - return MZ_FALSE; - - pZip->m_total_files++; - pZip->m_archive_size = cur_archive_file_ofs; - - return MZ_TRUE; -} - -mz_bool mz_zip_writer_add_read_buf_callback( - mz_zip_archive *pZip, const char *pArchive_name, - mz_file_read_func read_callback, void *callback_opaque, mz_uint64 max_size, - const MZ_TIME_T *pFile_time, const void *pComment, mz_uint16 comment_size, - mz_uint level_and_flags, mz_uint32 ext_attributes, - const char *user_extra_data, mz_uint user_extra_data_len, - const char *user_extra_data_central, mz_uint user_extra_data_central_len) { - mz_uint16 gen_flags = (level_and_flags & MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE) - ? 0 - : MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR; - mz_uint uncomp_crc32 = MZ_CRC32_INIT, level, num_alignment_padding_bytes; - mz_uint16 method = 0, dos_time = 0, dos_date = 0; - mz_uint64 local_dir_header_ofs, cur_archive_file_ofs = 0, uncomp_size = 0, - comp_size = 0; - size_t archive_name_size; - mz_uint8 local_dir_header[MZ_ZIP_LOCAL_DIR_HEADER_SIZE]; - mz_uint8 *pExtra_data = NULL; - mz_uint32 extra_size = 0; - mz_uint8 extra_data[MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE]; - mz_zip_internal_state *pState; - mz_uint64 file_ofs = 0, cur_archive_header_file_ofs; - - if (!(level_and_flags & MZ_ZIP_FLAG_ASCII_FILENAME)) - gen_flags |= MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8; - - if ((int)level_and_flags < 0) - level_and_flags = MZ_DEFAULT_LEVEL; - level = level_and_flags & 0xF; - - /* Sanity checks */ - if ((!pZip) || (!pZip->m_pState) || - (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING) || (!pArchive_name) || - ((comment_size) && (!pComment)) || (level > MZ_UBER_COMPRESSION)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pState = pZip->m_pState; - cur_archive_file_ofs = pZip->m_archive_size; - - if ((!pState->m_zip64) && (max_size > MZ_UINT32_MAX)) { - /* Source file is too large for non-zip64 */ - /*return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); */ - pState->m_zip64 = MZ_TRUE; - } - - /* We could support this, but why? */ - if (level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (!mz_zip_writer_validate_archive_name(pArchive_name)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_FILENAME); - - if (pState->m_zip64) { - if (pZip->m_total_files == MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - } else { - if (pZip->m_total_files == MZ_UINT16_MAX) { - pState->m_zip64 = MZ_TRUE; - /*return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); */ - } - } - - archive_name_size = strlen(pArchive_name); - if (archive_name_size > MZ_UINT16_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_FILENAME); - - num_alignment_padding_bytes = - mz_zip_writer_compute_padding_needed_for_file_alignment(pZip); - - /* miniz doesn't support central dirs >= MZ_UINT32_MAX bytes yet */ - if (((mz_uint64)pState->m_central_dir.m_size + - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size + - MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE + comment_size) >= MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE); - - if (!pState->m_zip64) { - /* Bail early if the archive would obviously become too large */ - if ((pZip->m_archive_size + num_alignment_padding_bytes + - MZ_ZIP_LOCAL_DIR_HEADER_SIZE + archive_name_size + - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size + comment_size + - user_extra_data_len + pState->m_central_dir.m_size + - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE + 1024 + - MZ_ZIP_DATA_DESCRIPTER_SIZE32 + user_extra_data_central_len) > - 0xFFFFFFFF) { - pState->m_zip64 = MZ_TRUE; - /*return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); */ - } - } - -#ifndef MINIZ_NO_TIME - if (pFile_time) { - mz_zip_time_t_to_dos_time(*pFile_time, &dos_time, &dos_date); - } -#endif - - if (max_size <= 3) - level = 0; - - if (!mz_zip_writer_write_zeros(pZip, cur_archive_file_ofs, - num_alignment_padding_bytes)) { - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - - cur_archive_file_ofs += num_alignment_padding_bytes; - local_dir_header_ofs = cur_archive_file_ofs; - - if (pZip->m_file_offset_alignment) { - MZ_ASSERT((cur_archive_file_ofs & (pZip->m_file_offset_alignment - 1)) == - 0); - } - - if (max_size && level) { - method = MZ_DEFLATED; - } - - MZ_CLEAR_OBJ(local_dir_header); - if (pState->m_zip64) { - if (max_size >= MZ_UINT32_MAX || local_dir_header_ofs >= MZ_UINT32_MAX) { - pExtra_data = extra_data; - if (level_and_flags & MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE) - extra_size = mz_zip_writer_create_zip64_extra_data( - extra_data, (max_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL, - (max_size >= MZ_UINT32_MAX) ? &comp_size : NULL, - (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs - : NULL); - else - extra_size = mz_zip_writer_create_zip64_extra_data( - extra_data, NULL, NULL, - (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs - : NULL); - } - - if (!mz_zip_writer_create_local_dir_header( - pZip, local_dir_header, (mz_uint16)archive_name_size, - (mz_uint16)(extra_size + user_extra_data_len), 0, 0, 0, method, - gen_flags, dos_time, dos_date)) - return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, - local_dir_header, - sizeof(local_dir_header)) != sizeof(local_dir_header)) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_archive_file_ofs += sizeof(local_dir_header); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pArchive_name, - archive_name_size) != archive_name_size) { - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - - cur_archive_file_ofs += archive_name_size; - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, extra_data, - extra_size) != extra_size) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_archive_file_ofs += extra_size; - } else { - if ((comp_size > MZ_UINT32_MAX) || (cur_archive_file_ofs > MZ_UINT32_MAX)) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - if (!mz_zip_writer_create_local_dir_header( - pZip, local_dir_header, (mz_uint16)archive_name_size, - (mz_uint16)user_extra_data_len, 0, 0, 0, method, gen_flags, - dos_time, dos_date)) - return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, - local_dir_header, - sizeof(local_dir_header)) != sizeof(local_dir_header)) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_archive_file_ofs += sizeof(local_dir_header); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pArchive_name, - archive_name_size) != archive_name_size) { - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - - cur_archive_file_ofs += archive_name_size; - } - - if (user_extra_data_len > 0) { - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, - user_extra_data, - user_extra_data_len) != user_extra_data_len) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_archive_file_ofs += user_extra_data_len; - } - - if (max_size) { - void *pRead_buf = - pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1, MZ_ZIP_MAX_IO_BUF_SIZE); - if (!pRead_buf) { - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - if (!level) { - while (1) { - size_t n = read_callback(callback_opaque, file_ofs, pRead_buf, - MZ_ZIP_MAX_IO_BUF_SIZE); - if (n == 0) - break; - - if ((n > MZ_ZIP_MAX_IO_BUF_SIZE) || (file_ofs + n > max_size)) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - } - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pRead_buf, - n) != n) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - file_ofs += n; - uncomp_crc32 = - (mz_uint32)mz_crc32(uncomp_crc32, (const mz_uint8 *)pRead_buf, n); - cur_archive_file_ofs += n; - } - uncomp_size = file_ofs; - comp_size = uncomp_size; - } else { - mz_bool result = MZ_FALSE; - mz_zip_writer_add_state state; - tdefl_compressor *pComp = (tdefl_compressor *)pZip->m_pAlloc( - pZip->m_pAlloc_opaque, 1, sizeof(tdefl_compressor)); - if (!pComp) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf); - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - state.m_pZip = pZip; - state.m_cur_archive_file_ofs = cur_archive_file_ofs; - state.m_comp_size = 0; - - if (tdefl_init(pComp, mz_zip_writer_add_put_buf_callback, &state, - tdefl_create_comp_flags_from_zip_params( - level, -15, MZ_DEFAULT_STRATEGY)) != - TDEFL_STATUS_OKAY) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pComp); - pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf); - return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - } - - for (;;) { - tdefl_status status; - tdefl_flush flush = TDEFL_NO_FLUSH; - - size_t n = read_callback(callback_opaque, file_ofs, pRead_buf, - MZ_ZIP_MAX_IO_BUF_SIZE); - if ((n > MZ_ZIP_MAX_IO_BUF_SIZE) || (file_ofs + n > max_size)) { - mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - break; - } - - file_ofs += n; - uncomp_crc32 = - (mz_uint32)mz_crc32(uncomp_crc32, (const mz_uint8 *)pRead_buf, n); - - if (pZip->m_pNeeds_keepalive != NULL && - pZip->m_pNeeds_keepalive(pZip->m_pIO_opaque)) - flush = TDEFL_FULL_FLUSH; - - if (n == 0) - flush = TDEFL_FINISH; - - status = tdefl_compress_buffer(pComp, pRead_buf, n, flush); - if (status == TDEFL_STATUS_DONE) { - result = MZ_TRUE; - break; - } else if (status != TDEFL_STATUS_OKAY) { - mz_zip_set_error(pZip, MZ_ZIP_COMPRESSION_FAILED); - break; - } - } - - pZip->m_pFree(pZip->m_pAlloc_opaque, pComp); - - if (!result) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf); - return MZ_FALSE; - } - - uncomp_size = file_ofs; - comp_size = state.m_comp_size; - cur_archive_file_ofs = state.m_cur_archive_file_ofs; - } - - pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf); - } - - if (!(level_and_flags & MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE)) { - mz_uint8 local_dir_footer[MZ_ZIP_DATA_DESCRIPTER_SIZE64]; - mz_uint32 local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE32; - - MZ_WRITE_LE32(local_dir_footer + 0, MZ_ZIP_DATA_DESCRIPTOR_ID); - MZ_WRITE_LE32(local_dir_footer + 4, uncomp_crc32); - if (pExtra_data == NULL) { - if (comp_size > MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - - MZ_WRITE_LE32(local_dir_footer + 8, comp_size); - MZ_WRITE_LE32(local_dir_footer + 12, uncomp_size); - } else { - MZ_WRITE_LE64(local_dir_footer + 8, comp_size); - MZ_WRITE_LE64(local_dir_footer + 16, uncomp_size); - local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE64; - } - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, - local_dir_footer, - local_dir_footer_size) != local_dir_footer_size) - return MZ_FALSE; - - cur_archive_file_ofs += local_dir_footer_size; - } - - if (level_and_flags & MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE) { - if (pExtra_data != NULL) { - extra_size = mz_zip_writer_create_zip64_extra_data( - extra_data, (max_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL, - (max_size >= MZ_UINT32_MAX) ? &comp_size : NULL, - (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs - : NULL); - } - - if (!mz_zip_writer_create_local_dir_header( - pZip, local_dir_header, (mz_uint16)archive_name_size, - (mz_uint16)(extra_size + user_extra_data_len), - (max_size >= MZ_UINT32_MAX) ? MZ_UINT32_MAX : uncomp_size, - (max_size >= MZ_UINT32_MAX) ? MZ_UINT32_MAX : comp_size, - uncomp_crc32, method, gen_flags, dos_time, dos_date)) - return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR); - - cur_archive_header_file_ofs = local_dir_header_ofs; - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_header_file_ofs, - local_dir_header, - sizeof(local_dir_header)) != sizeof(local_dir_header)) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - if (pExtra_data != NULL) { - cur_archive_header_file_ofs += sizeof(local_dir_header); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_header_file_ofs, - pArchive_name, - archive_name_size) != archive_name_size) { - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - - cur_archive_header_file_ofs += archive_name_size; - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_header_file_ofs, - extra_data, extra_size) != extra_size) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_archive_header_file_ofs += extra_size; - } - } - - if (pExtra_data != NULL) { - extra_size = mz_zip_writer_create_zip64_extra_data( - extra_data, (uncomp_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL, - (uncomp_size >= MZ_UINT32_MAX) ? &comp_size : NULL, - (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs : NULL); - } - - if (!mz_zip_writer_add_to_central_dir( - pZip, pArchive_name, (mz_uint16)archive_name_size, pExtra_data, - (mz_uint16)extra_size, pComment, comment_size, uncomp_size, comp_size, - uncomp_crc32, method, gen_flags, dos_time, dos_date, - local_dir_header_ofs, ext_attributes, user_extra_data_central, - user_extra_data_central_len)) - return MZ_FALSE; - - pZip->m_total_files++; - pZip->m_archive_size = cur_archive_file_ofs; - - return MZ_TRUE; -} - -#ifndef MINIZ_NO_STDIO - -static size_t mz_file_read_func_stdio(void *pOpaque, mz_uint64 file_ofs, - void *pBuf, size_t n) { - MZ_FILE *pSrc_file = (MZ_FILE *)pOpaque; - mz_int64 cur_ofs = MZ_FTELL64(pSrc_file); - - if (((mz_int64)file_ofs < 0) || - (((cur_ofs != (mz_int64)file_ofs)) && - (MZ_FSEEK64(pSrc_file, (mz_int64)file_ofs, SEEK_SET)))) - return 0; - - return MZ_FREAD(pBuf, 1, n, pSrc_file); -} - -mz_bool mz_zip_writer_add_cfile( - mz_zip_archive *pZip, const char *pArchive_name, MZ_FILE *pSrc_file, - mz_uint64 max_size, const MZ_TIME_T *pFile_time, const void *pComment, - mz_uint16 comment_size, mz_uint level_and_flags, mz_uint32 ext_attributes, - const char *user_extra_data, mz_uint user_extra_data_len, - const char *user_extra_data_central, mz_uint user_extra_data_central_len) { - return mz_zip_writer_add_read_buf_callback( - pZip, pArchive_name, mz_file_read_func_stdio, pSrc_file, max_size, - pFile_time, pComment, comment_size, level_and_flags, ext_attributes, - user_extra_data, user_extra_data_len, user_extra_data_central, - user_extra_data_central_len); -} - -mz_bool mz_zip_writer_add_file(mz_zip_archive *pZip, const char *pArchive_name, - const char *pSrc_filename, const void *pComment, - mz_uint16 comment_size, mz_uint level_and_flags, - mz_uint32 ext_attributes) { - MZ_FILE *pSrc_file = NULL; - mz_uint64 uncomp_size = 0; - MZ_TIME_T file_modified_time; - MZ_TIME_T *pFile_time = NULL; - mz_bool status; - - memset(&file_modified_time, 0, sizeof(file_modified_time)); - -#if !defined(MINIZ_NO_TIME) && !defined(MINIZ_NO_STDIO) - pFile_time = &file_modified_time; - if (!mz_zip_get_file_modified_time(pSrc_filename, &file_modified_time)) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_STAT_FAILED); -#endif - - pSrc_file = MZ_FOPEN(pSrc_filename, "rb"); - if (!pSrc_file) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED); - - MZ_FSEEK64(pSrc_file, 0, SEEK_END); - uncomp_size = MZ_FTELL64(pSrc_file); - MZ_FSEEK64(pSrc_file, 0, SEEK_SET); - - status = mz_zip_writer_add_cfile( - pZip, pArchive_name, pSrc_file, uncomp_size, pFile_time, pComment, - comment_size, level_and_flags, ext_attributes, NULL, 0, NULL, 0); - - MZ_FCLOSE(pSrc_file); - - return status; -} -#endif /* #ifndef MINIZ_NO_STDIO */ - -static mz_bool mz_zip_writer_update_zip64_extension_block( - mz_zip_array *pNew_ext, mz_zip_archive *pZip, const mz_uint8 *pExt, - uint32_t ext_len, mz_uint64 *pComp_size, mz_uint64 *pUncomp_size, - mz_uint64 *pLocal_header_ofs, mz_uint32 *pDisk_start) { - /* + 64 should be enough for any new zip64 data */ - if (!mz_zip_array_reserve(pZip, pNew_ext, ext_len + 64, MZ_FALSE)) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - mz_zip_array_resize(pZip, pNew_ext, 0, MZ_FALSE); - - if ((pUncomp_size) || (pComp_size) || (pLocal_header_ofs) || (pDisk_start)) { - mz_uint8 new_ext_block[64]; - mz_uint8 *pDst = new_ext_block; - mz_write_le16(pDst, MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID); - mz_write_le16(pDst + sizeof(mz_uint16), 0); - pDst += sizeof(mz_uint16) * 2; - - if (pUncomp_size) { - mz_write_le64(pDst, *pUncomp_size); - pDst += sizeof(mz_uint64); - } - - if (pComp_size) { - mz_write_le64(pDst, *pComp_size); - pDst += sizeof(mz_uint64); - } - - if (pLocal_header_ofs) { - mz_write_le64(pDst, *pLocal_header_ofs); - pDst += sizeof(mz_uint64); - } - - if (pDisk_start) { - mz_write_le32(pDst, *pDisk_start); - pDst += sizeof(mz_uint32); - } - - mz_write_le16(new_ext_block + sizeof(mz_uint16), - (mz_uint16)((pDst - new_ext_block) - sizeof(mz_uint16) * 2)); - - if (!mz_zip_array_push_back(pZip, pNew_ext, new_ext_block, - pDst - new_ext_block)) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - if ((pExt) && (ext_len)) { - mz_uint32 extra_size_remaining = ext_len; - const mz_uint8 *pExtra_data = pExt; - - do { - mz_uint32 field_id, field_data_size, field_total_size; - - if (extra_size_remaining < (sizeof(mz_uint16) * 2)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - field_id = MZ_READ_LE16(pExtra_data); - field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16)); - field_total_size = field_data_size + sizeof(mz_uint16) * 2; - - if (field_total_size > extra_size_remaining) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - if (field_id != MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) { - if (!mz_zip_array_push_back(pZip, pNew_ext, pExtra_data, - field_total_size)) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - pExtra_data += field_total_size; - extra_size_remaining -= field_total_size; - } while (extra_size_remaining); - } - - return MZ_TRUE; -} - -/* TODO: This func is now pretty freakin complex due to zip64, split it up? */ -mz_bool mz_zip_writer_add_from_zip_reader(mz_zip_archive *pZip, - mz_zip_archive *pSource_zip, - mz_uint src_file_index) { - mz_uint n, bit_flags, num_alignment_padding_bytes, - src_central_dir_following_data_size; - mz_uint64 src_archive_bytes_remaining, local_dir_header_ofs; - mz_uint64 cur_src_file_ofs, cur_dst_file_ofs; - mz_uint32 - local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) / - sizeof(mz_uint32)]; - mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32; - mz_uint8 new_central_header[MZ_ZIP_CENTRAL_DIR_HEADER_SIZE]; - size_t orig_central_dir_size; - mz_zip_internal_state *pState; - void *pBuf; - const mz_uint8 *pSrc_central_header; - mz_zip_archive_file_stat src_file_stat; - mz_uint32 src_filename_len, src_comment_len, src_ext_len; - mz_uint32 local_header_filename_size, local_header_extra_len; - mz_uint64 local_header_comp_size, local_header_uncomp_size; - mz_bool found_zip64_ext_data_in_ldir = MZ_FALSE; - - /* Sanity checks */ - if ((!pZip) || (!pZip->m_pState) || - (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING) || (!pSource_zip->m_pRead)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pState = pZip->m_pState; - - /* Don't support copying files from zip64 archives to non-zip64, even though - * in some cases this is possible */ - if ((pSource_zip->m_pState->m_zip64) && (!pZip->m_pState->m_zip64)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - /* Get pointer to the source central dir header and crack it */ - if (NULL == - (pSrc_central_header = mz_zip_get_cdh(pSource_zip, src_file_index))) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (MZ_READ_LE32(pSrc_central_header + MZ_ZIP_CDH_SIG_OFS) != - MZ_ZIP_CENTRAL_DIR_HEADER_SIG) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - src_filename_len = - MZ_READ_LE16(pSrc_central_header + MZ_ZIP_CDH_FILENAME_LEN_OFS); - src_comment_len = - MZ_READ_LE16(pSrc_central_header + MZ_ZIP_CDH_COMMENT_LEN_OFS); - src_ext_len = MZ_READ_LE16(pSrc_central_header + MZ_ZIP_CDH_EXTRA_LEN_OFS); - src_central_dir_following_data_size = - src_filename_len + src_ext_len + src_comment_len; - - /* TODO: We don't support central dir's >= MZ_UINT32_MAX bytes right now (+32 - * fudge factor in case we need to add more extra data) */ - if ((pState->m_central_dir.m_size + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + - src_central_dir_following_data_size + 32) >= MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE); - - num_alignment_padding_bytes = - mz_zip_writer_compute_padding_needed_for_file_alignment(pZip); - - if (!pState->m_zip64) { - if (pZip->m_total_files == MZ_UINT16_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - } else { - /* TODO: Our zip64 support still has some 32-bit limits that may not be - * worth fixing. */ - if (pZip->m_total_files == MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - } - - if (!mz_zip_file_stat_internal(pSource_zip, src_file_index, - pSrc_central_header, &src_file_stat, NULL)) - return MZ_FALSE; - - cur_src_file_ofs = src_file_stat.m_local_header_ofs; - cur_dst_file_ofs = pZip->m_archive_size; - - /* Read the source archive's local dir header */ - if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque, cur_src_file_ofs, - pLocal_header, MZ_ZIP_LOCAL_DIR_HEADER_SIZE) != - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - - if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - - cur_src_file_ofs += MZ_ZIP_LOCAL_DIR_HEADER_SIZE; - - /* Compute the total size we need to copy (filename+extra data+compressed - * data) */ - local_header_filename_size = - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS); - local_header_extra_len = - MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS); - local_header_comp_size = - MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_COMPRESSED_SIZE_OFS); - local_header_uncomp_size = - MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_DECOMPRESSED_SIZE_OFS); - src_archive_bytes_remaining = local_header_filename_size + - local_header_extra_len + - src_file_stat.m_comp_size; - - /* Try to find a zip64 extended information field */ - if ((local_header_extra_len) && - ((local_header_comp_size == MZ_UINT32_MAX) || - (local_header_uncomp_size == MZ_UINT32_MAX))) { - mz_zip_array file_data_array; - const mz_uint8 *pExtra_data; - mz_uint32 extra_size_remaining = local_header_extra_len; - - mz_zip_array_init(&file_data_array, 1); - if (!mz_zip_array_resize(pZip, &file_data_array, local_header_extra_len, - MZ_FALSE)) { - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque, - src_file_stat.m_local_header_ofs + - MZ_ZIP_LOCAL_DIR_HEADER_SIZE + - local_header_filename_size, - file_data_array.m_p, local_header_extra_len) != - local_header_extra_len) { - mz_zip_array_clear(pZip, &file_data_array); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - } - - pExtra_data = (const mz_uint8 *)file_data_array.m_p; - - do { - mz_uint32 field_id, field_data_size, field_total_size; - - if (extra_size_remaining < (sizeof(mz_uint16) * 2)) { - mz_zip_array_clear(pZip, &file_data_array); - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - } - - field_id = MZ_READ_LE16(pExtra_data); - field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16)); - field_total_size = field_data_size + sizeof(mz_uint16) * 2; - - if (field_total_size > extra_size_remaining) { - mz_zip_array_clear(pZip, &file_data_array); - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - } - - if (field_id == MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) { - const mz_uint8 *pSrc_field_data = pExtra_data + sizeof(mz_uint32); - - if (field_data_size < sizeof(mz_uint64) * 2) { - mz_zip_array_clear(pZip, &file_data_array); - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED); - } - - local_header_uncomp_size = MZ_READ_LE64(pSrc_field_data); - local_header_comp_size = MZ_READ_LE64( - pSrc_field_data + - sizeof(mz_uint64)); /* may be 0 if there's a descriptor */ - - found_zip64_ext_data_in_ldir = MZ_TRUE; - break; - } - - pExtra_data += field_total_size; - extra_size_remaining -= field_total_size; - } while (extra_size_remaining); - - mz_zip_array_clear(pZip, &file_data_array); - } - - if (!pState->m_zip64) { - /* Try to detect if the new archive will most likely wind up too big and - * bail early (+(sizeof(mz_uint32) * 4) is for the optional descriptor which - * could be present, +64 is a fudge factor). */ - /* We also check when the archive is finalized so this doesn't need to be - * perfect. */ - mz_uint64 approx_new_archive_size = - cur_dst_file_ofs + num_alignment_padding_bytes + - MZ_ZIP_LOCAL_DIR_HEADER_SIZE + src_archive_bytes_remaining + - (sizeof(mz_uint32) * 4) + pState->m_central_dir.m_size + - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + src_central_dir_following_data_size + - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE + 64; - - if (approx_new_archive_size >= MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - } - - /* Write dest archive padding */ - if (!mz_zip_writer_write_zeros(pZip, cur_dst_file_ofs, - num_alignment_padding_bytes)) - return MZ_FALSE; - - cur_dst_file_ofs += num_alignment_padding_bytes; - - local_dir_header_ofs = cur_dst_file_ofs; - if (pZip->m_file_offset_alignment) { - MZ_ASSERT((local_dir_header_ofs & (pZip->m_file_offset_alignment - 1)) == - 0); - } - - /* The original zip's local header+ext block doesn't change, even with zip64, - * so we can just copy it over to the dest zip */ - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_dst_file_ofs, pLocal_header, - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) != - MZ_ZIP_LOCAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - cur_dst_file_ofs += MZ_ZIP_LOCAL_DIR_HEADER_SIZE; - - /* Copy over the source archive bytes to the dest archive, also ensure we have - * enough buf space to handle optional data descriptor */ - if (NULL == (pBuf = pZip->m_pAlloc( - pZip->m_pAlloc_opaque, 1, - (size_t)MZ_MAX(32U, MZ_MIN((mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE, - src_archive_bytes_remaining))))) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - while (src_archive_bytes_remaining) { - n = (mz_uint)MZ_MIN((mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE, - src_archive_bytes_remaining); - if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque, cur_src_file_ofs, pBuf, - n) != n) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - } - cur_src_file_ofs += n; - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_dst_file_ofs, pBuf, n) != n) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - cur_dst_file_ofs += n; - - src_archive_bytes_remaining -= n; - } - - /* Now deal with the optional data descriptor */ - bit_flags = MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_BIT_FLAG_OFS); - if (bit_flags & 8) { - /* Copy data descriptor */ - if ((pSource_zip->m_pState->m_zip64) || (found_zip64_ext_data_in_ldir)) { - /* src is zip64, dest must be zip64 */ - - /* name uint32_t's */ - /* id 1 (optional in zip64?) */ - /* crc 1 */ - /* comp_size 2 */ - /* uncomp_size 2 */ - if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque, cur_src_file_ofs, - pBuf, (sizeof(mz_uint32) * 6)) != - (sizeof(mz_uint32) * 6)) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - } - - n = sizeof(mz_uint32) * - ((MZ_READ_LE32(pBuf) == MZ_ZIP_DATA_DESCRIPTOR_ID) ? 6 : 5); - } else { - /* src is NOT zip64 */ - mz_bool has_id; - - if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque, cur_src_file_ofs, - pBuf, sizeof(mz_uint32) * 4) != - sizeof(mz_uint32) * 4) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED); - } - - has_id = (MZ_READ_LE32(pBuf) == MZ_ZIP_DATA_DESCRIPTOR_ID); - - if (pZip->m_pState->m_zip64) { - /* dest is zip64, so upgrade the data descriptor */ - const mz_uint32 *pSrc_descriptor = - (const mz_uint32 *)((const mz_uint8 *)pBuf + - (has_id ? sizeof(mz_uint32) : 0)); - const mz_uint32 src_crc32 = pSrc_descriptor[0]; - const mz_uint64 src_comp_size = pSrc_descriptor[1]; - const mz_uint64 src_uncomp_size = pSrc_descriptor[2]; - - mz_write_le32((mz_uint8 *)pBuf, MZ_ZIP_DATA_DESCRIPTOR_ID); - mz_write_le32((mz_uint8 *)pBuf + sizeof(mz_uint32) * 1, src_crc32); - mz_write_le64((mz_uint8 *)pBuf + sizeof(mz_uint32) * 2, src_comp_size); - mz_write_le64((mz_uint8 *)pBuf + sizeof(mz_uint32) * 4, - src_uncomp_size); - - n = sizeof(mz_uint32) * 6; - } else { - /* dest is NOT zip64, just copy it as-is */ - n = sizeof(mz_uint32) * (has_id ? 4 : 3); - } - } - - if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_dst_file_ofs, pBuf, n) != n) { - pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf); - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - } - - cur_src_file_ofs += n; - cur_dst_file_ofs += n; - } - pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf); - - /* Finally, add the new central dir header */ - orig_central_dir_size = pState->m_central_dir.m_size; - - memcpy(new_central_header, pSrc_central_header, - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE); - - if (pState->m_zip64) { - /* This is the painful part: We need to write a new central dir header + ext - * block with updated zip64 fields, and ensure the old fields (if any) are - * not included. */ - const mz_uint8 *pSrc_ext = - pSrc_central_header + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + src_filename_len; - mz_zip_array new_ext_block; - - mz_zip_array_init(&new_ext_block, sizeof(mz_uint8)); - - MZ_WRITE_LE32(new_central_header + MZ_ZIP_CDH_COMPRESSED_SIZE_OFS, - MZ_UINT32_MAX); - MZ_WRITE_LE32(new_central_header + MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS, - MZ_UINT32_MAX); - MZ_WRITE_LE32(new_central_header + MZ_ZIP_CDH_LOCAL_HEADER_OFS, - MZ_UINT32_MAX); - - if (!mz_zip_writer_update_zip64_extension_block( - &new_ext_block, pZip, pSrc_ext, src_ext_len, - &src_file_stat.m_comp_size, &src_file_stat.m_uncomp_size, - &local_dir_header_ofs, NULL)) { - mz_zip_array_clear(pZip, &new_ext_block); - return MZ_FALSE; - } - - MZ_WRITE_LE16(new_central_header + MZ_ZIP_CDH_EXTRA_LEN_OFS, - new_ext_block.m_size); - - if (!mz_zip_array_push_back(pZip, &pState->m_central_dir, - new_central_header, - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE)) { - mz_zip_array_clear(pZip, &new_ext_block); - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - if (!mz_zip_array_push_back(pZip, &pState->m_central_dir, - pSrc_central_header + - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE, - src_filename_len)) { - mz_zip_array_clear(pZip, &new_ext_block); - mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size, - MZ_FALSE); - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - if (!mz_zip_array_push_back(pZip, &pState->m_central_dir, new_ext_block.m_p, - new_ext_block.m_size)) { - mz_zip_array_clear(pZip, &new_ext_block); - mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size, - MZ_FALSE); - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - if (!mz_zip_array_push_back(pZip, &pState->m_central_dir, - pSrc_central_header + - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + - src_filename_len + src_ext_len, - src_comment_len)) { - mz_zip_array_clear(pZip, &new_ext_block); - mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size, - MZ_FALSE); - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - mz_zip_array_clear(pZip, &new_ext_block); - } else { - /* sanity checks */ - if (cur_dst_file_ofs > MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - - if (local_dir_header_ofs >= MZ_UINT32_MAX) - return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); - - MZ_WRITE_LE32(new_central_header + MZ_ZIP_CDH_LOCAL_HEADER_OFS, - local_dir_header_ofs); - - if (!mz_zip_array_push_back(pZip, &pState->m_central_dir, - new_central_header, - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE)) - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - - if (!mz_zip_array_push_back(pZip, &pState->m_central_dir, - pSrc_central_header + - MZ_ZIP_CENTRAL_DIR_HEADER_SIZE, - src_central_dir_following_data_size)) { - mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size, - MZ_FALSE); - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - } - - /* This shouldn't trigger unless we screwed up during the initial sanity - * checks */ - if (pState->m_central_dir.m_size >= MZ_UINT32_MAX) { - /* TODO: Support central dirs >= 32-bits in size */ - mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size, - MZ_FALSE); - return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE); - } - - n = (mz_uint32)orig_central_dir_size; - if (!mz_zip_array_push_back(pZip, &pState->m_central_dir_offsets, &n, 1)) { - mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size, - MZ_FALSE); - return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED); - } - - pZip->m_total_files++; - pZip->m_archive_size = cur_dst_file_ofs; - - return MZ_TRUE; -} - -mz_bool mz_zip_writer_finalize_archive(mz_zip_archive *pZip) { - mz_zip_internal_state *pState; - mz_uint64 central_dir_ofs, central_dir_size; - mz_uint8 hdr[256]; - - if ((!pZip) || (!pZip->m_pState) || (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - pState = pZip->m_pState; - - if (pState->m_zip64) { - if ((pZip->m_total_files > MZ_UINT32_MAX) || - (pState->m_central_dir.m_size >= MZ_UINT32_MAX)) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - } else { - if ((pZip->m_total_files > MZ_UINT16_MAX) || - ((pZip->m_archive_size + pState->m_central_dir.m_size + - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) > MZ_UINT32_MAX)) - return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); - } - - central_dir_ofs = 0; - central_dir_size = 0; - if (pZip->m_total_files) { - /* Write central directory */ - central_dir_ofs = pZip->m_archive_size; - central_dir_size = pState->m_central_dir.m_size; - pZip->m_central_directory_file_ofs = central_dir_ofs; - if (pZip->m_pWrite(pZip->m_pIO_opaque, central_dir_ofs, - pState->m_central_dir.m_p, - (size_t)central_dir_size) != central_dir_size) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - pZip->m_archive_size += central_dir_size; - } - - if (pState->m_zip64) { - /* Write zip64 end of central directory header */ - mz_uint64 rel_ofs_to_zip64_ecdr = pZip->m_archive_size; - - MZ_CLEAR_OBJ(hdr); - MZ_WRITE_LE32(hdr + MZ_ZIP64_ECDH_SIG_OFS, - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIG); - MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_SIZE_OF_RECORD_OFS, - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE - sizeof(mz_uint32) - - sizeof(mz_uint64)); - MZ_WRITE_LE16(hdr + MZ_ZIP64_ECDH_VERSION_MADE_BY_OFS, - 0x031E); /* TODO: always Unix */ - MZ_WRITE_LE16(hdr + MZ_ZIP64_ECDH_VERSION_NEEDED_OFS, 0x002D); - MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS, - pZip->m_total_files); - MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_CDIR_TOTAL_ENTRIES_OFS, - pZip->m_total_files); - MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_CDIR_SIZE_OFS, central_dir_size); - MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_CDIR_OFS_OFS, central_dir_ofs); - if (pZip->m_pWrite(pZip->m_pIO_opaque, pZip->m_archive_size, hdr, - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE) != - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - pZip->m_archive_size += MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE; - - /* Write zip64 end of central directory locator */ - MZ_CLEAR_OBJ(hdr); - MZ_WRITE_LE32(hdr + MZ_ZIP64_ECDL_SIG_OFS, - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIG); - MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDL_REL_OFS_TO_ZIP64_ECDR_OFS, - rel_ofs_to_zip64_ecdr); - MZ_WRITE_LE32(hdr + MZ_ZIP64_ECDL_TOTAL_NUMBER_OF_DISKS_OFS, 1); - if (pZip->m_pWrite(pZip->m_pIO_opaque, pZip->m_archive_size, hdr, - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE) != - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - - pZip->m_archive_size += MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE; - } - - /* Write end of central directory record */ - MZ_CLEAR_OBJ(hdr); - MZ_WRITE_LE32(hdr + MZ_ZIP_ECDH_SIG_OFS, - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIG); - MZ_WRITE_LE16(hdr + MZ_ZIP_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS, - MZ_MIN(MZ_UINT16_MAX, pZip->m_total_files)); - MZ_WRITE_LE16(hdr + MZ_ZIP_ECDH_CDIR_TOTAL_ENTRIES_OFS, - MZ_MIN(MZ_UINT16_MAX, pZip->m_total_files)); - MZ_WRITE_LE32(hdr + MZ_ZIP_ECDH_CDIR_SIZE_OFS, - MZ_MIN(MZ_UINT32_MAX, central_dir_size)); - MZ_WRITE_LE32(hdr + MZ_ZIP_ECDH_CDIR_OFS_OFS, - MZ_MIN(MZ_UINT32_MAX, central_dir_ofs)); - - if (pZip->m_pWrite(pZip->m_pIO_opaque, pZip->m_archive_size, hdr, - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) != - MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED); - -#ifndef MINIZ_NO_STDIO - if ((pState->m_pFile) && (MZ_FFLUSH(pState->m_pFile) == EOF)) - return mz_zip_set_error(pZip, MZ_ZIP_FILE_CLOSE_FAILED); -#endif /* #ifndef MINIZ_NO_STDIO */ - - pZip->m_archive_size += MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE; - - pZip->m_zip_mode = MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED; - return MZ_TRUE; -} - -mz_bool mz_zip_writer_finalize_heap_archive(mz_zip_archive *pZip, void **ppBuf, - size_t *pSize) { - if ((!ppBuf) || (!pSize)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - *ppBuf = NULL; - *pSize = 0; - - if ((!pZip) || (!pZip->m_pState)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (pZip->m_pWrite != mz_zip_heap_write_func) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - if (!mz_zip_writer_finalize_archive(pZip)) - return MZ_FALSE; - - *ppBuf = pZip->m_pState->m_pMem; - *pSize = pZip->m_pState->m_mem_size; - pZip->m_pState->m_pMem = NULL; - pZip->m_pState->m_mem_size = pZip->m_pState->m_mem_capacity = 0; - - return MZ_TRUE; -} - -mz_bool mz_zip_writer_end(mz_zip_archive *pZip) { - return mz_zip_writer_end_internal(pZip, MZ_TRUE); -} - -#ifndef MINIZ_NO_STDIO -mz_bool mz_zip_add_mem_to_archive_file_in_place( - const char *pZip_filename, const char *pArchive_name, const void *pBuf, - size_t buf_size, const void *pComment, mz_uint16 comment_size, - mz_uint level_and_flags) { - return mz_zip_add_mem_to_archive_file_in_place_v2( - pZip_filename, pArchive_name, pBuf, buf_size, pComment, comment_size, - level_and_flags, NULL); -} - -mz_bool mz_zip_add_mem_to_archive_file_in_place_v2( - const char *pZip_filename, const char *pArchive_name, const void *pBuf, - size_t buf_size, const void *pComment, mz_uint16 comment_size, - mz_uint level_and_flags, mz_zip_error *pErr) { - mz_bool status, created_new_archive = MZ_FALSE; - mz_zip_archive zip_archive; - struct MZ_FILE_STAT_STRUCT file_stat; - mz_zip_error actual_err = MZ_ZIP_NO_ERROR; - - mz_zip_zero_struct(&zip_archive); - if ((int)level_and_flags < 0) - level_and_flags = MZ_DEFAULT_LEVEL; - - if ((!pZip_filename) || (!pArchive_name) || ((buf_size) && (!pBuf)) || - ((comment_size) && (!pComment)) || - ((level_and_flags & 0xF) > MZ_UBER_COMPRESSION)) { - if (pErr) - *pErr = MZ_ZIP_INVALID_PARAMETER; - return MZ_FALSE; - } - - if (!mz_zip_writer_validate_archive_name(pArchive_name)) { - if (pErr) - *pErr = MZ_ZIP_INVALID_FILENAME; - return MZ_FALSE; - } - - /* Important: The regular non-64 bit version of stat() can fail here if the - * file is very large, which could cause the archive to be overwritten. */ - /* So be sure to compile with _LARGEFILE64_SOURCE 1 */ - if (MZ_FILE_STAT(pZip_filename, &file_stat) != 0) { - /* Create a new archive. */ - if (!mz_zip_writer_init_file_v2(&zip_archive, pZip_filename, 0, - level_and_flags)) { - if (pErr) - *pErr = zip_archive.m_last_error; - return MZ_FALSE; - } - - created_new_archive = MZ_TRUE; - } else { - /* Append to an existing archive. */ - if (!mz_zip_reader_init_file_v2( - &zip_archive, pZip_filename, - level_and_flags | MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY, 0, - 0)) { - if (pErr) - *pErr = zip_archive.m_last_error; - return MZ_FALSE; - } - - if (!mz_zip_writer_init_from_reader_v2(&zip_archive, pZip_filename, - level_and_flags)) { - if (pErr) - *pErr = zip_archive.m_last_error; - - mz_zip_reader_end_internal(&zip_archive, MZ_FALSE); - - return MZ_FALSE; - } - } - - status = - mz_zip_writer_add_mem_ex(&zip_archive, pArchive_name, pBuf, buf_size, - pComment, comment_size, level_and_flags, 0, 0); - actual_err = zip_archive.m_last_error; - - /* Always finalize, even if adding failed for some reason, so we have a valid - * central directory. (This may not always succeed, but we can try.) */ - if (!mz_zip_writer_finalize_archive(&zip_archive)) { - if (!actual_err) - actual_err = zip_archive.m_last_error; - - status = MZ_FALSE; - } - - if (!mz_zip_writer_end_internal(&zip_archive, status)) { - if (!actual_err) - actual_err = zip_archive.m_last_error; - - status = MZ_FALSE; - } - - if ((!status) && (created_new_archive)) { - /* It's a new archive and something went wrong, so just delete it. */ - int ignoredStatus = MZ_DELETE_FILE(pZip_filename); - (void)ignoredStatus; - } - - if (pErr) - *pErr = actual_err; - - return status; -} - -void *mz_zip_extract_archive_file_to_heap_v2(const char *pZip_filename, - const char *pArchive_name, - const char *pComment, - size_t *pSize, mz_uint flags, - mz_zip_error *pErr) { - mz_uint32 file_index; - mz_zip_archive zip_archive; - void *p = NULL; - - if (pSize) - *pSize = 0; - - if ((!pZip_filename) || (!pArchive_name)) { - if (pErr) - *pErr = MZ_ZIP_INVALID_PARAMETER; - - return NULL; - } - - mz_zip_zero_struct(&zip_archive); - if (!mz_zip_reader_init_file_v2( - &zip_archive, pZip_filename, - flags | MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY, 0, 0)) { - if (pErr) - *pErr = zip_archive.m_last_error; - - return NULL; - } - - if (mz_zip_reader_locate_file_v2(&zip_archive, pArchive_name, pComment, flags, - &file_index)) { - p = mz_zip_reader_extract_to_heap(&zip_archive, file_index, pSize, flags); - } - - mz_zip_reader_end_internal(&zip_archive, p != NULL); - - if (pErr) - *pErr = zip_archive.m_last_error; - - return p; -} - -void *mz_zip_extract_archive_file_to_heap(const char *pZip_filename, - const char *pArchive_name, - size_t *pSize, mz_uint flags) { - return mz_zip_extract_archive_file_to_heap_v2(pZip_filename, pArchive_name, - NULL, pSize, flags, NULL); -} - -#endif /* #ifndef MINIZ_NO_STDIO */ - -#endif /* #ifndef MINIZ_NO_ARCHIVE_WRITING_APIS */ - -/* ------------------- Misc utils */ - -mz_zip_mode mz_zip_get_mode(mz_zip_archive *pZip) { - return pZip ? pZip->m_zip_mode : MZ_ZIP_MODE_INVALID; -} - -mz_zip_type mz_zip_get_type(mz_zip_archive *pZip) { - return pZip ? pZip->m_zip_type : MZ_ZIP_TYPE_INVALID; -} - -mz_zip_error mz_zip_set_last_error(mz_zip_archive *pZip, mz_zip_error err_num) { - mz_zip_error prev_err; - - if (!pZip) - return MZ_ZIP_INVALID_PARAMETER; - - prev_err = pZip->m_last_error; - - pZip->m_last_error = err_num; - return prev_err; -} - -mz_zip_error mz_zip_peek_last_error(mz_zip_archive *pZip) { - if (!pZip) - return MZ_ZIP_INVALID_PARAMETER; - - return pZip->m_last_error; -} - -mz_zip_error mz_zip_clear_last_error(mz_zip_archive *pZip) { - return mz_zip_set_last_error(pZip, MZ_ZIP_NO_ERROR); -} - -mz_zip_error mz_zip_get_last_error(mz_zip_archive *pZip) { - mz_zip_error prev_err; - - if (!pZip) - return MZ_ZIP_INVALID_PARAMETER; - - prev_err = pZip->m_last_error; - - pZip->m_last_error = MZ_ZIP_NO_ERROR; - return prev_err; -} - -const char *mz_zip_get_error_string(mz_zip_error mz_err) { - switch (mz_err) { - case MZ_ZIP_NO_ERROR: - return "no error"; - case MZ_ZIP_UNDEFINED_ERROR: - return "undefined error"; - case MZ_ZIP_TOO_MANY_FILES: - return "too many files"; - case MZ_ZIP_FILE_TOO_LARGE: - return "file too large"; - case MZ_ZIP_UNSUPPORTED_METHOD: - return "unsupported method"; - case MZ_ZIP_UNSUPPORTED_ENCRYPTION: - return "unsupported encryption"; - case MZ_ZIP_UNSUPPORTED_FEATURE: - return "unsupported feature"; - case MZ_ZIP_FAILED_FINDING_CENTRAL_DIR: - return "failed finding central directory"; - case MZ_ZIP_NOT_AN_ARCHIVE: - return "not a ZIP archive"; - case MZ_ZIP_INVALID_HEADER_OR_CORRUPTED: - return "invalid header or archive is corrupted"; - case MZ_ZIP_UNSUPPORTED_MULTIDISK: - return "unsupported multidisk archive"; - case MZ_ZIP_DECOMPRESSION_FAILED: - return "decompression failed or archive is corrupted"; - case MZ_ZIP_COMPRESSION_FAILED: - return "compression failed"; - case MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE: - return "unexpected decompressed size"; - case MZ_ZIP_CRC_CHECK_FAILED: - return "CRC-32 check failed"; - case MZ_ZIP_UNSUPPORTED_CDIR_SIZE: - return "unsupported central directory size"; - case MZ_ZIP_ALLOC_FAILED: - return "allocation failed"; - case MZ_ZIP_FILE_OPEN_FAILED: - return "file open failed"; - case MZ_ZIP_FILE_CREATE_FAILED: - return "file create failed"; - case MZ_ZIP_FILE_WRITE_FAILED: - return "file write failed"; - case MZ_ZIP_FILE_READ_FAILED: - return "file read failed"; - case MZ_ZIP_FILE_CLOSE_FAILED: - return "file close failed"; - case MZ_ZIP_FILE_SEEK_FAILED: - return "file seek failed"; - case MZ_ZIP_FILE_STAT_FAILED: - return "file stat failed"; - case MZ_ZIP_INVALID_PARAMETER: - return "invalid parameter"; - case MZ_ZIP_INVALID_FILENAME: - return "invalid filename"; - case MZ_ZIP_BUF_TOO_SMALL: - return "buffer too small"; - case MZ_ZIP_INTERNAL_ERROR: - return "internal error"; - case MZ_ZIP_FILE_NOT_FOUND: - return "file not found"; - case MZ_ZIP_ARCHIVE_TOO_LARGE: - return "archive is too large"; - case MZ_ZIP_VALIDATION_FAILED: - return "validation failed"; - case MZ_ZIP_WRITE_CALLBACK_FAILED: - return "write callback failed"; - case MZ_ZIP_TOTAL_ERRORS: - return "total errors"; - default: - break; - } - - return "unknown error"; -} - -/* Note: Just because the archive is not zip64 doesn't necessarily mean it - * doesn't have Zip64 extended information extra field, argh. */ -mz_bool mz_zip_is_zip64(mz_zip_archive *pZip) { - if ((!pZip) || (!pZip->m_pState)) - return MZ_FALSE; - - return pZip->m_pState->m_zip64; -} - -size_t mz_zip_get_central_dir_size(mz_zip_archive *pZip) { - if ((!pZip) || (!pZip->m_pState)) - return 0; - - return pZip->m_pState->m_central_dir.m_size; -} - -mz_uint mz_zip_reader_get_num_files(mz_zip_archive *pZip) { - return pZip ? pZip->m_total_files : 0; -} - -mz_uint64 mz_zip_get_archive_size(mz_zip_archive *pZip) { - if (!pZip) - return 0; - return pZip->m_archive_size; -} - -mz_uint64 mz_zip_get_archive_file_start_offset(mz_zip_archive *pZip) { - if ((!pZip) || (!pZip->m_pState)) - return 0; - return pZip->m_pState->m_file_archive_start_ofs; -} - -MZ_FILE *mz_zip_get_cfile(mz_zip_archive *pZip) { - if ((!pZip) || (!pZip->m_pState)) - return 0; - return pZip->m_pState->m_pFile; -} - -size_t mz_zip_read_archive_data(mz_zip_archive *pZip, mz_uint64 file_ofs, - void *pBuf, size_t n) { - if ((!pZip) || (!pZip->m_pState) || (!pBuf) || (!pZip->m_pRead)) - return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - - return pZip->m_pRead(pZip->m_pIO_opaque, file_ofs, pBuf, n); -} - -mz_uint mz_zip_reader_get_filename(mz_zip_archive *pZip, mz_uint file_index, - char *pFilename, mz_uint filename_buf_size) { - mz_uint n; - const mz_uint8 *p = mz_zip_get_cdh(pZip, file_index); - if (!p) { - if (filename_buf_size) - pFilename[0] = '\0'; - mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER); - return 0; - } - n = MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS); - if (filename_buf_size) { - n = MZ_MIN(n, filename_buf_size - 1); - memcpy(pFilename, p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE, n); - pFilename[n] = '\0'; - } - return n + 1; -} - -mz_bool mz_zip_reader_file_stat(mz_zip_archive *pZip, mz_uint file_index, - mz_zip_archive_file_stat *pStat) { - return mz_zip_file_stat_internal( - pZip, file_index, mz_zip_get_cdh(pZip, file_index), pStat, NULL); -} - -mz_bool mz_zip_end(mz_zip_archive *pZip) { - if (!pZip) - return MZ_FALSE; - - if (pZip->m_zip_mode == MZ_ZIP_MODE_READING) - return mz_zip_reader_end(pZip); -#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS - else if ((pZip->m_zip_mode == MZ_ZIP_MODE_WRITING) || - (pZip->m_zip_mode == MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED)) - return mz_zip_writer_end(pZip); -#endif - - return MZ_FALSE; -} - -#ifdef __cplusplus -} -#endif - -#endif /*#ifndef MINIZ_NO_ARCHIVE_APIS*/ diff --git a/src/plugins/pixelart/src/deps/zip/src/zip.c b/src/plugins/pixelart/src/deps/zip/src/zip.c deleted file mode 100644 index 128974b7..00000000 --- a/src/plugins/pixelart/src/deps/zip/src/zip.c +++ /dev/null @@ -1,1913 +0,0 @@ -/* - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR - * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, - * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - */ -#define __STDC_WANT_LIB_EXT1__ 1 - -#ifdef FIZZY_ZIP_WASM -#include "../fizzy_zip_wasm.h" -#define STRCLONE(STR) ((STR) ? fizzy_strdup(STR) : NULL) -#else -#include -#include -#include - -#if defined(_WIN32) || defined(__WIN32__) || defined(_MSC_VER) || \ - defined(__MINGW32__) -/* Win32, DOS, MSVC, MSVS */ -#include - -#define STRCLONE(STR) ((STR) ? _strdup(STR) : NULL) -#define HAS_DEVICE(P) \ - ((((P)[0] >= 'A' && (P)[0] <= 'Z') || ((P)[0] >= 'a' && (P)[0] <= 'z')) && \ - (P)[1] == ':') -#define FILESYSTEM_PREFIX_LEN(P) (HAS_DEVICE(P) ? 2 : 0) - -#else - -#include // needed for symlink() -#define STRCLONE(STR) ((STR) ? strdup(STR) : NULL) - -#endif - -#ifdef __MINGW32__ -#include -#include -#endif - -#include "miniz.h" -#endif -#include "zip.h" - -#ifdef _MSC_VER -#include - -#define ftruncate(fd, sz) (-(_chsize_s((fd), (sz)) != 0)) -#define fileno _fileno -#endif - -#if defined(__TINYC__) && (defined(_WIN32) || defined(_WIN64)) -#include - -#define ftruncate(fd, sz) (-(_chsize_s((fd), (sz)) != 0)) -#define fileno _fileno -#endif - -#ifndef HAS_DEVICE -#define HAS_DEVICE(P) 0 -#endif - -#ifndef FILESYSTEM_PREFIX_LEN -#define FILESYSTEM_PREFIX_LEN(P) 0 -#endif - -#ifndef ISSLASH -#define ISSLASH(C) ((C) == '/' || (C) == '\\') -#endif - -#define CLEANUP(ptr) \ - do { \ - if (ptr) { \ - free((void *)ptr); \ - ptr = NULL; \ - } \ - } while (0) - -#define UNX_IFDIR 0040000 /* Unix directory */ -#define UNX_IFREG 0100000 /* Unix regular file */ -#define UNX_IFSOCK 0140000 /* Unix socket (BSD, not SysV or Amiga) */ -#define UNX_IFLNK 0120000 /* Unix symbolic link (not SysV, Amiga) */ -#define UNX_IFBLK 0060000 /* Unix block special (not Amiga) */ -#define UNX_IFCHR 0020000 /* Unix character special (not Amiga) */ -#define UNX_IFIFO 0010000 /* Unix fifo (BCC, not MSC or Amiga) */ - -struct zip_entry_t { - ssize_t index; - char *name; - mz_uint64 uncomp_size; - mz_uint64 comp_size; - mz_uint32 uncomp_crc32; - mz_uint64 offset; - mz_uint8 header[MZ_ZIP_LOCAL_DIR_HEADER_SIZE]; - mz_uint64 header_offset; - mz_uint16 method; - mz_zip_writer_add_state state; - tdefl_compressor comp; - mz_uint32 external_attr; -#ifdef MINIZ_NO_TIME - mz_uint32 m_time; -#else - time_t m_time; -#endif -}; - -struct zip_t { - mz_zip_archive archive; - mz_uint level; - struct zip_entry_t entry; -}; - -enum zip_modify_t { - MZ_KEEP = 0, - MZ_DELETE = 1, - MZ_MOVE = 2, -}; - -struct zip_entry_mark_t { - ssize_t file_index; - enum zip_modify_t type; - mz_uint64 m_local_header_ofs; - size_t lf_length; -}; - -static const char *const zip_errlist[30] = { - NULL, - "not initialized\0", - "invalid entry name\0", - "entry not found\0", - "invalid zip mode\0", - "invalid compression level\0", - "no zip 64 support\0", - "memset error\0", - "cannot write data to entry\0", - "cannot initialize tdefl compressor\0", - "invalid index\0", - "header not found\0", - "cannot flush tdefl buffer\0", - "cannot write entry header\0", - "cannot create entry header\0", - "cannot write to central dir\0", - "cannot open file\0", - "invalid entry type\0", - "extracting data using no memory allocation\0", - "file not found\0", - "no permission\0", - "out of memory\0", - "invalid zip archive name\0", - "make dir error\0", - "symlink error\0", - "close archive error\0", - "capacity size too small\0", - "fseek error\0", - "fread error\0", - "fwrite error\0", -}; - -const char *zip_strerror(int errnum) { - errnum = -errnum; - if (errnum <= 0 || errnum >= 30) { - return NULL; - } - - return zip_errlist[errnum]; -} - -#ifndef FIZZY_ZIP_WASM - -static const char *zip_basename(const char *name) { - char const *p; - char const *base = name += FILESYSTEM_PREFIX_LEN(name); - int all_slashes = 1; - - for (p = name; *p; p++) { - if (ISSLASH(*p)) - base = p + 1; - else - all_slashes = 0; - } - - /* If NAME is all slashes, arrange to return `/'. */ - if (*base == '\0' && ISSLASH(*name) && all_slashes) - --base; - - return base; -} - -static int zip_mkpath(char *path) { - char *p; - char npath[MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE + 1]; - int len = 0; - int has_device = HAS_DEVICE(path); - - memset(npath, 0, MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE + 1); - if (has_device) { - // only on windows - npath[0] = path[0]; - npath[1] = path[1]; - len = 2; - } - for (p = path + len; *p && len < MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE; p++) { - if (ISSLASH(*p) && ((!has_device && len > 0) || (has_device && len > 2))) { -#if defined(_WIN32) || defined(__WIN32__) || defined(_MSC_VER) || \ - defined(__MINGW32__) -#else - if ('\\' == *p) { - *p = '/'; - } -#endif - - if (MZ_MKDIR(npath) == -1) { - if (errno != EEXIST) { - return ZIP_EMKDIR; - } - } - } - npath[len++] = *p; - } - - return 0; -} - -static char *zip_strrpl(const char *str, size_t n, char oldchar, char newchar) { - char c; - size_t i; - char *rpl = (char *)calloc((1 + n), sizeof(char)); - char *begin = rpl; - if (!rpl) { - return NULL; - } - - for (i = 0; (i < n) && (c = *str++); ++i) { - if (c == oldchar) { - c = newchar; - } - *rpl++ = c; - } - - return begin; -} - -static char *zip_name_normalize(char *name, char *const nname, size_t len) { - size_t offn = 0; - size_t offnn = 0, ncpy = 0; - - if (name == NULL || nname == NULL || len <= 0) { - return NULL; - } - // skip trailing '/' - while (ISSLASH(*name)) - name++; - - for (; offn < len; offn++) { - if (ISSLASH(name[offn])) { - if (ncpy > 0 && strcmp(&nname[offnn], ".\0") && - strcmp(&nname[offnn], "..\0")) { - offnn += ncpy; - nname[offnn++] = name[offn]; // append '/' - } - ncpy = 0; - } else { - nname[offnn + ncpy] = name[offn]; - ncpy++; - } - } - - // at the end, extra check what we've already copied - if (ncpy == 0 || !strcmp(&nname[offnn], ".\0") || - !strcmp(&nname[offnn], "..\0")) { - nname[offnn] = 0; - } - return nname; -} - -static mz_bool zip_name_match(const char *name1, const char *name2) { - char *nname2 = NULL; - -#ifdef ZIP_RAW_ENTRYNAME - nname2 = STRCLONE(name2); -#else - nname2 = zip_strrpl(name2, strlen(name2), '\\', '/'); -#endif - - if (!nname2) { - return MZ_FALSE; - } - - mz_bool res = (strcmp(name1, nname2) == 0) ? MZ_TRUE : MZ_FALSE; - CLEANUP(nname2); - return res; -} - -#endif /* !FIZZY_ZIP_WASM */ - -static int zip_archive_truncate(mz_zip_archive *pzip) { -#ifdef FIZZY_ZIP_WASM - (void)pzip; - return 0; -#else - mz_zip_internal_state *pState = pzip->m_pState; - mz_uint64 file_size = pzip->m_archive_size; - if ((pzip->m_pWrite == mz_zip_heap_write_func) && (pState->m_pMem)) { - return 0; - } - if (pzip->m_zip_mode == MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED) { - if (pState->m_pFile) { - int fd = fileno(pState->m_pFile); - return ftruncate(fd, file_size); - } - } - return 0; -#endif -} - -#ifndef FIZZY_ZIP_WASM - -static int zip_archive_extract(mz_zip_archive *zip_archive, const char *dir, - int (*on_extract)(const char *filename, - void *arg), - void *arg) { - int err = 0; - mz_uint i, n; - char path[MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE + 1]; - char symlink_to[MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE + 1]; - mz_zip_archive_file_stat info; - size_t dirlen = 0, filename_size = MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE; - mz_uint32 xattr = 0; - - memset(path, 0, sizeof(path)); - memset(symlink_to, 0, sizeof(symlink_to)); - - dirlen = strlen(dir); - if (dirlen + 1 > MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE) { - return ZIP_EINVENTNAME; - } - - memset((void *)&info, 0, sizeof(mz_zip_archive_file_stat)); - -#if defined(_MSC_VER) - strcpy_s(path, MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE, dir); -#else - strcpy(path, dir); -#endif - - if (!ISSLASH(path[dirlen - 1])) { -#if defined(_WIN32) || defined(__WIN32__) - path[dirlen] = '\\'; -#else - path[dirlen] = '/'; -#endif - ++dirlen; - } - - if (filename_size > MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE - dirlen) { - filename_size = MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE - dirlen; - } - // Get and print information about each file in the archive. - n = mz_zip_reader_get_num_files(zip_archive); - for (i = 0; i < n; ++i) { - if (!mz_zip_reader_file_stat(zip_archive, i, &info)) { - // Cannot get information about zip archive; - err = ZIP_ENOENT; - goto out; - } - - if (!zip_name_normalize(info.m_filename, info.m_filename, - strlen(info.m_filename))) { - // Cannot normalize file name; - err = ZIP_EINVENTNAME; - goto out; - } - -#if defined(_MSC_VER) - strncpy_s(&path[dirlen], filename_size, info.m_filename, - filename_size); -#else - strncpy(&path[dirlen], info.m_filename, filename_size); -#endif - err = zip_mkpath(path); - if (err < 0) { - // Cannot make a path - goto out; - } - - if ((((info.m_version_made_by >> 8) == 3) || - ((info.m_version_made_by >> 8) == - 19)) // if zip is produced on Unix or macOS (3 and 19 from - // section 4.4.2.2 of zip standard) - && info.m_external_attr & - (0x20 << 24)) { // and has sym link attribute (0x80 is file, 0x40 - // is directory) -#if defined(_WIN32) || defined(__WIN32__) || defined(_MSC_VER) || \ - defined(__MINGW32__) -#else - if (info.m_uncomp_size > MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE || - !mz_zip_reader_extract_to_mem_no_alloc(zip_archive, i, symlink_to, - MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE, 0, NULL, 0)) { - err = ZIP_EMEMNOALLOC; - goto out; - } - symlink_to[info.m_uncomp_size] = '\0'; - if (symlink(symlink_to, path) != 0) { - err = ZIP_ESYMLINK; - goto out; - } -#endif - } else { - if (!mz_zip_reader_is_file_a_directory(zip_archive, i)) { - if (!mz_zip_reader_extract_to_file(zip_archive, i, path, 0)) { - // Cannot extract zip archive to file - err = ZIP_ENOFILE; - goto out; - } - } - -#if defined(_MSC_VER) || defined(PS4) - (void)xattr; // unused -#else - xattr = (info.m_external_attr >> 16) & 0xFFFF; - if (xattr > 0 && xattr <= MZ_UINT16_MAX) { - if (CHMOD(path, (mode_t)xattr) < 0) { - err = ZIP_ENOPERM; - goto out; - } - } -#endif - } - - if (on_extract) { - if (on_extract(path, arg) < 0) { - goto out; - } - } - } - -out: - // Close the archive, freeing any resources it was using - if (!mz_zip_reader_end(zip_archive)) { - // Cannot end zip reader - err = ZIP_ECLSZIP; - } - return err; -} - -#endif /* !FIZZY_ZIP_WASM */ - -static inline void zip_archive_finalize(mz_zip_archive *pzip) { - mz_zip_writer_finalize_archive(pzip); - zip_archive_truncate(pzip); -} - -#ifndef FIZZY_ZIP_WASM - -static ssize_t zip_entry_mark(struct zip_t *zip, - struct zip_entry_mark_t *entry_mark, - const ssize_t n, char *const entries[], - const size_t len) { - ssize_t i = 0; - ssize_t err = 0; - if (!zip || !entry_mark || !entries) { - return ZIP_ENOINIT; - } - - mz_zip_archive_file_stat file_stat; - mz_uint64 d_pos = UINT64_MAX; - for (i = 0; i < n; ++i) { - if ((err = zip_entry_openbyindex(zip, i))) { - return (ssize_t)err; - } - - mz_bool name_matches = MZ_FALSE; - { - size_t j; - for (j = 0; j < len; ++j) { - if (zip_name_match(zip->entry.name, entries[j])) { - name_matches = MZ_TRUE; - break; - } - } - } - if (name_matches) { - entry_mark[i].type = MZ_DELETE; - } else { - entry_mark[i].type = MZ_KEEP; - } - - if (!mz_zip_reader_file_stat(&zip->archive, i, &file_stat)) { - return ZIP_ENOENT; - } - - zip_entry_close(zip); - - entry_mark[i].m_local_header_ofs = file_stat.m_local_header_ofs; - entry_mark[i].file_index = (ssize_t)-1; - entry_mark[i].lf_length = 0; - if ((entry_mark[i].type) == MZ_DELETE && - (d_pos > entry_mark[i].m_local_header_ofs)) { - d_pos = entry_mark[i].m_local_header_ofs; - } - } - - for (i = 0; i < n; ++i) { - if ((entry_mark[i].m_local_header_ofs > d_pos) && - (entry_mark[i].type != MZ_DELETE)) { - entry_mark[i].type = MZ_MOVE; - } - } - return err; -} - -static ssize_t zip_index_next(mz_uint64 *local_header_ofs_array, - ssize_t cur_index) { - ssize_t new_index = 0, i; - for (i = cur_index - 1; i >= 0; --i) { - if (local_header_ofs_array[cur_index] > local_header_ofs_array[i]) { - new_index = i + 1; - return new_index; - } - } - return new_index; -} - -static ssize_t zip_sort(mz_uint64 *local_header_ofs_array, ssize_t cur_index) { - ssize_t nxt_index = zip_index_next(local_header_ofs_array, cur_index); - - if (nxt_index != cur_index) { - mz_uint64 temp = local_header_ofs_array[cur_index]; - ssize_t i; - for (i = cur_index; i > nxt_index; i--) { - local_header_ofs_array[i] = local_header_ofs_array[i - 1]; - } - local_header_ofs_array[nxt_index] = temp; - } - return nxt_index; -} - -static int zip_index_update(struct zip_entry_mark_t *entry_mark, - ssize_t last_index, ssize_t nxt_index) { - ssize_t j; - for (j = 0; j < last_index; j++) { - if (entry_mark[j].file_index >= nxt_index) { - entry_mark[j].file_index += 1; - } - } - entry_mark[nxt_index].file_index = last_index; - return 0; -} - -static int zip_entry_finalize(struct zip_t *zip, - struct zip_entry_mark_t *entry_mark, - const ssize_t n) { - - ssize_t i = 0; - mz_uint64 *local_header_ofs_array = (mz_uint64 *)calloc(n, sizeof(mz_uint64)); - if (!local_header_ofs_array) { - return ZIP_EOOMEM; - } - - for (i = 0; i < n; ++i) { - local_header_ofs_array[i] = entry_mark[i].m_local_header_ofs; - ssize_t index = zip_sort(local_header_ofs_array, i); - - if (index != i) { - zip_index_update(entry_mark, i, index); - } - entry_mark[i].file_index = index; - } - - size_t *length = (size_t *)calloc(n, sizeof(size_t)); - if (!length) { - CLEANUP(local_header_ofs_array); - return ZIP_EOOMEM; - } - for (i = 0; i < n - 1; i++) { - length[i] = - (size_t)(local_header_ofs_array[i + 1] - local_header_ofs_array[i]); - } - length[n - 1] = - (size_t)(zip->archive.m_archive_size - local_header_ofs_array[n - 1]); - - for (i = 0; i < n; i++) { - entry_mark[i].lf_length = length[entry_mark[i].file_index]; - } - - CLEANUP(length); - CLEANUP(local_header_ofs_array); - return 0; -} - -static ssize_t zip_entry_set(struct zip_t *zip, - struct zip_entry_mark_t *entry_mark, ssize_t n, - char *const entries[], const size_t len) { - ssize_t err = 0; - - if ((err = zip_entry_mark(zip, entry_mark, n, entries, len)) < 0) { - return err; - } - if ((err = zip_entry_finalize(zip, entry_mark, n)) < 0) { - return err; - } - return 0; -} - -static ssize_t zip_file_move(MZ_FILE *m_pFile, const mz_uint64 to, - const mz_uint64 from, const size_t length, - mz_uint8 *move_buf, const size_t capacity_size) { - if (length > capacity_size) { - return ZIP_ECAPSIZE; - } - if (MZ_FSEEK64(m_pFile, from, SEEK_SET)) { - return ZIP_EFSEEK; - } - if (fread(move_buf, 1, length, m_pFile) != length) { - return ZIP_EFREAD; - } - if (MZ_FSEEK64(m_pFile, to, SEEK_SET)) { - return ZIP_EFSEEK; - } - if (fwrite(move_buf, 1, length, m_pFile) != length) { - return ZIP_EFWRITE; - } - return (ssize_t)length; -} - -static ssize_t zip_files_move(MZ_FILE *m_pFile, mz_uint64 writen_num, - mz_uint64 read_num, size_t length) { - ssize_t n = 0; - const size_t page_size = 1 << 12; // 4K - mz_uint8 *move_buf = (mz_uint8 *)calloc(1, page_size); - if (!move_buf) { - return ZIP_EOOMEM; - } - - ssize_t moved_length = 0; - ssize_t move_count = 0; - while ((mz_int64)length > 0) { - move_count = (length >= page_size) ? page_size : length; - n = zip_file_move(m_pFile, writen_num, read_num, move_count, move_buf, - page_size); - if (n < 0) { - moved_length = n; - goto cleanup; - } - - if (n != move_count) { - goto cleanup; - } - - writen_num += move_count; - read_num += move_count; - length -= move_count; - moved_length += move_count; - } - -cleanup: - CLEANUP(move_buf); - return moved_length; -} - -static int zip_central_dir_move(mz_zip_internal_state *pState, int begin, - int end, int entry_num) { - if (begin == entry_num) { - return 0; - } - - size_t l_size = 0; - size_t r_size = 0; - mz_uint32 d_size = 0; - mz_uint8 *next = NULL; - mz_uint8 *deleted = &MZ_ZIP_ARRAY_ELEMENT( - &pState->m_central_dir, mz_uint8, - MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, begin)); - l_size = (size_t)(deleted - (mz_uint8 *)(pState->m_central_dir.m_p)); - if (end == entry_num) { - r_size = 0; - } else { - next = &MZ_ZIP_ARRAY_ELEMENT( - &pState->m_central_dir, mz_uint8, - MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, end)); - r_size = pState->m_central_dir.m_size - - (mz_uint32)(next - (mz_uint8 *)(pState->m_central_dir.m_p)); - d_size = (mz_uint32)(next - deleted); - } - - if (next && l_size == 0) { - memmove(pState->m_central_dir.m_p, next, r_size); - pState->m_central_dir.m_p = MZ_REALLOC(pState->m_central_dir.m_p, r_size); - { - int i; - for (i = end; i < entry_num; i++) { - MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, i) -= - d_size; - } - } - } - - if (next && l_size * r_size != 0) { - memmove(deleted, next, r_size); - { - int i; - for (i = end; i < entry_num; i++) { - MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, i) -= - d_size; - } - } - } - - pState->m_central_dir.m_size = l_size + r_size; - return 0; -} - -static int zip_central_dir_delete(mz_zip_internal_state *pState, - int *deleted_entry_index_array, - int entry_num) { - int i = 0; - int begin = 0; - int end = 0; - int d_num = 0; - while (i < entry_num) { - while ((i < entry_num) && (!deleted_entry_index_array[i])) { - i++; - } - begin = i; - - while ((i < entry_num) && (deleted_entry_index_array[i])) { - i++; - } - end = i; - zip_central_dir_move(pState, begin, end, entry_num); - } - - i = 0; - while (i < entry_num) { - while ((i < entry_num) && (!deleted_entry_index_array[i])) { - i++; - } - begin = i; - if (begin == entry_num) { - break; - } - while ((i < entry_num) && (deleted_entry_index_array[i])) { - i++; - } - end = i; - int k = 0, j; - for (j = end; j < entry_num; j++) { - MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, - begin + k) = - (mz_uint32)MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, - mz_uint32, j); - k++; - } - d_num += end - begin; - } - - pState->m_central_dir_offsets.m_size = - sizeof(mz_uint32) * (entry_num - d_num); - return 0; -} - -static ssize_t zip_entries_delete_mark(struct zip_t *zip, - struct zip_entry_mark_t *entry_mark, - int entry_num) { - mz_uint64 writen_num = 0; - mz_uint64 read_num = 0; - size_t deleted_length = 0; - size_t move_length = 0; - int i = 0; - size_t deleted_entry_num = 0; - ssize_t n = 0; - - mz_bool *deleted_entry_flag_array = - (mz_bool *)calloc(entry_num, sizeof(mz_bool)); - if (deleted_entry_flag_array == NULL) { - return ZIP_EOOMEM; - } - - mz_zip_internal_state *pState = zip->archive.m_pState; - zip->archive.m_zip_mode = MZ_ZIP_MODE_WRITING; - - if ((!pState->m_pFile) || MZ_FSEEK64(pState->m_pFile, 0, SEEK_SET)) { - CLEANUP(deleted_entry_flag_array); - return ZIP_ENOENT; - } - - while (i < entry_num) { - while ((i < entry_num) && (entry_mark[i].type == MZ_KEEP)) { - writen_num += entry_mark[i].lf_length; - read_num = writen_num; - i++; - } - - while ((i < entry_num) && (entry_mark[i].type == MZ_DELETE)) { - deleted_entry_flag_array[i] = MZ_TRUE; - read_num += entry_mark[i].lf_length; - deleted_length += entry_mark[i].lf_length; - i++; - deleted_entry_num++; - } - - while ((i < entry_num) && (entry_mark[i].type == MZ_MOVE)) { - move_length += entry_mark[i].lf_length; - mz_uint8 *p = &MZ_ZIP_ARRAY_ELEMENT( - &pState->m_central_dir, mz_uint8, - MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, i)); - if (!p) { - CLEANUP(deleted_entry_flag_array); - return ZIP_ENOENT; - } - mz_uint32 offset = MZ_READ_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS); - offset -= (mz_uint32)deleted_length; - MZ_WRITE_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS, offset); - i++; - } - - n = zip_files_move(pState->m_pFile, writen_num, read_num, move_length); - if (n != (ssize_t)move_length) { - CLEANUP(deleted_entry_flag_array); - return n; - } - writen_num += move_length; - read_num += move_length; - } - - zip->archive.m_archive_size -= (mz_uint64)deleted_length; - zip->archive.m_total_files = - (mz_uint32)entry_num - (mz_uint32)deleted_entry_num; - - zip_central_dir_delete(pState, deleted_entry_flag_array, entry_num); - CLEANUP(deleted_entry_flag_array); - - return (ssize_t)deleted_entry_num; -} - -struct zip_t *zip_open(const char *zipname, int level, char mode) { - struct zip_t *zip = NULL; - - if (!zipname || strlen(zipname) < 1) { - // zip_t archive name is empty or NULL - goto cleanup; - } - - if (level < 0) - level = MZ_DEFAULT_LEVEL; - if ((level & 0xF) > MZ_UBER_COMPRESSION) { - // Wrong compression level - goto cleanup; - } - - zip = (struct zip_t *)calloc((size_t)1, sizeof(struct zip_t)); - if (!zip) - goto cleanup; - - zip->level = (mz_uint)level; - switch (mode) { - case 'w': - // Create a new archive. - if (!mz_zip_writer_init_file_v2(&(zip->archive), zipname, 0, - MZ_ZIP_FLAG_WRITE_ZIP64)) { - // Cannot initialize zip_archive writer - goto cleanup; - } - break; - - case 'r': - if (!mz_zip_reader_init_file_v2( - &(zip->archive), zipname, - zip->level | MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY, 0, 0)) { - // An archive file does not exist or cannot initialize - // zip_archive reader - goto cleanup; - } - break; - - case 'a': - case 'd': - if (!mz_zip_reader_init_file_v2_rpb( - &(zip->archive), zipname, - zip->level | MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY, 0, 0)) { - // An archive file does not exist or cannot initialize - // zip_archive reader - goto cleanup; - } - if ((mode == 'a' || mode == 'd')) { - if (!mz_zip_writer_init_from_reader_v2_noreopen(&(zip->archive), zipname, - 0)) { - mz_zip_reader_end(&(zip->archive)); - goto cleanup; - } - } - break; - - default: - goto cleanup; - } - - return zip; - -cleanup: - CLEANUP(zip); - return NULL; -} - -void zip_close(struct zip_t *zip) { - if (zip) { - // Always finalize, even if adding failed for some reason, so we have a - // valid central directory. - mz_zip_writer_finalize_archive(&(zip->archive)); - zip_archive_truncate(&(zip->archive)); - mz_zip_writer_end(&(zip->archive)); - mz_zip_reader_end(&(zip->archive)); - - CLEANUP(zip); - } -} - -int zip_is64(struct zip_t *zip) { - if (!zip || !zip->archive.m_pState) { - // zip_t handler or zip state is not initialized - return ZIP_ENOINIT; - } - - return (int)zip->archive.m_pState->m_zip64; -} - -#endif /* !FIZZY_ZIP_WASM */ - -static int _zip_entry_open(struct zip_t *zip, const char *entryname, - int case_sensitive) { - size_t entrylen = 0; - mz_zip_archive *pzip = NULL; - mz_uint num_alignment_padding_bytes, level; - mz_zip_archive_file_stat stats; - int err = 0; - mz_uint16 dos_time = 0, dos_date = 0; - mz_uint32 extra_size = 0; - mz_uint8 extra_data[MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE]; - mz_uint64 local_dir_header_ofs = 0; - - if (!zip) { - return ZIP_ENOINIT; - } - - local_dir_header_ofs = zip->archive.m_archive_size; - - if (!entryname) { - return ZIP_EINVENTNAME; - } - - entrylen = strlen(entryname); - if (entrylen == 0) { - return ZIP_EINVENTNAME; - } - - /* - .ZIP File Format Specification Version: 6.3.3 - - 4.4.17.1 The name of the file, with optional relative path. - The path stored MUST not contain a drive or - device letter, or a leading slash. All slashes - MUST be forward slashes '/' as opposed to - backwards slashes '\' for compatibility with Amiga - and UNIX file systems etc. If input came from standard - input, there is no file name field. - */ - if (zip->entry.name) { - CLEANUP(zip->entry.name); - } -#ifdef ZIP_RAW_ENTRYNAME - zip->entry.name = STRCLONE(entryname); -#else - zip->entry.name = zip_strrpl(entryname, entrylen, '\\', '/'); -#endif - - if (!zip->entry.name) { - // Cannot parse zip entry name - return ZIP_EINVENTNAME; - } - - pzip = &(zip->archive); - if (pzip->m_zip_mode == MZ_ZIP_MODE_READING) { - zip->entry.index = (ssize_t)mz_zip_reader_locate_file( - pzip, zip->entry.name, NULL, - case_sensitive ? MZ_ZIP_FLAG_CASE_SENSITIVE : 0); - if (zip->entry.index < (ssize_t)0) { - err = ZIP_ENOENT; - goto cleanup; - } - - if (!mz_zip_reader_file_stat(pzip, (mz_uint)zip->entry.index, &stats)) { - err = ZIP_ENOENT; - goto cleanup; - } - - zip->entry.comp_size = stats.m_comp_size; - zip->entry.uncomp_size = stats.m_uncomp_size; - zip->entry.uncomp_crc32 = stats.m_crc32; - zip->entry.offset = stats.m_central_dir_ofs; - zip->entry.header_offset = stats.m_local_header_ofs; - zip->entry.method = stats.m_method; - zip->entry.external_attr = stats.m_external_attr; -#ifndef MINIZ_NO_TIME - zip->entry.m_time = stats.m_time; -#endif - - return 0; - } - - level = zip->level & 0xF; - - zip->entry.index = (ssize_t)zip->archive.m_total_files; - zip->entry.comp_size = 0; - zip->entry.uncomp_size = 0; - zip->entry.uncomp_crc32 = MZ_CRC32_INIT; - zip->entry.offset = zip->archive.m_archive_size; - zip->entry.header_offset = zip->archive.m_archive_size; - memset(zip->entry.header, 0, MZ_ZIP_LOCAL_DIR_HEADER_SIZE * sizeof(mz_uint8)); - zip->entry.method = level ? MZ_DEFLATED : 0; - - // UNIX or APPLE -#if MZ_PLATFORM == 3 || MZ_PLATFORM == 19 - // regular file with rw-r--r-- permissions - zip->entry.external_attr = (mz_uint32)(0100644) << 16; -#else - zip->entry.external_attr = 0; -#endif - - num_alignment_padding_bytes = - mz_zip_writer_compute_padding_needed_for_file_alignment(pzip); - - if (!pzip->m_pState || (pzip->m_zip_mode != MZ_ZIP_MODE_WRITING)) { - // Invalid zip mode - err = ZIP_EINVMODE; - goto cleanup; - } - if (zip->level & MZ_ZIP_FLAG_COMPRESSED_DATA) { - // Invalid zip compression level - err = ZIP_EINVLVL; - goto cleanup; - } - - if (!mz_zip_writer_write_zeros(pzip, zip->entry.offset, - num_alignment_padding_bytes)) { - // Cannot memset zip entry header - err = ZIP_EMEMSET; - goto cleanup; - } - local_dir_header_ofs += num_alignment_padding_bytes; - -#ifndef MINIZ_NO_TIME - zip->entry.m_time = time(NULL); - mz_zip_time_t_to_dos_time(zip->entry.m_time, &dos_time, &dos_date); -#endif - - // ZIP64 header with NULL sizes (sizes will be in the data descriptor, just - // after file data) - extra_size = mz_zip_writer_create_zip64_extra_data( - extra_data, NULL, NULL, - (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs : NULL); - - if (!mz_zip_writer_create_local_dir_header( - pzip, zip->entry.header, entrylen, (mz_uint16)extra_size, 0, 0, 0, - zip->entry.method, - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8 | - MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR, - dos_time, dos_date)) { - // Cannot create zip entry header - err = ZIP_EMEMSET; - goto cleanup; - } - - zip->entry.header_offset = zip->entry.offset + num_alignment_padding_bytes; - - if (pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.header_offset, - zip->entry.header, - sizeof(zip->entry.header)) != sizeof(zip->entry.header)) { - // Cannot write zip entry header - err = ZIP_EMEMSET; - goto cleanup; - } - - if (pzip->m_file_offset_alignment) { - MZ_ASSERT( - (zip->entry.header_offset & (pzip->m_file_offset_alignment - 1)) == 0); - } - zip->entry.offset += num_alignment_padding_bytes + sizeof(zip->entry.header); - - if (pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.offset, zip->entry.name, - entrylen) != entrylen) { - // Cannot write data to zip entry - err = ZIP_EWRTENT; - goto cleanup; - } - - zip->entry.offset += entrylen; - - if (pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.offset, extra_data, - extra_size) != extra_size) { - // Cannot write ZIP64 data to zip entry - err = ZIP_EWRTENT; - goto cleanup; - } - zip->entry.offset += extra_size; - - if (level) { - zip->entry.state.m_pZip = pzip; - zip->entry.state.m_cur_archive_file_ofs = zip->entry.offset; - zip->entry.state.m_comp_size = 0; - - if (tdefl_init(&(zip->entry.comp), mz_zip_writer_add_put_buf_callback, - &(zip->entry.state), - (int)tdefl_create_comp_flags_from_zip_params( - (int)level, -15, MZ_DEFAULT_STRATEGY)) != - TDEFL_STATUS_OKAY) { - // Cannot initialize the zip compressor - err = ZIP_ETDEFLINIT; - goto cleanup; - } - } - - return 0; - -cleanup: - CLEANUP(zip->entry.name); - return err; -} - -int zip_entry_open(struct zip_t *zip, const char *entryname) { - return _zip_entry_open(zip, entryname, 0); -} - -int zip_entry_opencasesensitive(struct zip_t *zip, const char *entryname) { - return _zip_entry_open(zip, entryname, 1); -} - -int zip_entry_openbyindex(struct zip_t *zip, size_t index) { - mz_zip_archive *pZip = NULL; - mz_zip_archive_file_stat stats; - mz_uint namelen; - const mz_uint8 *pHeader; - const char *pFilename; - - if (!zip) { - // zip_t handler is not initialized - return ZIP_ENOINIT; - } - - pZip = &(zip->archive); - if (pZip->m_zip_mode != MZ_ZIP_MODE_READING) { - // open by index requires readonly mode - return ZIP_EINVMODE; - } - - if (index >= (size_t)pZip->m_total_files) { - // index out of range - return ZIP_EINVIDX; - } - - if (!(pHeader = &MZ_ZIP_ARRAY_ELEMENT( - &pZip->m_pState->m_central_dir, mz_uint8, - MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_central_dir_offsets, - mz_uint32, index)))) { - // cannot find header in central directory - return ZIP_ENOHDR; - } - - namelen = MZ_READ_LE16(pHeader + MZ_ZIP_CDH_FILENAME_LEN_OFS); - pFilename = (const char *)pHeader + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE; - - /* - .ZIP File Format Specification Version: 6.3.3 - - 4.4.17.1 The name of the file, with optional relative path. - The path stored MUST not contain a drive or - device letter, or a leading slash. All slashes - MUST be forward slashes '/' as opposed to - backwards slashes '\' for compatibility with Amiga - and UNIX file systems etc. If input came from standard - input, there is no file name field. - */ - if (zip->entry.name) { - CLEANUP(zip->entry.name); - } -#ifdef ZIP_RAW_ENTRYNAME - zip->entry.name = STRCLONE(pFilename); -#else - zip->entry.name = zip_strrpl(pFilename, namelen, '\\', '/'); -#endif - - if (!zip->entry.name) { - // local entry name is NULL - return ZIP_EINVENTNAME; - } - - if (!mz_zip_reader_file_stat(pZip, (mz_uint)index, &stats)) { - return ZIP_ENOENT; - } - - zip->entry.index = (ssize_t)index; - zip->entry.comp_size = stats.m_comp_size; - zip->entry.uncomp_size = stats.m_uncomp_size; - zip->entry.uncomp_crc32 = stats.m_crc32; - zip->entry.offset = stats.m_central_dir_ofs; - zip->entry.header_offset = stats.m_local_header_ofs; - zip->entry.method = stats.m_method; - zip->entry.external_attr = stats.m_external_attr; -#ifndef MINIZ_NO_TIME - zip->entry.m_time = stats.m_time; -#endif - - return 0; -} - -int zip_entry_close(struct zip_t *zip) { - mz_zip_archive *pzip = NULL; - mz_uint level; - tdefl_status done; - mz_uint16 entrylen; - mz_uint16 dos_time = 0, dos_date = 0; - int err = 0; - mz_uint8 *pExtra_data = NULL; - mz_uint32 extra_size = 0; - mz_uint8 extra_data[MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE]; - mz_uint8 local_dir_footer[MZ_ZIP_DATA_DESCRIPTER_SIZE64]; - mz_uint32 local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE64; - - if (!zip) { - // zip_t handler is not initialized - err = ZIP_ENOINIT; - goto cleanup; - } - - pzip = &(zip->archive); - if (pzip->m_zip_mode == MZ_ZIP_MODE_READING) { - goto cleanup; - } - - level = zip->level & 0xF; - if (level) { - done = tdefl_compress_buffer(&(zip->entry.comp), "", 0, TDEFL_FINISH); - if (done != TDEFL_STATUS_DONE && done != TDEFL_STATUS_OKAY) { - // Cannot flush compressed buffer - err = ZIP_ETDEFLBUF; - goto cleanup; - } - zip->entry.comp_size = zip->entry.state.m_comp_size; - zip->entry.offset = zip->entry.state.m_cur_archive_file_ofs; - zip->entry.method = MZ_DEFLATED; - } - - entrylen = (mz_uint16)strlen(zip->entry.name); -#ifndef MINIZ_NO_TIME - mz_zip_time_t_to_dos_time(zip->entry.m_time, &dos_time, &dos_date); -#endif - - MZ_WRITE_LE32(local_dir_footer + 0, MZ_ZIP_DATA_DESCRIPTOR_ID); - MZ_WRITE_LE32(local_dir_footer + 4, zip->entry.uncomp_crc32); - MZ_WRITE_LE64(local_dir_footer + 8, zip->entry.comp_size); - MZ_WRITE_LE64(local_dir_footer + 16, zip->entry.uncomp_size); - - if (pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.offset, local_dir_footer, - local_dir_footer_size) != local_dir_footer_size) { - // Cannot write zip entry header - err = ZIP_EWRTHDR; - goto cleanup; - } - zip->entry.offset += local_dir_footer_size; - - pExtra_data = extra_data; - extra_size = mz_zip_writer_create_zip64_extra_data( - extra_data, - (zip->entry.uncomp_size >= MZ_UINT32_MAX) ? &zip->entry.uncomp_size - : NULL, - (zip->entry.comp_size >= MZ_UINT32_MAX) ? &zip->entry.comp_size : NULL, - (zip->entry.header_offset >= MZ_UINT32_MAX) ? &zip->entry.header_offset - : NULL); - - if ((entrylen) && (zip->entry.name[entrylen - 1] == '/') && - !zip->entry.uncomp_size) { - /* Set DOS Subdirectory attribute bit. */ - zip->entry.external_attr |= MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG; - } - - if (!mz_zip_writer_add_to_central_dir( - pzip, zip->entry.name, entrylen, pExtra_data, (mz_uint16)extra_size, - "", 0, zip->entry.uncomp_size, zip->entry.comp_size, - zip->entry.uncomp_crc32, zip->entry.method, - MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8 | - MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR, - dos_time, dos_date, zip->entry.header_offset, - zip->entry.external_attr, NULL, 0)) { - // Cannot write to zip central dir - err = ZIP_EWRTDIR; - goto cleanup; - } - - pzip->m_total_files++; - pzip->m_archive_size = zip->entry.offset; - -cleanup: - if (zip) { - zip->entry.m_time = 0; - CLEANUP(zip->entry.name); - } - return err; -} - -const char *zip_entry_name(struct zip_t *zip) { - if (!zip) { - // zip_t handler is not initialized - return NULL; - } - - return zip->entry.name; -} - -ssize_t zip_entry_index(struct zip_t *zip) { - if (!zip) { - // zip_t handler is not initialized - return (ssize_t)ZIP_ENOINIT; - } - - return zip->entry.index; -} - -int zip_entry_isdir(struct zip_t *zip) { - if (!zip) { - // zip_t handler is not initialized - return ZIP_ENOINIT; - } - - if (zip->entry.index < (ssize_t)0) { - // zip entry is not opened - return ZIP_EINVIDX; - } - - return (int)mz_zip_reader_is_file_a_directory(&zip->archive, - (mz_uint)zip->entry.index); -} - -unsigned long long zip_entry_size(struct zip_t *zip) { - return zip_entry_uncomp_size(zip); -} - -unsigned long long zip_entry_uncomp_size(struct zip_t *zip) { - return zip ? zip->entry.uncomp_size : 0; -} - -unsigned long long zip_entry_comp_size(struct zip_t *zip) { - return zip ? zip->entry.comp_size : 0; -} - -unsigned int zip_entry_crc32(struct zip_t *zip) { - return zip ? zip->entry.uncomp_crc32 : 0; -} - -int zip_entry_write(struct zip_t *zip, const void *buf, size_t bufsize) { - mz_uint level; - mz_zip_archive *pzip = NULL; - tdefl_status status; - - if (!zip) { - // zip_t handler is not initialized - return ZIP_ENOINIT; - } - - pzip = &(zip->archive); - if (buf && bufsize > 0) { - zip->entry.uncomp_size += bufsize; - zip->entry.uncomp_crc32 = (mz_uint32)mz_crc32( - zip->entry.uncomp_crc32, (const mz_uint8 *)buf, bufsize); - - level = zip->level & 0xF; - if (!level) { - if ((pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.offset, buf, - bufsize) != bufsize)) { - // Cannot write buffer - return ZIP_EWRTENT; - } - zip->entry.offset += bufsize; - zip->entry.comp_size += bufsize; - } else { - status = tdefl_compress_buffer(&(zip->entry.comp), buf, bufsize, - TDEFL_NO_FLUSH); - if (status != TDEFL_STATUS_DONE && status != TDEFL_STATUS_OKAY) { - // Cannot compress buffer - return ZIP_ETDEFLBUF; - } - } - } - - return 0; -} - -#ifndef FIZZY_ZIP_WASM - -int zip_entry_fwrite(struct zip_t *zip, const char *filename) { - int err = 0; - size_t n = 0; - MZ_FILE *stream = NULL; - mz_uint8 buf[MZ_ZIP_MAX_IO_BUF_SIZE]; - struct MZ_FILE_STAT_STRUCT file_stat; - mz_uint16 modes; - - if (!zip) { - // zip_t handler is not initialized - return ZIP_ENOINIT; - } - - memset(buf, 0, MZ_ZIP_MAX_IO_BUF_SIZE); - memset((void *)&file_stat, 0, sizeof(struct MZ_FILE_STAT_STRUCT)); - if (MZ_FILE_STAT(filename, &file_stat) != 0) { - // problem getting information - check errno - return ZIP_ENOENT; - } - -#if defined(_WIN32) || defined(__WIN32__) || defined(DJGPP) - (void)modes; // unused -#else - /* Initialize with permission bits--which are not implementation-optional */ - modes = file_stat.st_mode & - (S_IRWXU | S_IRWXG | S_IRWXO | S_ISUID | S_ISGID | S_ISVTX); - if (S_ISDIR(file_stat.st_mode)) - modes |= UNX_IFDIR; - if (S_ISREG(file_stat.st_mode)) - modes |= UNX_IFREG; - if (S_ISLNK(file_stat.st_mode)) - modes |= UNX_IFLNK; - if (S_ISBLK(file_stat.st_mode)) - modes |= UNX_IFBLK; - if (S_ISCHR(file_stat.st_mode)) - modes |= UNX_IFCHR; - if (S_ISFIFO(file_stat.st_mode)) - modes |= UNX_IFIFO; - if (S_ISSOCK(file_stat.st_mode)) - modes |= UNX_IFSOCK; - zip->entry.external_attr = (modes << 16) | !(file_stat.st_mode & S_IWUSR); - if ((file_stat.st_mode & S_IFMT) == S_IFDIR) { - zip->entry.external_attr |= MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG; - } -#endif - - zip->entry.m_time = file_stat.st_mtime; - - if (!(stream = MZ_FOPEN(filename, "rb"))) { - // Cannot open filename - return ZIP_EOPNFILE; - } - - while ((n = fread(buf, sizeof(mz_uint8), MZ_ZIP_MAX_IO_BUF_SIZE, stream)) > - 0) { - if (zip_entry_write(zip, buf, n) < 0) { - err = ZIP_EWRTENT; - break; - } - } - fclose(stream); - - return err; -} - -#endif /* !FIZZY_ZIP_WASM */ - -ssize_t zip_entry_read(struct zip_t *zip, void **buf, size_t *bufsize) { - mz_zip_archive *pzip = NULL; - mz_uint idx; - size_t size = 0; - - if (!zip) { - // zip_t handler is not initialized - return (ssize_t)ZIP_ENOINIT; - } - - pzip = &(zip->archive); - if (pzip->m_zip_mode != MZ_ZIP_MODE_READING || - zip->entry.index < (ssize_t)0) { - // the entry is not found or we do not have read access - return (ssize_t)ZIP_ENOENT; - } - - idx = (mz_uint)zip->entry.index; - if (mz_zip_reader_is_file_a_directory(pzip, idx)) { - // the entry is a directory - return (ssize_t)ZIP_EINVENTTYPE; - } - - *buf = mz_zip_reader_extract_to_heap(pzip, idx, &size, 0); - if (*buf && bufsize) { - *bufsize = size; - } - return (ssize_t)size; -} - -ssize_t zip_entry_noallocread(struct zip_t *zip, void *buf, size_t bufsize) { - mz_zip_archive *pzip = NULL; - - if (!zip) { - // zip_t handler is not initialized - return (ssize_t)ZIP_ENOINIT; - } - - pzip = &(zip->archive); - if (pzip->m_zip_mode != MZ_ZIP_MODE_READING || - zip->entry.index < (ssize_t)0) { - // the entry is not found or we do not have read access - return (ssize_t)ZIP_ENOENT; - } - - if (!mz_zip_reader_extract_to_mem_no_alloc(pzip, (mz_uint)zip->entry.index, - buf, bufsize, 0, NULL, 0)) { - return (ssize_t)ZIP_EMEMNOALLOC; - } - - return (ssize_t)zip->entry.uncomp_size; -} - -#ifndef FIZZY_ZIP_WASM - -int zip_entry_fread(struct zip_t *zip, const char *filename) { - mz_zip_archive *pzip = NULL; - mz_uint idx; - mz_uint32 xattr = 0; - mz_zip_archive_file_stat info; - - if (!zip) { - // zip_t handler is not initialized - return ZIP_ENOINIT; - } - - memset((void *)&info, 0, sizeof(mz_zip_archive_file_stat)); - pzip = &(zip->archive); - if (pzip->m_zip_mode != MZ_ZIP_MODE_READING || - zip->entry.index < (ssize_t)0) { - // the entry is not found or we do not have read access - return ZIP_ENOENT; - } - - idx = (mz_uint)zip->entry.index; - if (mz_zip_reader_is_file_a_directory(pzip, idx)) { - // the entry is a directory - return ZIP_EINVENTTYPE; - } - - if (!mz_zip_reader_extract_to_file(pzip, idx, filename, 0)) { - return ZIP_ENOFILE; - } - -#if defined(_MSC_VER) || defined(PS4) - (void)xattr; // unused -#else - if (!mz_zip_reader_file_stat(pzip, idx, &info)) { - // Cannot get information about zip archive; - return ZIP_ENOFILE; - } - - xattr = (info.m_external_attr >> 16) & 0xFFFF; - if (xattr > 0 && xattr <= MZ_UINT16_MAX) { - if (CHMOD(filename, (mode_t)xattr) < 0) { - return ZIP_ENOPERM; - } - } -#endif - - return 0; -} - -int zip_entry_extract(struct zip_t *zip, - size_t (*on_extract)(void *arg, uint64_t offset, - const void *buf, size_t bufsize), - void *arg) { - mz_zip_archive *pzip = NULL; - mz_uint idx; - - if (!zip) { - // zip_t handler is not initialized - return ZIP_ENOINIT; - } - - pzip = &(zip->archive); - if (pzip->m_zip_mode != MZ_ZIP_MODE_READING || - zip->entry.index < (ssize_t)0) { - // the entry is not found or we do not have read access - return ZIP_ENOENT; - } - - idx = (mz_uint)zip->entry.index; - return (mz_zip_reader_extract_to_callback(pzip, idx, on_extract, arg, 0)) - ? 0 - : ZIP_EINVIDX; -} - -#endif /* !FIZZY_ZIP_WASM */ - -ssize_t zip_entries_total(struct zip_t *zip) { - if (!zip) { - // zip_t handler is not initialized - return ZIP_ENOINIT; - } - - return (ssize_t)zip->archive.m_total_files; -} - -#ifndef FIZZY_ZIP_WASM - -ssize_t zip_entries_delete(struct zip_t *zip, char *const entries[], - size_t len) { - ssize_t n = 0; - ssize_t err = 0; - struct zip_entry_mark_t *entry_mark = NULL; - - if (zip == NULL || (entries == NULL && len != 0)) { - return ZIP_ENOINIT; - } - - if (entries == NULL && len == 0) { - return 0; - } - - n = zip_entries_total(zip); - - entry_mark = (struct zip_entry_mark_t *)calloc( - (size_t)n, sizeof(struct zip_entry_mark_t)); - if (!entry_mark) { - return ZIP_EOOMEM; - } - - zip->archive.m_zip_mode = MZ_ZIP_MODE_READING; - - err = zip_entry_set(zip, entry_mark, n, entries, len); - if (err < 0) { - CLEANUP(entry_mark); - return err; - } - - err = zip_entries_delete_mark(zip, entry_mark, (int)n); - CLEANUP(entry_mark); - return err; -} - -#endif /* !FIZZY_ZIP_WASM */ - -#ifndef FIZZY_ZIP_WASM - -int zip_stream_extract(const char *stream, size_t size, const char *dir, - int (*on_extract)(const char *filename, void *arg), - void *arg) { - mz_zip_archive zip_archive; - if (!stream || !dir) { - // Cannot parse zip archive stream - return ZIP_ENOINIT; - } - if (!memset(&zip_archive, 0, sizeof(mz_zip_archive))) { - // Cannot memset zip archive - return ZIP_EMEMSET; - } - if (!mz_zip_reader_init_mem(&zip_archive, stream, size, 0)) { - // Cannot initialize zip_archive reader - return ZIP_ENOINIT; - } - - return zip_archive_extract(&zip_archive, dir, on_extract, arg); -} - -#endif /* !FIZZY_ZIP_WASM */ - -struct zip_t *zip_stream_open(const char *stream, size_t size, int level, - char mode) { - struct zip_t *zip = (struct zip_t *)calloc((size_t)1, sizeof(struct zip_t)); - if (!zip) { - return NULL; - } - - if (level < 0) { - level = MZ_DEFAULT_LEVEL; - } - if ((level & 0xF) > MZ_UBER_COMPRESSION) { - // Wrong compression level - goto cleanup; - } - zip->level = (mz_uint)level; - - if ((stream != NULL) && (size > 0) && (mode == 'r')) { - if (!mz_zip_reader_init_mem(&(zip->archive), stream, size, 0)) { - goto cleanup; - } - } else if ((stream == NULL) && (size == 0) && (mode == 'w')) { - // Create a new archive. - if (!mz_zip_writer_init_heap(&(zip->archive), 0, 1024)) { - // Cannot initialize zip_archive writer - goto cleanup; - } - } else { - goto cleanup; - } - return zip; - -cleanup: - CLEANUP(zip); - return NULL; -} - -ssize_t zip_stream_copy(struct zip_t *zip, void **buf, size_t *bufsize) { - size_t n; - - if (!zip) { - return (ssize_t)ZIP_ENOINIT; - } - zip_archive_finalize(&(zip->archive)); - - n = (size_t)zip->archive.m_archive_size; - if (bufsize != NULL) { - *bufsize = n; - } - - *buf = calloc(sizeof(unsigned char), n); - memcpy(*buf, zip->archive.m_pState->m_pMem, n); - - return (ssize_t)n; -} - -void zip_stream_close(struct zip_t *zip) { - if (zip) { - mz_zip_writer_end(&(zip->archive)); - mz_zip_reader_end(&(zip->archive)); - CLEANUP(zip); - } -} - -#ifndef FIZZY_ZIP_WASM - -int zip_create(const char *zipname, const char *filenames[], size_t len) { - int err = 0; - size_t i; - mz_zip_archive zip_archive; - struct MZ_FILE_STAT_STRUCT file_stat; - mz_uint32 ext_attributes = 0; - mz_uint16 modes; - - if (!zipname || strlen(zipname) < 1) { - // zip_t archive name is empty or NULL - return ZIP_EINVZIPNAME; - } - - // Create a new archive. - if (!memset(&(zip_archive), 0, sizeof(zip_archive))) { - // Cannot memset zip archive - return ZIP_EMEMSET; - } - - if (!mz_zip_writer_init_file(&zip_archive, zipname, 0)) { - // Cannot initialize zip_archive writer - return ZIP_ENOINIT; - } - - if (!memset((void *)&file_stat, 0, sizeof(struct MZ_FILE_STAT_STRUCT))) { - return ZIP_EMEMSET; - } - - for (i = 0; i < len; ++i) { - const char *name = filenames[i]; - if (!name) { - err = ZIP_EINVENTNAME; - break; - } - - if (MZ_FILE_STAT(name, &file_stat) != 0) { - // problem getting information - check errno - err = ZIP_ENOFILE; - break; - } - -#if defined(_WIN32) || defined(__WIN32__) || defined(DJGPP) - (void)modes; // unused -#else - - /* Initialize with permission bits--which are not implementation-optional */ - modes = file_stat.st_mode & - (S_IRWXU | S_IRWXG | S_IRWXO | S_ISUID | S_ISGID | S_ISVTX); - if (S_ISDIR(file_stat.st_mode)) - modes |= UNX_IFDIR; - if (S_ISREG(file_stat.st_mode)) - modes |= UNX_IFREG; - if (S_ISLNK(file_stat.st_mode)) - modes |= UNX_IFLNK; - if (S_ISBLK(file_stat.st_mode)) - modes |= UNX_IFBLK; - if (S_ISCHR(file_stat.st_mode)) - modes |= UNX_IFCHR; - if (S_ISFIFO(file_stat.st_mode)) - modes |= UNX_IFIFO; - if (S_ISSOCK(file_stat.st_mode)) - modes |= UNX_IFSOCK; - ext_attributes = (modes << 16) | !(file_stat.st_mode & S_IWUSR); - if ((file_stat.st_mode & S_IFMT) == S_IFDIR) { - ext_attributes |= MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG; - } -#endif - - if (!mz_zip_writer_add_file(&zip_archive, zip_basename(name), name, "", 0, - ZIP_DEFAULT_COMPRESSION_LEVEL, - ext_attributes)) { - // Cannot add file to zip_archive - err = ZIP_ENOFILE; - break; - } - } - - mz_zip_writer_finalize_archive(&zip_archive); - mz_zip_writer_end(&zip_archive); - return err; -} - -int zip_extract(const char *zipname, const char *dir, - int (*on_extract)(const char *filename, void *arg), void *arg) { - mz_zip_archive zip_archive; - - if (!zipname || !dir) { - // Cannot parse zip archive name - return ZIP_EINVZIPNAME; - } - - if (!memset(&zip_archive, 0, sizeof(mz_zip_archive))) { - // Cannot memset zip archive - return ZIP_EMEMSET; - } - - // Now try to open the archive. - if (!mz_zip_reader_init_file(&zip_archive, zipname, 0)) { - // Cannot initialize zip_archive reader - return ZIP_ENOINIT; - } - - return zip_archive_extract(&zip_archive, dir, on_extract, arg); -} - -#else /* FIZZY_ZIP_WASM */ - -struct zip_t *zip_open(const char *zipname, int level, char mode) { - (void)zipname; - (void)level; - (void)mode; - return NULL; -} - -void zip_close(struct zip_t *zip) { - if (zip) { - mz_zip_writer_end(&(zip->archive)); - mz_zip_reader_end(&(zip->archive)); - CLEANUP(zip); - } -} - -int zip_entry_fwrite(struct zip_t *zip, const char *filename) { - (void)zip; - (void)filename; - return ZIP_ENOFILE; -} - -int zip_entry_fread(struct zip_t *zip, const char *filename) { - (void)zip; - (void)filename; - return ZIP_ENOFILE; -} - -int zip_entry_extract(struct zip_t *zip, - size_t (*on_extract)(void *arg, uint64_t offset, - const void *buf, size_t bufsize), - void *arg) { - (void)zip; - (void)on_extract; - (void)arg; - return ZIP_ENOFILE; -} - -ssize_t zip_entries_delete(struct zip_t *zip, char *const entries[], - size_t len) { - (void)zip; - (void)entries; - (void)len; - return ZIP_ENOFILE; -} - -int zip_stream_extract(const char *stream, size_t size, const char *dir, - int (*on_extract)(const char *filename, void *arg), - void *arg) { - (void)stream; - (void)size; - (void)dir; - (void)on_extract; - (void)arg; - return ZIP_ENOFILE; -} - -int zip_create(const char *zipname, const char *filenames[], size_t len) { - (void)zipname; - (void)filenames; - (void)len; - return ZIP_ENOFILE; -} - -int zip_extract(const char *zipname, const char *dir, - int (*on_extract)(const char *filename, void *arg), void *arg) { - (void)zipname; - (void)dir; - (void)on_extract; - (void)arg; - return ZIP_ENOFILE; -} - -#endif /* FIZZY_ZIP_WASM */ diff --git a/src/plugins/pixelart/src/deps/zip/src/zip.h b/src/plugins/pixelart/src/deps/zip/src/zip.h deleted file mode 100644 index 84973acd..00000000 --- a/src/plugins/pixelart/src/deps/zip/src/zip.h +++ /dev/null @@ -1,477 +0,0 @@ -/* - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR - * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, - * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - */ - -#pragma once -#ifndef ZIP_H -#define ZIP_H - -#include -#ifdef FIZZY_ZIP_WASM -extern void *memcpy(void *dest, const void *src, size_t n); -extern void *memset(void *dest, int c, size_t n); -#else -#include -#endif -#ifdef FIZZY_ZIP_WASM -#include -#ifndef _SSIZE_T_DEFINED -#define _SSIZE_T_DEFINED -typedef ptrdiff_t ssize_t; -#endif -#else -#include -#endif - -#ifndef ZIP_SHARED -#define ZIP_EXPORT -#else -#ifdef _WIN32 -#ifdef ZIP_BUILD_SHARED -#define ZIP_EXPORT __declspec(dllexport) -#else -#define ZIP_EXPORT __declspec(dllimport) -#endif -#else -#define ZIP_EXPORT __attribute__((visibility("default"))) -#endif -#endif - -#ifdef __cplusplus -extern "C" { -#endif - -#if !defined(_POSIX_C_SOURCE) && defined(_MSC_VER) -// 64-bit Windows is the only mainstream platform -// where sizeof(long) != sizeof(void*) -#ifdef _WIN64 -typedef long long ssize_t; /* byte count or error */ -#else -typedef long ssize_t; /* byte count or error */ -#endif -#endif - -/** - * @mainpage - * - * Documentation for @ref zip. - */ - -/** - * @addtogroup zip - * @{ - */ - -/** - * Default zip compression level. - */ -#define ZIP_DEFAULT_COMPRESSION_LEVEL 6 - -/** - * Error codes - */ -#define ZIP_ENOINIT -1 // not initialized -#define ZIP_EINVENTNAME -2 // invalid entry name -#define ZIP_ENOENT -3 // entry not found -#define ZIP_EINVMODE -4 // invalid zip mode -#define ZIP_EINVLVL -5 // invalid compression level -#define ZIP_ENOSUP64 -6 // no zip 64 support -#define ZIP_EMEMSET -7 // memset error -#define ZIP_EWRTENT -8 // cannot write data to entry -#define ZIP_ETDEFLINIT -9 // cannot initialize tdefl compressor -#define ZIP_EINVIDX -10 // invalid index -#define ZIP_ENOHDR -11 // header not found -#define ZIP_ETDEFLBUF -12 // cannot flush tdefl buffer -#define ZIP_ECRTHDR -13 // cannot create entry header -#define ZIP_EWRTHDR -14 // cannot write entry header -#define ZIP_EWRTDIR -15 // cannot write to central dir -#define ZIP_EOPNFILE -16 // cannot open file -#define ZIP_EINVENTTYPE -17 // invalid entry type -#define ZIP_EMEMNOALLOC -18 // extracting data using no memory allocation -#define ZIP_ENOFILE -19 // file not found -#define ZIP_ENOPERM -20 // no permission -#define ZIP_EOOMEM -21 // out of memory -#define ZIP_EINVZIPNAME -22 // invalid zip archive name -#define ZIP_EMKDIR -23 // make dir error -#define ZIP_ESYMLINK -24 // symlink error -#define ZIP_ECLSZIP -25 // close archive error -#define ZIP_ECAPSIZE -26 // capacity size too small -#define ZIP_EFSEEK -27 // fseek error -#define ZIP_EFREAD -28 // fread error -#define ZIP_EFWRITE -29 // fwrite error - -/** - * Looks up the error message string corresponding to an error number. - * @param errnum error number - * @return error message string corresponding to errnum or NULL if error is not - * found. - */ -extern ZIP_EXPORT const char *zip_strerror(int errnum); - -/** - * @struct zip_t - * - * This data structure is used throughout the library to represent zip archive - - * forward declaration. - */ -struct zip_t; - -/** - * Opens zip archive with compression level using the given mode. - * - * @param zipname zip archive file name. - * @param level compression level (0-9 are the standard zlib-style levels). - * @param mode file access mode. - * - 'r': opens a file for reading/extracting (the file must exists). - * - 'w': creates an empty file for writing. - * - 'a': appends to an existing archive. - * - * @return the zip archive handler or NULL on error - */ -extern ZIP_EXPORT struct zip_t *zip_open(const char *zipname, int level, - char mode); - -/** - * Closes the zip archive, releases resources - always finalize. - * - * @param zip zip archive handler. - */ -extern ZIP_EXPORT void zip_close(struct zip_t *zip); - -/** - * Determines if the archive has a zip64 end of central directory headers. - * - * @param zip zip archive handler. - * - * @return the return code - 1 (true), 0 (false), negative number (< 0) on - * error. - */ -extern ZIP_EXPORT int zip_is64(struct zip_t *zip); - -/** - * Opens an entry by name in the zip archive. - * - * For zip archive opened in 'w' or 'a' mode the function will append - * a new entry. In readonly mode the function tries to locate the entry - * in global dictionary. - * - * @param zip zip archive handler. - * @param entryname an entry name in local dictionary. - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int zip_entry_open(struct zip_t *zip, const char *entryname); - -/** - * Opens an entry by name in the zip archive. - * - * For zip archive opened in 'w' or 'a' mode the function will append - * a new entry. In readonly mode the function tries to locate the entry - * in global dictionary (case sensitive). - * - * @param zip zip archive handler. - * @param entryname an entry name in local dictionary (case sensitive). - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int zip_entry_opencasesensitive(struct zip_t *zip, - const char *entryname); - -/** - * Opens a new entry by index in the zip archive. - * - * This function is only valid if zip archive was opened in 'r' (readonly) mode. - * - * @param zip zip archive handler. - * @param index index in local dictionary. - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int zip_entry_openbyindex(struct zip_t *zip, size_t index); - -/** - * Closes a zip entry, flushes buffer and releases resources. - * - * @param zip zip archive handler. - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int zip_entry_close(struct zip_t *zip); - -/** - * Returns a local name of the current zip entry. - * - * The main difference between user's entry name and local entry name - * is optional relative path. - * Following .ZIP File Format Specification - the path stored MUST not contain - * a drive or device letter, or a leading slash. - * All slashes MUST be forward slashes '/' as opposed to backwards slashes '\' - * for compatibility with Amiga and UNIX file systems etc. - * - * @param zip: zip archive handler. - * - * @return the pointer to the current zip entry name, or NULL on error. - */ -extern ZIP_EXPORT const char *zip_entry_name(struct zip_t *zip); - -/** - * Returns an index of the current zip entry. - * - * @param zip zip archive handler. - * - * @return the index on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT ssize_t zip_entry_index(struct zip_t *zip); - -/** - * Determines if the current zip entry is a directory entry. - * - * @param zip zip archive handler. - * - * @return the return code - 1 (true), 0 (false), negative number (< 0) on - * error. - */ -extern ZIP_EXPORT int zip_entry_isdir(struct zip_t *zip); - -/** - * Returns the uncompressed size of the current zip entry. - * Alias for zip_entry_uncomp_size (for backward compatibility). - * - * @param zip zip archive handler. - * - * @return the uncompressed size in bytes. - */ -extern ZIP_EXPORT unsigned long long zip_entry_size(struct zip_t *zip); - -/** - * Returns the uncompressed size of the current zip entry. - * - * @param zip zip archive handler. - * - * @return the uncompressed size in bytes. - */ -extern ZIP_EXPORT unsigned long long zip_entry_uncomp_size(struct zip_t *zip); - -/** - * Returns the compressed size of the current zip entry. - * - * @param zip zip archive handler. - * - * @return the compressed size in bytes. - */ -extern ZIP_EXPORT unsigned long long zip_entry_comp_size(struct zip_t *zip); - -/** - * Returns CRC-32 checksum of the current zip entry. - * - * @param zip zip archive handler. - * - * @return the CRC-32 checksum. - */ -extern ZIP_EXPORT unsigned int zip_entry_crc32(struct zip_t *zip); - -/** - * Compresses an input buffer for the current zip entry. - * - * @param zip zip archive handler. - * @param buf input buffer. - * @param bufsize input buffer size (in bytes). - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int zip_entry_write(struct zip_t *zip, const void *buf, - size_t bufsize); - -/** - * Compresses a file for the current zip entry. - * - * @param zip zip archive handler. - * @param filename input file. - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int zip_entry_fwrite(struct zip_t *zip, const char *filename); - -/** - * Extracts the current zip entry into output buffer. - * - * The function allocates sufficient memory for a output buffer. - * - * @param zip zip archive handler. - * @param buf output buffer. - * @param bufsize output buffer size (in bytes). - * - * @note remember to release memory allocated for a output buffer. - * for large entries, please take a look at zip_entry_extract function. - * - * @return the return code - the number of bytes actually read on success. - * Otherwise a negative number (< 0) on error. - */ -extern ZIP_EXPORT ssize_t zip_entry_read(struct zip_t *zip, void **buf, - size_t *bufsize); - -/** - * Extracts the current zip entry into a memory buffer using no memory - * allocation. - * - * @param zip zip archive handler. - * @param buf preallocated output buffer. - * @param bufsize output buffer size (in bytes). - * - * @note ensure supplied output buffer is large enough. - * zip_entry_size function (returns uncompressed size for the current - * entry) can be handy to estimate how big buffer is needed. - * For large entries, please take a look at zip_entry_extract function. - * - * @return the return code - the number of bytes actually read on success. - * Otherwise a negative number (< 0) on error (e.g. bufsize is not large - * enough). - */ -extern ZIP_EXPORT ssize_t zip_entry_noallocread(struct zip_t *zip, void *buf, - size_t bufsize); - -/** - * Extracts the current zip entry into output file. - * - * @param zip zip archive handler. - * @param filename output file. - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int zip_entry_fread(struct zip_t *zip, const char *filename); - -/** - * Extracts the current zip entry using a callback function (on_extract). - * - * @param zip zip archive handler. - * @param on_extract callback function. - * @param arg opaque pointer (optional argument, which you can pass to the - * on_extract callback) - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int -zip_entry_extract(struct zip_t *zip, - size_t (*on_extract)(void *arg, uint64_t offset, - const void *data, size_t size), - void *arg); - -/** - * Returns the number of all entries (files and directories) in the zip archive. - * - * @param zip zip archive handler. - * - * @return the return code - the number of entries on success, negative number - * (< 0) on error. - */ -extern ZIP_EXPORT ssize_t zip_entries_total(struct zip_t *zip); - -/** - * Deletes zip archive entries. - * - * @param zip zip archive handler. - * @param entries array of zip archive entries to be deleted. - * @param len the number of entries to be deleted. - * @return the number of deleted entries, or negative number (< 0) on error. - */ -extern ZIP_EXPORT ssize_t zip_entries_delete(struct zip_t *zip, - char *const entries[], size_t len); - -/** - * Extracts a zip archive stream into directory. - * - * If on_extract is not NULL, the callback will be called after - * successfully extracted each zip entry. - * Returning a negative value from the callback will cause abort and return an - * error. The last argument (void *arg) is optional, which you can use to pass - * data to the on_extract callback. - * - * @param stream zip archive stream. - * @param size stream size. - * @param dir output directory. - * @param on_extract on extract callback. - * @param arg opaque pointer. - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int -zip_stream_extract(const char *stream, size_t size, const char *dir, - int (*on_extract)(const char *filename, void *arg), - void *arg); - -/** - * Opens zip archive stream into memory. - * - * @param stream zip archive stream. - * @param size stream size. - * - * @return the zip archive handler or NULL on error - */ -extern ZIP_EXPORT struct zip_t *zip_stream_open(const char *stream, size_t size, - int level, char mode); - -/** - * Copy zip archive stream output buffer. - * - * @param zip zip archive handler. - * @param buf output buffer. User should free buf. - * @param bufsize output buffer size (in bytes). - * - * @return copy size - */ -extern ZIP_EXPORT ssize_t zip_stream_copy(struct zip_t *zip, void **buf, - size_t *bufsize); - -/** - * Close zip archive releases resources. - * - * @param zip zip archive handler. - * - * @return - */ -extern ZIP_EXPORT void zip_stream_close(struct zip_t *zip); - -/** - * Creates a new archive and puts files into a single zip archive. - * - * @param zipname zip archive file. - * @param filenames input files. - * @param len: number of input files. - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int zip_create(const char *zipname, const char *filenames[], - size_t len); - -/** - * Extracts a zip archive file into directory. - * - * If on_extract_entry is not NULL, the callback will be called after - * successfully extracted each zip entry. - * Returning a negative value from the callback will cause abort and return an - * error. The last argument (void *arg) is optional, which you can use to pass - * data to the on_extract_entry callback. - * - * @param zipname zip archive file. - * @param dir output directory. - * @param on_extract_entry on extract callback. - * @param arg opaque pointer. - * - * @return the return code - 0 on success, negative number (< 0) on error. - */ -extern ZIP_EXPORT int zip_extract(const char *zipname, const char *dir, - int (*on_extract_entry)(const char *filename, - void *arg), - void *arg); -/** @} */ -#ifdef __cplusplus -} -#endif - -#endif diff --git a/src/plugins/pixelart/src/deps/zip/zip.zig b/src/plugins/pixelart/src/deps/zip/zip.zig deleted file mode 100644 index ea1e635a..00000000 --- a/src/plugins/pixelart/src/deps/zip/zip.zig +++ /dev/null @@ -1,62 +0,0 @@ -//usingnamespace @cImport(@cInclude("zip.h")); -pub extern fn zip_strerror(errnum: c_int) [*c]const u8; -pub const struct_zip_t = opaque {}; -pub extern fn zip_open(zipname: [*c]const u8, level: c_int, mode: u8) ?*struct_zip_t; -pub extern fn zip_close(zip: ?*struct_zip_t) void; -pub extern fn zip_is64(zip: ?*struct_zip_t) c_int; -pub extern fn zip_entry_open(zip: ?*struct_zip_t, entryname: [*c]const u8) c_int; -pub extern fn zip_entry_openbyindex(zip: ?*struct_zip_t, index: c_int) c_int; -pub extern fn zip_entry_close(zip: ?*struct_zip_t) c_int; -pub extern fn zip_entry_name(zip: ?*struct_zip_t) [*c]const u8; -pub extern fn zip_entry_index(zip: ?*struct_zip_t) c_int; -pub extern fn zip_entry_isdir(zip: ?*struct_zip_t) c_int; -pub extern fn zip_entry_size(zip: ?*struct_zip_t) c_ulonglong; -pub extern fn zip_entry_crc32(zip: ?*struct_zip_t) c_uint; -pub extern fn zip_entry_write(zip: ?*struct_zip_t, buf: ?*const anyopaque, bufsize: usize) c_int; -pub extern fn zip_entry_fwrite(zip: ?*struct_zip_t, filename: [*c]const u8) c_int; -pub extern fn zip_entry_read(zip: ?*struct_zip_t, buf: [*c]?*anyopaque, bufsize: [*c]usize) isize; -pub extern fn zip_entry_noallocread(zip: ?*struct_zip_t, buf: ?*anyopaque, bufsize: usize) isize; -pub extern fn zip_entry_fread(zip: ?*struct_zip_t, filename: [*c]const u8) c_int; -pub extern fn zip_entry_extract(zip: ?*struct_zip_t, on_extract: ?fn (?*anyopaque, c_ulonglong, ?*const anyopaque, usize) callconv(.C) usize, arg: ?*anyopaque) c_int; -pub extern fn zip_entries_total(zip: ?*struct_zip_t) c_int; -pub extern fn zip_entries_delete(zip: ?*struct_zip_t, entries: [*c]const [*c]u8, len: usize) c_int; -pub extern fn zip_stream_extract(stream: [*c]const u8, size: usize, dir: [*c]const u8, on_extract: ?fn ([*c]const u8, ?*anyopaque) callconv(.C) c_int, arg: ?*anyopaque) c_int; -pub extern fn zip_stream_open(stream: [*c]const u8, size: usize, level: c_int, mode: u8) ?*struct_zip_t; -pub extern fn zip_stream_copy(zip: ?*struct_zip_t, buf: [*c]?*anyopaque, bufsize: [*c]usize) isize; -pub extern fn zip_stream_close(zip: ?*struct_zip_t) void; -pub extern fn zip_create(zipname: [*c]const u8, filenames: [*c][*c]const u8, len: usize) c_int; -pub extern fn zip_extract(zipname: [*c]const u8, dir: [*c]const u8, on_extract_entry: ?fn ([*c]const u8, ?*anyopaque) callconv(.C) c_int, arg: ?*anyopaque) c_int; -pub const ZIP_DEFAULT_COMPRESSION_LEVEL = @as(c_int, 6); -pub const ZIP_ENOINIT = -@as(c_int, 1); -pub const ZIP_EINVENTNAME = -@as(c_int, 2); -pub const ZIP_ENOENT = -@as(c_int, 3); -pub const ZIP_EINVMODE = -@as(c_int, 4); -pub const ZIP_EINVLVL = -@as(c_int, 5); -pub const ZIP_ENOSUP64 = -@as(c_int, 6); -pub const ZIP_EMEMSET = -@as(c_int, 7); -pub const ZIP_EWRTENT = -@as(c_int, 8); -pub const ZIP_ETDEFLINIT = -@as(c_int, 9); -pub const ZIP_EINVIDX = -@as(c_int, 10); -pub const ZIP_ENOHDR = -@as(c_int, 11); -pub const ZIP_ETDEFLBUF = -@as(c_int, 12); -pub const ZIP_ECRTHDR = -@as(c_int, 13); -pub const ZIP_EWRTHDR = -@as(c_int, 14); -pub const ZIP_EWRTDIR = -@as(c_int, 15); -pub const ZIP_EOPNFILE = -@as(c_int, 16); -pub const ZIP_EINVENTTYPE = -@as(c_int, 17); -pub const ZIP_EMEMNOALLOC = -@as(c_int, 18); -pub const ZIP_ENOFILE = -@as(c_int, 19); -pub const ZIP_ENOPERM = -@as(c_int, 20); -pub const ZIP_EOOMEM = -@as(c_int, 21); -pub const ZIP_EINVZIPNAME = -@as(c_int, 22); -pub const ZIP_EMKDIR = -@as(c_int, 23); -pub const ZIP_ESYMLINK = -@as(c_int, 24); -pub const ZIP_ECLSZIP = -@as(c_int, 25); -pub const ZIP_ECAPSIZE = -@as(c_int, 26); -pub const ZIP_EFSEEK = -@as(c_int, 27); -pub const ZIP_EFREAD = -@as(c_int, 28); -pub const ZIP_EFWRITE = -@as(c_int, 29); -pub const zip_t = struct_zip_t; - -/// Frees a buffer returned by `zip_stream_copy` (C `calloc`). Only used on wasm. -pub extern fn fizzy_zip_free(ptr: ?*anyopaque) void; diff --git a/src/plugins/pixelart/src/dialogs/Export.zig b/src/plugins/pixelart/src/dialogs/Export.zig deleted file mode 100644 index a732c52c..00000000 --- a/src/plugins/pixelart/src/dialogs/Export.zig +++ /dev/null @@ -1,1040 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const dvui = @import("dvui"); -const msf_gif = @import("msf_gif"); -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 ExportImageFormat = enum { png, jpg }; - -pub var mode: enum(usize) { - single, - animation, - layer, - all, -} = .animation; - -pub var scale: f32 = 1.0; - -pub var scroll_info: dvui.ScrollInfo = .{ - .horizontal = .auto, - .vertical = .auto, -}; - -pub var scroll_info_full: dvui.ScrollInfo = .{ - .horizontal = .auto, - .vertical = .auto, -}; - -pub const max_size: [2]u32 = .{ 4096, 4096 }; -pub const min_size: [2]u32 = .{ 1, 1 }; - -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 { - const idx = file.selected_animation_index orelse return null; - if (idx >= file.animations.len) return null; - return idx; -} - -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); - } - - var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); - defer outer_box.deinit(); - - { // Mode selector - - var horizontal_box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ .expand = .none, .gravity_x = 0.5, .margin = .all(4) }); - defer horizontal_box.deinit(); - - const field_names = std.meta.fieldNames(@TypeOf(mode)); - - for (field_names, 0..) |tag, i| { - const corner_radius: dvui.Rect = if (i == 0) .{ - .x = 100000, - .h = 100000, - } else if (i == field_names.len - 1) .{ - .y = 100000, - .w = 100000, - } else .all(0); - - var name = dvui.currentWindow().arena().dupe(u8, tag) catch { - dvui.log.err("Failed to dupe tag {s}", .{tag}); - return false; - }; - @memcpy(name.ptr, tag); - name[0] = std.ascii.toUpper(name[0]); - - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{}, .{ - .corner_radius = corner_radius, - .id_extra = i, - .margin = .{ .y = 2, .h = 4 }, - .padding = .all(6), - .expand = .horizontal, - .color_fill = if (mode == @as(@TypeOf(mode), @enumFromInt(i))) dvui.themeGet().color(.window, .fill).lighten(-4) else dvui.themeGet().color(.control, .fill), - .box_shadow = if (i != @intFromEnum(mode)) .{ - .color = .black, - .offset = .{ .x = 0.0, .y = 2 }, - .fade = 7.0, - .alpha = 0.2, - .corner_radius = corner_radius, - .shrink = 0, - } else null, - }); - defer button.deinit(); - if (i != @intFromEnum(mode)) { - button.processEvents(); - } - - var clip_rect = button.data().rectScale().r; - - clip_rect.y -= 10000; - clip_rect.h += 20000; - - if (i == 0) { - clip_rect.x -= 10000; - clip_rect.w += 10000; - } else if (i == field_names.len - 1) { - clip_rect.w += 10000; - } - - const clip = dvui.clip(clip_rect); - defer dvui.clipSet(clip); - - button.drawFocus(); - button.drawBackground(); - - dvui.labelNoFmt(@src(), name, .{}, .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = if (mode == @as(@TypeOf(mode), @enumFromInt(i))) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), - .margin = .all(0), - .padding = .all(0), - }); - - if (button.clicked()) { - mode = @enumFromInt(i); - if (mode == .animation) { - anim_frame_index = 0; - dvui.currentWindow().timerRemove(id); - } - // Second layout pass after the scroll+preview id stabilizes; avoids one blank frame. - dvui.currentWindow().extra_frames_needed = 2; - } - } - } - - const mode_valid: bool = switch (mode) { - .single => try singleDialog(id), - .animation => try animationDialog(id), - .layer => try layerDialog(id), - .all => try allDialog(id), - }; - - return mode_valid and (Globals.state.docs.activeFile(Globals.state.host) != null); -} - -pub fn singleDialog(_: dvui.Id) anyerror!bool { - const max_gif_size: [2]f32 = .{ 1024, 1024 }; - var max_scale: f32 = 16.0; - var valid: bool = false; - - 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 (Globals.state.docs.activeFile(Globals.state.host)) |file| { - if (file.editor.selected_sprites.findFirstSet()) |sprite_index| { - renderExportPreviewSprite(file, sprite_index); - } - } - - exportScaleSlider(max_scale); - - 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); - exportDimensionsLabelForExport(column_width, row_height); - } - } - - return valid; -} - -pub fn animationDialog(id: dvui.Id) anyerror!bool { - const max_gif_size: [2]f32 = .{ 1024, 1024 }; - var max_scale: f32 = 16.0; - var preview_sprite: ?usize = null; - - 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))), - ); - if (exportAnimationIndex(file)) |animation_index| { - const anim = file.animations.get(animation_index); - - if (anim.frames.len > 0) { - if (anim_frame_index >= anim.frames.len) anim_frame_index = 0; - - const frame_ms = anim.frames[anim_frame_index].ms; - if (dvui.timerGet(id) == null) { - dvui.timer(id, @intCast(frame_ms * 1000)); - } else if (dvui.timerDone(id)) { - anim_frame_index = (anim_frame_index + 1) % anim.frames.len; - const next_ms = anim.frames[anim_frame_index].ms; - dvui.timer(id, @intCast(next_ms * 1000)); - dvui.currentWindow().extra_frames_needed = 1; - } - - preview_sprite = anim.frames[anim_frame_index].sprite_index; - } - } else if (file.animations.len == 0) { - dvui.labelNoFmt(@src(), "This file has no animations.", .{}, .{ - .gravity_x = 0.5, - .color_text = dvui.themeGet().color(.control, .text), - .margin = .{ .y = 8, .h = 8 }, - }); - } else { - dvui.labelNoFmt(@src(), "Select an animation in the editor.", .{}, .{ - .gravity_x = 0.5, - .color_text = dvui.themeGet().color(.control, .text), - .margin = .{ .y = 8, .h = 8 }, - }); - } - } - - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { - if (preview_sprite) |sprite_index| { - renderExportPreviewSprite(file, sprite_index); - } - } - - exportScaleSlider(max_scale); - - if (preview_sprite) |_| { - 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); - } - } - - return preview_sprite != null; -} - -pub fn layerDialog(_: dvui.Id) anyerror!bool { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { - renderExportPreview(file, .layer); - } - if (Globals.state.docs.activeFile(Globals.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| { - renderExportPreview(file, .composite); - } - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { - exportDimensionsLabelForExport(file.width(), file.height()); - } - return true; -} - -pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void { - switch (response) { - .ok => { - switch (mode) { - .animation => { - const default = blk: { - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { - break :blk "animation.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", .{}); - return; - }; - - break :blk default_filename; - }; - - Globals.state.host.showSaveDialog( - saveAnimationCallback, - &[_]pixelart.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 sprite_index = file.editor.selected_sprites.findFirstSet() orelse return; - - const base = file.spriteExportName(Globals.allocator(), sprite_index) catch { - dvui.log.err("Failed to allocate default export name", .{}); - return; - }; - defer Globals.allocator().free(base); - - const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch { - dvui.log.err("Failed to allocate filename", .{}); - return; - }; - defer Globals.allocator().free(default); - - Globals.state.host.showSaveDialog( - exportCurrentSpriteCallback, - &[_]pixelart.sdk.SaveDialogFilter{ - .{ .name = "PNG", .pattern = "png" }, - .{ .name = "JPEG", .pattern = "jpg;jpeg" }, - }, - default, - null, - ); - }, - .layer => { - 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 Globals.allocator().free(base); - - const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch { - dvui.log.err("Failed to allocate filename", .{}); - return; - }; - defer Globals.allocator().free(default); - - Globals.state.host.showSaveDialog( - exportLayerCallback, - &[_]pixelart.sdk.SaveDialogFilter{ - .{ .name = "PNG", .pattern = "png" }, - .{ .name = "JPEG", .pattern = "jpg;jpeg" }, - }, - default, - null, - ); - }, - .all => { - 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 Globals.allocator().free(base); - - const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch { - dvui.log.err("Failed to allocate filename", .{}); - return; - }; - defer Globals.allocator().free(default); - - Globals.state.host.showSaveDialog( - exportAllCallback, - &[_]pixelart.sdk.SaveDialogFilter{ - .{ .name = "PNG", .pattern = "png" }, - .{ .name = "JPEG", .pattern = "jpg;jpeg" }, - }, - default, - null, - ); - }, - } - }, - .cancel => {}, - else => {}, - } -} - -/// 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 { - const sprite_rect = file.spriteRect(sprite_index); - const max_size_content: dvui.Size = .{ - .w = (dvui.currentWindow().rect_pixels.w / dvui.currentWindow().natural_scale) / 2, - .h = (dvui.currentWindow().rect_pixels.h / dvui.currentWindow().natural_scale) / 2.0, - }; - const min_size_content: dvui.Size = sprite_rect.justSize().scale(scale, dvui.Rect).size(); - - var scroll_area = dvui.scrollArea(@src(), .{ - .scroll_info = &scroll_info, - .horizontal_bar = .auto_overlay, - .vertical_bar = .auto_overlay, - }, .{ - .background = false, - .expand = .both, - .max_size_content = .{ .w = max_size_content.w, .h = max_size_content.h }, - }); - defer scroll_area.deinit(); - - { - var box = dvui.box(@src(), .{ - .dir = .horizontal, - }, .{ - .expand = .none, - .min_size_content = min_size_content, - .gravity_x = 0.5, - }); - defer box.deinit(); - - const uv = dvui.Rect{ - .x = sprite_rect.x / @as(f32, @floatFromInt(file.width())), - .y = sprite_rect.y / @as(f32, @floatFromInt(file.height())), - .w = sprite_rect.w / @as(f32, @floatFromInt(file.width())), - .h = sprite_rect.h / @as(f32, @floatFromInt(file.height())), - }; - - // Same tiled checker + tone as layer/all. Sprite box natural space is (0,0)–(sw×scale,sh×scale) - // (see `min_size_content`), not file coordinates—geometry must be local, UVs use file `sprite_rect`. - 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(.{ - .file = file, - .rs = box.data().rectScale(), - .uv = uv, - }) catch { - dvui.log.err("Failed to render layers", .{}); - }; - } -} - -fn exportScaleSlider(max_scale_val: f32) void { - if (dvui.sliderEntry(@src(), "Scale: {d}", .{ .value = &scale, .min = 1, .max = max_scale_val, .interval = 1 }, .{ - .expand = .horizontal, - .box_shadow = .{ - .color = .black, - .offset = .{ .x = 0.0, .y = 3 }, - .fade = 5.0, - .alpha = 0.2, - .corner_radius = .all(100000), - }, - .color_fill = dvui.themeGet().color(.window, .fill).lighten(-4), - .color_fill_hover = dvui.themeGet().color(.window, .fill).lighten(2), - .corner_radius = .all(100000), - .margin = .all(6), - })) dvui.currentWindow().extra_frames_needed = 2; -} - -fn exportDimensionsLabelForExport(column_w: u32, row_h: u32) void { - const entry_font = dvui.Font.theme(.mono); - DimensionsLabel.drawDimensionsLabel(@src(), column_w, row_h, entry_font, "px", .{ .gravity_x = 0.5 }); -} - -const ExportFullPreviewKind = enum { layer, composite }; - -const CheckerboardPalette = struct { - tone: dvui.Color, - c_tl: dvui.Color, - c_tr: dvui.Color, - c_bl: dvui.Color, - c_br: dvui.Color, -}; - -fn exportCheckerboardPalette() CheckerboardPalette { - const tone = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(dvui.currentWindow().alpha); - const c_tl = tone; - const c_tr = tone.lerp(.red, 0.18); - const c_bl = tone.lerp(.blue, 0.12); - const c_br = c_tr.lerp(c_bl, 0.5); - return .{ .tone = tone, .c_tl = c_tl, .c_tr = c_tr, .c_bl = c_bl, .c_br = c_br }; -} - -fn exportCheckerboardGridColorBilinear( - c_tl: dvui.Color, - c_tr: dvui.Color, - c_bl: dvui.Color, - c_br: dvui.Color, - u: f32, - v: f32, -) dvui.Color { - const top = c_tl.lerp(c_tr, u); - const bottom = c_bl.lerp(c_br, u); - return top.lerp(bottom, v); -} - -fn exportCheckerboardVertexColor( - c_tl: dvui.Color, - c_tr: dvui.Color, - c_bl: dvui.Color, - c_br: dvui.Color, - u: f32, - v: f32, - mu: f32, - mv: f32, - tone: dvui.Color, -) dvui.Color { - const c_corner = exportCheckerboardGridColorBilinear(c_tl, c_tr, c_bl, c_br, u, v); - const du = u - mu; - const dv = v - mv; - const dist = @sqrt(du * du + dv * dv); - var t = @min(@max(dist * 1.55, 0), 1); - t = t * t * (3.0 - 2.0 * t); - 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| { - var animation_index: ?usize = null; - - if (file.selected_animation_index) |selected_animation_index| { - for (file.animations.items(.frames)[selected_animation_index]) |frame| { - if (frame.sprite_index == sprite_index) { - animation_index = selected_animation_index; - break; - } - } - } - - if (animation_index == null) { - anim_blk: for (file.animations.items(.frames), 0..) |frames, i| { - for (frames) |frame| { - if (frame.sprite_index == sprite_index) { - animation_index = i; - break :anim_blk; - } - } - } - } - - if (animation_index) |ai| { - const id = file.animations.get(ai).id; - return palette.getDVUIColor(@intCast(id)); - } - } - return null; -} - -fn exportCheckerboardCellCornerColor( - file: *pixelart.internal.File, - sprite_index: usize, - pal: CheckerboardPalette, - u: f32, - v: f32, -) dvui.Color { - 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 => { - if (exportSpriteAnimationPaletteColor(file, sprite_index)) |ac| { - const row = file.rowFromIndex(sprite_index); - const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0); - const v_cell_top = @as(f32, @floatFromInt(row)) / rows_f; - const v_cell_bot = @as(f32, @floatFromInt(row + 1)) / rows_f; - const v_mid = (v_cell_top + v_cell_bot) * 0.5; - if (v <= v_mid) return pal.tone; - return pal.tone.lerp(ac, 0.4); - } - return pal.tone; - }, - } -} - -/// One quad per sprite cell, UV 0..1 (matches `FileWidget.drawCheckerboardCellsBatched`). -fn appendCheckerboardCellQuad( - builder: *dvui.Triangles.Builder, - quad_idx: *usize, - file: *pixelart.internal.File, - sprite_index: usize, - pal: CheckerboardPalette, - geometry_natural: dvui.Rect, - rs_box: dvui.RectScale, -) void { - if (geometry_natural.w <= 0 or geometry_natural.h <= 0) return; - - const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0); - const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0); - const col_i = file.columnFromIndex(sprite_index); - const row_i = file.rowFromIndex(sprite_index); - const u_left = @as(f32, @floatFromInt(col_i)) / cols_f; - const u_right = @as(f32, @floatFromInt(col_i + 1)) / cols_f; - const v_top = @as(f32, @floatFromInt(row_i)) / rows_f; - const v_bot = @as(f32, @floatFromInt(row_i + 1)) / rows_f; - - const r = rs_box.rectToPhysical(geometry_natural); - const tl = r.topLeft(); - const tr = r.topRight(); - const br = r.bottomRight(); - const bl = r.bottomLeft(); - - const pma_tl = dvui.Color.PMA.fromColor(exportCheckerboardCellCornerColor(file, sprite_index, pal, u_left, v_top)); - const pma_tr = dvui.Color.PMA.fromColor(exportCheckerboardCellCornerColor(file, sprite_index, pal, u_right, v_top)); - const pma_br = dvui.Color.PMA.fromColor(exportCheckerboardCellCornerColor(file, sprite_index, pal, u_right, v_bot)); - const pma_bl = dvui.Color.PMA.fromColor(exportCheckerboardCellCornerColor(file, sprite_index, pal, u_left, v_bot)); - - builder.appendVertex(.{ .pos = tl, .col = pma_tl, .uv = .{ 0, 0 } }); - builder.appendVertex(.{ .pos = tr, .col = pma_tr, .uv = .{ 1, 0 } }); - builder.appendVertex(.{ .pos = br, .col = pma_br, .uv = .{ 1, 1 } }); - builder.appendVertex(.{ .pos = bl, .col = pma_bl, .uv = .{ 0, 1 } }); - - const quad_base: dvui.Vertex.Index = @intCast(quad_idx.* * 4); - builder.appendTriangles(&.{ quad_base + 1, quad_base + 0, quad_base + 3, quad_base + 1, quad_base + 3, quad_base + 2 }); - quad_idx.* += 1; -} - -fn drawCheckerboardCell( - file: *pixelart.internal.File, - sprite_index: usize, - geometry_natural: dvui.Rect, - rs_box: dvui.RectScale, -) void { - const tex = file.checkerboardTileTexture() orelse return; - - const pal = exportCheckerboardPalette(); - const arena = dvui.currentWindow().arena(); - var builder = dvui.Triangles.Builder.init(arena, 4, 6) catch return; - defer builder.deinit(arena); - - var quad_idx: usize = 0; - appendCheckerboardCellQuad(&builder, &quad_idx, file, sprite_index, pal, geometry_natural, rs_box); - if (quad_idx == 0) return; - - const triangles = builder.build(); - dvui.renderTriangles(triangles, tex) catch { - dvui.log.err("Failed to render export preview checkerboard", .{}); - }; -} - -fn drawCheckerboardFileGrid(file: *pixelart.internal.File, rs_box: dvui.RectScale) void { - const n = file.spriteCount(); - if (n == 0) return; - - const tex = file.checkerboardTileTexture() orelse return; - - const pal = exportCheckerboardPalette(); - const arena = dvui.currentWindow().arena(); - var builder = dvui.Triangles.Builder.init(arena, n * 4, n * 6) catch return; - defer builder.deinit(arena); - - var quad_idx: usize = 0; - for (0..n) |i| { - appendCheckerboardCellQuad(&builder, &quad_idx, file, i, pal, file.spriteRect(i), rs_box); - } - - if (quad_idx == 0) return; - - const triangles = builder.build(); - dvui.renderTriangles(triangles, tex) catch { - dvui.log.err("Failed to render export preview checkerboard", .{}); - }; -} - -/// 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 { - const w = file.width(); - const h = file.height(); - if (w == 0 or h == 0) return; - - if (kind == .composite) { - pixelart.render.syncLayerComposite(file) catch { - dvui.log.err("Export preview: failed to build layer composite", .{}); - return; - }; - } - - const max_size_content: dvui.Size = .{ - .w = (dvui.currentWindow().rect_pixels.w / dvui.currentWindow().natural_scale) / 2, - .h = (dvui.currentWindow().rect_pixels.h / dvui.currentWindow().natural_scale) / 2.0, - }; - const min_size_content: dvui.Size = .{ .w = @floatFromInt(w), .h = @floatFromInt(h) }; - - var scroll_area = dvui.scrollArea(@src(), .{ - .scroll_info = &scroll_info_full, - .horizontal_bar = .auto_overlay, - .vertical_bar = .auto_overlay, - }, .{ - .background = false, - .expand = .both, - .max_size_content = .{ .w = max_size_content.w, .h = max_size_content.h }, - }); - defer scroll_area.deinit(); - - { - var box = dvui.box(@src(), .{ - .dir = .horizontal, - }, .{ - .expand = .none, - .min_size_content = min_size_content, - .gravity_x = 0.5, - }); - defer box.deinit(); - - drawCheckerboardFileGrid(file, box.data().rectScale()); - - 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()); - 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 { - return; - }; - defer tris.deinit(Globals.allocator()); - tris.uvFromRectuv(rs.r, full_uv); - - switch (kind) { - .layer => { - const layer = file.layers.get(file.selected_layer_index); - if (layer.visible) { - if (layer.source.getTexture() catch null) |tex| { - dvui.renderTriangles(tris, tex) catch { - dvui.log.err("Failed to render layer for export preview", .{}); - }; - } - } - }, - .composite => { - if (file.editor.layer_composite_target) |ct| { - if (dvui.Texture.fromTargetTemp(ct) catch null) |ctex| { - dvui.renderTriangles(tris, ctex) catch { - dvui.log.err("Failed to draw composite for export preview", .{}); - }; - } - } - }, - } - } -} - -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()); - errdefer out.deinit(); - switch (format) { - .png => try pixelart.image.writePngToWriter(source, &out.writer, 0), - .jpg => try pixelart.image.writeJpgPpiToWriter(source, &out.writer, 0), - } - const bytes = try out.toOwnedSlice(); - defer Globals.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), - } -} - -fn writeGifBytes(path: []const u8, data: []const u8) !void { - if (comptime builtin.target.cpu.arch == .wasm32) { - try WebFileIo.downloadBytes(path, data); - return; - } - try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = data }); -} - -/// 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 { - const sprite_rect = file.spriteRect(sprite_index); - const w: usize = @intFromFloat(sprite_rect.w); - const h: usize = @intFromFloat(sprite_rect.h); - - var front: usize = 0; - while (front < file.layers.len) : (front += 1) { - const layer = file.layers.get(front); - if (!layer.visible) continue; - - const pixels = layer.pixelsFromRect(allocator, sprite_rect) orelse continue; - errdefer allocator.free(pixels); - - var behind = front + 1; - while (behind < file.layers.len) : (behind += 1) { - const lower = file.layers.get(behind); - if (!lower.visible) continue; - - 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); - } - - return pixels; - } - - return error.NoPixels; -} - -// This is for use with the SDL dialogs, but currently the SDL dialogs dont support sending the default path -// on macOS, so we are going to use the native dialogs instead. -pub fn saveAnimationCallback(paths: ?[][:0]const u8) void { - if (paths) |paths_| { - for (paths_) |path| { - createAnimationGif(path) catch |err| { - dvui.log.err("Failed to save animation: {any}", .{err}); - }; - } - } -} - -pub fn exportCurrentSpriteCallback(paths: ?[][:0]const u8) void { - if (paths) |paths_| { - for (paths_) |path| { - exportCurrentSprite(path) catch |err| { - dvui.log.err("Failed to save image: {any}", .{err}); - }; - } - } -} - -pub fn exportLayerCallback(paths: ?[][:0]const u8) void { - if (paths) |paths_| { - for (paths_) |path| { - exportLayerToPath(path) catch |err| { - dvui.log.err("Failed to save layer: {any}", .{err}); - }; - } - } -} - -pub fn exportAllCallback(paths: ?[][:0]const u8) void { - if (paths) |paths_| { - for (paths_) |path| { - exportAllToPath(path) catch |err| { - dvui.log.err("Failed to save image: {any}", .{err}); - }; - } - } -} - -pub fn exportCurrentSprite(path: []const u8) anyerror!void { - const ext = std.fs.path.extension(path); - const is_png = std.mem.eql(u8, ext, ".png"); - const is_jpg = std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg"); - if (!is_png and !is_jpg) { - dvui.log.err("Export: File must be .png or .jpg, got {s}", .{ext}); - return error.InvalidExtension; - } - - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { - dvui.log.err("Export: No active file", .{}); - return error.NoActiveFile; - }; - const sprite_index = file.editor.selected_sprites.findFirstSet() orelse { - dvui.log.err("Export: No tile selected", .{}); - return error.NoSelectedTile; - }; - - var export_width: u32 = file.column_width; - var export_height: u32 = file.row_height; - if (scale != 1.0) { - export_width = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale); - export_height = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale); - } - - const pixels = try compositedSpritePixels(Globals.allocator(), file, sprite_index); - defer Globals.allocator().free(pixels); - - if (scale != 1.0) { - const resized = Globals.allocator().alloc([4]u8, export_width * export_height) catch { - return error.OutOfMemory; - }; - defer Globals.allocator().free(resized); - if (zstbi.resize( - pixels, - file.column_width, - file.row_height, - resized, - export_width, - export_height, - ) == null) { - return error.ResizeFailed; - } - - const src: dvui.ImageSource = .{ .pixels = .{ - .rgba = std.mem.sliceAsBytes(resized), - .width = export_width, - .height = export_height, - } }; - const format: ExportImageFormat = if (is_png) .png else .jpg; - try writeImageToPath(src, path, format); - } else { - const src: dvui.ImageSource = .{ .pixels = .{ - .rgba = std.mem.sliceAsBytes(pixels), - .width = file.column_width, - .height = file.row_height, - } }; - const format: ExportImageFormat = if (is_png) .png else .jpg; - try writeImageToPath(src, path, format); - } -} - -pub fn exportLayerToPath(path: []const u8) anyerror!void { - const ext = std.fs.path.extension(path); - const is_png = std.mem.eql(u8, ext, ".png"); - const is_jpg = std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg"); - if (!is_png and !is_jpg) { - dvui.log.err("Export: File must be .png, .jpg, or .jpeg, got {s}", .{ext}); - return error.InvalidExtension; - } - - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { - dvui.log.err("Export: No active file", .{}); - return error.NoActiveFile; - }; - - const layer = file.layers.get(file.selected_layer_index); - const src = layer.source; - const format: ExportImageFormat = if (is_png) .png else .jpg; - try writeImageToPath(src, path, format); -} - -pub fn exportAllToPath(path: []const u8) anyerror!void { - const ext = std.fs.path.extension(path); - const is_png = std.mem.eql(u8, ext, ".png"); - const is_jpg = std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg"); - if (!is_png and !is_jpg) { - dvui.log.err("Export: File must be .png, .jpg, or .jpeg, got {s}", .{ext}); - return error.InvalidExtension; - } - - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { - dvui.log.err("Export: No active file", .{}); - return error.NoActiveFile; - }; - - const w = file.width(); - const h = file.height(); - if (w == 0 or h == 0) return error.InvalidImageSize; - - 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(Globals.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]); - } - - 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; - try writeImageToPath(tmp_layer.source, path, format); -} - -pub fn createAnimationGif(path: []const u8) anyerror!void { - const ext = std.fs.path.extension(path); - const is_gif = std.mem.eql(u8, ext, ".gif"); - - if (!is_gif) { - dvui.log.err("Export: File must end with .gif extension, got {s}", .{ext}); - return error.InvalidExtension; - } - - const file = Globals.state.docs.activeFile(Globals.state.host) orelse { - dvui.log.err("Export: No active file", .{}); - return error.NoActiveFile; - }; - - if (file.animations.len == 0) { - dvui.log.err("Export: No animations in file", .{}); - return error.NoAnimations; - } - - const animation_index = exportAnimationIndex(file) orelse return error.NoSelectedAnimation; - { - const anim: pixelart.internal.Animation = file.animations.get(animation_index); - - var export_width = file.column_width; - var export_height = file.row_height; - - if (scale != 1.0) { - export_width = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale); - export_height = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale); - } - - var handle: msf_gif.MSFGifState = undefined; - _ = msf_gif.begin(&handle, export_width, export_height); - - // Anything less than this number will be considered transparent - // When resizing, sometimes we see a small outline of the pixels? - // Only see in some gif readers, but not all. - msf_gif.msf_gif_alpha_threshold = 240; - - for (anim.frames) |frame| { - const pixels = compositedSpritePixels(Globals.allocator(), file, frame.sprite_index) catch |err| { - if (err == error.NoPixels) continue; - return err; - }; - defer Globals.allocator().free(pixels); - - { // msf_gif will error if there are only transparent pixels - const valid = blk: { - for (pixels) |pixel| { - if (pixel[3] > msf_gif.msf_gif_alpha_threshold) { - break :blk true; - } - } - - break :blk false; - }; - - if (!valid) { - dvui.log.debug("Export: No valid pixels, skipping animation frame", .{}); - continue; - } - } - - if (scale != 1.0) { - const resized_pixels = Globals.allocator().alloc([4]u8, export_width * export_height) catch { - dvui.log.err("Failed to allocate resized pixels", .{}); - continue; - }; - defer Globals.allocator().free(resized_pixels); - - _ = zstbi.resize( - pixels, - file.column_width, - file.row_height, - resized_pixels, - export_width, - export_height, - ); - - _ = msf_gif.frame(&handle, @ptrCast(resized_pixels.ptr), @divTrunc(@as(i32, @intCast(frame.ms)), 10)); - } else { - _ = msf_gif.frame(&handle, @ptrCast(pixels.ptr), @divTrunc(@as(i32, @intCast(frame.ms)), 10)); - } - } - - const result = msf_gif.end(&handle); - defer msf_gif.free(result); - - if (result.data) |data| { - writeGifBytes(path, data[0..result.dataSize]) catch { - dvui.log.err("Failed to write to file {s}", .{path}); - return; - }; - } - - return; - } -} diff --git a/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig deleted file mode 100644 index 213c350b..00000000 --- a/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig +++ /dev/null @@ -1,171 +0,0 @@ -const std = @import("std"); -const dvui = @import("dvui"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; - -pub const Mode = pixelart.sdk.Plugin.FlatRasterSaveMode; - -pub var pending_mode: Mode = .editor_save; - -/// 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; - var mutex = pixelart.core.dvui.dialog(@src(), .{ - .displayFn = dialog, - .callafterFn = callAfter, - .title = "Save as .fiz or current extension?", - .ok_label = "", - .cancel_label = "", - .resizeable = false, - .default = .cancel, - .hide_footer = true, - .max_size = .{ .w = 520, .h = 300 }, - .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); -} - -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 { - const opts: dvui.Options = .{ - .tab_index = tab_idx, - .style = style, - .id_extra = id_extra, - .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(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); - const ext_disp = blk: { - var buf: [32]u8 = undefined; - if (ext_raw.len > buf.len) break :blk ext_raw; - break :blk std.ascii.lowerString(&buf, ext_raw); - }; - - const bold_hi = dvui.Font.theme(.body).withWeight(.bold); - const hi_fill = dvui.themeGet().color(.highlight, .fill); - - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(8) }); - defer outer.deinit(); - - { - var tl = dvui.textLayout(@src(), .{}, .{ - .expand = .horizontal, - .background = false, - }); - tl.addText("File contains data only compatible with the ", .{ .font = dvui.Font.theme(.body) }); - tl.addText(".fiz", .{ .font = bold_hi, .color_text = hi_fill }); - tl.addText(" extension. Would you like to save a copy of your file as a ", .{ .font = dvui.Font.theme(.body) }); - tl.addText(".fiz", .{ .font = bold_hi, .color_text = hi_fill }); - tl.format(" extension or proceed saving as a {s}?", .{ext_disp}, .{ .font = dvui.Font.theme(.body) }); - tl.deinit(); - } - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 16 } }); - - var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer row.deinit(); - - var btn_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .gravity_x = 0.5 }); - defer btn_row.deinit(); - - if (dialogButton(@src(), ".fiz", .highlight, 1, 0)) { - try onChooseFizzy(file_id); - } - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } }); - if (dialogButton(@src(), ext_disp, .control, 2, 1)) { - try onChooseFlatRaster(file_id, from_quit); - } - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } }); - if (dialogButton(@src(), "Cancel", .control, 3, 2)) { - onCancel(); - } - - return true; -} - -fn onChooseFizzy(file_id: u64) !void { - const idx = Globals.state.host.docIndex(file_id) orelse return; - Globals.state.host.setActiveDocIndex(idx); - if (pending_mode == .save_and_close) { - Globals.state.host.setPendingCloseDocId(file_id); - } - pixelart.core.dvui.closeFloatingDialogAnchored(); - Globals.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(); - 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); - } else { - try f.saveAsync(); - } - }, - .save_and_close => { - // Kick off async; close happens once the worker settles (see - // Editor.tickPendingSaveCloses / advanceSaveAllQuit). When this dialog - // was reached from save-all quit, route the close through the quit - // walker's in-flight set so it gates pending_app_close correctly; - // 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(); - return; - }; - 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(); - return; - }; - Globals.state.host.resumeSaveAllQuit(); - } else { - try Globals.state.host.queueCloseAfterSave(file_id); - } - pixelart.core.dvui.closeFloatingDialogAnchored(); - }, - } -} - -fn onCancel() void { - Globals.state.host.cancelPendingSaveDialog(); - pixelart.core.dvui.closeFloatingDialogAnchored(); -} - -pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) !void { - switch (response) { - .cancel => Globals.state.host.cancelPendingSaveDialog(), - else => {}, - } -} diff --git a/src/plugins/pixelart/src/dialogs/GridLayout.zig b/src/plugins/pixelart/src/dialogs/GridLayout.zig deleted file mode 100644 index b45f942c..00000000 --- a/src/plugins/pixelart/src/dialogs/GridLayout.zig +++ /dev/null @@ -1,1762 +0,0 @@ -//! "Grid Layout" dialog: change the file's `column_width × row_height` (cell size) -//! and `columns × rows` (cell count) with a per-cell anchor that decides how each -//! existing tile is padded into a larger cell or cropped into a smaller one. -//! -//! The middle is a horizontal strip: intrinsic-width form column on the left (vertical fill), -//! 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 dvui = @import("dvui"); -const std = @import("std"); - -const NewFile = @import("NewFile.zig"); -const CanvasWidget = pixelart.core.dvui.CanvasWidget; -const CanvasBridge = @import("../widgets/CanvasBridge.zig"); -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 { - column_width: u32 = 32, - row_height: u32 = 32, - columns: u32 = 1, - rows: u32 = 1, -}; - -/// Resize tab form — module scope so `callAfter` can read it after the window closes. -pub var resize_form: GridFormState = .{}; -/// Slice tab form — independent from resize so switching pills does not overwrite the other side. -pub var slice_form: GridFormState = .{}; -/// Index into `anchors`/`anchor_labels`. 4 == .c (centered). Resize mode only. -pub var anchor_ix: usize = 4; - -/// Two top-level operations on the grid: -/// `.resize` — change cell count and/or cell pixel size; per-cell content is re-anchored -/// (padded on growth, cropped on shrink) using the user-chosen anchor. -/// `.slice` — metadata-only grid; own form backing (`slice_form`). Preview draws the full -/// layer composite (not per-sprite remapping) plus grid overlay. -pub const Mode = enum { slice, resize }; -pub var mode: Mode = .resize; - -// Slice auto-link: previous frame's slice form fields. -var slice_prev_columns: u32 = 1; -var slice_prev_rows: u32 = 1; -var slice_prev_column_width: u32 = 32; -var slice_prev_row_height: u32 = 32; - -var preview_canvas: CanvasWidget = .{}; - -var left_scroll: dvui.ScrollInfo = .{ .horizontal = .auto }; -/// Middle region only (below the fixed header + mode pill): scrolls when form + preview exceed viewport height. -var dialog_middle_scroll: dvui.ScrollInfo = .{ .horizontal = .auto, .vertical = .auto }; - -/// Last preview pane size used for `applyPreferredScaleToHost`; reset in `presetFromFile`. -var preview_pane_fit_w: f32 = 0; -var preview_pane_fit_h: f32 = 0; - -/// Scroll viewport size from the previous frame — fits scale/center to the real preview port, not a too-large parent rect before layout settles. -var preview_viewport_fit_w: f32 = 0; -var preview_viewport_fit_h: f32 = 0; - -/// `slice_full_layer` + `nw` + `nh` — when the Slice/Resize tab or pixel size changes, refit even if `nw==preview_last_nw`. -var preview_fit_key_cache: u64 = 0; - -/// Last `{nw, nh}` we applied a preferred scale/fit for; reset in `presetFromFile`. -var preview_last_nw: u32 = 0; -var preview_last_nh: u32 = 0; - -/// Fade preview after Slice↔Resize or the first fit-key refit on open — not window resize. -var preview_content_alpha: f32 = 1.0; -var preview_first_open_fade_pending: bool = false; -var preview_have_prev_slice_mode: bool = false; -var preview_prev_slice_full_layer: bool = false; - -/// Refit the preview when host/viewport sizes differ by more than this many **logical** pixels. -/// A threshold of ~1px skipped refits during very slow corner-resize drags (sub-pixel per frame on -/// 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 = .{ - .nw, .n, .ne, - .w, .c, .e, - .sw, .s, .se, -}; - -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 = .{ - .column_width = file.column_width, - .row_height = file.row_height, - .columns = file.columns, - .rows = file.rows, - }; - slice_form = resize_form; - anchor_ix = 4; - mode = .resize; - preview_last_nw = 0; - preview_last_nh = 0; - - slice_prev_columns = slice_form.columns; - slice_prev_rows = slice_form.rows; - slice_prev_column_width = slice_form.column_width; - slice_prev_row_height = slice_form.row_height; - - // The preview canvas is module-global so its state (scale, origin, prev_size, first/second - // center flags, scroll viewport) survives across dialog opens. On a re-open the cached - // `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 = .{}; - left_scroll = .{ .horizontal = .auto }; - dialog_middle_scroll = .{ .horizontal = .auto, .vertical = .auto }; - preview_pane_fit_w = 0; - preview_pane_fit_h = 0; - preview_viewport_fit_w = 0; - preview_viewport_fit_h = 0; - preview_fit_key_cache = 0; - preview_content_alpha = 0.0; - preview_first_open_fade_pending = true; - preview_have_prev_slice_mode = false; -} - -/// 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()) - else - content_color; - } - return content_color; -} - -/// Match `FileWidget.drawLayers`: window fill, then content fill (`fade = 1.5`), same order and colors. -fn drawPreviewViewportBackdrop(rs_box: dvui.RectScale, nw: f32, nh: f32) void { - if (nw <= 0 or nh <= 0) return; - const natural = dvui.Rect{ .x = 0, .y = 0, .w = nw, .h = nh }; - const phys = rs_box.rectToPhysical(natural); - phys.fill(.all(0), .{ .color = dvui.themeGet().color(.window, .fill), .fade = 1.0 }); - phys.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 }); -} - -fn previewCheckerboardPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr: dvui.Color, c_bl: dvui.Color, c_br: dvui.Color } { - const tone = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(dvui.currentWindow().alpha); - const c_tl = tone; - const c_tr = tone.lerp(.red, 0.18); - const c_bl = tone.lerp(.blue, 0.12); - const c_br = c_tr.lerp(c_bl, 0.5); - return .{ .tone = tone, .c_tl = c_tl, .c_tr = c_tr, .c_bl = c_bl, .c_br = c_br }; -} - -fn previewCheckerboardGridColorBilinear(c_tl: dvui.Color, c_tr: dvui.Color, c_bl: dvui.Color, c_br: dvui.Color, u: f32, v: f32) dvui.Color { - const top = c_tl.lerp(c_tr, u); - const bottom = c_bl.lerp(c_br, u); - return top.lerp(bottom, v); -} - -/// Same rule as `FileWidget.checkerboardVertexColor` (see drawing viewport). -fn previewCheckerboardVertexColor( - c_tl: dvui.Color, - c_tr: dvui.Color, - c_bl: dvui.Color, - c_br: dvui.Color, - u: f32, - v: f32, - mu: f32, - mv: f32, - tone: dvui.Color, -) dvui.Color { - const c_corner = previewCheckerboardGridColorBilinear(c_tl, c_tr, c_bl, c_br, u, v); - const du = u - mu; - const dv = v - mv; - const dist = std.math.sqrt(du * du + dv * dv); - var t = std.math.clamp(dist * 1.55, 0, 1); - t = t * t * (3.0 - 2.0 * t); - return tone.lerp(c_corner, t); -} - -fn updatePreviewCheckerboardMouseUv(cv: *CanvasWidget, nw: f32, nh: f32) dvui.Point { - const mouse_screen = dvui.currentWindow().mouse_pt; - var target_mu: f32 = 0.5; - var target_mv: f32 = 0.5; - if (cv.rect.contains(mouse_screen)) { - const md = cv.screen_rect_scale.pointFromPhysical(mouse_screen); - if (nw > 0) target_mu = std.math.clamp(md.x / nw, 0, 1); - if (nh > 0) target_mv = std.math.clamp(md.y / nh, 0, 1); - } - const prev_uv = dvui.dataGet(null, cv.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 }; - const smooth_t: f32 = 0.15; - const mu = prev_uv.x + (target_mu - prev_uv.x) * smooth_t; - const mv = prev_uv.y + (target_mv - prev_uv.y) * smooth_t; - dvui.dataSet(null, cv.id, "checkerboard_mouse_uv", dvui.Point{ .x = mu, .y = mv }); - return .{ .x = mu, .y = mv }; -} - -/// Grid line color: reads clearly on top of the checker / content fill (brighter in dark theme, darker in light). -fn previewGridLineColor() dvui.Color { - return dvui.themeGet().color(.window, .text).opacity(if (dvui.themeGet().dark) 0.58 else 0.52); -} - -fn font() dvui.Font { - return dvui.Font.theme(.body); -} - -/// 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, - cv: *CanvasWidget, - rs_box: dvui.RectScale, - cols: u32, - rows: u32, - cell_w: f32, - cell_h: f32, -) void { - 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 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; - const nh = cell_h * rows_f; - const mu_mv = updatePreviewCheckerboardMouseUv(cv, nw, nh); - const mu = mu_mv.x; - const mv = mu_mv.y; - - const n_cells = @as(usize, cols) * @as(usize, rows); - const arena = dvui.currentWindow().arena(); - var builder = dvui.Triangles.Builder.init(arena, n_cells * 4, n_cells * 6) catch return; - defer builder.deinit(arena); - - var quad_idx: usize = 0; - var row: u32 = 0; - while (row < rows) : (row += 1) { - var col: u32 = 0; - while (col < cols) : (col += 1) { - const natural = dvui.Rect{ - .x = @as(f32, @floatFromInt(col)) * cell_w, - .y = @as(f32, @floatFromInt(row)) * cell_h, - .w = cell_w, - .h = cell_h, - }; - const r = rs_box.rectToPhysical(natural); - const tl = r.topLeft(); - const tr = r.topRight(); - const br = r.bottomRight(); - const bl = r.bottomLeft(); - - const u_left = @as(f32, @floatFromInt(col)) / cols_f; - const u_right = @as(f32, @floatFromInt(col + 1)) / cols_f; - const v_top = @as(f32, @floatFromInt(row)) / rows_f; - const v_bot = @as(f32, @floatFromInt(row + 1)) / rows_f; - - switch (te) { - .rainbow => { - const p_tl = dvui.Color.PMA.fromColor(previewCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u_left, v_top, mu, mv, pal.tone)); - const p_tr = dvui.Color.PMA.fromColor(previewCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u_right, v_top, mu, mv, pal.tone)); - const p_br = dvui.Color.PMA.fromColor(previewCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u_right, v_bot, mu, mv, pal.tone)); - const p_bl = dvui.Color.PMA.fromColor(previewCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u_left, v_bot, mu, mv, pal.tone)); - builder.appendVertex(.{ .pos = tl, .col = p_tl, .uv = .{ 0, 0 } }); - builder.appendVertex(.{ .pos = tr, .col = p_tr, .uv = .{ 1, 0 } }); - builder.appendVertex(.{ .pos = br, .col = p_br, .uv = .{ 1, 1 } }); - builder.appendVertex(.{ .pos = bl, .col = p_bl, .uv = .{ 0, 1 } }); - }, - .none, .animation => { - const pma = dvui.Color.PMA.fromColor(pal.tone); - builder.appendVertex(.{ .pos = tl, .col = pma, .uv = .{ 0, 0 } }); - builder.appendVertex(.{ .pos = tr, .col = pma, .uv = .{ 1, 0 } }); - builder.appendVertex(.{ .pos = br, .col = pma, .uv = .{ 1, 1 } }); - builder.appendVertex(.{ .pos = bl, .col = pma, .uv = .{ 0, 1 } }); - }, - } - - const quad_base: dvui.Vertex.Index = @intCast(quad_idx * 4); - builder.appendTriangles(&.{ quad_base + 1, quad_base + 0, quad_base + 3, quad_base + 1, quad_base + 3, quad_base + 2 }); - quad_idx += 1; - } - } - - if (quad_idx == 0) return; - - const triangles = builder.build(); - dvui.renderTriangles(triangles, file.checkerboardTileTexture()) catch { - dvui.log.err("Grid layout preview: failed to render checkerboard", .{}); - }; -} - -fn appendGridLineQuad(builder: *dvui.Triangles.Builder, tl: dvui.Point.Physical, br: dvui.Point.Physical, col: dvui.Color.PMA) void { - const base: dvui.Vertex.Index = @intCast(builder.vertexes.items.len); - builder.appendVertex(.{ .pos = tl, .col = col }); - builder.appendVertex(.{ .pos = .{ .x = br.x, .y = tl.y }, .col = col }); - builder.appendVertex(.{ .pos = br, .col = col }); - builder.appendVertex(.{ .pos = .{ .x = tl.x, .y = br.y }, .col = col }); - builder.appendTriangles(&.{ base, base + 1, base + 2, base, base + 2, base + 3 }); -} - -fn drawPreviewGridOverlay( - rs_box: dvui.RectScale, - nw: f32, - nh: f32, - cols_vis: usize, - rows_vis: usize, - proto_cell_w: f32, - proto_cell_h: f32, - canvas_scale: f32, - grid_color: dvui.Color, -) void { - var line_slots: usize = 0; - if (cols_vis > 1) line_slots += cols_vis - 1; - if (rows_vis > 1) line_slots += rows_vis - 1; - if (line_slots == 0) return; - - // Each grid line is drawn twice: a 2×-thick content-fill understroke, then the text-colored stroke on top. - var builder = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), line_slots * 8, line_slots * 12) catch return; - defer builder.deinit(dvui.currentWindow().arena()); - - const cw = dvui.currentWindow(); - const grid_thickness = std.math.clamp(cw.natural_scale * canvas_scale, 0, cw.natural_scale); - const rs_den = @max(rs_box.s, 0.0001); - - const half_phys_fg = @max(grid_thickness, 1.0) * 0.5; - const half_nat_fg = half_phys_fg / rs_den; - - const half_phys_halo = @max(grid_thickness, 1.0); - const half_nat_halo = half_phys_halo / rs_den; - - const pma_halo: dvui.Color.PMA = .fromColor(dvui.themeGet().color(.content, .fill).opacity(cw.alpha)); - const pma_fg: dvui.Color.PMA = .fromColor(grid_color.opacity(cw.alpha)); - - const grid_passes = [_]struct { half_nat: f32, col: dvui.Color.PMA }{ - .{ .half_nat = half_nat_halo, .col = pma_halo }, - .{ .half_nat = half_nat_fg, .col = pma_fg }, - }; - - var ix: usize = 1; - while (ix < cols_vis) : (ix += 1) { - const xf = @as(f32, @floatFromInt(ix)) * proto_cell_w; - for (grid_passes) |pass| { - const r_phys = rs_box.rectToPhysical(.{ - .x = xf - pass.half_nat, - .y = 0, - .w = pass.half_nat * 2, - .h = nh, - }); - appendGridLineQuad(&builder, .{ .x = r_phys.x, .y = r_phys.y }, .{ .x = r_phys.x + r_phys.w, .y = r_phys.y + r_phys.h }, pass.col); - } - } - - var iy: usize = 1; - while (iy < rows_vis) : (iy += 1) { - const yf = @as(f32, @floatFromInt(iy)) * proto_cell_h; - for (grid_passes) |pass| { - const r_phys = rs_box.rectToPhysical(.{ - .x = 0, - .y = yf - pass.half_nat, - .w = nw, - .h = pass.half_nat * 2, - }); - appendGridLineQuad(&builder, .{ .x = r_phys.x, .y = r_phys.y }, .{ .x = r_phys.x + r_phys.w, .y = r_phys.y + r_phys.h }, pass.col); - } - } - - const tris = builder.build_unowned(); - dvui.renderTriangles(tris, null) catch { - dvui.log.err("Grid layout preview: failed to render grid overlay", .{}); - }; -} - -fn appendTexturedRectQuad( - builder: *dvui.Triangles.Builder, - dest_phys: dvui.Rect.Physical, - uv: dvui.Rect, - tint: dvui.Color.PMA, -) void { - const base: dvui.Vertex.Index = @intCast(builder.vertexes.items.len); - const tl = dest_phys.topLeft(); - const tr = dest_phys.topRight(); - const br = dest_phys.bottomRight(); - const bl = dest_phys.bottomLeft(); - builder.appendVertex(.{ .pos = tl, .col = tint, .uv = .{ uv.x, uv.y } }); - builder.appendVertex(.{ .pos = tr, .col = tint, .uv = .{ uv.x + uv.w, uv.y } }); - builder.appendVertex(.{ .pos = br, .col = tint, .uv = .{ uv.x + uv.w, uv.y + uv.h } }); - builder.appendVertex(.{ .pos = bl, .col = tint, .uv = .{ uv.x, uv.y + uv.h } }); - builder.appendTriangles(&.{ base + 1, base, base + 3, base + 1, base + 3, base + 2 }); -} - -/// 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, - rs_box: dvui.RectScale, - old_cols: u32, - old_rows: u32, - old_cw: u32, - old_rh: u32, - new_cols: u32, - new_rows: u32, - new_cw_: u32, - new_rh_: u32, - anchor_vis: pixelart.math.layout_anchor.LayoutAnchor, -) void { - pixelart.render.syncLayerComposite(file) catch { - dvui.log.err("Grid layout preview: composite failed", .{}); - return; - }; - const ct = file.editor.layer_composite_target orelse return; - const ctex = dvui.Texture.fromTargetTemp(ct) catch return; - - const fw_f = @as(f32, @floatFromInt(ct.width)); - const fh_f = @as(f32, @floatFromInt(ct.height)); - - const visible_cols = @min(new_cols, old_cols); - const visible_rows = @min(new_rows, old_rows); - if (visible_cols == 0 or visible_rows == 0) return; - - const quad_count: u32 = visible_cols * visible_rows; - const arena = dvui.currentWindow().arena(); - var builder = dvui.Triangles.Builder.init(arena, quad_count * 4, quad_count * 6) catch return; - 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); - if (blk.sw == 0 or blk.sh == 0) return; - - var nrow: u32 = 0; - while (nrow < visible_rows) : (nrow += 1) { - var ncol: u32 = 0; - while (ncol < visible_cols) : (ncol += 1) { - const dest_natural = dvui.Rect{ - .x = @as(f32, @floatFromInt(ncol * new_cw_ + blk.dx)), - .y = @as(f32, @floatFromInt(nrow * new_rh_ + blk.dy)), - .w = @as(f32, @floatFromInt(blk.sw)), - .h = @as(f32, @floatFromInt(blk.sh)), - }; - const x0_px = ncol * old_cw + blk.sx; - const y0_px = nrow * old_rh + blk.sy; - const uv = dvui.Rect{ - .x = @as(f32, @floatFromInt(x0_px)) / fw_f, - .y = @as(f32, @floatFromInt(y0_px)) / fh_f, - .w = @as(f32, @floatFromInt(blk.sw)) / fw_f, - .h = @as(f32, @floatFromInt(blk.sh)) / fh_f, - }; - const dest_phys = rs_box.rectToPhysical(dest_natural); - appendTexturedRectQuad(&builder, dest_phys, uv, tint); - } - } - - const tris = builder.build_unowned(); - dvui.renderTriangles(tris, ctex) catch { - dvui.log.err("Grid layout preview: failed to render batched composite", .{}); - }; -} - -/// 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 { - if (nw <= 0 or nh <= 0) return; - pixelart.render.syncLayerComposite(file) catch { - dvui.log.err("Grid layout preview: composite failed", .{}); - return; - }; - const ct = file.editor.layer_composite_target orelse return; - const ctex = dvui.Texture.fromTargetTemp(ct) catch return; - - const dest_natural = dvui.Rect{ .x = 0, .y = 0, .w = nw, .h = nh }; - const dest_phys = rs_box.rectToPhysical(dest_natural); - const tint = dvui.Color.PMA.fromColor(dvui.Color.white.opacity(dvui.currentWindow().alpha)); - const uv = dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 }; - - var builder = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), 4, 6) catch return; - defer builder.deinit(dvui.currentWindow().arena()); - appendTexturedRectQuad(&builder, dest_phys, uv, tint); - const tris = builder.build_unowned(); - dvui.renderTriangles(tris, ctex) catch { - dvui.log.err("Grid layout preview: failed to render full composite", .{}); - }; -} - -/// 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 { - const canvas = file.canvasPixelSize(); - const tw = canvas.w; - const th = canvas.h; - if (tw == 0 or th == 0) return; - const s = &slice_form; - const form_tiles_layer = s.columns > 0 and s.column_width > 0 and s.rows > 0 and s.row_height > 0 and - s.columns * s.column_width == tw and s.rows * s.row_height == th; - - if (!form_tiles_layer) { - s.column_width = file.column_width; - s.row_height = file.row_height; - s.columns = file.columns; - s.rows = file.rows; - if (!(s.columns * s.column_width == tw and s.rows * s.row_height == th)) { - s.columns = 1; - s.rows = 1; - s.column_width = tw; - s.row_height = th; - } - } - slice_prev_columns = s.columns; - slice_prev_rows = s.rows; - slice_prev_column_width = s.column_width; - slice_prev_row_height = s.row_height; -} - -fn renderPreview( - mutex_id: dvui.Id, - dlg_id: dvui.Id, - file: *pixelart.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, - slice_full_layer: bool, - host_rect: dvui.Rect, -) void { - if (nw == 0 or nh == 0) return; - - const old_cols = file.columns; - const old_rows = file.rows; - const old_cw = file.column_width; - const old_rh = file.row_height; - - // Prefer the live host rect (this frame's parent contentRect) so resize tracks immediately; - // fall back to last frame's viewport before the parent has a real rect (first frame open). - const live_host_ok = host_rect.w > 8 and host_rect.h > 8; - const vp_host_w = if (live_host_ok) host_rect.w else preview_pane_fit_w; - const vp_host_h = if (live_host_ok) host_rect.h else preview_pane_fit_h; - const host_vp_ok = vp_host_w > 8 and vp_host_h > 8; - - const fit_key: u64 = (@as(u64, @intFromBool(slice_full_layer)) << 63) | - (@as(u64, @intCast(nw)) << 32) | - @as(u64, @intCast(nh)); - const fit_key_changed = fit_key != preview_fit_key_cache; - if (fit_key_changed) { - preview_viewport_fit_w = 0; - preview_viewport_fit_h = 0; - preview_canvas.scroll_info.viewport.x = 0; - preview_canvas.scroll_info.viewport.y = 0; - } - - const dims_changed = nw != preview_last_nw or nh != preview_last_nh; - - const shell_resize_drag = blk: { - const wid = dvui.dataGet(null, mutex_id, "_grid_layout_float_wd_id", dvui.Id) orelse break :blk false; - break :blk FloatingWindowWidget.DragPart.isResizeDrag(wid); - }; - - const host_changed = host_vp_ok and (preview_pane_fit_w < 4 or preview_pane_fit_h < 4 or - @abs(vp_host_w - preview_pane_fit_w) >= preview_layout_min_delta or - @abs(vp_host_h - preview_pane_fit_h) >= preview_layout_min_delta); - const needs_preinstall_refit = host_vp_ok and (fit_key_changed or dims_changed or host_changed or shell_resize_drag); - - const preview_data: dvui.Size = .{ .w = @floatFromInt(nw), .h = @floatFromInt(nh) }; - - const reset_viewport_after_fit = fit_key_changed or dims_changed or host_changed or shell_resize_drag; - - if (needs_preinstall_refit) { - preview_canvas.fitContentContainInHost( - preview_data, - dvui.Rect{ .x = 0, .y = 0, .w = vp_host_w, .h = vp_host_h }, - 1.2, - ); - if (reset_viewport_after_fit) { - preview_canvas.scroll_info.viewport.x = 0; - preview_canvas.scroll_info.viewport.y = 0; - } - if (fit_key_changed) { - preview_fit_key_cache = fit_key; - } - if (dims_changed) { - preview_last_nw = nw; - preview_last_nh = nh; - } - dvui.refresh(null, @src(), preview_canvas.id); - } - - // `CanvasWidget.install` rescale/recenter uses `parentGet()` under the scroll/scaler — wrong - // for this dialog. Skip that branch; scale/center from real scroll viewport. - preview_canvas.prev_size = .{ .w = @floatFromInt(nw), .h = @floatFromInt(nh) }; - - preview_canvas.install(@src(), .{ - .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, - .color_fill = workspaceCanvasChromeColor(), - }); - defer preview_canvas.deinit(); - - const vpw = preview_canvas.scroll_info.viewport.w; - const vph = preview_canvas.scroll_info.viewport.h; - - const vp_ok = vpw > 8 and vph > 8; - - var did_post_install_refit = false; - const needs_bootstrap_refit = !host_vp_ok and vp_ok and (fit_key_changed or dims_changed); - const needs_post_install_host_refit = vp_ok and (host_changed or shell_resize_drag); - if (needs_bootstrap_refit or needs_post_install_host_refit) { - preview_canvas.fitContentContainInHost( - preview_data, - dvui.Rect{ .x = 0, .y = 0, .w = vpw, .h = vph }, - 1.2, - ); - if (reset_viewport_after_fit or needs_post_install_host_refit) { - preview_canvas.scroll_info.viewport.x = 0; - preview_canvas.scroll_info.viewport.y = 0; - } - if (fit_key_changed) { - preview_fit_key_cache = fit_key; - } - if (dims_changed) { - preview_last_nw = nw; - preview_last_nh = nh; - } - did_post_install_refit = true; - preview_canvas.syncTransformCachesFromWidgets(); - dvui.refresh(null, @src(), preview_canvas.id); - } - - // `CanvasWidget.install` restores this snapshot while the parent rect is mid-resize; keep - // origin and viewport in sync so the preview stays centered, not pinned to the upper-left. - if (needs_preinstall_refit or did_post_install_refit) { - preview_canvas.stable_origin = preview_canvas.origin; - preview_canvas.stable_viewport = preview_canvas.scroll_info.viewport; - preview_canvas.stable_virtual_size = preview_canvas.scroll_info.virtual_size; - preview_canvas.has_stable_snapshot = true; - } - - preview_viewport_fit_w = vpw; - preview_viewport_fit_h = vph; - if (host_vp_ok) { - preview_pane_fit_w = vp_host_w; - preview_pane_fit_h = vp_host_h; - } - - const any_refit = needs_preinstall_refit or did_post_install_refit; - - { - const slice_mode_changed = preview_have_prev_slice_mode and - (preview_prev_slice_full_layer != slice_full_layer); - const refit_triggers_fade = slice_mode_changed or - (preview_first_open_fade_pending and fit_key_changed); - const should_zero_fade_alpha = vp_ok and any_refit and refit_triggers_fade; - - const dt = @min(@max(dvui.secondsSinceLastFrame(), 0.0), 0.05); - if (should_zero_fade_alpha) { - preview_content_alpha = 0.0; - } else if (vp_ok and preview_content_alpha < 1.0) { - const fade_s: f32 = 0.1; - preview_content_alpha = @min(1.0, preview_content_alpha + dt / fade_s); - } - if (preview_first_open_fade_pending and preview_content_alpha >= 1.0) { - preview_first_open_fade_pending = false; - } - if (preview_content_alpha < 1.0) { - dvui.refresh(null, @src(), preview_canvas.id); - } - } - - preview_prev_slice_full_layer = slice_full_layer; - preview_have_prev_slice_mode = true; - - const preview_alpha_saved = dvui.alpha(preview_content_alpha); - defer dvui.alphaSet(preview_alpha_saved); - - // Drop shadow under the preview texture, mirroring `FileWidget.drawLayers` so the preview - // reads as a "document" floating over the dialog's right pane. - { - const scale = @max(preview_canvas.scale, 0.0001); - const shadow_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = .{ .x = 0, .y = 0, .w = @floatFromInt(nw), .h = @floatFromInt(nh) }, - .border = dvui.Rect.all(0), - .box_shadow = .{ - .fade = 20 * 1 / scale, - .corner_radius = dvui.Rect.all(2 * 1 / scale), - .alpha = if (dvui.themeGet().dark) 0.4 else 0.2, - .offset = .{ - .x = 2 * 1 / scale, - .y = 2 * 1 / scale, - }, - }, - }); - shadow_box.deinit(); - } - - const nw_f: f32 = @floatFromInt(nw); - const nh_f: f32 = @floatFromInt(nh); - const rs = preview_canvas.screen_rect_scale; - drawPreviewViewportBackdrop(rs, nw_f, nh_f); - drawCheckerboardPreviewTiled( - file, - &preview_canvas, - rs, - @max(new_cols, 1), - @max(new_rows, 1), - @floatFromInt(new_cw_), - @floatFromInt(new_rh_), - ); - - if (slice_full_layer) { - drawCompositePreviewFullLayer(file, rs, @floatFromInt(nw), @floatFromInt(nh)); - } else { - drawCompositePreviewPerCells( - file, - rs, - old_cols, - old_rows, - old_cw, - old_rh, - new_cols, - new_rows, - new_cw_, - new_rh_, - anchor_vis, - ); - } - - const grid_col = previewGridLineColor(); - drawPreviewGridOverlay( - rs, - nw_f, - nh_f, - @max(new_cols, 1), - @max(new_rows, 1), - @floatFromInt(new_cw_), - @floatFromInt(new_rh_), - preview_canvas.scale, - grid_col, - ); - - // Same order as `FileWidget`: draw first, then scroll/zoom input (wheel applies next frame). - preview_canvas.processEvents(); -} - -/// Slice/Resize mode pill — lives in the dialog shell header strip (non-scrolling). -fn gridLayoutDrawModePill(dlg_id: dvui.Id) void { - const file_id_for_dialog = dvui.dataGet(null, dlg_id, "_grid_layout_file_id", u64); - - var horizontal_box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ - .expand = .none, - .gravity_x = 0.5, - .margin = .all(4), - }); - defer horizontal_box.deinit(); - - const field_names = std.meta.fieldNames(@TypeOf(mode)); - - for (field_names, 0..) |tag, i| { - const corner_radius: dvui.Rect = if (i == 0) .{ - .x = 100000, - .h = 100000, - } else if (i == field_names.len - 1) .{ - .y = 100000, - .w = 100000, - } else .all(0); - - var name = dvui.currentWindow().arena().dupe(u8, tag) catch { - dvui.log.err("Failed to dupe tag {s}", .{tag}); - return; - }; - @memcpy(name.ptr, tag); - name[0] = std.ascii.toUpper(name[0]); - - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{}, .{ - .corner_radius = corner_radius, - .id_extra = i, - .margin = .{ .y = 2, .h = 4 }, - .padding = .{ .x = 12, .y = 6, .w = 12, .h = 6 }, - .expand = .horizontal, - .color_fill = if (mode == @as(@TypeOf(mode), @enumFromInt(i))) dvui.themeGet().color(.window, .fill).lighten(-4) else dvui.themeGet().color(.control, .fill), - .box_shadow = if (i != @intFromEnum(mode)) .{ - .color = .black, - .offset = .{ .x = 0.0, .y = 2 }, - .fade = 7.0, - .alpha = 0.2, - .corner_radius = corner_radius, - .shrink = 0, - } else null, - }); - defer button.deinit(); - if (i != @intFromEnum(mode)) { - button.processEvents(); - } - - var clip_rect = button.data().rectScale().r; - - clip_rect.y -= 10000; - clip_rect.h += 20000; - - if (i == 0) { - clip_rect.x -= 10000; - clip_rect.w += 10000; - } else if (i == field_names.len - 1) { - clip_rect.w += 10000; - } - - const clip = dvui.clip(clip_rect); - defer dvui.clipSet(clip); - - button.drawFocus(); - button.drawBackground(); - - dvui.labelNoFmt(@src(), name, .{}, .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = if (mode == @as(@TypeOf(mode), @enumFromInt(i))) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), - .margin = .all(0), - .padding = .all(0), - }); - - 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| - harmonizeSliceStateWithLayer(tf); - } - mode = new_mode; - dvui.currentWindow().extra_frames_needed = 2; - } - } -} - -/// Returns true while the form input is valid AND differs from the active file's current -/// grid (column_width / row_height / columns / rows). The dialog framework uses this to enable/disable -/// the OK button — re-applying an identical grid is a no-op so we disable accept rather than invoke. -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) - else - null; - - const unique_id = id.update("grid_layout"); - - var valid: bool = true; - - // While opening, `windowFn` runs autoSize — allow the scroll area to report full content height - // so the dialog grows to fit (up to main window size). After open, cap reported min height so - // a short user resize does not push the footer off-screen (see DVUI `scrolling.zig` main_area). - const grid_dialog_open_done = dvui.dataGet(null, id, "_grid_dialog_open_done", bool) orelse false; - const mid_scroll_max: dvui.Options.MaxSize = if (grid_dialog_open_done) - .height(0) - else - .height(dvui.max_float_safe); - - var mid_scroll = dvui.scrollArea(@src(), .{ - .scroll_info = &dialog_middle_scroll, - .horizontal_bar = .auto_overlay, - }, .{ - .expand = .both, - .gravity_y = 0, - .background = false, - .max_size_content = mid_scroll_max, - .id_extra = unique_id.update("glp_mid_sc").asUsize(), - }); - defer mid_scroll.deinit(); - - defer { - if (dialog_middle_scroll.offset(.vertical) > 0.0) - pixelart.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, .{}); - } - - // Form (intrinsic width, full height) + preview (expands horizontally with the window). - var form_preview_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .gravity_y = 0, - .background = false, - .id_extra = unique_id.update("glp_main_row").asUsize(), - }); - defer form_preview_row.deinit(); - - { - const shell_left = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .vertical, - .gravity_x = 0, - .gravity_y = 0, - .background = false, - .id_extra = unique_id.update("glp_shell_l").asUsize(), - }); - - const pane_left = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .vertical, - .gravity_x = 0, - .gravity_y = 0, - .background = false, - .id_extra = unique_id.update("glp_pane_l").asUsize(), - }); - - var scroll_left = dvui.scrollArea(@src(), .{ - .scroll_info = &left_scroll, - .horizontal_bar = .auto_overlay, - .vertical_bar = .auto_overlay, - }, .{ - .expand = .both, - .gravity_x = 0, - .gravity_y = 0, - .background = false, - .id_extra = unique_id.update("glp_sc_l").asUsize(), - }); - - var inner_left = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - .gravity_x = 0, - .gravity_y = 0, - .padding = .all(4), - .id_extra = unique_id.update("glp_inner_l").asUsize(), - }); - - switch (mode) { - .resize => valid = drawResizeForm(unique_id, target_file, form_font) and valid, - .slice => valid = drawSliceForm(unique_id, target_file, form_font) and valid, - } - - inner_left.deinit(); - scroll_left.deinit(); - - 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, .{}); - } - if (left_scroll.virtual_size.h > left_scroll.viewport.h) { - pixelart.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, .{}); - } - if (h_scroll > 0.0) { - pixelart.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{}); - } - shell_left.deinit(); - } - - const preview_w: u32 = blk: { - if (target_file) |tf| { - if (mode == .slice) break :blk tf.canvasPixelSize().w; - } - break :blk resize_form.column_width * resize_form.columns; - }; - const preview_h: u32 = blk: { - if (target_file) |tf| { - if (mode == .slice) break :blk tf.canvasPixelSize().h; - } - break :blk resize_form.row_height * resize_form.rows; - }; - - const slice_grid_ok: bool = if (mode == .slice) blk: { - const tf = target_file orelse break :blk false; - const c = tf.canvasPixelSize(); - if (c.w == 0 or c.h == 0) break :blk false; - const s = slice_form; - break :blk s.column_width * s.columns == c.w and s.row_height * s.rows == c.h; - } else true; - // Invalid slice proposal: still show the full layer in the preview (no shrink) using the - // on-disk grid for sampling until the form is a valid tiling again. - const pv_cw, const pv_rh, const pv_cols, const pv_rows, const pv_anchor = blk: { - if (mode == .slice and !slice_grid_ok) { - const tf = target_file orelse break :blk .{ - slice_form.column_width, - slice_form.row_height, - slice_form.columns, - 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 switch (mode) { - .slice => .{ - slice_form.column_width, - slice_form.row_height, - slice_form.columns, - slice_form.rows, - anchors[@min(anchor_ix, anchors.len - 1)], - }, - .resize => .{ - resize_form.column_width, - resize_form.row_height, - resize_form.columns, - resize_form.rows, - anchors[@min(anchor_ix, anchors.len - 1)], - }, - }; - }; - - { - var preview_host = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .gravity_y = 0, - .background = false, - .id_extra = unique_id.update("glp_preview_host").asUsize(), - }); - defer preview_host.deinit(); - - 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, .{}); - } - - 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); - if (dims_ok) { - renderPreview( - id, - unique_id, - tf, - preview_w, - preview_h, - pv_cw, - pv_rh, - pv_cols, - pv_rows, - pv_anchor, - mode == .slice, - host_rect, - ); - } else { - // Keep the preview pane filled: invalid form state still shows the current layer using on-disk grid. - renderPreview( - id, - unique_id, - tf, - preview_w, - preview_h, - tf.column_width, - tf.row_height, - tf.columns, - tf.rows, - .nw, - mode == .slice, - host_rect, - ); - } - } - } - - // OK is enabled only when the form is valid AND the proposed grid actually changes something. - const changed: bool = blk: { - const tf = target_file orelse break :blk false; - break :blk switch (mode) { - .slice => !(slice_form.column_width == tf.column_width and - slice_form.row_height == tf.row_height and - slice_form.columns == tf.columns and - slice_form.rows == tf.rows), - .resize => !(resize_form.column_width == tf.column_width and - resize_form.row_height == tf.row_height and - resize_form.columns == tf.columns and - resize_form.rows == tf.rows), - }; - }; - - return valid and changed and (target_file != null); -} - -/// 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, - form_font: dvui.Font, -) bool { - var valid: bool = true; - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); - - if (target_file) |af| { - dvui.label(@src(), "Current size: {d} × {d} px", .{ af.width(), af.height() }, .{ - .gravity_x = 0, - .font = form_font, - .color_text = dvui.themeGet().color(.control, .text), - }); - } else { - valid = false; - } - - dvui.label(@src(), "After apply: {d} × {d} px", .{ - resize_form.column_width * resize_form.columns, - resize_form.row_height * resize_form.rows, - }, .{ - .gravity_x = 0, - .font = form_font, - .color_text = dvui.themeGet().color(.control, .text), - }); - - if (!pixelart.internal.File.validateGridLayoutProposedDims( - resize_form.column_width, - resize_form.row_height, - resize_form.columns, - resize_form.rows, - )) { - valid = false; - dvui.label( - @src(), - "Resulting size must fit within 4096 × 4096 px.", - .{}, - .{ - .gravity_x = 0, - .color_text = dvui.themeGet().color(.err, .text), - .font = form_font, - }, - ); - } - - // ── Cell width (x) - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - dvui.label(@src(), "Cell width (x):", .{}, .{ .gravity_y = 0.5, .font = form_font }); - const res_cw = dvui.textEntryNumber(@src(), u32, .{ - .min = NewFile.min_size[0], - .max = NewFile.max_size[0], - .value = &resize_form.column_width, - .show_min_max = true, - }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.update("cw").asUsize(), - .font = form_font, - }); - if (res_cw.value == .Valid) { - resize_form.column_width = res_cw.value.Valid; - } else { - valid = false; - } - } - - // ── Cell height (y) - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - dvui.label(@src(), "Cell height (y):", .{}, .{ .gravity_y = 0.5, .font = form_font }); - const res_rh = dvui.textEntryNumber(@src(), u32, .{ - .min = NewFile.min_size[1], - .max = NewFile.max_size[1], - .value = &resize_form.row_height, - .show_min_max = true, - }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.update("rh").asUsize(), - .font = form_font, - }); - if (res_rh.value == .Valid) { - resize_form.row_height = res_rh.value.Valid; - } else { - valid = false; - } - } - - // ── Columns (x) - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - dvui.label(@src(), "Columns (x):", .{}, .{ .gravity_y = 0.5, .font = form_font }); - const res_col = dvui.textEntryNumber(@src(), u32, .{ - .min = 1, - .max = NewFile.max_size[0], - .value = &resize_form.columns, - .show_min_max = true, - }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.update("cols").asUsize(), - .font = form_font, - }); - if (res_col.value == .Valid) { - resize_form.columns = res_col.value.Valid; - } else { - valid = false; - } - } - - // ── Rows (y) - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - dvui.label(@src(), "Rows (y):", .{}, .{ .gravity_y = 0.5, .font = form_font }); - const res_row = dvui.textEntryNumber(@src(), u32, .{ - .min = 1, - .max = NewFile.max_size[1], - .value = &resize_form.rows, - .show_min_max = true, - }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.update("rows").asUsize(), - .font = form_font, - }); - if (res_row.value == .Valid) { - resize_form.rows = res_row.value.Valid; - } else { - valid = false; - } - } - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 6, .h = 8 } }); - - // ── Anchor 3×3 button grid (single-select toggle). - dvui.label(@src(), "Anchor", .{}, .{ .gravity_x = 0, .font = form_font }); - - const row_tag = [_][]const u8{ "_r0", "_r1", "_r2" }; - { - var grid_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - .gravity_x = 0.5, - .id_extra = unique_id.update("agrid").asUsize(), - }); - defer grid_box.deinit(); - - var r: usize = 0; - while (r < 3) : (r += 1) { - var row_b = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .gravity_x = 0.5, - .id_extra = unique_id.update(row_tag[r]).asUsize(), - }); - defer row_b.deinit(); - - var c: usize = 0; - while (c < 3) : (c += 1) { - const ix = r * 3 + c; - const selected = ix == anchor_ix; - const color = if (selected) - dvui.themeGet().color(.window, .fill).lighten(-4) - else - dvui.themeGet().color(.control, .fill); - const button_opts: dvui.Options = .{ - .padding = .all(4), - .margin = .all(2), - .corner_radius = .all(4), - .min_size_content = .{ .w = 36, .h = 28 }, - .color_fill = color, - .color_fill_hover = if (selected) color else null, - .id_extra = unique_id.update(anchor_labels[ix]).asUsize(), - }; - - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{}, button_opts); - defer button.deinit(); - - if (!selected) button.processEvents(); - button.drawBackground(); - - dvui.labelNoFmt(@src(), anchor_labels[ix], .{}, button_opts.strip().override(button.style()).override(.{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = if (selected) - dvui.themeGet().color(.window, .text) - else - dvui.themeGet().color(.control, .text), - .font = form_font, - })); - if (button.clicked()) { - anchor_ix = ix; - dvui.currentWindow().extra_frames_needed = 2; - } - } - } - } - - return valid; -} - -/// Slice-mode form: image dimensions are pinned to the file's current `width × height`. Field order -/// matches resize: cell width, cell height, columns, rows. The user edits any field and the dialog -/// auto-fills the linked value whenever it divides evenly. The grid is invalid if values don't -/// multiply back to the locked total. -fn drawSliceForm( - unique_id: dvui.Id, - target_file: ?*pixelart.internal.File, - form_font: dvui.Font, -) bool { - var valid: bool = true; - const tf = target_file orelse return false; - const canvas = tf.canvasPixelSize(); - const total_w: u32 = canvas.w; - const total_h: u32 = canvas.h; - if (total_w == 0 or total_h == 0) { - dvui.label(@src(), "No layer pixels to slice.", .{}, .{ - .gravity_x = 0, - .color_text = dvui.themeGet().color(.err, .text), - .font = form_font, - }); - return false; - } - - dvui.label(@src(), "Image size: {d} × {d} px (locked)", .{ total_w, total_h }, .{ - .gravity_x = 0, - .font = form_font, - .color_text = dvui.themeGet().color(.control, .text), - }); - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 4, .h = 6 } }); - - // ── Cell width (x) - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - dvui.label(@src(), "Cell width (x):", .{}, .{ .gravity_y = 0.5, .font = form_font }); - const res = dvui.textEntryNumber(@src(), u32, .{ - .min = 1, - .max = @max(total_w, 1), - .value = &slice_form.column_width, - .show_min_max = true, - }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.update("s_cw").asUsize(), - .font = form_font, - }); - if (res.value == .Valid) slice_form.column_width = res.value.Valid else valid = false; - } - - // ── Cell height (y) - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - dvui.label(@src(), "Cell height (y):", .{}, .{ .gravity_y = 0.5, .font = form_font }); - const res = dvui.textEntryNumber(@src(), u32, .{ - .min = 1, - .max = @max(total_h, 1), - .value = &slice_form.row_height, - .show_min_max = true, - }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.update("s_ch").asUsize(), - .font = form_font, - }); - if (res.value == .Valid) slice_form.row_height = res.value.Valid else valid = false; - } - - // ── Columns (x) - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - dvui.label(@src(), "Columns (x):", .{}, .{ .gravity_y = 0.5, .font = form_font }); - const res = dvui.textEntryNumber(@src(), u32, .{ - .min = 1, - .max = @max(total_w, 1), - .value = &slice_form.columns, - .show_min_max = true, - }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.update("s_cols").asUsize(), - .font = form_font, - }); - if (res.value == .Valid) slice_form.columns = res.value.Valid else valid = false; - } - - // ── Rows (y) - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - dvui.label(@src(), "Rows (y):", .{}, .{ .gravity_y = 0.5, .font = form_font }); - const res = dvui.textEntryNumber(@src(), u32, .{ - .min = 1, - .max = @max(total_h, 1), - .value = &slice_form.rows, - .show_min_max = true, - }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.update("s_rows").asUsize(), - .font = form_font, - }); - if (res.value == .Valid) slice_form.rows = res.value.Valid else valid = false; - } - - // Auto-link: prefer count-driven autofill (if columns or rows changed, derive the cell size). - // If only the cell size changed, derive the count. Single-frame lag is fine — both fields - // converge on the next render. - if (slice_form.columns != slice_prev_columns and slice_form.columns > 0 and total_w % slice_form.columns == 0) { - slice_form.column_width = total_w / slice_form.columns; - } else if (slice_form.column_width != slice_prev_column_width and slice_form.column_width > 0 and total_w % slice_form.column_width == 0) { - slice_form.columns = total_w / slice_form.column_width; - } - if (slice_form.rows != slice_prev_rows and slice_form.rows > 0 and total_h % slice_form.rows == 0) { - slice_form.row_height = total_h / slice_form.rows; - } else if (slice_form.row_height != slice_prev_row_height and slice_form.row_height > 0 and total_h % slice_form.row_height == 0) { - slice_form.rows = total_h / slice_form.row_height; - } - slice_prev_columns = slice_form.columns; - slice_prev_rows = slice_form.rows; - slice_prev_column_width = slice_form.column_width; - slice_prev_row_height = slice_form.row_height; - - // Validation: in slice mode the *only* legal grids are those whose cell × count matches the - // locked image size. We surface the mismatch inline rather than silently snapping so the - // user sees what the constraint is. - const cw_eff = slice_form.column_width; - const rh_eff = slice_form.row_height; - const w_match = cw_eff > 0 and slice_form.columns > 0 and cw_eff * slice_form.columns == total_w; - const h_match = rh_eff > 0 and slice_form.rows > 0 and rh_eff * slice_form.rows == total_h; - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 6, .h = 6 } }); - - if (!(w_match and h_match)) { - valid = false; - dvui.label(@src(), "Cells must tile the image exactly.", .{}, .{ - .gravity_x = 0, - .color_text = dvui.themeGet().color(.err, .text), - .font = form_font, - }); - if (!w_match) { - dvui.label(@src(), " • {d} × {d} ≠ {d} (x)", .{ cw_eff, slice_form.columns, total_w }, .{ - .gravity_x = 0, - .color_text = dvui.themeGet().color(.err, .text), - .font = form_font, - }); - } - if (!h_match) { - dvui.label(@src(), " • {d} × {d} ≠ {d} (y)", .{ rh_eff, slice_form.rows, total_h }, .{ - .gravity_x = 0, - .color_text = dvui.themeGet().color(.err, .text), - .font = form_font, - }); - } - } - - return valid; -} - -/// 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` -/// here — in DVUI it *caps* reported min size and was shrinking the dialog. -pub fn windowFn(id: dvui.Id) anyerror!void { - const modal = dvui.dataGet(null, id, "_modal", bool) orelse { - dvui.log.err("GridLayout windowFn lost data for dialog {x}", .{id}); - dvui.dialogRemove(id); - return; - }; - - if (modal) { - pixelart.core.dvui.modal_dim_titlebar = true; - } - - const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse { - dvui.dialogRemove(id); - return; - }; - const ok_label = dvui.dataGetSlice(null, id, "_ok_label", []u8) orelse { - dvui.dialogRemove(id); - return; - }; - 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); - - // Default shell: wide enough for form + preview; DVUI autoSize grows to content if larger. - const wr = dvui.windowRect(); - const init_w = @round(wr.w * 0.62); - const init_h = @round(wr.h * 0.52); - const center_on = dvui.currentWindow().subwindows.current_rect; - - var win = pixelart.core.dvui.floatingWindow(@src(), .{ - .modal = modal, - .center_on = center_on, - .window_avoid = .nudge, - .process_events_in_deinit = true, - .resize = .all, - }, .{ - .id_extra = id.asUsize(), - .color_text = .black, - .corner_radius = dvui.Rect.all(10), - .min_size_content = .{ .w = init_w, .h = @max(init_h, 400) }, - .border = .all(0), - .color_fill = dvui.themeGet().color(.content, .fill).opacity(0.85), - .box_shadow = .{ - .color = .black, - .alpha = 0.35, - .fade = 10, - .corner_radius = dvui.Rect.all(10), - }, - }); - defer win.deinit(); - // `renderPreview` refits when the preview viewport changes; during very slow resize drags the - // scroll viewport can lag the shell by sub-pixel amounts for multiple frames. While this window - // holds capture (resize or drag), refit every frame so scale/center stay correct. - dvui.dataSet(null, id, "_grid_layout_float_wd_id", win.data().id); - - if (dvui.dataGet(null, id, "_grid_dialog_open_done", bool) orelse false) { - win.stopAutoSizing(); - } - - if (dvui.animationGet(win.data().id, "_close_x")) |a| { - if (a.done()) { - pixelart.core.dvui.dialog_close_rect_override = null; - dvui.dialogRemove(id); - } - } else if (pixelart.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; - } 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". - const open_done = dvui.dataGet(null, id, "_grid_dialog_open_done", bool) orelse false; - if (!open_done) { - win.autoSize(); - var anim_busy = false; - if (dvui.animationGet(win.data().id, "_auto_width")) |a| { - if (!a.done()) anim_busy = true; - } - if (dvui.animationGet(win.data().id, "_auto_height")) |a2| { - if (!a2.done()) anim_busy = true; - } - if (!anim_busy and !dvui.firstFrame(win.data().id) and win.data().rect.w > 32 and win.data().rect.h > 32) { - dvui.dataSet(null, id, "_grid_dialog_open_done", true); - win.stopAutoSizing(); - } - } - } - - // Header (title) + mode pill are fixed; middle expands and scrolls inside `dialog()`; footer fixed. - 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, - else => .none, - }; - - var header_openflag = true; - win.dragAreaSet(pixelart.core.dvui.windowHeader(title, "", &header_openflag, header_kind)); - if (!header_openflag) { - if (callafter) |ca| { - ca(id, .cancel) catch { - dvui.log.err("GridLayout dialog callafter cancel failed", .{}); - return; - }; - } - var close_rect = win.data().rectScale().r; - close_rect.x = close_rect.center().x; - close_rect.y = close_rect.center().y; - close_rect.w = 1; - close_rect.h = 1; - dvui.dataSet(null, win.data().id, "_close_rect", close_rect); - } - - gridLayoutDrawModePill(id); - - var valid: bool = true; - - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .padding = .all(8), - .expand = .both, - .gravity_x = 0.5, - }); - defer hbox.deinit(); - - if (displayFn) |df| { - valid = df(id) catch false; - } - } - - { // 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 }, - }); - defer hbox.deinit(); - - if (cancel_label) |cl| { - var cancel_data: dvui.WidgetData = undefined; - const gravx: f32, const tindex: u16 = switch (dvui.currentWindow().button_order) { - .cancel_ok => .{ 0.0, 1 }, - .ok_cancel => .{ 1.0, 3 }, - }; - if (dvui.button(@src(), cl, .{}, .{ - .tab_index = tindex, - .data_out = &cancel_data, - .gravity_x = gravx, - .box_shadow = .{ - .color = .black, - .alpha = 0.25, - .offset = .{ .x = -4, .y = 4 }, - .fade = 8, - }, - })) { - if (callafter) |ca| { - ca(id, .cancel) catch { - dvui.log.err("GridLayout dialog callafter cancel failed", .{}); - return; - }; - } - var close_rect = win.data().rectScale().r; - close_rect.x = close_rect.center().x; - close_rect.y = close_rect.center().y; - close_rect.w = 1; - close_rect.h = 1; - dvui.dataSet(null, win.data().id, "_close_rect", close_rect); - } - if (default != null and dvui.firstFrame(hbox.data().id) and default.? == .cancel and !valid) { - dvui.focusWidget(cancel_data.id, null, null); - } - } - - const alpha = dvui.alpha(if (valid) 1.0 else 0.5); - defer dvui.alphaSet(alpha); - - var ok_data: dvui.WidgetData = undefined; - const ok_opts: dvui.Options = .{ - .tab_index = 2, - .data_out = &ok_data, - .style = if (valid) .highlight else .control, - .box_shadow = .{ - .color = .black, - .alpha = 0.25, - .offset = .{ .x = -4, .y = 4 }, - .fade = 8, - }, - }; - var ok_button: dvui.ButtonWidget = undefined; - ok_button.init(@src(), .{}, ok_opts); - defer ok_button.deinit(); - if (valid) ok_button.processEvents(); - ok_button.drawFocus(); - ok_button.drawBackground(); - dvui.labelNoFmt(@src(), ok_label, .{}, ok_opts.strip().override(ok_button.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 })); - - if (ok_button.clicked()) { - if (!valid) return; - if (callafter) |ca| { - ca(id, .ok) catch { - dvui.log.err("GridLayout dialog callafter ok failed", .{}); - return; - }; - } - var close_rect_ok = win.data().rectScale().r; - close_rect_ok.x = close_rect_ok.center().x; - close_rect_ok.y = close_rect_ok.center().y; - close_rect_ok.w = 1; - close_rect_ok.h = 1; - dvui.dataSet(null, win.data().id, "_close_rect", close_rect_ok); - } - if (default != null and dvui.firstFrame(hbox.data().id) and default.? == .ok and valid) { - dvui.focusWidget(ok_data.id, null, null); - } - } -} - -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; - - switch (mode) { - .slice => { - const s = slice_form; - if (!pixelart.internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows)) - return; - file.applyGridSliceOnly(.{ - .column_width = s.column_width, - .row_height = s.row_height, - .columns = s.columns, - .rows = s.rows, - }) catch |err| { - dvui.log.err("Failed to apply grid slice: {s}", .{@errorName(err)}); - return; - }; - }, - .resize => { - const r = resize_form; - if (!pixelart.internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows)) - return; - file.applyGridLayout(.{ - .column_width = r.column_width, - .row_height = r.row_height, - .columns = r.columns, - .rows = r.rows, - .anchor = anchors[@min(anchor_ix, anchors.len - 1)], - }) catch |err| { - dvui.log.err("Failed to apply grid layout: {s}", .{@errorName(err)}); - return; - }; - }, - } - - dvui.refresh(null, @src(), dvui.currentWindow().data().id); - Globals.state.host.requestCompositeWarmup(); - }, - .cancel => {}, - else => {}, - } -} diff --git a/src/plugins/pixelart/src/dialogs/NewFile.zig b/src/plugins/pixelart/src/dialogs/NewFile.zig deleted file mode 100644 index b2c1aad5..00000000 --- a/src/plugins/pixelart/src/dialogs/NewFile.zig +++ /dev/null @@ -1,247 +0,0 @@ -const std = @import("std"); -const dvui = @import("dvui"); - -const DimensionsLabel = @import("dimensions_label.zig"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; - -pub var mode: enum(usize) { - single, - grid, -} = .single; - -pub var columns: u32 = 1; -pub var rows: u32 = 1; -pub var column_width: u32 = 32; -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); - - // Touch explorer target path every frame so dvui does not drop it at Window.end before OK. - _ = dvui.dataGetSlice(null, id, "_parent_path", []u8); - - var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); - defer outer_box.deinit(); - - { - var valid: bool = true; - - var unique_id = id.update(if (mode == .single) "single" else "grid"); - - { - const hbox = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ .expand = .horizontal, .corner_radius = .all(100000), .margin = .all(4) }); - defer hbox.deinit(); - - for (0..2) |i| { - const color = if (i == @intFromEnum(mode)) dvui.themeGet().color(.window, .fill).lighten(-4) else dvui.themeGet().color(.control, .fill); - const button_opts: dvui.Options = .{ - .padding = .all(6), - .margin = .{ .y = 2, .h = 4 }, - .corner_radius = if (i == 0) .{ .x = 100000, .h = 100000 } else .{ .y = 100000, .w = 100000 }, - .expand = .horizontal, - .color_fill = color, - .color_fill_hover = if (i == @intFromEnum(mode)) color else null, - .id_extra = i, - .box_shadow = if (i != @intFromEnum(mode)) .{ - .color = .black, - .offset = .{ .x = 0.0, .y = 2.0 }, - .fade = 7.0, - .alpha = 0.2, - .corner_radius = if (i == 0) .{ .x = 100000, .h = 100000 } else .{ .y = 100000, .w = 100000 }, - } else null, - }; - - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{}, button_opts); - defer button.deinit(); - - if (i != @intFromEnum(mode)) { - button.processEvents(); - } - - button.drawBackground(); - - if (i == 0) { - dvui.labelNoFmt(@src(), "Single", .{}, button_opts.strip().override(button.style()).override(.{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = if (i == @intFromEnum(mode)) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), - })); - if (button.clicked()) { - mode = .single; - _ = dvui.dataSet(null, id, "_id_extra", id.update("single_tile").asUsize()); - } - } else { - dvui.labelNoFmt(@src(), "Grid", .{}, button_opts.strip().override(button.style()).override(.{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = if (i == @intFromEnum(mode)) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), - })); - if (button.clicked()) { - mode = .grid; - _ = dvui.dataSet(null, id, "_id_extra", id.update("grid").asUsize()); - } - } - } - } - - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - { - dvui.label(@src(), "{s}", .{if (mode == .single) "Width (x):" else "Column Width (x):"}, .{ .gravity_y = 0.5, .gravity_x = 0.0 }); - const result = dvui.textEntryNumber(@src(), u32, .{ .min = min_size[0], .max = max_size[0], .value = &column_width, .show_min_max = true }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.asUsize(), - .font = entry_font, - }); - if (result.value == .Valid) { - column_width = result.value.Valid; - } else { - valid = false; - } - } - } - - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer hbox.deinit(); - - { - dvui.label(@src(), "{s}", .{if (mode == .single) "Height (y):" else "Row Height (y):"}, .{ .gravity_y = 0.5, .gravity_x = 0.0 }); - const result = dvui.textEntryNumber(@src(), u32, .{ .min = min_size[1], .max = max_size[1], .value = &row_height, .show_min_max = true }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.asUsize(), - .font = entry_font, - }); - if (result.value == .Valid) { - row_height = result.value.Valid; - } else { - valid = false; - } - } - } - - if (mode == .grid) { - { - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); - defer hbox.deinit(); - - dvui.label(@src(), "Columns (x):", .{}, .{ .gravity_y = 0.5 }); - const result = dvui.textEntryNumber(@src(), u32, .{ .min = 1, .max = @divTrunc(max_size[0], column_width), .value = &columns, .show_min_max = true }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.asUsize(), - .font = entry_font, - }); - if (result.value == .Valid) { - columns = result.value.Valid; - } else { - valid = false; - } - } - { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); - defer hbox.deinit(); - dvui.label(@src(), "Rows (y):", .{}, .{ .gravity_y = 0.5 }); - const result = dvui.textEntryNumber(@src(), u32, .{ .min = 1, .max = @divTrunc(max_size[1], row_height), .value = &rows, .show_min_max = true }, .{ - .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 }, - .label = .{ .label_widget = .prev }, - .gravity_x = 1.0, - .id_extra = unique_id.asUsize(), - .font = entry_font, - }); - if (result.value == .Valid) { - rows = result.value.Valid; - } else { - valid = false; - } - } - } - } - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); - - const width = column_width * (if (mode == .single) 1 else columns); - const height = row_height * (if (mode == .single) 1 else rows); - - DimensionsLabel.drawDimensionsLabel(@src(), width, height, entry_font, "px", .{ .gravity_x = 0.5 }); - - return valid; - } - - return false; -} - -pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void { - const parent_path = dvui.dataGetSlice(null, id, "_parent_path", []u8); - - 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 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, - }); - 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). - file.saveAsync() catch { - dvui.log.err("Failed to save file: {s}", .{new_path}); - return error.FailedToSaveFile; - }; - - try Globals.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, .{ - .column_width = column_width, - .row_height = row_height, - .columns = if (mode == .single) 1 else columns, - .rows = if (mode == .single) 1 else rows, - }); - } - }, - .cancel => {}, - else => {}, - } -} diff --git a/src/plugins/pixelart/src/dialogs/dimensions_label.zig b/src/plugins/pixelart/src/dialogs/dimensions_label.zig deleted file mode 100644 index 42db8da8..00000000 --- a/src/plugins/pixelart/src/dialogs/dimensions_label.zig +++ /dev/null @@ -1,73 +0,0 @@ -//! 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/doc_bridge.zig b/src/plugins/pixelart/src/doc_bridge.zig deleted file mode 100644 index b9d48914..00000000 --- a/src/plugins/pixelart/src/doc_bridge.zig +++ /dev/null @@ -1,93 +0,0 @@ -//! 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 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(); -} - -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/doc_lifecycle.zig b/src/plugins/pixelart/src/doc_lifecycle.zig deleted file mode 100644 index b84071a1..00000000 --- a/src/plugins/pixelart/src/doc_lifecycle.zig +++ /dev/null @@ -1,155 +0,0 @@ -//! 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; - -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 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/docs_registry.zig b/src/plugins/pixelart/src/docs_registry.zig deleted file mode 100644 index b6744e28..00000000 --- a/src/plugins/pixelart/src/docs_registry.zig +++ /dev/null @@ -1,32 +0,0 @@ -//! 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/explorer/project.zig b/src/plugins/pixelart/src/explorer/project.zig deleted file mode 100644 index 59ce990a..00000000 --- a/src/plugins/pixelart/src/explorer/project.zig +++ /dev/null @@ -1,530 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const icons = @import("icons"); - -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 - // only exposes the Pack button (operates on currently-open files via - // `startPackProject`'s wasm path). Native flow below assumes a folder. - if (comptime builtin.target.cpu.arch == .wasm32) { - try drawWeb(); - return; - } - - if (Globals.state.host.folder()) |folder| { - if (Globals.state.project) |_| { - const tl = dvui.textLayout(@src(), .{}, .{ - .expand = .none, - .margin = dvui.Rect.all(0), - .background = false, - }); - defer tl.deinit(); - - const project_path = std.fs.path.join(dvui.currentWindow().lifo(), &.{ folder, ".fizproject" }) catch { - dvui.log.err("Failed to join project path", .{}); - return; - }; - defer dvui.currentWindow().lifo().free(project_path); - - tl.addText(project_path, .{ .color_text = dvui.themeGet().color(.control, .text) }); - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } }); - } else { - var box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .horizontal, - .max_size_content = .{ .w = Globals.state.host.explorerVirtualSize().w, .h = std.math.floatMax(f32) }, - }); - defer box.deinit(); - - const tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false }); - tl.addText("No project file found!\n\n", .{}); - tl.addText("Would you like to create a project file to specify constant output paths and other project-specific behaviors?\n", .{ .color_text = dvui.themeGet().color(.control, .text) }); - tl.deinit(); - - if (dvui.button(@src(), "Create Project", .{}, .{ .expand = .horizontal })) { - Globals.state.project = .{}; - } - return; - } - - const packing = Globals.state.host.isPackingActive(); - if (packProjectButton(packing)) { - Globals.state.host.startPackProject() catch |err| { - dvui.log.err("Failed to start project pack: {any}", .{err}); - }; - } - - if (Globals.packer.atlas != null) { - drawPackedAtlasStats(); - } - - pathTextEntry(.atlas) catch { - dvui.log.err("Failed to draw path text entry", .{}); - }; - pathTextEntry(.image) catch { - dvui.log.err("Failed to draw path text entry", .{}); - }; - - 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, - .style = .highlight, - })) { - if (project.packed_atlas_output) |output| { - atlas.save(output, .data) catch { - dvui.log.err("Failed to save atlas data", .{}); - }; - } - - if (project.packed_image_output) |image_output| { - atlas.save(image_output, .source) catch { - dvui.log.err("Failed to save atlas image", .{}); - }; - } - } - } - } - } - - // { - // var set_text: bool = false; - // dvui.labelNoFmt(@src(), "Atlas Data Output:", .{}, .{}); - - // var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - // defer box.deinit(); - - // if (dvui.buttonIcon(@src(), "example.atlas", icons.tvg.lucide.@"folder-open", .{}, .{ - // .fill_color = .fromTheme(.text_press), - // }, .{ - // .gravity_y = 0.5, - // .padding = dvui.Rect.all(4), - // .border = dvui.Rect.all(1), - // .margin = .{ .x = 1, .w = 1 }, - // })) { - // const valid_path: bool = blk: { - // if (project.packed_atlas_output) |output| { - // const base_name = std.fs.path.basename(output); - // if (std.mem.indexOf(u8, output, base_name)) |i| { - // if (!std.fs.path.isAbsolute(output[0..i])) { - // break :blk false; - // } - - // std.Io.Dir.accessAbsolute(dvui.io, output[0..i], .{}) catch { - // break :blk false; - // }; - // } else { - // if (!std.fs.path.isAbsolute(output)) { - // break :blk false; - // } - // std.Io.Dir.accessAbsolute(dvui.io, output, .{}) catch { - // break :blk false; - // }; - // } - // } - - // break :blk true; - // }; - - // 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 = Globals.allocator().dupe(u8, path[0..]) catch null; - // set_text = true; - // } else { - // dvui.log.err("Project failed to copy new path", .{}); - // } - // } - - // const te = dvui.textEntry(@src(), .{ - // .placeholder = "example.atlas", - // }, .{ - // .padding = dvui.Rect.all(5), - // .expand = .horizontal, - // .margin = dvui.Rect.all(0), - // .color_text = if (project.packed_atlas_output) |_| .text else .text_press, - // }); - - // defer te.deinit(); - - // if (project.packed_atlas_output) |packed_atlas_output| { - // if (dvui.firstFrame(te.data().id) or set_text) { - // te.textSet(packed_atlas_output, false); - // } - // } - - // if (te.text_changed) { - // const t = te.getText(); - // if (t.len > 0) { - // project.packed_atlas_output = Globals.allocator().dupe(u8, t) catch null; - // } else { - // project.packed_atlas_output = null; - // } - // } - // } - - // _ = dvui.spacer(@src(), .{ .expand = .horizontal, .min_size_content = .{ .h = 10 } }); - - // { - // var set_text: bool = false; - // dvui.labelNoFmt(@src(), "Atlas Image Output:", .{}, .{}); - - // var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - // defer box.deinit(); - - // if (dvui.buttonIcon(@src(), "example.atlas", icons.tvg.lucide.@"folder-open", .{}, .{ - // .fill_color = .fromTheme(.text_press), - // }, .{ - // .gravity_y = 0.5, - // .padding = dvui.Rect.all(4), - // .border = dvui.Rect.all(1), - // .margin = .{ .x = 1, .w = 1 }, - // })) { - // const valid_path: bool = blk: { - // if (project.packed_image_output) |output| { - // const base_name = std.fs.path.basename(output); - // if (std.mem.indexOf(u8, output, base_name)) |i| { - // if (!std.fs.path.isAbsolute(output[0..i])) { - // break :blk false; - // } - - // std.Io.Dir.accessAbsolute(dvui.io, output[0..i], .{}) catch { - // break :blk false; - // }; - // } else { - // if (!std.fs.path.isAbsolute(output)) { - // break :blk false; - // } - // std.Io.Dir.accessAbsolute(dvui.io, output, .{}) catch { - // break :blk false; - // }; - // } - // } - - // break :blk true; - // }; - - // 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 = Globals.allocator().dupe(u8, path[0..]) catch null; - // set_text = true; - // } else { - // dvui.log.err("Project failed to copy new path", .{}); - // } - // } - - // const te = dvui.textEntry(@src(), .{ - // .placeholder = "example.png", - // }, .{ - // .padding = dvui.Rect.all(5), - // .expand = .horizontal, - // .margin = dvui.Rect.all(0), - // .color_text = if (project.packed_image_output) |_| .text else .text_press, - // }); - - // defer te.deinit(); - - // if (project.packed_image_output) |packed_image_output| { - // if (dvui.firstFrame(te.data().id) or set_text) { - // te.textSet(packed_image_output, false); - // } - // } - - // if (te.text_changed) { - // const t = te.getText(); - // if (t.len > 0) { - // project.packed_image_output = Globals.allocator().dupe(u8, t) catch null; - // } else { - // project.packed_image_output = null; - // } - // } - // } - -} - -const PathType = enum { - atlas, - image, -}; - -fn pathTextEntry(path_type: PathType) !void { - if (Globals.state.project) |*project| { - const output_path = switch (path_type) { - .atlas => &project.packed_atlas_output, - .image => &project.packed_image_output, - }; - - const index: usize = switch (path_type) { - .atlas => 0, - .image => 1, - }; - - defer _ = dvui.spacer(@src(), .{ .id_extra = index }); - - const label_text = switch (path_type) { - .atlas => "Atlas Data Output:", - .image => "Atlas Image Output:", - }; - - var set_text: bool = false; - dvui.labelNoFmt(@src(), label_text, .{}, .{ - .id_extra = index, - }); - - var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .id_extra = index }); - defer box.deinit(); - - if (dvui.buttonIcon(@src(), "example.atlas", icons.tvg.lucide.@"folder-open", .{}, .{}, .{ - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - .border = dvui.Rect.all(1), - .margin = .{ .x = 1, .w = 1 }, - .id_extra = index, - })) { - const valid_path: bool = blk: { - if (output_path.*) |output| { - const base_name = std.fs.path.basename(output); - if (std.mem.indexOf(u8, output, base_name)) |i| { - if (!std.fs.path.isAbsolute(output[0..i])) { - break :blk false; - } - - std.Io.Dir.accessAbsolute(dvui.io, output[0..i], .{}) catch { - break :blk false; - }; - } else { - if (!std.fs.path.isAbsolute(output)) { - break :blk false; - } - std.Io.Dir.accessAbsolute(dvui.io, output, .{}) catch { - break :blk false; - }; - } - } - - break :blk true; - }; - - 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; - } - - const te = dvui.textEntry(@src(), .{ - .placeholder = "example.atlas", - }, .{ - .padding = dvui.Rect.all(5), - .expand = .horizontal, - .margin = dvui.Rect.all(0), - .color_text = if (output_path.*) |_| dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), - .id_extra = index, - }); - - defer te.deinit(); - - if (output_path.*) |packed_atlas_output| { - if (dvui.firstFrame(te.data().id) or dvui.focusedWidgetId() != te.data().id) { - te.textSet(packed_atlas_output, false); - } - } - - if (te.text_changed) { - const t = te.getText(); - if (t.len > 0) { - output_path.* = Globals.allocator().dupe(u8, t) catch null; - } else { - output_path.* = null; - } - } - } -} - -fn drawPackedAtlasStats() void { - 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); - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } }); - - const tl = dvui.textLayout(@src(), .{}, .{ - .expand = .horizontal, - .margin = dvui.Rect.all(0), - .background = false, - }); - defer tl.deinit(); - - const body = dvui.Font.theme(.body); - const label_color = dvui.themeGet().color(.window, .text); - const value_color = dvui.themeGet().color(.control, .text); - 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| { - var when_buf: [64]u8 = undefined; - const when = formatLastPacked(&when_buf, packed_at_ns); - tl.addText("Last packed: ", label_opts); - tl.addText(when, value_opts); - tl.addText("\n", value_opts); - } - - var value_buf: [48]u8 = undefined; - const sprites = std.fmt.bufPrint(&value_buf, "{d}", .{atlas.data.sprites.len}) catch "0"; - tl.addText("Sprites: ", label_opts); - tl.addText(sprites, value_opts); - tl.addText("\n", value_opts); - - const animations = std.fmt.bufPrint(&value_buf, "{d}", .{atlas.data.animations.len}) catch "0"; - tl.addText("Animations: ", label_opts); - tl.addText(animations, value_opts); - tl.addText("\n", value_opts); - - const atlas_size = std.fmt.bufPrint(&value_buf, "{d} px x {d} px", .{ atlas_w, atlas_h }) catch "0 px x 0 px"; - tl.addText("Atlas size: ", label_opts); - tl.addText(atlas_size, value_opts); -} - -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); - if (elapsed_s < 10) { - return std.fmt.bufPrint(buf, "just now", .{}) catch "recently"; - } - if (elapsed_s < 60) { - return std.fmt.bufPrint(buf, "{d}s ago", .{elapsed_s}) catch "recently"; - } - const elapsed_min = @divTrunc(elapsed_s, 60); - if (elapsed_min < 60) { - return std.fmt.bufPrint(buf, "{d} min ago", .{elapsed_min}) catch "recently"; - } - const elapsed_hr = @divTrunc(elapsed_min, 60); - if (elapsed_hr < 48) { - return std.fmt.bufPrint(buf, "{d} hr ago", .{elapsed_hr}) catch "recently"; - } - const elapsed_day = @divTrunc(elapsed_hr, 24); - return std.fmt.bufPrint(buf, "{d} days ago", .{elapsed_day}) catch "recently"; -} - -/// "Pack Project" button. Same look-and-feel as `dvui.button`, but with a bubble spinner -/// pinned to the right edge while a pack is in flight. Always interactive — rapid clicks / -/// per-save repack triggers coalesce via `Editor.startPackProject` cancelling predecessors. -fn packProjectButton(packing: bool) bool { - var bw: dvui.ButtonWidget = undefined; - bw.init(@src(), .{ .draw_focus = false }, .{ - .expand = .horizontal, - .style = .highlight, - }); - defer bw.deinit(); - - bw.processEvents(); - bw.drawBackground(); - const clicked = bw.clicked(); - - // Center label across the full button rect via gravity. Mirrors `dvui.button`'s call - // signature so the text picks up the same hovered/pressed colors. - const label_text: []const u8 = if (packing) "Packing…" else "Pack Project"; - const content_opts = (dvui.Options{}).strip().override(bw.style()).override(.{ - .gravity_x = 0.5, - .gravity_y = 0.5, - }); - dvui.labelNoFmt(@src(), label_text, .{ .align_x = 0.5, .align_y = 0.5 }, content_opts); - - // 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(.{ - .min_size_content = .{ .w = 16, .h = 16 }, - .gravity_x = 1.0, - .gravity_y = 0.5, - .padding = .{ .w = 4 }, - }), .{}); - } - - bw.drawFocus(); - return clicked; -} - -pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void { - if (Globals.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; - } - } - } -} - -pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void { - if (Globals.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; - } - } - } -} - -/// Wasm-specific simplified pack pane. No folder, no `.fizproject` UI — just -/// 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) { - dvui.labelNoFmt( - @src(), - "Open one or more files to pack.", - .{}, - .{ .color_text = dvui.themeGet().color(.control, .text) }, - ); - return; - } - - var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); - defer vbox.deinit(); - - const btn_opts = dvui.Options{ - .expand = .horizontal, - .style = .highlight, - }; - - const packing = Globals.state.host.isPackingActive(); - if (packProjectButton(packing)) { - Globals.state.host.startPackProject() catch |err| { - dvui.log.err("Failed to pack open files: {any}", .{err}); - }; - } - - if (Globals.packer.atlas != null) { - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } }); - drawPackedAtlasStats(); - } - - 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 { - dvui.log.err("Failed to download atlas data", .{}); - }; - } - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } }); - if (dvui.button(@src(), "Download Atlas PNG", .{ .draw_focus = false }, btn_opts)) { - atlas.save("atlas.png", .source) catch { - dvui.log.err("Failed to download atlas image", .{}); - }; - } - } -} diff --git a/src/plugins/pixelart/src/explorer/sprites.zig b/src/plugins/pixelart/src/explorer/sprites.zig deleted file mode 100644 index c431669f..00000000 --- a/src/plugins/pixelart/src/explorer/sprites.zig +++ /dev/null @@ -1,2499 +0,0 @@ -const std = @import("std"); -const dvui = @import("dvui"); -const icons = @import("icons"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; - -const Sprites = @This(); - -/// Edge shadows on animation/frame lists only when scroll range exceeds this (avoids subpixel overflow). -const scroll_list_shadow_deadzone_ns: f32 = 4.0; - -fn pointerReleaseInRectWithoutSelectionModifier(r: dvui.Rect.Physical) bool { - for (dvui.events()) |*e| { - switch (e.evt) { - .mouse => |me| { - if (me.action == .release and me.button.pointer() and r.contains(me.p)) { - return !me.mod.shift() and !me.mod.control() and !me.mod.command(); - } - }, - else => {}, - } - } - return false; -} - -/// In-flight primary-button gesture for the animation list (reorder / click / rename). -const AnimationRowGesture = struct { - file_id: u64, - press_idx: usize, - press_p: dvui.Point.Physical, - drag_branch: ?usize, - moved: bool, - reorder_drag: bool, - /// Finder-style: plain click on an already-multi-selected row preserves the set while the - /// user might start a drag; if they release without dragging, narrow to just this row. - narrow_on_release: bool, -}; -var animation_row_gesture: ?AnimationRowGesture = null; -var anim_rename_hit_te_id: ?dvui.Id = null; -var anim_rename_hit_rect: ?dvui.Rect.Physical = null; - -/// In-flight primary-button gesture for the frame list (reorder / click). -const FrameRowGesture = struct { - file_id: u64, - anim_id: u64, - press_idx: usize, - press_p: dvui.Point.Physical, - drag_branch: ?usize, - moved: bool, - reorder_drag: bool, - narrow_on_release: bool, -}; -var frame_row_gesture: ?FrameRowGesture = null; - -/// Sorted (ascending) indices whose animation-tree branch reported `removed()` last frame. Used -/// by the drop handler to move multiple selected animations as a group. -var removed_animation_indices_buf: [64]usize = undefined; -var removed_animation_indices_len: usize = 0; - -/// Sorted (ascending) frame indices whose frame-tree branch reported `removed()` last frame. -var removed_frame_indices_buf: [256]usize = undefined; -var removed_frame_indices_len: usize = 0; - -animation_insert_before_index: ?usize = null, -sprite_insert_before_index: ?usize = null, -edit_anim_id: ?u64 = null, -prev_anim_count: usize = 0, -prev_anim_id: u64 = 0, -prev_sprite_count: usize = 0, - -/// Origin axis values for sprites tab (slider + text); resync when `origin_fields_sync_key` changes. -origin_edit_x: f32 = 0, -origin_edit_y: f32 = 0, -origin_fields_sync_key: u64 = 0, - -/// Mouse-drag batching for origin sliders: snapshot until drag ends, then one history step if origins changed. -origin_x_drag_indices: ?[]usize = null, -origin_x_drag_old_vals: ?[][2]f32 = null, -origin_x_slider_drag_prev: bool = false, -origin_y_drag_indices: ?[]usize = null, -origin_y_drag_old_vals: ?[][2]f32 = null, -origin_y_slider_drag_prev: bool = false, - -/// Visible clip of the animation list scroll area (for pointer gating, same idea as layers). -animations_scroll_viewport_rect: ?dvui.Rect.Physical = null, -/// Visible clip of the frames list scroll area. -frames_scroll_viewport_rect: ?dvui.Rect.Physical = null, - -pub fn init() Sprites { - return .{}; -} - -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; - const last = file.editor.selected_sprites.findLastSet() orelse return 0; - // Widen to u64 before shifting: `usize` is `u32` on wasm32, so `c << 48` - // would overflow without the cast. - 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 { - 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; - } - return false; -} - -fn freeOriginAxisDragSnapshot(self: *Sprites, axis: enum { x, y }) void { - switch (axis) { - .x => { - if (self.origin_x_drag_indices) |s| { - Globals.allocator().free(s); - self.origin_x_drag_indices = null; - } - if (self.origin_x_drag_old_vals) |v| { - Globals.allocator().free(v); - self.origin_x_drag_old_vals = null; - } - }, - .y => { - if (self.origin_y_drag_indices) |s| { - Globals.allocator().free(s); - self.origin_y_drag_indices = null; - } - if (self.origin_y_drag_old_vals) |v| { - Globals.allocator().free(v); - self.origin_y_drag_old_vals = null; - } - }, - } -} - -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 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) { - indices[i] = si; - old_vals[i] = file.sprites.items(.origin)[si]; - } - switch (axis) { - .x => { - self.origin_x_drag_indices = indices; - self.origin_x_drag_old_vals = old_vals; - }, - .y => { - self.origin_y_drag_indices = indices; - self.origin_y_drag_old_vals = old_vals; - }, - } -} - -fn appendOriginsHistory(file: *pixelart.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); - return err; - }; -} - -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) { - .x => cw, - .y => rh, - }; - const clamped = std.math.clamp(new_val, 0, max_v); - var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |si| { - switch (axis) { - .x => file.sprites.items(.origin)[si][0] = clamped, - .y => file.sprites.items(.origin)[si][1] = clamped, - } - } -} - -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) { - .x => cw, - .y => rh, - }; - const clamped = std.math.clamp(new_val, 0, max_v); - - 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); - - var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - var i: usize = 0; - while (iter.next()) |si| : (i += 1) { - indices[i] = si; - old_vals[i] = file.sprites.items(.origin)[si]; - } - - for (indices) |si| { - switch (axis) { - .x => file.sprites.items(.origin)[si][0] = clamped, - .y => file.sprites.items(.origin)[si][1] = clamped, - } - } - - file.history.append(.{ .origins = .{ .indices = indices, .values = old_vals } }) catch |err| { - for (indices, 0..) |si, j| { - file.sprites.items(.origin)[si] = old_vals[j]; - } - Globals.allocator().free(indices); - Globals.allocator().free(old_vals); - return err; - }; -} - -pub fn draw(self: *Sprites) !void { - 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(); - - const vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .max_size_content = .{ .w = std.math.floatMax(f32), .h = parent_height }, - }); - defer vbox.deinit(); - - const hbox = dvui.box(@src(), .{ - .dir = .vertical, - .equal_space = false, - }, .{ - .expand = .horizontal, - .background = false, - }); - defer hbox.deinit(); - - self.drawOriginControls() catch { - dvui.log.err("Failed to draw origin controls", .{}); - }; - - { - var animations_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - }); - defer animations_box.deinit(); - - self.drawAnimations() catch { - dvui.log.err("Failed to draw layers", .{}); - }; - - if (file.selected_animation_index != null) { - self.drawFrames() catch { - dvui.log.err("Failed to draw sprites", .{}); - }; - } - } - - for (dvui.events()) |*e| { - if (e.evt == .mouse and e.evt.mouse.action == .press) { - if (dvui.eventMatchSimple(e, parent_data)) { - const p = e.evt.mouse.p; - var in_sprite_list: bool = false; - if (self.animations_scroll_viewport_rect) |r| { - if (r.contains(p)) in_sprite_list = true; - } - if (self.frames_scroll_viewport_rect) |r| { - if (r.contains(p)) in_sprite_list = true; - } - if (!in_sprite_list) { - file.clearSelectedSprites(); - } - } - } - } - } -} - -pub fn drawOriginControls(self: *Sprites) !void { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { - if (file.editor.selected_sprites.count() == 0) return; - - const key = selectionUiKey(file); - if (key != self.origin_fields_sync_key) { - self.origin_fields_sync_key = key; - freeOriginAxisDragSnapshot(self, .x); - freeOriginAxisDragSnapshot(self, .y); - self.origin_x_slider_drag_prev = false; - self.origin_y_slider_drag_prev = false; - - var ox_unified: ?f32 = null; - var oy_unified: ?f32 = null; - if (file.editor.selected_sprites.findFirstSet()) |first_si| { - const first_sp = file.sprites.get(first_si); - ox_unified = first_sp.origin[0]; - oy_unified = first_sp.origin[1]; - - var iter = file.editor.selected_sprites.iterator(.{ .direction = .forward, .kind = .set }); - while (iter.next()) |si| { - const sp = file.sprites.get(si); - if (ox_unified) |u| { - if (sp.origin[0] != u) ox_unified = null; - } - if (oy_unified) |u| { - if (sp.origin[1] != u) oy_unified = null; - } - if (ox_unified == null and oy_unified == null) break; - } - } - - self.origin_edit_x = ox_unified orelse if (file.editor.selected_sprites.findFirstSet()) |first_si| file.sprites.get(first_si).origin[0] else 0; - self.origin_edit_y = oy_unified orelse if (file.editor.selected_sprites.findFirstSet()) |first_si| file.sprites.get(first_si).origin[1] else 0; - } - - const cw = @as(f32, @floatFromInt(file.column_width)); - const rh = @as(f32, @floatFromInt(file.row_height)); - - var mixed_x = false; - var mixed_y = false; - if (file.editor.selected_sprites.findFirstSet()) |first_si| { - const o0 = file.sprites.get(first_si).origin; - var iter = file.editor.selected_sprites.iterator(.{ .direction = .forward, .kind = .set }); - while (iter.next()) |si| { - const o = file.sprites.get(si).origin; - if (o[0] != o0[0]) mixed_x = true; - if (o[1] != o0[1]) mixed_y = true; - } - } - - var origin_group = dvui.groupBox(@src(), "Origin", .{ - .expand = .horizontal, - }); - defer origin_group.deinit(); - - var animation = dvui.animate(@src(), .{ .duration = 400_000, .easing = dvui.easing.outBack, .kind = .vertical }, .{ - .expand = .horizontal, - }); - defer animation.deinit(); - - var fields = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .horizontal, - }); - defer fields.deinit(); - - { - var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - }); - defer row.deinit(); - - dvui.labelNoFmt(@src(), "X", .{}, .{ .font = dvui.Font.theme(.body) }); - if (mixed_x) { - dvui.icon(@src(), "OriginXIcon", icons.tvg.lucide.@"link-2-off", .{ - .stroke_color = dvui.themeGet().color(.control, .text), - }, .{ - .gravity_y = 0.5, - .expand = .none, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }); - } else { - dvui.icon(@src(), "OriginXIcon", icons.tvg.lucide.@"link-2", .{ - .stroke_color = dvui.themeGet().color(.control, .text), - }, .{ - .gravity_y = 0.5, - .expand = .none, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }); - } - var x_slider_wd: dvui.WidgetData = undefined; - const x_changed = dvui.sliderEntry(@src(), "{d:0.0}", .{ - .value = &self.origin_edit_x, - .min = 0, - .max = cw, - .interval = 1, - }, .{ - .id_extra = 0xb00001, - .expand = .horizontal, - .data_out = &x_slider_wd, - }); - const x_slider_dragging = dvui.dataGet(null, x_slider_wd.id, "_start_v", f32) != null; - - if (x_slider_dragging and self.origin_x_drag_indices == null) { - try beginOriginAxisDragSnapshot(self, file, .x); - } - - if (x_changed) { - const cl = std.math.clamp(self.origin_edit_x, 0, cw); - if (x_slider_dragging) { - applySpriteOriginAxisNoHistory(file, .x, cl); - } else { - freeOriginAxisDragSnapshot(self, .x); - try commitSpriteOriginAxis(file, .x, cl); - } - self.origin_edit_x = cl; - } - - if (self.origin_x_slider_drag_prev and !x_slider_dragging) { - if (self.origin_x_drag_indices) |indices| { - const old_vals = self.origin_x_drag_old_vals.?; - defer { - self.origin_x_drag_indices = null; - self.origin_x_drag_old_vals = null; - } - if (selectionOriginsDifferFrom(file, indices, old_vals)) { - try appendOriginsHistory(file, indices, old_vals); - } else { - Globals.allocator().free(indices); - Globals.allocator().free(old_vals); - } - } - } - self.origin_x_slider_drag_prev = x_slider_dragging; - } - { - var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - }); - defer row.deinit(); - - dvui.labelNoFmt(@src(), "Y", .{}, .{ .font = dvui.Font.theme(.body) }); - if (mixed_y) { - dvui.icon(@src(), "OriginYIcon", icons.tvg.lucide.@"link-2-off", .{ - .stroke_color = dvui.themeGet().color(.control, .text), - }, .{ - .gravity_y = 0.5, - .expand = .none, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }); - } else { - dvui.icon(@src(), "OriginYIcon", icons.tvg.lucide.@"link-2", .{ - .stroke_color = dvui.themeGet().color(.control, .text), - }, .{ - .gravity_y = 0.5, - .expand = .none, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }); - } - var y_slider_wd: dvui.WidgetData = undefined; - const y_changed = dvui.sliderEntry(@src(), "{d:0.0}", .{ - .value = &self.origin_edit_y, - .min = 0, - .max = rh, - .interval = 1, - }, .{ - .id_extra = 0xb00002, - .expand = .horizontal, - .data_out = &y_slider_wd, - }); - const y_slider_dragging = dvui.dataGet(null, y_slider_wd.id, "_start_v", f32) != null; - - if (y_slider_dragging and self.origin_y_drag_indices == null) { - try beginOriginAxisDragSnapshot(self, file, .y); - } - - if (y_changed) { - const cl = std.math.clamp(self.origin_edit_y, 0, rh); - if (y_slider_dragging) { - applySpriteOriginAxisNoHistory(file, .y, cl); - } else { - freeOriginAxisDragSnapshot(self, .y); - try commitSpriteOriginAxis(file, .y, cl); - } - self.origin_edit_y = cl; - } - - if (self.origin_y_slider_drag_prev and !y_slider_dragging) { - if (self.origin_y_drag_indices) |indices| { - const old_vals = self.origin_y_drag_old_vals.?; - defer { - self.origin_y_drag_indices = null; - self.origin_y_drag_old_vals = null; - } - if (selectionOriginsDifferFrom(file, indices, old_vals)) { - try appendOriginsHistory(file, indices, old_vals); - } else { - Globals.allocator().free(indices); - Globals.allocator().free(old_vals); - } - } - } - self.origin_y_slider_drag_prev = y_slider_dragging; - } - } -} - -pub fn drawAnimationControls(self: *Sprites) !void { - var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - }); - defer box.deinit(); - - const icon_color = dvui.themeGet().color(.control, .text); - - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { - { - var add_animation_button: dvui.ButtonWidget = undefined; - add_animation_button.init(@src(), .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - }); - defer add_animation_button.deinit(); - - add_animation_button.processEvents(); - add_animation_button.drawBackground(); - - dvui.icon( - @src(), - "AddAnimationIcon", - icons.tvg.lucide.plus, - .{ - .fill_color = icon_color, - .stroke_color = icon_color, - }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .expand = .ratio, - .color_text = add_animation_button.data().options.color_text, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }, - ); - - if (add_animation_button.clicked()) { - const anim_index = try file.createAnimation(); - file.selected_animation_index = anim_index; - file.editor.animations_scroll_to_index = anim_index; - self.edit_anim_id = file.animations.items(.id)[anim_index]; - - file.history.append(.{ - .animation_restore_delete = .{ - .action = .delete, - .index = anim_index, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } - } - - { - var duplicate_animation_button: dvui.ButtonWidget = undefined; - duplicate_animation_button.init(@src(), .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - }); - - defer duplicate_animation_button.deinit(); - duplicate_animation_button.processEvents(); - const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5); - duplicate_animation_button.drawBackground(); - - dvui.icon( - @src(), - "DuplicateAnimationIcon", - icons.tvg.lucide.@"copy-plus", - .{ .fill_color = icon_color, .stroke_color = icon_color }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .expand = .ratio, - .color_text = duplicate_animation_button.data().options.color_text, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }, - ); - - dvui.alphaSet(alpha); - - if (duplicate_animation_button.clicked()) { - if (file.animations.len > 0) { - if (file.selected_animation_index) |index| { - const anim_index = try file.duplicateAnimation(index); - file.selected_animation_index = anim_index; - file.editor.animations_scroll_to_index = anim_index; - self.edit_anim_id = file.animations.items(.id)[anim_index]; - - file.history.append(.{ - .animation_restore_delete = .{ - .action = .delete, - .index = anim_index, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } - } - } - } - - { - var delete_animation_button: dvui.ButtonWidget = undefined; - delete_animation_button.init(@src(), .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.err, .fill), - }); - defer delete_animation_button.deinit(); - delete_animation_button.processEvents(); - - const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5); - delete_animation_button.drawBackground(); - - dvui.icon( - @src(), - "DeleteAnimationIcon", - icons.tvg.lucide.trash, - .{ .fill_color = dvui.themeGet().color(.window, .fill), .stroke_color = dvui.themeGet().color(.window, .fill) }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .expand = .ratio, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }, - ); - - dvui.alphaSet(alpha); - - if (delete_animation_button.clicked()) { - if (file.animations.len > 0) { - if (file.selected_animation_index) |index| { - file.deleteAnimation(index) catch { - dvui.log.err("Failed to delete animation", .{}); - }; - if (index > 0) { - file.selected_animation_index = index - 1; - } else { - file.selected_animation_index = null; - } - } - } - } - } - } -} - -pub fn drawAnimations(self: *Sprites) !void { - const outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .horizontal, - .background = false, - }); - defer outer_box.deinit(); - - const parent_width = dvui.parentGet().data().rect.w; - const controls_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .horizontal, - .background = false, - }); - dvui.labelNoFmt(@src(), "ANIMATIONS", .{}, .{ .font = dvui.Font.theme(.heading) }); - - self.drawAnimationControls() catch {}; - - controls_box.deinit(); - - 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; - - self.animations_scroll_viewport_rect = null; - anim_rename_hit_te_id = null; - anim_rename_hit_rect = null; - - var scroll_area = dvui.scrollArea(@src(), .{ - .scroll_info = &file.editor.animations_scroll_info, - .horizontal_bar = .auto_overlay, - .vertical_bar = .auto_overlay, - }, .{ - .expand = .horizontal, - .background = false, - - .max_size_content = .{ .h = std.math.floatMax(f32), .w = parent_width / 2.0 }, - }); - defer scroll_area.deinit(); - - if (dvui.ScrollContainerWidget.current()) |sc| { - self.animations_scroll_viewport_rect = sc.data().contentRectScale().r; - } - - var inner_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = false, - .margin = .{ .h = 6, .w = 6 }, - }); - defer inner_box.deinit(); - - 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, .{}); - } - if (file.editor.animations_scroll_info.offset(.horizontal) > 0.0) { - pixelart.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 }, .{ - .expand = .horizontal, - .background = false, - }); - defer tree.deinit(); - - var anim_hits_buf: [256]AnimationRowHit = undefined; - var anim_hits_len: usize = 0; - - // Drag and drop is completing — supports single- and multi-row drags. - if (self.animation_insert_before_index) |insert_before_raw| { - if (removed_animation_indices_len > 0) { - const sources = removed_animation_indices_buf[0..removed_animation_indices_len]; - - const primary_before_opt = file.selected_animation_index; - var primary_was_moved = false; - var primary_pos_in_sources: usize = 0; - if (primary_before_opt) |pb| { - for (sources, 0..) |s, pi| { - if (s == pb) { - primary_was_moved = true; - primary_pos_in_sources = pi; - break; - } - } - } - - 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); - } - - var ri = sources.len; - while (ri > 0) { - ri -= 1; - file.animations.orderedRemove(sources[ri]); - } - - 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(Globals.allocator(), target + i, anim) catch { - dvui.log.err("Failed to insert animation", .{}); - }; - } - - if (primary_was_moved) { - file.selected_animation_index = target + primary_pos_in_sources; - } - - file.editor.selected_animation_indices.clearRetainingCapacity(); - for (0..moved.len) |i| { - file.editor.selected_animation_indices.append(Globals.allocator(), target + i) catch { - dvui.log.err("Failed to update animation selection", .{}); - }; - } - file.editor.animation_selection_anchor = file.selected_animation_index; - - self.animation_insert_before_index = null; - removed_animation_indices_len = 0; - } else { - self.animation_insert_before_index = null; - } - } else if (removed_animation_indices_len > 0) { - removed_animation_indices_len = 0; - } - - ensureAnimationSelection(file); - - const box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .horizontal, - .background = false, - .corner_radius = dvui.Rect.all(1000), - .margin = dvui.Rect.rect(4, 0, 4, 4), - }); - defer box.deinit(); - - const no_buttons_r: dvui.Rect.Physical = .{ .x = 0, .y = 0, .w = 0, .h = 0 }; - - for (file.animations.items(.id), 0..) |anim_id, anim_index| { - const in_multi = animationIndexInMulti(file, anim_index); - const is_primary_row = if (file.selected_animation_index) |p| p == anim_index else false; - 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| { - color = palette.getDVUIColor(@intCast(anim_id)); - } - - var branch = tree.branch(@src(), .{ - .expanded = false, - .process_events = false, - .can_accept_children = false, - .animation_duration = 250_000, - .animation_easing = dvui.easing.outBack, - }, .{ - .id_extra = @intCast(anim_id), - .expand = .horizontal, - .corner_radius = dvui.Rect.all(1000), - .background = false, - .margin = .all(0), - .padding = dvui.Rect.all(1), - }); - defer branch.deinit(); - - if (branch.removed()) { - if (removed_animation_indices_len < removed_animation_indices_buf.len) { - removed_animation_indices_buf[removed_animation_indices_len] = anim_index; - removed_animation_indices_len += 1; - } - } else if (branch.insertBefore()) { - self.animation_insert_before_index = anim_index; - } - - const row_r = branch.data().borderRectScale().r; - const mp = dvui.currentWindow().mouse_pt; - const row_hovered = row_r.contains(mp) and animationPointerInScrollViewport(mp, self.animations_scroll_viewport_rect); - - const ctrl_hover = dvui.themeGet().color(.control, .fill).opacity(0.5); - const row_highlight = blk: { - if (tree.reorderDragActive()) { - if (tree.id_branch) |idb| { - break :blk idb == branch.data().id.asUsize(); - } - break :blk false; - } - break :blk row_hovered and tree.drag_point == null; - }; - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = true, - .color_fill = if (branch.floating()) - .transparent - else if (selected or row_highlight) - ctrl_hover - else - .transparent, - .color_fill_hover = .transparent, - .margin = .all(0), - .padding = dvui.Rect.all(5), - .corner_radius = dvui.Rect.all(8), - }); - defer hbox.deinit(); - - var color_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .background = true, - .gravity_y = 0.5, - .min_size_content = .{ .w = 8.0, .h = 8.0 }, - .color_fill = color, - .corner_radius = dvui.Rect.all(1000), - .margin = dvui.Rect.all(2), - .padding = dvui.Rect.all(0), - }); - color_box.deinit(); - - const font = dvui.Font.theme(.body); - const rename_padding = dvui.Rect.all(0); - - if (self.edit_anim_id != anim_id) { - var name_label_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .background = false, - .gravity_y = 0.5, - .margin = dvui.Rect.rect(2, 0, 2, 0), - .padding = dvui.Rect.all(0), - }); - defer name_label_box.deinit(); - - const anim_name = file.animations.items(.name)[anim_index]; - const name_color: dvui.Color = if (!selected) - dvui.themeGet().color(.control, .text) - else if (is_primary_row) - dvui.themeGet().color(.window, .text) - else - dvui.themeGet().color(.control, .text); - - if (selected) { - if (dvui.labelClick(@src(), "{s}", .{anim_name}, .{ .label_opts = .{ .ellipsize = true } }, .{ - .expand = .none, - .gravity_y = 0.5, - .margin = dvui.Rect{}, - .font = font, - .padding = .{ .y = 1 }, - .color_text = name_color, - })) { - const lr = name_label_box.data().borderRectScale().r; - if (pointerReleaseInRectWithoutSelectionModifier(lr)) { - self.edit_anim_id = anim_id; - } - } - } else { - dvui.labelNoFmt(@src(), anim_name, .{ .ellipsize = true }, .{ - .expand = .none, - .gravity_y = 0.5, - .margin = dvui.Rect{}, - .font = font, - .padding = .{ .y = 1 }, - .color_text = name_color, - }); - } - - var drag_sink = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = false, - .min_size_content = .{ .w = 0, .h = 0 }, - .gravity_y = 0.5, - }); - defer drag_sink.deinit(); - - if (row_hovered and animationPointerInScrollViewport(mp, self.animations_scroll_viewport_rect)) { - dvui.cursorSet(.hand); - } - - if (anim_hits_len < anim_hits_buf.len) { - anim_hits_buf[anim_hits_len] = .{ - .row_r = branch.data().borderRectScale().r, - .buttons_r = no_buttons_r, - .branch_usize = branch.data().id.asUsize(), - .anim_index = anim_index, - .hbox_tl = hbox.data().rectScale().r.topLeft(), - }; - anim_hits_len += 1; - } - } else { - var te = dvui.textEntry(@src(), .{}, .{ - .expand = .horizontal, - .background = false, - .padding = rename_padding, - .margin = dvui.Rect.all(0), - .font = font, - .gravity_y = 0.5, - }); - defer te.deinit(); - - if (dvui.firstFrame(te.data().id)) { - te.textSet(file.animations.items(.name)[anim_index], true); - dvui.focusWidget(te.data().id, null, null); - } - - anim_rename_hit_te_id = te.data().id; - anim_rename_hit_rect = te.data().borderRectScale().r; - - const should_commit_rename = te.enter_pressed or dvui.focusedWidgetId() != te.data().id; - if (should_commit_rename) { - if (!std.mem.eql(u8, file.animations.items(.name)[anim_index], te.getText()) and te.getText().len > 0) { - file.history.append(.{ - .animation_name = .{ - .index = anim_index, - .name = try Globals.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()); - } - if (te.enter_pressed) { - file.selected_animation_index = anim_index; - } - dvui.captureMouse(null, 0); - dvui.focusWidget(null, null, null); - self.edit_anim_id = null; - dvui.refresh(null, @src(), tree.data().id); - } - } - - if (file.editor.animations_scroll_to_index != null and dvui.timerGet(hbox.data().id) == null) { - dvui.timer(hbox.data().id, 1); - } - - if (dvui.timerDone(hbox.data().id)) { - if (file.editor.animations_scroll_to_index) |index| { - if (index == anim_index) { - dvui.scrollTo(.{ .screen_rect = hbox.data().rectScale().r, .over_scroll = true }); - file.editor.animations_scroll_to_index = null; - } - } - } - } - - processAnimationTreePointerEvents(self, tree, file, anim_hits_buf[0..anim_hits_len], self.animations_scroll_viewport_rect); - - if (tree.drag_point != null) { - var tail = tree.branch(@src(), .{ - .expanded = false, - .process_events = false, - .can_accept_children = false, - }, .{ - .id_extra = 0x7fff_fffd, - .expand = .horizontal, - .min_size_content = .{ .w = 0, .h = 14 }, - .color_fill = .transparent, - .color_fill_hover = .transparent, - .color_fill_press = .transparent, - }); - defer tail.deinit(); - if (tail.insertBefore()) { - self.animation_insert_before_index = file.animations.len; - } - } - - 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, .{}); - - 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, .{}); - } -} - -pub fn drawFrameControls(_: *Sprites) !void { - var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - }); - defer box.deinit(); - - 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); - - const icon_color = dvui.themeGet().color(.control, .text); - - { - var sort_anim_asc_button: dvui.ButtonWidget = undefined; - sort_anim_asc_button.init(@src(), .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - }); - - defer sort_anim_asc_button.deinit(); - sort_anim_asc_button.processEvents(); - const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5); - sort_anim_asc_button.drawBackground(); - - dvui.icon( - @src(), - "SortAnimationAscIcon", - icons.tvg.lucide.@"arrow-up-from-line", - .{ .fill_color = icon_color, .stroke_color = icon_color }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .expand = .ratio, - .color_text = sort_anim_asc_button.data().options.color_text, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }, - ); - - 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); - - if (!animation.eqlFrames(prev_order)) { - file.history.append(.{ - .animation_frames = .{ - .index = index, - .frames = prev_order, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - - file.animations.set(index, animation); - } else { - Globals.allocator().free(prev_order); - } - } - } - { - { - var sort_anim_desc_button: dvui.ButtonWidget = undefined; - sort_anim_desc_button.init(@src(), .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - }); - - defer sort_anim_desc_button.deinit(); - sort_anim_desc_button.processEvents(); - const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5); - sort_anim_desc_button.drawBackground(); - - dvui.icon( - @src(), - "SortAnimationDescIcon", - icons.tvg.lucide.@"arrow-down-from-line", - .{ .fill_color = icon_color, .stroke_color = icon_color }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .expand = .ratio, - .color_text = sort_anim_desc_button.data().options.color_text, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }, - ); - - 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); - - if (!animation.eqlFrames(prev_order)) { - file.history.append(.{ - .animation_frames = .{ - .index = index, - .frames = prev_order, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - - file.animations.set(index, animation); - } else { - Globals.allocator().free(prev_order); - } - } - } - } - - { - var add_sprite_button: dvui.ButtonWidget = undefined; - add_sprite_button.init(@src(), .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - }); - - defer add_sprite_button.deinit(); - add_sprite_button.processEvents(); - const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5); - add_sprite_button.drawBackground(); - - dvui.icon( - @src(), - "AddSpriteIcon", - icons.tvg.lucide.plus, - .{ .fill_color = icon_color, .stroke_color = icon_color }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .expand = .ratio, - .color_text = add_sprite_button.data().options.color_text, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }, - ); - - dvui.alphaSet(alpha); - - 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()); - while (iter.next()) |sprite_index| { - frames.append(.{ - .sprite_index = sprite_index, - .ms = @intFromFloat(1000.0 / @as(f32, @floatFromInt(file.editor.selected_sprites.count()))), - }) catch { - dvui.log.err("Failed to append frame", .{}); - return; - }; - } - - const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames); - - animation.appendFrames(Globals.allocator(), frames.items) catch { - dvui.log.err("Failed to append frames", .{}); - }; - - if (!animation.eqlFrames(prev_order)) { - file.history.append(.{ - .animation_frames = .{ - .index = index, - .frames = prev_order, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - - file.animations.set(index, animation); - } else { - Globals.allocator().free(prev_order); - } - } - } - } - - var selection_in_animation = false; - - var selection_iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - blk: while (selection_iter.next()) |sprite_index| { - for (animation.frames) |frame| { - if (frame.sprite_index == sprite_index) { - selection_in_animation = true; - break :blk; - } - } - } - - { - var duplicate_animation_button: dvui.ButtonWidget = undefined; - duplicate_animation_button.init(@src(), .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - }); - - defer duplicate_animation_button.deinit(); - duplicate_animation_button.processEvents(); - const alpha = dvui.alpha(if (selection_in_animation) 1.0 else 0.5); - duplicate_animation_button.drawBackground(); - - dvui.icon( - @src(), - "DuplicateAnimationIcon", - icons.tvg.lucide.@"copy-plus", - .{ .fill_color = icon_color, .stroke_color = icon_color }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .expand = .ratio, - .color_text = duplicate_animation_button.data().options.color_text, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }, - ); - - dvui.alphaSet(alpha); - - 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); - - while (iter.next()) |sprite_index| { - for (animation.frames) |frame| { - if (frame.sprite_index == sprite_index) { - try animation.appendFrame(Globals.allocator(), .{ - .sprite_index = frame.sprite_index, - .ms = frame.ms, - }); - break; - } - } - } - - if (!animation.eqlFrames(prev_order)) { - file.history.append(.{ - .animation_frames = .{ - .index = index, - .frames = prev_order, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - file.selected_animation_frame_index = 0; - file.animations.set(index, animation); - } else { - Globals.allocator().free(prev_order); - } - } - } - - { - var delete_animation_button: dvui.ButtonWidget = undefined; - delete_animation_button.init(@src(), .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.err, .fill).opacity(0.75), - }); - - defer delete_animation_button.deinit(); - delete_animation_button.processEvents(); - const alpha = dvui.alpha(if (selection_in_animation) 1.0 else 0.5); - delete_animation_button.drawBackground(); - - dvui.icon( - @src(), - "DeleteAnimationIcon", - icons.tvg.lucide.minus, - .{ .fill_color = dvui.themeGet().color(.err, .text), .stroke_color = dvui.themeGet().color(.err, .text) }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = dvui.themeGet().color(.err, .text), - .expand = .ratio, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }, - ); - - dvui.alphaSet(alpha); - - 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); - - 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); - break; - } - } - } - - if (!animation.eqlFrames(prev_order)) { - file.history.append(.{ - .animation_frames = .{ - .index = index, - .frames = prev_order, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - file.selected_animation_frame_index = 0; - file.animations.set(index, animation); - } else { - Globals.allocator().free(prev_order); - } - } - } - } -} - -pub fn drawFrames(self: *Sprites) !void { - 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(); - - const outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .horizontal, - .background = false, - }); - defer outer_box.deinit(); - - const controls_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - .background = false, - }); - dvui.labelNoFmt(@src(), "FRAMES", .{}, .{ .font = dvui.Font.theme(.heading) }); - - self.drawFrameControls() catch {}; - - controls_box.deinit(); - - self.frames_scroll_viewport_rect = null; - - // Vertical-only: wide frame rows widen this column; the explorer scroll pans horizontally so - // controls stay reachable. A nested horizontal scroller here fought the explorer at limits (bounce). - file.editor.sprites_scroll_info.horizontal = .none; - file.editor.sprites_scroll_info.viewport.x = 0; - file.editor.sprites_scroll_info.velocity.x = 0; - - var scroll_area = dvui.scrollArea(@src(), .{ .scroll_info = &file.editor.sprites_scroll_info, .horizontal_bar = .hide, .vertical_bar = .auto_overlay }, .{ - .expand = .horizontal, - .background = false, - .corner_radius = dvui.Rect.all(1000), - }); - - defer scroll_area.deinit(); - - if (dvui.ScrollContainerWidget.current()) |sc| { - self.frames_scroll_viewport_rect = sc.data().contentRectScale().r; - } - - var inner_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = false, - .margin = .{ .h = 6, .w = 6 }, - }); - defer inner_box.deinit(); - - const vertical_scroll = file.editor.sprites_scroll_info.offset(.vertical); - - if (file.selected_animation_index) |animation_index| { - var animation = file.animations.get(animation_index); - if (animation.id != self.prev_anim_id) { - frame_row_gesture = null; - } - - 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 }, .{ - .expand = .horizontal, - .background = false, - }); - defer tree.deinit(); - - var frame_hits_buf: [512]FrameRowHit = undefined; - var frame_hits_len: usize = 0; - - if (self.sprite_insert_before_index) |insert_before_raw| { - 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); - defer file.animations.set(animation_index, animation); - - const primary_before = file.selected_animation_frame_index; - var primary_was_moved = false; - var primary_pos_in_sources: usize = 0; - for (sources, 0..) |s, pi| { - if (s == primary_before) { - primary_was_moved = true; - primary_pos_in_sources = pi; - break; - } - } - - 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 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; - for (animation.frames, 0..) |f, idx| { - _ = f; - var is_source = false; - for (sources) |s| if (s == idx) { - is_source = true; - break; - }; - if (!is_source) { - remaining[wi] = animation.frames[idx]; - wi += 1; - } - ri += 1; - } - } - - const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); - const target = @min(target_raw, remaining.len); - - var wi: usize = 0; - for (remaining[0..target]) |f| { - animation.frames[wi] = f; - wi += 1; - } - for (moved) |f| { - animation.frames[wi] = f; - wi += 1; - } - for (remaining[target..]) |f| { - animation.frames[wi] = f; - wi += 1; - } - - if (primary_was_moved) { - file.selected_animation_frame_index = target + primary_pos_in_sources; - } - - file.editor.selected_frame_indices.clearRetainingCapacity(); - for (0..moved.len) |i| { - file.editor.selected_frame_indices.append(Globals.allocator(), target + i) catch { - dvui.log.err("Failed to update frame selection", .{}); - }; - } - file.editor.selected_frame_indices_for_animation_id = animation.id; - file.editor.frame_selection_anchor = file.selected_animation_frame_index; - syncSpritesFromCurrentFrameSelection(file, animation_index); - - if (!animation.eqlFrames(prev_order)) { - file.history.append(.{ - .animation_frames = .{ - .index = animation_index, - .frames = prev_order, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } else { - Globals.allocator().free(prev_order); - } - - self.sprite_insert_before_index = null; - removed_frame_indices_len = 0; - } else { - self.sprite_insert_before_index = null; - } - } else if (removed_frame_indices_len > 0) { - removed_frame_indices_len = 0; - } - - ensureFrameSelection(file, animation_index, animation.id); - - const box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .horizontal, - .background = false, - .corner_radius = dvui.Rect.all(1000), - .margin = dvui.Rect.rect(4, 0, 4, 4), - }); - defer box.deinit(); - - for (animation.frames, 0..) |*frame, frame_index| { - var anim_color = dvui.themeGet().color(.control, .fill_hover); - if (Globals.state.colors.file_tree_palette) |*palette| { - anim_color = palette.getDVUIColor(@intCast(animation.id)); - } - - var branch = tree.branch(@src(), .{ - .expanded = false, - .process_events = false, - .can_accept_children = false, - .animation_duration = 250_000, - .animation_easing = dvui.easing.outBack, - }, .{ - .id_extra = @intCast(frame_index), - .expand = .horizontal, - .corner_radius = dvui.Rect.all(1000), - .background = false, - .margin = .all(0), - .padding = dvui.Rect.all(1), - }); - defer branch.deinit(); - - if (branch.removed()) { - if (removed_frame_indices_len < removed_frame_indices_buf.len) { - removed_frame_indices_buf[removed_frame_indices_len] = frame_index; - removed_frame_indices_len += 1; - } - } else if (branch.insertBefore()) { - self.sprite_insert_before_index = frame_index; - } - - const row_r = branch.data().borderRectScale().r; - const mp = dvui.currentWindow().mouse_pt; - const row_hovered = row_r.contains(mp) and animationPointerInScrollViewport(mp, self.frames_scroll_viewport_rect); - - const sprite_selected = if (frame.sprite_index < file.editor.selected_sprites.capacity()) file.editor.selected_sprites.isSet(frame.sprite_index) else false; - const ctrl_hover = dvui.themeGet().color(.control, .fill).opacity(0.5); - const row_highlight = blk: { - if (tree.reorderDragActive()) { - if (tree.id_branch) |idb| { - break :blk idb == branch.data().id.asUsize(); - } - break :blk false; - } - break :blk row_hovered and tree.drag_point == null; - }; - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = true, - .color_fill = if (branch.floating()) - .transparent - else if ((sprite_selected or row_highlight)) - ctrl_hover - else - .transparent, - .color_fill_hover = .transparent, - .margin = dvui.Rect{}, - .padding = .{ .x = 5, .y = 3, .w = 5, .h = 2 }, - .corner_radius = dvui.Rect.all(8), - }); - defer hbox.deinit(); - - var color_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .background = true, - .gravity_y = 0.5, - .min_size_content = .{ .w = 8.0, .h = 8.0 }, - .color_fill = anim_color, - .corner_radius = dvui.Rect.all(1000), - .margin = .{ .x = 2, .w = 4 }, - .padding = dvui.Rect.all(0), - }); - color_box.deinit(); - - dvui.labelNoFmt(@src(), try file.fmtSprite(dvui.currentWindow().arena(), frame.sprite_index, .grid), .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .margin = dvui.Rect.rect(2, 0, 2, 0), - .padding = dvui.Rect.all(0), - .corner_radius = dvui.Rect.all(1000), - .color_text = if (sprite_selected) dvui.themeGet().color(.control, .text) else dvui.themeGet().color(.control, .text), - }); - - var drag_sink = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = false, - .min_size_content = .{ .w = 0, .h = 0 }, - .gravity_y = 0.5, - }); - defer drag_sink.deinit(); - - var ms_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .background = false, - .gravity_y = 0.5, - .gravity_x = 1.0, - .padding = dvui.Rect.all(0), - .margin = dvui.Rect.all(0), - }); - defer ms_box.deinit(); - - const frame_ms_text = std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{frame.ms}) catch { - dvui.log.err("Failed to allocate frame ms text", .{}); - 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, - .padding = dvui.Rect.all(2), - .margin = dvui.Rect.all(0), - .border = dvui.Rect.all(0), - .min_size_content = .{ - .w = frame_font.textSize(frame_ms_text).w + 2.0, - .h = frame_font.textSize(frame_ms_text).h + 2.0, - }, - .font = frame_font, - .gravity_y = 0.5, - }); - - if (result.changed) { - if (result.value == .Valid) { - for (animation.frames) |*f| { - if (file.editor.selected_sprites.isSet(f.sprite_index) and file.editor.selected_sprites.isSet(frame.sprite_index)) { - f.ms = result.value.Valid; - } - } - } - } - - dvui.labelNoFmt(@src(), "ms", .{}, .{ - .gravity_y = 0.5, - .margin = dvui.Rect.all(0), - .font = frame_font, - .padding = .{ .x = 2, .w = 6 }, - }); - - if (row_hovered and animationPointerInScrollViewport(mp, self.frames_scroll_viewport_rect)) { - dvui.cursorSet(.hand); - } - - const ms_buttons_r = ms_box.data().borderRectScale().r; - if (frame_hits_len < frame_hits_buf.len) { - // Hit-test the actual row chrome (hbox), not the branch shell — the branch - // border rect can be taller/wider than the interactive row and skew pick-one - // resolution when several rows' rects overlap the same point. - frame_hits_buf[frame_hits_len] = .{ - .row_r = hbox.data().borderRectScale().r, - .buttons_r = ms_buttons_r, - .branch_usize = branch.data().id.asUsize(), - .frame_index = frame_index, - .sprite_index = frame.sprite_index, - .hbox_tl = hbox.data().rectScale().r.topLeft(), - }; - frame_hits_len += 1; - } - } - - processFrameTreePointerEvents(tree, file, animation.id, animation_index, frame_hits_buf[0..frame_hits_len], self.frames_scroll_viewport_rect); - - if (tree.drag_point != null) { - var tail = tree.branch(@src(), .{ - .expanded = false, - .process_events = false, - .can_accept_children = false, - }, .{ - .id_extra = 0x7fff_fffc, - .expand = .horizontal, - .min_size_content = .{ .w = 0, .h = 14 }, - .color_fill = .transparent, - .color_fill_hover = .transparent, - .color_fill_press = .transparent, - }); - defer tail.deinit(); - if (tail.insertBefore()) { - self.sprite_insert_before_index = animation.frames.len; - } - } - } - - 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, .{}); - - 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, .{}); - } -} - -/// Geometry for one frame row; used for tree pointer pass (reorder / click). -const FrameRowHit = struct { - row_r: dvui.Rect.Physical, - buttons_r: dvui.Rect.Physical, - branch_usize: usize, - frame_index: usize, - sprite_index: usize, - hbox_tl: dvui.Point.Physical, -}; - -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 pixelart.internal.File) void { - frame_row_gesture = null; -} - -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: *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| { - if (fi >= frames.len) continue; - const si = frames[fi].sprite_index; - if (si < file.editor.selected_sprites.capacity()) file.editor.selected_sprites.set(si); - } -} - -/// 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 { - const frames = file.animations.get(anim_index).frames; - - if (file.editor.selected_frame_indices_for_animation_id != anim_id) { - file.editor.selected_frame_indices.clearRetainingCapacity(); - file.editor.frame_selection_anchor = null; - file.editor.selected_frame_indices_for_animation_id = anim_id; - } - - if (frames.len == 0) { - file.editor.selected_frame_indices.clearRetainingCapacity(); - file.editor.frame_selection_anchor = null; - file.selected_animation_frame_index = 0; - return; - } - - if (file.selected_animation_frame_index >= frames.len) { - file.selected_animation_frame_index = frames.len - 1; - } - - 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; - } - } - std.sort.pdq(usize, file.editor.selected_frame_indices.items, {}, std.sort.asc(usize)); - - if (file.editor.frame_selection_anchor) |a| { - var need_reanchor = false; - if (a >= frames.len) { - need_reanchor = true; - } else { - const spr = frames[a].sprite_index; - if (spr >= file.editor.selected_sprites.capacity() or !file.editor.selected_sprites.isSet(spr)) { - need_reanchor = true; - } - } - if (need_reanchor) { - // While animation plays, `selected_animation_frame_index` is the playhead and must not - // be used to re-establish shift-range / TreeSelection "primary" when the user changes - // which frames are selected (e.g. canvas rect select). - if (file.editor.selected_frame_indices.items.len > 0) { - file.editor.frame_selection_anchor = file.editor.selected_frame_indices.items[0]; - } else if (file.editor.playing) { - file.editor.frame_selection_anchor = null; - } else { - file.editor.frame_selection_anchor = file.selected_animation_frame_index; - } - } - } -} - -fn applyFrameClick( - file: *pixelart.internal.File, - anim_index: usize, - anim_id: u64, - clicked: usize, - mode: pixelart.core.dvui.TreeSelection.ClickMode, -) !bool { - ensureFrameSelection(file, anim_index, anim_id); - - const prev_multi = file.editor.selected_frame_indices.items; - - var clicked_in_prev = false; - for (prev_multi) |i| { - if (i == clicked) { - clicked_in_prev = true; - break; - } - } - const defer_narrow = (mode == .replace and prev_multi.len > 1 and clicked_in_prev); - - if (defer_narrow) { - file.selected_animation_frame_index = clicked; - return true; - } - - var out: std.ArrayList(usize) = .empty; - 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 - // bound instead. - const primary_for_tree: ?usize = if (mode == .extend and - file.editor.playing and - file.editor.frame_selection_anchor == null and - file.editor.selected_frame_indices.items.len > 0) blk: { - 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(), - prev_multi, - primary_for_tree, - file.editor.frame_selection_anchor, - clicked, - mode, - false, - &out, - ); - - file.editor.selected_frame_indices.clearRetainingCapacity(); - 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; - syncSpritesFromCurrentFrameSelection(file, anim_index); - return false; -} - -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(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 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; - const primary: usize = if (file.editor.selected_frame_indices.items.len > 0) blk: { - for (file.editor.selected_frame_indices.items) |fi| { - if (fi == playhead) break :blk playhead; - } - break :blk file.editor.selected_frame_indices.items[0]; - } else playhead; - for (hits) |h| { - if (h.frame_index == primary) { - if (len < out.len) { - out[len] = h.branch_usize; - len += 1; - } - break; - } - } - for (frames, 0..) |f, i| { - if (i == primary) continue; - if (f.sprite_index < file.editor.selected_sprites.capacity() and file.editor.selected_sprites.isSet(f.sprite_index)) { - for (hits) |h| { - if (h.frame_index == i) { - if (len < out.len) { - out[len] = h.branch_usize; - len += 1; - } - break; - } - } - } - } - return out[0..len]; -} - -fn processFrameTreePointerEvents( - tree: *pixelart.core.dvui.TreeWidget, - file: *pixelart.internal.File, - anim_id: u64, - animation_index: usize, - hits: []const FrameRowHit, - viewport_r: ?dvui.Rect.Physical, -) void { - if (!tree.init_options.enable_reordering) return; - - for (dvui.events()) |*e| { - switch (e.evt) { - .mouse => |me| { - if (me.action == .press and me.button.pointer()) { - if (!animationPointerInScrollViewport(me.p, viewport_r)) continue; - - var row_hit: ?FrameRowHit = null; - var ri = hits.len; - while (ri > 0) { - ri -= 1; - const h = hits[ri]; - if (h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) { - row_hit = h; - break; - } - } - if (row_hit) |h| { - const cw = dvui.currentWindow(); - if (cw.dragging.state != .none) dvui.dragEnd(); - frameTreeClearGestureKeysOnly(file); - dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - - 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; - }; - - frame_row_gesture = .{ - .file_id = file.id, - .anim_id = anim_id, - .press_idx = h.frame_index, - .press_p = me.p, - .drag_branch = h.branch_usize, - .moved = false, - .reorder_drag = false, - .narrow_on_release = narrow_on_release, - }; - - dvui.refresh(null, @src(), tree.data().id); - } else { - frameTreeResetRowPointerGesture(file); - } - continue; - } - - if (me.action == .motion) { - if (frame_row_gesture) |*g| { - if (g.file_id == file.id and g.anim_id == anim_id) { - const dx = me.p.x - g.press_p.x; - const dy = me.p.y - g.press_p.y; - if (dx * dx + dy * dy > 16.0) { - g.moved = true; - } - } - } - - if (tree.reorderDragActive()) { - _ = tree.matchEvent(e); - continue; - } - - const branch_usize = if (frameGestureMatches(file, anim_id)) frame_row_gesture.?.drag_branch else null; - if (branch_usize == null) continue; - _ = tree.matchEvent(e); - if (!animationTreeMotionAllowsReorder(tree, e)) continue; - - const prev_th = dvui.Dragging.threshold; - dvui.Dragging.threshold = @max(prev_th, 8.0); - defer dvui.Dragging.threshold = prev_th; - if (dvui.dragging(me.p, null)) |_| { - var row_size: dvui.Size = .{}; - for (hits) |h| { - if (h.branch_usize == branch_usize.?) { - const rn = h.row_r.toNatural(); - row_size = .{ .w = rn.w, .h = rn.h }; - break; - } - } - - var multi_buf: [256]usize = undefined; - const multi_ids = buildFrameMultiDragIds(file, animation_index, hits, &multi_buf); - if (multi_ids.len > 1) { - tree.dragStartMulti(branch_usize.?, multi_ids, me.p, row_size); - } else { - tree.dragStart(branch_usize.?, me.p, row_size); - } - - if (frame_row_gesture) |*g| { - if (g.file_id == file.id and g.anim_id == anim_id) { - g.reorder_drag = true; - g.drag_branch = null; - g.narrow_on_release = false; - } - } - } - } else if (me.action == .release and me.button.pointer()) { - const release_in_vp = animationPointerInScrollViewport(me.p, viewport_r); - - var release_frame_idx: ?usize = null; - var rj = hits.len; - while (rj > 0) { - rj -= 1; - const h = hits[rj]; - if (release_in_vp and h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) { - release_frame_idx = h.frame_index; - break; - } - } - - const idx_opt: ?usize = if (frameGestureMatches(file, anim_id)) frame_row_gesture.?.press_idx else null; - const did_reorder = if (frameGestureMatches(file, anim_id)) frame_row_gesture.?.reorder_drag else false; - const narrow_on_release = if (frameGestureMatches(file, anim_id)) frame_row_gesture.?.narrow_on_release else false; - - var selected_on_release = false; - // Finder-style narrow on release: only when a plain click lands & releases on - // the same already-multi-selected row. - if (!did_reorder and !tree.drag_ending and narrow_on_release and release_in_vp) { - if (release_frame_idx) |rh| { - if (idx_opt) |pi| if (rh == pi) { - narrowFrameSelectionTo(file, animation_index, anim_id, rh); - selected_on_release = true; - }; - } - } - - if (idx_opt != null) { - frameTreeResetRowPointerGesture(file); - if (!did_reorder and !dvui.captured(tree.data().id)) { - dvui.captureMouse(null, e.num); - } - } - - if (selected_on_release) { - dvui.refresh(null, @src(), tree.data().id); - } - } - }, - else => {}, - } - } -} - -/// Geometry for one animation row; used for tree pointer pass (reorder / click / rename). -const AnimationRowHit = struct { - row_r: dvui.Rect.Physical, - buttons_r: dvui.Rect.Physical, - branch_usize: usize, - anim_index: usize, - hbox_tl: dvui.Point.Physical, -}; - -fn animationGestureMatches(file: *const pixelart.internal.File) bool { - return animation_row_gesture != null and animation_row_gesture.?.file_id == file.id; -} - -fn animationTreeClearGestureKeysOnly(_: *const pixelart.internal.File) void { - animation_row_gesture = null; -} - -fn animationTreeResetRowPointerGesture(_: *const pixelart.internal.File) void { - dvui.dragEnd(); - animation_row_gesture = null; -} - -fn animationPointerRenameConsumes(e: *const dvui.Event, me: dvui.Event.Mouse) bool { - if (e.handled) return true; - if (anim_rename_hit_te_id) |rid| { - if (e.target_widgetId) |tid| { - if (tid == rid) return true; - } - } - if (anim_rename_hit_rect) |r| { - if (r.contains(me.p)) return true; - } - return false; -} - -fn animationPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Rect.Physical) bool { - if (viewport_r) |r| return r.contains(p); - return true; -} - -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; - if (!dvui.clipGet().contains(p)) return false; - return true; -} - -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: *pixelart.core.dvui.TreeWidget, e: *dvui.Event) bool { - if (e.target_widgetId) |fwid| { - if (fwid == tree.data().id) return true; - } - const cw = dvui.currentWindow(); - if (cw.dragging.state == .dragging and cw.dragging.name != null) return false; - const me = e.evt.mouse; - const in_surface = animationTreePointerInTreeSurface(tree, me.p, me.floating_win); - const in_border = animationTreePointerInTreeBorder(tree, me.p, me.floating_win); - return in_surface or in_border; -} - -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) { - file.selected_animation_frame_index = anim.frames.len - 1; - } - } else { - file.selected_animation_frame_index = 0; - } -} - -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; - } - return false; -} - -/// 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 { - const count = file.animations.len; - if (count == 0) { - file.editor.selected_animation_indices.clearRetainingCapacity(); - file.editor.animation_selection_anchor = null; - file.selected_animation_index = null; - return; - } - - var w: usize = 0; - var items = file.editor.selected_animation_indices.items; - for (items) |v| { - if (v < count) { - items[w] = v; - w += 1; - } - } - file.editor.selected_animation_indices.shrinkRetainingCapacity(w); - - if (file.selected_animation_index) |p| { - if (p >= count) file.selected_animation_index = null; - } - if (file.selected_animation_index) |p| { - var found = false; - for (file.editor.selected_animation_indices.items) |v| { - if (v == p) { - found = true; - break; - } - } - if (!found) { - 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)); - } - } - - if (file.editor.animation_selection_anchor) |a| { - if (a >= count) file.editor.animation_selection_anchor = file.selected_animation_index; - } -} - -/// 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 { - const prev_multi = file.editor.selected_animation_indices.items; - const was_in_multi = animationIndexInMulti(file, clicked); - const was_multi = prev_multi.len > 1; - - const defer_narrow = (mode == .replace and was_multi and was_in_multi); - - var out: std.ArrayList(usize) = .empty; - defer out.deinit(Globals.allocator()); - - if (defer_narrow) { - 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(Globals.allocator(), out.items); - file.selected_animation_index = clicked; - syncAnimationSelectionFrames(file, clicked); - return true; - } - - const res = try pixelart.core.dvui.TreeSelection.applyClickUsize( - Globals.allocator(), - prev_multi, - file.selected_animation_index, - file.editor.animation_selection_anchor, - clicked, - mode, - false, - &out, - ); - - file.editor.selected_animation_indices.clearRetainingCapacity(); - 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: *pixelart.internal.File, clicked: usize) void { - file.editor.selected_animation_indices.clearRetainingCapacity(); - 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); -} - -/// 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 { - var len: usize = 0; - const primary = file.selected_animation_index; - if (primary) |p| { - for (hits) |h| { - if (h.anim_index == p) { - if (len < out.len) { - out[len] = h.branch_usize; - len += 1; - } - break; - } - } - } - for (file.editor.selected_animation_indices.items) |i| { - if (primary) |p| if (i == p) continue; - for (hits) |h| { - if (h.anim_index == i) { - if (len < out.len) { - out[len] = h.branch_usize; - len += 1; - } - break; - } - } - } - 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 { - if (!tree.init_options.enable_reordering) return; - - for (dvui.events()) |*e| { - switch (e.evt) { - .mouse => |me| { - if (me.action == .press and me.button.pointer()) { - if (animationPointerRenameConsumes(e, me)) continue; - if (!animationPointerInScrollViewport(me.p, viewport_r)) continue; - - var row_hit: ?AnimationRowHit = null; - var ri = hits.len; - while (ri > 0) { - ri -= 1; - const h = hits[ri]; - if (h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) { - row_hit = h; - break; - } - } - if (row_hit) |h| { - const cw = dvui.currentWindow(); - if (cw.dragging.state != .none) dvui.dragEnd(); - animationTreeClearGestureKeysOnly(file); - dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - - 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; - }; - - animation_row_gesture = .{ - .file_id = file.id, - .press_idx = h.anim_index, - .press_p = me.p, - .drag_branch = h.branch_usize, - .moved = false, - .reorder_drag = false, - .narrow_on_release = narrow_on_release, - }; - - dvui.refresh(null, @src(), tree.data().id); - } else { - animationTreeResetRowPointerGesture(file); - } - continue; - } - - if (me.action == .motion) { - if (animationPointerRenameConsumes(e, me)) continue; - - if (animation_row_gesture) |*g| { - if (g.file_id == file.id) { - const dx = me.p.x - g.press_p.x; - const dy = me.p.y - g.press_p.y; - if (dx * dx + dy * dy > 16.0) { - g.moved = true; - } - } - } - - if (tree.reorderDragActive()) { - _ = tree.matchEvent(e); - continue; - } - - const branch_usize = if (animationGestureMatches(file)) animation_row_gesture.?.drag_branch else null; - if (branch_usize == null) continue; - _ = tree.matchEvent(e); - if (!animationTreeMotionAllowsReorder(tree, e)) continue; - - const prev_th = dvui.Dragging.threshold; - dvui.Dragging.threshold = @max(prev_th, 8.0); - defer dvui.Dragging.threshold = prev_th; - if (dvui.dragging(me.p, null)) |_| { - var row_size: dvui.Size = .{}; - for (hits) |h| { - if (h.branch_usize == branch_usize.?) { - const rn = h.row_r.toNatural(); - row_size = .{ .w = rn.w, .h = rn.h }; - break; - } - } - - var multi_buf: [64]usize = undefined; - const multi_ids = buildAnimationMultiDragIds(file, hits, &multi_buf); - if (multi_ids.len > 1) { - tree.dragStartMulti(branch_usize.?, multi_ids, me.p, row_size); - } else { - tree.dragStart(branch_usize.?, me.p, row_size); - } - - if (animation_row_gesture) |*g| { - if (g.file_id == file.id) { - g.reorder_drag = true; - g.drag_branch = null; - g.narrow_on_release = false; - } - } - } - } else if (me.action == .release and me.button.pointer()) { - if (animationPointerRenameConsumes(e, me)) continue; - - const release_in_vp = animationPointerInScrollViewport(me.p, viewport_r); - - var release_anim: ?usize = null; - var rj = hits.len; - while (rj > 0) { - rj -= 1; - const h = hits[rj]; - if (release_in_vp and h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) { - release_anim = h.anim_index; - break; - } - } - - const idx_opt: ?usize = if (animationGestureMatches(file)) animation_row_gesture.?.press_idx else null; - const did_reorder = if (animationGestureMatches(file)) animation_row_gesture.?.reorder_drag else false; - const narrow_on_release = if (animationGestureMatches(file)) animation_row_gesture.?.narrow_on_release else false; - var selected_on_release = false; - if (!did_reorder and !tree.drag_ending and narrow_on_release and release_in_vp) { - if (release_anim) |rh| { - if (idx_opt) |pi| if (rh == pi) { - narrowAnimationSelectionTo(file, rh); - selected_on_release = true; - }; - } - } - - if (idx_opt != null) { - animationTreeResetRowPointerGesture(file); - if (!did_reorder and !dvui.captured(tree.data().id)) { - dvui.captureMouse(null, e.num); - } - } - - if (selected_on_release) { - dvui.refresh(null, @src(), tree.data().id); - } - } - }, - else => {}, - } - } -} - -const FrameSort = struct { - 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: pixelart.internal.Animation.Frame, b: pixelart.internal.Animation.Frame) bool { - return a.sprite_index > b.sprite_index; - } -}; diff --git a/src/plugins/pixelart/src/explorer/tools.zig b/src/plugins/pixelart/src/explorer/tools.zig deleted file mode 100644 index 904b1a32..00000000 --- a/src/plugins/pixelart/src/explorer/tools.zig +++ /dev/null @@ -1,1649 +0,0 @@ -const std = @import("std"); -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 Tools = @This(); - -var insert_before_index: ?usize = null; -/// Sorted (ascending) list of layer indices whose TreeWidget branch reported `removed()` on the -/// last frame's drag completion. Used by the drop handler to reorder multiple selected layers as -/// a group. Bounded because layer count is expected to be modest in practice. -var removed_layer_indices_buf: [64]usize = undefined; -var removed_layer_indices_len: usize = 0; -var edit_layer_id: ?u64 = null; -var prev_layer_count: usize = 0; -var max_split_ratio: f32 = 0.4; - -/// In-flight primary-button gesture for the active file's layer list (reorder / click / rename). -/// Not stored in `dvui.data`: a single path at end of `drawLayers` processes events after rename `textEntry`. -const LayerRowGesture = struct { - file_id: u64, - press_idx: usize, - press_p: dvui.Point.Physical, - drag_branch: ?usize, - moved: bool, - reorder_drag: bool, - /// True when the press landed on a row that was already part of the current multi-selection - /// with no modifier key. We preserve the full selection so the user can drag the whole group; - /// on release without drag we narrow the selection to just `press_idx` (Finder-style). - narrow_on_release: bool, -}; -var layer_row_gesture: ?LayerRowGesture = null; - -/// Filled while the layer rename text entry exists so `processLayerTreePointerEvents` can skip those hits. -var layer_rename_hit_te_id: ?dvui.Id = null; -var layer_rename_hit_rect: ?dvui.Rect.Physical = null; - -layers_rect: ?dvui.Rect.Physical = null, -/// Visible clip of the layer list (scroll container content rect). Rows can have screen rects that -/// extend below this when scrolled; without gating, those rects overlap the palettes pane and steal hover/input. -layers_scroll_viewport_rect: ?dvui.Rect.Physical = null, - -pub fn init() Tools { - return .{}; -} - -pub fn draw(self: *Tools) !void { - var tools_top = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = false, - }); - defer tools_top.deinit(); - - // First time (or after the tools pane was not drawn last frame), horizontal boxes lack - // published min sizes and can lay out like a vertical stack for one frame. Clip drawing - // until the next frame when sizes settle. - const tools_top_settling = dvui.firstFrame(tools_top.data().id); - const prev_clip: ?dvui.Rect.Physical = if (tools_top_settling) - dvui.clip(.{ .x = 0, .y = 0, .w = 0, .h = 0 }) - else - null; - - drawTools() catch {}; - if (prev_clip) |p| dvui.clipSet(p); - drawColors() catch {}; - 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; - defer prev_layer_count = layer_count; - - var paned = pixelart.core.dvui.paned(@src(), .{ - .direction = .vertical, - .collapsed_size = 0, - .handle_size = 10, - .handle_dynamic = .{}, - }, .{ .expand = .both, .background = false }); - defer paned.deinit(); - - if (paned.dragging) { - max_split_ratio = paned.split_ratio.*; - Globals.state.layers_ratio = paned.split_ratio.*; - } - - if (paned.showFirst()) { - self.layers_rect = self.drawLayers() catch { - dvui.log.err("Failed to draw layers", .{}); - return; - }; - } else { - self.layers_rect = null; - self.layers_scroll_viewport_rect = null; - } - - 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) and layer_count == 0) - paned.split_ratio.* = 0.0; - - // `firstFrame` is also true the first time we see the paned after it was not drawn - // (e.g. another explorer tab was active). Min sizes for the subtree are not published - // from the prior frame, so getFirstFittedRatio can be clamped to max_split, then a - // second pass animates to the true fit. Restore from the saved ratio; refit+animate - // 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.*; - } else { - const ratio = paned.getFirstFittedRatio( - .{ - .min_split = 0, - .max_split = @min(max_split_ratio, 0.75), - .min_size = 0, - }, - ); - - const diff = @abs(ratio - paned.split_ratio.*); - - if (diff > 0.000001 and layer_count > 0) { - paned.animateSplit(ratio, dvui.easing.outBack); - } - } - } else { - if (dvui.firstFrame(paned.data().id)) { - if (layer_count == 0) - paned.split_ratio.* = 0.0 - else - paned.split_ratio.* = Globals.state.layers_ratio; - - Globals.state.layers_ratio = paned.split_ratio.*; - } - } - - if (paned.showSecond()) { - drawPaletteControls() catch {}; - drawPalettes() catch {}; - } -} - -pub fn layersHovered(self: *Tools) bool { - const mp = dvui.currentWindow().mouse_pt; - if (self.layers_scroll_viewport_rect) |vr| { - if (!vr.contains(mp)) return false; - } - if (self.layers_rect) |rect| { - return rect.contains(mp); - } - return false; -} - -pub fn drawTools() !void { - const toolbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .gravity_x = 0.5, - .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); - const id_extra = i; - - const selected = Globals.state.tools.current == tool; - - var color = dvui.themeGet().color(.control, .fill_hover); - if (Globals.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 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], - .selection => selection_sprite, - }; - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{}, .{ - .expand = .none, - .min_size_content = .{ .w = 40, .h = 40 }, - .id_extra = id_extra, - .background = true, - .corner_radius = dvui.Rect.all(1000), - .color_fill = if (selected) dvui.themeGet().color(.content, .fill) else .transparent, - .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - .box_shadow = if (selected) .{ - .color = .black, - .offset = .{ .x = -2.5, .y = 2.5 }, - .fade = 4.0, - .alpha = 0.25, - } else null, - .padding = .all(0), - //.border = dvui.Rect.all(1.0), - //.color_border = if (selected) color else dvui.themeGet().color(.control, .fill), - }); - defer button.deinit(); - - 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(Globals.state.host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; - - const uv = dvui.Rect{ - .x = @as(f32, @floatFromInt(sprite.source[0])) / size.w, - .y = @as(f32, @floatFromInt(sprite.source[1])) / size.h, - .w = @as(f32, @floatFromInt(sprite.source[2])) / size.w, - .h = @as(f32, @floatFromInt(sprite.source[3])) / size.h, - }; - - button.processEvents(); - button.drawBackground(); - - var rs = button.data().contentRectScale(); - - const width = @as(f32, @floatFromInt(sprite.source[2])) * rs.s; - const height = @as(f32, @floatFromInt(sprite.source[3])) * rs.s; - - rs.r.x = @round(rs.r.x + (rs.r.w - width) / 2.0); - rs.r.y = @round(rs.r.y + (rs.r.h - height) / 2.0); - rs.r.w = width; - rs.r.h = height; - - dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{ - .uv = uv, - .fade = 0.0, - }) catch { - dvui.log.err("Failed to render image", .{}); - }; - - if (button.clicked()) { - Globals.state.tools.set(tool); - } - } -} - -pub fn drawLayerControls() !void { - var box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .horizontal, - .background = false, - }); - 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| { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .background = false, - .gravity_x = 1.0, - }); - defer hbox.deinit(); - - const merge_up_enabled = file.selected_layer_index > 0; - const merge_down_enabled = file.selected_layer_index + 1 < file.layers.len; - - { - const a = dvui.alpha(if (merge_up_enabled) 1.0 else 0.35); - defer dvui.alphaSet(a); - if (dvui.buttonIcon(@src(), "MergeLayerUp", icons.tvg.lucide.@"arrow-up-to-line", .{}, .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - })) { - if (merge_up_enabled) { - file.mergeSelectedLayerUp() catch { - dvui.log.err("Failed to merge layer up", .{}); - }; - } - } - } - - { - const a = dvui.alpha(if (merge_down_enabled) 1.0 else 0.35); - defer dvui.alphaSet(a); - if (dvui.buttonIcon(@src(), "MergeLayerDown", icons.tvg.lucide.@"arrow-down-to-line", .{}, .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - })) { - if (merge_down_enabled) { - file.mergeSelectedLayerDown() catch { - dvui.log.err("Failed to merge layer down", .{}); - }; - } - } - } - - if (dvui.buttonIcon( - @src(), - "TogglePeek", - if (file.editor.isolate_layer) icons.tvg.lucide.@"layers-2" else icons.tvg.lucide.layers, - .{}, - .{}, - .{ - .expand = .none, - .gravity_y = 0.5, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .style = if (file.editor.isolate_layer) .highlight else .control, - }, - )) { - file.editor.isolate_layer = !file.editor.isolate_layer; - } - - if (dvui.buttonIcon(@src(), "AddLayer", icons.tvg.lucide.plus, .{}, .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - })) { - if (file.createLayer() catch null) |id| { - edit_layer_id = id; - } - } - - if (dvui.buttonIcon(@src(), "DuplicateLayer", icons.tvg.lucide.@"copy-plus", .{}, .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill), - })) { - if (file.duplicateLayer(file.selected_layer_index) catch null) |id| { - edit_layer_id = id; - } - } - - if (file.layers.len > 1) { - if (dvui.buttonIcon(@src(), "DeleteLayer", icons.tvg.lucide.trash, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ - .style = .err, - .expand = .none, - .gravity_y = 0.5, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - })) { - file.deleteLayer(file.selected_layer_index) catch { - dvui.log.err("Failed to delete layer", .{}); - }; - } - } - } -} - -pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { - tools.layers_scroll_viewport_rect = null; - - const vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = false, - }); - defer vbox.deinit(); - - 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; - file.editor.layer_drag_preview_insert_before = null; - - var scroll_area = dvui.scrollArea(@src(), .{ .scroll_info = &file.editor.layers_scroll_info }, .{ - .expand = .both, - .background = false, - .corner_radius = dvui.Rect.all(1000), - }); - - defer scroll_area.deinit(); - - // Visible clip for the layer list (same rect used for scroll content clipping). Row widgets can - // still have screen rects extending below this when scrolled; gate hover/hits to this rect. - if (dvui.ScrollContainerWidget.current()) |sc| { - tools.layers_scroll_viewport_rect = sc.data().contentRectScale().r; - } - - const vertical_scroll = file.editor.layers_scroll_info.offset(.vertical); - - var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ - .expand = .horizontal, - .background = false, - }); - defer tree.deinit(); - - var layer_hits_buf: [256]LayerRowHit = undefined; - var layer_hits_len: usize = 0; - - // Drag and drop is completing — supports single- and multi-row drags. - if (insert_before_index) |insert_before_raw| { - 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); - for (file.layers.items(.id), 0..) |id, i| { - prev_order[i] = id; - } - - const primary_before = file.selected_layer_index; - var primary_was_moved: bool = false; - var primary_pos_in_sources: usize = 0; - for (sources, 0..) |s, pi| { - if (s == primary_before) { - primary_was_moved = true; - primary_pos_in_sources = pi; - break; - } - } - - // 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); - for (sources, 0..) |s, i| { - moved[i] = file.layers.get(s); - } - - // Remove from highest → lowest so earlier indices aren't shifted. - var ri = sources.len; - while (ri > 0) { - ri -= 1; - file.layers.orderedRemove(sources[ri]); - } - - 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(Globals.allocator(), target + i, layer) catch { - dvui.log.err("Failed to insert layer", .{}); - }; - } - - file.editor.layer_composite_dirty = true; - file.editor.split_composite_dirty = true; - - if (primary_was_moved) { - file.selected_layer_index = target + primary_pos_in_sources; - } - - // 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 { - dvui.log.err("Failed to update layer selection", .{}); - }; - } - file.editor.layer_selection_anchor = file.selected_layer_index; - - if (!std.mem.eql(u64, file.layers.items(.id)[0..file.layers.len], prev_order)) { - file.history.append(.{ - .layers_order = .{ - .order = prev_order, - // Layer ids are u64 on disk; convert to the usize the - // history's `selected` field expects. - .selected = @intCast(file.layers.items(.id)[file.selected_layer_index]), - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } else { - Globals.allocator().free(prev_order); - } - - insert_before_index = null; - removed_layer_indices_len = 0; - } else { - insert_before_index = null; - } - } else if (removed_layer_indices_len > 0) { - // Drag ended without a valid drop target; discard the removal intent. - removed_layer_indices_len = 0; - } - - // Sync the multi-selection list with the primary index each frame so it tracks operations - // (delete/duplicate/merge) that only update `selected_layer_index`. The set must always - // contain the primary — the editor cannot have zero selected layers. - ensureLayerSelection(file); - - const box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .horizontal, - .background = false, - .corner_radius = dvui.Rect.all(1000), - .margin = dvui.Rect.rect(4, 0, 4, 4), - }); - defer box.deinit(); - - for (file.layers.items(.id), 0..) |layer_id, layer_index| { - const in_multi = layerIndexInMulti(file, layer_index); - const is_primary_row = file.selected_layer_index == layer_index; - const selected = if (edit_layer_id) |id| id == layer_id else (is_primary_row or in_multi); - const visible = file.layers.items(.visible)[layer_index]; - 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| { - color = palette.getDVUIColor(@intCast(layer_id)); - } - - // `process_events` must be false: Tree Branch's header `ButtonWidget.processEvents` runs - // `dvui.clicked`, which captures on press + dragPreStart for the full button rect (~row height), - // stealing presses before label/sink (dvui `clickedEx` press handler). - var branch = tree.branch(@src(), .{ - .expanded = false, - .process_events = false, - .can_accept_children = false, - .animation_duration = 250_000, - .animation_easing = dvui.easing.outBack, - }, .{ - .id_extra = @intCast(layer_id), - .expand = .horizontal, - .corner_radius = dvui.Rect.all(1000), - .background = false, - .margin = .all(0), - .padding = dvui.Rect.all(1), - }); - defer branch.deinit(); - - if (branch.removed()) { - if (removed_layer_indices_len < removed_layer_indices_buf.len) { - removed_layer_indices_buf[removed_layer_indices_len] = layer_index; - removed_layer_indices_len += 1; - } - } else if (branch.insertBefore()) { - insert_before_index = layer_index; - } - - const row_r = branch.data().borderRectScale().r; - const mp = dvui.currentWindow().mouse_pt; - const row_hovered = row_r.contains(mp) and layerPointerInScrollViewport(mp, tools.layers_scroll_viewport_rect); - - if (tree.reorderDragActive()) { - if (tree.id_branch) |idb| { - if (idb == branch.data().id.asUsize()) { - file.peek_layer_index = layer_index; - } - } - } else if (row_hovered) { - file.peek_layer_index = layer_index; - } - - var min_layer_index: usize = 0; - 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()) { - min_layer_index = file.selected_layer_index; - } - } - - const below_mouse = dvui.currentWindow().mouse_pt.y > branch.data().contentRectScale().r.y + branch.data().contentRectScale().r.h; - - var alpha: f32 = dvui.alpha(1.0); - if (file.editor.isolate_layer and (layer_index < min_layer_index or (below_mouse and tools.layersHovered()))) { - alpha = dvui.alpha(0.5); - } - defer dvui.alphaSet(alpha); - - const ctrl_hover = dvui.themeGet().color(.control, .fill).opacity(0.5); - const row_highlight = blk: { - if (tree.reorderDragActive()) { - if (tree.id_branch) |idb| { - break :blk idb == branch.data().id.asUsize(); - } - break :blk false; - } - break :blk row_hovered and tree.drag_point == null; - }; - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = true, - .color_fill = if ((selected or row_highlight) and !branch.floating()) - ctrl_hover - else - .transparent, - .color_fill_hover = .transparent, - .margin = dvui.Rect{}, - .padding = dvui.Rect.all(1), - .corner_radius = dvui.Rect.all(8), - .box_shadow = null, - }); - defer hbox.deinit(); - - // _ = dvui.icon( - // @src(), - // "LayerIcon", - // icons.tvg.heroicons.solid.@"square-3-stack-3d", - // .{ - // .stroke_color = if (!(selected or row_hovered)) dvui.themeGet().color(.control, .fill) else if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.window, .fill), - // .fill_color = if (!(selected or row_hovered)) dvui.themeGet().color(.control, .fill) else if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.window, .fill), - // }, - // .{ .expand = .none, .gravity_y = 0.5, .margin = .{ .x = 4, .w = 4 } }, - // ); - - var color_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .background = true, - .gravity_y = 0.5, - .min_size_content = .{ .w = 8.0, .h = 8.0 }, - .color_fill = color, - .corner_radius = dvui.Rect.all(1000), - .margin = dvui.Rect.all(2), - .padding = dvui.Rect.all(0), - }); - color_box.deinit(); - - if (edit_layer_id != layer_id) { - // Always use the same label wrapper so sibling widget ids (drag_sink, button_box) stay stable - // when selection changes — otherwise the extra box only on the selected row causes a layout flash. - var name_label_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .background = false, - .gravity_y = 0.5, - .margin = dvui.Rect.rect(2, 0, 2, 0), - .padding = dvui.Rect.all(0), - }); - defer name_label_box.deinit(); - - const name_text = file.layers.items(.name)[layer_index]; - const name_color: dvui.Color = if (!selected) - dvui.themeGet().color(.control, .text) - else if (is_primary_row) - dvui.themeGet().color(.window, .text) - else - dvui.themeGet().color(.control, .text); - - if (selected) { - if (dvui.labelClick(@src(), "{s}", .{name_text}, .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .margin = dvui.Rect{}, - .font = font, - .padding = dvui.Rect.all(0), - .color_text = name_color, - })) { - const lr = name_label_box.data().borderRectScale().r; - if (pointerReleaseInRectWithoutSelectionModifier(lr)) { - edit_layer_id = layer_id; - } - } - } else { - dvui.labelNoFmt(@src(), name_text, .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .margin = dvui.Rect{}, - .font = font, - .padding = dvui.Rect.all(0), - .color_text = name_color, - }); - } - } else { - var te = dvui.textEntry(@src(), .{}, .{ - .expand = .horizontal, - .background = false, - .padding = dvui.Rect.all(0), - .margin = dvui.Rect.all(0), - .font = font, - .gravity_y = 0.5, - }); - defer te.deinit(); - - if (dvui.firstFrame(te.data().id)) { - te.textSet(file.layers.items(.name)[layer_index], true); - dvui.focusWidget(te.data().id, null, null); - } - - layer_rename_hit_te_id = te.data().id; - layer_rename_hit_rect = te.data().borderRectScale().r; - - const should_commit_rename = te.enter_pressed or dvui.focusedWidgetId() != te.data().id; - if (should_commit_rename) { - if (!std.mem.eql(u8, file.layers.items(.name)[layer_index], te.getText()) and te.getText().len > 0) { - file.history.append(.{ - .layer_name = .{ - .index = layer_index, - .name = try Globals.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()); - } - if (te.enter_pressed) { - file.selected_layer_index = layer_index; - } - dvui.captureMouse(null, 0); - dvui.focusWidget(null, null, null); - edit_layer_id = null; - dvui.refresh(null, @src(), tree.data().id); - } - } - - if (edit_layer_id != layer_id) { - var drag_sink = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = false, - .min_size_content = .{ .w = 0, .h = 0 }, - .gravity_y = 0.5, - }); - defer drag_sink.deinit(); - - var button_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .background = false, - .gravity_x = 1.0, - .gravity_y = 0.5, - }); - defer button_box.deinit(); - - if (dvui.buttonIcon( - @src(), - "collapse_button", - if (file.layers.items(.collapse)[layer_index]) icons.tvg.lucide.@"arrow-down-to-line" else icons.tvg.lucide.package, - .{ .draw_focus = false }, - .{}, - .{ - .expand = .ratio, - .min_size_content = .{ .w = 1.0, .h = 11.0 }, - .id_extra = layer_index, - .corner_radius = dvui.Rect.all(1000), - .margin = dvui.Rect.all(1), - }, - )) { - file.layers.items(.collapse)[layer_index] = !file.layers.items(.collapse)[layer_index]; - } - - if (dvui.buttonIcon( - @src(), - "hide_button", - if (file.layers.items(.visible)[layer_index]) icons.tvg.lucide.eye else icons.tvg.lucide.@"eye-closed", - .{ .draw_focus = false }, - .{}, - .{ - .expand = .ratio, - .min_size_content = .{ .w = 1.0, .h = 11.0 }, - .id_extra = layer_index, - .corner_radius = dvui.Rect.all(1000), - .margin = dvui.Rect.all(1), - }, - )) { - file.layers.items(.visible)[layer_index] = !file.layers.items(.visible)[layer_index]; - file.editor.layer_composite_dirty = true; - file.editor.split_composite_dirty = true; - } - - if (layer_hits_len < layer_hits_buf.len) { - layer_hits_buf[layer_hits_len] = .{ - .row_r = branch.data().borderRectScale().r, - .buttons_r = button_box.data().borderRectScale().r, - .branch_usize = branch.data().id.asUsize(), - .layer_index = layer_index, - .hbox_tl = hbox.data().rectScale().r.topLeft(), - }; - layer_hits_len += 1; - } - - if (row_hovered) { - if (!button_box.data().borderRectScale().r.contains(mp)) { - dvui.cursorSet(.hand); - } - } - } else { - var button_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .background = false, - .gravity_x = 1.0, - }); - defer button_box.deinit(); - - if (dvui.buttonIcon( - @src(), - "collapse_button", - if (file.layers.items(.collapse)[layer_index]) icons.tvg.lucide.@"arrow-down-to-line" else icons.tvg.lucide.package, - .{ .draw_focus = false }, - .{}, - .{ - .expand = .ratio, - .min_size_content = .{ .w = 1.0, .h = 11.0 }, - .id_extra = layer_index, - .corner_radius = dvui.Rect.all(1000), - .margin = dvui.Rect.all(1), - }, - )) { - file.layers.items(.collapse)[layer_index] = !file.layers.items(.collapse)[layer_index]; - } - - if (dvui.buttonIcon( - @src(), - "hide_button", - if (file.layers.items(.visible)[layer_index]) icons.tvg.lucide.eye else icons.tvg.lucide.@"eye-closed", - .{ .draw_focus = false }, - .{}, - .{ - .expand = .ratio, - .min_size_content = .{ .w = 1.0, .h = 11.0 }, - .id_extra = layer_index, - .corner_radius = dvui.Rect.all(1000), - .margin = dvui.Rect.all(1), - }, - )) { - file.layers.items(.visible)[layer_index] = !file.layers.items(.visible)[layer_index]; - file.editor.layer_composite_dirty = true; - file.editor.split_composite_dirty = true; - } - } - } - - processLayerTreePointerEvents(tree, file, layer_hits_buf[0..layer_hits_len], tools.layers_scroll_viewport_rect); - - var layer_tail_branch_id: ?usize = null; - if (tree.drag_point != null) { - const tail = tree.branch(@src(), .{ - .expanded = false, - .process_events = false, - .can_accept_children = false, - }, .{ - .id_extra = 0x7fff_fffe, - .expand = .horizontal, - .min_size_content = .{ .w = 0, .h = 14 }, - .color_fill = .transparent, - .color_fill_hover = .transparent, - .color_fill_press = .transparent, - }); - defer tail.deinit(); - layer_tail_branch_id = tail.data().id.asUsize(); - if (tail.insertBefore()) { - insert_before_index = file.layers.len; - } - } - - if (tree.reorderDragActive()) { - if (tree.id_branch) |idb| { - var from: ?usize = null; - for (layer_hits_buf[0..layer_hits_len]) |h| { - if (h.branch_usize == idb) { - from = h.layer_index; - break; - } - } - if (from) |fr| { - var insert_before: ?usize = null; - if (tree.drop_target_branch_id) |dtb| { - if (dtb == idb) { - insert_before = fr; - } else if (layer_tail_branch_id) |tid| { - if (dtb == tid) { - insert_before = file.layers.len; - } - } - if (insert_before == null) { - for (layer_hits_buf[0..layer_hits_len]) |h| { - if (h.branch_usize == dtb) { - insert_before = h.layer_index; - break; - } - } - } - } - if (insert_before) |ins| { - if (fr != ins) { - file.editor.layer_drag_preview_removed = fr; - file.editor.layer_drag_preview_insert_before = ins; - file.editor.layer_composite_dirty = true; - file.editor.split_composite_dirty = true; - } - } - } - } - } - - // 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, .{}); - - 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, .{}); - } - - 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; - } - return vbox.data().contentRectScale().r; - } - - return null; -} - -pub fn drawColors() !void { - dvui.labelNoFmt(@src(), "COLORS", .{}, .{ .font = dvui.Font.theme(.heading) }); - - var hbox = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ - .expand = .horizontal, - .background = false, - .min_size_content = .{ .w = 64.0, .h = 64.0 }, - .margin = dvui.Rect.all(4), - }); - 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 button_opts: dvui.Options = .{ - .expand = .both, - .background = true, - .corner_radius = dvui.Rect.all(8.0), - .color_fill = primary, - .color_fill_hover = primary, - .color_fill_press = primary, - .margin = dvui.Rect.all(4), - .padding = dvui.Rect.all(0), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(8.0), - }, - }; - - const secondary_overrider: dvui.Options = .{ - .color_fill = secondary, - .color_fill_hover = secondary, - .color_fill_press = secondary, - }; - - var clicked: bool = false; - { - var primary_button: dvui.ButtonWidget = undefined; - primary_button.init(@src(), .{}, button_opts); - defer primary_button.deinit(); - - try drawColorPicker(primary_button.data().rectScale().r, &Globals.state.colors.primary, 0); - - primary_button.processEvents(); - primary_button.drawBackground(); - - if (primary_button.clicked()) clicked = true; - } - - { - var secondary_button: dvui.ButtonWidget = undefined; - 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); - - secondary_button.processEvents(); - secondary_button.drawBackground(); - - if (secondary_button.clicked()) clicked = true; - } - - if (clicked) { - std.mem.swap([4]u8, &Globals.state.colors.primary, &Globals.state.colors.secondary); - } -} - -fn drawColorPicker(rect: dvui.Rect.Physical, backing_color: *[4]u8, id_extra: usize) !void { - var context = dvui.context(@src(), .{ .rect = rect }, .{ .id_extra = id_extra }); - defer context.deinit(); - - if (context.activePoint()) |point| { - var fw2 = dvui.floatingMenu(@src(), .{ .from = dvui.Rect.Natural.fromPoint(point) }, .{ .box_shadow = .{ - .color = .black, - .offset = .{ .x = 0, .y = 0 }, - .shrink = 0, - .fade = 10, - .alpha = 0.15, - } }); - defer fw2.deinit(); - - var color: dvui.Color.HSV = .fromColor(.{ - .r = backing_color.*[0], - .g = backing_color.*[1], - .b = backing_color.*[2], - .a = backing_color.*[3], - }); - - if (dvui.colorPicker(@src(), .{ .alpha = true, .hsv = &color }, .{ - .expand = .horizontal, - .background = false, - .corner_radius = dvui.Rect.all(1000), - // Default saturation box is 100×100 and the sliders / hue strip size off of - // that. Bumping the outer min_size_content to ~2× makes everything inside - // (the value/saturation pad, the hue strip, the RGB+A sliders, the hex - // entry) scale up via their `.expand = .ratio` / `.expand = .horizontal`, - // giving a touch-friendly hit area without restyling each piece. - .min_size_content = .{ .w = 220, .h = 220 }, - })) { - const c = color.toColor(); - backing_color.* = .{ - c.r, - c.g, - c.b, - c.a, - }; - } - } -} - -pub fn drawPaletteControls() !void { - var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .background = false, - }); - defer box.deinit(); - - dvui.labelNoFmt(@src(), "PALETTES", .{}, .{ .font = dvui.Font.theme(.heading) }); - - if (dvui.buttonIcon(@src(), "PinPalettes", dvui.entypo.pin, .{ .draw_focus = false }, .{}, .{ - .expand = .none, - .gravity_y = 0.5, - .gravity_x = 1.0, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), - }, - .rotation = std.math.pi * 0.25, - .style = if (Globals.state.pinned_palettes) .highlight else .control, - })) { - Globals.state.pinned_palettes = !Globals.state.pinned_palettes; - } -} - -pub fn drawPalettes() !void { - var scroll_area = dvui.scrollArea(@src(), .{}, .{ - .expand = .both, - .background = false, - }); - defer scroll_area.deinit(); - - // Palette search dropdown - { - const oldt = dvui.themeGet(); - var t = oldt; - t.control.fill = t.window.fill; - dvui.themeSet(t); - defer dvui.themeSet(oldt); - - var dropdown: dvui.DropdownWidget = undefined; - dropdown.init(@src(), .{ .label = "Palette" }, .{ - .expand = .horizontal, - .corner_radius = dvui.Rect.all(1000), - }); - - defer dropdown.deinit(); - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .vertical, - .gravity_x = 1.0, - }); - - 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) }); - } - - dvui.icon( - @src(), - "dropdown_triangle", - dvui.entypo.triangle_down, - .{}, - .{ .gravity_y = 0.5 }, - ); - - hbox.deinit(); - - if (dropdown.dropped()) { - dvui.labelNoFmt(@src(), "Built-in", .{}, .{ - .margin = .all(0), - .gravity_x = 0.5, - }); - _ = dvui.separator(@src(), .{ .expand = .horizontal }); - - var it = (try assets.root.dir("palettes")).iterate(); - while (it.next()) |entry| { - switch (entry.data) { - .file => |data| { - 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| { - dvui.log.err("Failed to load palette: {s}", .{@errorName(err)}); - return error.FailedToLoadPalette; - }; - } - } - }, - .dir => {}, - } - } - - _ = dvui.separator(@src(), .{ .expand = .horizontal }); - // User palette folder scan uses Io.Dir.iterate (NAME_MAX) — unavailable on wasm. - if (comptime builtin.target.cpu.arch != .wasm32) { - searchPalettes(&dropdown) catch { - dvui.log.err("Failed to search palettes", .{}); - }; - } - } - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); - } - - { - if (Globals.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, - }, - }); - - var triangles = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), palette.colors.len * 300, palette.colors.len * 300 * 30) catch return; - - for (palette.colors, 0..) |color, i| { - var anim = dvui.animate( - @src(), - .{ - .duration = 250_000 + 10_000 * @as(i32, @intCast(i)), - .kind = .horizontal, - .easing = dvui.easing.outBack, - }, - .{ - .expand = .none, - .id_extra = dvui.Id.extendId(flex_box.data().id, @src(), i).update(palette.name).asUsize(), - }, - ); - defer anim.deinit(); - - var box_widget = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .min_size_content = .{ .w = 18.0, .h = 18.0 }, - .id_extra = i, - .background = false, - .margin = dvui.Rect.all(1), - }); - - const button_center = box_widget.data().rectScale().r.center(); - const dist = dvui.currentWindow().mouse_pt.diff(button_center).length(); - - // Calculate scale based on mouse distance (closer = larger) - const max_distance = 24.0 * dvui.currentWindow().natural_scale; // Maximum distance for scaling effect - const scale_factor = if (dist < max_distance) - 1.0 + (1.0 - (dist / max_distance)) * 0.5 // Scale up to 1.5x when very close - else - 1.0; - - var path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - defer path.deinit(); - - var rect = box_widget.data().rect.scale(scale_factor, dvui.Rect); - rect.x = box_widget.data().rect.center().x - rect.w / 2.0; - rect.y = box_widget.data().rect.center().y - rect.h / 2.0; - - box_widget.deinit(); - - var button_widget: dvui.ButtonWidget = undefined; - button_widget.init(@src(), .{ .touch_drag = false }, .{ - .expand = .none, - .rect = rect, - .id_extra = i, - }); - - defer button_widget.deinit(); - - path.addRect(button_widget.data().rectScale().r, .all(1000)); - - const base_index: u16 = @intCast(triangles.vertexes.items.len); - - const b = path.build().fillConvexTriangles( - dvui.currentWindow().arena(), - .{ .color = .{ - .r = color[0], - .g = color[1], - .b = color[2], - .a = color[3], - }, .fade = 1.0 }, - ) catch return; - for (b.vertexes) |vertex| { - triangles.appendVertex(vertex); - } - for (b.indices) |*index| { - index.* += @as(u16, @intCast(base_index)); - } - triangles.appendTriangles(b.indices); - - if (dvui.clickedEx(button_widget.data(), .{ .buttons = .any, .touch_drag = true })) |evt| { - switch (evt) { - .mouse => |mouse_evt| { - if (mouse_evt.button.pointer() or mouse_evt.button.touch()) { - @memcpy(&Globals.state.colors.primary, &color); - } else if (mouse_evt.button == .right) { - @memcpy(&Globals.state.colors.secondary, &color); - } - }, - else => {}, - } - } - } - - flex_box.deinit(); - - const clip = dvui.clip(dvui.currentWindow().rect_pixels); - defer dvui.clipSet(clip); - - dvui.renderTriangles(triangles.build(), null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - } -} -fn searchPalettes(dropdown: *dvui.DropdownWidget) !void { - const io = dvui.io; - 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); - var iter = dir.iterate(); - while (try iter.next(io)) |entry| { - if (entry.kind == .file) { - const ext = std.fs.path.extension(entry.name); - 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(), &.{ palette_folder, entry.name }); - - if (Globals.state.colors.palette) |*palette| - palette.deinit(); - - 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; - }; - } - } - } - } - } -} - -/// Geometry for one layer row, collected while drawing; used for a single chronological pointer pass. -const LayerRowHit = struct { - row_r: dvui.Rect.Physical, - buttons_r: dvui.Rect.Physical, - branch_usize: usize, - layer_index: usize, - hbox_tl: dvui.Point.Physical, -}; - -fn pointerReleaseInRectWithoutSelectionModifier(r: dvui.Rect.Physical) bool { - for (dvui.events()) |*e| { - switch (e.evt) { - .mouse => |me| { - if (me.action == .release and me.button.pointer() and r.contains(me.p)) { - return !me.mod.shift() and !me.mod.control() and !me.mod.command(); - } - }, - else => {}, - } - } - return false; -} - -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 pixelart.internal.File, layer_index: usize) bool { - for (file.editor.selected_layer_indices.items) |i| { - if (i == layer_index) return true; - } - return false; -} - -/// 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 { - var sel = &file.editor.selected_layer_indices; - - // Drop out-of-range entries. - var write: usize = 0; - for (sel.items) |i| { - if (i < file.layers.len) { - sel.items[write] = i; - write += 1; - } - } - sel.items.len = write; - - // Clamp primary to valid range (should already be, but be defensive). - if (file.selected_layer_index >= file.layers.len) { - file.selected_layer_index = if (file.layers.len == 0) 0 else file.layers.len - 1; - } - - // Guarantee the primary index is present. - var has_primary = false; - for (sel.items) |i| { - if (i == file.selected_layer_index) { - has_primary = true; - break; - } - } - if (!has_primary and file.layers.len > 0) { - sel.append(Globals.allocator(), file.selected_layer_index) catch return; - std.sort.pdq(usize, sel.items, {}, std.sort.asc(usize)); - } -} - -/// Apply a modifier-aware click to the layer multi-selection. Returns the new primary index and -/// whether the narrow-on-release deferral should be armed (true when a plain click lands on a row -/// that is already part of a multi-selection: selection stays until the user releases without -/// dragging, at which point we narrow to just that row). -const LayerClickApplied = struct { - primary: usize, - narrow_on_release: bool, -}; - -fn applyLayerClick( - file: *pixelart.internal.File, - clicked: usize, - mode: pixelart.core.dvui.TreeSelection.ClickMode, -) LayerClickApplied { - const count_before = file.editor.selected_layer_indices.items.len; - - // Plain click on a row that is already part of the current multi-selection preserves the set - // so the user can drag the whole group. We narrow later on release if no drag happened. - if (mode == .replace and layerIndexInMulti(file, clicked) and count_before > 1) { - return .{ .primary = clicked, .narrow_on_release = true }; - } - - var tmp: std.ArrayList(usize) = .empty; - defer tmp.deinit(Globals.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, - clicked, - mode, - true, // require_primary: layers always has ≥ 1 selected - &tmp, - ) 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 {}; - - const new_primary = res.primary orelse clicked; - file.selected_layer_index = new_primary; - file.editor.layer_selection_anchor = res.anchor; - - return .{ .primary = new_primary, .narrow_on_release = false }; -} - -/// 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 { - file.editor.selected_layer_indices.clearRetainingCapacity(); - file.editor.selected_layer_indices.append(Globals.allocator(), clicked) catch {}; - file.selected_layer_index = clicked; - file.editor.layer_selection_anchor = clicked; -} - -/// Build a list of branch widget ids (one per selected layer) to pass into `tree.dragStartMulti`. -/// Uses the per-row `LayerRowHit` geometry captured during drawing. Only layers currently visible -/// 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, - hits: []const LayerRowHit, - out: []usize, -) usize { - var n: usize = 0; - for (file.editor.selected_layer_indices.items) |layer_index| { - for (hits) |h| { - if (h.layer_index == layer_index) { - if (n < out.len) { - out[n] = h.branch_usize; - n += 1; - } - break; - } - } - } - return n; -} - -/// Clear in-flight gesture only (no `dragEnd`). Used before arming a new row press. -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 pixelart.internal.File) void { - dvui.dragEnd(); - layer_row_gesture = null; -} - -/// Rename `textEntry` is drawn above the row; skip layer-tree handling when it already consumed the event -/// or the pointer maps to its rect (runs after `textEntry()` so rects/targets are valid this frame). -fn layerPointerRenameConsumes(e: *const dvui.Event, me: dvui.Event.Mouse) bool { - if (e.handled) return true; - if (layer_rename_hit_te_id) |rid| { - if (e.target_widgetId) |tid| { - if (tid == rid) return true; - } - } - if (layer_rename_hit_rect) |r| { - if (r.contains(me.p)) return true; - } - return false; -} - -/// Layer row rects can extend outside the scroll viewport when content is scrolled; only treat the -/// pointer as interacting with the list when it lies inside the scroll container's visible clip. -fn layerPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Rect.Physical) bool { - if (viewport_r) |r| return r.contains(p); - return true; -} - -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; - if (!dvui.clipGet().contains(p)) return false; - return true; -} - -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: *pixelart.core.dvui.TreeWidget, e: *dvui.Event) bool { - if (e.target_widgetId) |fwid| { - if (fwid == tree.data().id) return true; - } - const cw = dvui.currentWindow(); - if (cw.dragging.state == .dragging and cw.dragging.name != null) return false; - const me = e.evt.mouse; - const in_surface = layerTreePointerInTreeSurface(tree, me.p, me.floating_win); - const in_border = layerTreePointerInTreeBorder(tree, me.p, me.floating_win); - return in_surface or in_border; -} - -/// 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 { - if (!tree.init_options.enable_reordering) return; - - for (dvui.events()) |*e| { - switch (e.evt) { - .mouse => |me| { - if (me.action == .press and me.button.pointer()) { - if (layerPointerRenameConsumes(e, me)) continue; - if (!layerPointerInScrollViewport(me.p, layers_viewport_r)) continue; - - var row_hit: ?LayerRowHit = null; - var ri = hits.len; - while (ri > 0) { - ri -= 1; - const h = hits[ri]; - if (h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) { - row_hit = h; - break; - } - } - if (row_hit) |h| { - const cw = dvui.currentWindow(); - if (cw.dragging.state != .none) dvui.dragEnd(); - layerTreeClearGestureKeysOnly(file); - dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - - const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod); - const applied = applyLayerClick(file, h.layer_index, mode); - - layer_row_gesture = .{ - .file_id = file.id, - .press_idx = h.layer_index, - .press_p = me.p, - .drag_branch = h.branch_usize, - .moved = false, - .reorder_drag = false, - .narrow_on_release = applied.narrow_on_release, - }; - } else { - layerTreeResetRowPointerGesture(file); - } - continue; - } - - if (me.action == .motion) { - if (layerPointerRenameConsumes(e, me)) continue; - - if (layer_row_gesture) |*g| { - if (g.file_id == file.id) { - const dx = me.p.x - g.press_p.x; - const dy = me.p.y - g.press_p.y; - if (dx * dx + dy * dy > 16.0) { - g.moved = true; - } - } - } - - // After `tree.dragStart`, `drag_branch` is cleared — do not gate `matchEvent` on it. - if (tree.reorderDragActive()) { - _ = tree.matchEvent(e); - continue; - } - - const branch_usize = if (layerGestureMatches(file)) layer_row_gesture.?.drag_branch else null; - if (branch_usize == null) continue; - _ = tree.matchEvent(e); - if (!layerTreeMotionAllowsLayerReorder(tree, e)) continue; - - const prev_th = dvui.Dragging.threshold; - dvui.Dragging.threshold = @max(prev_th, 8.0); - defer dvui.Dragging.threshold = prev_th; - if (dvui.dragging(me.p, null)) |_| { - // Row size in natural units; `.{}` → `TreeWidget.dragStart` uses `branch_size`. - var row_size: dvui.Size = .{}; - for (hits) |h| { - if (h.branch_usize == branch_usize.?) { - const rn = h.row_r.toNatural(); - row_size = .{ .w = rn.w, .h = rn.h }; - break; - } - } - - var multi_buf: [128]usize = undefined; - const multi_len = buildLayerMultiDragIds(file, hits, multi_buf[0..]); - if (multi_len > 1) { - tree.dragStartMulti(branch_usize.?, multi_buf[0..multi_len], me.p, row_size); - } else { - tree.dragStart(branch_usize.?, me.p, row_size); - } - - if (layer_row_gesture) |*g| { - if (g.file_id == file.id) { - g.reorder_drag = true; - g.drag_branch = null; - g.narrow_on_release = false; - } - } - } - } else if (me.action == .release and me.button.pointer()) { - if (layerPointerRenameConsumes(e, me)) continue; - - const release_in_vp = layerPointerInScrollViewport(me.p, layers_viewport_r); - - var release_layer: ?usize = null; - var rj = hits.len; - while (rj > 0) { - rj -= 1; - const h = hits[rj]; - if (release_in_vp and h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) { - release_layer = h.layer_index; - break; - } - } - - const idx_opt: ?usize = if (layerGestureMatches(file)) layer_row_gesture.?.press_idx else null; - const did_reorder = if (layerGestureMatches(file)) layer_row_gesture.?.reorder_drag else false; - const narrow = if (layerGestureMatches(file)) layer_row_gesture.?.narrow_on_release else false; - - var selection_changed = false; - if (!did_reorder and !tree.drag_ending and release_in_vp and narrow) { - if (idx_opt) |pi| { - narrowLayerSelectionTo(file, pi); - selection_changed = true; - } - } - - if (idx_opt != null) { - layerTreeResetRowPointerGesture(file); - if (!did_reorder and !dvui.captured(tree.data().id)) { - dvui.captureMouse(null, e.num); - } - } - - if (selection_changed) { - dvui.refresh(null, @src(), tree.data().id); - } - } - }, - else => {}, - } - } -} diff --git a/src/plugins/pixelart/src/infobar_status.zig b/src/plugins/pixelart/src/infobar_status.zig deleted file mode 100644 index 068e6c1f..00000000 --- a/src/plugins/pixelart/src/infobar_status.zig +++ /dev/null @@ -1,83 +0,0 @@ -//! 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); - - 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/internal/Animation.zig b/src/plugins/pixelart/src/internal/Animation.zig deleted file mode 100644 index 70c3c4a1..00000000 --- a/src/plugins/pixelart/src/internal/Animation.zig +++ /dev/null @@ -1,140 +0,0 @@ -const std = @import("std"); -const dvui = @import("dvui"); -const Animation = @This(); -pub const Frame = @import("../Animation.zig").Frame; - -// TODO: make the same type as external without id -id: u64, -name: []const u8, -frames: []Frame, - -pub const AnimationV2 = struct { - id: u64, - name: []const u8, - frames: []usize, - fps: f32, -}; - -pub fn init(allocator: std.mem.Allocator, id: u64, name: []const u8, frames: []Frame) !Animation { - return .{ - .id = id, - .name = try allocator.dupe(u8, name), - .frames = try allocator.dupe(Frame, frames), - }; -} - -pub fn appendFrame(self: *Animation, allocator: std.mem.Allocator, frame: Frame) !void { - var new_frames = std.array_list.Managed(Frame).init(allocator); - new_frames.appendSlice(self.frames) catch |err| { - dvui.log.err("Failed to append frames", .{}); - return err; - }; - new_frames.append(frame) catch |err| { - dvui.log.err("Failed to append frame", .{}); - return err; - }; - - allocator.free(self.frames); - - self.frames = new_frames.toOwnedSlice() catch |err| { - dvui.log.err("Failed to free frames", .{}); - return err; - }; -} - -pub fn appendFrames(self: *Animation, allocator: std.mem.Allocator, frames: []Frame) !void { - var new_frames = std.array_list.Managed(Frame).init(allocator); - new_frames.appendSlice(self.frames) catch |err| { - dvui.log.err("Failed to append frames", .{}); - return err; - }; - new_frames.appendSlice(frames) catch |err| { - dvui.log.err("Failed to append frames", .{}); - return err; - }; - - allocator.free(self.frames); - self.frames = new_frames.toOwnedSlice() catch |err| { - dvui.log.err("Failed to free frames", .{}); - return err; - }; -} - -pub fn insertFrame(self: *Animation, allocator: std.mem.Allocator, index: usize, frame: Frame) !void { - var new_frames = std.array_list.Managed(Frame).init(allocator); - new_frames.appendSlice(self.frames) catch |err| { - dvui.log.err("Failed to append frames", .{}); - return err; - }; - new_frames.insert(index, frame) catch |err| { - dvui.log.err("Failed to insert frame", .{}); - return err; - }; - - allocator.free(self.frames); - - self.frames = new_frames.toOwnedSlice() catch |err| { - dvui.log.err("Failed to free frames", .{}); - return err; - }; -} - -pub fn removeFrame(self: *Animation, allocator: std.mem.Allocator, index: usize) void { - var new_frames = std.array_list.Managed(Frame).init(allocator); - new_frames.appendSlice(self.frames) catch { - dvui.log.err("Failed to append frames", .{}); - return; - }; - _ = new_frames.orderedRemove(index); - - allocator.free(self.frames); - - self.frames = new_frames.toOwnedSlice() catch { - dvui.log.err("Failed to free frames", .{}); - return; - }; -} - -pub fn eql(a: Animation, b: Animation) bool { - var e: bool = true; - if (a.frames.len != b.frames.len) { - return false; - } - - for (a.frames, b.frames) |frame_a, frame_b| { - if (frame_a.sprite_index != frame_b.sprite_index) { - e = false; - break; - } else if (frame_a.ms != frame_b.ms) { - e = false; - break; - } - } - - return e; -} - -pub fn eqlFrames(a: Animation, frames: []Frame) bool { - var e: bool = true; - - if (a.frames.len != frames.len) { - return false; - } - - for (a.frames, frames) |frame_a, frame_b| { - if (frame_a.sprite_index != frame_b.sprite_index) { - e = false; - break; - } else if (frame_a.ms != frame_b.ms) { - e = false; - break; - } - } - - return e; -} - -pub fn deinit(self: *Animation, allocator: std.mem.Allocator) void { - allocator.free(self.name); - allocator.free(self.frames); -} diff --git a/src/plugins/pixelart/src/internal/Atlas.zig b/src/plugins/pixelart/src/internal/Atlas.zig deleted file mode 100644 index dc160a02..00000000 --- a/src/plugins/pixelart/src/internal/Atlas.zig +++ /dev/null @@ -1,110 +0,0 @@ -const std = @import("std"); -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: pixelart.core.dvui.CanvasWidget = .{}, - -/// Checkerboard tile for the project-tab atlas preview (not tied to open files). -checkerboard_tile: ?dvui.Texture = null, - -// /// The packed atlas heightmap -// heightmap: ?fizzy.gfx.Texture = null, - -/// The actual atlas, which contains the sprites and animations data -data: ExternalAtlas, - -pub fn initCheckerboardTile(atlas: *Atlas) void { - deinitCheckerboardTile(atlas); - atlas.checkerboard_tile = pixelart.image.checkerboardTile( - alpha_checkerboard_count, - alpha_checkerboard_count, - Globals.state.settings.checker_color_even, - Globals.state.settings.checker_color_odd, - ); -} - -pub fn deinitCheckerboardTile(atlas: *Atlas) void { - if (atlas.checkerboard_tile) |t| { - dvui.textureDestroyLater(t); - atlas.checkerboard_tile = null; - } -} - -pub const Selector = enum { - source, - data, -}; - -pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { - // Wasm: there's no on-disk path to write to. Encode the atlas into a buffer - // and trigger a browser download via `wasm_download_data`. The native path - // 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(); - 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); - } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - 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("../web_file_io.zig").downloadBytes(path, bytes); - }, - .data => { - if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) { - std.log.debug("File name must end with .atlas extension!", .{}); - return error.InvalidExtension; - } - const options: std.json.Stringify.Options = .{}; - const output = try std.json.Stringify.valueAlloc(allocator, atlas.data, options); - defer allocator.free(output); - try @import("../web_file_io.zig").downloadBytes(path, output); - }, - } - return; - } - - 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; - - if (std.mem.eql(u8, ext, ".png")) { - try pixelart.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); - } else { - std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{}); - return error.InvalidExtension; - } - }, - .data => { - if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) { - std.log.debug("File name must end with .atlas extension!", .{}); - return error.InvalidExtension; - } - const options: std.json.Stringify.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/src/internal/Buffers.zig b/src/plugins/pixelart/src/internal/Buffers.zig deleted file mode 100644 index b498e92f..00000000 --- a/src/plugins/pixelart/src/internal/Buffers.zig +++ /dev/null @@ -1,133 +0,0 @@ -const std = @import("std"); - -const History = @import("History.zig"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; -const Buffers = @This(); - -stroke: Stroke, -temporary_stroke: Stroke, - -pub const Stroke = struct { - //indices: std.ArrayList(usize), - //values: std.ArrayList([4]u8), - - pixels: std.AutoHashMap(usize, [4]u8), - //canvas: pixelart.file.gui.canvas = .primary, - - pub fn init(allocator: std.mem.Allocator) Stroke { - return .{ - .pixels = .init(allocator), - // .indices = std.ArrayList(usize).init(allocator), - // .values = std.ArrayList([4]u8).init(allocator), - }; - } - - 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 (!ptr.found_existing) - ptr.value_ptr.* = value; - - // try stroke.indices.append(index); - - // try stroke.values.append(value); - //stroke.canvas = canvas; - } - - /// Clears the stroke map and reserves hash buckets for up to `max_keys` entries (no rehash churn - /// while filling). Call before a known full-layer pass such as transform accept. - pub fn clearAndReserveCapacity(stroke: *Stroke, max_keys: usize) !void { - stroke.clearAndFree(); - const cap: u32 = @intCast(@min(max_keys, std.math.maxInt(u32))); - try stroke.pixels.ensureTotalCapacity(cap); - } - - /// 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 (!gop.found_existing) - gop.value_ptr.* = value; - } - - pub fn appendSlice(stroke: *Stroke, indices: []usize, values: [][4]u8) !void { - for (indices, values) |index, value| { - try stroke.append(index, value); - } - - //try stroke.indices.appendSlice(indices); - //try stroke.values.appendSlice(values); - //stroke.canvas = canvas; - } - - pub fn toChange(stroke: *Stroke, layer_id: u64) !History.Change { - 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 = 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(); - - var i: usize = 0; - while (it.next()) |entry| { - indices[i] = entry.key_ptr.*; - values[i] = entry.value_ptr.*; - i += 1; - } - - 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; - } - - return .{ .pixels = .{ - .layer_id = layer_id, - .indices = indices, - .values = values, - } }; - } - - pub fn clearAndFree(stroke: *Stroke) void { - stroke.pixels.clearAndFree(); - // stroke.indices.clearAndFree(); - // stroke.values.clearAndFree(); - } - - pub fn deinit(stroke: *Stroke) void { - stroke.clearAndFree(); - // stroke.indices.deinit(); - // stroke.values.deinit(); - } -}; - -pub fn init(allocator: std.mem.Allocator) Buffers { - return .{ - .stroke = Stroke.init(allocator), - .temporary_stroke = Stroke.init(allocator), - }; -} - -pub fn clearAndFree(buffers: *Buffers) void { - buffers.stroke.clearAndFree(); - buffers.temporary_stroke.clearAndFree(); -} - -pub fn deinit(buffers: *Buffers) void { - buffers.clearAndFree(); - buffers.stroke.deinit(); - buffers.temporary_stroke.deinit(); -} diff --git a/src/plugins/pixelart/src/internal/File.zig b/src/plugins/pixelart/src/internal/File.zig deleted file mode 100644 index a61ca14a..00000000 --- a/src/plugins/pixelart/src/internal/File.zig +++ /dev/null @@ -1,3860 +0,0 @@ -const std = @import("std"); -const zip = @import("zip"); -const dvui = @import("dvui"); - -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; - -/// Deferred brush snapshot is skipped above this area (width×height); drawing falls back to per-pixel stroke recording. -pub const stroke_undo_max_snapshot_pixels: u64 = 16 * 1024 * 1024; - -id: u64, -path: []const u8, - -columns: u32 = 1, -rows: u32 = 1, -column_width: u32, -row_height: u32, - -selected_layer_index: usize = 0, -peek_layer_index: ?usize = null, -layers: std.MultiArrayList(Layer) = .{}, -deleted_layers: std.MultiArrayList(Layer) = .{}, - -sprites: std.MultiArrayList(Sprite) = .{}, - -selected_animation_index: ?usize = null, -selected_animation_frame_index: usize = 0, - -animations: std.MultiArrayList(Animation) = .{}, -deleted_animations: std.MultiArrayList(Animation) = .{}, - -history: History, -buffers: Buffers, - -layer_id_counter: u64 = 0, -anim_id_counter: u64 = 0, - -/// File-specific editor data -editor: EditorData = .{}, - -/// This may be a confusing distinction between "editor" and File fields, -/// but the intent is that fields inside of the editor namespace are actively -/// used each frame to write/read data the editor directly depends on. -/// -/// Also, the fields here tend to be directly coupled with the UI library -pub const EditorData = struct { - /// 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, - /// 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 = .{}, - 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: ?Transform = null, - - playing: bool = false, - saving: bool = false, - grouping: u64 = 0, - - // Internal layers for editor - isolate_layer: bool = false, - temporary_layer: Layer = undefined, - selection_layer: Layer = undefined, - transform_layer: Layer = undefined, - selected_sprites: std.DynamicBitSet = undefined, - /// Primary tile among a multi-sprite selection (cover-flow center / tallest bubble). - /// When an animation is selected, `selected_animation_frame_index` is kept in sync. - primary_sprite_index: ?usize = null, - - checkerboard: std.DynamicBitSet = undefined, - checkerboard_tile: ?dvui.Texture = null, - - /// Flattened visible-layer stack cached as a render target. - /// Reused across frames; rebuilt only when content or structure changes. - layer_composite_target: ?dvui.Texture.Target = null, - layer_composite_frame_built: u64 = 0, - layer_composite_dirty: bool = true, - /// Bumped each time the flattened layer texture is actually rebuilt, so - /// downstream bakes (e.g. the sprite preview) can detect a stale input cheaply. - layer_composite_generation: u64 = 0, - - /// Split composites for use during active drawing. The "below" target - /// contains all visible layers below the active layer; the "above" target - /// contains all visible layers above it. This avoids per-layer draws - /// without requiring per-frame render target switches. - split_composite_below: ?dvui.Texture.Target = null, - split_composite_above: ?dvui.Texture.Target = null, - split_composite_layer: ?usize = null, - split_composite_dirty: bool = true, - split_composite_frame_built: u64 = 0, - - /// Sprite-panel cover-flow preview: checkerboard + flattened layers + - /// selection + temp baked into one texture, so each card (front and - /// reflection) draws in a single textured pass instead of replaying the - /// whole stack as several overlapping alpha-blended fills. Rebuilt at most - /// once per frame (see `render.syncPreviewComposite`). - preview_composite_target: ?dvui.Texture.Target = null, - preview_composite_frame_built: u64 = 0, - /// Content signature of the last preview-composite bake. The bake is skipped - /// (texture reused) when this is unchanged — so a static cover flow whose - /// reflections are merely rippling doesn't re-flatten the sprite every frame. - preview_composite_sig: u64 = 0, - preview_composite_valid: bool = false, - - /// Tracks when the active layer transparency mask was last built, - /// so we can skip rebuilding it when the layer hasn't changed. - mask_built_for_layer: ?usize = null, - mask_built_source_hash: u64 = 0, - - /// Pixel region written by the last temp layer brush preview. Used to - /// cheaply clear only the affected area instead of memset-ing the full - /// 64 MB buffer each frame. - temp_preview_dirty_rect: ?dvui.Rect = null, - /// True when the temp layer contains any non-zero content (brush preview, - /// selection visualization, etc.) and needs clearing next frame. - temp_layer_has_content: bool = false, - /// Bumped every time the temp layer's pixels change (brush preview moves, - /// stroke segment, selection visualization, clear). The temp layer uses - /// `.ptr` invalidation, so `source.hash()` only sees the buffer pointer and - /// not its contents — downstream bakes (the sprite preview composite) hash - /// this counter instead to notice a moved hover preview that reuses the - /// same buffer. - temp_layer_generation: u64 = 0, - /// Accumulated region of the temp layer whose CPU pixels differ from the - /// GPU texture. Persists across frames until flushed via sub-rect upload - /// in renderLayers, so stale GPU data is always cleaned up. - temp_gpu_dirty_rect: ?dvui.Rect = null, - /// True while a stroke drag is in progress (mouse pressed and captured). - active_drawing: bool = false, - /// Accumulated dirty rect for the active layer during the current frame. - /// Used to perform a sub-rect texture upload instead of a full invalidate. - active_layer_dirty_rect: ?dvui.Rect = null, - - /// While true, brush painting skips per-pixel `buffers.stroke.append` during the drag; the - /// pre-stroke region is snapshotted and diffed on commit (mouse release) instead. - stroke_undo_deferred: bool = false, - /// Row-major RGBA snapshot for `stroke_undo_{x,y,w,h}` (length `w * h * 4`). - stroke_undo_pixels: ?[]u8 = null, - stroke_undo_x: u32 = 0, - stroke_undo_y: u32 = 0, - stroke_undo_w: u32 = 0, - stroke_undo_h: u32 = 0, - - /// Layer list reorder preview while dragging in the tree (`null` = no preview). Matches drop logic in `explorer/tools.zig`. - layer_drag_preview_removed: ?usize = null, - layer_drag_preview_insert_before: ?usize = null, - - /// Multi-selection for the layer list. Sorted, deduplicated. Lazily seeded with the primary - /// `selected_layer_index` on first click gesture; the tree's drop handler uses this set to - /// move multiple selected layers as a group. The primary index must always be present (the - /// editor cannot have zero selected layers). - selected_layer_indices: std.ArrayListUnmanaged(usize) = .empty, - layer_selection_anchor: ?usize = null, - - /// Multi-selection for the animation list. Sorted, deduplicated. Empty iff no animation is - /// selected; otherwise always contains the primary `selected_animation_index`. - selected_animation_indices: std.ArrayListUnmanaged(usize) = .empty, - animation_selection_anchor: ?usize = null, - - /// Multi-selection for the frame list of the currently selected animation. Cleared and - /// reseeded whenever the primary animation changes. `..._for_animation_id` tracks which - /// animation the set belongs to so stale selections are discarded cheaply. - selected_frame_indices: std.ArrayListUnmanaged(usize) = .empty, - selected_frame_indices_for_animation_id: u64 = 0, - frame_selection_anchor: ?usize = null, - - /// Last frame's `isSaving()` — used on the GUI thread to detect save-finished transitions. - 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. - save_complete_show_duration: ?i128 = null, - /// Set with `save_complete_show_duration` when the flash arms (`isSaving` → false). - save_complete_show_start: ?i128 = null, -}; - -pub const History = @import("History.zig"); -pub const Buffers = @import("Buffers.zig"); - -pub const InitOptions = struct { - columns: u32 = 1, - rows: u32 = 1, - column_width: u32, - 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), - .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()), - }; - - // 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.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 = 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: 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(Globals.allocator(), .{ - .origin = .{ 0.0, 0.0 }, - }) catch return error.FileLoadError; - } - - return internal; -} - -/// Tile pixel dimensions sized so each checker square is roughly square when stretched to a -/// `column_width × row_height` cell. Width fixed at `alpha_checkerboard_count`; height tracks the -/// inverse aspect ratio (clamped to a sane range). -fn checkerboardTileDims(column_width: u32, row_height: u32) struct { w: u32, h: u32 } { - const aspect = @as(f32, @floatFromInt(column_width)) / @as(f32, @floatFromInt(row_height)); - const h_f = @round(@as(f32, @floatFromInt(alpha_checkerboard_count)) / aspect); - return .{ .w = alpha_checkerboard_count, .h = std.math.clamp(2, @as(u32, @intFromFloat(h_f)), 1024) }; -} - -/// Lazily build (or rebuild on grid resize) the wrap=.repeat checker texture. Called from UI render -/// paths so it can use the dvui frame context — file load runs on a worker thread, so we can't -/// touch `dvui.textureCreate` there. -pub fn checkerboardTileTexture(file: *File) ?dvui.Texture { - const want = checkerboardTileDims(file.column_width, file.row_height); - if (file.editor.checkerboard_tile) |t| { - if (t.width == want.w and t.height == want.h) return t; - dvui.textureDestroyLater(t); - file.editor.checkerboard_tile = null; - } - file.editor.checkerboard_tile = pixelart.image.checkerboardTile( - want.w, - want.h, - Globals.state.settings.checker_color_even, - Globals.state.settings.checker_color_odd, - ); - return file.editor.checkerboard_tile; -} - -pub fn width(file: *const File) u32 { - return file.columns * file.column_width; -} - -pub fn height(file: *const File) u32 { - return file.rows * file.row_height; -} - -/// Set the save-in-progress flag with an atomic store. `saveZip` runs on a background worker -/// thread and writes through this helper; the main thread reads via `isSaving()` for the tab -/// strip spinner. `monotonic` is sufficient — we don't synchronize any other data through this -/// flag, just publish the boolean. -pub fn setSaving(file: *File, v: bool) void { - const was = file.isSaving(); - if (was == v) return; - @atomicStore(bool, &file.editor.saving, v, .monotonic); - if (v) { - file.editor.save_complete.store(false, .monotonic); - file.editor.save_complete_show_duration = null; - file.editor.save_complete_show_start = null; - } 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(); - 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); - } -} - -/// Atomic-load counterpart to `setSaving`. Safe to call from any thread. -pub fn isSaving(file: *const File) bool { - return @atomicLoad(bool, &file.editor.saving, .monotonic); -} - -/// Clears in-flight save UI state (tab spinner, blocked close). Call when a save dialog is cancelled. -pub fn resetSaveUIState(file: *File) void { - file.setSaving(false); - file.editor.save_complete.store(false, .monotonic); - file.editor.save_complete_show_duration = null; - file.editor.save_complete_show_start = null; - file.editor.was_saving = false; -} - -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 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) { - file.editor.save_complete_show_start = now; - file.editor.save_complete_show_duration = now + save_done_flash_duration_ns; - } - if (saving) { - file.editor.save_complete_show_duration = null; - file.editor.save_complete_show_start = null; - file.editor.save_complete.store(false, .monotonic); - } - file.editor.was_saving = saving; - if (file.editor.save_complete_show_duration) |until| { - if (now >= until) { - file.editor.save_complete_show_duration = null; - file.editor.save_complete_show_start = null; - } - } -} - -/// Tab / tree slot should show the bubble spinner (saving or finish animation). -pub fn showsSaveStatusIndicator(file: *const File) bool { - if (file.isSaving()) return true; - return timeSinceSaveComplete(file) != null; -} - -pub fn showSaveDoneFlash(file: *const File) bool { - return timeSinceSaveComplete(file) != null; -} - -/// 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 = pixelart.perf.nanoTimestamp(); - if (now >= until) return null; - return @max(@as(i128, 0), now - st); -} - -/// Width × height of the artwork in pixels, taken from the first layer. This matches the in-memory -/// canvas even if grid metadata were ever inconsistent with `width()` / `height()`. -pub fn canvasPixelSize(file: *const File) struct { w: u32, h: u32 } { - if (file.layers.len == 0) return .{ .w = 0, .h = 0 }; - const s = file.layers.get(0).size(); - return .{ - .w = @intFromFloat(s.w), - .h = @intFromFloat(s.h), - }; -} - -/// Clears the cached per-layer transparency mask used by the selection overlay (`FileWidget.updateActiveLayerMask`). -/// Call after any in-memory edit to layer pixels while `ImageSource.hash()` is pointer-based and does not -/// change when bytes change (see also `Transform.accept` / undo-redo). -pub fn invalidateActiveLayerTransparencyMaskCache(file: *File) void { - file.editor.mask_built_for_layer = null; -} - -/// Fills `out[0..len]` with storage indices in list order (position 0 = top row / front of stack) -/// after moving the layer at `removed` to sit before `insert_before`, matching `explorer/tools.zig` drop handling. -/// -/// The implementation lives in `layer_order.zig` (pure logic, no dvui) -/// so it can be unit-tested by `zig build test`. Re-exported here to -/// keep existing call sites unchanged. -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 { - const extension = std.fs.path.extension(path); - if (isFlatImageExtension(extension)) { - return fromBytesFlatImage(path, file_bytes); - } - if (isFizzyExtension(extension)) { - return fromBytesFizzy(path, file_bytes); - } - return error.InvalidExtension; -} - -/// Attempts to load a file from the given path to create a new 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| { - dvui.log.err("{any}: {s}", .{ err, path }); - return err; - }; - return file; - } - - if (isFizzyExtension(extension)) { - const file = fromPathFizzy(path) catch |err| { - dvui.log.err("{any}: {s}", .{ err, path }); - return err; - }; - return file; - } - - return error.InvalidExtension; -} - -/// `.fiz` is the current native extension; `.pixi` is kept for legacy file load support. -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 { - return loadFizzyZip(path, null); -} - -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) !?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 Globals.allocator().dupeZ(u8, path) - else - ""; - defer if (file_bytes == null) Globals.allocator().free(null_terminated_path); - - zip_open: { - const fizzy_file = if (file_bytes) |bytes| - zip.zip_stream_open(bytes.ptr, bytes.len, 0, 'r') - else - zip.zip_open(null_terminated_path.ptr, 0, 'r') orelse break :zip_open; - defer if (file_bytes != null) zip.zip_stream_close(fizzy_file) else zip.zip_close(fizzy_file); - - var buf: ?*anyopaque = null; - var size: usize = 0; - // Try the current entry name first, then fall back to the legacy `pixidata.json` - // so files saved by the pre-rename Pixi versions still load. - if (zip.zip_entry_open(fizzy_file, "fizzydata.json") != 0) { - _ = zip.zip_entry_open(fizzy_file, "pixidata.json"); - } - _ = zip.zip_entry_read(fizzy_file, &buf, &size); - _ = zip.zip_entry_close(fizzy_file); - defer if (buf) |b| { - if (comptime @import("builtin").target.cpu.arch == .wasm32) { - zip.fizzy_zip_free(b); - } else { - std.c.free(b); - } - }; - - const content: []const u8 = if (buf) |b| @as([*]const u8, @ptrCast(b))[0..size] else ""; - - const options = std.json.ParseOptions{ - .duplicate_field_behavior = .use_first, - .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 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(pixelart.File.FileV3, Globals.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); - 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); - for (animation.frames, old_animation.frames) |*frame, old_frame| { - frame.sprite_index = old_frame; - frame.ms = @intFromFloat(1000 / old_animation.fps); - } - } - - ext = .{ - .version = old_file.value.version, - .columns = old_file.value.columns, - .rows = old_file.value.rows, - .column_width = old_file.value.column_width, - .row_height = old_file.value.row_height, - .layers = old_file.value.layers, - .sprites = old_file.value.sprites, - .animations = animations, - }; - } 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 Globals.allocator().alloc(pixelart.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); - for (animation.frames, old_animation.frames) |*frame, old_frame| { - frame.sprite_index = old_frame; - frame.ms = @intFromFloat(1000 / old_animation.fps); - } - } - - ext = .{ - .version = old_file.value.version, - .columns = @divExact(old_file.value.width, old_file.value.tile_width), - .rows = @divExact(old_file.value.height, old_file.value.tile_height), - .column_width = old_file.value.tile_width, - .row_height = old_file.value.tile_height, - .layers = old_file.value.layers, - .sprites = old_file.value.sprites, - .animations = animations, - }; - } 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 Globals.allocator().alloc(pixelart.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); - 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); - } - } - - ext = .{ - .version = old_file.value.version, - .columns = @divExact(old_file.value.width, old_file.value.tile_width), - .rows = @divExact(old_file.value.height, old_file.value.tile_height), - .column_width = old_file.value.tile_width, - .row_height = old_file.value.tile_height, - .layers = old_file.value.layers, - .sprites = old_file.value.sprites, - .animations = animations, - }; - } - } - - defer if (try_parse) |parsed| parsed.deinit(); - - //defer parsed.deinit(); - - 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 = pixelart.internal.File.History.init(Globals.allocator()), - .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), - }; - - //Initialize editor layers and selected sprites - // .ptr: same as new-file init — GPU sync via invalidate / temp_gpu_dirty_rect + updateSubRect. - // .always would re-upload the full texture on every getTexture() (e.g. sprite panel reflection). - 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.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 = 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(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; - - if (zip.zip_entry_open(fizzy_file, layer_image_name.ptr) == 0) { // Read layer file as directly pixels - _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len); - const data = img_buf orelse continue; - - var new_layer: Layer = try .fromPixelsPMA( - internal.newLayerID(), - l.name, - @as([*]dvui.Color.PMA, @ptrCast(@constCast(data)))[0..(internal.width() * internal.height())], - internal.width(), - internal.height(), - .ptr, - ); - - new_layer.visible = l.visible; - new_layer.collapse = l.collapse; - - new_layer.setMaskFromTransparency(true); - - internal.layers.append(Globals.allocator(), new_layer) catch return error.FileLoadError; - - if (l.visible and !set_layer_index) { - internal.selected_layer_index = i; - set_layer_index = true; - } - } else if (zip.zip_entry_open(fizzy_file, png_image_name.ptr) == 0) { // Read the layer file as PNG file - _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len); - const data = img_buf orelse continue; - - var new_layer: Layer = try .fromImageFileBytes( - internal.newLayerID(), - l.name, - @as([*]u8, @ptrCast(data))[0..img_len], - .ptr, - ); - - new_layer.visible = l.visible; - new_layer.collapse = l.collapse; - - new_layer.setMaskFromTransparency(true); - - internal.layers.append(Globals.allocator(), new_layer) catch return error.FileLoadError; - - if (l.visible and !set_layer_index) { - internal.selected_layer_index = i; - set_layer_index = true; - } - } - - _ = zip.zip_entry_close(fizzy_file); - } - _ = zip.zip_entry_close(fizzy_file); - - for (0..internal.spriteCount()) |sprite_index| { - if (sprite_index >= ext.sprites.len) { - internal.sprites.append(Globals.allocator(), .{ - .origin = .{ 0, 0 }, - }) catch return error.FileLoadError; - } else { - 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(Globals.allocator(), .{ - .id = internal.newAnimationID(), - .name = try Globals.allocator().dupe(u8, animation.name), - .frames = try Globals.allocator().dupe(Animation.Frame, animation.frames), - }) catch return error.FileLoadError; - } - return internal; - } - // { // Loading TAR experiment - // 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| { - // std.log.debug("Read file bytes!", .{}); - // var input = std.io.fixedBufferStream(file_bytes); - // var iter = std.tar.iterator(input.reader(), .{ - // .file_name_buffer = &file_name_buffer, - // .link_name_buffer = &link_name_buffer, - // }); - - // var json_content = std.array_list.Managed(u8).init(Globals.allocator()); - // defer json_content.deinit(); - - // while (try iter.next()) |entry| { - // const ext = std.fs.path.extension(entry.name); - // if (std.mem.eql(u8, ext, ".json")) { - // entry.writeAll(json_content.writer()) catch return error.FileLoadError; - // } - // } - - // const options = std.json.ParseOptions{ - // .duplicate_field_behavior = .use_first, - // .ignore_unknown_fields = true, - // }; - - // 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: 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 = 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 }, - // .ptr, - // ) catch return error.LayerCreateError, - // .temporary_layer = undefined, - // .selection_layer = undefined, - // .selected_sprites = try std.DynamicBitSet.initEmpty( - // Globals.allocator(), - // @divExact(ext.width, ext.tile_width) * @divExact(ext.height, ext.tile_height), - // ), - // }; - - // internal.temporary_layer = try .init(internal.newID(), "Temporary", internal.width, internal.height, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .always); - - // for (ext.layers, 0..) |ext_layer, i| { - // const layer_image_name = std.fmt.allocPrintZ(dvui.currentWindow().arena(), "{s}.layer", .{ext_layer.name}) catch "Memory Allocation Failed"; - - // if (ext_layer.visible) { - // internal.selected_layer_index = i; - // } - - // iter = std.tar.iterator(input.reader(), .{ - // .file_name_buffer = &file_name_buffer, - // .link_name_buffer = &link_name_buffer, - // }); - - // while (iter.next() catch null) |entry| { - // 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()); - // 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; - - // 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; - // } else { - // std.log.err("Failed to create layer from pixels", .{}); - // } - // } - // } - // } - - // return internal; - // } - // } - // } - - return error.FileLoadError; -} - -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"); -} - -/// Extensions that `saveAsync` can write without a Save As dialog. -pub fn hasRecognizedSaveExtension(path: []const u8) bool { - const ext = std.fs.path.extension(path); - return isFizzyExtension(ext) or isFlatImageExtension(ext); -} - -/// True when the document holds state that a flat PNG/JPEG round-trip would not preserve -/// (layers, tile grid, animations, per-sprite origins). -pub fn requiresFizzyCompatibleSave(self: File) bool { - if (self.layers.len != 1) return true; - if (self.columns != 1 or self.rows != 1) return true; - if (self.animations.len != 0) return true; - for (self.sprites.items(.origin)) |o| { - if (o[0] != 0.0 or o[1] != 0.0) return true; - } - return false; -} - -pub fn shouldConfirmFlatRasterSave(self: File) bool { - const ext = std.fs.path.extension(self.path); - if (!isFlatImageExtension(ext)) return false; - return requiresFizzyCompatibleSave(self); -} - -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: Layer = try Layer.fromImageFileBytes( - Globals.state.host.allocDocId(), - "Layer", - file_bytes, - .ptr, - ); - return finishFlatImageFile(path, image_layer); -} - -/// 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 { - 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); - return finishFlatImageFile(path, image_layer); -} - -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: 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 = pixelart.internal.File.History.init(Globals.allocator()), - .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()), - }; - - 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(Globals.allocator(), internal.spriteCount()); - - 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 = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); - internal.editor.checkerboard.setValue(i, value); - } - - return internal; -} - -pub const ResizeOptions = struct { - columns: u32, - 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 - sprite_data: ?[][2]f32 = null, // If provided, the sprite data will be applied to the sprites after resizing -}; - -pub fn resize(file: *File, options: ResizeOptions) !void { - const current_columns = file.columns; - const current_rows = file.rows; - - if (options.columns == current_columns and - options.rows == current_rows) return; - - if (options.columns == 0 or options.rows == 0) return error.InvalidImageSize; - - const new_columns = options.columns; - const new_rows = options.rows; - - const new_width = new_columns * file.column_width; - const new_height = new_rows * file.row_height; - - // First, record the current layer data for undo/redo - 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); - 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; - } - 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); - 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; - } - file.history.undo_animation_data_stack.append(anim_data) catch return error.MemoryAllocationFailed; - - 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]; - } - file.history.undo_sprite_data_stack.append(sprite_data) catch return error.MemoryAllocationFailed; - } - - if (options.animation_data) |anim_data| { - for (0..file.animations.len) |anim_index| { - 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; - defer file.animations.set(anim_index, new_animation); - defer current_animation.deinit(Globals.allocator()); - for (current_data) |frame| { - 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 = Animation.init(Globals.allocator(), animation.id, animation.name, &.{}) catch return error.AnimationCreateError; - defer file.animations.set(anim_index, new_animation); - 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(Globals.allocator(), .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError; - } - } - } - - const old_sprite_count = current_columns * current_rows; - 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); - - if (options.sprite_data == null) { - const snapshot = try Globals.allocator().alloc([2]f32, old_sprite_count); - for (0..old_sprite_count) |i| { - snapshot[i] = file.sprites.items(.origin)[i]; - } - old_origins_snapshot = snapshot; - } - - file.sprites.resize( - Globals.allocator(), - new_sprite_count, - ) catch return error.MemoryAllocationFailed; - - // MultiArrayList growth leaves new elements undefined; zero everything then restore from undo data or remaps. - for (0..new_sprite_count) |i| { - file.sprites.items(.origin)[i] = .{ 0, 0 }; - } - - if (options.sprite_data) |sprite_data| { - const n = @min(new_sprite_count, sprite_data.len); - for (0..n) |sprite_index| { - const d = sprite_data[sprite_index]; - file.sprites.items(.origin)[sprite_index] = .{ d[0], d[1] }; - } - } else { - const old_origins = old_origins_snapshot orelse unreachable; - for (0..old_sprite_count) |old_i| { - if (getResizedIndex(file, old_i, new_columns, new_rows)) |new_i| { - file.sprites.items(.origin)[new_i] = old_origins[old_i]; - } - } - } - - // Now, resize the layers, and apply any layer data if needed - for (0..file.layers.len) |layer_index| { - var layer = file.layers.get(layer_index); - - layer.resize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }) catch return error.LayerResizeError; - - if (options.layer_data) |data| { - if (data[layer_index].len == new_width * new_height) - layer.blit(data[layer_index], .fromSize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }), .{}); - } - file.layers.set(layer_index, layer); - } - - file.editor.temporary_layer.resize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }) catch return error.LayerResizeError; - file.editor.selection_layer.resize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }) catch return error.LayerResizeError; - file.editor.transform_layer.resize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }) catch return error.LayerResizeError; - file.editor.selected_sprites.resize(options.columns * options.rows, false) catch return error.MemoryAllocationFailed; - - 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); - file.editor.checkerboard.setValue(i, value); - } - - file.columns = new_columns; - file.rows = new_rows; -} - -/// Returns the sprite index after a grid resize, or null if the cell is outside the new grid. -/// Index layout is row-major: index = row * columns + column. -pub fn getResizedIndex( - self: *File, - sprite_index: usize, - new_columns: u32, - new_rows: u32, -) ?usize { - const old_col: u32 = @intCast(@mod(sprite_index, self.columns)); - const old_row: u32 = @intCast(@divTrunc(sprite_index, self.columns)); - - if (old_row >= self.rows or old_col >= self.columns) - return null; - - if (old_row < new_rows and old_col < new_columns) { - return old_row * new_columns + old_col; - } else { - return null; - } -} - -/// Returns the sprite index after a drag-and-drop reorder of one column, row, or single cell. -/// For column/row: `removed_index` is the column/row that was dragged, `insert_before_index` is where it was dropped (before that column/row). -/// For cell: `removed_index` and `insert_before_index` are sprite indices (grid cell indices); returns where `sprite_index` ends up after the move. -pub fn getReorderedIndex( - self: *File, - removed_index: usize, - insert_before_index: usize, - orientation: enum { column, row, cell }, - sprite_index: usize, -) usize { - if (removed_index == insert_before_index) return sprite_index; - - const insert_pos: usize = if (insert_before_index > removed_index) - insert_before_index - 1 - else - insert_before_index; - - const col: u32 = @intCast(@mod(sprite_index, self.columns)); - const row: u32 = @intCast(@divTrunc(sprite_index, self.columns)); - - const pos_along: usize = switch (orientation) { - .column => col, - .row => row, - .cell => sprite_index, - }; - - const new_pos_along: usize = if (pos_along == removed_index) - insert_pos - else blk: { - const temp = if (pos_along < removed_index) pos_along else pos_along - 1; - break :blk if (temp >= insert_pos) temp + 1 else temp; - }; - - return switch (orientation) { - .column => row * self.columns + @as(u32, @intCast(new_pos_along)), - .row => @as(u32, @intCast(new_pos_along)) * self.columns + col, - .cell => new_pos_along, - }; -} - -const SpriteReorderMode = enum { - replace, - insert, -}; - -pub const CellMovePair = struct { - remove: usize, - insert: usize, -}; - -pub const CellSorting = struct { - pub fn asc(_: void, a: CellMovePair, b: CellMovePair) bool { - - // This below line makes the sorting logic work correctly, but crashes when moving outside of the bounds sometimes. - if (a.remove > a.insert and b.remove > b.insert) return a.remove < b.remove else if (a.remove < a.insert and b.remove < b.insert) return a.remove > b.remove; - - // This removes the crashing, and works for all cases, except for when moving a set forward (increasing index from removed to insert) and overlapping with the removed set. - if ((a.remove > a.insert and b.remove > b.insert) or (a.remove < a.insert and b.remove < b.insert)) { - return a.remove < b.remove; - } - return a.remove > a.insert; - } - - pub fn desc(_: void, a: CellMovePair, b: CellMovePair) bool { - return if (a.remove < a.insert) a.remove < b.remove else a.remove > b.remove; - } -}; - -/// Returns a freshly allocated slice of length file.spriteCount() such that result[original_sprite_index] -/// is the new sprite index after applying the given reorder moves. Caller owns the returned memory. -pub fn getReorderIndices( - file: *File, - allocator: std.mem.Allocator, - removed_sprite_indices: []const usize, - insert_before_sprite_indices: []const usize, - mode: SpriteReorderMode, - reverse: bool, -) ![]usize { - if (removed_sprite_indices.len == 0 or insert_before_sprite_indices.len == 0) return error.InvalidReorderSlices; - if (removed_sprite_indices.len != insert_before_sprite_indices.len) return error.InvalidReorderSlices; - - const sprite_count = file.spriteCount(); - if (removed_sprite_indices.len > sprite_count) return error.InvalidReorderSlices; - - var order = try allocator.alloc(usize, sprite_count); - defer allocator.free(order); - for (0..sprite_count) |i| order[i] = i; - - var pairs = try dvui.currentWindow().arena().alloc(CellMovePair, removed_sprite_indices.len); - for (0..removed_sprite_indices.len) |i| { - pairs[i] = .{ .remove = removed_sprite_indices[i], .insert = insert_before_sprite_indices[i] }; - } - - std.mem.sort(CellMovePair, pairs, {}, CellSorting.asc); - if (reverse) { - std.mem.reverse(CellMovePair, pairs); - } - - for (pairs) |pair| { - if (mode == .insert) { - dvui.ReorderWidget.reorderSlice(usize, order, pair.remove, pair.insert); - } else { - std.mem.swap(usize, &order[pair.remove], &order[pair.insert]); - } - } - - const reorder_indices = try allocator.alloc(usize, sprite_count); - for (order, 0..) |order_index, i| { - reorder_indices[order_index] = i; - } - - return reorder_indices; -} - -pub fn reorderCells(file: *File, removed_sprite_indices: []const usize, insert_before_sprite_indices: []const usize, mode: SpriteReorderMode, reverse: bool) !void { - const arena = dvui.currentWindow().arena(); - const new_sprite_indices = try file.getReorderIndices(arena, removed_sprite_indices, insert_before_sprite_indices, mode, reverse); - - const sprite_count = new_sprite_indices.len; - const layer_count = file.layers.len; - - var old_pixels_per_layer = try arena.alloc([]?[][4]u8, layer_count); - for (old_pixels_per_layer) |*slice| slice.* = try arena.alloc(?[][4]u8, sprite_count); - - for (0..layer_count) |layer_index| { - var layer = file.layers.get(layer_index); - for (0..sprite_count) |i| { - const new_sprite_index = new_sprite_indices[i]; - if (new_sprite_index != i) { - const old_rect = file.spriteRect(i); - old_pixels_per_layer[layer_index][i] = layer.pixelsFromRect(arena, old_rect); - } - } - } - - for (0..layer_count) |layer_index| { - var layer = file.layers.get(layer_index); - for (0..sprite_count) |original_sprite_index| { - const new_sprite_index = new_sprite_indices[original_sprite_index]; - if (new_sprite_index != original_sprite_index) { - const src_pixels = old_pixels_per_layer[layer_index][original_sprite_index] orelse return error.MemoryAllocationFailed; - const dst_rect = file.spriteRect(new_sprite_index); - layer.blit(src_pixels, dst_rect, .{ .transparent = false, .mask = false }); - } - } - } - - for (file.animations.items(.frames)) |*frames| { - for (frames.*) |*frame| { - frame.sprite_index = new_sprite_indices[frame.sprite_index]; - } - } - - var new_origins = try arena.dupe([2]f32, file.sprites.items(.origin)); - for (file.sprites.items(.origin), 0..) |origin, sprite_index| { - const new_index = new_sprite_indices[sprite_index]; - if (new_index != sprite_index) { - new_origins[new_index] = origin; - } - } - for (new_origins, 0..) |origin, sprite_index| { - file.sprites.items(.origin)[sprite_index] = origin; - } - - if (file.editor.selected_sprites.count() > 0) { - const selected_count = file.editor.selected_sprites.count(); - var old_indices = try arena.alloc(usize, selected_count); - var idx: usize = 0; - var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |old_index| { - old_indices[idx] = old_index; - idx += 1; - } - file.editor.selected_sprites.setRangeValue(.{ .start = 0, .end = sprite_count }, false); - for (old_indices) |old_index| { - file.editor.selected_sprites.set(new_sprite_indices[old_index]); - } - } -} - -pub fn reorderColumns(file: *File, removed_column_index: usize, insert_before_column_index: usize) !void { - if (removed_column_index == insert_before_column_index) return; - if (removed_column_index > file.columns or insert_before_column_index > file.columns) return error.InvalidIndex; - - for (0..file.layers.len) |layer_index| { - var layer = file.layers.get(layer_index); - - var insert_column_rect = file.columnRect(insert_before_column_index); - var removed_column_rect = file.columnRect(removed_column_index); - - if (insert_before_column_index < removed_column_index) { - var translate_rect = insert_column_rect; - translate_rect.w = @as(f32, @floatFromInt(file.column_width)) * @as(f32, @floatFromInt(removed_column_index - insert_before_column_index)); - - const translate_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), translate_rect) orelse return error.MemoryAllocationFailed; - translate_rect.x += @as(f32, @floatFromInt(file.column_width)); - - const removed_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), removed_column_rect) orelse return error.MemoryAllocationFailed; - - layer.blit(translate_pixels, translate_rect, .{ .transparent = false, .mask = false }); - layer.blit(removed_pixels, insert_column_rect, .{ .transparent = false, .mask = false }); - } else { - var translate_rect = removed_column_rect.offsetPoint(.{ .x = @as(f32, @floatFromInt(file.column_width)) }); - translate_rect.w = @as(f32, @floatFromInt(file.column_width)) * @as(f32, @floatFromInt(insert_before_column_index - removed_column_index)); - - const translate_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), translate_rect) orelse return error.MemoryAllocationFailed; - translate_rect.x -= @as(f32, @floatFromInt(file.column_width)); - - const removed_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), removed_column_rect) orelse return error.MemoryAllocationFailed; - layer.blit(translate_pixels, translate_rect, .{ .transparent = false, .mask = false }); - layer.blit(removed_pixels, insert_column_rect.offsetPoint(.{ .x = -@as(f32, @floatFromInt(file.column_width)) }), .{ .transparent = false, .mask = false }); - } - } - - for (file.animations.items(.frames)) |*frames| { - for (frames.*) |*frame| { - frame.sprite_index = file.getReorderedIndex( - removed_column_index, - insert_before_column_index, - .column, - frame.sprite_index, - ); - } - } - - var new_origins = try dvui.currentWindow().arena().dupe([2]f32, file.sprites.items(.origin)); - for (file.sprites.items(.origin), 0..) |*origin, sprite_index| { - const reordered_index = file.getReorderedIndex(removed_column_index, insert_before_column_index, .column, sprite_index); - - if (reordered_index != sprite_index) { - new_origins[reordered_index] = origin.*; - } - } - - for (new_origins, 0..) |origin, sprite_index| { - file.sprites.items(.origin)[sprite_index] = origin; - } -} - -pub fn reorderRows(file: *File, removed_row_index: usize, insert_before_row_index: usize) !void { - if (removed_row_index + 1 == insert_before_row_index) return; - if (removed_row_index >= file.rows or insert_before_row_index > file.rows) return error.InvalidIndex; - - for (0..file.layers.len) |layer_index| { - var layer = file.layers.get(layer_index); - - var insert_row_rect = file.rowRect(insert_before_row_index); - var removed_row_rect = file.rowRect(removed_row_index); - - if (insert_before_row_index < removed_row_index) { - var translate_rect = insert_row_rect; - translate_rect.h = @as(f32, @floatFromInt(file.row_height)) * @as(f32, @floatFromInt(removed_row_index - insert_before_row_index)); - - const translate_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), translate_rect) orelse return error.MemoryAllocationFailed; - translate_rect.y += @as(f32, @floatFromInt(file.row_height)); - - const removed_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), removed_row_rect) orelse return error.MemoryAllocationFailed; - - layer.blit(translate_pixels, translate_rect, .{ .transparent = false, .mask = false }); - layer.blit(removed_pixels, insert_row_rect, .{ .transparent = false, .mask = false }); - } else { - var translate_rect = removed_row_rect.offsetPoint(.{ .y = @as(f32, @floatFromInt(file.row_height)) }); - translate_rect.h = @as(f32, @floatFromInt(file.row_height)) * @as(f32, @floatFromInt(insert_before_row_index - removed_row_index)); - - const translate_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), translate_rect) orelse return error.MemoryAllocationFailed; - translate_rect.y -= @as(f32, @floatFromInt(file.row_height)); - - const removed_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), removed_row_rect) orelse return error.MemoryAllocationFailed; - layer.blit(translate_pixels, translate_rect, .{ .transparent = false, .mask = false }); - layer.blit(removed_pixels, insert_row_rect.offsetPoint(.{ .y = -@as(f32, @floatFromInt(file.row_height)) }), .{ .transparent = false, .mask = false }); - } - } - - for (file.animations.items(.frames)) |*frames| { - for (frames.*) |*frame| { - frame.sprite_index = file.getReorderedIndex( - removed_row_index, - insert_before_row_index, - .row, - frame.sprite_index, - ); - } - } - - var new_origins = try dvui.currentWindow().arena().dupe([2]f32, file.sprites.items(.origin)); - for (file.sprites.items(.origin), 0..) |*origin, sprite_index| { - const reordered_index = file.getReorderedIndex(removed_row_index, insert_before_row_index, .row, sprite_index); - - if (reordered_index != sprite_index) { - new_origins[reordered_index] = origin.*; - } - } - - for (new_origins, 0..) |origin, sprite_index| { - file.sprites.items(.origin)[sprite_index] = origin; - } -} - -pub fn deinit(file: *File) void { - pixelart.render.destroyLayerCompositeResources(file); - - strokeUndoFreeSnapshot(file); - - file.history.deinit(); - file.buffers.deinit(); - - for (file.layers.items(.name)) |name| { - Globals.allocator().free(name); - } - - for (file.animations.items(.name)) |name| { - Globals.allocator().free(name); - } - - for (file.animations.items(.frames)) |frames| { - Globals.allocator().free(frames); - } - - file.editor.temporary_layer.deinit(); - file.editor.selection_layer.deinit(); - file.editor.transform_layer.deinit(); - if (file.editor.checkerboard_tile) |t| { - dvui.textureDestroyLater(t); - 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.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 { - // Never-saved buffers use a path with no extension (e.g. `untitled-1`); treat as unsaved even at bookmark 0. - if (std.fs.path.extension(self.path).len == 0) return true; - return self.history.bookmark != 0; -} - -pub fn newAnimationID(file: *File) u64 { - file.anim_id_counter += 1; - return file.anim_id_counter; -} - -pub fn newLayerID(file: *File) u64 { - file.layer_id_counter += 1; - return file.layer_id_counter; -} - -pub fn spritePoint(file: *File, point: dvui.Point) dvui.Point { - const column = @divTrunc(@as(i32, @intFromFloat(point.x)), @as(i32, @intCast(file.column_width))); - const row = @divTrunc(@as(i32, @intFromFloat(point.y)), @as(i32, @intCast(file.row_height))); - - return .{ - .x = @as(f32, @floatFromInt(column * @as(i32, @intCast(file.column_width)))), - .y = @as(f32, @floatFromInt(row * @as(i32, @intCast(file.row_height)))), - }; -} - -pub fn spriteCount(file: *File) usize { - return file.columns * file.rows; -} - -pub fn spriteIndex(file: *File, point: dvui.Point) ?usize { - if (!file.editor.canvas.dataFromScreenRect(file.editor.canvas.rect).contains(point)) return null; - - const tiles_wide = @divExact(file.width(), file.column_width); - - const column = @divTrunc(@as(u32, @intFromFloat(point.x)), file.column_width); - const row = @divTrunc(@as(u32, @intFromFloat(point.y)), file.row_height); - - return row * tiles_wide + column; -} - -pub fn wrappedSpriteIndex(file: *File, point: dvui.Point) usize { - if (file.spriteIndex(point)) |index| { - return index; - } - // Point is outside bounds: wrap coordinates into [0, width) x [0, height) - const w = @as(f32, @floatFromInt(file.width())); - const h = @as(f32, @floatFromInt(file.height())); - const wrapped_x = @mod(point.x, w); - const wrapped_y = @mod(point.y, h); - - const tiles_wide = @divExact(file.width(), file.column_width); - const column = @divTrunc(@as(u32, @intFromFloat(wrapped_x)), file.column_width); - const row = @divTrunc(@as(u32, @intFromFloat(wrapped_y)), file.row_height); - - return row * tiles_wide + column; -} - -pub const SpriteName = enum { index, animation, file, grid }; - -// Names sprites based o -pub fn fmtSprite(file: *File, allocator: std.mem.Allocator, sprite_index: usize, name_type: SpriteName) ![]const u8 { - return switch (name_type) { - .animation => blk: { - for (file.animations.items(.frames), 0..) |frames, animation_index| { - for (frames) |frame| { - if (frame.sprite_index != sprite_index) continue; - - if (frames.len > 1) { - break :blk std.fmt.allocPrint(allocator, "{s}_{d}", .{ file.animations.items(.name)[animation_index], animation_index }) catch return error.MemoryAllocationFailed; - } else { - break :blk std.fmt.allocPrint(allocator, "{s}", .{file.animations.items(.name)[animation_index]}) catch return error.MemoryAllocationFailed; - } - } - } - - break :blk std.fmt.allocPrint(allocator, "{d}", .{sprite_index}) catch return error.MemoryAllocationFailed; - }, - .file => std.fmt.allocPrint(allocator, "{s}_{s}_{d}", .{ std.fs.path.basename(file.path), file.layers.items(.name)[file.selected_layer_index], sprite_index }) catch return error.MemoryAllocationFailed, - .index => std.fmt.allocPrint(allocator, "{d}", .{sprite_index}) catch return error.MemoryAllocationFailed, - .grid => std.fmt.allocPrint(allocator, "{s}{d}", .{ try fmtColumn(file, allocator, file.columnFromIndex(sprite_index)), file.rowFromIndex(sprite_index) }) catch return error.MemoryAllocationFailed, - }; -} - -/// Default base name (no extension) for exporting a single tile: `{file_stem}_{anim}_{frame_idx}` if the -/// sprite appears in an animation, else `{file_stem}_{grid}`. If `selected_animation_index` is set -/// and that animation contains the sprite, that animation and frame index are used; otherwise the -/// first matching animation in file order is used. -pub fn spriteExportName(file: *File, allocator: std.mem.Allocator, sprite_index: usize) ![]const u8 { - const basename = std.fs.path.basename(file.path); - const file_stem = std.fs.path.stem(basename); - - if (file.selected_animation_index) |ai| { - const anim = file.animations.get(ai); - for (anim.frames, 0..) |frame, fi| { - if (frame.sprite_index == sprite_index) { - return try std.fmt.allocPrint(allocator, "{s}_{s}_{d}", .{ file_stem, anim.name, fi }); - } - } - } - - for (file.animations.items(.frames), file.animations.items(.name), 0..) |frames, name, aidx| { - _ = aidx; - for (frames, 0..) |frame, fi| { - if (frame.sprite_index == sprite_index) { - return try std.fmt.allocPrint(allocator, "{s}_{s}_{d}", .{ file_stem, name, fi }); - } - } - } - - const grid = try file.fmtSprite(allocator, sprite_index, .grid); - defer allocator.free(grid); - return try std.fmt.allocPrint(allocator, "{s}_{s}", .{ file_stem, grid }); -} - -/// Default base filename (no extension) for exporting the full selected layer only. -pub fn layerExportBaseName(file: *File, allocator: std.mem.Allocator) ![]const u8 { - const file_stem = std.fs.path.stem(std.fs.path.basename(file.path)); - const lname = file.layers.items(.name)[file.selected_layer_index]; - return try std.fmt.allocPrint(allocator, "{s}_{s}", .{ file_stem, lname }); -} - -/// Default base filename (no extension) for exporting the flattened (all visible layers) image. -pub fn allExportBaseName(file: *File, allocator: std.mem.Allocator) ![]const u8 { - const file_stem = std.fs.path.stem(std.fs.path.basename(file.path)); - return try std.fmt.allocPrint(allocator, "{s}_all", .{file_stem}); -} - -pub fn fmtColumn(_: *File, allocator: std.mem.Allocator, column: usize) ![]const u8 { - // Excel-style: 0 -> A, 1 -> B, ... 25 -> Z, 26 -> AA, 27 -> AB, etc. - var temp: [10]u8 = undefined; // Enough for absurdly large columns (> 1 billion) - var len: usize = 0; - - var idx = column; - while (true) { - const rem = idx % 26; - temp[9 - len] = std.ascii.uppercase[rem]; - len += 1; - if (idx < 26) break; - // Adjust for 1-based carryover because Excel-style is nonzero-based - idx = idx / 26 - 1; - } - const start = 10 - len; - const fmt = allocator.alloc(u8, len) catch return error.MemoryAllocationFailed; - @memcpy(fmt, temp[start .. start + len]); - return fmt; -} - -pub fn columnFromIndex(file: *File, index: usize) usize { - return @mod(index, file.columns); -} - -pub fn rowFromIndex(file: *File, index: usize) usize { - return @divTrunc(index, file.columns); -} - -pub fn columnFromPixel(file: *File, pixel: dvui.Point) usize { - return @mod(@as(usize, @intFromFloat(pixel.x)), file.column_width); -} - -pub fn rowFromPixel(file: *File, pixel: dvui.Point) usize { - return @divTrunc(@as(usize, @intFromFloat(pixel.y)), file.row_height); -} - -pub fn spriteRect(file: *File, index: usize) dvui.Rect { - const column = file.columnFromIndex(index); - const row = file.rowFromIndex(index); - - const out: dvui.Rect = .{ - .x = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width)), - .y = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height)), - .w = @as(f32, @floatFromInt(file.column_width)), - .h = @as(f32, @floatFromInt(file.row_height)), - }; - return out; -} - -pub fn columnRect(file: *File, column_index: usize) dvui.Rect { - return .{ - .x = @as(f32, @floatFromInt(column_index)) * @as(f32, @floatFromInt(file.column_width)), - .y = 0, - .w = @as(f32, @floatFromInt(file.column_width)), - .h = @as(f32, @floatFromInt(file.height())), - }; -} - -pub fn columnIndex(file: *File, point: dvui.Point) ?usize { - if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width()))) return null; - if (point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) return null; - const index = @divTrunc(@as(usize, @intFromFloat(point.x)), file.column_width); - if (index >= file.columns) return null; - return index; -} - -pub fn rowIndex(file: *File, point: dvui.Point) ?usize { - if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width()))) return null; - if (point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) return null; - const index = @divTrunc(@as(usize, @intFromFloat(point.y)), file.row_height); - if (index >= file.rows) return null; - return index; -} - -pub fn rowRect(file: *File, row_index: usize) dvui.Rect { - return .{ - .x = 0, - .y = @as(f32, @floatFromInt(row_index)) * @as(f32, @floatFromInt(file.row_height)), - .w = @as(f32, @floatFromInt(file.width())), - .h = @as(f32, @floatFromInt(file.row_height)), - }; -} -pub fn clearSelectedSprites(file: *File) void { - file.editor.selected_sprites.setRangeValue(.{ .start = 0, .end = file.spriteCount() }, false); - file.editor.primary_sprite_index = null; -} - -/// Sprite that should read as primary (tallest frame bubble, cover-flow focus). -pub fn primarySpriteIndex(file: *const File) ?usize { - if (file.editor.primary_sprite_index) |p| { - if (p < file.editor.selected_sprites.capacity() and file.editor.selected_sprites.isSet(p)) { - return p; - } - } - if (file.selected_animation_index) |ai| { - const frames = file.animations.get(ai).frames; - if (frames.len > 0 and file.selected_animation_frame_index < frames.len) { - return frames[file.selected_animation_frame_index].sprite_index; - } - } - return file.editor.selected_sprites.findLastSet(); -} - -/// Move the primary sprite/frame to `sprite_index` without changing the selection set. -pub fn promotePrimarySprite(file: *File, sprite_index: usize) void { - if (sprite_index >= file.editor.selected_sprites.capacity() or - !file.editor.selected_sprites.isSet(sprite_index)) - { - return; - } - file.editor.primary_sprite_index = sprite_index; - - const animation_index = file.selected_animation_index orelse return; - const frames = file.animations.get(animation_index).frames; - if (frames.len == 0) return; - - if (file.editor.selected_frame_indices.items.len > 0) { - for (file.editor.selected_frame_indices.items) |fi| { - if (fi < frames.len and frames[fi].sprite_index == sprite_index) { - file.selected_animation_frame_index = fi; - return; - } - } - } - - for (frames, 0..) |f, fi| { - if (f.sprite_index == sprite_index) { - file.selected_animation_frame_index = fi; - return; - } - } -} - -/// Collapse animation list multi-selection to the current primary only. Used when the primary is -/// set from the canvas/grid; the animation tree keeps multi-select via `applyAnimationClick`, but -/// those paths must not leave stale extra indices when the grid picks a new animation. -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.animation_selection_anchor = p; - } -} - -pub fn setSpriteSelection(file: *File, selection_rect: dvui.Rect, value: bool) void { - for (0..spriteCount(file)) |index| { - if (!file.spriteRect(index).intersect(selection_rect).empty()) { - file.editor.selected_sprites.setValue(index, value); - } - } -} - -pub const SelectOptions = struct { - value: bool = true, - clear: bool = false, - stroke_size: usize, - constrain_to_tile: bool = false, -}; - -/// Selects a point by considering the current stroke size and setting bits in the selection layer mask if there are -/// non-transparent pixels in the currently active layer. -/// If `value` is true, the point will be selected, otherwise it will be deselected. -/// If `clear` is true, the selection layer mask will be cleared before setting the new value. -pub fn selectPoint(file: *File, point: dvui.Point, select_options: SelectOptions) void { - const read_layer: Layer = file.layers.get(file.selected_layer_index); - var selection_layer: *Layer = &file.editor.selection_layer; - - if (select_options.clear) { - selection_layer.clearMask(); - } - - if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width())) or point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) { - return; - } - - const column = file.columnFromPixel(point); - const row = file.rowFromPixel(point); - - const min_x: f32 = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width)); - const min_y: f32 = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height)); - - const max_x: f32 = min_x + @as(f32, @floatFromInt(file.column_width)); - const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height)); - - if (select_options.stroke_size < 10) { - const size: usize = @intCast(select_options.stroke_size); - - for (0..(size * size)) |index| { - if (selection_layer.getIndexShapeOffset(point, index)) |result| { - if (select_options.constrain_to_tile) { - if (result.point.x < min_x or result.point.x >= max_x or result.point.y < min_y or result.point.y >= max_y) { - continue; - } - } - - if (read_layer.pixels()[result.index][3] > 0) { - selection_layer.mask.setValue(result.index, select_options.value); - } - } - } - } else { - var iter = Globals.state.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |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) { - if (new_point.x < min_x or new_point.x >= max_x or new_point.y < min_y or new_point.y >= max_y) { - continue; - } - } - - if (selection_layer.pixelIndex(new_point)) |index| { - if (read_layer.pixels()[index][3] > 0) { - selection_layer.mask.setValue(index, select_options.value); - } - } - } - } -} - -pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_options: SelectOptions) void { - const read_layer: Layer = file.layers.get(file.selected_layer_index); - var selection_layer: *Layer = &file.editor.selection_layer; - - if (select_options.clear) { - selection_layer.clearMask(); - } - - if (point1.x < 0 or point1.x >= @as(f32, @floatFromInt(file.width())) or point1.y < 0 or point1.y >= @as(f32, @floatFromInt(file.height()))) { - return; - } - - if (point2.x < 0 or point2.x >= @as(f32, @floatFromInt(file.width())) or point2.y < 0 or point2.y >= @as(f32, @floatFromInt(file.height()))) { - return; - } - - const column = file.columnFromPixel(point2); - const row = file.rowFromPixel(point2); - - const min_x: f32 = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width)); - const min_y: f32 = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height)); - - const max_x: f32 = min_x + @as(f32, @floatFromInt(file.column_width)); - 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(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; - - 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| { - mask.unset(i); - } - } - } - - if (pixelart.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 iter = stroke.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |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) { - if (new_point.x < min_x or new_point.x >= max_x or new_point.y < min_y or new_point.y >= max_y) { - continue; - } - } - - if (selection_layer.pixelIndex(new_point)) |index| { - if (read_layer.pixels()[index][3] > 0) { - selection_layer.mask.setValue(index, select_options.value); - } - } - } - } - } - } -} - -/// Selects every opaque pixel on the active layer inside the axis-aligned rectangle between `p1` and `p2` (inclusive). -pub fn selectRectBetweenPoints(file: *File, p1: dvui.Point, p2: dvui.Point, select_options: SelectOptions) void { - const read_layer: Layer = file.layers.get(file.selected_layer_index); - const selection_layer: *Layer = &file.editor.selection_layer; - - const x0: i32 = @intFromFloat(@floor(@min(p1.x, p2.x))); - const y0: i32 = @intFromFloat(@floor(@min(p1.y, p2.y))); - const x1: i32 = @intFromFloat(@floor(@max(p1.x, p2.x))); - const y1: i32 = @intFromFloat(@floor(@max(p1.y, p2.y))); - - const iw: i32 = @intCast(file.width()); - const ih: i32 = @intCast(file.height()); - - var py = y0; - while (py <= y1) : (py += 1) { - if (py < 0 or py >= ih) continue; - var px = x0; - while (px <= x1) : (px += 1) { - if (px < 0 or px >= iw) continue; - const pt: dvui.Point = .{ .x = @floatFromInt(px), .y = @floatFromInt(py) }; - if (selection_layer.pixelIndex(pt)) |idx| { - if (read_layer.pixels()[idx][3] > 0) { - selection_layer.mask.setValue(idx, select_options.value); - } - } - } - } -} - -/// Flood-selects or flood-deselects every pixel in the active layer that matches the color at `p` -/// (4-way contiguous region), same as bucket fill matching. `value` true adds to the selection mask, false removes. -pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void { - const read_layer = file.layers.get(file.selected_layer_index); - const selection_layer: *Layer = &file.editor.selection_layer; - - 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 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); - defer visited.deinit(); - - var queue = std.array_list.Managed(dvui.Point).init(Globals.allocator()); - defer queue.deinit(); - - try queue.append(p); - visited.set(start_idx); - - const directions: [4]dvui.Point = .{ - .{ .x = 0, .y = -1 }, - .{ .x = 0, .y = 1 }, - .{ .x = -1, .y = 0 }, - .{ .x = 1, .y = 0 }, - }; - - while (queue.pop()) |qp| { - 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); - - for (directions) |direction| { - const np = qp.plus(direction); - if (!bounds.contains(np)) continue; - 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); - try queue.append(np); - } - } - } -} - -pub const DrawLayer = enum { - temporary, - selected, -}; - -pub const DrawOptions = struct { - stroke_size: usize, - mask_only: bool = false, - invalidate: bool = false, - to_change: bool = false, - constrain_to_tile: bool = false, - color: dvui.Color = .{ .r = 0, .g = 0, .b = 0, .a = 255 }, - /// When set, only writes pixels inside this rect (data space). Used for temporary preview draws. - clip_rect: ?dvui.Rect = null, -}; - -/// Computes the pixel bounding rect of a brush stamp, clamped to image bounds. -fn brushRect(point: dvui.Point, stroke_size: usize, img_w: u32, img_h: u32) dvui.Rect { - const s: i32 = @intCast(stroke_size); - const half: i32 = @divFloor(s, 2); - const px: i32 = @intFromFloat(@floor(point.x)); - const py: i32 = @intFromFloat(@floor(point.y)); - const w: i32 = @intCast(img_w); - const h: i32 = @intCast(img_h); - const x0 = @max(px - half, 0); - const y0 = @max(py - half, 0); - const x1 = @min(px - half + s, w); - const y1 = @min(py - half + s, h); - return .{ - .x = @floatFromInt(x0), - .y = @floatFromInt(y0), - .w = @floatFromInt(@max(x1 - x0, 0)), - .h = @floatFromInt(@max(y1 - y0, 0)), - }; -} - -/// Expands the active layer dirty rect to include a new brush stamp region. -fn expandActiveLayerDirtyRect(file: *File, new_rect: dvui.Rect) void { - if (file.editor.active_layer_dirty_rect) |existing| { - file.editor.active_layer_dirty_rect = existing.unionWith(new_rect); - } else { - file.editor.active_layer_dirty_rect = new_rect; - } - file.editor.layer_composite_dirty = true; -} - -fn intRectFromDvuiRect(r: dvui.Rect, img_w: u32, img_h: u32) struct { x: u32, y: u32, w: u32, h: u32 } { - const x0 = @as(i32, @intFromFloat(@floor(r.x))); - const y0 = @as(i32, @intFromFloat(@floor(r.y))); - const x1 = @as(i32, @intFromFloat(@ceil(r.x + r.w))); - const y1 = @as(i32, @intFromFloat(@ceil(r.y + r.h))); - const wlim: i32 = @intCast(img_w); - const hlim: i32 = @intCast(img_h); - const ix0 = @max(x0, 0); - const iy0 = @max(y0, 0); - const ix1 = @min(x1, wlim); - const iy1 = @min(y1, hlim); - const cw: u32 = @intCast(@max(ix1 - ix0, 0)); - const ch: u32 = @intCast(@max(iy1 - iy0, 0)); - return .{ .x = @intCast(ix0), .y = @intCast(iy0), .w = cw, .h = ch }; -} - -/// Bounding box (clamped to image) that covers a brush stroke along the segment between two points. -pub fn lineBrushCoverRect(file: *const File, p1: dvui.Point, p2: dvui.Point, stroke_size: usize) dvui.Rect { - const iw = file.width(); - const ih = file.height(); - const w: i32 = @intCast(iw); - const h: i32 = @intCast(ih); - const s: i32 = @intCast(stroke_size); - const half = @divFloor(s, 2); - const ix1 = @as(i32, @intFromFloat(@floor(p1.x))); - const iy1 = @as(i32, @intFromFloat(@floor(p1.y))); - const ix2 = @as(i32, @intFromFloat(@floor(p2.x))); - const iy2 = @as(i32, @intFromFloat(@floor(p2.y))); - const min_px = @min(ix1, ix2) - half; - const min_py = @min(iy1, iy2) - half; - const max_px = @max(ix1, ix2) - half + s; - const max_py = @max(iy1, iy2) - half + s; - const x0 = @max(min_px, 0); - const y0 = @max(min_py, 0); - const x1 = @min(max_px, w); - const y1 = @min(max_py, h); - return .{ - .x = @floatFromInt(x0), - .y = @floatFromInt(y0), - .w = @floatFromInt(@max(x1 - x0, 0)), - .h = @floatFromInt(@max(y1 - y0, 0)), - }; -} - -pub fn brushStampRect(file: *const File, point: dvui.Point, stroke_size: usize) dvui.Rect { - return brushRect(point, stroke_size, file.width(), file.height()); -} - -fn strokeUndoFreeSnapshot(file: *File) void { - if (file.editor.stroke_undo_pixels) |p| { - Globals.allocator().free(p); - file.editor.stroke_undo_pixels = null; - } - file.editor.stroke_undo_x = 0; - file.editor.stroke_undo_y = 0; - file.editor.stroke_undo_w = 0; - file.editor.stroke_undo_h = 0; - file.editor.stroke_undo_deferred = false; -} - -/// Clears any prior snapshot and captures the current active layer pixels under `cover` (clamped). -pub fn strokeUndoBegin(file: *File, cover: dvui.Rect) !void { - strokeUndoFreeSnapshot(file); - - const iw = file.width(); - const ih = file.height(); - const b = intRectFromDvuiRect(cover, iw, ih); - if (b.w == 0 or b.h == 0) { - return; - } - - const snap_area = @as(u64, b.w) * @as(u64, b.h); - if (snap_area > stroke_undo_max_snapshot_pixels) { - return; - } - - const n = @as(usize, b.w) * @as(usize, b.h) * 4; - const buf = try Globals.allocator().alloc(u8, n); - - const layer = file.layers.get(file.selected_layer_index); - const pix = layer.pixels(); - const stride: usize = @intCast(iw); - var row: u32 = 0; - while (row < b.h) : (row += 1) { - const gy: usize = @intCast(b.y + row); - const src_start: usize = gy * stride + @as(usize, b.x); - const dst_start: usize = @as(usize, row) * @as(usize, b.w) * 4; - const row_px: usize = @intCast(b.w); - @memcpy(buf[dst_start..][0 .. row_px * 4], std.mem.sliceAsBytes(pix[src_start..][0..row_px])); - } - - file.editor.stroke_undo_pixels = buf; - file.editor.stroke_undo_x = b.x; - file.editor.stroke_undo_y = b.y; - file.editor.stroke_undo_w = b.w; - file.editor.stroke_undo_h = b.h; - file.editor.stroke_undo_deferred = true; -} - -/// Grows the snapshot so it includes `cover` (copying newly exposed pixels from the layer before paint). -pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void { - if (!file.editor.stroke_undo_deferred) return; - - const old_buf = file.editor.stroke_undo_pixels orelse return; - const iw = file.width(); - const ih = file.height(); - const ox = file.editor.stroke_undo_x; - const oy = file.editor.stroke_undo_y; - const ow = file.editor.stroke_undo_w; - const oh = file.editor.stroke_undo_h; - - const nb = intRectFromDvuiRect(cover, iw, ih); - if (nb.w == 0 or nb.h == 0) return; - - const tx: u32 = @min(ox, nb.x); - const ty: u32 = @min(oy, nb.y); - const tw: u32 = @max(ox + ow, nb.x + nb.w) - tx; - const th: u32 = @max(oy + oh, nb.y + nb.h) - ty; - - if (tw == ow and th == oh and tx == ox and ty == oy) return; - - const snap_area = @as(u64, tw) * @as(u64, th); - if (snap_area > stroke_undo_max_snapshot_pixels) { - strokeUndoFlushSnapshotToStrokeBuffer(file); - return; - } - - const new_n = @as(usize, tw) * @as(usize, th) * 4; - const new_buf = try Globals.allocator().alloc(u8, new_n); - - const layer = file.layers.get(file.selected_layer_index); - const pix = layer.pixels(); - const stride: usize = @intCast(iw); - - var gy: u32 = 0; - while (gy < th) : (gy += 1) { - var gx_off: u32 = 0; - while (gx_off < tw) : (gx_off += 1) { - const gx: u32 = tx + gx_off; - const gyy: u32 = ty + gy; - const dst: usize = (@as(usize, gy) * @as(usize, tw) + @as(usize, gx_off)) * 4; - const in_old = gx >= ox and gx < ox + ow and gyy >= oy and gyy < oy + oh; - if (in_old) { - const ox_l = gx - ox; - const oy_l = gyy - oy; - const src: usize = (@as(usize, oy_l) * @as(usize, ow) + @as(usize, ox_l)) * 4; - @memcpy(new_buf[dst..][0..4], old_buf[src..][0..4]); - } else { - const idx: usize = @as(usize, gyy) * stride + @as(usize, gx); - @memcpy(new_buf[dst..][0..4], std.mem.asBytes(&pix[idx])); - } - } - } - - Globals.allocator().free(old_buf); - file.editor.stroke_undo_pixels = new_buf; - file.editor.stroke_undo_x = tx; - file.editor.stroke_undo_y = ty; - file.editor.stroke_undo_w = tw; - file.editor.stroke_undo_h = th; -} - -/// Move deferred snapshot diffs into the live stroke buffer and stop using the snapshot. -/// Used when the snapshot can no longer grow (size cap) so later pixels still get undo entries. -fn strokeUndoFlushSnapshotToStrokeBuffer(file: *File) void { - if (!file.editor.stroke_undo_deferred) return; - const snap = file.editor.stroke_undo_pixels orelse { - file.editor.stroke_undo_deferred = false; - return; - }; - - const layer = file.layers.get(file.selected_layer_index); - const pixels = layer.pixels(); - const iw: usize = @intCast(file.width()); - - const sx = file.editor.stroke_undo_x; - const sy = file.editor.stroke_undo_y; - const sw = file.editor.stroke_undo_w; - const sh = file.editor.stroke_undo_h; - - var row: u32 = 0; - while (row < sh) : (row += 1) { - var col: u32 = 0; - while (col < sw) : (col += 1) { - const gx: usize = @as(usize, sx + col); - const gyy: usize = @as(usize, sy + row); - const idx: usize = gyy * iw + gx; - const off: usize = (@as(usize, row) * @as(usize, sw) + @as(usize, col)) * 4; - const old_px: [4]u8 = .{ snap[off], snap[off + 1], snap[off + 2], snap[off + 3] }; - const cur = pixels[idx]; - if (!std.mem.eql(u8, &old_px, &cur)) { - file.buffers.stroke.append(idx, old_px) catch { - dvui.log.err("Failed to append to stroke buffer (flush snapshot)", .{}); - }; - } - } - } - - strokeUndoFreeSnapshot(file); -} - -pub fn strokeUndoCommit(file: *File) void { - if (file.editor.stroke_undo_deferred) { - strokeUndoFlushSnapshotToStrokeBuffer(file); - } - - const change_opt = file.buffers.stroke.toChange(file.layers.get(file.selected_layer_index).id) catch null; - if (change_opt) |change| { - file.history.append(change) catch { - dvui.log.err("Failed to append to history", .{}); - }; - } -} - -fn selectionMaskHasPixels(file: *const File) bool { - var it = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); - return it.next() != null; -} - -fn pixelInAnySelectedSprite(file: *File, px: usize, py: usize) bool { - const fx: f32 = @floatFromInt(px); - const fy: f32 = @floatFromInt(py); - var it = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (it.next()) |idx| { - const r = file.spriteRect(idx); - if (fx >= r.x and fx < r.x + r.w and fy >= r.y and fy < r.y + r.h) { - return true; - } - } - return false; -} - -/// Clears pixels covered by the fine selection mask, or clears every selected sprite tile on the -/// active layer. When the selection mask is non-empty, it takes precedence over sprite tile selection. -pub fn deleteSelectedContents(file: *File) void { - if (file.editor.transform != null) return; - - if (selectionMaskHasPixels(file)) { - deleteSelectedFinePixels(file); - return; - } - - if (file.editor.selected_sprites.count() > 0) { - deleteSelectedSpriteTiles(file); - } -} - -fn deleteSelectedFinePixels(file: *File) void { - const iw: u32 = file.width(); - const stride: usize = @intCast(iw); - - var min_x: u32 = iw; - var min_y: u32 = file.height(); - var max_x: u32 = 0; - var max_y: u32 = 0; - var any = false; - - { - var it = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); - while (it.next()) |pixel_index| { - const x: u32 = @intCast(pixel_index % stride); - const y: u32 = @intCast(pixel_index / stride); - min_x = @min(min_x, x); - min_y = @min(min_y, y); - max_x = @max(max_x, x); - max_y = @max(max_y, y); - any = true; - } - } - if (!any) return; - - const cover = dvui.Rect{ - .x = @floatFromInt(min_x), - .y = @floatFromInt(min_y), - .w = @floatFromInt(max_x - min_x + 1), - .h = @floatFromInt(max_y - min_y + 1), - }; - - file.strokeUndoBegin(cover) catch { - dvui.log.err("deleteSelectedFinePixels: strokeUndoBegin failed", .{}); - return; - }; - - var layer = file.layers.get(file.selected_layer_index); - - if (file.editor.stroke_undo_deferred) { - var it2 = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); - while (it2.next()) |pixel_index| { - layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 }; - } - layer.invalidate(); - file.strokeUndoCommit(); - } else { - file.buffers.stroke.clearAndFree(); - var it2 = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); - while (it2.next()) |pixel_index| { - file.buffers.stroke.append(pixel_index, layer.pixels()[pixel_index]) catch { - dvui.log.err("deleteSelectedFinePixels: stroke buffer append failed", .{}); - return; - }; - layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 }; - } - layer.invalidate(); - const change = file.buffers.stroke.toChange(layer.id) catch |err| { - dvui.log.err("deleteSelectedFinePixels: toChange failed: {}", .{err}); - return; - }; - file.history.append(change) catch { - dvui.log.err("deleteSelectedFinePixels: history append failed", .{}); - }; - } - - file.editor.selection_layer.clearMask(); - file.invalidateActiveLayerTransparencyMaskCache(); - file.editor.layer_composite_dirty = true; - file.editor.split_composite_dirty = true; -} - -fn deleteSelectedSpriteTiles(file: *File) void { - var cover: ?dvui.Rect = null; - { - var it = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (it.next()) |idx| { - const r = file.spriteRect(idx); - cover = if (cover) |c| c.unionWith(r) else r; - } - } - const cvr = cover orelse return; - - file.strokeUndoBegin(cvr) catch { - dvui.log.err("deleteSelectedSpriteTiles: strokeUndoBegin failed", .{}); - return; - }; - - var layer = file.layers.get(file.selected_layer_index); - - if (file.editor.stroke_undo_deferred) { - var it = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (it.next()) |idx| { - layer.clearRect(file.spriteRect(idx)); - } - file.strokeUndoCommit(); - } else { - file.buffers.stroke.clearAndFree(); - const b = intRectFromDvuiRect(cvr, file.width(), file.height()); - if (b.w == 0 or b.h == 0) { - file.invalidateActiveLayerTransparencyMaskCache(); - file.editor.layer_composite_dirty = true; - file.editor.split_composite_dirty = true; - return; - } - const iw_sz: usize = @intCast(file.width()); - var row: u32 = 0; - while (row < b.h) : (row += 1) { - var col: u32 = 0; - while (col < b.w) : (col += 1) { - const gx: usize = @as(usize, b.x + col); - const gy: usize = @as(usize, b.y + row); - if (!pixelInAnySelectedSprite(file, gx, gy)) continue; - const pidx = gy * iw_sz + gx; - file.buffers.stroke.append(pidx, layer.pixels()[pidx]) catch { - dvui.log.err("deleteSelectedSpriteTiles: stroke buffer append failed", .{}); - return; - }; - layer.pixels()[pidx] = .{ 0, 0, 0, 0 }; - } - } - layer.invalidate(); - const change = file.buffers.stroke.toChange(layer.id) catch |err| { - dvui.log.err("deleteSelectedSpriteTiles: toChange failed: {}", .{err}); - return; - }; - file.history.append(change) catch { - dvui.log.err("deleteSelectedSpriteTiles: history append failed", .{}); - }; - } - - file.invalidateActiveLayerTransparencyMaskCache(); - file.editor.layer_composite_dirty = true; - file.editor.split_composite_dirty = true; -} - -/// Draws a point on the `.selected` (the point will be added to the stroke buffer) or `.temporary` layer. -/// If `to_change` is true, the point will be added to the stroke buffer and then the history will be appended. -/// If `invalidate` is true, the layer will be invalidated. -/// If `mask_only` is true, the drawn pixels will only be marked on the mask, not the layer pixels themselves. -/// If `constrain_to_tile` is true, the drawn pixels will only be marked on the tile that the point is currently within -/// regardless of the stroke size. -pub fn drawPoint(file: *File, point: dvui.Point, layer: DrawLayer, draw_options: DrawOptions) void { - var active_layer: Layer = switch (layer) { - .temporary => file.editor.temporary_layer, - .selected => file.layers.get(file.selected_layer_index), - }; - - defer active_layer.dirty = true; - - if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width())) or point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) { - return; - } - - const clip_rect: ?dvui.Rect = if (layer == .temporary) draw_options.clip_rect else null; - - const mask_value: bool = draw_options.color.a != 0; - - const column = file.columnFromPixel(point); - const row = file.rowFromPixel(point); - - const min_x: f32 = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width)); - const min_y: f32 = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height)); - - const max_x: f32 = min_x + @as(f32, @floatFromInt(file.column_width)); - const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height)); - - if (clip_rect) |cr| { - const br = brushRect(point, draw_options.stroke_size, file.width(), file.height()); - if (br.intersect(cr).empty()) return; - } - - if (draw_options.stroke_size < 10) { - const size: usize = @intCast(draw_options.stroke_size); - - for (0..(size * size)) |index| { - if (active_layer.getIndexShapeOffset(point, index)) |result| { - if (clip_rect) |cr| { - if (!cr.contains(result.point)) continue; - } - if (draw_options.constrain_to_tile) { - if (result.point.x < min_x or result.point.x >= max_x or result.point.y < min_y or result.point.y >= max_y) { - continue; - } - } - - active_layer.mask.setValue(result.index, mask_value); - - if (draw_options.mask_only) { - continue; - } - - if (layer == .selected and !file.editor.stroke_undo_deferred) { - file.buffers.stroke.append(result.index, result.color) catch { - dvui.log.err("Failed to append to stroke buffer", .{}); - }; - } - - active_layer.pixels()[result.index] = draw_options.color.toRGBA(); - } - } - } else { - var iter = Globals.state.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |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| { - if (!cr.contains(new_point)) continue; - } - if (draw_options.constrain_to_tile) { - if (new_point.x < min_x or new_point.x >= max_x or new_point.y < min_y or new_point.y >= max_y) { - continue; - } - } - - if (active_layer.pixelIndex(new_point)) |index| { - active_layer.mask.setValue(index, mask_value); - if (draw_options.mask_only) { - continue; - } - if (layer == .selected and !file.editor.stroke_undo_deferred) { - file.buffers.stroke.append(index, active_layer.pixels()[index]) catch { - dvui.log.err("Failed to append to stroke buffer", .{}); - }; - } - - active_layer.pixels()[index] = draw_options.color.toRGBA(); - } - } - } - - if (draw_options.mask_only) { - return; - } - - if (draw_options.invalidate) { - if (layer == .selected) { - expandActiveLayerDirtyRect(file, brushRect(point, draw_options.stroke_size, file.width(), file.height())); - } else { - active_layer.invalidate(); - } - } - - if (draw_options.to_change and layer == .selected) { - if (file.editor.stroke_undo_deferred) { - file.strokeUndoCommit(); - } else { - const change_opt = file.buffers.stroke.toChange(active_layer.id) catch null; - if (change_opt) |change| { - file.history.append(change) catch { - dvui.log.err("Failed to append to history", .{}); - }; - } - } - } -} - -pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: DrawLayer, draw_options: DrawOptions) void { - var active_layer: Layer = switch (layer) { - .temporary => file.editor.temporary_layer, - .selected => file.layers.get(file.selected_layer_index), - }; - - defer active_layer.dirty = true; - - if (point1.x < 0 or point1.x >= @as(f32, @floatFromInt(file.width())) or point1.y < 0 or point1.y >= @as(f32, @floatFromInt(file.height()))) { - return; - } - - if (point2.x < 0 or point2.x >= @as(f32, @floatFromInt(file.width())) or point2.y < 0 or point2.y >= @as(f32, @floatFromInt(file.height()))) { - return; - } - - const clip_rect: ?dvui.Rect = if (layer == .temporary) draw_options.clip_rect else null; - const iw = file.width(); - const ih = file.height(); - - const mask_value: bool = draw_options.color.a != 0; - - const column = file.columnFromPixel(point2); - const row = file.rowFromPixel(point2); - - const min_x: f32 = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width)); - const min_y: f32 = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height)); - - const max_x: f32 = min_x + @as(f32, @floatFromInt(file.column_width)); - 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(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; - - 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| { - mask.unset(i); - } - } - } - - 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 < Tools.min_full_stroke_size) { - drawPoint(file, point, layer, .{ - .color = draw_options.color, - .stroke_size = draw_options.stroke_size, - .mask_only = draw_options.mask_only, - .invalidate = false, - .to_change = false, - .constrain_to_tile = draw_options.constrain_to_tile, - .clip_rect = draw_options.clip_rect, - }); - } else { - 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 = 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| { - if (!cr.contains(new_point)) continue; - } - if (draw_options.constrain_to_tile) { - if (new_point.x < min_x or new_point.x >= max_x or new_point.y < min_y or new_point.y >= max_y) { - continue; - } - } - - if (active_layer.pixelIndex(new_point)) |index| { - active_layer.mask.setValue(index, mask_value); - if (draw_options.mask_only) { - continue; - } - if (layer == .selected and !file.editor.stroke_undo_deferred) { - file.buffers.stroke.append(index, active_layer.pixels()[index]) catch { - dvui.log.err("Failed to append to stroke buffer", .{}); - }; - } - - active_layer.pixels()[index] = draw_options.color.toRGBA(); - } - } - } - } - - if (draw_options.mask_only) { - return; - } - - if (draw_options.invalidate) { - if (layer == .selected) { - const r1 = brushRect(point1, draw_options.stroke_size, file.width(), file.height()); - const r2 = brushRect(point2, draw_options.stroke_size, file.width(), file.height()); - expandActiveLayerDirtyRect(file, r1.unionWith(r2)); - } else { - active_layer.invalidate(); - } - } - - if (draw_options.to_change and layer == .selected) { - if (file.editor.stroke_undo_deferred) { - file.strokeUndoCommit(); - } else { - const change_opt = file.buffers.stroke.toChange(active_layer.id) catch null; - if (change_opt) |change| { - file.history.append(change) catch { - dvui.log.err("Failed to append to history", .{}); - }; - } - } - } - } -} - -pub const FillOptions = struct { - invalidate: bool = false, - to_change: bool = false, - mask_only: bool = false, - constrain_to_tile: bool = false, - replace: bool = false, - color: dvui.Color = .{ .r = 0, .g = 0, .b = 0, .a = 255 }, -}; - -pub fn fillPoint(file: *File, point: dvui.Point, layer: DrawLayer, fill_options: FillOptions) void { - var active_layer: Layer = switch (layer) { - .temporary => file.editor.temporary_layer, - .selected => file.layers.get(file.selected_layer_index), - }; - - defer active_layer.dirty = true; - - const active_mask_before = active_layer.mask.clone(dvui.currentWindow().arena()) catch { - dvui.log.err("Failed to clone active mask", .{}); - return; - }; - - if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width())) or point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) { - return; - } - - if (fill_options.replace) { - if (active_layer.pixel(point)) |c| { - active_layer.clearMask(); - active_layer.setMaskFromColor(.{ .r = c[0], .g = c[1], .b = c[2], .a = c[3] }, true); - } - } else { - active_layer.clearMask(); - active_layer.floodMaskPoint(point, .fromSize(.{ .w = @as(f32, @floatFromInt(file.width())), .h = @as(f32, @floatFromInt(file.height())) }), true) catch { - dvui.log.err("Failed to fill point", .{}); - }; - } - - if (fill_options.mask_only) { - active_layer.mask.setUnion(active_mask_before); - return; - } - - var iter = active_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |index| { - file.buffers.stroke.append(index, active_layer.pixels()[index]) catch { - dvui.log.err("Failed to append to stroke buffer", .{}); - }; - - active_layer.pixels()[index] = fill_options.color.toRGBA(); - } - - if (fill_options.invalidate) { - active_layer.invalidate(); - } - - if (fill_options.to_change and layer == .selected and !fill_options.mask_only) { - const change_opt = file.buffers.stroke.toChange(active_layer.id) catch null; - if (change_opt) |change| { - file.history.append(change) catch { - dvui.log.err("Failed to append to history", .{}); - }; - } - } - - if (fill_options.color.a != 0) { - active_layer.mask.toggleAll(); // This will ensure that all drawn pixels are off, and all undrawn pixels are on - } - - active_layer.mask.setUnion(active_mask_before); - - // Bucket fill leaves `active_layer.mask` in brush-tracking form (toggle+union) and does not - // change `ImageSource.hash()`. The selection overlay (`updateActiveLayerMask` → - // `setMaskFromTransparency`) must be rebuilt from actual pixels, not a stale cache. - if (layer == .selected) { - file.invalidateActiveLayerTransparencyMaskCache(); - } -} - -pub fn getLayer(self: *File, id: u64) ?Layer { - for (self.layers.items(.id), 0..) |layer_id, layer_index| { - if (layer_id == id) { - return self.layers.get(layer_index); - } - } - - return null; -} - -pub fn deleteLayer(self: *File, index: usize) !void { - 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; - try self.history.append(.{ .layer_restore_delete = .{ - .action = .restore, - .index = index, - } }); - - if (index > 0) { - self.selected_layer_index = index - 1; - } -} - -pub fn mergeSelectedLayerUp(self: *File) !void { - const s = self.selected_layer_index; - if (s == 0) return; - try self.mergeLayerInternal(.up, s, s - 1); -} - -pub fn mergeSelectedLayerDown(self: *File) !void { - const s = self.selected_layer_index; - if (s + 1 >= self.layers.len) return; - try self.mergeLayerInternal(.down, s, s + 1); -} - -fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: usize, dest_i: usize) !void { - var dest = self.layers.get(dest_i); - const src = self.layers.get(src_i); - - const pix_n = dest.pixels().len; - if (src.pixels().len != pix_n) return error.InvalidLayerMerge; - - 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); - - var dest_mask_before = try dest.mask.clone(Globals.allocator()); - errdefer dest_mask_before.deinit(); - - for (0..pix_n) |i| { - const dpx = dest.pixels()[i]; - const spx = src.pixels()[i]; - dest.pixels()[i] = switch (kind) { - .up => Layer.blendPmaSrcOver(dpx, spx), - .down => Layer.blendPmaSrcOver(spx, dpx), - }; - } - dest.mask.setUnion(src.mask); - dest.invalidate(); - self.layers.set(dest_i, dest); - - try self.deleted_layers.append(Globals.allocator(), self.layers.slice().get(src_i)); - self.layers.orderedRemove(src_i); - - self.editor.layer_composite_dirty = true; - self.editor.split_composite_dirty = true; - - self.selected_layer_index = switch (kind) { - .up => dest_i, - .down => dest_i - 1, - }; - - try self.history.append(.{ .layer_merge = .{ - .kind = kind, - .source_index = src_i, - .dest_layer_id = dest_id, - .source_layer_id = src_id, - .dest_pixels_before = dest_pixels_before, - .dest_mask_before = dest_mask_before, - } }); - Globals.state.host.setActiveSidebarView(plugin.view_tools); -} - -pub fn duplicateLayer(self: *File, index: usize) !u64 { - const layer = self.layers.slice().get(index); - - const new_name = try std.fmt.allocPrint(dvui.currentWindow().lifo(), "{s}_copy", .{layer.name}); - defer dvui.currentWindow().lifo().free(new_name); - - var new_layer = Layer.init(self.newLayerID(), new_name, self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.FailedToDuplicateLayer; - new_layer.visible = layer.visible; - new_layer.collapse = layer.collapse; - - @memcpy(new_layer.pixels(), layer.pixels()); - - self.layers.insert(Globals.allocator(), 0, new_layer) catch { - dvui.log.err("Failed to append layer", .{}); - }; - - self.selected_layer_index = 0; - self.editor.layer_composite_dirty = true; - self.editor.split_composite_dirty = true; - - self.history.append(.{ - .layer_restore_delete = .{ - .index = 0, - .action = .delete, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - - return new_layer.id; -} - -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 { - dvui.log.err("Failed to append layer", .{}); - }; - self.selected_layer_index = 0; - self.editor.layer_composite_dirty = true; - self.editor.split_composite_dirty = true; - - self.history.append(.{ - .layer_restore_delete = .{ - .index = 0, - .action = .delete, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - - return layer.id; - } - - return error.FailedToCreateLayer; -} - -pub fn createAnimation(self: *File) !usize { - var animation = Animation.init( - Globals.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()); - - var iter = self.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - var i: usize = 0; - while (iter.next()) |sprite_index| : (i += 1) { - animation.frames[i] = .{ .sprite_index = sprite_index, .ms = @intFromFloat(1000.0 / @as(f32, @floatFromInt(self.editor.selected_sprites.count()))) }; - } - } - - self.animations.append(Globals.allocator(), animation) catch { - dvui.log.err("Failed to append animation", .{}); - }; - return self.animations.len - 1; -} - -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 { - 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)); - self.animations.orderedRemove(index); - try self.history.append(.{ .animation_restore_delete = .{ - .action = .restore, - .index = index, - } }); -} - -pub fn undo(self: *File) !void { - return self.history.undoRedo(self, .undo); -} - -pub fn redo(self: *File) !void { - return self.history.undoRedo(self, .redo); -} - -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()); - - 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(Globals.allocator()); - const out_stream = json.writer(); - const options = std.json.StringifyOptions{}; - - try std.json.stringify(ext, options, out_stream); - - const json_output = try json.toOwnedSlice(); - - try wrt.writeFileBytes("fizzydata.json", json_output, .{}); - - if (self.layers.len > 0) { - const slice = self.layers.slice(); - var index: usize = 0; - while (index < self.layers.len) : (index += 1) { - const layer = slice.get(index); - - const data: []u8 = switch (layer.source) { - .pixels => |p| @as([*]u8, @ptrCast(@constCast(p.rgba.ptr)))[0..(p.width * p.height * 4)], - .pixelsPMA => |p| @as([*]u8, @ptrCast(@constCast(p.rgba.ptr)))[0..(p.width * p.height * 4)], - else => return error.InvalidImageSource, - }; - - 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, 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); - id_mutex.mutex.unlock(dvui.io); - } - - self.saving = false; - self.history.bookmark = 0; -} - -/// All visible layers composited with src-over (same as on-canvas), then encoded to PNG or JPEG. -fn writeFlattenedLayersToPath(self: *File, out_path: []const u8, window: *dvui.Window, comptime kind: enum { png, jpg }) !void { - const w = self.width(); - const h = self.height(); - if (w == 0 or h == 0) return error.InvalidImageSize; - - 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(Globals.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]); - } - - 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 pixelart.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); - }, - } -} - -pub fn savePng(self: *File, window: *dvui.Window) !void { - if (self.isSaving()) return; - self.setSaving(true); - errdefer self.setSaving(false); - - try self.writeFlattenedLayersToPath(self.path, window, .png); - - { - // `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 = 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); - } - - self.setSaving(false); - self.history.bookmark = 0; -} - -pub fn saveJpg(self: *File, window: *dvui.Window) !void { - if (self.isSaving()) return; - self.setSaving(true); - errdefer self.setSaving(false); - - try self.writeFlattenedLayersToPath(self.path, window, .jpg); - - { - // `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 = 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); - } - - self.setSaving(false); - self.history.bookmark = 0; -} - -pub fn saveZip(self: *File, window: *dvui.Window) !void { - if (self.isSaving()) return; - self.setSaving(true); - defer self.setSaving(false); - // Synchronous callers (e.g. `saveAsFizzy`) run on the GUI thread, which is - // 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()); - try writeSnapshotToZip(self.id, window, &snap); -} - -/// Layer pixel bytes + metadata copied from `*File` on the GUI thread before a -/// worker save kicks off. The worker reads only this struct, never the live -/// `*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, - layer_bytes: [][]u8, - layer_entry_names: [][:0]const u8, - null_terminated_path: [:0]u8, - - /// Allocate + populate from `*File`. MUST be called from the GUI thread - /// (the only writer of `file.layers`). - pub fn fromFileOnGuiThread(file: *File, allocator: std.mem.Allocator) !SaveSnapshot { - var snap: SaveSnapshot = .{ - .ext = try file.external(allocator), - .layer_bytes = try allocator.alloc([]u8, file.layers.len), - .layer_entry_names = try allocator.alloc([:0]const u8, file.layers.len), - .null_terminated_path = try allocator.dupeZ(u8, file.path), - }; - // Initialize slots so partial-init cleanup is safe. - @memset(snap.layer_bytes, &[_]u8{}); - for (snap.layer_entry_names) |*n| n.* = ""; - errdefer snap.deinit(allocator); - - const slice = file.layers.slice(); - var i: usize = 0; - while (i < file.layers.len) : (i += 1) { - const layer = slice.get(i); - snap.layer_bytes[i] = try allocator.dupe(u8, layer.bytes()); - snap.layer_entry_names[i] = try std.fmt.allocPrintSentinel(allocator, "{s}.layer", .{layer.name}, 0); - } - return snap; - } - - pub fn deinit(self: *SaveSnapshot, allocator: std.mem.Allocator) void { - self.ext.deinit(allocator); - for (self.layer_bytes) |b| if (b.len != 0) allocator.free(b); - allocator.free(self.layer_bytes); - for (self.layer_entry_names) |n| if (n.len != 0) allocator.free(n); - allocator.free(self.layer_entry_names); - allocator.free(self.null_terminated_path); - } -}; - -/// Single dedicated worker that serializes all `.fiz` async saves through one -/// thread. Pushed-into by `saveAsync` (via `save_queue.submit`), drained by the -/// long-lived `saveQueueWorker` thread spawned by `initSaveQueue`. -/// -/// IMPORTANT: jobs reference the target file by `id`, not by `*File`. The editor's -/// `open_files` is an `AutoArrayHashMap` with inline values, and `rawCloseFileID` -/// does an `orderedRemove` that shifts later entries down to fill the gap. Any -/// `*File` pointer captured at submit time would be silently invalidated the -/// moment an earlier file in the queue completes and gets closed — manifesting -/// as the worker dequeuing the "right" Job slot but its `file` pointer reading -/// the SHIFTED-INTO-PLACE file's data. -pub const SaveQueue = struct { - pub const Job = struct { - file_id: u64, - window: *dvui.Window, - snap: *SaveSnapshot, - }; - - mutex: std.Io.Mutex = .init, - cond: std.Io.Condition = .init, - queue: std.ArrayListUnmanaged(Job) = .empty, - shutdown: bool = false, - worker: ?std.Thread = null, - - 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); - self.cond.signal(dvui.io); - } -}; - -var save_queue: SaveQueue = .{}; - -/// Spawn the long-lived save-queue worker. Safe to call multiple times; second+ -/// calls are no-ops. Call from the GUI thread before any `saveAsync` runs. -/// Wasm: no-op. Single-threaded freestanding wasm can't spawn threads; the -/// browser save path needs a different strategy (inline + `wasm_download_data`). -pub fn initSaveQueue() !void { - if (comptime @import("builtin").target.cpu.arch == .wasm32) return; - if (save_queue.worker != null) return; - save_queue.worker = try std.Thread.spawn(.{}, saveQueueWorker, .{}); -} - -/// Signal the save-queue worker to drain remaining jobs and exit, then join. -/// Call from the GUI thread during editor shutdown. -/// Wasm: no-op (no worker was ever spawned). -pub fn deinitSaveQueue() void { - if (comptime @import("builtin").target.cpu.arch == .wasm32) return; - save_queue.mutex.lockUncancelable(dvui.io); - save_queue.shutdown = true; - save_queue.cond.broadcast(dvui.io); - save_queue.mutex.unlock(dvui.io); - if (save_queue.worker) |w| { - w.join(); - save_queue.worker = null; - } - // 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); - } - save_queue.queue.deinit(Globals.allocator()); -} - -fn saveQueueWorker() void { - while (true) { - save_queue.mutex.lockUncancelable(dvui.io); - while (save_queue.queue.items.len == 0 and !save_queue.shutdown) { - save_queue.cond.waitUncancelable(dvui.io, &save_queue.mutex); - } - if (save_queue.shutdown and save_queue.queue.items.len == 0) { - save_queue.mutex.unlock(dvui.io); - return; - } - const job = save_queue.queue.orderedRemove(0); - save_queue.mutex.unlock(dvui.io); - - // The snapshot owns everything the writer needs. For the post-write - // `setSaving(false)` and `history.bookmark = 0` we MUST re-lookup the - // file pointer at the moment of use — `editor.open_files` is an - // AutoArrayHashMap with inline values, and any concurrent `orderedRemove` - // shifts later entries down. A pointer captured here at dequeue time - // 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); - dvui.refresh(job.window, @src(), null); - } - writeSnapshotToZip(job.file_id, job.window, job.snap) catch |err| { - dvui.log.err("Async save failed: {s}", .{@errorName(err)}); - }; - } -} - -/// Shared zip-writing logic. Reads ONLY from `snap`, never `self.*` collection -/// fields. `self` is used for the post-save `history.bookmark = 0` update. -/// -/// This runs on a worker thread when invoked via `saveAsync`/`saveZipFromSnapshot`. -/// Anything that mutates dvui state (toasts, window arena allocations, etc.) MUST -/// stay off this path — concurrent workers calling into `dvui.toastAdd` race on -/// the toast subsystem's mutex against the GUI thread's per-frame toast iteration, -/// and the contention can wedge one of them indefinitely (observed in multi-doc -/// save-all-quit). The save-complete toast is duplicate feedback — the dialog -/// closing + tab disappearing already signals completion — so we skip it. -/// Async-path entry takes `file_id` and re-looks up the file at the END (for -/// `history.bookmark` reset). The snapshot owns its own bytes so the actual -/// zip write doesn't need any access to the live file. Re-lookup is critical -/// because `open_files` shifts inline values on remove and any pointer captured -/// upfront would silently alias a different file by the time we write. -fn writeSnapshotToZip(file_id: u64, window: *dvui.Window, snap: *const SaveSnapshot) !void { - _ = window; - const zip_file = zip.zip_open(snap.null_terminated_path.ptr, zip.ZIP_DEFAULT_COMPRESSION_LEVEL, 'w'); - - if (zip_file) |z| { - try writeSnapshotEntriesToZip(z, snap); - zip.zip_close(z); - } - - if (Globals.state.docs.fileById(file_id)) |f| f.history.bookmark = 0; -} - -fn zipEntryOk(rc: c_int) !void { - if (rc < 0) return error.ZipEntryFailed; -} - -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); - - try zipEntryOk(zip.zip_entry_open(z, "fizzydata.json")); - try zipEntryOk(zip.zip_entry_write(z, output.ptr, output.len)); - try zipEntryOk(zip.zip_entry_close(z)); - - for (snap.layer_entry_names, snap.layer_bytes) |entry_name, bytes| { - try zipEntryOk(zip.zip_entry_open(z, @as([*c]const u8, @ptrCast(entry_name)))); - try zipEntryOk(zip.zip_entry_write(z, @ptrCast(bytes.ptr), bytes.len)); - try zipEntryOk(zip.zip_entry_close(z)); - } -} - -fn writeSnapshotToZipBytes(snap: *const SaveSnapshot, allocator: std.mem.Allocator) ![]u8 { - // Stored (level 0) on wasm: DEFLATE in miniz is less tested on freestanding and some - // readers reject malformed compressed entries from in-memory writers. - const level: c_int = if (comptime @import("builtin").target.cpu.arch == .wasm32) 0 else zip.ZIP_DEFAULT_COMPRESSION_LEVEL; - const z = zip.zip_stream_open(null, 0, level, 'w') orelse return error.ZipOpenFailed; - defer zip.zip_stream_close(z); - try writeSnapshotEntriesToZip(z, snap); - var buf: ?*anyopaque = null; - var bufsize: usize = 0; - const n = zip.zip_stream_copy(z, &buf, &bufsize); - if (n < 0 or buf == null) return error.ZipCopyFailed; - const slice = @as([*]const u8, @ptrCast(buf))[0..bufsize]; - const owned = try allocator.dupe(u8, slice); - zip.fizzy_zip_free(buf); - if (owned.len < 4 or !std.mem.eql(u8, owned[0..4], "PK\x03\x04")) return error.InvalidZip; - return owned; -} - -/// Browser save: encode in memory and trigger a download (no on-disk project folder). -pub fn saveToDownload(self: *File, window: *dvui.Window) !void { - if (comptime @import("builtin").target.cpu.arch != .wasm32) return; - if (self.isSaving()) return; - self.setSaving(true); - defer self.setSaving(false); - - const basename = std.fs.path.basename(self.path); - 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); - 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("../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("../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 = id_mutex.id; - const message = std.fmt.allocPrint(window.arena(), "Downloaded {s}", .{basename}) catch "Downloaded file"; - dvui.dataSetSlice(window, id, "_message", message); - id_mutex.mutex.unlock(dvui.io); -} - -fn flattenedImageBytes(self: *File, window: *dvui.Window, comptime kind: enum { png, jpg }) ![]u8 { - const w = self.width(); - const h = self.height(); - if (w == 0 or h == 0) return error.InvalidImageSize; - - 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(Globals.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]); - } - - 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()); - 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); - }, - .jpg => { - const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try pixelart.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi); - }, - } - return out.toOwnedSlice(); -} - -/// Point `path` at `new_path`, then `saveZip` (same on-disk work as a normal .pixi save). Restores the previous `path` if saving fails. -pub fn saveAsFizzy(self: *File, new_path: []const u8, window: *dvui.Window) !void { - if (self.isSaving()) return; - if (std.mem.eql(u8, self.path, new_path)) { - if (comptime @import("builtin").target.cpu.arch == .wasm32) { - return saveToDownload(self, window); - } - return saveZip(self, window); - } - const old_path = self.path; - const new_owned = try Globals.allocator().dupe(u8, new_path); - self.path = new_owned; - errdefer { - Globals.allocator().free(self.path[0..self.path.len]); - self.path = old_path; - } - if (comptime @import("builtin").target.cpu.arch == .wasm32) { - try saveToDownload(self, window); - } else { - try saveZip(self, window); - } - Globals.allocator().free(old_path[0..old_path.len]); -} - -/// Default filename (with `.fiz`) for a Save As dialog, derived from the current path. -pub fn defaultSaveAsFilename(allocator: std.mem.Allocator, current_path: []const u8) ![]u8 { - const base = std.fs.path.basename(current_path); - 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, "{s}", .{"untitled.fiz"}); - } - return try std.fmt.allocPrint(allocator, "{s}.fiz", .{stem}); -} - -fn deinitAllUserLayers(self: *File) void { - while (self.layers.len > 0) { - const i = self.layers.len - 1; - var layer = self.layers.get(i); - layer.deinit(); - self.layers.orderedRemove(i); - } -} - -fn clearAnimationsForSaveAs(self: *File) void { - for (self.animations.items(.name)) |n| { - Globals.allocator().free(n); - } - for (self.animations.items(.frames)) |frames| { - Globals.allocator().free(frames); - } - self.animations.clearRetainingCapacity(); - self.deleted_animations.clearRetainingCapacity(); - self.selected_animation_index = null; - self.selected_animation_frame_index = 0; - self.editor.selected_animation_indices.clearRetainingCapacity(); - self.editor.selected_frame_indices.clearRetainingCapacity(); -} - -fn reinitEditorSurfaceForFlatDocument(self: *File) !void { - self.editor.temporary_layer.deinit(); - self.editor.selection_layer.deinit(); - self.editor.transform_layer.deinit(); - self.editor.checkerboard.deinit(); - if (self.editor.checkerboard_tile) |t| { - dvui.textureDestroyLater(t); - self.editor.checkerboard_tile = null; - } - self.editor.selected_sprites.deinit(); - - 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.checkerboard = try std.DynamicBitSet.initEmpty(Globals.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); - self.editor.checkerboard.setValue(i, value); - } - self.editor.selected_layer_indices.clearRetainingCapacity(); - 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 -/// the document with a single layer matching the flattened result. -pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Window) !void { - if (self.isSaving()) return; - self.setSaving(true); - errdefer self.setSaving(false); - - strokeUndoFreeSnapshot(self); - const w = self.width(); - const h = self.height(); - if (w == 0 or h == 0) { - self.setSaving(false); - return error.InvalidImageSize; - } - - 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(Globals.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]); - } - - const ext = std.fs.path.extension(output_path); - const is_png = std.mem.eql(u8, ext, ".png"); - const is_jpg = std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg"); - if (!is_png and !is_jpg) { - self.setSaving(false); - return error.InvalidExtension; - } - - 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(Globals.allocator()); - errdefer out.deinit(); - 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(Globals.allocator()); - errdefer out.deinit(); - try pixelart.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi); - break :blk try out.toOwnedSlice(); - }; - defer Globals.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); - } else { - const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try pixelart.image.writeToJpgPpi(single_layer.source, output_path, ppi); - } - - pixelart.render.destroyLayerCompositeResources(self); - pixelart.render.destroySplitCompositeResources(self); - - deinitAllUserLayers(self); - clearAnimationsForSaveAs(self); - self.sprites.clearRetainingCapacity(); - for (0..self.spriteCount()) |_| { - self.sprites.append(Globals.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]); - self.path = new_path; - self.columns = 1; - self.rows = 1; - self.column_width = w; - self.row_height = h; - self.selected_layer_index = 0; - self.peek_layer_index = null; - self.layers.append(Globals.allocator(), single_layer) catch { - single_layer.deinit(); - return error.LayerCreateError; - }; - - self.history.deinit(); - self.history = .init(Globals.allocator()); - - try reinitEditorSurfaceForFlatDocument(self); - self.editor.layer_composite_dirty = true; - self.editor.split_composite_dirty = true; - self.setSaving(false); - { - // `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 = 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(); -} - -pub const GridLayoutOptions = struct { - column_width: u32, - row_height: u32, - columns: u32, - rows: u32, - 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. - history: bool = true, -}; - -/// 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) !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 Globals.allocator().alloc(u64, layer_count); - errdefer Globals.allocator().free(layer_ids); - - var layer_pixels = try Globals.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 (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); - @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); - for (0..sprite_count) |i| sprite_origins[i] = file.sprites.items(.origin)[i]; - - return .{ - .column_width = file.column_width, - .row_height = file.row_height, - .columns = file.columns, - .rows = file.rows, - .layer_ids = layer_ids, - .layer_pixels = layer_pixels, - .sprite_origins = sprite_origins, - .selected_animation_index = file.selected_animation_index, - .selected_animation_frame_index = file.selected_animation_frame_index, - .selected_layer_index = file.selected_layer_index, - }; -} - -/// 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: 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); - - // Replace each live layer's pixel buffer with the snapshot's. Layers are matched by id so an - // intervening reorder doesn't paint pixels into the wrong layer. - for (0..file.layers.len) |layer_index| { - var live = file.layers.get(layer_index); - const live_id = live.id; - - const snap_idx_opt: ?usize = blk: { - for (snap.layer_ids, 0..) |sid, j| if (sid == live_id) break :blk j; - break :blk null; - }; - - var rebuilt = Layer.init( - live.id, - live.name, - new_w, - new_h, - .{ .r = 0, .g = 0, .b = 0, .a = 0 }, - .ptr, - ) catch return error.LayerCreateError; - rebuilt.visible = live.visible; - rebuilt.collapse = live.collapse; - - if (snap_idx_opt) |j| @memcpy(rebuilt.pixels(), snap.layer_pixels[j]); - - rebuilt.invalidate(); - live.deinit(); - file.layers.set(layer_index, rebuilt); - } - - file.editor.temporary_layer.deinit(); - file.editor.selection_layer.deinit(); - file.editor.transform_layer.deinit(); - 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(Globals.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.checkerboard.deinit(); - file.editor.checkerboard = std.DynamicBitSet.initEmpty(Globals.allocator(), total) catch return error.MemoryAllocationFailed; - for (0..total) |idx| { - const value = pixelart.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); - file.editor.checkerboard.setValue(idx, value); - } - - file.editor.transform = null; - file.selected_animation_index = snap.selected_animation_index; - file.selected_animation_frame_index = snap.selected_animation_frame_index; - if (snap.selected_layer_index < file.layers.len) { - file.selected_layer_index = snap.selected_layer_index; - } - - file.column_width = snap.column_width; - file.row_height = snap.row_height; - file.columns = snap.columns; - file.rows = snap.rows; - - pixelart.render.destroyLayerCompositeResources(file); - file.invalidateActiveLayerTransparencyMaskCache(); -} - -/// Mirrors the export size cap (4096×4096) and rejects degenerate proposals before any allocation. -/// Pure logic lives in `internal/grid_layout_validate.zig` for unit-testability. -pub const validateGridLayoutProposedDims = @import("grid_layout_validate.zig").validateGridLayoutProposedDims; - -pub const GridSliceOptions = struct { - column_width: u32, - row_height: u32, - columns: u32, - rows: u32, - history: bool = true, -}; - -/// Re-tile metadata only: pixel buffers, layer masks, scratch layers, and per-cell artwork are left -/// untouched. Requires `column_width × columns` and `row_height × rows` to match `canvasPixelSize`. -/// Sprite origins are preserved for indices that still exist after the new `columns × rows`; new -/// cells get origin `(0, 0)`. -pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { - if (!validateGridLayoutProposedDims(options.column_width, options.row_height, options.columns, options.rows)) { - return error.InvalidGridLayout; - } - - const canvas = file.canvasPixelSize(); - if (canvas.w == 0 or canvas.h == 0) return error.InvalidGridLayout; - - const prop_w: u32 = options.column_width * options.columns; - const prop_h: u32 = options.row_height * options.rows; - if (prop_w != canvas.w or prop_h != canvas.h) return error.InvalidGridLayout; - - const same = - options.column_width == file.column_width and - options.row_height == file.row_height and - options.columns == file.columns and - options.rows == file.rows; - if (same) return; - - var snapshot_opt: ?History.Change.GridLayout = if (options.history) - try file.captureGridLayoutSnapshot() - else - null; - errdefer if (snapshot_opt) |snap| { - var ch = History.Change{ .grid_layout = snap }; - ch.deinit(); - }; - - const new_cw = options.column_width; - const new_rh = options.row_height; - const new_cols = options.columns; - const new_rows = options.rows; - 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; - - if (new_sprite_count > old_sprite_count) { - var i: usize = old_sprite_count; - while (i < new_sprite_count) : (i += 1) { - file.sprites.items(.origin)[i] = .{ 0, 0 }; - } - } - - 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); - } - file.editor.selected_sprites.deinit(); - file.editor.selected_sprites = new_selected; - - file.column_width = new_cw; - file.row_height = new_rh; - file.columns = new_cols; - file.rows = new_rows; - - pixelart.render.destroyLayerCompositeResources(file); - file.invalidateActiveLayerTransparencyMaskCache(); - - if (snapshot_opt) |snap| { - snapshot_opt = null; - try file.history.append(.{ .grid_layout = snap }); - } -} - -/// Re-grid the document. For every cell present in both the old and new grids, -/// `cellAnchoredBlit` decides how the old `column_width × row_height` tile is composed -/// into the new cell (pad on growth, crop on shrink, mixed axes resolved per-axis). -/// -/// Layer pixel buffers are reallocated; sprite origins, the selected-sprite bitset, and the editor -/// scratch layers (temporary/selection/transform) are rebuilt to the new total size. The composite -/// targets are torn down so they get re-created at the next paint. -/// -/// `applyGridLayout` is destructive: history is **not** repurposed for it (the existing `resize` event -/// captures only width/height and would lose the cell-size delta). Callers should warn before invoking. -pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { - if (!validateGridLayoutProposedDims(options.column_width, options.row_height, options.columns, options.rows)) { - return error.InvalidGridLayout; - } - - const same = - options.column_width == file.column_width and - options.row_height == file.row_height and - options.columns == file.columns and - options.rows == file.rows; - if (same) return; - - // 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: ?History.Change.GridLayout = if (options.history) - try file.captureGridLayoutSnapshot() - else - null; - errdefer if (snapshot_opt) |snap| { - var ch = History.Change{ .grid_layout = snap }; - ch.deinit(); - }; - - const old_cw = file.column_width; - const old_rh = file.row_height; - const old_cols = file.columns; - const old_rows = file.rows; - const old_w: u32 = old_cw * old_cols; - - const new_cw = options.column_width; - const new_rh = options.row_height; - const new_cols = options.columns; - const new_rows = options.rows; - const new_w: u32 = new_cw * new_cols; - const new_h: u32 = new_rh * new_rows; - - // Slice/regrid: when total pixel dims don't change, the pixel buffer is bit-identical and - // the operation is purely metadata + per-cell sprite reset. Going through `cellAnchoredBlit` - // here would be wrong — that function maps cell index → cell index, so e.g. 1×1 → 4×4 of - // the same total size only fills cell (0,0) and zeroes the other 15 cells. - const total_preserved = new_w == old_w and new_h == old_rh * old_rows; - - // For each layer: build a new pixel buffer at the new total size. When total is preserved - // we copy the whole buffer; otherwise re-grid each shared cell through `cellAnchoredBlit`. - for (0..file.layers.len) |layer_index| { - var old_layer = file.layers.get(layer_index); - const old_pix = old_layer.pixels(); - - var new_layer = Layer.init( - old_layer.id, - old_layer.name, - new_w, - new_h, - .{ .r = 0, .g = 0, .b = 0, .a = 0 }, - .ptr, - ) catch return error.LayerCreateError; - new_layer.visible = old_layer.visible; - new_layer.collapse = old_layer.collapse; - - const new_pix = new_layer.pixels(); - - if (total_preserved) { - std.debug.assert(new_pix.len == old_pix.len); - @memcpy(new_pix, old_pix); - } else { - var nrow: u32 = 0; - 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); - if (blk.sw == 0 or blk.sh == 0) continue; - - const src_x0: u32 = ncol * old_cw + blk.sx; - const src_y0: u32 = nrow * old_rh + blk.sy; - const dst_x0: u32 = ncol * new_cw + blk.dx; - const dst_y0: u32 = nrow * new_rh + blk.dy; - - var row: u32 = 0; - while (row < blk.sh) : (row += 1) { - const src_off: usize = (@as(usize, src_y0 + row) * old_w) + src_x0; - const dst_off: usize = (@as(usize, dst_y0 + row) * new_w) + dst_x0; - @memcpy( - new_pix[dst_off .. dst_off + blk.sw], - old_pix[src_off .. src_off + blk.sw], - ); - } - } - } - } - - new_layer.invalidate(); - old_layer.deinit(); - file.layers.set(layer_index, new_layer); - } - - // Editor scratch layers must follow the canvas dimensions or every brush/selection coordinate is wrong. - file.editor.temporary_layer.deinit(); - file.editor.selection_layer.deinit(); - file.editor.transform_layer.deinit(); - 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(Globals.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.checkerboard.deinit(); - 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 = pixelart.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); - file.editor.checkerboard.setValue(idx, value); - } - - // Sprite-bound editor state (animations reference cell indices that may no longer exist; transforms - // reference dimensions). Drop selections rather than guess at remaps. - file.selected_animation_index = null; - file.selected_animation_frame_index = 0; - file.editor.transform = null; - - file.column_width = new_cw; - file.row_height = new_rh; - file.columns = new_cols; - file.rows = new_rows; - - pixelart.render.destroyLayerCompositeResources(file); - file.invalidateActiveLayerTransparencyMaskCache(); - - if (snapshot_opt) |snap| { - snapshot_opt = null; - try file.history.append(.{ .grid_layout = snap }); - } -} - -pub fn saveAsync(self: *File) !void { - if (comptime @import("builtin").target.cpu.arch == .wasm32) { - try self.saveToDownload(dvui.currentWindow()); - return; - } - - //if (!self.dirty()) return; - - if (!hasRecognizedSaveExtension(self.path)) return; - - const ext = std.fs.path.extension(self.path); - - if (isFizzyExtension(ext)) { - // Flip the in-flight flag here on the GUI thread, before submitting to - // the save queue. Otherwise `Editor.tickPendingSaveCloses` can run on the - // next GUI frame, observe `isSaving() == false` (worker hasn't dequeued - // yet), and close the file before the worker reads it. - if (self.isSaving()) return; - self.setSaving(true); - - // 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| { - self.setSaving(false); - return err; - }; - snap_ptr.* = SaveSnapshot.fromFileOnGuiThread(self, Globals.allocator()) catch |err| { - Globals.allocator().destroy(snap_ptr); - self.setSaving(false); - return err; - }; - - // Hand off to the single dedicated save-queue worker. Serializing all - // .fiz writes through one thread (instead of spawning a per-save thread) - // avoids worker-vs-worker contention on allocator / zip lib / dvui state - // that previously wedged one of N concurrent saves indefinitely. - // - // We submit by file id rather than by `*File` pointer: the editor's - // `open_files` AutoArrayHashMap shifts inline values on `orderedRemove`, - // so a stored pointer would silently start aliasing a different file the - // moment any earlier file in the queue completes and gets closed. - save_queue.submit(.{ - .file_id = self.id, - .window = dvui.currentWindow(), - .snap = snap_ptr, - }) catch |err| { - snap_ptr.deinit(Globals.allocator()); - Globals.allocator().destroy(snap_ptr); - self.setSaving(false); - return err; - }; - } else if (std.mem.eql(u8, ext, ".png")) { - // `writeFlattenedLayersToPath` uses `syncLayerComposite` + `readTarget` (GPU); must run on the GUI thread. - try savePng(self, dvui.currentWindow()); - } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - try saveJpg(self, dvui.currentWindow()); - } -} - -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]); - working_layer.visible = self.layers.items(.visible)[i]; - working_layer.collapse = self.layers.items(.collapse)[i]; - } - - for (sprites, 0..) |*sprite, i| { - sprite.origin = self.sprites.items(.origin)[i]; - } - - for (animations, 0..) |*animation, i| { - animation.name = try allocator.dupe(u8, self.animations.items(.name)[i]); - animation.frames = try allocator.dupe(Animation.Frame, self.animations.items(.frames)[i]); - } - - return .{ - .version = pixelart.version, - .columns = self.columns, - .rows = self.rows, - .column_width = self.column_width, - .row_height = self.row_height, - .layers = layers, - .sprites = sprites, - .animations = animations, - }; -} diff --git a/src/plugins/pixelart/src/internal/History.zig b/src/plugins/pixelart/src/internal/History.zig deleted file mode 100644 index 8b9e501a..00000000 --- a/src/plugins/pixelart/src/internal/History.zig +++ /dev/null @@ -1,965 +0,0 @@ -const std = @import("std"); -const zgui = @import("zgui"); -const History = @This(); -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 }; -pub const ChangeType = enum { - pixels, - origins, - animation_name, - animation_frames, - animation_settings, - animation_order, - animation_restore_delete, - layers_order, - layer_restore_delete, - layer_name, - layer_settings, - resize, - reorder_col_row, - reorder_cell, - layer_merge, - grid_layout, -}; - -pub const Change = union(ChangeType) { - pub const Pixels = struct { - layer_id: u64, - indices: []usize, - values: [][4]u8, - temporary: bool = false, - }; - - pub const Origins = struct { - indices: []usize, - values: [][2]f32, - }; - - pub const AnimationName = struct { - index: usize, - name: []u8, - }; - - pub const AnimationSettings = struct { - index: usize, - fps: f32, - }; - - pub const AnimationOrder = struct { - order: []u64, - selected: usize, - }; - - pub const AnimationFrames = struct { - index: usize, - frames: []pixelart.Animation.Frame, - }; - - pub const AnimationRestoreDelete = struct { - index: usize, - action: RestoreDelete, - }; - - pub const LayersOrder = struct { - order: []u64, - selected: usize, - }; - - pub const LayerRestoreDelete = struct { - index: usize, - action: RestoreDelete, - }; - - pub const LayerMerge = struct { - pub const Kind = enum { up, down }; - - kind: Kind, - /// Index of the merged-away layer before removal. - source_index: usize, - dest_layer_id: u64, - source_layer_id: u64, - dest_pixels_before: [][4]u8, - dest_mask_before: std.DynamicBitSet, - }; - pub const LayerName = struct { - index: usize, - name: []u8, - }; - pub const LayerSettings = struct { - index: usize, - visible: bool, - collapse: bool, - }; - - pub const Resize = struct { - width: u32, - height: u32, - }; - - pub const ColumnRowReorder = struct { - pub const Mode = enum { - columns, - rows, - }; - - mode: Mode, - removed_index: usize, - insert_before_index: usize, - }; - - pub const CellReorder = struct { - removed_sprite_indices: []usize, - insert_before_sprite_indices: []usize, - }; - - /// Snapshot of all state that `File.applyGridLayout` mutates. Stored on the undo/redo stacks - /// as the *previous* full state; `undoRedo` swaps the snapshot with the live file state to - /// move forward or back. All slices are owned by the snapshot and freed in `deinit`. - pub const GridLayout = struct { - column_width: u32, - row_height: u32, - columns: u32, - rows: u32, - - /// Layer ids in the order layers existed when the snapshot was captured. Pixel buffers are - /// matched to live layers by id, not index, so layer-list reorderings between snapshots - /// don't corrupt the restore. - layer_ids: []u64, - /// One full pixel buffer per id in `layer_ids`, sized `column_width * columns * row_height * rows`. - layer_pixels: [][][4]u8, - - sprite_origins: [][2]f32, - - selected_animation_index: ?usize, - selected_animation_frame_index: usize, - selected_layer_index: usize, - }; - - pixels: Pixels, - origins: Origins, - animation_name: AnimationName, - animation_frames: AnimationFrames, - animation_settings: AnimationSettings, - animation_order: AnimationOrder, - animation_restore_delete: AnimationRestoreDelete, - layers_order: LayersOrder, - layer_restore_delete: LayerRestoreDelete, - layer_name: LayerName, - layer_settings: LayerSettings, - resize: Resize, - reorder_col_row: ColumnRowReorder, - reorder_cell: CellReorder, - layer_merge: LayerMerge, - grid_layout: GridLayout, - - pub fn create(allocator: std.mem.Allocator, field: ChangeType, len: usize) !Change { - return switch (field) { - .pixels => .{ - .pixels = .{ - .layer_id = 0, - .indices = try allocator.alloc(usize, len), - .values = try allocator.alloc([4]u8, len), - .temporary = false, - }, - }, - .origins => .{ - .origins = .{ - .indices = try allocator.alloc(usize, len), - .values = try allocator.alloc([2]f32, len), - }, - }, - .animation => .{ - .animation = .{ - .index = 0, - .name = undefined, - .fps = 1, - .start = 0, - .length = 1, - }, - }, - .layers_order => .{ .layers_order = .{ - .order = try allocator.alloc(usize, len), - .selected = 0, - } }, - .layer_name => .{ .animation_name = .{ - .name = [_:0]u8{0} ** pixelart.max_name_len, - .index = 0, - } }, - else => error.NotSupported, - }; - } - - pub fn deinit(self: *Change) void { - switch (self.*) { - .pixels => |*pixels| { - Globals.allocator().free(pixels.indices); - Globals.allocator().free(pixels.values); - }, - .origins => |*origins| { - Globals.allocator().free(origins.indices); - Globals.allocator().free(origins.values); - }, - .layers_order => |*layers_order| { - Globals.allocator().free(layers_order.order); - }, - .layer_merge => |*layer_merge| { - Globals.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); - }, - else => {}, - } - } -}; - -bookmark: i32 = 0, -undo_stack: std.array_list.Managed(Change), -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_sprite_data_stack: std.array_list.Managed([][2]f32), -redo_sprite_data_stack: std.array_list.Managed([][2]f32), - -pub fn init(allocator: std.mem.Allocator) History { - return .{ - .undo_stack = std.array_list.Managed(Change).init(allocator), - .redo_stack = std.array_list.Managed(Change).init(allocator), - - .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_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator), - .redo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator), - }; -} - -pub fn append(self: *History, change: Change) !void { - 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) pixelart.perf.nanoTimestamp() else 0; - - if (self.redo_stack.items.len > 0) { - for (self.redo_stack.items) |*c| { - Change.deinit(c); - } - self.redo_stack.clearRetainingCapacity(); - } - - if (self.redo_layer_data_stack.items.len > 0) { - for (self.redo_layer_data_stack.items) |data| { - for (data) |layer| { - Globals.allocator().free(layer); - } - Globals.allocator().free(data); - } - self.redo_layer_data_stack.clearRetainingCapacity(); - } - - // Equality check, don't append if equal - var equal: bool = self.undo_stack.items.len > 0; - if (self.undo_stack.getLastOrNull()) |last| { - const last_active_tag = std.meta.activeTag(last); - const change_active_tag = std.meta.activeTag(change); - - if (last_active_tag == change_active_tag) { - switch (last) { - .origins => |origins| { - if (std.mem.eql(usize, origins.indices, change.origins.indices)) { - for (origins.values, 0..) |value, i| { - if (!std.mem.eql(f32, &value, &change.origins.values[i])) { - equal = false; - break; - } - } - } else { - equal = false; - } - }, - .pixels => |pixels| { - equal = pixels.layer_id == change.pixels.layer_id; - if (equal) { - equal = std.mem.eql(usize, pixels.indices, change.pixels.indices); - } - if (equal) { - for (pixels.values, 0..) |value, i| { - equal = std.mem.eql(u8, &value, &change.pixels.values[i]); - if (!equal) break; - } - } - }, - .animation_name => { - equal = false; - }, - .animation_frames => { - equal = false; - }, - .animation_settings => { - equal = false; - }, - .animation_order => { - equal = false; - }, - .animation_restore_delete => { - equal = false; - }, - .layers_order => { - equal = false; - }, - .layer_restore_delete => { - equal = false; - }, - .layer_name => { - equal = false; - }, - .layer_settings => { - equal = false; - }, - .resize => { - equal = false; - }, - .reorder_col_row => { - equal = false; - }, - .reorder_cell => { - equal = false; - }, - .layer_merge => { - equal = false; - }, - .grid_layout => { - equal = false; - }, - } - } else equal = false; - } - - if (equal) { - var discard = change; - Change.deinit(&discard); - } else { - try self.undo_stack.append(change); - self.bookmark += 1; - } - - 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; - } -} - -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; - - 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.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); - - 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); - file.invalidateActiveLayerTransparencyMaskCache(); -} - -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; - const dest_i = for (file.layers.items(.id), 0..) |id, i| { - if (id == lm.dest_layer_id) break i; - } else return error.InvalidLayerMerge; - - switch (lm.kind) { - .up => if (dest_i + 1 != src_i) return error.InvalidLayerMerge, - .down => if (src_i + 1 != dest_i) return error.InvalidLayerMerge, - } - - var dest = file.layers.get(dest_i); - const src = file.layers.get(src_i); - - for (0..dest.pixels().len) |i| { - const dpx = dest.pixels()[i]; - const spx = src.pixels()[i]; - dest.pixels()[i] = switch (lm.kind) { - .up => Layer.blendPmaSrcOver(dpx, spx), - .down => Layer.blendPmaSrcOver(spx, dpx), - }; - } - dest.mask.setUnion(src.mask); - dest.invalidate(); - file.layers.set(dest_i, dest); - - try file.deleted_layers.append(Globals.allocator(), file.layers.slice().get(src_i)); - file.layers.orderedRemove(src_i); - - file.editor.layer_composite_dirty = true; - file.editor.split_composite_dirty = true; - - file.selected_layer_index = switch (lm.kind) { - .up => dest_i, - .down => dest_i - 1, - }; - 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: *pixelart.internal.File, action: Action) !void { - var active_stack = switch (action) { - .undo => &self.undo_stack, - .redo => &self.redo_stack, - }; - - var other_stack = switch (action) { - .undo => &self.redo_stack, - .redo => &self.undo_stack, - }; - - if (active_stack.items.len == 0) return; - - var temporary: bool = false; - - // Modify this change before its put into the other stack. - var change = active_stack.pop().?; - - defer { - // Microseconds-since-epoch (~1.7e15) overflows a 32-bit `usize` on wasm32, so a - // 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 id = id_mutex.id; - const action_text = switch (action) { - .undo => "Undo:", - .redo => "Redo:", - }; - var message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{action_text}) catch "Invalid change"; - - switch (change) { - .pixels => |*pixels| { - for (file.layers.items(.name), file.layers.items(.id)) |name, layer_id| { - if (layer_id == pixels.layer_id) { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} pixels modified", .{ action_text, name }) catch "Invalid change"; - break; - } - } - }, - .origins => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Sprite origins modified", .{action_text}) catch "Invalid change"; - }, - .animation_name => |*animation_name| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation name {s} -> {s}", .{ - action_text, - animation_name.name, - file.animations.items(.name)[animation_name.index], - }) catch "Invalid change"; - }, - .animation_frames => |*animation_frames| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} frames modified", .{ - action_text, - file.animations.items(.name)[animation_frames.index], - }) catch "Invalid change"; - }, - .animation_settings => |*animation_settings| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} settings modified", .{ - action_text, - file.animations.items(.name)[animation_settings.index], - }) catch "Invalid change"; - }, - .animation_order => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animations order modified", .{action_text}) catch "Invalid change"; - }, - .animation_restore_delete => |*animation_restore_delete| { - switch (animation_restore_delete.action) { - .restore => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} deleted", .{ - action_text, - file.deleted_animations.items(.name)[file.deleted_animations.len - 1], - }) catch "Invalid change"; - }, - .delete => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} created", .{ - action_text, - file.animations.items(.name)[animation_restore_delete.index], - }) catch "Invalid change"; - }, - } - }, - .layers_order => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layers order modified", .{action_text}) catch "Invalid change"; - }, - .layer_restore_delete => |*layer_restore_delete| { - switch (layer_restore_delete.action) { - .restore => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} deleted", .{ - action_text, - file.deleted_layers.items(.name)[file.deleted_layers.len - 1], - }) catch "Invalid change"; - }, - .delete => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} created", .{ - action_text, - file.layers.items(.name)[layer_restore_delete.index], - }) catch "Invalid change"; - }, - } - }, - .layer_name => |*layer_name| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer name {s} -> {s}", .{ - action_text, - layer_name.name, - file.layers.items(.name)[layer_name.index], - }) catch "Invalid change"; - }, - .layer_settings => |*layer_settings| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} settings modified", .{ - action_text, - file.layers.items(.name)[layer_settings.index], - }) catch "Invalid change"; - }, - .resize => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} File resized to {d}x{d}", .{ - action_text, - file.width(), - file.height(), - }) catch "Invalid change"; - }, - .reorder_col_row => |*reorder| { - const removed = reorder.removed_index; - const insert_before = reorder.insert_before_index; - switch (reorder.mode) { - .columns => { - const removed_column_name = file.fmtColumn(dvui.currentWindow().arena(), removed) catch "Invalid change"; - const insert_before_column_name = file.fmtColumn(dvui.currentWindow().arena(), insert_before - 1) catch "Invalid change"; - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Column {s} moved to {s}", .{ action_text, insert_before_column_name, removed_column_name }) catch "Invalid change"; - }, - .rows => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Row {d} moved to {d}", .{ action_text, insert_before, removed }) catch "Invalid change"; - }, - } - }, - .reorder_cell => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Cells reordered", .{action_text}) catch "Invalid change"; - }, - .layer_merge => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer merge", .{action_text}) catch "Invalid change"; - }, - .grid_layout => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Grid layout {d}×{d} cells of {d}×{d}", .{ - action_text, - file.columns, - file.rows, - file.column_width, - file.row_height, - }) catch "Invalid change"; - }, - } - - dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message); - id_mutex.mutex.unlock(dvui.io); - } - - switch (change) { - .pixels => |*pixels| { - if (pixels.temporary) temporary = true; - - const layer_index = for (file.layers.slice().items(.id), 0..) |layer_id, i| { - if (layer_id == pixels.layer_id) break i; - } else 0; - - var layer = file.layers.slice().get(layer_index); - - for (pixels.indices, 0..) |pixel_index, i| { - std.mem.swap([4]u8, &pixels.values[i], &layer.pixels()[pixel_index]); - } - - layer.invalidate(); - file.selected_layer_index = layer_index; - file.invalidateActiveLayerTransparencyMaskCache(); - }, - .origins => |*origins| { - //file.editor.selected_sprites.clearAndFree(); - for (origins.indices, 0..) |sprite_index, i| { - const origin = origins.values[i]; - origins.values[i] = file.sprites.items(.origin)[sprite_index]; - file.sprites.items(.origin)[sprite_index] = origin; - - //try file.editor.selected_sprites.append(sprite_index); - } - 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 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]; - } - - const slice = file.layers.slice(); - - for (layers_order.order, 0..) |id, i| { - if (slice.items(.id)[i] == id) continue; - - // Save current layer - const current_layer = slice.get(i); - layers_order.order[i] = current_layer.id; - - // Make changes to the layers - var other_layer_index: usize = 0; - while (other_layer_index < file.layers.len) : (other_layer_index += 1) { - const layer = slice.get(other_layer_index); - if (layer.id == layers_order.selected) { - file.selected_layer_index = other_layer_index; - } - if (layer.id == id) { - file.layers.set(i, layer); - file.layers.set(other_layer_index, current_layer); - continue; - } - } - } - - @memcpy(layers_order.order, new_order); - Globals.allocator().free(new_order); - file.invalidateActiveLayerTransparencyMaskCache(); - }, - .layer_restore_delete => |*layer_restore_delete| { - file.editor.layer_composite_dirty = true; - file.editor.split_composite_dirty = true; - const a = layer_restore_delete.action; - switch (a) { - .restore => { - 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(Globals.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); - 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); - layer_name.name = name; - Globals.state.host.setActiveSidebarView(plugin.view_tools); - }, - .layer_settings => |*layer_settings| { - const idx = layer_settings.index; - const cur_visible = file.layers.items(.visible)[idx]; - const cur_collapse = file.layers.items(.collapse)[idx]; - const incoming_visible = layer_settings.visible; - const visibility_changed = cur_visible != incoming_visible; - - file.layers.items(.visible)[idx] = incoming_visible; - file.layers.items(.collapse)[idx] = layer_settings.collapse; - layer_settings.visible = cur_visible; - layer_settings.collapse = cur_collapse; - - // Split composites only depend on layer visibility, not row collapse in the layer list. - file.editor.layer_composite_dirty = true; - if (visibility_changed) { - file.editor.split_composite_dirty = true; - } - 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(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(Globals.allocator(), animation); - animation_restore_delete.action = .restore; - - if (file.selected_animation_index) |selected_animation_index| { - if (file.animations.len == 0) { - file.selected_animation_index = null; - } else if (selected_animation_index >= file.animations.len) { - file.selected_animation_index = file.animations.len - 1; - } - } - }, - } - Globals.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); - animation_name.name = name; - Globals.state.host.setActiveSidebarView(plugin.view_sprites); - }, - .animation_settings => {}, - .animation_order => |*animation_order| { - // `new_order` holds animation ids (u64), matching `animation_order.order: []u64`. - var new_order = try dvui.currentWindow().arena().alloc(u64, animation_order.order.len); - for (0..file.animations.len) |anim_index| { - new_order[anim_index] = file.animations.items(.id)[anim_index]; - } - - for (animation_order.order, 0..) |id, i| { - // Save current animation - const current_animation = file.animations.get(i); - animation_order.order[i] = current_animation.id; - - // Make changes to the animations - var other_animation_index: usize = 0; - while (other_animation_index < file.animations.len) : (other_animation_index += 1) { - const animation = file.animations.get(other_animation_index); - if (animation.id == animation_order.selected) { - file.selected_animation_index = other_animation_index; - } - if (animation.id == id and current_animation.id != id) { - file.animations.set(i, animation); - file.animations.set(other_animation_index, current_animation); - continue; - } - } - } - - @memcpy(animation_order.order, new_order); - - file.selected_animation_index = animation_order.selected; - }, - .animation_frames => |*animation_frames| { - 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); - - file.selected_animation_index = animation_frames.index; - }, - .resize => |*resize| { - const new_size_wide = resize.width; - const new_size_high = resize.height; - resize.width = file.width(); - resize.height = file.height(); - - var layer_data: ?[][][4]u8 = null; - var animation_data: ?[][]pixelart.Animation.Frame = null; - var sprite_data: ?[][2]f32 = null; - - switch (action) { - .undo => { - if (self.undo_layer_data_stack.pop()) |ld| { - try self.redo_layer_data_stack.append(ld); - layer_data = ld; - } - - if (self.undo_animation_data_stack.pop()) |ad| { - animation_data = ad; - - var anim_data = try Globals.allocator().alloc([]pixelart.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; - } - try self.redo_animation_data_stack.append(anim_data); - } - - if (self.undo_sprite_data_stack.pop()) |sd| { - sprite_data = sd; - - 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]; - } - try self.redo_sprite_data_stack.append(new_sprite_data); - } - }, - .redo => { - if (self.redo_layer_data_stack.pop()) |ld| { - try self.undo_layer_data_stack.append(ld); - layer_data = ld; - } - if (self.redo_animation_data_stack.pop()) |ad| { - animation_data = ad; - - var anim_data = try Globals.allocator().alloc([]pixelart.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; - } - 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()); - for (0..file.spriteCount()) |sprite_index| { - new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index]; - } - try self.undo_sprite_data_stack.append(new_sprite_data); - } - }, - } - - file.resize(.{ - .columns = @divTrunc(new_size_wide, file.column_width), - .rows = @divTrunc(new_size_high, file.row_height), - .history = false, - .layer_data = layer_data, - .animation_data = animation_data, - .sprite_data = sprite_data, - }) catch return error.ResizeError; - - if (animation_data) |ad| { - Globals.allocator().free(ad); - } - - if (sprite_data) |sd| { - Globals.allocator().free(sd); - } - - file.invalidateActiveLayerTransparencyMaskCache(); - }, - .reorder_col_row => |*reorder| { - switch (reorder.mode) { - .columns => file.reorderColumns(reorder.removed_index, reorder.insert_before_index) catch return error.ReorderError, - .rows => file.reorderRows(reorder.removed_index, reorder.insert_before_index) catch return error.ReorderError, - } - - const prev_removed_index = reorder.removed_index; - const prev_insert_before_index = reorder.insert_before_index; - - if (prev_removed_index < prev_insert_before_index) { - // Column was removed before the insert position, so it "shifts left" after being inserted. - reorder.removed_index = prev_insert_before_index - 1; - reorder.insert_before_index = prev_removed_index; - } else { - reorder.removed_index = prev_insert_before_index; - reorder.insert_before_index = prev_removed_index + 1; - } - - file.invalidateActiveLayerTransparencyMaskCache(); - }, - - .reorder_cell => |*reorder| { - const reverse = (action == .undo); - file.reorderCells(reorder.removed_sprite_indices, reorder.insert_before_sprite_indices, .replace, reverse) catch return error.ReorderError; - file.invalidateActiveLayerTransparencyMaskCache(); - }, - .layer_merge => |*layer_merge| { - switch (action) { - .undo => try layerMergeUndo(file, layer_merge), - .redo => try layerMergeRedo(file, layer_merge), - } - }, - .grid_layout => |*gl| { - // Symmetric swap: capture live state into a fresh snapshot, restore the popped one - // into the file, then put the fresh snapshot in place of the popped one (which is - // now redundant — its data lives in the file again). The fresh snapshot rides the - // normal `append` below to the opposite stack. - const fresh = try file.captureGridLayoutSnapshot(); - file.applyGridLayoutSnapshot(gl.*) catch |err| { - var fresh_ch = Change{ .grid_layout = fresh }; - Change.deinit(&fresh_ch); - return err; - }; - var old_ch = Change{ .grid_layout = gl.* }; - Change.deinit(&old_ch); - gl.* = fresh; - }, - } - - if (!temporary) { - try other_stack.append(change); - } else { - var discard = change; - Change.deinit(&discard); - } - - self.bookmark += switch (action) { - .undo => -1, - .redo => 1, - }; -} - -pub fn clearAndFree(self: *History) void { - for (self.undo_stack.items) |*u| { - Change.deinit(u); - } - for (self.redo_stack.items) |*r| { - Change.deinit(r); - } - self.undo_stack.clearAndFree(); - self.redo_stack.clearAndFree(); -} - -pub fn clearRetainingCapacity(self: *History) void { - for (self.undo_stack.items) |*u| { - Change.deinit(u); - } - for (self.redo_stack.items) |*r| { - Change.deinit(r); - } - self.undo_stack.clearRetainingCapacity(); - self.redo_stack.clearRetainingCapacity(); -} - -pub fn deinit(self: *History) void { - for (self.undo_layer_data_stack.items) |data| { - for (data) |layer| { - Globals.allocator().free(layer); - } - Globals.allocator().free(data); - } - - for (self.redo_layer_data_stack.items) |data| { - for (data) |layer| { - Globals.allocator().free(layer); - } - Globals.allocator().free(data); - } - - self.undo_layer_data_stack.deinit(); - self.redo_layer_data_stack.deinit(); - self.clearAndFree(); - self.undo_stack.deinit(); - self.redo_stack.deinit(); -} diff --git a/src/plugins/pixelart/src/internal/Layer.zig b/src/plugins/pixelart/src/internal/Layer.zig deleted file mode 100644 index 29a2bd66..00000000 --- a/src/plugins/pixelart/src/internal/Layer.zig +++ /dev/null @@ -1,484 +0,0 @@ -const std = @import("std"); -const dvui = @import("dvui"); -const zip = @import("zip"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; - -const Layer = @This(); - -/// Bit count for layer masks must match `pixels(source).len`. Do not use `imageSize` float -/// products: `s.w * s.h` loses integer precision once the true pixel count exceeds ~2^24. -fn pixelCountForSource(source: dvui.ImageSource) usize { - return switch (source) { - .pixelsPMA => |p| @as(usize, @intCast(p.width)) * @as(usize, @intCast(p.height)), - .pixels => |p| @as(usize, @intCast(p.width)) * @as(usize, @intCast(p.height)), - .texture => |t| @as(usize, @intCast(t.width)) * @as(usize, @intCast(t.height)), - .imageFile => |f| blk: { - var w: c_int = undefined; - var h: c_int = undefined; - var n: c_int = undefined; - if (dvui.c.stbi_info_from_memory(f.bytes.ptr, @as(c_int, @intCast(f.bytes.len)), &w, &h, &n) != 1) - break :blk 0; - break :blk @as(usize, @intCast(w)) * @as(usize, @intCast(h)); - }, - }; -} - -id: u64, -name: []const u8, -source: dvui.ImageSource, -mask: std.DynamicBitSet, -visible: bool = true, -collapse: bool = false, -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; - - @memset(p, default_color.toRGBA()); - - return .{ - .id = id, - .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, - .source = .{ - .pixelsPMA = .{ - .rgba = @ptrCast(p), - .width = width, - .height = height, - .interpolation = .nearest, - .invalidation = invalidation, - }, - }, - .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 = 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 = 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 = 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 = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, - .source = source, - .mask = mask, - }; -} - -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; - - return .{ - .id = id, - .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, - .source = source, - .mask = mask, - }; -} - -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; - - return .{ - .id = id, - .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 = 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 = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, - .source = source, - .mask = mask, - }; -} - -pub fn size(self: Layer) dvui.Size { - return dvui.imageSize(self.source) catch .{ .w = 0, .h = 0 }; -} - -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), - .texture => |t| dvui.textureDestroyLater(t), - } - - 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 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 pixelart.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); -} - -/// 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); -} - -/// 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); -} - -/// 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); -} - -/// 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); -} - -/// Sets the mask at the given point -pub fn setMaskPoint(self: *Layer, p: dvui.Point) void { - if (self.pixelIndex(p)) |index| { - self.mask.set(index); - } -} - -/// Clears the layer mask -pub fn clearMask(self: *Layer) void { - self.mask.setRangeValue(.{ .start = 0, .end = self.mask.capacity() }, false); -} - -/// Sets all pixels in the mask that match the given color -pub fn setMaskFromColor(self: *Layer, color: dvui.Color, value: bool) void { - const test_color: [4]u8 = color.toRGBA(); - for (self.pixels(), 0..) |*p, index| { - if (std.meta.eql(test_color, p.*)) { - self.mask.setValue(index, value); - } - } -} - -/// Sets all pixels in the mask that are not transparent -pub fn setMaskFromTransparency(self: *Layer, value: bool) void { - const pix_n = self.pixels().len; - if (self.mask.capacity() != pix_n) { - self.mask.resize(pix_n, false) catch return; - } - for (self.pixels(), 0..) |*p, index| { - if (p[3] != 0) { - self.mask.setValue(index, value); - } - } -} - -/// Sets all pixels in the layer that are in the mask to the given color -pub fn setColorFromMask(self: *Layer, color: dvui.Color) void { - var iter = self.mask.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |index| { - self.pixels()[index] = color.toRGBA(); - } -} - -/// Flood fill a pixel and mark the flood to the mask, so you can handle changes. -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()); - defer queue.deinit(); - queue.append(p) catch return error.MemoryAllocationFailed; - - const directions: [4]dvui.Point = .{ - .{ .x = 0, .y = -1 }, - .{ .x = 0, .y = 1 }, - .{ .x = -1, .y = 0 }, - .{ .x = 1, .y = 0 }, - }; - - if (layer.pixelIndex(p)) |index| { - layer.mask.setValue(index, value); - const original_color = layer.pixels()[index]; - - while (queue.pop()) |qp| { - for (directions) |direction| { - const new_point = qp.plus(direction); - if (layer.pixelIndex(new_point)) |iter_index| { - if (layer.mask.isSet(iter_index)) continue; - if (!std.meta.eql(original_color, layer.pixels()[iter_index])) continue; - if (!bounds.contains(new_point)) continue; - - queue.append(new_point) catch return error.MemoryAllocationFailed; - layer.mask.setValue(iter_index, value); - } - } - } - } -} - -pub fn setPixelIndex(self: *Layer, index: usize, color: [4]u8) void { - pixelart.image.setPixelIndex(self.source, index, color); -} - -pub const ShapeOffsetResult = struct { - index: usize, - color: [4]u8, - point: dvui.Point, -}; - -pub fn invalidate(self: *Layer) void { - dvui.textureInvalidateCache(self.source.hash()); - self.dirty = false; -} - -/// 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); - - if (s == 1) { - if (current_index != 0) - return null; - - if (self.pixelIndex(origin)) |index| { - return .{ - .index = index, - .color = self.pixels()[index], - .point = origin, - }; - } - } - - const size_center_offset: i32 = -@divFloor(@as(i32, @intCast(s)), 2); - const index_i32: i32 = @as(i32, @intCast(current_index)); - const pixel_offset: [2]i32 = .{ @mod(index_i32, s) + size_center_offset, @divFloor(index_i32, s) + size_center_offset }; - - if (shape == .circle) { - const extra_pixel_offset_circle: [2]i32 = if (@mod(s, 2) == 0) .{ 1, 1 } else .{ 0, 0 }; - const pixel_offset_circle: [2]i32 = .{ pixel_offset[0] * 2 + extra_pixel_offset_circle[0], pixel_offset[1] * 2 + extra_pixel_offset_circle[1] }; - const sqr_magnitude = pixel_offset_circle[0] * pixel_offset_circle[0] + pixel_offset_circle[1] * pixel_offset_circle[1]; - - // adjust radius check for nicer looking circles - const radius_check_mult: f32 = (if (s == 3 or s > 10) 0.7 else 0.8); - - if (@as(f32, @floatFromInt(sqr_magnitude)) > @as(f32, @floatFromInt(s * s)) * radius_check_mult) { - return null; - } - } - - const pixel_i32: [2]i32 = .{ @as(i32, @intFromFloat(origin.x)) + pixel_offset[0], @as(i32, @intFromFloat(origin.y)) + pixel_offset[1] }; - const size_i32: [2]i32 = .{ @as(i32, @intFromFloat(self.size().w)), @as(i32, @intFromFloat(self.size().h)) }; - - if (pixel_i32[0] < 0 or pixel_i32[1] < 0 or pixel_i32[0] >= size_i32[0] or pixel_i32[1] >= size_i32[1]) { - return null; - } - - const p: dvui.Point = .{ .x = @floatFromInt(pixel_i32[0]), .y = @floatFromInt(pixel_i32[1]) }; - - if (self.pixelIndex(p)) |index| { - return .{ - .index = index, - .color = self.pixels()[index], - .point = p, - }; - } - - return null; -} - -/// 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 fn clearRect(self: *Layer, rect: dvui.Rect) void { - pixelart.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); - self.invalidate(); -} - -pub const BlitOptions = struct { - transparent: bool = true, - mask: bool = false, -}; - -pub fn blit(self: *Layer, src_pixels: [][4]u8, dst_rect: dvui.Rect, options: BlitOptions) void { - if (src_pixels.len != @as(usize, @intFromFloat(dst_rect.w)) * @as(usize, @intFromFloat(dst_rect.h))) { - dvui.log.err("Source pixel length {d} does not match destination rectangle size {any}", .{ src_pixels.len, dst_rect }); - return; - } - const self_size = self.size(); - - const x = @as(usize, @intFromFloat(dst_rect.x)); - const y = @as(usize, @intFromFloat(dst_rect.y)); - const width = @as(usize, @intFromFloat(dst_rect.w)); - const height = @as(usize, @intFromFloat(dst_rect.h)); - - const tex_width = @as(usize, @intFromFloat(self_size.w)); - - var yy = y; - var h = height; - - var d = self.pixels()[x + yy * tex_width .. x + yy * tex_width + width]; - var src_y: usize = 0; - while (h > 0) { - h -= 1; - const src_row = src_pixels[src_y * width .. (src_y * width) + width]; - if (!options.transparent) { - if (options.mask) { - self.mask.setRangeValue( - .{ .start = x + yy * tex_width, .end = x + yy * tex_width + width }, - true, - ); - } - - @memcpy(d, src_row); - } else { - for (src_row, d, 0..) |src, *dst, index| { - if (src[3] > 0) { - if (options.mask) - self.mask.set(x + yy * tex_width + index); - - dst.* = src; - } - } - } - - // next row and move our slice to it as well - src_y += 1; - yy += 1; - - const next_row_start = x + yy * tex_width; - const next_row_end = next_row_start + width; - // Exclusive end must satisfy `next_row_end <= len` (`== len` is valid for the bottom row). - if (next_row_start < self.pixels().len and next_row_end <= self.pixels().len) { - d = self.pixels()[next_row_start..next_row_end]; - } - } - self.invalidate(); -} - -pub fn clear(self: *Layer) void { - @memset(self.pixels(), .{ 0, 0, 0, 0 }); - self.invalidate(); - self.dirty = false; -} - -pub fn writeSourceToZip( - layer: *const Layer, - zip_file: ?*anyopaque, - resolution: u32, -) !void { - 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(Globals.state.host.arena()); - - 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)); - } -} - -pub fn writeSourceToPng(layer: *const Layer, path: []const u8) !void { - return pixelart.fs.writeSourceToPng(layer.source, path); -} - -pub fn resize(layer: *Layer, new_size: dvui.Size) !void { - const layer_size = layer.size(); - if (layer_size.w == new_size.w and layer_size.h == new_size.h) return; - - var new_layer = Layer.init( - layer.id, - 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 }, - .ptr, - ) catch return error.MemoryAllocationFailed; - - new_layer.blit(layer.pixelsFromRect(dvui.currentWindow().arena(), .{ - .x = 0, - .y = 0, - .w = new_size.w, - .h = new_size.h, - }) orelse return error.MemoryAllocationFailed, .{ - .x = 0, - .y = 0, - .w = new_size.w, - .h = new_size.h, - }, .{}); - - new_layer.invalidate(); - - layer.deinit(); - layer.* = new_layer; -} - -/// 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 -/// 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, .{ - .x = @intFromFloat(src.x), - .y = @intFromFloat(src.y), - .w = @intFromFloat(src.w), - .h = @intFromFloat(src.h), - }) orelse return null; - - return .{ - .x = @floatFromInt(r.x), - .y = @floatFromInt(r.y), - .w = @floatFromInt(r.w), - .h = @floatFromInt(r.h), - }; -} diff --git a/src/plugins/pixelart/src/internal/Palette.zig b/src/plugins/pixelart/src/internal/Palette.zig deleted file mode 100644 index bc15b826..00000000 --- a/src/plugins/pixelart/src/internal/Palette.zig +++ /dev/null @@ -1,52 +0,0 @@ -const std = @import("std"); -const dvui = @import("dvui"); - -const palette_parse = @import("palette_parse.zig"); -const pixelart = @import("../../pixelart.zig"); -const Globals = pixelart.Globals; - -pub const Palette = @This(); - -name: []const u8, -colors: [][4]u8, - -pub fn getDVUIColor(self: *Palette, id: usize) dvui.Color { - if (self.colors.len == 0) return .magenta; - const new_id = id % self.colors.len; - return .{ .r = self.colors[new_id][0], .g = self.colors[new_id][1], .b = self.colors[new_id][2], .a = self.colors[new_id][3] }; -} - -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); - - return loadFromBytes(allocator, std.fs.path.basename(file), read); - } - } - return error.WrongFileType; -} - -pub fn loadFromBytes(allocator: std.mem.Allocator, name: []const u8, bytes: []const u8) !Palette { - const colors = palette_parse.parseHexBytes(allocator, bytes) catch |err| { - switch (err) { - error.InvalidHexLine => { - dvui.log.err("Failed to parse palette: invalid hex line", .{}); - return error.FailedToParseColor; - }, - error.OutOfMemory => return error.OutOfMemory, - } - }; - - return .{ - .name = try allocator.dupe(u8, name), - .colors = colors, - }; -} - -pub fn deinit(self: *Palette) void { - Globals.allocator().free(self.name); - Globals.allocator().free(self.colors); -} diff --git a/src/plugins/pixelart/src/internal/Sprite.zig b/src/plugins/pixelart/src/internal/Sprite.zig deleted file mode 100644 index ec3c3e90..00000000 --- a/src/plugins/pixelart/src/internal/Sprite.zig +++ /dev/null @@ -1 +0,0 @@ -origin: [2]f32 = .{ 0.0, 0.0 }, diff --git a/src/plugins/pixelart/src/internal/grid_layout_validate.zig b/src/plugins/pixelart/src/internal/grid_layout_validate.zig deleted file mode 100644 index 66686e8f..00000000 --- a/src/plugins/pixelart/src/internal/grid_layout_validate.zig +++ /dev/null @@ -1,70 +0,0 @@ -//! Pure validation predicate for proposed grid-layout changes. -//! -//! Mirrors the export size cap (4096×4096) and rejects degenerate proposals before any -//! allocation. Lives in its own std-only file so the editor's grid-layout dialog and -//! `Internal.File.applyGridLayout{,SliceOnly}` can share a single source of truth that's -//! also reachable from `zig build test` without dvui / fizzy globals. -//! -//! Re-exported by `Internal.File.validateGridLayoutProposedDims` for backward compatibility -//! with existing call sites. - -const std = @import("std"); - -/// Maximum exported atlas dimension. Keep aligned with `tools/Packer.zig`'s texture size -/// table — that table tops out at 8192 along the long axis but the editor caps single-axis -/// document size at 4096 so the document can always be exported into a single atlas page. -pub const max_axis: u32 = 4096; - -pub fn validateGridLayoutProposedDims( - column_width: u32, - row_height: u32, - columns: u32, - rows: u32, -) bool { - if (column_width == 0 or row_height == 0 or columns == 0 or rows == 0) return false; - const total_w: u64 = @as(u64, column_width) * @as(u64, columns); - const total_h: u64 = @as(u64, row_height) * @as(u64, rows); - if (total_w == 0 or total_h == 0) return false; - if (total_w > max_axis or total_h > max_axis) return false; - return true; -} - -const expect = std.testing.expect; - -test "rejects any zero dimension" { - try expect(!validateGridLayoutProposedDims(0, 16, 1, 1)); - try expect(!validateGridLayoutProposedDims(16, 0, 1, 1)); - try expect(!validateGridLayoutProposedDims(16, 16, 0, 1)); - try expect(!validateGridLayoutProposedDims(16, 16, 1, 0)); -} - -test "accepts the smallest non-zero proposal" { - try expect(validateGridLayoutProposedDims(1, 1, 1, 1)); -} - -test "accepts a tile-sliced layout that fits inside the cap" { - try expect(validateGridLayoutProposedDims(32, 32, 16, 16)); // 512×512 - try expect(validateGridLayoutProposedDims(64, 64, 32, 32)); // 2048×2048 -} - -test "accepts the largest dimension exactly at the cap" { - try expect(validateGridLayoutProposedDims(max_axis, max_axis, 1, 1)); - try expect(validateGridLayoutProposedDims(1, 1, max_axis, max_axis)); -} - -test "rejects when total width exceeds the cap by one" { - try expect(!validateGridLayoutProposedDims(max_axis + 1, 1, 1, 1)); - try expect(!validateGridLayoutProposedDims(max_axis, 1, 2, 1)); -} - -test "rejects when total height exceeds the cap by one" { - try expect(!validateGridLayoutProposedDims(1, max_axis + 1, 1, 1)); - try expect(!validateGridLayoutProposedDims(1, max_axis, 1, 2)); -} - -test "rejects multiplications that would overflow u32 (defensive — uses u64 internally)" { - // column_width * columns would wrap to 0 in u32 arithmetic for these inputs; the predicate - // must still reject (not silently accept the wrapped value). - try expect(!validateGridLayoutProposedDims(0xFFFF_FFFF, 1, 2, 1)); - try expect(!validateGridLayoutProposedDims(1, 0xFFFF_FFFF, 1, 2)); -} \ No newline at end of file diff --git a/src/plugins/pixelart/src/internal/layer_order.zig b/src/plugins/pixelart/src/internal/layer_order.zig deleted file mode 100644 index 8cb655f9..00000000 --- a/src/plugins/pixelart/src/internal/layer_order.zig +++ /dev/null @@ -1,115 +0,0 @@ -//! Pure layer-list reorder algorithm. -//! -//! Extracted from `internal/File.zig` so `zig build test` can exercise -//! it without pulling in dvui / fizzy globals. Re-exported by File.zig -//! as `layerOrderAfterMove`. -//! -//! Mirrors the drop-handling logic in `editor/explorer/tools.zig`: given -//! a logical "remove element at index `removed`, then insert it before -//! position `insert_before`" operation, fill `out[0..len]` with the new -//! position-to-storage-index mapping (position 0 = top of stack). - -const std = @import("std"); - -/// Maximum list length supported by the in-place implementation. -/// Layers in fizzy are bounded well below this; raising it just bumps -/// the stack-allocated scratch buffer. -pub const max_len: usize = 1024; - -pub fn layerOrderAfterMove( - len: usize, - removed: usize, - insert_before: usize, - out: []usize, -) void { - std.debug.assert(out.len >= len); - std.debug.assert(removed < len); - std.debug.assert(insert_before <= len); - if (removed == insert_before) { - for (0..len) |i| out[i] = i; - return; - } - const insert_pos = if (removed < insert_before) insert_before - 1 else insert_before; - var tmp: [max_len]usize = undefined; - std.debug.assert(len <= tmp.len); - var m: usize = 0; - for (0..len) |i| { - if (i == removed) continue; - tmp[m] = i; - m += 1; - } - var ti: usize = 0; - for (0..len) |dst| { - if (dst == insert_pos) { - out[dst] = removed; - } else { - out[dst] = tmp[ti]; - ti += 1; - } - } -} - -const expectEqualSlices = std.testing.expectEqualSlices; - -test "layerOrderAfterMove no-op when removed == insert_before" { - var out: [5]usize = undefined; - layerOrderAfterMove(5, 2, 2, &out); - try expectEqualSlices(usize, &.{ 0, 1, 2, 3, 4 }, &out); -} - -test "layerOrderAfterMove first to last" { - // Move element 0 to insert before position 5 (i.e. the end). - var out: [5]usize = undefined; - layerOrderAfterMove(5, 0, 5, &out); - try expectEqualSlices(usize, &.{ 1, 2, 3, 4, 0 }, &out); -} - -test "layerOrderAfterMove last to first" { - // Move element 4 to the very front. - var out: [5]usize = undefined; - layerOrderAfterMove(5, 4, 0, &out); - try expectEqualSlices(usize, &.{ 4, 0, 1, 2, 3 }, &out); -} - -test "layerOrderAfterMove forward middle" { - // Move index 1 to before position 4 (slides past indexes 2 and 3). - // Because removed (1) < insert_before (4), the insert position - // collapses to 4 - 1 = 3 after the removal. - var out: [5]usize = undefined; - layerOrderAfterMove(5, 1, 4, &out); - try expectEqualSlices(usize, &.{ 0, 2, 3, 1, 4 }, &out); -} - -test "layerOrderAfterMove backward middle" { - // Move index 3 to before position 1 (i.e. earlier in the list). - var out: [5]usize = undefined; - layerOrderAfterMove(5, 3, 1, &out); - try expectEqualSlices(usize, &.{ 0, 3, 1, 2, 4 }, &out); -} - -test "layerOrderAfterMove single-element list is a no-op" { - var out: [1]usize = undefined; - layerOrderAfterMove(1, 0, 0, &out); - try expectEqualSlices(usize, &.{0}, &out); -} - -test "layerOrderAfterMove permutation is always a valid permutation" { - // Every output should be a permutation of 0..len. Sweep all - // (removed, insert_before) pairs for a small list. - const len: usize = 6; - var out: [len]usize = undefined; - var seen: [len]bool = undefined; - var removed: usize = 0; - while (removed < len) : (removed += 1) { - var insert_before: usize = 0; - while (insert_before <= len) : (insert_before += 1) { - layerOrderAfterMove(len, removed, insert_before, &out); - @memset(&seen, false); - for (out) |idx| { - try std.testing.expect(idx < len); - try std.testing.expect(!seen[idx]); - seen[idx] = true; - } - } - } -} diff --git a/src/plugins/pixelart/src/internal/palette_parse.zig b/src/plugins/pixelart/src/internal/palette_parse.zig deleted file mode 100644 index fd28c50c..00000000 --- a/src/plugins/pixelart/src/internal/palette_parse.zig +++ /dev/null @@ -1,112 +0,0 @@ -//! Pure parser for `.hex` palette files. -//! -//! Extracted from `internal/Palette.zig` so `zig build test` can -//! exercise it without pulling in dvui / fizzy globals. -//! -//! The `.hex` format is one 6-digit RRGGBB hex color per line. Empty -//! lines and lines beginning with `#` (comments) are ignored. The -//! parser intentionally accepts both LF (`\n`) and CRLF line endings; -//! the historical implementation depended on a trailing newline, but -//! this version handles a missing trailing newline gracefully too. - -const std = @import("std"); - -pub const Error = error{ - InvalidHexLine, - OutOfMemory, -}; - -const PackedColor = packed struct(u32) { r: u8, g: u8, b: u8, a: u8 }; - -/// Parse `bytes` as a `.hex` palette file into a heap-allocated slice -/// of RGBA colors. Returns `Error.InvalidHexLine` on the first line -/// that is non-empty, non-comment, and fails to parse as a 6-digit -/// hex value. -/// -/// The caller owns the returned slice and must free it with the same -/// allocator passed in. -pub fn parseHexBytes(allocator: std.mem.Allocator, bytes: []const u8) Error![][4]u8 { - var colors = std.array_list.Managed([4]u8).init(allocator); - errdefer colors.deinit(); - - var iter = std.mem.splitSequence(u8, bytes, "\n"); - while (iter.next()) |raw_line| { - const line = trimLine(raw_line); - if (line.len == 0) continue; - if (line[0] == '#') continue; - - const color_u32 = std.fmt.parseInt(u32, line, 16) catch { - return Error.InvalidHexLine; - }; - const packed_color: PackedColor = @bitCast(color_u32); - // The original loader byte-shuffles to {b, g, r, 255}; preserve - // that exactly so existing palettes load identically. - try colors.append(.{ packed_color.b, packed_color.g, packed_color.r, 255 }); - } - - return colors.toOwnedSlice() catch return Error.OutOfMemory; -} - -/// Trim trailing CR (handles CRLF input) and surrounding whitespace. -fn trimLine(line: []const u8) []const u8 { - return std.mem.trim(u8, line, " \t\r"); -} - -const expectEqual = std.testing.expectEqual; -const expectEqualSlices = std.testing.expectEqualSlices; - -test "parseHexBytes parses 4 valid hex lines" { - const bytes = "112233\n445566\nAABBCC\nDEADBE\n"; - const colors = try parseHexBytes(std.testing.allocator, bytes); - defer std.testing.allocator.free(colors); - - try expectEqual(@as(usize, 4), colors.len); - for (colors) |c| try expectEqual(@as(u8, 255), c[3]); - - // Verify the historical byte-shuffle: line "112233" produces the - // BGR-swapped triple {0x33, 0x22, 0x11, 0xff}. - try expectEqualSlices(u8, &.{ 0x33, 0x22, 0x11, 0xff }, &colors[0]); - try expectEqualSlices(u8, &.{ 0x66, 0x55, 0x44, 0xff }, &colors[1]); - try expectEqualSlices(u8, &.{ 0xcc, 0xbb, 0xaa, 0xff }, &colors[2]); - try expectEqualSlices(u8, &.{ 0xbe, 0xad, 0xde, 0xff }, &colors[3]); -} - -test "parseHexBytes ignores blank lines and comments" { - const bytes = - \\# fizzy default palette - \\ - \\112233 - \\# another comment - \\445566 - \\ - ; - const colors = try parseHexBytes(std.testing.allocator, bytes); - defer std.testing.allocator.free(colors); - try expectEqual(@as(usize, 2), colors.len); -} - -test "parseHexBytes accepts CRLF line endings" { - const bytes = "112233\r\n445566\r\n"; - const colors = try parseHexBytes(std.testing.allocator, bytes); - defer std.testing.allocator.free(colors); - try expectEqual(@as(usize, 2), colors.len); -} - -test "parseHexBytes accepts a trailing line without newline" { - const bytes = "112233\n445566"; - const colors = try parseHexBytes(std.testing.allocator, bytes); - defer std.testing.allocator.free(colors); - try expectEqual(@as(usize, 2), colors.len); -} - -test "parseHexBytes returns InvalidHexLine on malformed input" { - const bytes = "112233\nNOTHEX\n"; - const result = parseHexBytes(std.testing.allocator, bytes); - try std.testing.expectError(Error.InvalidHexLine, result); -} - -test "parseHexBytes on empty input returns an empty slice" { - const colors = try parseHexBytes(std.testing.allocator, ""); - defer std.testing.allocator.free(colors); - try expectEqual(@as(usize, 0), colors.len); -} diff --git a/src/plugins/pixelart/src/keybind_ticks.zig b/src/plugins/pixelart/src/keybind_ticks.zig deleted file mode 100644 index fff4a09f..00000000 --- a/src/plugins/pixelart/src/keybind_ticks.zig +++ /dev/null @@ -1,82 +0,0 @@ -//! 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/pack_project.zig b/src/plugins/pixelart/src/pack_project.zig deleted file mode 100644 index 303c1c64..00000000 --- a/src/plugins/pixelart/src/pack_project.zig +++ /dev/null @@ -1,236 +0,0 @@ -//! 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/panel/sprites.zig b/src/plugins/pixelart/src/panel/sprites.zig deleted file mode 100644 index f2b6b06e..00000000 --- a/src/plugins/pixelart/src/panel/sprites.zig +++ /dev/null @@ -1,1329 +0,0 @@ -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 Sprites = @This(); - -/// Side-card fly-out / fly-in master timeline (microseconds, linear 0↔1). -const fly_anim_duration_us: i64 = 750_000; -/// Normalised fly speed below which a card stops stirring the water. -const ripple_vel_dead: f32 = 0.06; -/// Per-slot reflection bookkeeping arrays are indexed by `it.d + field_center`. -const max_refl_ripple_slots: usize = wsurf.max_slots; -/// Mean per-cell surface energy below which the water is settled (stop refreshing). -const water_settle_energy: f32 = 0.006; -/// Fly motion → velocity impulse (normalised fly speed × k). -const water_stir_k: f32 = 68.0; -/// Inertial drag wake: velocity impulse per unit change in shelf speed (slots/s), -/// injected as a localized splash (like fly-in) so it ripples rather than uniformly -/// shrinking the reflections. Acceleration-driven, so small quick shakes read big -/// and an abrupt stop throws a forward wake, while a steady drag stays calm. -const water_drag_k: f32 = 20.0; -/// Inject radius (field columns) for the drag wake. Wider than a point so the -/// per-frame scroll stir excites smooth, propagating ripples instead of grid-scale -/// spikes (a 1-cell impulse shimmers in place; a broad bump travels and reads watery). -const water_drag_radius: f32 = 10.0; -/// Steady drag/coast bow wake: dv-only injects vanish at constant speed, so a small -/// per-frame velocity stir keeps ripples visible under the finger (scaled by dt). -const water_scroll_bow_k: f32 = 3.2; -/// Extra reflection refraction while the shelf is actively moving. -const water_scroll_disp_boost: f32 = 1.25; -/// Fly-out transition splash at the centre (velocity impulse). -const water_fly_out_impulse: f32 = -10.5; -/// Fly-in: card bottom is `baseline_y - fly_offset`; ripple only once this close. -/// Fly-out: stir while the card is still near the line as it lifts away. -const water_fly_out_near_k: f32 = 0.22; -/// Downward velocity impulse when a flown-in card splashes back through the waterline. -const water_land_impulse: f32 = -20.0; -/// Surface slope → horizontal refraction at the waterline (fraction of card height). -const water_disp_k: f32 = 1.1; -/// Fly stir / splash: Gaussian radius as a fraction of one card's field span. -/// Wider + more taps than a point inject — spreads energy instead of column bars. -const water_fly_stir_radius_frac: f32 = 0.24; -/// Fly in/out impulses are scaled down vs scroll/drag — staggered lifts otherwise -/// over-drive the surface and pull the reflection seam up. -const water_fly_impulse_scale: f32 = 0.36; -/// Reflection wobble while `fly_t > 0` (field still ripples, seam rise reads softer). -const water_fly_refl_scale: f32 = 0.40; -/// Fly stir velocity dead-zone — higher than scroll so only brisk line contact stirs. -const water_fly_vel_dead: f32 = 0.11; -/// Scroll wake spread (slots): the head-on cards each carve their own wake, fading -/// to nothing ~this many slots out, so ripples emanate from every card the shelf -/// drags across instead of one point at the focus. -const wake_spread_slots: f32 = 3.5; - -/// Per-card scroll-wake weight by screen offset — 1 at the focus, linear to 0 at -/// `wake_spread_slots`. Used to distribute the shelf's stir across the visible cards. -fn wakeWeight(off: f32) f32 { - return @max(0.0, 1.0 - @abs(off) / wake_spread_slots); -} - -const FlowItem = struct { idx: usize, off: f32, d: i64, id: usize, center: bool }; - -/// Spread a fly velocity impulse across a card's width with per-card phase so -/// staggered fly-in/out stirs don't line up as slot-column vertical bars. -fn injectFlyRipple(water: *wsurf.WaterSurface, slot_d: i64, vel_strength: f32, phase: f32) void { - const strength = vel_strength * water_fly_impulse_scale; - if (@abs(strength) < 0.0001) return; - const left = wsurf.slotLeftCol(slot_d); - const span: f32 = @as(f32, @floatFromInt(wsurf.cols_per_slot)); - const r = span * water_fly_stir_radius_frac; - const wobble = std.math.sin(phase + @as(f32, @floatFromInt(slot_d)) * 2.17) * span * 0.11; - const taps = [_]struct { t: f32, w: f32 }{ - .{ .t = 0.18, .w = 0.28 }, - .{ .t = 0.40, .w = 0.26 }, - .{ .t = 0.62, .w = 0.24 }, - .{ .t = 0.82, .w = 0.22 }, - }; - for (taps) |tap| { - water.inject(left + tap.t * span + wobble, r, 0, strength * tap.w); - } -} - -/// True once a flying side card's bottom has reached the shared waterline. -fn flyCardTouchesWater(fly_offset: f32, fly_anim_out: bool, max_fly_off: f32) bool { - if (fly_anim_out) return fly_offset < max_fly_off * water_fly_out_near_k; - return fly_offset <= 0.0; -} - -const CardDraw = struct { - item: FlowItem, - rect: dvui.Rect, - w: f32, - h: f32, - depth: f32, - opacity: f32, - item_scale: f32, - fly_offset: f32, - off: f32, - is_focus: bool, -}; - -/// Stable widget id for a cover-flow slot (sprite draw + reflection ripple share this). -const SpriteSlot = struct { - fn src() std.builtin.SourceLocation { - return @src(); - } - fn id(id_extra: usize) dvui.Id { - return dvui.parentGet().extendId(src(), id_extra); - } -}; - -/// 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: pixelart.Fling.Tuning = .{ - .decay = 4.0, - .min_start = 1.2, - .stop = 0.6, - .max = 50.0, - .idle_s = 0.12, -}; -/// Window the mouse/trackpad release velocity is averaged over (s). -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 = .{ - .decay = 4.0, - .min_start = 0.6, - .stop = 0.6, - .max = 50.0, - .idle_s = 0.2, -}; -/// Window the touch release velocity is averaged over (s). -const sprite_fling_touch_window_s: f32 = 0.1; -/// Upper bound on the per-frame delta fed to the passive cover-flow ease. -const max_ease_dt: f32 = 1.0 / 30.0; -/// Extra skewed shelf slots drawn beyond the pane-fit estimate (each side). -const shelf_edge_extra: i64 = 2; -/// Slot distance past `flat_zone` over which skewed shelf cards fade out. Kept -/// short so a wide pane doesn't spread a gentle fade across the whole window. -const shelf_opacity_fade_span: f32 = 2.5 + @as(f32, @floatFromInt(shelf_edge_extra)); -/// Pane-edge distance (in card widths) over which cards fade to transparent. -const shelf_edge_fade_w: f32 = 2.5; -/// Below this opacity a non-focus card is invisible — skip building/rendering its -/// (expensive O(n²)) reflection mesh entirely rather than drawing it transparent. -const card_cull_opacity: f32 = 0.012; -/// Reflection mesh density for a fully-skewed shelf card, as a fraction of the -/// head-on (focus) density. The head-on three cards render at full detail; skewed -/// cards ramp down to this so the off-axis shelf stays cheap on slower GPUs. -const skewed_reflection_detail: f32 = 0.3; -/// Draw an on-screen readout of the last touch fling decision (velocity / idle / coast) -/// so the touch-only momentum can be tuned on a real device. Set false to hide. -const debug_touch_fling = false; - -// Animated fit-scale state (shared, like a singleton preview). -var prev_scale: f32 = 1.0; -var current_scale: f32 = 1.0; - -// ---- Cover-flow state (persisted on the Panel's Sprites instance) ---- -/// Current fractional center index that the flow is rendered around. The sprite -/// nearest this value is drawn flat and on top; neighbours rotate away like -/// records on a shelf. -scroll_pos: f32 = 0.0, -/// Index the flow is easing toward. Driven either by the editor selection or by -/// the user scrolling/dragging the flow itself. -goal: f32 = 0.0, -/// Last virtual center index we observed from the rest of the editor, so we -/// can tell an external selection change apart from one we caused ourselves. -last_sel_virtual: usize = std.math.maxInt(usize), -/// Last virtual index we pushed into editor state from the cover flow. -last_committed_virtual: usize = std.math.maxInt(usize), -/// Accumulates fractional wheel deltas until they cross a whole step. -wheel_accum: f32 = 0.0, -/// True only on frames where the user is actively dragging the flow. -drag_active: bool = false, -/// Whether the pointer moved between press and release (drag vs. click). -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 = .{}, -/// 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 / -/// fly-in transition the frame it flips. While flown, the side cards lift up -/// out of view so only the focused card shows (less distracting). -was_flown: bool = false, -/// Direction of the in-flight `play_fly` animation (outBack vs inBack). -fly_anim_out: bool = false, -/// Shared water surface (slot space) all reflections ripple in. See `water_surface.zig`. -water: wsurf.WaterSurface = .{}, -/// Focused slot index last frame — re-anchors the water field as the shelf scrolls. -prev_center_i: i64 = 0, -/// Per-slot previous fly offset for velocity estimation (indexed by `d + field_center`). -prev_fly_offset: [max_refl_ripple_slots]f32 = .{0} ** max_refl_ripple_slots, -/// Per-slot: card dipped below the waterline (fly-in inBack overshoot), awaiting a splash. -was_dipping: [max_refl_ripple_slots]bool = .{false} ** max_refl_ripple_slots, -/// Previous `scroll_pos` — the per-frame delta drives the inertial slosh. -prev_scroll_pos: f32 = 0.0, -/// Smoothed shelf velocity (slots/s); its per-frame change tilts the water. -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) { - return; - } - - self.drawAnimationControlsDialog(); - - // 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 }); - } - - const parent = dvui.parentGet().data().rect; - const parent_height = parent.h; - - const mode = scrollMode(file); - const count = scrollCount(file, mode); - if (count == 0) { - return; - } - - // ---- Fly-out / fly-in master timeline. `fly_t` runs 0 (all cards at - // rest) → 1 (side cards lifted out of view) as a linear master clock; each - // card derives a staggered, eased offset from it below. We flip the target - // the frame playback starts/stops. ---- - const playing = file.editor.playing; - const flown = sideCardsFlown(playing); - const panel_id = dvui.parentGet().data().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; - dvui.animation(panel_id, "play_fly", .{ - .end_time = fly_anim_duration_us, - .easing = dvui.easing.linear, - .start_val = cur, - .end_val = if (flown) 1.0 else 0.0, - }); - if (flown) { - @memset(&self.water.height, 0); - @memset(&self.water.vel, 0); - if (!dvui.reduce_motion) { - injectFlyRipple(&self.water, 0, water_fly_out_impulse, cur); - } - } else { - @memset(&self.was_dipping, false); - } - self.was_flown = flown; - } - const fly_t: f32 = if (dvui.animationGet(panel_id, "play_fly")) |a| - std.math.clamp(a.value(), 0.0, 1.0) - else if (flown) 1.0 else 0.0; - - // Every sprite in a file shares the same cell size, so any sprite rect - // works for sizing the flow. - const src_rect = file.spriteRect(0); - - // ---- 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 sprite_width = src_rect.w; - const sprite_height = src_rect.h; - const target_width = parent.w * 0.34; - const target_height = parent.h * 0.62; - var target_scale: f32 = 1.0; - - for (steps, 0..) |zoom, i| { - if ((sprite_width * zoom) >= target_width or (sprite_height * zoom) >= target_height) { - if (i > 0) { - target_scale = steps[i - 1]; - break; - } - target_scale = steps[i]; - break; - } - } - - if (target_scale != current_scale) { - if (dvui.animationGet(dvui.parentGet().data().id, "scale")) |a| { - if (a.done()) { - current_scale = target_scale; - prev_scale = current_scale; - } else { - if (a.end_val != target_scale) { - _ = dvui.currentWindow().animations.remove(dvui.parentGet().data().id.update("scale")); - dvui.animation(dvui.parentGet().data().id, "scale", .{ - .end_time = 600_000, - .easing = dvui.easing.outBack, - .start_val = a.value(), - .end_val = target_scale, - }); - } else { - current_scale = a.value(); - } - } - } else { - prev_scale = current_scale; - dvui.animation(dvui.parentGet().data().id, "scale", .{ - .end_time = 600_000, - .easing = dvui.easing.outBack, - .start_val = prev_scale, - .end_val = target_scale, - }); - } - } - - break :blk current_scale; - }; - - const item_w = @as(f32, @floatFromInt(file.column_width)) * scale; - const item_h = @as(f32, @floatFromInt(file.row_height)) * scale; - - // Front group: the focus card plus `flat_zone` neighbours each side sit - // flat, spaced `front_gap` apart. Past the group a `shelf_gap` opens up - // (eased in, not a hard step) and the rest tile `far_spread` apart while - // rotating onto the shelf over `tilt_ramp` index units. - const front_gap = item_w * 1.2; - const shelf_gap = item_w * 0.5; - const far_spread = item_w * 0.62; - const max_depth: f32 = 0.55; - const flat_zone: f32 = 1.0; - const tilt_ramp: f32 = 1.5; - const gap_ramp: f32 = 1.0; - - // ---- Seed the flow position from the current selection on first frame. ---- - const sel_virtual = currentVirtualTarget(file, mode, count); - if (!self.initialized) { - self.scroll_pos = @floatFromInt(sel_virtual); - self.goal = self.scroll_pos; - self.prev_scroll_pos = self.scroll_pos; - self.prev_center_i = @intFromFloat(@floor(self.scroll_pos)); - self.last_sel_virtual = sel_virtual; - self.last_committed_virtual = sel_virtual; - self.initialized = true; - } - - // ---- User input (wheel / drag) may override the flow and the selection. ---- - self.handleInput(file, mode, count, front_gap, flown); - - if (debug_touch_fling) { - const d = self.fling.last_debug; - dvui.label(@src(), "touch fling: vel {d:.2} idle {d:.3}s dt {d:.3}s n {d} coast {}", .{ - d.vel, d.idle_s, d.dt, d.samples, d.coasted, - }, .{ - .color_text = dvui.themeGet().color(.content, .text), - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - }); - } - - // An external selection change (clicking a sprite, picking an animation, - // playback advancing a frame) retargets the flow. Pick the wrapped - // representative nearest the current position so we ease the short way - // around the loop (e.g. from the first sprite leftwards to the last). - if (!self.drag_active and sel_virtual != self.last_sel_virtual) { - self.goal = nearestWrapped(self.scroll_pos, sel_virtual, count); - self.last_sel_virtual = sel_virtual; - self.last_committed_virtual = sel_virtual; - } - - // ---- Move toward the goal. While cards are flown (playback, drawing - // tools, or the preview toggle) we snap so the focus card swaps instantly - // instead of sliding through neighbours; reduce_motion snaps always. - // Otherwise ease (frame-rate independent). ---- - if (flown or dvui.reduce_motion) { - self.scroll_pos = self.goal; - self.fling.cancel(); - self.commitCenteredIfNeeded(file, mode, count); - } else if (self.drag_active) { - // Position is driven directly by the drag in handleInput. - self.fling.cancel(); - } else if (self.fling.coasting) { - // Coast with decaying momentum from the release, then snap to (and - // select) the nearest sprite once the coast slows to a stop. - if (self.fling.step(sprite_fling)) |d| { - self.scroll_pos += d; - self.goal = self.scroll_pos; - } - if (!self.fling.coasting) { - const snapped: i64 = @intFromFloat(@round(self.scroll_pos)); - self.goal = @floatFromInt(snapped); - } - dvui.refresh(null, @src(), dvui.parentGet().data().id); - } else { - const diff = self.goal - self.scroll_pos; - if (@abs(diff) > 0.001) { - // Clamp dt so a wake-from-idle frame (huge secondsSinceLastFrame) doesn't - // collapse the ease into a single-frame snap. See `max_ease_dt`. - const dt = @min(dvui.secondsSinceLastFrame(), max_ease_dt); - const t = 1.0 - @exp(-12.0 * dt); - self.scroll_pos += diff * t; - dvui.refresh(null, @src(), dvui.parentGet().data().id); - } else { - self.scroll_pos = self.goal; - // Passive ease finished — sync editor state once at the destination. - self.commitCenteredIfNeeded(file, mode, count); - } - } - // Infinite wrap: keep scroll_pos (and the goal it chases) within one loop - // by shifting both by whole turns. The wrapped rendering below is identical - // regardless of which turn we're on, so this is seamless even mid-ease. - { - const c: f32 = @floatFromInt(count); - const k = @floor(self.scroll_pos / c); - if (k != 0.0) { - self.scroll_pos -= k * c; - self.goal -= k * c; - self.prev_scroll_pos -= k * c; - } - } - - // Only push selection / frame changes while the user is actively scrubbing. - // During passive ease toward a goal, scroll_pos lags behind — per-frame - // commits would fight wheel/drag commits and retrigger canvas bubble animations. - if (self.drag_active or self.fling.coasting) { - self.commitCenteredIfNeeded(file, mode, count); - } - - if (parent.h < 32.0) { - return; - } - - 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. - const center_y = parent.center().y - 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; - - // ---- Collect a window of sprites around the centre and draw them back - // to front so the focused sprite lands on top. The window grows with the - // pane so we show as many cards as actually fit, up to a sane cap. ---- - const max_window: i64 = 12; - const window: i64 = blk: { - const half_visible = parent.w / 2.0 + item_w; - const front_extent = flat_zone * front_gap + shelf_gap; - if (far_spread <= 0.0 or half_visible <= front_extent) break :blk @max(1, @as(i64, @intFromFloat(flat_zone))); - const extra = @floor((half_visible - front_extent) / far_spread); - const fit = @as(i64, @intFromFloat(flat_zone)) + 1 + @as(i64, @intFromFloat(extra)) + shelf_edge_extra; - break :blk std.math.clamp(fit, 1, max_window); - }; - - // Floor (not round) so the focused slot doesn't swap at half-integers while - // scroll_pos eases toward goal after a slow release. - const center_i: i64 = @intFromFloat(@floor(self.scroll_pos)); - - const scroll_dt = @max(dvui.secondsSinceLastFrame(), 0.0001); - // Signed slots the shelf moved this frame — covers drag, ease, and fling - // coast alike (they all move scroll_pos). This single delta drives the wake. - const scroll_travel = self.scroll_pos - self.prev_scroll_pos; - self.prev_scroll_pos = self.scroll_pos; - - // ---- Advance the shared water surface. Ripples live in cover-flow slot - // space anchored to the focused card. While side cards are flown out, - // scroll snaps instantly (playback / focus mode) — skip stir and reset on - // slot change so frame advances don't retrigger endless waves. ---- - const water_live = !dvui.reduce_motion; - const water_scroll_stir = water_live and !flown; - // Scroll-wake velocity impulses for this frame, distributed across the - // visible cards in pass 1 (so each head-on card stirs its own slot) rather - // than injected at one point here. `dv` ≈ acceleration; `bow` is the steady - // drag/coast stir. Both are computed once and shared out by `wakeWeight`. - var wake_dv_impulse: f32 = 0; - var wake_bow_impulse: f32 = 0; - if (water_live) { - if (flown) { - if (center_i != self.prev_center_i) { - @memset(&self.water.height, 0); - @memset(&self.water.vel, 0); - } - } else { - self.water.reanchor(center_i - self.prev_center_i); - } - self.water.step(scroll_dt); - - if (water_scroll_stir) { - // Inertial drag wake: the same localized splash the fly-in uses, but - // triggered by the *change* in shelf speed (≈ acceleration) rather - // than a bulk tilt. A localized impulse makes curved, propagating - // ripples — the watery look — whereas tilting the whole field just - // shifts each reflection uniformly. Driving by velocity-change means - // a small quick shake fires a big ripple and an abrupt stop throws a - // forward wake, while a steady drag stays calm. Zero-mean over a - // gesture, so it settles on its own. - const v_raw = scroll_travel / scroll_dt; // signed slots/s - // Smooth the shelf-velocity estimate more (was 42): pointer input is - // noisy frame-to-frame, and `dv` drives the wake — a gentler tracker - // means a steadier stir instead of a jittery stream of impulses. - const v_new = std.math.lerp(self.shelf_vel, v_raw, 1.0 - @exp(-18.0 * scroll_dt)); - const dv = v_new - self.shelf_vel; - self.shelf_vel = v_new; - if (@abs(dv) > 0.0001) { - wake_dv_impulse = -dv * water_drag_k; - } - // Acceleration injects miss steady drags — add a bow wake while moving. - if (@abs(v_new) > 0.22 and (self.drag_active or self.fling.coasting)) { - wake_bow_impulse = -v_new * water_scroll_bow_k * scroll_dt; - } - } else { - self.shelf_vel = 0; - } - } - self.prev_center_i = center_i; - - // `slot` is the unwrapped position (so `off` and the skew stay continuous); - // `idx` is the wrapped sprite it shows; `id` is a per-slot widget id so - // duplicate sprites (loop shorter than the window) don't collide. - var items: [2 * 12 + 1]FlowItem = undefined; - var n: usize = 0; - var d: i64 = -window; - while (d <= window) : (d += 1) { - const slot = center_i + d; - const virtual = wrapIndex(slot, count); - items[n] = .{ - .idx = virtualToSpriteIndex(file, mode, virtual), - .off = @as(f32, @floatFromInt(slot)) - self.scroll_pos, - .d = d, - .id = @intCast(d + window), - .center = d == 0, - }; - n += 1; - } - - const SortCtx = struct { - fn lessThan(_: void, a: FlowItem, b: FlowItem) bool { - return @abs(a.off) > @abs(b.off); - } - }; - std.sort.pdq(FlowItem, items[0..n], {}, SortCtx.lessThan); - - // Total wake weight across the visible cards, so the per-card stir in pass 1 - // shares out the *same* total energy as the old single-point wake — just - // spread over the cards by `wakeWeight` instead of all at the focus. - var wake_w_total: f32 = 0; - if (water_scroll_stir and (wake_dv_impulse != 0 or wake_bow_impulse != 0)) { - for (items[0..n]) |it| wake_w_total += wakeWeight(it.off); - } - - // Cull side cards only once the fly-out has finished — not when outBack - // crosses 1 mid-animation (that overshoot is the visible fling). - const fly_cull_side_cards = blk: { - if (dvui.animationGet(panel_id, "play_fly")) |a| break :blk a.done() and flown; - break :blk flown; - }; - - var draws: [max_refl_ripple_slots]CardDraw = undefined; - var draw_n: usize = 0; - // Pass 1 — layout, then inject this card's motion into the shared water. - for (items[0..n]) |it| { - const off = it.off; - - // Per-card scroll wake: stir this card's own patch of water (its slot's - // sample band) so ripples are born under each head-on card and fade out - // as cards skew toward the edges — not all from the focus. The normalized - // weight keeps the total energy equal to the old single wake. - if (water_scroll_stir and wake_w_total > 0.0) { - const w = wakeWeight(off) / wake_w_total; - if (w > 0.0) { - const col = wsurf.slotCenterCol(it.d); - if (wake_dv_impulse != 0) self.water.inject(col, water_drag_radius, 0, wake_dv_impulse * w); - if (wake_bow_impulse != 0) self.water.inject(col, water_drag_radius * 1.15, 0, wake_bow_impulse * w); - } - } - - const a = std.math.clamp(off, -flat_zone, flat_zone); - const beyond = off - a; - - const tilt = std.math.clamp((@abs(off) - flat_zone) / tilt_ramp, 0.0, 1.0); - const gap_t = std.math.clamp((@abs(off) - flat_zone) / gap_ramp, 0.0, 1.0); - const x_off = a * front_gap + beyond * far_spread + std.math.sign(off) * gap_t * shelf_gap; - - const depth = -std.math.sign(off) * tilt * max_depth; - - // Every card is the same size: the three head-on cards match, and each - // skewed card's standing (baseline) edge is full height too. Depth reads - // from the perspective fold, shelf spacing, and opacity fade — not from - // shrinking the cards (which would also distort the sprite's aspect). - const item_scale: f32 = 1.0; - const w = item_w * item_scale; - const h = item_h * item_scale; - - // Head-on cards (inside `flat_zone`) stay fully opaque. Skewed shelf - // cards fade over `shelf_opacity_fade_span` slots — not the full window - // — so outer cards fall off quickly on wide panes. Pane-edge clipping - // fades further when cards actually run into the sides. - const card_x = center_x + x_off; - const abs_off = @abs(off); - const opacity: f32 = if (abs_off <= flat_zone) 1.0 else blk: { - const skew_t = std.math.clamp((abs_off - flat_zone) / shelf_opacity_fade_span, 0.0, 1.0); - const slot_op = 1.0 - skew_t; - const edge_dist = @min(card_x - parent.x, (parent.x + parent.w) - card_x); - const edge_op = std.math.clamp(edge_dist / (item_w * shelf_edge_fade_w), 0.0, 1.0); - break :blk @min(slot_op, edge_op); - }; - const is_focus = it.center; - - const si: usize = @intCast(it.d + @as(i64, @intCast(wsurf.field_center))); - const max_fly_off = parent.h + item_h; - - var fly_offset: f32 = 0.0; - if (!is_focus and fly_t > 0.0) { - const s = std.math.clamp((@abs(off) - 1.0) / @as(f32, @floatFromInt(window)), 0.0, 1.0); - const stagger_span: f32 = 0.5; - const local = std.math.clamp((fly_t - s * stagger_span) / (1.0 - stagger_span), 0.0, 1.0); - const f = if (self.fly_anim_out) dvui.easing.outBack(local) else dvui.easing.inBack(local); - fly_offset = f * max_fly_off; - if (fly_cull_side_cards and f >= 1.0) { - self.prev_fly_offset[si] = fly_offset; - continue; - } - } - - // Index per-slot bookkeeping by slot position (d + field_center), so the - // arrays track the card currently at each screen slot as the shelf flows. - const fly_delta = fly_offset - self.prev_fly_offset[si]; - // Cards culled during fly-out reappear with a huge position jump — don't - // treat that as velocity or the water ripples before they reach the line. - const fly_teleport = @abs(fly_delta) > max_fly_off * 0.4; - const fly_vel: f32 = if (fly_teleport) 0 else fly_delta / scroll_dt; - self.prev_fly_offset[si] = fly_offset; - - // Stand every card on a shared waterline: pin the bottom edge to the - // baseline (so shrunk side cards drop to the same line as the focus - // card). Per-column wobble is applied in the sprite mesh via - // `reflection_lag.cols_dy`; the rect stays on the resting line. - const rect = dvui.Rect{ - .x = center_x + x_off - w / 2.0, - .y = baseline_y - h - fly_offset, - .w = w, - .h = h, - }; - - if (water_live and !is_focus and fly_t > 0.0) { - const fly_speed = fly_vel / @max(parent.h, 1.0); - const touches_water = flyCardTouchesWater(fly_offset, self.fly_anim_out, max_fly_off); - const ripple_phase = fly_offset * 0.008 + fly_t * 6.28 + @as(f32, @floatFromInt(it.id)) * 0.41; - - if (touches_water and !fly_teleport and @abs(fly_speed) > water_fly_vel_dead) { - injectFlyRipple(&self.water, it.d, -fly_speed * water_stir_k, ripple_phase); - } - - const dipping = !self.fly_anim_out and touches_water and fly_offset < -1.0; - if (dipping) { - self.was_dipping[si] = true; - } else if (self.was_dipping[si] and fly_offset >= -0.3) { - self.was_dipping[si] = false; - injectFlyRipple(&self.water, it.d, water_land_impulse, ripple_phase + 1.9); - } - } else if (fly_cull_side_cards or fly_t <= 0.0) { - self.was_dipping[si] = false; - } - - draws[draw_n] = .{ - .item = it, - .rect = rect, - .w = w, - .h = h, - .depth = depth, - .opacity = opacity, - .item_scale = item_scale, - .fly_offset = fly_offset, - .off = off, - .is_focus = is_focus, - }; - draw_n += 1; - } - - const max_fly_off_draw = parent.h + item_h; - - // Pass 2 — draw cards; reflections sample the shared water surface across - // each card's slot span, so adjacent reflections distort continuously. - for (draws[0..draw_n]) |cd| { - // Faded-out edge cards are invisible — skip them so we don't build and - // render their reflection meshes (the per-card hot path) for nothing. - if (!cd.is_focus and cd.opacity <= card_cull_opacity) continue; - - const it = cd.item; - - // Grow the shadow smoothly as a card nears the centre (1 at the focus, - // 0 by one slot out) instead of a hard focus/non-focus switch — so the - // heavier shadow doesn't snap between cards as the focus flips on scroll. - const focusness = std.math.clamp(1.0 - @abs(cd.off), 0.0, 1.0); - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .id_extra = it.id, - .expand = .none, - .rect = cd.rect, - .box_shadow = .{ - .color = .black, - .offset = .{ .x = 0.0, .y = std.math.lerp(5.0, 8.0, focusness) }, - .fade = std.math.lerp(8.0, 12.0, focusness), - .alpha = std.math.lerp(0.2, 0.25, focusness) * cd.opacity, - .corner_radius = dvui.Rect.all(parent_height / 32.0), - }, - }); - defer hbox.deinit(); - - const item_src = file.spriteRect(it.idx); - - // Sample the shared surface once the card bottom is on the waterline. - // During fly-in the reflection travels with the card but the surface - // field stays flat until contact — avoids the line rising early. - var lag_sample: ReflectionLagSample = .{}; - const touches_water_draw = flyCardTouchesWater(cd.fly_offset, self.fly_anim_out, max_fly_off_draw); - const refl_water = !dvui.reduce_motion and (it.center or fly_t <= 0.0 or touches_water_draw); - if (refl_water) { - const left_col = wsurf.slotLeftCol(it.d); - const span: f32 = @floatFromInt(wsurf.cols_per_slot); - const refl_scale: f32 = if (fly_t > 0.0) water_fly_refl_scale else 1.0; - // Horizontal refraction only — cols_dy is unused (vertical mesh warp squished - // the reflection while the field was active). - const scroll_wake_disp: f32 = if (flown or fly_t > 0.0 or - !(self.drag_active or self.fling.coasting or @abs(self.shelf_vel) > 0.45)) - 1.0 - else - water_scroll_disp_boost; - inline for (0..reflection_surface_cols) |c| { - const t = @as(f32, @floatFromInt(c)) / @as(f32, @floatFromInt(reflection_surface_cols - 1)); - const col = left_col + t * span; - const slope = self.water.visualSlopeAt(col); - lag_sample.cols_dx[c] = slope * cd.h * water_disp_k * refl_scale * scroll_wake_disp; - } - } - - // Head-on cards (no skew → depth 0) get the full, high-res reflection - // mesh; skewed shelf cards ramp down to `skewed_reflection_detail` so the - // off-axis cards stay cheap. Ramps with the tilt so there's no pop as a - // card scrolls between the flat group and the shelf. - 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(), .{ - .source = file.layers.items(.source)[file.selected_layer_index], - .file = file, - .alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null, - .sprite = .{ - .source = .{ - @intFromFloat(item_src.x), - @intFromFloat(item_src.y), - @intFromFloat(item_src.w), - @intFromFloat(item_src.h), - }, - .origin = .{ 0, 0 }, - }, - .scale = scale * cd.item_scale, - .depth = cd.depth, - .opacity = cd.opacity, - .reflection = true, - // Peel the reflection down as the card lifts (2× fly_offset). 1:1 left the - // seam at the waterline while the card rose — reflection stayed put and - // vanished on cull. Seam pinning + no fly cols_dy keeps 2× from reading - // as the old ~⅛-card rise. - .reflection_offset = 2.0 * cd.fly_offset, - .reflection_lag = if (refl_water) lag_sample else null, - .reflection_detail = refl_detail, - }, .{ - .id_extra = it.id, - .margin = .all(0), - .padding = .all(0), - }); - } - - // Keep animating until the water settles, so ripples decay smoothly after - // the cards stop moving. Crucially, stay awake (and never hard-reset) while - // the shelf is still moving: a small drag injects only a little localized - // velocity, so its mean energy can sit below `water_settle_energy` for the - // first frames — without this, the reset below would wipe the disturbance - // the same frame it's injected, before the wave develops into ripples (the - // intermittent "sometimes no ripple" bug). - if (!dvui.reduce_motion) { - const e = self.water.energy(); - const moving = self.drag_active or self.fling.coasting or @abs(scroll_travel) > 1e-5; - if (e > water_settle_energy or moving) { - dvui.refresh(null, @src(), panel_id); - } else if (e > 0.0001) { - @memset(&self.water.height, 0); - @memset(&self.water.vel, 0); - } - } - } -} - -/// 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; -} - -/// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee). -fn drawingToolActive() bool { - return switch (Globals.state.tools.current) { - .pointer, .selection => false, - .pencil, .eraser, .bucket => true, - }; -} - -/// How the cover-flow loop and scroll-to-editor sync behave. -const ScrollMode = enum { - /// All sprites; scrolling does not change selection or animation frame. - all_passive, - /// All sprites; the centered sprite becomes the sole selection. - all_follow_selection, - /// Animation frames only; the active frame follows the center; no sprite selection. - animation_passive, - /// Animation frames; active frame and a single in-animation sprite follow the center. - animation_follow_selection, - /// Multi-sprite selection only; primary tile follows the centered sprite. - selection_only, -}; - -fn scrollMode(file: anytype) ScrollMode { - const sel_count = file.editor.selected_sprites.count(); - if (sel_count > 1) return .selection_only; - - if (file.selected_animation_index) |ai| { - const frames = file.animations.get(ai).frames; - if (frames.len == 0) return .all_passive; - if (sel_count == 1) { - const si = file.editor.selected_sprites.findFirstSet() orelse return .all_passive; - for (frames) |f| { - if (f.sprite_index == si) return .animation_follow_selection; - } - return .all_follow_selection; - } - return .animation_passive; - } - - if (sel_count == 1) return .all_follow_selection; - return .all_passive; -} - -fn scrollCount(file: anytype, mode: ScrollMode) usize { - return switch (mode) { - .all_passive, .all_follow_selection => file.spriteCount(), - .animation_passive, .animation_follow_selection => blk: { - const ai = file.selected_animation_index orelse return file.spriteCount(); - break :blk file.animations.get(ai).frames.len; - }, - .selection_only => file.editor.selected_sprites.count(), - }; -} - -fn nthSelectedSprite(file: anytype, n: usize) usize { - var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - var i: usize = 0; - while (iter.next()) |si| { - if (i == n) return si; - i += 1; - } - return 0; -} - -fn selectedSpriteVirtual(file: anytype, sprite_index: usize) ?usize { - var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - var i: usize = 0; - while (iter.next()) |si| { - if (si == sprite_index) return i; - i += 1; - } - return null; -} - -fn virtualToSpriteIndex(file: anytype, mode: ScrollMode, virtual: usize) usize { - return switch (mode) { - .all_passive, .all_follow_selection => virtual, - .animation_passive, .animation_follow_selection => { - const ai = file.selected_animation_index orelse return virtual; - const frames = file.animations.get(ai).frames; - if (frames.len == 0) return virtual; - return frames[@min(virtual, frames.len - 1)].sprite_index; - }, - .selection_only => nthSelectedSprite(file, virtual), - }; -} - -fn virtualFromSprite(file: anytype, mode: ScrollMode, sprite_index: usize) ?usize { - return switch (mode) { - .all_passive, .all_follow_selection => sprite_index, - .animation_passive, .animation_follow_selection => { - const ai = file.selected_animation_index orelse return sprite_index; - const frames = file.animations.get(ai).frames; - for (frames, 0..) |f, i| { - if (f.sprite_index == sprite_index) return i; - } - return null; - }, - .selection_only => selectedSpriteVirtual(file, sprite_index), - }; -} - -/// Virtual center index the cover flow eases toward when the user isn't driving it. -fn currentVirtualTarget(file: anytype, mode: ScrollMode, count: usize) usize { - if (count == 0) return 0; - - if (file.editor.playing and (mode == .animation_passive or mode == .animation_follow_selection)) { - return @min(file.selected_animation_frame_index, count - 1); - } - - if (file.editor.canvas.hovered and drawingToolActive()) { - if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt_prev))) |sprite_index| { - if (virtualFromSprite(file, mode, sprite_index)) |v| return @min(v, count - 1); - } - } - - return switch (mode) { - .all_passive, .all_follow_selection => blk: { - if (file.editor.selected_sprites.count() > 0) { - if (file.editor.selected_sprites.findLastSet()) |last| break :blk @min(last, count - 1); - } - break :blk 0; - }, - .animation_passive, .animation_follow_selection => @min(file.selected_animation_frame_index, count - 1), - .selection_only => blk: { - if (file.primarySpriteIndex()) |primary| { - if (selectedSpriteVirtual(file, primary)) |v| break :blk @min(v, count - 1); - } - break :blk 0; - }, - }; -} - -/// Wrap an unbounded slot index into a real sprite index in [0, count). -fn wrapIndex(slot: i64, count: usize) usize { - return @intCast(@mod(slot, @as(i64, @intCast(count)))); -} - -/// Advance the cover flow by one whole item and snap `scroll_pos` to match (flown-out mode). -fn stepScrollGoal(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, step: f32) void { - const next_slot: i64 = @as(i64, @intFromFloat(@round(self.goal))) + @as(i64, @intFromFloat(step)); - const v = wrapIndex(next_slot, count); - self.goal = @floatFromInt(v); - self.scroll_pos = self.goal; - self.fling.cancel(); - if (mode != .all_passive) { - self.commitVirtualCenter(file, mode, v); - } -} - -/// The representative of sprite `target` nearest to `from` in the infinite wrapped -/// index space, so easing crosses the seam the short way round. -fn nearestWrapped(from: f32, target: usize, count: usize) f32 { - const c: f32 = @floatFromInt(count); - const base: f32 = @floatFromInt(target); - return base + @round((from - base) / c) * c; -} - -/// Sync editor state to the sprite/frame under the cover-flow center, if it changed. -fn commitCenteredIfNeeded(self: *Sprites, file: anytype, mode: ScrollMode, count: usize) void { - if (mode == .all_passive or count == 0) return; - const centered = wrapIndex(@intFromFloat(@round(self.scroll_pos)), count); - if (centered == self.last_committed_virtual) return; - self.commitVirtualCenter(file, mode, centered); -} - -/// Apply the centered virtual index to editor state. Records the virtual index so -/// external-selection sync doesn't treat our own change as a new target to chase. -fn commitVirtualCenter(self: *Sprites, file: anytype, mode: ScrollMode, virtual: usize) void { - switch (mode) { - .all_passive => return, - .all_follow_selection => { - const si = virtualToSpriteIndex(file, mode, virtual); - if (file.editor.selected_sprites.count() != 1 or - si >= file.editor.selected_sprites.capacity() or - !file.editor.selected_sprites.isSet(si)) - { - file.clearSelectedSprites(); - if (si < file.editor.selected_sprites.capacity()) { - file.editor.selected_sprites.set(si); - } - } - file.editor.primary_sprite_index = si; - }, - .selection_only => { - const si = virtualToSpriteIndex(file, mode, virtual); - file.promotePrimarySprite(si); - }, - .animation_passive => { - if (file.selected_animation_frame_index != virtual) { - file.selected_animation_frame_index = virtual; - } - }, - .animation_follow_selection => { - const si = virtualToSpriteIndex(file, mode, virtual); - if (file.selected_animation_frame_index != virtual or - file.editor.selected_sprites.count() != 1 or - si >= file.editor.selected_sprites.capacity() or - !file.editor.selected_sprites.isSet(si)) - { - file.selected_animation_frame_index = virtual; - file.clearSelectedSprites(); - if (si < file.editor.selected_sprites.capacity()) { - file.editor.selected_sprites.set(si); - } - } - file.promotePrimarySprite(si); - }, - } - self.last_committed_virtual = virtual; - self.last_sel_virtual = virtual; -} - -/// True when pointer events at `p` belong to the main workspace, not a floating -/// dialog/tooltip drawn above it (e.g. Grid Layout over this pane). -fn pointerTargetsMainPane(p: dvui.Point.Physical) bool { - const cw = dvui.currentWindow(); - const main_id = cw.data().id; - const target = cw.subwindows.windowFor(p); - if (target != .zero and target != main_id) return false; - for (cw.subwindows.stack.items[1..]) |sub| { - if (sub.modal) return false; - } - return true; -} - -/// Wheel scrolls one step at a time; horizontal drag scrubs the flow freely and -/// snaps to the nearest item on release. When `snap_scroll` (cards flown out), -/// every step jumps straight to the next centered sprite with no in-between pan. -fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px_per_index: f32, snap_scroll: bool) void { - const pane = dvui.parentGet().data(); - const rs = pane.rectScale(); - const id = pane.id; - - self.drag_active = false; - - // Total drag distance (index units) accumulated across this frame's motion - // events, plus whether a drag was released this frame — both finalized after - // the loop so velocity is computed once per frame (frameTimeNS is per-frame). - var frame_dx: f32 = 0.0; - var released_moved = false; - - // 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 (dvui.captured(id)) { - for (dvui.events()) |*e| { - if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - } - } - } - return; - } - - for (dvui.events()) |*e| { - if (e.handled) continue; - if (e.evt != .mouse) continue; - const me = e.evt.mouse; - if (!pointerTargetsMainPane(me.p)) continue; - const inside = rs.r.contains(me.p); - if (!inside and !dvui.captured(id)) continue; - - switch (me.action) { - .press => { - if (me.button.pointer()) { - e.handle(@src(), pane); - dvui.captureMouse(pane, e.num); - dvui.dragPreStart(me.p, .{ .name = "coverflow_drag", .cursor = .hand }); - self.moved_since_press = false; - self.drag_was_touch = me.button.touch(); - self.wheel_accum = 0.0; - // Grabbing again cancels any in-flight coast and its velocity. - self.fling.begin(); - } - }, - .release => { - if (me.button.pointer() and dvui.captured(id)) { - e.handle(@src(), pane); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - if (self.moved_since_press) released_moved = true; - self.moved_since_press = false; - } - }, - .motion => { - if (!dvui.captured(id)) continue; - // Touch moves use the event delta directly — waiting for the mouse drag - // threshold drops most of the last samples before `touchend`. - const dps: dvui.Point.Physical = if (me.button.touch()) - me.action.motion - else if (dvui.dragging(me.p, "coverflow_drag")) |d| - d - else - continue; - self.drag_active = true; - self.moved_since_press = true; - if (px_per_index > 0.0) { - const di = -dps.x / rs.s / px_per_index; - if (snap_scroll) { - self.wheel_accum += di; - while (@abs(self.wheel_accum) >= 1.0) { - const step: f32 = if (self.wheel_accum > 0.0) 1.0 else -1.0; - self.wheel_accum -= step; - stepScrollGoal(self, file, mode, count, step); - } - } else { - self.scroll_pos += di; - self.goal = self.scroll_pos; - frame_dx += di; - } - } - dvui.refresh(null, @src(), id); - }, - .wheel_x, .wheel_y => { - if (inside) { - e.handle(@src(), pane); - const amt = if (me.action == .wheel_x) me.action.wheel_x else me.action.wheel_y; - // A discrete mouse wheel advances one sprite per notch; a trackpad - // accumulates its stream of small deltas smoothly. We can't key off the - // raw magnitude: a single wheel notch is ~1.0 - if (dvui.mouseType() == .mouse) { - self.wheel_accum += std.math.sign(amt); - } else { - self.wheel_accum += amt * 0.01; - } - while (@abs(self.wheel_accum) >= 1.0) { - const step: f32 = if (self.wheel_accum > 0.0) 1.0 else -1.0; - self.wheel_accum -= step; - if (snap_scroll) { - stepScrollGoal(self, file, mode, count, step); - } else { - const ng = @round(self.goal) + step; - self.goal = ng; - if (mode != .all_passive) { - const v = wrapIndex(@intFromFloat(ng), count); - self.commitVirtualCenter(file, mode, v); - // scroll_pos may still be easing toward ng; don't let a - // passive-ease commit revert this until we arrive. - self.last_committed_virtual = v; - } - } - } - dvui.refresh(null, @src(), id); - } - }, - else => {}, - } - } - - if (!snap_scroll) { - // Touch and mouse/trackpad share one path: record each moved frame into the - // position/time history and, on release, coast from a velocity averaged over a - // wall-clock window. That window is refresh-independent, so momentum is reliable - // at 60 Hz and 120 Hz alike — unlike the old per-frame EMA, which underread short - // flicks at lower refresh rates. Only the feel tuning differs per input type. - if (self.drag_active) self.fling.sampleTimed(frame_dx); - if (released_moved) { - // The last move and the release commonly land on the same frame (more so at - // low refresh), which leaves `drag_active` set. Clear it after sampling that - // final move so draw()'s `drag_active` branch doesn't cancel the coast we - // start here — that race was eating momentum on a large share of flicks. - self.drag_active = false; - const tuning = if (self.drag_was_touch) sprite_fling_touch else sprite_fling; - const window_s = if (self.drag_was_touch) sprite_fling_touch_window_s else sprite_fling_window_s; - if (!self.fling.releaseWindowed(tuning, window_s)) { - const snapped: i64 = @intFromFloat(@round(self.scroll_pos)); - self.goal = @floatFromInt(snapped); - dvui.refresh(null, @src(), id); - } - } - } else if (released_moved) { - const v = wrapIndex(@intFromFloat(@round(self.goal)), count); - self.goal = @floatFromInt(v); - self.scroll_pos = self.goal; - self.fling.cancel(); - if (mode != .all_passive) { - self.commitVirtualCenter(file, mode, v); - } - } -} - -pub fn drawAnimationControlsDialog(_: *Sprites) void { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { - const rect = dvui.parentGet().data().rectScale().r; - - if (dvui.parentGet().data().rect.h < 48.0) { - return; - } - - // Round controls floating in the top-left corner. Mirrors the workspace - // hamburger / sample buttons: content-fill circles with a soft drop - // shadow and a centered icon. - const button_size: f32 = 32; - const gap: f32 = 6; - const base_x = rect.toNatural().x + 10; - const base_y = rect.toNatural().y + 10; - - // Play / pause. Always present; "disabled" (muted, no action) when no - // animation is selected. - const play_enabled = file.selected_animation_index != null; - if (drawRoundButton( - @src(), - base_x, - base_y, - button_size, - "Play", - if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, - play_enabled, - file.editor.playing, - ) and play_enabled) { - file.editor.playing = !file.editor.playing; - } - - // Fly-out preview. Toggles the side cards out / in without advancing - // playback — a static look at the focused-card layout. Highlighted while - // active; inert while playback or drawing tools already flew them. - const playing = file.editor.playing; - const flown = sideCardsFlown(playing); - const fly_forced = playing or drawingToolActive(); - if (drawRoundButton( - @src(), - base_x + button_size + gap, - base_y, - button_size, - "Toggle card focus", - if (flown) icons.tvg.entypo.doc else icons.tvg.entypo.docs, - !fly_forced, - flown, - ) and !fly_forced) { - 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); - } - } -} - -/// One round, floating action button matching the workspace hamburger / sample -/// buttons. Returns true on click. `enabled` mutes the icon (the caller also -/// gates the action on it); `active` tints the fill to show a toggled-on state. -/// Each call site supplies its own `@src()` for a stable, distinct id. -fn drawRoundButton( - src: std.builtin.SourceLocation, - x: f32, - y: f32, - size: f32, - name: []const u8, - icon_tvg: []const u8, - enabled: bool, - active: bool, -) bool { - const btn_radius: f32 = size / 2; - const icon_padding: f32 = size * 0.33; - - var fw: dvui.FloatingWidget = undefined; - fw.init(src, .{}, .{ - .rect = .{ .x = x, .y = y, .w = size, .h = size }, - .expand = .none, - .background = false, - }); - defer fw.deinit(); - - const fill = if (active) - dvui.themeGet().color(.highlight, .fill) - else - dvui.themeGet().color(.content, .fill); - - var btn: dvui.ButtonWidget = undefined; - btn.init(src, .{}, .{ - .expand = .both, - .min_size_content = .{ .w = size, .h = size }, - .background = true, - .corner_radius = dvui.Rect.all(btn_radius), - .color_fill = fill, - .color_fill_hover = fill.lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - .color_border = .transparent, - // Inset lives on the button (not the icon): a uniform pad on the icon - // would force its content rect square and skew non-square glyphs like - // the entypo play/pause. Padding here keeps the icon's own rect free to - // take the glyph's native aspect under `expand = .ratio`. - .padding = dvui.Rect.all(icon_padding), - .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 text_color = if (active) - dvui.themeGet().color(.highlight, .text) - else - dvui.themeGet().color(.content, .text); - const icon_color = if (enabled) text_color else text_color.opacity(0.35); - - // `min_size_content.h` must be a real height: IconWidget derives width as - // `iconWidth(h)` but clamps it up to at least `min_size_content.w`. With a - // height of 1 a glyph taller than wide derives width < 1, gets clamped to a - // square min size, and `expand = .ratio` then stretches it. A full-size - // height keeps the derived width true to the glyph's aspect. - dvui.icon( - src, - name, - icon_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 = size }, - }, - ); - - return btn.clicked(); -} diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig deleted file mode 100644 index 2b77a2df..00000000 --- a/src/plugins/pixelart/src/plugin.zig +++ /dev/null @@ -1,685 +0,0 @@ -//! 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"); -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 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 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; - -/// 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 = .{ - .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, - .unregisterDocument = unregisterDocument, - .bindDocumentToPane = bindDocumentToPane, - .documentGrouping = documentGrouping, - .setDocumentGrouping = setDocumentGrouping, - .removeCanvasPane = removeCanvasPane, - .documentPath = documentPath, - .setDocumentPath = setDocumentPath, - .documentHasNativeExtension = documentHasNativeExtension, - .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, - .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, - .runPackWorkers = pluginRunPackWorkers, - .persistProjectFolder = pluginPersistProjectFolder, - .reloadProjectFolder = pluginReloadProjectFolder, -}; - -/// 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).?; -} - -/// 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; -} - -/// 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. - 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(); -} - -/// 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-pane ruler/overlay/reorder state + draw helpers -/// 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 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 - // 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. - defer chrome.columns_drag_index = null; - defer chrome.rows_drag_index = null; - defer chrome.processColumnReorder(file); - defer chrome.processRowReorder(file); - - pixelart.perf.canvasPaneDrawn(); - - 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 (Globals.state.settings.show_rulers and !dvui.firstFrame(container.id)) { - defer pixelart.core.dvui.drawEdgeShadow(container.rectScale(), .left, .{}); - chrome.drawRuler(file, .vertical); - } - - chrome.drawTransformDialog(file, container); - chrome.drawEditPill(container); - // Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom). - chrome.drawSampleButton(container); - - 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, - .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)) { - FileWidget.drawSampleMagnifier(file, data_pt); - } - } -} - -/// 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. -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()) - else - content_color; - } - - const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32) - Globals.packer.atlas != null - else - Globals.state.host.folder() != null and Globals.packer.atlas != null; - - var canvas_vbox = sdk.pane_layout.mainCanvasVbox(content_color, show_packed_atlas, pane.grouping); - defer { - pane.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 = &Globals.packer.atlas.?; - var image_widget = ImageWidget.init(@src(), .{ - .source = atlas.source, - .canvas = &atlas.canvas, - .grouping = pane.grouping, - }, .{ - .id_extra = @intCast(pane.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)) { - ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt); - } - } - } else { - var box = sdk.pane_layout.emptyStateCard(content_color, pane.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 (Globals.state.host.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 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); -} - -fn redo(_: *anyopaque, doc: DocHandle) anyerror!void { - const file = docFile(doc); - 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 - // 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, - .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, - .draw_workspace = drawProjectView, - }); - try host.registerBottomView(.{ - .id = bottom_sprites, - .owner = &plugin, - .title = "Sprites", - .draw = drawSpritesPanel, - }); - try host.registerSettingsSection(.{ - .id = "pixelart.settings", - .owner = &plugin, - .title = "Pixel Art", - .draw = PixelArtSettings.draw, - }); -} - -/// Stable `*Plugin` for constructing `DocHandle.owner` fields. -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(); -} -fn drawSprites(_: ?*anyopaque) anyerror!void { - try Globals.state.sprites_pane.draw(); -} -fn drawProject(_: ?*anyopaque) anyerror!void { - try pixelart.explorer.project.draw(); -} -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(); -} - -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 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 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); -} - -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 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); -} - -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 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 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 { - _ = 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); -} - -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 { - 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 }); - 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/plugins/pixelart/src/radial_menu.zig b/src/plugins/pixelart/src/radial_menu.zig deleted file mode 100644 index 103c7e2a..00000000 --- a/src/plugins/pixelart/src/radial_menu.zig +++ /dev/null @@ -1,238 +0,0 @@ -//! 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/pixelart/src/render.zig b/src/plugins/pixelart/src/render.zig deleted file mode 100644 index 4cefd7e2..00000000 --- a/src/plugins/pixelart/src/render.zig +++ /dev/null @@ -1,918 +0,0 @@ -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; - -/// Monotonic frame counter, incremented once per frame from Editor.tick. -pub var frame_index: u64 = 0; - -pub const RenderFileOptions = struct { - file: *pixelart.internal.File, - rs: dvui.RectScale, - color_mod: dvui.Color = .white, - fade: f32 = 0.0, - uv: dvui.Rect = .{ .w = 1.0, .h = 1.0 }, - corner_radius: dvui.Rect = .all(0), - allow_peek: bool = true, - /// Optional skewed quad in physical corner order (tl, tr, br, bl). When set, - /// the layer stack renders into this quad instead of the axis-aligned `rs.r`, - /// so perspective/depth skew applies to the art itself — not just the - /// background. Leave null for normal (canvas) rendering. - quad: ?[4]dvui.Point.Physical = null, - quad_subdivisions: usize = 8, -}; - -/// Web backends without `textureUpdateSubRect` recreate the GPU texture on upload; sync the cache -/// when the pointer changes so we do not keep drawing a texture id that was destroyLater'd. -fn uploadSubRectAndSyncCache( - key: u64, - tex: *dvui.Texture, - pixels: [*]const u8, - x: u32, - y: u32, - w: u32, - h: u32, -) void { - const prev_ptr = tex.ptr; - tex.updateSubRect(pixels, x, y, w, h) catch |err| { - dvui.log.err("Sub-rect texture upload failed: {any}", .{err}); - return; - }; - if (tex.ptr != prev_ptr) { - dvui.textureAddToCache(key, tex.*); - } -} - -/// Pushes pending CPU pixel edits to GPU textures. Must run even when `renderLayers` returns early -/// (scale zero / clip empty): otherwise `defer` blocks that normally perform uploads are never -/// registered, and `temp_gpu_dirty_rect` keeps unioning every frame until it covers the whole image. -fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void { - const file = init_opts.file; - - if (file.editor.active_layer_dirty_rect) |dirty| { - if (dirty.w > 0 and dirty.h > 0) { - perf.draw_active_rect_area += @intFromFloat(dirty.w * dirty.h); - const source = file.layers.items(.source)[file.selected_layer_index]; - const source_key = source.hash(); - if (dvui.textureGetCached(source_key)) |cached| { - var tex = cached; - uploadSubRectAndSyncCache( - source_key, - &tex, - pixelart.image.bytes(source).ptr, - @intFromFloat(dirty.x), - @intFromFloat(dirty.y), - @intFromFloat(dirty.w), - @intFromFloat(dirty.h), - ); - } - } - file.editor.active_layer_dirty_rect = null; - } - - if (file.editor.temp_layer_has_content or - file.editor.temp_gpu_dirty_rect != null) - { - const temp_source = file.editor.temporary_layer.source; - const temp_key = temp_source.hash(); - if (dvui.textureGetCached(temp_key)) |cached| { - if (file.editor.temp_gpu_dirty_rect) |dirty| { - if (dirty.w > 0 and dirty.h > 0) { - perf.draw_temp_rect_area += @intFromFloat(dirty.w * dirty.h); - var tex = cached; - uploadSubRectAndSyncCache( - temp_key, - &tex, - pixelart.image.bytes(temp_source).ptr, - @intFromFloat(dirty.x), - @intFromFloat(dirty.y), - @intFromFloat(dirty.w), - @intFromFloat(dirty.h), - ); - } - file.editor.temp_gpu_dirty_rect = null; - } else if (file.editor.temp_layer_has_content) { - // CPU redraw (e.g. selection overlay via setColorFromMask) may leave the cache valid - // without a dirty rect; sync the full texture so the GPU matches the pixel buffer. - _ = temp_source.getTexture() catch null; - } - } else if (file.editor.temp_layer_has_content) { - _ = temp_source.getTexture() catch null; - file.editor.temp_gpu_dirty_rect = null; - } else if (file.editor.temp_gpu_dirty_rect != null) { - file.editor.temp_gpu_dirty_rect = null; - } - } -} - -fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_index: usize, needs_dimmed: bool } { - var min_layer_index: usize = 0; - if (init_opts.allow_peek) { - 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()) { - min_layer_index = init_opts.file.selected_layer_index; - } - } - } - const needs_dimmed = init_opts.allow_peek and init_opts.file.peek_layer_index != null; - return .{ .min_layer_index = min_layer_index, .needs_dimmed = needs_dimmed }; -} - -/// 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 { - 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]); - return buf[0..file.layers.len]; -} - -/// Builds the same cached composites `renderLayers` would use (split when drawing, full when idle), -/// so callers (e.g. sprite preview reflection) can draw before `renderLayers` runs. -pub fn ensureLayerCompositesForPreview(init_opts: RenderFileOptions) !void { - const vs = layerViewStateForRender(init_opts); - if (splitCompositeEligible(init_opts, vs.min_layer_index, vs.needs_dimmed)) { - try syncSplitComposite(init_opts.file); - } else if (fullCompositeEligible(init_opts, vs.min_layer_index, vs.needs_dimmed)) { - try syncLayerComposite(init_opts.file); - } -} - -fn renderTransformIfActive(init_opts: RenderFileOptions, triangles: dvui.Triangles) void { - if (init_opts.file.editor.transform) |*transform| { - if (dvui.textureFromTarget(transform.target_texture) catch null) |tex| { - dvui.renderTriangles(triangles, tex) catch { - dvui.log.err("Failed to render transform layer", .{}); - }; - } - } -} - -/// Draws the layer stack for the sprite-panel reflection using the same composite paths as -/// `renderLayers` (1–3 draws) instead of N per-layer draws when possible. -pub fn renderReflectionLayerStack( - init_opts: RenderFileOptions, - reflection_tris: dvui.Triangles, - reflection_tris_dimmed: dvui.Triangles, -) !void { - const file = init_opts.file; - const vs = layerViewStateForRender(init_opts); - try ensureLayerCompositesForPreview(init_opts); - - var order_buf: [1024]usize = undefined; - const order_opt = layerOrderBufForDragPreview(file, order_buf[0..]); - - if (file.peek_layer_index != null) { - var list_pos: usize = file.layers.len; - while (list_pos > vs.min_layer_index) { - list_pos -= 1; - const layer_index = if (order_opt) |o| o[list_pos] else list_pos; - const visible = file.layers.items(.visible)[layer_index]; - var tris = reflection_tris; - if (vs.needs_dimmed) { - if (file.peek_layer_index) |peek_layer_index| { - if (peek_layer_index != layer_index) { - tris = reflection_tris_dimmed; - } - } - } - if (visible) { - dvui.renderTriangles(tris, file.layers.items(.source)[layer_index].getTexture() catch null) catch { - dvui.log.err("Failed to render reflection layer", .{}); - }; - } - if (layer_index == file.selected_layer_index) { - renderTransformIfActive(init_opts, reflection_tris); - } - } - return; - } - - if (splitCompositeEligible(init_opts, vs.min_layer_index, vs.needs_dimmed)) { - if (order_opt != null) { - var list_pos: usize = file.layers.len; - while (list_pos > vs.min_layer_index) { - list_pos -= 1; - const layer_index = order_opt.?[list_pos]; - const visible = file.layers.items(.visible)[layer_index]; - var tris = reflection_tris; - if (vs.needs_dimmed) { - if (file.peek_layer_index) |peek_layer_index| { - if (peek_layer_index != layer_index) { - tris = reflection_tris_dimmed; - } - } - } - if (visible) { - dvui.renderTriangles(tris, file.layers.items(.source)[layer_index].getTexture() catch null) catch { - dvui.log.err("Failed to render reflection layer", .{}); - }; - } - if (layer_index == file.selected_layer_index) { - renderTransformIfActive(init_opts, reflection_tris); - } - } - return; - } - if (file.editor.split_composite_below) |ct| { - if (dvui.Texture.fromTargetTemp(ct) catch null) |tex| { - dvui.renderTriangles(reflection_tris, tex) catch { - dvui.log.err("Failed to render reflection below composite", .{}); - }; - } - } - const active_source = file.layers.items(.source)[file.selected_layer_index]; - if (file.layers.items(.visible)[file.selected_layer_index]) { - if (active_source.getTexture() catch null) |tex| { - dvui.renderTriangles(reflection_tris, tex) catch { - dvui.log.err("Failed to render reflection active layer", .{}); - }; - } - } - renderTransformIfActive(init_opts, reflection_tris); - if (file.editor.split_composite_above) |ct| { - if (dvui.Texture.fromTargetTemp(ct) catch null) |tex| { - dvui.renderTriangles(reflection_tris, tex) catch { - dvui.log.err("Failed to render reflection above composite", .{}); - }; - } - } - return; - } - - if (fullCompositeEligible(init_opts, vs.min_layer_index, vs.needs_dimmed)) { - if (file.editor.layer_composite_target) |ct| { - if (dvui.Texture.fromTargetTemp(ct) catch null) |ctex| { - dvui.renderTriangles(reflection_tris, ctex) catch { - dvui.log.err("Failed to render reflection full composite", .{}); - }; - return; - } - } - } - - var list_pos2: usize = file.layers.len; - while (list_pos2 > vs.min_layer_index) { - list_pos2 -= 1; - const layer_index = if (order_opt) |o| o[list_pos2] else list_pos2; - const visible = file.layers.items(.visible)[layer_index]; - var tris = reflection_tris; - if (vs.needs_dimmed) { - if (file.peek_layer_index) |peek_layer_index| { - if (peek_layer_index != layer_index) { - tris = reflection_tris_dimmed; - } - } - } - if (visible) { - dvui.renderTriangles(tris, file.layers.items(.source)[layer_index].getTexture() catch null) catch { - dvui.log.err("Failed to render reflection layer stack fallback", .{}); - }; - } - if (layer_index == file.selected_layer_index) { - renderTransformIfActive(init_opts, reflection_tris); - } - } -} - -/// Draw layers into the **current** render target using cached composites only (no `sync*` rebinding). -/// Caller must run `ensureLayerCompositesForPreview` first while the screen target is active. -pub fn renderLayersMagnifierSample(init_opts: RenderFileOptions) !void { - flushPendingLayerTextureUploads(init_opts); - - if (init_opts.rs.s == 0) return; - if (dvui.clipGet().intersect(init_opts.rs.r).empty()) return; - - const vs = layerViewStateForRender(init_opts); - - 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(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(Globals.allocator()); - } - if (vs.needs_dimmed) { - var dt = try triangles.dupe(Globals.allocator()); - dt.color(.gray); - dimmed_triangles = dt; - } - - const dimmed = dimmed_triangles orelse triangles; - try renderReflectionLayerStack(init_opts, triangles, dimmed); - - if (dvui.textureGetCached(init_opts.file.editor.selection_layer.source.hash()) == null) - perf.draw_texture_creates += 1; - dvui.renderTriangles(triangles, init_opts.file.editor.selection_layer.source.getTexture() catch null) catch { - dvui.log.err("Failed to render magnifier selection layer", .{}); - }; - - if (init_opts.file.editor.temp_layer_has_content) { - const temp_source = init_opts.file.editor.temporary_layer.source; - if (dvui.textureGetCached(temp_source.hash()) == null) - perf.draw_texture_creates += 1; - if (dvui.textureGetCached(temp_source.hash())) |cached| { - dvui.renderTriangles(triangles, cached) catch { - dvui.log.err("Failed to render magnifier temporary layer", .{}); - }; - } else { - dvui.renderTriangles(triangles, temp_source.getTexture() catch null) catch { - dvui.log.err("Failed to render magnifier temporary layer", .{}); - }; - } - } -} - -fn fullCompositeEligible( - init_opts: RenderFileOptions, - min_layer_index: usize, - needs_dimmed: bool, -) bool { - if (needs_dimmed) return false; - if (min_layer_index != 0) return false; - if (init_opts.fade != 0) return false; - // A uniform color_mod (e.g. the cover-flow opacity fade) is correct to apply - // once to the flattened composite — and avoids the per-layer translucency - // artifacts you'd get fading each layer separately. - if (init_opts.file.editor.transform != null) return false; - if (init_opts.file.editor.active_drawing) return false; - const ce = layerCompositeExtent(init_opts.file); - if (ce.w == 0 or ce.h == 0) return false; - return true; -} - -fn splitCompositeEligible( - init_opts: RenderFileOptions, - min_layer_index: usize, - needs_dimmed: bool, -) bool { - if (!init_opts.file.editor.active_drawing and init_opts.file.editor.transform == null) return false; - if (needs_dimmed) return false; - if (min_layer_index != 0) return false; - if (init_opts.fade != 0) return false; - // See fullCompositeEligible: a uniform color_mod applies cleanly to the - // split composites too, so it no longer forces the per-layer path. - const ce = layerCompositeExtent(init_opts.file); - if (ce.w == 0 or ce.h == 0) return false; - return true; -} - -/// 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 } { - const c = file.canvasPixelSize(); - if (c.w > 0 and c.h > 0) return .{ .w = c.w, .h = c.h }; - const w = file.width(); - const h = file.height(); - return .{ .w = w, .h = h }; -} - -/// Pixel format for off-screen render targets (layer composites, transforms). -/// The web backend only supports `.rgba_32`. -pub fn compositeTargetPixelFormat() dvui.enums.TexturePixelFormat { - return if (comptime builtin.target.cpu.arch == .wasm32) - .rgba_32 - else - .rgba_8_8_8_8; -} - -/// Rebuilds the full-canvas flattened layer texture (all layers included). -/// Used when NOT actively drawing. -pub fn syncLayerComposite(file: *pixelart.internal.File) !void { - const ce = layerCompositeExtent(file); - const w = ce.w; - const h = ce.h; - if (w == 0 or h == 0) return; - - if (file.editor.layer_composite_frame_built == frame_index) return; - file.editor.layer_composite_frame_built = frame_index; - - if (file.editor.layer_composite_target) |t| { - if (t.width != w or t.height != h) { - t.destroyLater(); - file.editor.layer_composite_target = null; - } - } - - var needs_rebuild = file.editor.layer_composite_target == null or file.editor.layer_composite_dirty; - - if (!needs_rebuild) { - var i: usize = file.layers.len; - while (i > 0) { - i -= 1; - if (!file.layers.items(.visible)[i]) continue; - if (dvui.textureGetCached(file.layers.items(.source)[i].hash()) == null) { - needs_rebuild = true; - break; - } - } - } - - if (!needs_rebuild) return; - - perf.draw_full_composite_rebuilds += 1; - - const sc_t0 = perf.syncCompositeBegin(); - defer perf.syncCompositeEnd(sc_t0); - - const target = if (file.editor.layer_composite_target) |t| t else blk: { - const nt = try dvui.textureCreateTarget(.{ .width = w, .height = h, .format = compositeTargetPixelFormat(), .interpolation = .nearest }); - file.editor.layer_composite_target = nt; - break :blk nt; - }; - - try renderLayersIntoTarget(file, target, 0, file.layers.len, null); - file.editor.layer_composite_dirty = false; - file.editor.layer_composite_generation +%= 1; -} - -/// Builds two split composites that exclude the active (selected) layer. -/// 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 { - const ce = layerCompositeExtent(file); - const w = ce.w; - const h = ce.h; - if (w == 0 or h == 0) return; - - if (file.editor.split_composite_frame_built == frame_index) return; - file.editor.split_composite_frame_built = frame_index; - - // Prevent the full composite from also rebuilding this frame (e.g. from - // the sprite panel reflection calling syncLayerComposite directly). - file.editor.layer_composite_frame_built = frame_index; - - const active_idx = file.selected_layer_index; - - var needs_rebuild = file.editor.split_composite_dirty or - file.editor.split_composite_layer == null or - file.editor.split_composite_layer.? != active_idx; - - inline for (&[_]*?dvui.Texture.Target{ - &file.editor.split_composite_below, - &file.editor.split_composite_above, - }) |target_ptr| { - if (target_ptr.*) |t| { - if (t.width != w or t.height != h) { - t.destroyLater(); - target_ptr.* = null; - needs_rebuild = true; - } - } else { - needs_rebuild = true; - } - } - - if (!needs_rebuild) { - var i: usize = file.layers.len; - while (i > 0) { - i -= 1; - if (i == active_idx) continue; - if (!file.layers.items(.visible)[i]) continue; - if (dvui.textureGetCached(file.layers.items(.source)[i].hash()) == null) { - needs_rebuild = true; - break; - } - } - } - - if (!needs_rebuild) return; - - perf.draw_split_rebuilds += 1; - - const sc_t0 = perf.syncCompositeBegin(); - defer perf.syncCompositeEnd(sc_t0); - - const below = if (file.editor.split_composite_below) |t| t else blk: { - const nt = try dvui.textureCreateTarget(.{ .width = w, .height = h, .format = compositeTargetPixelFormat(), .interpolation = .nearest }); - file.editor.split_composite_below = nt; - break :blk nt; - }; - - const above = if (file.editor.split_composite_above) |t| t else blk: { - const nt = try dvui.textureCreateTarget(.{ .width = w, .height = h, .format = compositeTargetPixelFormat(), .interpolation = .nearest }); - file.editor.split_composite_above = nt; - break :blk nt; - }; - - const t_below = perf.nanoTimestamp(); - try renderLayersIntoTarget(file, below, active_idx + 1, file.layers.len, null); - if (perf.record) { - perf.split_composite_below_ns = @intCast(perf.nanoTimestamp() - t_below); - } - - const t_above = perf.nanoTimestamp(); - try renderLayersIntoTarget(file, above, 0, active_idx, null); - if (perf.record) { - perf.split_composite_above_ns = @intCast(perf.nanoTimestamp() - t_above); - } - - file.editor.split_composite_layer = active_idx; - file.editor.split_composite_dirty = false; -} - -/// 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 { - const w0 = perf.nanoTimestamp(); - try syncSplitComposite(file); - _ = file.editor.temporary_layer.source.getTexture() catch null; - _ = file.editor.selection_layer.source.getTexture() catch null; - perf.composite_warmup_last_ns = @intCast(perf.nanoTimestamp() - w0); - perf.composite_warmup_total +%= 1; -} - -/// Renders a range of visible layers into a render target. Layers are drawn -/// from high index (visually bottom) to low index (visually top). An optional -/// `skip_index` excludes a single layer. -fn renderLayersIntoTarget( - file: *pixelart.internal.File, - target: dvui.Texture.Target, - min_index: usize, - max_index: usize, - skip_index: ?usize, -) !void { - const ce = layerCompositeExtent(file); - const w = ce.w; - const h = ce.h; - const image_rect = dvui.Rect.Physical{ - .x = 0, - .y = 0, - .w = @floatFromInt(w), - .h = @floatFromInt(h), - }; - - target.clear(); - const prev_target = dvui.renderTarget(.{ .texture = target, .offset = image_rect.topLeft() }); - defer _ = dvui.renderTarget(prev_target); - - const prev_clip = dvui.clipGet(); - defer dvui.clipSet(prev_clip); - dvui.clipSet(image_rect); - - 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(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; - const order_opt = layerOrderBufForDragPreview(file, order_buf[0..]); - - var list_pos: usize = max_index; - while (list_pos > min_index) { - list_pos -= 1; - const i = if (order_opt) |o| o[list_pos] else list_pos; - if (skip_index) |skip| { - if (i == skip) continue; - } - if (!file.layers.items(.visible)[i]) continue; - const source = file.layers.items(.source)[i]; - if (source.getTexture() catch null) |tex| { - dvui.renderTriangles(tris, tex) catch { - dvui.log.err("Failed to render layer into composite target", .{}); - }; - } - } -} - -/// Bakes the cover-flow preview composite — checkerboard backdrop + flattened -/// layers + selection + temp, all at canvas resolution — into one texture. The -/// 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 { - const ce = layerCompositeExtent(file); - const w = ce.w; - const h = ce.h; - if (w == 0 or h == 0) return; - - if (file.editor.preview_composite_frame_built == frame_index) return; - file.editor.preview_composite_frame_built = frame_index; - - // The flattened layer stack feeds the bake; build/refresh it first. - try syncLayerComposite(file); - - var fresh = false; - if (file.editor.preview_composite_target) |t| { - if (t.width != w or t.height != h) { - t.destroyLater(); - file.editor.preview_composite_target = null; - } - } - const target = if (file.editor.preview_composite_target) |t| t else blk: { - const nt = try dvui.textureCreateTarget(.{ .width = w, .height = h, .format = compositeTargetPixelFormat(), .interpolation = .nearest }); - file.editor.preview_composite_target = nt; - fresh = true; - break :blk nt; - }; - - // Skip the (clear + several renderTriangles) re-bake when nothing that feeds - // the composite changed. The cover-flow reflections animate via mesh - // displacement only, so during a ripple the baked sprite content is identical - // frame to frame — this turns a per-frame bake into a no-op while idle/rippling. - const theme_fill = dvui.themeGet().color(.content, .fill); - var sig: u64 = file.editor.layer_composite_generation; - sig = sig *% 1000003 +% file.editor.selection_layer.source.hash(); - if (file.editor.temp_layer_has_content) { - // `.ptr` invalidation makes `source.hash()` content-blind (it only sees the - // stable buffer pointer), so a moved hover/brush preview that reuses the same - // buffer wouldn't change the signature. Use the content generation instead. - sig = sig *% 1000003 +% file.editor.temp_layer_generation; - } - const fill_packed: u32 = @as(u32, theme_fill.r) | @as(u32, theme_fill.g) << 8 | @as(u32, theme_fill.b) << 16 | @as(u32, theme_fill.a) << 24; - sig = sig *% 1000003 +% @as(u64, fill_packed); - sig = sig *% 1000003 +% (@as(u64, @intCast(file.columns)) << 16 | @as(u64, @intCast(file.rows))); - - if (!fresh and file.editor.preview_composite_valid and sig == file.editor.preview_composite_sig) return; - file.editor.preview_composite_sig = sig; - file.editor.preview_composite_valid = true; - - const sc_t0 = perf.syncCompositeBegin(); - defer perf.syncCompositeEnd(sc_t0); - - const image_rect = dvui.Rect.Physical{ .x = 0, .y = 0, .w = @floatFromInt(w), .h = @floatFromInt(h) }; - - target.clear(); - const prev_target = dvui.renderTarget(.{ .texture = target, .offset = image_rect.topLeft() }); - defer _ = dvui.renderTarget(prev_target); - - const prev_clip = dvui.clipGet(); - defer dvui.clipSet(prev_clip); - dvui.clipSet(image_rect); - - // 1) Opaque content-fill base — the transparency backdrop, matching the card. - { - 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(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(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(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(Globals.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()); - tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 }); - - if (file.editor.layer_composite_target) |ct| { - if (dvui.Texture.fromTargetTemp(ct) catch null) |ctex| { - dvui.renderTriangles(tris, ctex) catch {}; - } - } - dvui.renderTriangles(tris, file.editor.selection_layer.source.getTexture() catch null) catch {}; - if (file.editor.temp_layer_has_content) { - dvui.renderTriangles(tris, file.editor.temporary_layer.source.getTexture() catch null) catch {}; - } -} - -/// 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 { - if (file.peek_layer_index != null) return null; - if (file.editor.isolate_layer) return null; - if (file.editor.transform != null) return null; - if (file.editor.active_drawing) return null; - const ce = layerCompositeExtent(file); - if (ce.w == 0 or ce.h == 0) return null; - syncPreviewComposite(file) catch return null; - const t = file.editor.preview_composite_target orelse return null; - return dvui.Texture.fromTargetTemp(t) catch null; -} - -pub fn destroyLayerCompositeResources(file: *pixelart.internal.File) void { - if (file.editor.layer_composite_target) |t| { - t.destroyLater(); - file.editor.layer_composite_target = null; - } - file.editor.layer_composite_dirty = true; - - if (file.editor.preview_composite_target) |t| { - t.destroyLater(); - file.editor.preview_composite_target = null; - } - file.editor.preview_composite_frame_built = 0; - - destroySplitCompositeResources(file); -} - -pub fn destroySplitCompositeResources(file: *pixelart.internal.File) void { - if (file.editor.split_composite_below) |t| { - t.destroyLater(); - file.editor.split_composite_below = null; - } - if (file.editor.split_composite_above) |t| { - t.destroyLater(); - file.editor.split_composite_above = null; - } - file.editor.split_composite_dirty = true; - file.editor.split_composite_layer = null; -} - -/// Renders visible layers of a file. Uses a cached composite texture when all -/// layers are drawn without peeking or dimming; falls back to per-layer draws -/// otherwise. During active drawing, uses split composites (below/above the -/// active layer) to avoid per-frame render target switches while still reducing -/// draw calls from N to 5. -pub fn renderLayers(init_opts: RenderFileOptions) !void { - const t0 = perf.renderLayersBegin(); - defer perf.renderLayersEnd(t0); - - perf.draw_render_layers_calls += 1; - - const content_rs = init_opts.rs; - - flushPendingLayerTextureUploads(init_opts); - - if (content_rs.s == 0) return; - if (dvui.clipGet().intersect(content_rs.r).empty()) return; - - const vs = layerViewStateForRender(init_opts); - const min_layer_index = vs.min_layer_index; - const needs_dimmed = vs.needs_dimmed; - - 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()); - 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(), .{ - .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()); - 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 }); - t.uvFromRectuv(content_rs.r, init_opts.uv); - break :blk t; - }; - defer triangles.deinit(Globals.allocator()); - - var dimmed_triangles: ?dvui.Triangles = null; - defer { - if (dimmed_triangles) |*dt| dt.deinit(Globals.allocator()); - } - if (needs_dimmed) { - var dt = try triangles.dupe(Globals.allocator()); - dt.color(.gray); - dimmed_triangles = dt; - } - - defer { - if (dvui.textureGetCached(init_opts.file.editor.selection_layer.source.hash()) == null) - perf.draw_texture_creates += 1; - dvui.renderTriangles(triangles, init_opts.file.editor.selection_layer.source.getTexture() catch null) catch { - dvui.log.err("Failed to render selection layer", .{}); - }; - - if (init_opts.file.editor.temp_layer_has_content) { - const temp_source = init_opts.file.editor.temporary_layer.source; - if (dvui.textureGetCached(temp_source.hash()) == null) - perf.draw_texture_creates += 1; - if (dvui.textureGetCached(temp_source.hash())) |cached| { - dvui.renderTriangles(triangles, cached) catch { - dvui.log.err("Failed to render temporary layer", .{}); - }; - } else { - dvui.renderTriangles(triangles, temp_source.getTexture() catch null) catch { - dvui.log.err("Failed to render temporary layer", .{}); - }; - } - } - } - - // Active stroke or transform: split composites (below + active + [transform] + above). - if (splitCompositeEligible(init_opts, min_layer_index, needs_dimmed)) { - syncSplitComposite(init_opts.file) catch |err| { - dvui.log.err("Split composite sync failed: {any}", .{err}); - }; - - const has_below = init_opts.file.editor.split_composite_below != null; - const has_above = init_opts.file.editor.split_composite_above != null; - - if (has_below or has_above) { - if (dvui.textureGetCached(init_opts.file.layers.items(.source)[init_opts.file.selected_layer_index].hash()) == null) - perf.draw_texture_creates += 1; - if (has_below) { - if (dvui.Texture.fromTargetTemp(init_opts.file.editor.split_composite_below.?) catch null) |tex| { - dvui.renderTriangles(triangles, tex) catch { - dvui.log.err("Failed to render below composite", .{}); - }; - } - } - - const active_source = init_opts.file.layers.items(.source)[init_opts.file.selected_layer_index]; - if (init_opts.file.layers.items(.visible)[init_opts.file.selected_layer_index]) { - if (active_source.getTexture() catch null) |tex| { - dvui.renderTriangles(triangles, tex) catch { - dvui.log.err("Failed to render active layer", .{}); - }; - } - } - - renderTransformIfActive(init_opts, triangles); - - if (has_above) { - if (dvui.Texture.fromTargetTemp(init_opts.file.editor.split_composite_above.?) catch null) |tex| { - dvui.renderTriangles(triangles, tex) catch { - dvui.log.err("Failed to render above composite", .{}); - }; - } - } - - return; - } - } - - // When idle: use full composite (all layers = 1 draw) - if (fullCompositeEligible(init_opts, min_layer_index, needs_dimmed)) { - syncLayerComposite(init_opts.file) catch |err| { - dvui.log.err("Layer composite sync failed: {any}", .{err}); - }; - if (init_opts.file.editor.layer_composite_target) |ct| { - if (dvui.Texture.fromTargetTemp(ct) catch null) |ctex| { - dvui.renderTriangles(triangles, ctex) catch { - dvui.log.err("Failed to render layer composite", .{}); - }; - return; - } - } - } - - // Fallback: per-layer rendering - var order_buf: [1024]usize = undefined; - const order_opt = layerOrderBufForDragPreview(init_opts.file, order_buf[0..]); - - var list_pos: usize = init_opts.file.layers.len; - while (list_pos > min_layer_index) { - list_pos -= 1; - const layer_index = if (order_opt) |o| o[list_pos] else list_pos; - - const visible = init_opts.file.layers.items(.visible)[layer_index]; - - var tris = triangles; - - if (needs_dimmed) { - if (init_opts.file.peek_layer_index) |peek_layer_index| { - if (peek_layer_index != layer_index) { - tris = dimmed_triangles.?; - } - } - } - - if (visible) { - const source = init_opts.file.layers.items(.source)[layer_index]; - if (source.getTexture() catch null) |tex| { - dvui.renderTriangles(tris, tex) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - } - - if (layer_index == init_opts.file.selected_layer_index) { - renderTransformIfActive(init_opts, triangles); - } - } -} diff --git a/src/plugins/pixelart/src/sprite_render.zig b/src/plugins/pixelart/src/sprite_render.zig deleted file mode 100644 index 2b0d705e..00000000 --- a/src/plugins/pixelart/src/sprite_render.zig +++ /dev/null @@ -1,694 +0,0 @@ -//! Sprite/atlas rendering library for the pixel-art plugin. -//! -//! 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. -const std = @import("std"); -const dvui = @import("dvui"); -const pixelart = @import("../pixelart.zig"); -const Globals = pixelart.Globals; - -pub const SpriteInitOptions = struct { - source: dvui.ImageSource, - file: ?*pixelart.internal.File = null, - alpha_source: ?dvui.ImageSource = null, - sprite: pixelart.core_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 = 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 -/// 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| pixelart.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 = pixelart.render.RenderFileOptions{ - .file = file, - .rs = .{ - .r = wd.contentRectScale().r, - .s = wd.contentRectScale().s, - }, - .uv = uv, - .corner_radius = .all(0), - }; - 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}); - }; - - 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| { - pixelart.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: 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; - }; - - 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/pixelart/src/transform_op.zig b/src/plugins/pixelart/src/transform_op.zig deleted file mode 100644 index ad03b262..00000000 --- a/src/plugins/pixelart/src/transform_op.zig +++ /dev/null @@ -1,123 +0,0 @@ -//! 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/pixelart/src/web_file_io.zig b/src/plugins/pixelart/src/web_file_io.zig deleted file mode 100644 index 62718bfc..00000000 --- a/src/plugins/pixelart/src/web_file_io.zig +++ /dev/null @@ -1,30 +0,0 @@ -//! 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/CanvasBridge.zig b/src/plugins/pixelart/src/widgets/CanvasBridge.zig deleted file mode 100644 index 7fe8869a..00000000 --- a/src/plugins/pixelart/src/widgets/CanvasBridge.zig +++ /dev/null @@ -1,24 +0,0 @@ -//! 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; - -/// 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)) { - .mouse => .mouse, - .trackpad => .trackpad, - }; -} - -/// Suppression hook for a main-scope canvas (the document editing surface, image previews). -pub fn mainSuppressed(_: ?*anyopaque) bool { - return pixelart.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(); -} diff --git a/src/plugins/pixelart/src/widgets/FileWidget.zig b/src/plugins/pixelart/src/widgets/FileWidget.zig deleted file mode 100644 index 0936378e..00000000 --- a/src/plugins/pixelart/src/widgets/FileWidget.zig +++ /dev/null @@ -1,6013 +0,0 @@ -const std = @import("std"); -const math = std.math; -const dvui = @import("dvui"); -const builtin = @import("builtin"); - -const Options = dvui.Options; -const Rect = dvui.Rect; -const Point = dvui.Point; - -const BoxWidget = dvui.BoxWidget; -const ButtonWidget = dvui.ButtonWidget; -const ScrollAreaWidget = dvui.ScrollAreaWidget; -const ScrollContainerWidget = dvui.ScrollContainerWidget; -const ScaleWidget = dvui.ScaleWidget; - -pub const FileWidget = @This(); -const CanvasWidget = pixelart.core.dvui.CanvasWidget; -const CanvasBridge = @import("CanvasBridge.zig"); -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 { - 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 = &Globals.state.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 Globals.state.tools.current == .pointer; -} - -init_options: InitOptions, -options: Options, -drag_data_point: ?dvui.Point = null, -/// Absolute Δx/Δy from opposite corner → dragged corner at transform vertex press; used for default (no-mod) aspect lock. -transform_aspect_w: ?f32 = null, -transform_aspect_h: ?f32 = null, -sample_data_point: ?dvui.Point = null, -resize_data_point: ?dvui.Point = null, -previous_mods: dvui.enums.Mod = .none, -left_mouse_down: bool = false, -right_mouse_down: bool = false, -sample_key_down: bool = false, -shift_key_down: bool = false, -hide_distance_bubble: bool = false, -hovered_bubble_sprite_index: ?usize = null, -grid_reorder_point: ?dvui.Point = null, -cell_reorder_point: ?dvui.Point = null, -cell_reorder_mode: SpriteReorderMode = .replace, - -removed_sprite_indices: ?[]usize = null, -insert_before_sprite_indices: ?[]usize = null, - -const SpriteReorderMode = enum { - replace, - insert, -}; - -pub const InitOptions = struct { - file: *pixelart.internal.File, - center: bool = false, -}; - -pub const temp_ms: u32 = 1000; // Default 1 second - -pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) FileWidget { - const fw: FileWidget = .{ - .init_options = init_opts, - .options = opts, - .drag_data_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "drag_data_point", dvui.Point)) |point| point else null, - .transform_aspect_w = dvui.dataGet(null, init_opts.file.editor.canvas.id, "transform_aspect_w", f32), - .transform_aspect_h = dvui.dataGet(null, init_opts.file.editor.canvas.id, "transform_aspect_h", f32), - .sample_data_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "sample_data_point", dvui.Point)) |point| point else null, - .sample_key_down = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "sample_key_down", bool)) |key| key else false, - .resize_data_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "resize_data_point", dvui.Point)) |point| point else null, - .grid_reorder_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "grid_reorder_point", dvui.Point)) |point| point else null, - .cell_reorder_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "cell_reorder_point", dvui.Point)) |point| point else null, - .right_mouse_down = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "right_mouse_down", bool)) |key| key else false, - .left_mouse_down = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "left_mouse_down", bool)) |key| key else false, - .hide_distance_bubble = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "hide_distance_bubble", bool)) |key| key else false, - .removed_sprite_indices = if (dvui.dataGetSlice(null, init_opts.file.editor.canvas.id, "removed_sprite_indices", []usize)) |slice| slice else null, - }; - - init_opts.file.editor.canvas.install(src, .{ - .id = init_opts.file.editor.canvas.id, - .data_size = .{ - .w = @floatFromInt(init_opts.file.width()), - .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; -} - -pub fn processSample(self: *FileWidget) void { - const file = self.init_options.file; - - const current_mods = dvui.currentWindow().modifiers; - - const mouse_pt = dvui.currentWindow().mouse_pt; - - if (current_mods.matchBind("ctrl/cmd") and !self.previous_mods.matchBind("ctrl/cmd") and (self.right_mouse_down or self.sample_key_down)) { - const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_pt); - self.sample(file, current_point, mouse_pt, true, true); - } - - if (current_mods.matchBind("sample") and !self.previous_mods.matchBind("sample")) { - self.sample_key_down = true; - const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_pt); - self.sample(file, current_point, mouse_pt, self.right_mouse_down or self.left_mouse_down, false); - } else if (!current_mods.matchBind("sample") and self.sample_key_down) { - self.sample_key_down = false; - if (!self.right_mouse_down) { - self.sample_data_point = null; - } - } - - const canvas = &self.init_options.file.editor.canvas; - const scroll_container = canvas.scroll_container; - if (!canvas.installed) return; - - const scroll_id = scroll_container.data().id; - - for (dvui.events()) |*e| { - switch (e.evt) { - .mouse => |me| { - const sample_captured = dvui.captured(scroll_id); - if (!scroll_container.matchEvent(e) and !sample_captured) { - continue; - } - const current_point = canvas.dataFromScreenPoint(me.p); - - if (me.action == .press and me.button.pointer()) { - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - self.left_mouse_down = true; - if (dvui.dragging(me.p, "sample_drag")) |_| { - self.sample(file, current_point, me.p, true, false); - } - } else if (me.action == .release and me.button.pointer()) { - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - self.left_mouse_down = false; - } - - if (me.action == .press and me.button == .right) { - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - self.right_mouse_down = true; - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "sample_drag" }); - self.drag_data_point = current_point; - - self.sample(file, current_point, me.p, self.sample_key_down or self.left_mouse_down, false); - - clearTempPreview(&file.editor); - if (file.editor.temp_layer_has_content) { - @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); - } - file.editor.temp_layer_has_content = false; - file.editor.temporary_layer.dirty = false; - } else if (me.action == .release and me.button == .right) { - dvui.refresh(null, @src(), scroll_container.data().id); - self.right_mouse_down = false; - if (sample_captured) { - e.handle(@src(), scroll_container.data()); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - if (canvas.samplePointerInViewport(me.p)) { - self.sample(file, current_point, me.p, self.sample_key_down or self.left_mouse_down, true); - } - if (!self.sample_key_down) { - self.drag_data_point = null; - self.sample_data_point = null; - } - } - } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) { - if (sample_captured and !canvas.samplePointerInViewport(me.p)) { - self.sample_data_point = null; - } - if (dvui.captured(scroll_id)) { - if (dvui.dragging(me.p, "sample_drag")) |diff| { - const previous_point = current_point.plus(self.init_options.file.editor.canvas.dataFromScreenPoint(diff)); - // Construct a rect spanning between current_point and previous_point - const min_x = @min(previous_point.x, current_point.x); - const min_y = @min(previous_point.y, current_point.y); - const max_x = @max(previous_point.x, current_point.x); - const max_y = @max(previous_point.y, current_point.y); - const span_rect = dvui.Rect{ - .x = min_x, - .y = min_y, - .w = max_x - min_x + 5, - .h = max_y - min_y + 5, - }; - - const screen_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect); - - dvui.scrollDrag(.{ - .mouse_pt = me.p, - .screen_rect = screen_rect, - }); - - self.sample(file, current_point, me.p, self.sample_key_down or self.left_mouse_down or current_mods.matchBind("ctrl/cmd"), false); - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - } - } else if (self.right_mouse_down or self.sample_key_down) { - self.sample(file, current_point, me.p, self.right_mouse_down and (self.sample_key_down or self.left_mouse_down or current_mods.matchBind("ctrl/cmd")), false); - } - } - }, - else => {}, - } - } -} - -/// 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 { - if (file.editor.isolate_layer) return; - - var layer_index: usize = file.layers.len; - while (layer_index > 0) { - layer_index -= 1; - var layer = file.layers.get(layer_index); - if (!layer.visible) continue; - if (layer.pixelIndex(point)) |index| { - const c = layer.pixels()[index]; - if (c[3] > 0) { - file.peek_layer_index = layer_index; - } - } - } -} - -/// 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, - point: dvui.Point, - change_layer: bool, - apply_primary: bool, - change_tool: bool, -) void { - var color: [4]u8 = .{ 0, 0, 0, 0 }; - - var min_layer_index: usize = 0; - 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()) { - min_layer_index = file.selected_layer_index; - } - } - - var layer_index: usize = file.layers.len; - while (layer_index > min_layer_index) { - layer_index -= 1; - var layer = file.layers.get(layer_index); - if (!layer.visible) continue; - if (layer.pixelIndex(point)) |index| { - const c = layer.pixels()[index]; - if (c[3] > 0) { - color = c; - if (change_layer and !file.editor.isolate_layer) { - file.selected_layer_index = layer_index; - file.peek_layer_index = layer_index; - // 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.layer_selection_anchor = layer_index; - } - } - } - } - - if (change_tool) { - const off_canvas = point.x < 0 or point.y < 0 or - point.x >= @as(f32, @floatFromInt(file.width())) or - point.y >= @as(f32, @floatFromInt(file.height())); - 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); - } - } else if (color[3] == 0) { - if (Globals.state.tools.current != .eraser) { - Globals.state.tools.set(.eraser); - } - } else { - Globals.state.colors.primary = color; - if (switch (Globals.state.tools.current) { - .pencil, .bucket => false, - else => true, - }) - Globals.state.tools.set(Globals.state.tools.previous_drawing_tool); - } - } else if (apply_primary and color[3] > 0) { - Globals.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 { - if (!file.editor.canvas.samplePointerInViewport(screen_p)) { - self.sample_data_point = null; - return; - } - self.sample_data_point = point; - sampleColorAtPoint(file, point, change_layer, change_tool, change_tool); -} - -/// Responsible for changing the currently selected animation index, the animation frame index, and the animations scroll to index -/// when the user clicks on a sprite that is part of an animation. -/// -/// This is not restricted to any pane or tool, and will change on hover for any tool except the pointer tool. -pub fn processAnimationSelection(self: *FileWidget) void { - const file = self.init_options.file; - for (dvui.events()) |*e| { - if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - - 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 (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| { - for (frames, 0..) |frame, frame_index| { - if (frame.sprite_index == sprite_index) { - file.selected_animation_index = anim_index; - file.editor.animations_scroll_to_index = anim_index; - - if (!file.editor.playing) - file.selected_animation_frame_index = frame_index; - - file.collapseAnimationSelectionToPrimary(); - found = true; - break; - } - if (found) break; - } - if (found) break; - } - } - } - }, - else => {}, - } - } -} - -pub fn processCellReorder(self: *FileWidget) void { - 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; - if (dvui.currentWindow().modifiers.matchBind("shift")) return; - - const file = self.init_options.file; - - for (dvui.events()) |*e| { - if (!file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - - switch (e.evt) { - .mouse => |me| { - const current_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - - var selected_sprite_move_hovered: bool = false; - - var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |sprite_index| { - const sprite_rect = file.spriteRect(sprite_index); - if (sprite_rect.contains(current_point)) { - selected_sprite_move_hovered = true; - break; - } - } - - if (selected_sprite_move_hovered) { - dvui.cursorSet(.hand); - } - - if (me.action == .press and me.button.pointer()) { - if (file.editor.selected_sprites.count() > 0) { - if (selected_sprite_move_hovered) { - e.handle(@src(), file.editor.canvas.scroll_container.data()); - dvui.captureMouse(file.editor.canvas.scroll_container.data(), e.num); - - const index = file.spriteIndex(current_point); - var offset: dvui.Point = .{}; - if (index) |i| { - offset = file.spriteRect(i).topLeft().diff(current_point); - } - dvui.dragPreStart(me.p, .{ .name = "sprite_reorder_drag", .offset = file.editor.canvas.screenFromDataPoint(offset) }); - - self.cell_reorder_point = current_point; - } - } - } else if (me.action == .release and me.button.pointer()) { - if (dvui.captured(file.editor.canvas.scroll_container.data().id) and dvui.dragging(me.p, "sprite_reorder_drag") != null) { - e.handle(@src(), file.editor.canvas.scroll_container.data()); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - dvui.refresh(null, @src(), file.editor.canvas.scroll_container.data().id); - } - - if (self.cell_reorder_point) |cell_reorder_point| { - defer self.cell_reorder_point = null; - const drag_index = file.spriteIndex(cell_reorder_point.plus(file.editor.canvas.dataFromScreenPoint(dvui.dragOffset()))); - if (drag_index) |di| { - if (di != file.spriteIndex(current_point)) { - // Drag has moved to a new cell, so we have shifted some sprites around - // and we have released, so we need to allocate a new array of insert_before_sprite_indices - - 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); - 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 { - dvui.log.err("Failed to allocate insert before sprite indices", .{}); - return; - }; - for (removed_sprite_indices, 0..) |removed_sprite_index, i| { - const removed_sprite_rect = file.spriteRect(removed_sprite_index); - const difference = current_point.diff(cell_reorder_point); - - if (file.spriteIndex(removed_sprite_rect.center().plus(difference))) |index| { - insert_before_sprite_indices[i] = index; - } else { - insert_before_sprite_indices[i] = file.wrappedSpriteIndex(removed_sprite_rect.center().plus(difference)); - } - } - - self.insert_before_sprite_indices = insert_before_sprite_indices; - - // This is where we will call reorder - file.reorderCells(removed_sprite_indices, insert_before_sprite_indices, .replace, false) catch { - dvui.log.err("Failed to reorder sprites", .{}); - return; - }; - - file.history.append(.{ - .reorder_cell = .{ - .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 = Globals.allocator().dupe(usize, insert_before_sprite_indices) catch { - dvui.log.err("Failed to duplicate insert before sprite indices", .{}); - return; - }, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - return; - }; - } - } - } - } - - if (self.removed_sprite_indices) |_| { - self.removed_sprite_indices = null; - dvui.dataRemove(null, file.editor.canvas.id, "removed_sprite_indices"); - } - } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) { - if (dvui.captured(file.editor.canvas.scroll_container.data().id)) { - if (dvui.dragging(me.p, "sprite_reorder_drag")) |_| { - 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 { - dvui.log.err("Failed to allocate removed sprite indices", .{}); - return; - }; - var i: usize = 0; - var sprite_iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (sprite_iter.next()) |sprite_index| { - removed_sprite_indices[i] = sprite_index; - i += 1; - } - self.removed_sprite_indices = removed_sprite_indices; - dvui.dataSetSlice(null, file.editor.canvas.id, "removed_sprite_indices", removed_sprite_indices); - } - } - } - } - }, - else => {}, - } - } -} - -/// Responsible for handling rough/broad sprite selection (grid tiles) -/// Sprites can only be selected with the pointer tool. -/// -/// Supports add/remove, drag selection, etc. -pub fn processSpriteSelection(self: *FileWidget) void { - if (Globals.state.tools.current != .pointer) return; - if (self.init_options.file.editor.transform != null) return; - if (self.sample_data_point != null) return; - - const file = self.init_options.file; - - // A second finger landing on a pending single-finger press promotes the touch to a - // pan/pinch gesture (see CanvasWidget.updateTouchGesture). The first press already - // ran through this handler and seeded `drag_data_point` / a `sprite_selection_drag`, - // which would otherwise render the marquee box while the user pans. The gesture - // takeover already reassigned mouse capture to the scaler, so just clear our own - // drag state and end the named drag. - if (file.editor.canvas.gesture_active) { - if (self.drag_data_point != null) { - self.drag_data_point = null; - if (dvui.dragName("sprite_selection_drag")) { - dvui.dragEnd(); - } - dvui.refresh(null, @src(), file.editor.canvas.scroll_container.data().id); - } - return; - } - - for (dvui.events()) |*e| { - if (!file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - - switch (e.evt) { - .key => |ke| { - if (ke.mod.matchBind("shift")) { - switch (ke.action) { - .down, .repeat => { - self.shift_key_down = true; - }, - .up => { - self.shift_key_down = false; - }, - } - } - }, - .mouse => |me| { - const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p); - - if (me.action == .press and me.button.pointer()) { - // A press off the artboard with no selection modifier belongs to - // the canvas pan (handled later in canvas.processEvents) — yield it - // so dragging empty space pans instead of starting a marquee. Holding - // ctrl/cmd (add) or shift (subtract) keeps the selection meaning even - // out in the margins, so those still fall through to the logic below. - const sel_mod = me.mod.matchBind("ctrl/cmd") or me.mod.matchBind("shift"); - if (!sel_mod and !file.editor.canvas.pointerOverDrawable(me.p)) { - continue; - } - - if (me.mod.matchBind("shift")) { - self.shift_key_down = true; - if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| { - file.editor.selected_sprites.unset(sprite_index); - } - } else if (me.mod.matchBind("ctrl/cmd")) { - if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| { - file.editor.selected_sprites.set(sprite_index); - file.editor.primary_sprite_index = sprite_index; - } - } else { - if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| { - const selected = file.editor.selected_sprites.isSet(sprite_index); - file.clearSelectedSprites(); - - if (!selected) { - file.editor.selected_sprites.set(sprite_index); - file.editor.primary_sprite_index = sprite_index; - } - } else if (!file.editor.canvas.hovered) { - Globals.state.host.cancel() catch { - dvui.log.err("Failed to cancel", .{}); - }; - } - } - - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "sprite_selection_drag" }); - - self.drag_data_point = current_point; - } else if (me.action == .release and me.button.pointer()) { - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id) and dvui.dragging(me.p, "sprite_selection_drag") != null) { - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - } - self.drag_data_point = null; - } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) { - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - if (dvui.dragging(me.p, "sprite_selection_drag")) |_| { - if (self.drag_data_point) |previous_point| { - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - const min_x = @min(previous_point.x, current_point.x); - const min_y = @min(previous_point.y, current_point.y); - const max_x = @max(previous_point.x, current_point.x); - const max_y = @max(previous_point.y, current_point.y); - const span_rect = dvui.Rect{ - .x = min_x, - .y = min_y, - .w = @max(max_x - min_x, 1), - .h = @max(max_y - min_y, 1), - }; - - const screen_selection_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect); - - dvui.scrollDrag(.{ - .mouse_pt = me.p, - .screen_rect = screen_selection_rect, - }); - - if (me.mod.matchBind("shift")) { - file.setSpriteSelection(span_rect, false); - self.shift_key_down = true; - //selection_color = dvui.themeGet().color(.err, .fill).opacity(0.5); - } else if (me.mod.matchBind("ctrl/cmd")) { - file.setSpriteSelection(span_rect, true); - self.shift_key_down = false; - } else { - self.shift_key_down = false; - file.clearSelectedSprites(); - file.setSpriteSelection(span_rect, true); - } - } - } - } - } - }, - else => {}, - } - } -} - -/// Cached once per `drawSpriteBubbles` / grid batch — avoids per-sprite `matchBind`, `count`, and `animationGet`. -const BubblePanShared = struct { - bubble_open: ?dvui.Animation, - bubble_close: ?dvui.Animation, - peek: bool, - selection_nonempty: bool, - tool_not_pointer: bool, -}; - -/// 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); -} - -/// 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.columnRowReorderActive()) return null; - if (self.removed_sprite_indices != null) return null; - if (!(self.active() or self.hovered())) return null; - - 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 mod_shift = cw.modifiers.matchBind("shift"); - const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd"); - const sample_active = self.sample_data_point != null; - const drag_sprite_selection = dvui.dragName("sprite_selection_drag"); - - return .{ - .bubble_open = dvui.animationGet(animation_id, "bubble_open"), - .bubble_close = dvui.animationGet(animation_id, "bubble_close"), - .peek = drag_sprite_selection or mod_shift or mod_ctrl_cmd or tool_not_pointer or sample_active, - .selection_nonempty = self.init_options.file.editor.selected_sprites.count() > 0, - .tool_not_pointer = tool_not_pointer, - }; -} - -/// Returns whether `drawSpriteBubbles` will invoke `drawSpriteBubble` for this sprite (same -/// conditions as the inner loop, without the shadow/bubble pass split). Used so horizontal grid -/// can be drawn per cell: we skip the flat grid segment where the bubble arc replaces it. -/// Pass shared bubble state from `bubblePanSharedForGrid` when iterating many sprites (avoids repeated `animationGet`). -fn spriteDrawsBubbleTopEdge(self: *FileWidget, sprite_index: usize, pan: ?BubblePanShared) bool { - const p = pan orelse return false; - - const sprite_rect = self.init_options.file.spriteRect(sprite_index); - - var automatic_animation: bool = false; - var animation_index: ?usize = null; - - if (self.init_options.file.selected_animation_index) |selected_animation_index| { - for (self.init_options.file.animations.items(.frames)[selected_animation_index], 0..) |frame, i| { - _ = i; - if (frame.sprite_index == sprite_index) { - animation_index = selected_animation_index; - break; - } - } - } - - if (animation_index == null) { - anim_blk: for (self.init_options.file.animations.items(.frames), 0..) |frames, i| { - for (frames, 0..) |frame, j| { - _ = j; - if (frame.sprite_index == sprite_index) { - animation_index = i; - break :anim_blk; - } - } - } - } - - if (animation_index) |ai| { - if (self.init_options.file.selected_animation_index == ai) { - automatic_animation = true; - } - } - - const sel_nonempty = p.selection_nonempty; - if (sel_nonempty) { - if (self.init_options.file.editor.selected_sprites.isSet(sprite_index)) { - automatic_animation = true; - } - } - - if (automatic_animation) { - return true; - } - - if (sel_nonempty) { - if (!self.init_options.file.editor.selected_sprites.isSet(sprite_index) or (animation_index != self.init_options.file.selected_animation_index and !self.init_options.file.editor.selected_sprites.isSet(sprite_index))) { - return false; - } - } - - var max_distance: f32 = sprite_rect.h * 1.2; - - if (p.bubble_open) |anim| { - max_distance += (max_distance * 0.5) * (1.0 - anim.value()); - } else if (p.bubble_close) |anim| { - max_distance += (max_distance * 0.5) * (1.0 - anim.value()); - } else { - max_distance += (max_distance * 0.5) * if (!self.hide_distance_bubble) @as(f32, 0.0) else @as(f32, 1.0); - } - - const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - - const dx = @abs(current_point.x - (sprite_rect.x + sprite_rect.w * 0.5)); - const dy = @abs(current_point.y - (sprite_rect.y - sprite_rect.h * 0.25)); - const distance = @sqrt((dx * dx) * 0.5 + (dy * dy) * 2.0); - - return distance < (max_distance * 2.0); -} - -/// Accumulator that merges multiple Triangles batches into a single draw call. -const TriAcc = struct { - vtx: std.ArrayList(dvui.Vertex) = .empty, - idx: std.ArrayList(dvui.Vertex.Index) = .empty, - alloc: std.mem.Allocator, - - fn init(alloc: std.mem.Allocator) TriAcc { - return .{ .alloc = alloc }; - } - - fn append(self: *TriAcc, tris: dvui.Triangles) void { - const base: dvui.Vertex.Index = @intCast(self.vtx.items.len); - self.vtx.appendSlice(self.alloc, tris.vertexes) catch return; - self.idx.ensureUnusedCapacity(self.alloc, tris.indices.len) catch return; - for (tris.indices) |idx| { - self.idx.appendAssumeCapacity(idx + base); - } - } - - fn render(self: *const TriAcc, tex: ?dvui.Texture) void { - if (self.vtx.items.len == 0) return; - var min_x: f32 = std.math.floatMax(f32); - var min_y: f32 = std.math.floatMax(f32); - var max_x: f32 = -std.math.floatMax(f32); - var max_y: f32 = -std.math.floatMax(f32); - for (self.vtx.items) |v| { - min_x = @min(min_x, v.pos.x); - min_y = @min(min_y, v.pos.y); - max_x = @max(max_x, v.pos.x); - max_y = @max(max_y, v.pos.y); - } - dvui.renderTriangles(.{ - .vertexes = self.vtx.items, - .indices = self.idx.items, - .bounds = .{ .x = min_x, .y = min_y, .w = max_x - min_x, .h = max_y - min_y }, - }, tex) catch {}; - } - - fn clear(self: *TriAcc) void { - self.vtx.clearRetainingCapacity(); - self.idx.clearRetainingCapacity(); - } -}; - -const BubbleAccs = struct { - shadow: TriAcc, - fill: TriAcc, - tex: TriAcc, - outline: TriAcc, - - fn init(alloc: std.mem.Allocator) BubbleAccs { - return .{ - .shadow = TriAcc.init(alloc), - .fill = TriAcc.init(alloc), - .tex = TriAcc.init(alloc), - .outline = TriAcc.init(alloc), - }; - } - - fn clearAll(self: *BubbleAccs) void { - self.shadow.clear(); - self.fill.clear(); - self.tex.clear(); - self.outline.clear(); - } -}; - -/// Responsible for drawing the indicators for animation frames as bubbles over each sprite. -/// -/// Bubbles contain a button that acts as a toggle for adding/removing a sprite from an animation. -/// When using the pointer tool, bubbles will be drawn based on distance from the mouse location, as well as the currently selected animation frames. -/// When using other tools, bubbles will be drawn based on the currently selected animation frames. -/// -/// Bubbles use a elastic animation, and also display the currently viewed animation frame in the panel. -pub fn drawSpriteBubbles(self: *FileWidget) void { - if (self.init_options.file.editor.transform != null) return; - if (self.resize_data_point != null) return; - - 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 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 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(); - - { // Create animations for closing or opening bubbles - const bubble_open_hdr = dvui.animationGet(animation_id, "bubble_open"); - const bubble_close_hdr = dvui.animationGet(animation_id, "bubble_close"); - - if ((drag_sprite_selection or tool_not_pointer or mod_shift or mod_ctrl_cmd) or - radial_visible or - sample_active or - canvas_gesturing) - { - if (bubble_close_hdr) |anim| { - if (anim.done()) { - self.hide_distance_bubble = true; - } - } else if (bubble_open_hdr != null) { - _ = dvui.currentWindow().animations.remove(animation_id.update("bubble_open")); - dvui.animation(animation_id, "bubble_close", .{ - .easing = dvui.easing.outQuint, - .end_time = 200_000, - .start_val = 1.0, - .end_val = 0.0, - }); - } else if (!self.hide_distance_bubble) { - dvui.animation(animation_id, "bubble_close", .{ - .easing = dvui.easing.outQuint, - .end_time = 200_000, - .start_val = 1.0, - .end_val = 0.0, - }); - } - } else { - if (bubble_open_hdr) |anim| { - if (anim.done()) { - self.hide_distance_bubble = false; - } - } else if (bubble_close_hdr != null) { - _ = dvui.currentWindow().animations.remove(animation_id.update("bubble_close")); - - dvui.animation(animation_id, "bubble_open", .{ - .easing = dvui.easing.outElastic, - .end_time = 900_000, - .start_val = 0.0, - .end_val = 1.0, - }); - } else if (self.hide_distance_bubble) { - dvui.animation(animation_id, "bubble_open", .{ - .easing = dvui.easing.outElastic, - .end_time = 900_000, - .start_val = 0.0, - .end_val = 1.0, - }); - } - } - } - - const bubble_open_draw = dvui.animationGet(animation_id, "bubble_open"); - const bubble_close_draw = dvui.animationGet(animation_id, "bubble_close"); - const selection_nonempty = self.init_options.file.editor.selected_sprites.count() > 0; - const pan_shared: BubblePanShared = .{ - .bubble_open = bubble_open_draw, - .bubble_close = bubble_close_draw, - .peek = drag_sprite_selection or mod_shift or mod_ctrl_cmd or tool_not_pointer or sample_active or canvas_gesturing, - .selection_nonempty = selection_nonempty, - .tool_not_pointer = tool_not_pointer, - }; - - const visible_data = self.init_options.file.editor.canvas.dataFromScreenRect(self.init_options.file.editor.canvas.rect); - const file = self.init_options.file; - const cols = file.columns; - const total_rows = file.rows; - if (total_rows == 0 or cols == 0) return; - - const row_h: f32 = @floatFromInt(file.row_height); - const col_w: f32 = @floatFromInt(file.column_width); - if (row_h <= 0 or col_w <= 0) return; - const bubble_headroom = @max(row_h, col_w); - - // Determine the visible row range to skip entire offscreen rows. - // Use explicit comparisons rather than clamp to be NaN-safe - // (NaN comparisons are always false, so NaN falls through to 0). - const max_row_f: f32 = @floatFromInt(total_rows); - const first_vis_f = (visible_data.y - bubble_headroom) / row_h; - const first_vis_row: usize = if (first_vis_f > 0 and first_vis_f < max_row_f) - @intFromFloat(first_vis_f) - else if (first_vis_f >= max_row_f) - total_rows - else - 0; - const last_vis_f = (visible_data.y + visible_data.h) / row_h + 2.0; - const last_vis_row: usize = if (last_vis_f > 0 and last_vis_f < max_row_f) - @intFromFloat(last_vis_f) - else if (last_vis_f >= max_row_f) - total_rows - else - 0; - - const checkerboard_tex = file.checkerboardTileTexture(); - var accs = BubbleAccs.init(dvui.currentWindow().arena()); - - // Row-based iteration with batched geometry rendering. - // Geometry is accumulated into TriAccs and rendered in bulk to minimize draw calls. - // - // `hovered_bubble_sprite_index` is set from geometry hit tests; it must reflect the - // bubble button under the mouse across *all* visible rows before any row's UI runs. - // Otherwise a vertical selection shows stale plus/minus hints on rows drawn earlier. - self.hovered_bubble_sprite_index = null; - - const vx0 = visible_data.x; - const vx1 = visible_data.x + visible_data.w; - - for (0..2) |pass_i| { - var row: usize = first_vis_row; - while (row < last_vis_row) : (row += 1) { - const row_start = row * cols; - const row_end = @min(row_start + cols, file.spriteCount()); - if (row_end <= row_start) continue; - - const row_span = row_end - row_start; - const base_y = @as(f32, @floatFromInt(row)) * row_h; - - // Horizontal clip: only columns whose cells can intersect the visible rect in x. - // Avoids spriteRect + cull for off-screen tiles (major win when zoomed / panned). - var col_lo: usize = 0; - if (vx0 > 0) col_lo = @intFromFloat(@floor(vx0 / col_w)); - if (vx1 <= 0) continue; - var col_hi_excl: usize = @intFromFloat(@ceil(vx1 / col_w)); - col_lo = @min(col_lo, row_span); - col_hi_excl = @min(col_hi_excl, row_span); - if (col_lo >= col_hi_excl) continue; - - const si_start = row_start + col_lo; - const si_end_excl = row_start + col_hi_excl; - - const first_sprite = dvui.Rect{ - .x = 0, - .y = base_y, - .w = col_w, - .h = row_h, - }; - const row_clip_screen = file.editor.canvas.screenFromDataRect(.{ - .x = first_sprite.x, - .y = first_sprite.y - bubble_headroom, - .w = col_w * @as(f32, @floatFromInt(cols)), - .h = bubble_headroom, - }); - - if (pass_i == 0) { - // Pass 0 — geometry: accumulate shadow + fill + tex + outline in one pass. - { - var si: usize = si_end_excl; - while (si > si_start) { - si -= 1; - const col_in_row = si - row_start; - const sprite_rect = bubbleSpriteDataRect(col_in_row, base_y, col_w, row_h); - if (!spriteCullVisible(sprite_rect, bubble_headroom, visible_data)) continue; - drawSpriteBubbleForRow(self, file, si, sprite_rect, &accs, pan_shared); - } - } - - // Render all accumulated geometry under the row clip - { - const prev_clip = dvui.clip(row_clip_screen); - defer dvui.clipSet(prev_clip); - accs.shadow.render(null); - accs.fill.render(null); - accs.tex.render(checkerboard_tex); - accs.outline.render(null); - } - accs.clearAll(); - } else { - // Pass 1 — UI: buttons, text, icons rendered per-sprite. - { - var si: usize = si_end_excl; - while (si > si_start) { - si -= 1; - const col_in_row = si - row_start; - const sprite_rect = bubbleSpriteDataRect(col_in_row, base_y, col_w, row_h); - if (!spriteCullVisible(sprite_rect, bubble_headroom, visible_data)) continue; - drawSpriteBubbleForRow(self, file, si, sprite_rect, null, pan_shared); - } - } - } - } - } -} - -fn spriteCullVisible(sprite_rect: dvui.Rect, headroom: f32, visible: dvui.Rect) bool { - const cull = dvui.Rect{ - .x = sprite_rect.x, - .y = sprite_rect.y - headroom, - .w = sprite_rect.w, - .h = sprite_rect.h + headroom, - }; - return !cull.intersect(visible).empty(); -} - -/// Data-space rect for sprite `si` when `row_start == row * cols` (same as `file.spriteRect(si)`). -fn bubbleSpriteDataRect(col_in_row: usize, base_y: f32, col_w: f32, row_h: f32) dvui.Rect { - return .{ - .x = @as(f32, @floatFromInt(col_in_row)) * col_w, - .y = base_y, - .w = col_w, - .h = row_h, - }; -} - -/// Per-sprite bubble logic extracted for use in the row-based loop. -/// Computes animation state and progress, then calls drawSpriteBubble. -/// When `accs` is non-null, geometry is accumulated instead of rendered. -/// When `accs` is null and `shadow_only` is false, only UI elements are drawn. -fn drawSpriteBubbleForRow( - self: *FileWidget, - file: *pixelart.internal.File, - sprite_index: usize, - sprite_rect: dvui.Rect, - accs: ?*BubbleAccs, - pan: BubblePanShared, -) void { - var color = dvui.themeGet().color(.window, .fill); - - var automatic_animation: bool = false; - var automatic_animation_frame_i: usize = 0; - - var animation_index: ?usize = null; - - if (file.selected_animation_index) |selected_animation_index| { - for (file.animations.items(.frames)[selected_animation_index], 0..) |frame, i| { - if (frame.sprite_index == sprite_index) { - automatic_animation_frame_i = i; - animation_index = selected_animation_index; - break; - } - } - } - - if (animation_index == null) { - anim_blk: for (file.animations.items(.frames), 0..) |frames, i| { - for (frames, 0..) |frame, j| { - if (frame.sprite_index == sprite_index) { - automatic_animation_frame_i = j; - animation_index = i; - break :anim_blk; - } - } - } - } - - if (animation_index) |ai| { - const id = file.animations.get(ai).id; - if (Globals.state.colors.file_tree_palette) |*palette| { - color = palette.getDVUIColor(@intCast(id)); - } - if (file.selected_animation_index == ai) { - automatic_animation = true; - } - } - - if (pan.selection_nonempty) { - if (file.editor.selected_sprites.isSet(sprite_index)) { - automatic_animation = true; - if (animation_index) |ai| { - if (ai != file.selected_animation_index) { - color = dvui.themeGet().color(.control, .fill_hover); - } - } - } - } - - if (automatic_animation) { - const total_duration: i32 = 1_500_000; - const max_step_duration: i32 = @divTrunc(total_duration, 3); - - var duration_step = max_step_duration; - - if (animation_index) |ai| { - duration_step = std.math.clamp(@divTrunc(total_duration, @as(i32, @intCast(file.animations.get(ai).frames.len))), 0, max_step_duration); - } - - const duration = max_step_duration + (duration_step * @as(i32, @intCast(automatic_animation_frame_i + 1))); - - var open: bool = true; - var id_extra: usize = sprite_index; - - if (animation_index) |ai| { - id_extra = dvui.Id.extendId(@enumFromInt(sprite_index), @src(), ai).asUsize(); - } - - { - const current_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - - const max_distance: f32 = @max(sprite_rect.h, sprite_rect.w) * 1.5; - - const dx = @abs(current_point.x - (sprite_rect.x + sprite_rect.w * 0.5)); - const dy = @abs(current_point.y - (sprite_rect.y) + sprite_rect.h * 0.5); - const distance = @sqrt(dx * dx + dy * dy); - - if (distance < max_distance and pan.peek and current_point.y - sprite_rect.y < 0.0 and current_point.y - sprite_rect.y > -sprite_rect.h) { - open = false; - id_extra = dvui.Id.update(@enumFromInt(id_extra), "peek").asUsize(); - } else { - id_extra = dvui.Id.update(@enumFromInt(id_extra), "unpeek").asUsize(); - } - } - - if (accs != null) { - id_extra = dvui.Id.update(@enumFromInt(id_extra), "geom").asUsize(); - } else { - id_extra = dvui.Id.update(@enumFromInt(id_extra), "ui").asUsize(); - } - - var t: f32 = 0.0; - - const anim = dvui.animate(@src(), .{ - .duration = if (open) duration else @divTrunc(duration, 4), - .kind = .vertical, - .easing = if (open) dvui.easing.outElastic else dvui.easing.outQuint, - }, .{ - .id_extra = id_extra, - }); - defer anim.deinit(); - - t = if (open) anim.val orelse 1.0 else std.math.clamp(1.0 - (anim.val orelse 1.0), 0.0, 2.0); - - if (drawSpriteBubble(self, sprite_index, sprite_rect, t, color, animation_index, accs, pan.bubble_open, pan.bubble_close, pan.tool_not_pointer)) { - self.hovered_bubble_sprite_index = sprite_index; - } - } else { - if (pan.selection_nonempty) { - if (!file.editor.selected_sprites.isSet(sprite_index) or (animation_index != file.selected_animation_index and !file.editor.selected_sprites.isSet(sprite_index))) { - return; - } - } - - const current_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - - var max_distance: f32 = sprite_rect.h * 1.2; - - if (pan.bubble_open) |anim| { - max_distance += (max_distance * 0.5) * (1.0 - anim.value()); - } else if (pan.bubble_close) |anim| { - max_distance += (max_distance * 0.5) * (1.0 - anim.value()); - } else { - max_distance += (max_distance * 0.5) * if (!self.hide_distance_bubble) @as(f32, 0.0) else @as(f32, 1.0); - } - - const dx = @abs(current_point.x - (sprite_rect.x + sprite_rect.w * 0.5)); - const dy = @abs(current_point.y - (sprite_rect.y - sprite_rect.h * 0.25)); - const distance = @sqrt((dx * dx) * 0.5 + (dy * dy) * 2.0); - - if (distance < (max_distance * 2.0)) { - var t: f32 = distance / max_distance; - - if (pan.bubble_open) |anim| { - t = (1.0 - t) * anim.value(); - } else if (pan.bubble_close) |anim| { - t = (1.0 - t) * anim.value(); - } else { - t = (1.0 - t) * if (self.hide_distance_bubble) @as(f32, 0.0) else @as(f32, 1.0); - } - - t = std.math.clamp(t, 0.0, 2.0); - - if (drawSpriteBubble( - self, - sprite_index, - sprite_rect, - t, - dvui.themeGet().color(.window, .fill).lerp(color, 1.0 - (distance / (max_distance * 2.0))), - animation_index, - accs, - pan.bubble_open, - pan.bubble_close, - pan.tool_not_pointer, - )) { - self.hovered_bubble_sprite_index = sprite_index; - } - } - } -} - -/// Draw a single sprite bubble based on sprite index and progress. Animation index just lets us know if not null, its part of an animation, -/// and if its equal to the currently selected animation index, we need to draw a checkmark in the bubble because its part of the currently selected animation. -/// When `accs` is non-null, triangle geometry is accumulated into the -/// accumulators instead of being rendered immediately. Pass null for the -/// UI-only phase so that only buttons/text/icons are drawn. -pub fn drawSpriteBubble( - self: *FileWidget, - sprite_index: usize, - sprite_rect: dvui.Rect, - progress: f32, - color: dvui.Color, - animation_index: ?usize, - accs: ?*BubbleAccs, - bubble_open: ?dvui.Animation, - bubble_close: ?dvui.Animation, - tool_not_pointer: bool, -) bool { - - // Would this sprite be removed if the user clicked the button? - var remove: bool = false; - if (self.init_options.file.selected_animation_index) |anim_index| { - const anim = self.init_options.file.animations.get(anim_index); - for (anim.frames) |frame| { - if (frame.sprite_index == sprite_index) { - remove = true; - } - } - } - - //if (sprite_index != 0) return; - const t = progress; - - const cell_tint = checkerboardTintAtSpriteCellCenter(self.init_options.file, sprite_index); - - const target_button_height: f32 = 24.0; - // Figure out artwork's baseline size (width or height, whichever is smaller) - const baseline_sprite_size: f32 = 64.0; - const min_sprite_size: f32 = @min(sprite_rect.w, sprite_rect.h); - const baseline_scale: f32 = baseline_sprite_size / min_sprite_size; - // Compensate the button size so that it stays visually consistent even if the tile is smaller/larger than 64x64 - var button_height = std.math.clamp((target_button_height * dvui.easing.outBack(t) / self.init_options.file.editor.canvas.scale), 0.0, min_sprite_size / 3.0); - - const sprite_rect_scale: dvui.RectScale = .{ - .r = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect), - .s = self.init_options.file.editor.canvas.scale, - }; - - var bubble_max_height: f32 = @min(sprite_rect.h, sprite_rect.w) * 0.5; - - if (self.init_options.file.selected_animation_index) |ai| { - if (self.init_options.file.selected_animation_frame_index < self.init_options.file.animations.get(ai).frames.len) { - const animation = self.init_options.file.animations.get(ai); - if (animation.frames.len > 0) { - const frame = animation.frames[self.init_options.file.selected_animation_frame_index]; - if (frame.sprite_index != sprite_index and animation_index == ai) { - bubble_max_height = @min(sprite_rect.h, sprite_rect.w) * 0.3333; - } - } - } - } else if (self.init_options.file.editor.selected_sprites.count() > 1) { - if (self.init_options.file.primarySpriteIndex()) |primary| { - if (sprite_index != primary) { - bubble_max_height = @min(sprite_rect.h, sprite_rect.w) * 0.3333; - } - } - } - - const bubble_height = std.math.clamp((bubble_max_height * t / self.init_options.file.editor.canvas.scale) * baseline_scale, 0.0, bubble_max_height * t); - const bubble_rect = dvui.Rect{ - .x = sprite_rect.x, - .y = sprite_rect.y - bubble_height, - .w = sprite_rect.w, - .h = bubble_height, - }; - - var bubble_rect_scale: dvui.RectScale = .{ - .r = self.init_options.file.editor.canvas.screenFromDataRect(bubble_rect), - .s = self.init_options.file.editor.canvas.scale, - }; - - var path = dvui.Path.Builder.init(dvui.currentWindow().lifo()); - - const center = bubble_rect.center(); - - // Choose a font size that fits scaled to button size. - const font = dvui.Font.theme(.body); - - const sprite_label = self.init_options.file.fmtSprite(dvui.currentWindow().arena(), sprite_index, .grid) catch { - dvui.log.err("Failed to format sprite index", .{}); - return false; - }; - - const text_size = font.textSize(sprite_label); - - var button_width = @max(button_height, (text_size.w + 4.0) / self.init_options.file.editor.canvas.scale); - - if (bubble_close) |anim| { - button_height *= anim.value(); - button_width *= anim.value(); - } else if (bubble_open) |anim| { - button_height *= anim.value(); - button_width *= anim.value(); - } else if (tool_not_pointer or self.hide_distance_bubble) { - button_height = 0.0; - button_width = 0.0; - } - - const button_rect = dvui.Rect{ .x = center.x - button_width / 2, .y = center.y - (button_height / 2), .w = button_width, .h = button_height }; - - if (bubble_rect_scale.r.h <= dvui.currentWindow().natural_scale) { - if (accs) |a| { - path.addPoint(bubble_rect_scale.r.topRight()); - path.addPoint(bubble_rect_scale.r.topLeft()); - const tris = path.build().strokeTriangles(dvui.currentWindow().arena(), .{ .thickness = 1, .color = color }) catch return false; - a.shadow.append(tris); - } - return false; - } else { - const ns = dvui.currentWindow().natural_scale; - // Upper bound can drop below `ns` when the sprite is only a few physical pixels (zoomed far out); - // `std.math.clamp` panics if min > max. - const sprite_screen_min = @min(sprite_rect_scale.r.h, sprite_rect_scale.r.w); - const arc_upper = sprite_screen_min * 0.5 - ns; - const arc_height = std.math.clamp(bubble_rect_scale.r.h, ns, @max(ns, arc_upper)); - - const d = bubble_rect_scale.r.w / 2; - - const radius: f32 = (d * d + arc_height * arc_height) / (2 * arc_height); - - const center_x: f32 = sprite_rect_scale.r.x + (sprite_rect_scale.r.w / 2); - - const arc_center: dvui.Point.Physical = .{ .x = center_x, .y = sprite_rect_scale.r.y + radius - arc_height }; - - const end_angle: f32 = std.math.atan2(arc_center.y - sprite_rect_scale.r.topLeft().y, arc_center.x - sprite_rect_scale.r.topLeft().x); - const start_angle: f32 = std.math.atan2(arc_center.y - sprite_rect_scale.r.topRight().y, arc_center.x - sprite_rect_scale.r.topRight().x); - - path.addArc(arc_center, radius, dvui.math.pi + start_angle, dvui.math.pi + end_angle, false); - - const built = path.build(); - defer path.deinit(); - - // Geometry phase: accumulate shadow + fill + outline into accumulators. - if (accs) |a| { - const shadow_fade = arc_height * 0.66 * dvui.easing.outExpo(t); - const shadow_color = dvui.Color.black.opacity(0.25); - var shadow_path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - shadow_path.addArc(arc_center, radius, dvui.math.pi + start_angle, dvui.math.pi + end_angle, false); - const shadow_tris = shadow_path.build().fillConvexTriangles(dvui.currentWindow().arena(), .{ .color = shadow_color, .fade = shadow_fade }) catch return false; - a.shadow.append(shadow_tris); - - if (self.init_options.file.editor.canvas.scale < 0.1) { - const fill_tris = built.fillConvexTriangles(dvui.currentWindow().arena(), .{ .color = cell_tint, .fade = 0.0 }) catch return false; - a.fill.append(fill_tris); - } else { - const fill_tris = built.fillConvexTriangles(dvui.currentWindow().arena(), .{ .color = cell_tint, .fade = 1.0 }) catch return false; - a.fill.append(fill_tris); - var tex_tris = built.fillConvexTriangles(dvui.currentWindow().arena(), .{ .color = cell_tint, .fade = 0.0 }) catch return false; - const h_ratio = arc_height / sprite_rect_scale.r.h; - tex_tris.uvFromRectuv(bubble_rect_scale.r, .{ .x = 0.0, .w = 1.0, .y = 1.0 - h_ratio, .h = h_ratio }); - a.tex.append(tex_tris); - } - const outline_tris = built.strokeTriangles(dvui.currentWindow().arena(), .{ .color = color, .thickness = dvui.currentWindow().natural_scale }) catch return false; - a.outline.append(outline_tris); - - const mouse_data_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - return button_rect.contains(mouse_data_pt); - } - - // UI-only phase: geometry was already batched, draw interactive content only. - // Dont draw any buttons if the button is too small or too large. - if (button_rect.w > bubble_rect.w * 0.666 or button_rect.w < bubble_rect.w * 0.001) return false; - - 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 (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 { - dvui.log.err("Failed to allocate add/remove message", .{}); - return false; - }; - } else { - add_rem_message = std.fmt.allocPrint(dvui.currentWindow().arena(), "New Animation", .{}) catch { - dvui.log.err("Failed to allocate add/remove message", .{}); - return false; - }; - } - } - - var show_hint: bool = false; - if (self.hovered_bubble_sprite_index) |hovered_button_index| { - var iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |selected_index| { - if (selected_index == sprite_index) { - show_hint = true; - } - } - - if (self.init_options.file.selected_animation_index) |selected_animation_index| { - const selected_animation = self.init_options.file.animations.get(selected_animation_index); - if (selected_animation.frames.len > 0) { - var hovered_in_animation: bool = false; - for (selected_animation.frames) |frame| { - if (frame.sprite_index == hovered_button_index) { - hovered_in_animation = true; - break; - } - } - - var current_in_animation: bool = false; - for (selected_animation.frames) |frame| { - if (frame.sprite_index == sprite_index) { - current_in_animation = true; - break; - } - } - - if (hovered_in_animation != current_in_animation) { - show_hint = false; - } - } - } - - var found_current_in_selection: bool = false; - - iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |selected_index| { - if (selected_index == hovered_button_index) { - found_current_in_selection = true; - } - } - - if (!found_current_in_selection) - show_hint = false; - } - - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{ - .draw_focus = false, - }, .{ - .rect = button_rect, - .margin = .all(0), - .padding = .all(0), - .id_extra = sprite_index, - .color_fill = dvui.themeGet().color(.control, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - //.color_border = dvui.themeGet().color(.control, .fill), - //.border = dvui.Rect.all(1).scale(1.0 / self.init_options.file.editor.canvas.scale, dvui.Rect), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -0.05 * button_height, .y = 0.08 * button_height }, - .fade = (button_height / 10) * t, - .alpha = 0.5 * t, - }, - .corner_radius = dvui.Rect.all(1000000), - .gravity_x = 0.5, - .gravity_y = 0.5, - }); - defer button.deinit(); - - button.processEvents(); - - if (button.hovered() or show_hint) { - if (remove) { - button.data().options.color_border = dvui.themeGet().color(.err, .fill).opacity(0.75); - } else { - button.data().options.color_border = dvui.themeGet().color(.highlight, .fill).opacity(0.75); - } - } - - button.drawBackground(); - - if (button.clicked()) { // Toggle animation frame on or off for this selection/animation - if (self.init_options.file.selected_animation_index) |anim_index| { - // TODO: Efficiently resize the animation frames array instead of duplicating it - - var anim = self.init_options.file.animations.get(anim_index); - - 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; - }; - - for (frames.items, 0..) |frame, i| { - if (frame.sprite_index == sprite_index) { - - // First remove the currently clicked frame, regardless - _ = frames.orderedRemove(i); - } - } - - if (self.init_options.file.editor.selected_sprites.count() > 0) { - var in_selection: bool = false; - var iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |selected_index| { - if (selected_index == sprite_index) { - in_selection = true; - break; - } - } - - if (in_selection) { - // Remove all selected_sprite_index values from frames, regardless of their position. - // To avoid skipping items due to shifting, iterate backward through frames. - iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |selected_sprite_index| { - var j: usize = frames.items.len; - while (j > 0) : (j -= 1) { - if (frames.items[j - 1].sprite_index == selected_sprite_index) { - _ = frames.orderedRemove(j - 1); - } - } - } - } - } - - if (!remove) { - if (self.init_options.file.editor.selected_sprites.count() > 0) { - var in_selection: bool = false; - - var iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |selected_index| { - if (selected_index == sprite_index) { - in_selection = true; - break; - } - } - - if (in_selection) { - iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |selected_index| { - frames.append(.{ .sprite_index = selected_index, .ms = temp_ms }) catch { - dvui.log.err("Failed to append frame", .{}); - return false; - }; - } - } else { - frames.append(.{ .sprite_index = sprite_index, .ms = temp_ms }) catch { - dvui.log.err("Failed to append frame", .{}); - return false; - }; - } - } else { - frames.append(.{ .sprite_index = sprite_index, .ms = temp_ms }) catch { - dvui.log.err("Failed to append frame", .{}); - return false; - }; - } - } - - if (!anim.eqlFrames(frames.items)) { - self.init_options.file.history.append(.{ - .animation_frames = .{ - .index = anim_index, - .frames = Globals.allocator().dupe(pixelart.Animation.Frame, anim.frames) catch { - dvui.log.err("Failed to dupe frames", .{}); - return false; - }, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - - Globals.allocator().free(anim.frames); - anim.frames = frames.toOwnedSlice() catch { - dvui.log.err("Failed to free frames", .{}); - return false; - }; - - self.init_options.file.animations.set(anim_index, anim); - } - } else { - if (self.init_options.file.createAnimation() catch null) |anim_index| { - 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); - - 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 { - dvui.log.err("Failed to append frame", .{}); - return false; - }; - } - self.init_options.file.animations.set(anim_index, anim); - - self.init_options.file.history.append(.{ - .animation_restore_delete = .{ - .action = .delete, - .index = anim_index, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } - } - } - - if (button.data().contentRectScale().r.w > text_size.w) { - // Determine the rect to draw in - const btn_rect = button.data().contentRectScale().r; - - const scaled_text_size = text_size.scale(dvui.currentWindow().natural_scale, dvui.Size.Physical); - - const text_rect = dvui.Rect.Physical{ - .x = btn_rect.x + (btn_rect.w - scaled_text_size.w) / 2, - .y = btn_rect.y + (btn_rect.h - scaled_text_size.h) / 2, - .w = scaled_text_size.w, - .h = scaled_text_size.h, - }; - - const color_main = if (button.hovered() or animation_index == self.init_options.file.selected_animation_index) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text); - - dvui.renderText(.{ - .text = sprite_label, - .font = font, - .color = color_main.opacity(progress), - .rs = .{ .r = text_rect, .s = dvui.currentWindow().natural_scale }, - }) catch { - dvui.log.err("Failed to render text", .{}); - return false; - }; - - var icon_rect = button.data().rectScale().r; - icon_rect.x += icon_rect.w; - icon_rect.w = icon_rect.w / 2.0; - icon_rect.h = icon_rect.h / 2.0; - icon_rect.x = icon_rect.x - icon_rect.w / 1.5; - icon_rect.y = icon_rect.y - icon_rect.h / 3; - - var fill_rect = icon_rect; - fill_rect.x += icon_rect.w + (2.0 * dvui.currentWindow().natural_scale); - - // Center fill_rect over the button rect if there is more than one selected sprite. - if (self.init_options.file.editor.selected_sprites.count() > 1) { - // Center fill_rect horizontally and vertically over button rect - fill_rect.x = button.data().rectScale().r.x + (button.data().rectScale().r.w - fill_rect.w) / 2.0; - - fill_rect.y -= fill_rect.h + fill_rect.h / 3; - } - - if (button.hovered() or show_hint) { - var icon_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = button.data().rectScale().rectFromPhysical(icon_rect), - .border = dvui.Rect.all(0), - .background = true, - .corner_radius = dvui.Rect.all(1000000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -0.05 * button_height, .y = 0.08 * button_height }, - .fade = (button_height / 10) * t, - .alpha = 0.35 * t, - }, - .color_fill = if (remove) dvui.themeGet().color(.err, .fill).opacity(0.75) else dvui.themeGet().color(.highlight, .fill).opacity(0.75), - }); - - dvui.renderIcon("close", if (remove) icons.tvg.lucide.minus else icons.tvg.lucide.plus, .{ .r = icon_box.data().rectScale().r, .s = dvui.currentWindow().natural_scale }, .{}, .{}) catch { - dvui.log.err("Failed to render icon", .{}); - return false; - }; - icon_box.deinit(); - - var message_size: dvui.Size = .{}; - - if (add_rem_message) |message| { - message_size.w = font.textSize(message).w * dvui.currentWindow().natural_scale; - message_size.h = font.textSize(message).h * dvui.currentWindow().natural_scale + 2.0 * dvui.currentWindow().natural_scale; - - fill_rect.w += message_size.w * 1.5; - fill_rect.h = @max(fill_rect.h, message_size.h); - } - if (button.hovered()) { - if (add_rem_message) |message| { - const fill_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = button.data().rectScale().rectFromPhysical(fill_rect), - .border = dvui.Rect.all(0), - .background = true, - .corner_radius = dvui.Rect.all(1000000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -0.05 * button_height, .y = 0.08 * button_height }, - .fade = (button_height / 10) * t, - .alpha = 0.35 * t, - }, - .color_fill = if (remove) dvui.themeGet().color(.err, .fill).opacity(0.75) else dvui.themeGet().color(.highlight, .fill).opacity(0.75), - }); - defer fill_box.deinit(); - - var text_box = fill_box.data().contentRectScale().r; - text_box.x += (text_box.w - (message_size.w)) / 2.0; - text_box.y += (text_box.h - (message_size.h)) / 2.0; - - dvui.renderText(.{ - .text = message, - .font = font, - .color = .white, - .rs = .{ .r = text_box, .s = dvui.currentWindow().natural_scale }, - }) catch { - dvui.log.err("Failed to render text", .{}); - }; - } - } - } - } - } - - return false; -} - -/// Draw the highlight colored selection box for each selected sprite. -pub fn drawSpriteSelection(self: *FileWidget) void { - 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) |previous_point| { - const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - const min_x = @min(previous_point.x, current_point.x); - const min_y = @min(previous_point.y, current_point.y); - const max_x = @max(previous_point.x, current_point.x); - const max_y = @max(previous_point.y, current_point.y); - const span_rect = dvui.Rect{ - .x = min_x, - .y = min_y, - .w = max_x - min_x, - .h = max_y - min_y, - }; - - const screen_selection_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect); - const selection_color = if (dvui.currentWindow().modifiers.matchBind("shift")) dvui.themeGet().color(.err, .fill).opacity(0.5) else dvui.themeGet().color(.highlight, .fill).opacity(0.5); - screen_selection_rect.fill( - dvui.Rect.Physical.all(6 * dvui.currentWindow().natural_scale), - .{ - .color = selection_color, - }, - ); - } -} - -/// Arc-length point along a piecewise-linear polyline (`cum` = cumulative segment lengths). -fn marqueePointAtArcLength( - points: []const dvui.Point.Physical, - cum: []const f32, - s: f32, -) dvui.Point.Physical { - const n = points.len; - std.debug.assert(n == cum.len); - if (n == 0) return .{ .x = 0, .y = 0 }; - if (n == 1) return points[0]; - - const total = cum[n - 1]; - const clamped = std.math.clamp(s, cum[0], total); - - var i: usize = 0; - while (i + 1 < n and cum[i + 1] < clamped) { - i += 1; - } - const seg_len = cum[i + 1] - cum[i]; - if (seg_len < 1e-5) { - return points[i + 1]; - } - const t = (clamped - cum[i]) / seg_len; - return .{ - .x = points[i].x + (points[i + 1].x - points[i].x) * t, - .y = points[i].y + (points[i + 1].y - points[i].y) * t, - }; -} - -fn marqueeAppendSpan( - points: []const dvui.Point.Physical, - cum: []const f32, - s0: f32, - s1: f32, - out: *std.array_list.Managed(dvui.Point.Physical), -) !void { - out.clearRetainingCapacity(); - const eps = 1e-4; - if (s1 <= s0 + eps) return; - - try out.append(marqueePointAtArcLength(points, cum, s0)); - - const n = points.len; - var k: usize = 1; - while (k < n) : (k += 1) { - const d = cum[k]; - if (d <= s0 + eps) continue; - if (d >= s1 - eps) break; - try out.append(points[k]); - } - - const end_pt = marqueePointAtArcLength(points, cum, s1); - const last = out.items[out.items.len - 1]; - const dx = end_pt.x - last.x; - const dy = end_pt.y - last.y; - if (dx * dx + dy * dy > 1e-8) { - try out.append(end_pt); - } -} - -/// Dashed stroke along a polyline (same approach as graphl `previewStrokePolylineDashed`). -fn strokePolylineDashedPhysical( - points: []const dvui.Point.Physical, - dash_len: f32, - gap_len: f32, - stroke: dvui.Path.StrokeOptions, -) void { - const n = points.len; - if (n < 2) return; - if (dash_len <= 0.0) return; - const gap = @max(0.0, gap_len); - const pattern = dash_len + gap; - if (pattern < 1e-5) return; - - const arena = dvui.currentWindow().arena(); - const cum = arena.alloc(f32, n) catch return; - cum[0] = 0; - var i: usize = 1; - while (i < n) : (i += 1) { - cum[i] = cum[i - 1] + dvui.Point.Physical.diff(points[i], points[i - 1]).length(); - } - - const total = cum[n - 1]; - if (total < 1e-4) return; - - var buf = std.array_list.Managed(dvui.Point.Physical).init(arena); - defer buf.deinit(); - - const edge_eps = 1e-5; - var s: f32 = 0; - while (s < total - edge_eps) { - const dash_end = @min(s + dash_len, total); - if (dash_end <= s + edge_eps) break; - marqueeAppendSpan(points, cum, s, dash_end, &buf) catch return; - if (buf.items.len != 0) { - dvui.Path.stroke(.{ .points = buf.items }, stroke); - } - s = dash_end + gap; - } -} - -fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void { - 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; - - const file = self.init_options.file; - const canvas = &file.editor.canvas; - const current = canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - - const min_x = @min(start.x, current.x); - const min_y = @min(start.y, current.y); - const max_x = @max(start.x, current.x); - const max_y = @max(start.y, current.y); - if (@abs(max_x - min_x) < 1e-4 and @abs(max_y - min_y) < 1e-4) return; - - const tl = canvas.screenFromDataPoint(.{ .x = min_x, .y = min_y }); - const tr = canvas.screenFromDataPoint(.{ .x = max_x, .y = min_y }); - const br = canvas.screenFromDataPoint(.{ .x = max_x, .y = max_y }); - const bl = canvas.screenFromDataPoint(.{ .x = min_x, .y = max_y }); - - const arena = dvui.currentWindow().arena(); - const loop_buf = arena.alloc(dvui.Point.Physical, 5) catch return; - loop_buf[0] = tl; - loop_buf[1] = tr; - loop_buf[2] = br; - loop_buf[3] = bl; - loop_buf[4] = tl; - - const rs = canvas.scroll_container.data().rectScale(); - const stroke_w = @max(1.0, 1.0 * rs.s); - - const outline_color = dvui.themeGet().color(.window, .text); - - const dash_px: f32 = 14.0; - const gap_px: f32 = 8.75; - - strokePolylineDashedPhysical(loop_buf, dash_px, gap_px, .{ - .thickness = stroke_w, - .color = outline_color, - .endcap_style = .none, - .after = true, - }); -} - -/// Preview for rectangular selection while dragging (box mode). -fn applySelectionBoxPreview( - file: *pixelart.internal.File, - active_layer: *const pixelart.internal.Layer, - start: dvui.Point, - end: dvui.Point, - mod: dvui.enums.Mod, -) void { - const read_layer = file.layers.get(file.selected_layer_index); - file.editor.temporary_layer.clearMask(); - file.editor.temporary_layer.mask.setUnion(file.editor.selection_layer.mask); - file.editor.temporary_layer.mask.setIntersection(active_layer.mask); - - const x0: i32 = @intFromFloat(@floor(@min(start.x, end.x))); - const y0: i32 = @intFromFloat(@floor(@min(start.y, end.y))); - const x1: i32 = @intFromFloat(@floor(@max(start.x, end.x))); - const y1: i32 = @intFromFloat(@floor(@max(start.y, end.y))); - - const iw: i32 = @intCast(file.width()); - const ih: i32 = @intCast(file.height()); - - const sub = mod.matchBind("shift"); - - var py = y0; - while (py <= y1) : (py += 1) { - if (py < 0 or py >= ih) continue; - var px = x0; - while (px <= x1) : (px += 1) { - if (px < 0 or px >= iw) continue; - const pt: dvui.Point = .{ .x = @floatFromInt(px), .y = @floatFromInt(py) }; - if (file.editor.temporary_layer.pixelIndex(pt)) |idx| { - if (read_layer.pixels()[idx][3] == 0) continue; - if (sub) { - file.editor.temporary_layer.mask.setValue(idx, false); - } else { - file.editor.temporary_layer.mask.setValue(idx, true); - } - } - } - } -} - -/// Responsible for processing events to create/modify the current fine-grained selection. -/// 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) { - .selection, - => false, - else => true, - }) return; - - if (self.sample_key_down or self.right_mouse_down) return; - - const file = self.init_options.file; - const widget_active = self.active(); - const active_layer = &file.layers.get(file.selected_layer_index); - - const selection_alpha: u8 = 185; - const selection_color_primary: dvui.Color = .{ .r = 200, .g = 200, .b = 200, .a = selection_alpha }; - const selection_color_secondary: dvui.Color = .{ .r = 50, .g = 50, .b = 50, .a = selection_alpha }; - - const selection_alpha_stroke: u8 = 225; - var selection_color_primary_stroke: dvui.Color = .{ .r = 255, .g = 255, .b = 255, .a = selection_alpha_stroke }; - var selection_color_secondary_stroke: dvui.Color = .{ .r = 200, .g = 200, .b = 200, .a = selection_alpha_stroke }; - - // 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) { - @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); - file.editor.temporary_layer.clearMask(); - - file.editor.temporary_layer.mask.setUnion(file.editor.selection_layer.mask); - file.editor.temporary_layer.mask.setIntersection(active_layer.mask); - - file.editor.temporary_layer.setColorFromMask(selection_color_primary); - file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard); - file.editor.temporary_layer.setColorFromMask(selection_color_secondary); - } - - for (dvui.events()) |*e| { - if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - - switch (e.evt) { - .key => |ke| { - var update: bool = false; - if (Globals.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; - - 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 (Globals.state.tools.stroke_size > 1) - Globals.state.tools.stroke_size -= 1; - - Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size); - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - update = true; - } - } - - if (update) { - const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - { - - // Clear temporary layer pixels and mask - @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); - file.editor.temporary_layer.clearMask(); - - // Set the temporary layer mask to the selection layer mask - file.editor.temporary_layer.mask.setUnion(file.editor.selection_layer.mask); - - // Draw the point at the stroke size to the temporary layer mask only - file.drawPoint( - current_point, - .temporary, - .{ - .mask_only = true, - .stroke_size = Globals.state.tools.stroke_size, - }, - ); - - // Intersect with the active layer mask so the stroke is confined to only non-transparent pixels - file.editor.temporary_layer.mask.setIntersection(active_layer.mask); - file.editor.temporary_layer.setColorFromMask(selection_color_primary); - - // Intersect with the checkerboard mask so we can show the pattern - file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard); - file.editor.temporary_layer.setColorFromMask(selection_color_secondary); - } - } - }, - .mouse => |me| { - 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 is_drag = dvui.dragging(me.p, "stroke_drag") != null; - const box_drag = box_mode and is_drag and self.drag_data_point != null; - - if ((box_mode and !box_drag) or color_mode) { - // Box: committed selection is painted after events. Color: no brush preview. - } else { - // Clear the mask, we now need to only draw the point at the stroke size to the mask - file.editor.temporary_layer.clearMask(); - - if (box_drag) { - if (self.drag_data_point) |start| { - // Clear pixels so subtract preview can drop overlay where the mask is cleared - // (setColorFromMask only writes where the mask is set). - @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); - applySelectionBoxPreview( - file, - active_layer, - start, - current_point, - me.mod, - ); - } - // Same checkerboard two-tone as the committed selection (no err/highlight tint). - file.editor.temporary_layer.mask.setIntersection(active_layer.mask); - file.editor.temporary_layer.setColorFromMask(selection_color_primary); - file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard); - file.editor.temporary_layer.setColorFromMask(selection_color_secondary); - } else { - var default: bool = true; - - if (me.mod.matchBind("shift")) { - default = false; - selection_color_primary_stroke = selection_color_primary_stroke.lerp(dvui.themeGet().color(.err, .fill), 0.7); - selection_color_primary_stroke.a = selection_alpha_stroke; - selection_color_secondary_stroke = selection_color_secondary_stroke.lerp(dvui.themeGet().color(.err, .fill), 0.7); - selection_color_secondary_stroke.a = selection_alpha_stroke; - } else if (me.mod.matchBind("ctrl/cmd")) { - default = false; - selection_color_primary_stroke = selection_color_primary_stroke.lerp(dvui.themeGet().color(.highlight, .fill), 0.7); - selection_color_primary_stroke.a = selection_alpha_stroke; - selection_color_secondary_stroke = selection_color_secondary_stroke.lerp(dvui.themeGet().color(.highlight, .fill), 0.7); - selection_color_secondary_stroke.a = selection_alpha_stroke; - } - - // Draw the point at the stroke size to the temporary layer mask only - file.drawPoint( - current_point, - .temporary, - .{ - .mask_only = true, - .stroke_size = Globals.state.tools.stroke_size, - }, - ); - - // Only show stroke over relevant pixels to make selection clearer - if (me.mod.matchBind("shift")) { - file.editor.temporary_layer.mask.setIntersection(file.editor.selection_layer.mask); - } else if (me.mod.matchBind("ctrl/cmd")) { - var copy_mask = file.editor.selection_layer.mask.clone(dvui.currentWindow().arena()) catch { - dvui.log.err("Failed to clone selection layer mask", .{}); - return; - }; - copy_mask.toggleAll(); - file.editor.temporary_layer.mask.setIntersection(copy_mask); - } - - // Intersect with the active layer mask so the stroke is confined to only non-transparent pixels - file.editor.temporary_layer.mask.setIntersection(active_layer.mask); - file.editor.temporary_layer.setColorFromMask(selection_color_primary_stroke); - - // Intersect with the checkerboard mask so we can show the pattern - file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard); - file.editor.temporary_layer.setColorFromMask(if (default) selection_color_secondary_stroke else selection_color_primary_stroke); - } - } - } - - if (me.action == .press and me.button.pointer()) { - if (!widget_active) continue; - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - - 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(); - - file.selectColorFloodFromPoint(current_point, !me.mod.matchBind("shift")) catch { - dvui.log.err("Color selection flood failed", .{}); - }; - continue; - } - - dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "stroke_drag" }); - - // 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(); - - 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 = Globals.state.tools.stroke_size, - }, - ); - - self.drag_data_point = current_point; - } - } else if (me.action == .release and me.button.pointer()) { - if (!widget_active) continue; - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - - 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 = Globals.state.tools.stroke_size, - }, - ); - } - } else if (Globals.state.tools.selection_mode != .color) { - file.selectPoint( - current_point, - .{ - .value = !me.mod.matchBind("shift"), - .stroke_size = Globals.state.tools.stroke_size, - }, - ); - } - - self.drag_data_point = null; - } - } else if (me.action == .position or me.action == .wheel_x or me.action == .wheel_y) { - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - if (!widget_active) continue; - if (dvui.dragging(me.p, "stroke_drag")) |_| { - if (self.drag_data_point) |previous_point| { - // Construct a rect spanning between current_point and previous_point - const min_x = @min(previous_point.x, current_point.x); - const min_y = @min(previous_point.y, current_point.y); - const max_x = @max(previous_point.x, current_point.x); - const max_y = @max(previous_point.y, current_point.y); - const span_rect = dvui.Rect{ - .x = min_x, - .y = min_y, - .w = max_x - min_x + 1, - .h = max_y - min_y + 1, - }; - - const screen_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect); - - dvui.scrollDrag(.{ - .mouse_pt = me.p, - .screen_rect = screen_rect, - }); - } - - 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 = Globals.state.tools.stroke_size, - }, - ); - } - - self.drag_data_point = current_point; - } - } - } - } - }, - else => {}, - } - } - - 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)) { - @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); - file.editor.temporary_layer.clearMask(); - - file.editor.temporary_layer.mask.setUnion(file.editor.selection_layer.mask); - file.editor.temporary_layer.mask.setIntersection(active_layer.mask); - - file.editor.temporary_layer.setColorFromMask(selection_color_primary); - file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard); - file.editor.temporary_layer.setColorFromMask(selection_color_secondary); - file.editor.temp_layer_generation +%= 1; - } - } - - file.editor.temp_layer_has_content = true; -} - -fn processStrokeDragSegment( - self: *FileWidget, - file: *pixelart.internal.File, - previous_point: dvui.Point, - current_point: dvui.Point, - screen_pt: dvui.Point.Physical, - color: [4]u8, - stroke_size: u8, - shift: bool, -) void { - const min_x = @min(previous_point.x, current_point.x); - const min_y = @min(previous_point.y, current_point.y); - const max_x = @max(previous_point.x, current_point.x); - const max_y = @max(previous_point.y, current_point.y); - const span_rect = dvui.Rect{ - .x = min_x, - .y = min_y, - .w = max_x - min_x + 1, - .h = max_y - min_y + 1, - }; - - const screen_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect); - dvui.scrollDrag(.{ - .mouse_pt = screen_pt, - .screen_rect = screen_rect, - }); - - if (shift) { - // Skip the shift-line temp preview on touch — finger occludes it anyway. - if (self.init_options.file.editor.canvas.last_input_was_touch) return; - const preview_clip = tempStrokePreviewClipRect(&self.init_options.file.editor.canvas, file, stroke_size); - const line_cover = file.lineBrushCoverRect(previous_point, current_point, stroke_size); - const dirty = dvui.Rect.intersect(line_cover, preview_clip); - if (!dirty.empty()) { - file.drawLine( - previous_point, - current_point, - .temporary, - .{ - .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, - .stroke_size = stroke_size, - .clip_rect = preview_clip, - }, - ); - file.editor.temp_preview_dirty_rect = dirty; - file.editor.temp_layer_has_content = true; - expandTempGpuDirtyRect(&file.editor, dirty); - } - return; - } - - if (file.strokeUndoExpandToCoverRect(file.lineBrushCoverRect(previous_point, current_point, stroke_size))) |_| { - file.drawLine( - previous_point, - current_point, - .selected, - .{ - .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, - .invalidate = true, - .to_change = false, - .stroke_size = stroke_size, - }, - ); - pixelart.perf.draw_event_count += 1; - } else |err| { - dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err}); - } - - self.drag_data_point = current_point; - - // Drag-position brush preview: pointless on touch (finger occludes the pixel and - // hover == drag), and leaves a phantom that lingers after the stroke ends. - if (!self.init_options.file.editor.canvas.last_input_was_touch and - self.init_options.file.editor.canvas.rect.contains(screen_pt) and - self.sample_data_point == null) - { - 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 }; - file.drawPoint( - current_point, - .temporary, - .{ - .color = .{ .r = temp_color[0], .g = temp_color[1], .b = temp_color[2], .a = temp_color[3] }, - .stroke_size = stroke_size, - }, - ); - const brush_rect = tempBrushRect(current_point, stroke_size, file.width(), file.height()); - file.editor.temp_preview_dirty_rect = brush_rect; - file.editor.temp_layer_has_content = true; - expandTempGpuDirtyRect(&file.editor, brush_rect); - } - } -} - -/// Responsible for processing events to modify pixels on the current layer for strokes of various size -/// 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 widget_active = self.active(); - - if (self.cell_reorder_point != null) return; - - if (switch (Globals.state.tools.current) { - .pencil, - .eraser, - => false, - else => true, - }) return; - - 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, - .eraser => [_]u8{ 0, 0, 0, 0 }, - else => unreachable, - }; - - for (dvui.events()) |*e| { - if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - - switch (e.evt) { - .mouse => |me| { - const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p); - - if (me.action == .press and me.button.pointer()) { - if (!widget_active) continue; - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "stroke_drag" }); - file.editor.active_drawing = true; - - file.buffers.stroke.clearAndFree(); - file.strokeUndoBegin(file.brushStampRect(current_point, stroke_size)) catch |err| { - dvui.log.err("strokeUndoBegin failed: {}", .{err}); - }; - - if (!me.mod.matchBind("shift")) { - file.drawPoint( - current_point, - .selected, - .{ - .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, - .invalidate = true, - .to_change = false, - .stroke_size = stroke_size, - }, - ); - } - - self.drag_data_point = current_point; - } else if (me.action == .release and me.button.pointer()) { - if (!widget_active) continue; - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - - // Touch drags do not get `.position` hover updates, so the per-frame temp - // cleanup in `process()` may not run after lift. Clear any brush preview now. - resetTempLayerPreview(&file.editor); - - if (me.mod.matchBind("shift")) { - if (self.drag_data_point) |previous_point| { - if (file.strokeUndoExpandToCoverRect(file.lineBrushCoverRect(previous_point, current_point, stroke_size))) |_| { - file.drawLine( - previous_point, - current_point, - .selected, - .{ - .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, - .invalidate = true, - .to_change = true, - .stroke_size = stroke_size, - }, - ); - } else |err| { - dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err}); - } - } - } else { - if (self.drag_data_point) |start| { - const full_cover = file.lineBrushCoverRect(start, current_point, stroke_size); - if (file.strokeUndoExpandToCoverRect(full_cover)) |_| {} else |err| { - dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err}); - } - } - - file.drawPoint( - current_point, - .selected, - .{ - .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, - .invalidate = true, - .to_change = true, - .stroke_size = stroke_size, - }, - ); - } - - // Redraw without the temp brush overlay; needed when hover stops after touch lift. - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - - // End active drawing after committing the release stroke. - // Reset the composite frame guard so the canvas renderLayers - // (which runs later this frame) can rebuild the full composite - // immediately rather than showing a stale pre-drawing composite. - file.editor.active_drawing = false; - file.editor.layer_composite_dirty = true; - file.editor.layer_composite_frame_built = 0; - - self.drag_data_point = null; - } - } else if (me.action == .motion and me.button.touch()) { - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - if (!widget_active) continue; - if (dvui.dragging(me.p, "stroke_drag")) |_| { - if (self.drag_data_point) |previous_point| { - processStrokeDragSegment( - self, - file, - previous_point, - current_point, - me.p, - color, - stroke_size, - me.mod.matchBind("shift"), - ); - } - } - } - } else if (me.action == .position or me.action == .wheel_x or me.action == .wheel_y) { - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - if (!widget_active) continue; - if (dvui.dragging(me.p, "stroke_drag")) |_| { - if (self.drag_data_point) |previous_point| { - processStrokeDragSegment( - self, - file, - previous_point, - current_point, - me.p, - color, - stroke_size, - me.mod.matchBind("shift"), - ); - } - } - } else { - // Hover (cursor-follow) brush preview — mouse only. Touch input - // has no hover state, and the finger covers the pixel anyway. - if (!self.init_options.file.editor.canvas.last_input_was_touch and - self.init_options.file.editor.canvas.rect.contains(me.p) and - self.sample_data_point == null) - { - clearTempPreview(&file.editor); - const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; - file.drawPoint( - current_point, - .temporary, - .{ - .stroke_size = stroke_size, - .color = .{ .r = temp_color[0], .g = temp_color[1], .b = temp_color[2], .a = temp_color[3] }, - }, - ); - const brush_rect = tempBrushRect(current_point, stroke_size, file.width(), file.height()); - file.editor.temp_preview_dirty_rect = brush_rect; - file.editor.temp_layer_has_content = true; - expandTempGpuDirtyRect(&file.editor, brush_rect); - } - } - } - }, - else => {}, - } - } -} - -/// Responsible for processing events to fill pixels on the current layer with a solid color. -/// 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 (self.sample_key_down) return; - const file = self.init_options.file; - 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 - // hover == drag, so the preview adds nothing but a phantom that lingers after lift. - if (!self.init_options.file.editor.canvas.last_input_was_touch and - self.init_options.file.editor.canvas.rect.contains(dvui.currentWindow().mouse_pt) and - 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 fill_preview_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - file.drawPoint( - fill_preview_pt, - .temporary, - .{ - .stroke_size = 1, - .color = .{ .r = temp_color[0], .g = temp_color[1], .b = temp_color[2], .a = temp_color[3] }, - }, - ); - const brush_rect = tempBrushRect(fill_preview_pt, 1, file.width(), file.height()); - file.editor.temp_preview_dirty_rect = brush_rect; - file.editor.temp_layer_has_content = true; - expandTempGpuDirtyRect(&file.editor, brush_rect); - } - - for (dvui.events()) |*e| { - if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - - switch (e.evt) { - .mouse => |me| { - const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p); - - if (me.action == .press and me.button.pointer()) { - if (!widget_active) continue; - file.fillPoint(current_point, .selected, .{ - .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, - .invalidate = true, - .to_change = true, - .replace = me.mod.matchBind("ctrl/cmd"), - }); - } - }, - else => {}, - } - } -} - -/// Responsible for processing events to create/modify a transform. A transform is basically a quad with controls on each corner, and -/// allows moving, rotating, skewing and scaling the quad. The controls also include a pivot point for the rotation. -pub fn processTransform(self: *FileWidget) void { - const file = self.init_options.file; - const image_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) }); - const image_rect_physical = dvui.Rect.Physical.fromSize(.{ .w = image_rect.w, .h = image_rect.h }); - - if (file.editor.transform) |*transform| { - - // Data path is necessary to build and fill with convex triangles, which will be how we render to the target texture - var data_path: dvui.Path.Builder = .init(dvui.currentWindow().arena()); - for (transform.data_points[0..4]) |*point| { - data_path.addPoint(.{ .x = point.x, .y = point.y }); - } - - // Calculate the centroid of the four corner points - const centroid = transform.centroid(); - - var triangle_opts: ?dvui.Triangles = data_path.build().fillConvexTriangles(dvui.currentWindow().arena(), .{ - .center = .{ .x = centroid.x, .y = centroid.y }, - .color = .white, - }) catch null; - - { // Update the rotate point to locate towards the mouse - const diff = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt).diff(transform.point(.pivot).*); - transform.point(.rotate).* = transform.point(.pivot).plus(diff.normalize().scale(transform.radius, dvui.Point)); - } - - if (triangle_opts) |*triangles| { - // First, we rotate the triangles to match the angle - 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 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); - screen_rect.w = 16 * dvui.currentWindow().natural_scale; - screen_rect.h = 16 * dvui.currentWindow().natural_scale; - screen_rect.x -= screen_rect.w / 2; - screen_rect.y -= screen_rect.h / 2; - - for (dvui.events()) |*e| { - if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - - 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))) { - dvui.cursorSet(.hand); - } - } - - switch (e.evt) { - .key => |ke| { - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("up")) { - transform.move(.{ .x = 0, .y = -1 }); - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - } - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("down")) { - transform.move(.{ .x = 0, .y = 1 }); - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - } - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("left")) { - transform.move(.{ .x = -1, .y = 0 }); - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - } - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("right")) { - transform.move(.{ .x = 1, .y = 0 }); - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - } - }, - .mouse => |me| { - const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p); - - if (me.action == .press and me.button.pointer()) { - if (screen_rect.contains(me.p)) { - transform.active_point = @enumFromInt(point_index); - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "transform_vertex_drag" }); - self.drag_data_point = current_point; - transform.start_rotation = transform.rotation; - if (point_index < 4) { - const oi: usize = switch (point_index) { - 0 => 2, - 1 => 3, - 2 => 0, - 3 => 1, - else => unreachable, - }; - const opp = transform.data_points[oi]; - const cur = transform.data_points[point_index]; - self.transform_aspect_w = @abs(cur.x - opp.x); - self.transform_aspect_h = @abs(cur.y - opp.y); - } - } - } else if (me.action == .release and me.button.pointer()) { - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - if (transform.active_point) |active_point| { - if (active_point == .pivot and transform.dragging == false) { - transform.point(.pivot).* = transform.centroid(); - transform.updateRadius(); - } - } - - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - transform.active_point = null; - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - self.drag_data_point = null; - self.transform_aspect_w = null; - self.transform_aspect_h = null; - transform.dragging = false; - } - } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) { - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - if (dvui.dragging(me.p, "transform_vertex_drag")) |_| { - transform.dragging = true; - if (transform.active_point) |active_point| { - if (@intFromEnum(active_point) == point_index) { - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - - // Set this state in advance so we can use it for the radius calculation - transform.track_pivot = active_point == .pivot; - - // This is the new data point of the dragged point - var new_point = file.editor.canvas.dataFromScreenPoint(me.p); - - // Calculate the radius of the transform no matter what point is changing - defer transform.updateRadius(); - - if (point_index < 4) { - // Only round the corner points - new_point.x = @round(new_point.x); - 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); - - const opposite_index: usize = switch (point_index) { - 0 => 2, - 1 => 3, - 2 => 0, - 3 => 1, - else => unreachable, - }; - - // ctrl/cmd: free skew. shift: axis-aligned rect (old default). no mod: same rect + locked aspect vs opposite corner. - if (me.mod.matchBind("ctrl/cmd")) { - data_point.* = new_point; - transform.ortho = false; - } else { - transform.ortho = true; - if (me.mod.matchBind("shift")) { - data_point.* = new_point; - } else { - const opp = transform.data_points[opposite_index]; - var constrained = new_point; - if (self.transform_aspect_w) |aw| { - if (self.transform_aspect_h) |ah| { - if (aw > 1e-4 and ah > 1e-4) { - const mx = new_point.x - opp.x; - const my = new_point.y - opp.y; - const ax = @abs(mx); - const ay = @abs(my); - const den = aw * aw + ah * ah; - if (den > 1e-8) { - const t = (aw * ax + ah * ay) / den; - constrained.x = @round(opp.x + math.copysign(aw * t, mx)); - constrained.y = @round(opp.y + math.copysign(ah * t, my)); - } - } - } - } - data_point.* = constrained; - } - - blk_vert: { - // Find adjacent verts - const adjacent_index_cw = if (point_index < 3) point_index + 1 else 0; - const adjacent_index_ccw = if (point_index > 0) point_index - 1 else 3; - - // Get the adjacent points - const adjacent_point_cw = &transform.data_points[adjacent_index_cw]; - const adjacent_point_ccw = &transform.data_points[adjacent_index_ccw]; - - 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); - - // Calculate the difference between the adjacent points and the new point - - { // Calculate intersection point to set adjacent vert - const as = data_point.*; - const bs = opposite_point.*; - const ad = rotation_direction.scale(-1.0, dvui.Point); - const bd = rotation_perp; - const dx = bs.x - as.x; - const dy = bs.y - as.y; - const det = bd.x * ad.y - bd.y * ad.x; - if (det == 0.0) break :blk_vert; - const u = (dy * bd.x - dx * bd.y) / det; - switch (point_index) { - 0, 2 => adjacent_point_cw.* = as.plus(ad.scale(u, dvui.Point)), - 1, 3 => adjacent_point_ccw.* = as.plus(ad.scale(u, dvui.Point)), - else => unreachable, - } - } - - { // Calculate intersection point to set adjacent vert - const as = data_point.*; - const bs = opposite_point.*; - const ad = rotation_perp.scale(-1.0, dvui.Point); - const bd = rotation_direction; - const dx = bs.x - as.x; - const dy = bs.y - as.y; - const det = bd.x * ad.y - bd.y * ad.x; - if (det == 0.0) break :blk_vert; - const u = (dy * bd.x - dx * bd.y) / det; - switch (point_index) { - 0, 2 => adjacent_point_ccw.* = as.plus(ad.scale(u, dvui.Point)), - 1, 3 => adjacent_point_cw.* = as.plus(ad.scale(u, dvui.Point)), - else => unreachable, - } - } - } - } - } - if (active_point == .pivot) { - data_point.* = new_point; - } - if (transform_point == .rotate) { - if (self.drag_data_point) |drag_data_point| { - const drag_diff = drag_data_point.diff(transform.point(.pivot).*); - const drag_angle = std.math.atan2(drag_diff.y, drag_diff.x); - - const diff = new_point.diff(transform.point(.pivot).*); - const angle = std.math.atan2(diff.y, diff.x); - - 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); - transform.rotation = switch (direction) { - .n => std.math.pi / 2.0, - .ne => std.math.pi / 4.0, - .e => 0, - .s => (3.0 * std.math.pi) / 2.0, - .nw => (3.0 * std.math.pi) / 4.0, - .w => std.math.pi, - .sw => (5.0 * std.math.pi) / 4.0, - .se => (7.0 * std.math.pi) / 4.0, - else => unreachable, - }; - } - } - } - } - } - } - } - } - }, - else => {}, - } - } - } - - // Now if we havent selected any of the points, we need to handle dragging the interior of the polygon - // to move the entire transform - if (transform.active_point == null) { - for (dvui.events()) |*e| { - if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - - var is_hovered: bool = false; - - if (transform.hovered(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) { - dvui.cursorSet(.hand); - is_hovered = true; - } - - switch (e.evt) { - .mouse => |me| { - if (me.action == .press and me.button.pointer()) { - //if (is_hovered or me.mod.matchBind("ctrl/cmd")) { - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "transform_drag" }); - //} - } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) { - if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - if (dvui.dragging(me.p, "transform_drag")) |_| { - dvui.cursorSet(.hand); - transform.dragging = true; - e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - - var prev_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt_prev); - prev_point.x = @round(prev_point.x); - prev_point.y = @round(prev_point.y); - var new_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - new_point.x = @round(new_point.x); - new_point.y = @round(new_point.y); - - transform.move(new_point.diff(prev_point)); - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id); - } - } - } - }, - else => {}, - } - } - } - - // Here pass in the data rect, since we will be rendering directly to the low-res texture - - transform.target_texture.clear(); - const previous_target = dvui.renderTarget(.{ .texture = transform.target_texture, .offset = image_rect_physical.topLeft() }); - - // Make sure we clip to the image rect, if we don't and the texture overlaps the canvas, - // the rendering will be clipped incorrectly - // Use clipSet instead of clip, clip unions with current clip - const clip_rect = image_rect_physical; - const prev_clip = dvui.clipGet(); - dvui.clipSet(clip_rect); - - // Set UVs, there are 5 vertexes, or 1 more than the number of triangles, and is at the center - triangles.vertexes[0].uv = .{ 0.0, 0.0 }; // TL - triangles.vertexes[1].uv = .{ 1.0, 0.0 }; // TR - triangles.vertexes[2].uv = .{ 1.0, 1.0 }; // BR - triangles.vertexes[3].uv = .{ 0.0, 1.0 }; // BL - triangles.vertexes[4].uv = .{ 0.5, 0.5 }; // C - - dvui.renderTriangles(triangles.*, transform.source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - - // Restore the previous clip - dvui.clipSet(prev_clip); - // Set the target back - _ = dvui.renderTarget(previous_target); - - // Read the target texture and copy it to the selection layer - // This is currently very slow, and is a bottleneck for the editor - // TODO: look into how to draw the target texture without needing to read the target back - // if (dvui.textureReadTarget(dvui.currentWindow().arena(), transform.target_texture) catch null) |image_data| { - // @memcpy(file.editor.temporary_layer.bytes(), @as([*]u8, @ptrCast(image_data.ptr))); - // file.editor.temporary_layer.invalidate(); - // } else { - // dvui.log.err("Failed to read target", .{}); - // } - } else { - dvui.log.err("Failed to fill triangles", .{}); - } - } -} - -/// Responsible for drawing the transform guides and controls for the current transform after processing. -/// Includes guides for the sprite size and angle in appropriately scaled text labels. -pub fn drawTransform(self: *FileWidget) void { - const file = self.init_options.file; - - if (file.editor.transform) |*transform| { - const show_ortho_dims = transform.ortho and blk: { - if (transform.active_point) |ap| { - break :blk @intFromEnum(ap) < 4; - } - break :blk transform.dragging; - }; - const dim_cell_opt: ?usize = if (show_ortho_dims) file.spriteIndex(transform.centroid()) else null; - - var path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - for (transform.data_points[0..4]) |*point| { - const screen_point = file.editor.canvas.screenFromDataPoint(point.*); - path.addPoint(screen_point); - } - - var centroid = transform.centroid(); - 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. - if (dim_cell_opt == null) { - if (file.spriteIndex(centroid)) |sprite_index| { - const sprite_rect = file.spriteRect(sprite_index); - const sprite_center = sprite_rect.center(); - - const sprite_diff = sprite_center.diff(centroid); - - if (@floor(sprite_diff.x) == 0) { - const point_1: dvui.Point = .{ .x = sprite_center.x, .y = sprite_rect.topLeft().y }; - const point_2: dvui.Point = .{ .x = sprite_center.x, .y = sprite_rect.bottomRight().y }; - - dvui.Path.stroke(.{ .points = &.{ - file.editor.canvas.screenFromDataPoint(point_1), - file.editor.canvas.screenFromDataPoint(point_2), - } }, .{ .thickness = 1, .color = .magenta }); - } - - if (@floor(sprite_diff.y) == 0) { - const point_1: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = sprite_center.y }; - const point_2: dvui.Point = .{ .x = sprite_rect.bottomRight().x, .y = sprite_center.y }; - - dvui.Path.stroke(.{ .points = &.{ - file.editor.canvas.screenFromDataPoint(point_1), - file.editor.canvas.screenFromDataPoint(point_2), - } }, .{ .thickness = 1, .color = .magenta }); - } - } - } - - { - const centroid_rect = dvui.Rect.fromPoint(centroid); - var centroid_screen_rect = file.editor.canvas.screenFromDataRect(centroid_rect); - centroid_screen_rect.w = 8 * dvui.currentWindow().natural_scale; - centroid_screen_rect.h = 8 * dvui.currentWindow().natural_scale; - centroid_screen_rect.x -= centroid_screen_rect.w / 2; - centroid_screen_rect.y -= centroid_screen_rect.h / 2; - - centroid_screen_rect.fill(dvui.Rect.Physical.all(100000), .{ - .color = dvui.themeGet().color(.control, .fill), - }); - - centroid_screen_rect = centroid_screen_rect.insetAll(2 * dvui.currentWindow().natural_scale); - centroid_screen_rect.fill(dvui.Rect.Physical.all(100000), .{ - .color = dvui.themeGet().color(.window, .text), - }); - } - - if (!show_ortho_dims) { - { // Draw circular outline for the rotation path - var rotate_path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - var outline_rect = dvui.Rect.fromSize(.{ .w = transform.radius * 2, .h = transform.radius * 2 }); - - outline_rect.x = transform.point(.pivot).x - transform.radius; - outline_rect.y = transform.point(.pivot).y - transform.radius; - const outline_screen_rect = file.editor.canvas.screenFromDataRect(outline_rect); - - rotate_path.addRect(outline_screen_rect, dvui.Rect.Physical.all(100000)); - rotate_path.build().stroke(.{ - .thickness = 4 * dvui.currentWindow().natural_scale, - .color = dvui.themeGet().color(.control, .fill), - .closed = true, - .endcap_style = .square, - }); - rotate_path.build().stroke(.{ - .thickness = 2, - .color = dvui.themeGet().color(.window, .text), - .closed = true, - .endcap_style = .square, - }); - } - - if (transform.active_point) |active_point| { - if (active_point == .rotate) { - // Draw the arms of the rotation - if (self.drag_data_point) |drag_data_point| { - const diff = drag_data_point.diff(transform.point(.pivot).*); - - // Start angle - doubleStroke(&.{ - file.editor.canvas.screenFromDataPoint(transform.point(.pivot).*), - file.editor.canvas.screenFromDataPoint(transform.point(.pivot).plus(diff.normalize().scale(transform.radius, dvui.Point))), - }, dvui.themeGet().color(.control, .text), 2); - - // New angle - doubleStroke(&.{ - file.editor.canvas.screenFromDataPoint(transform.point(.pivot).*), - file.editor.canvas.screenFromDataPoint(transform.point(.rotate).*), - }, dvui.themeGet().color(.control, .text), 2); - } - } - } - } - - var triangles_opt = path.build().fillConvexTriangles(dvui.currentWindow().arena(), .{ - .center = .{ .x = centroid.x, .y = centroid.y }, - .color = .white, - }) catch null; - - if (triangles_opt) |*triangles| { - triangles.rotate(file.editor.canvas.screenFromDataPoint(transform.point(.pivot).*), transform.rotation); - - { // Draw the outline of the triangles - const is_hovered = transform.hovered(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt)); - var outline_path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - for (triangles.vertexes[0..4]) |*vertex| { - outline_path.addPoint(.{ .x = vertex.pos.x, .y = vertex.pos.y }); - } - - outline_path.build().stroke(.{ - .thickness = 4 * dvui.currentWindow().natural_scale, - .color = dvui.themeGet().color(.control, .fill), - .closed = true, - .endcap_style = .square, - }); - outline_path.build().stroke(.{ - .thickness = 2, - .color = if ((is_hovered and transform.active_point == null) or transform.dragging) dvui.themeGet().color(.highlight, .fill) else dvui.themeGet().color(.window, .text), - .closed = true, - .endcap_style = .square, - }); - } - - // Dimensions and angle labels - { - const dim_font = dvui.Font.theme(.mono); - - if (show_ortho_dims) { - const ns = dvui.currentWindow().natural_scale; - const canvas = &file.editor.canvas; - const px_per_data_x = blk: { - const a = canvas.screenFromDataPoint(.{ .x = 0, .y = 0 }); - const b = canvas.screenFromDataPoint(.{ .x = 1, .y = 0 }); - break :blk @max(@abs(b.x - a.x), 0.001); - }; - const px_per_data_y = blk: { - const a = canvas.screenFromDataPoint(.{ .x = 0, .y = 0 }); - const b = canvas.screenFromDataPoint(.{ .x = 0, .y = 1 }); - break :blk @max(@abs(b.y - a.y), 0.001); - }; - const tick_half_px = 2.9 * ns; - const label_off_screen = 9 * ns; - - const tl_d = transform.data_points[0]; - const tr_d = transform.data_points[1]; - const br_d = transform.data_points[2]; - const bl_d = transform.data_points[3]; - const bbox_min_x = @min(@min(tl_d.x, tr_d.x), @min(bl_d.x, br_d.x)); - const bbox_max_x = @max(@max(tl_d.x, tr_d.x), @max(bl_d.x, br_d.x)); - const bbox_min_y = @min(@min(tl_d.y, tr_d.y), @min(bl_d.y, br_d.y)); - const bbox_max_y = @max(@max(tl_d.y, tr_d.y), @max(bl_d.y, br_d.y)); - - const cell_cap_x: f32 = if (dim_cell_opt) |ci| file.spriteRect(ci).w else bbox_max_x - bbox_min_x; - const cell_cap_y: f32 = if (dim_cell_opt) |ci| file.spriteRect(ci).h else bbox_max_y - bbox_min_y; - const arm_x_data = @max(0.2, @min(tick_half_px / px_per_data_x, cell_cap_x * 0.11)); - const arm_y_data = @max(0.2, @min(tick_half_px / px_per_data_y, cell_cap_y * 0.11)); - const dim_tick_thick: f32 = 0.65; - - const x_c = (bbox_min_x + bbox_max_x) * 0.5; - const y_c = (bbox_min_y + bbox_max_y) * 0.5; - - if (dim_cell_opt) |ci| { - const cell = file.spriteRect(ci); - const cell_left = cell.x; - const cell_right = cell.x + cell.w; - const cell_top = cell.y; - const cell_bot = cell.y + cell.h; - const arena = dvui.currentWindow().arena(); - const sprite_c = cell.center(); - const sd = sprite_c.diff(centroid); - const dim_inner_h: dvui.Color = if (@floor(sd.x) == 0) .blue else .magenta; - const dim_inner_v: dvui.Color = if (@floor(sd.y) == 0) .blue else .magenta; - - // Left: edge midpoint (bbox left, vertical center) → cell left; label near line. - { - const span = bbox_min_x - cell_left; - if (@abs(span) > 0.001) { - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = cell_left, .y = y_c }), - canvas.screenFromDataPoint(.{ .x = bbox_min_x, .y = y_c }), - }, 1, dim_inner_h); - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = cell_left, .y = y_c - arm_y_data }), - canvas.screenFromDataPoint(.{ .x = cell_left, .y = y_c + arm_y_data }), - }, dim_tick_thick, dim_inner_h); - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = bbox_min_x, .y = y_c - arm_y_data }), - canvas.screenFromDataPoint(.{ .x = bbox_min_x, .y = y_c + arm_y_data }), - }, dim_tick_thick, dim_inner_h); - const t = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(span)))}) catch "—"; - var lp = canvas.screenFromDataPoint(.{ .x = (cell_left + bbox_min_x) * 0.5, .y = y_c }); - lp.x -= label_off_screen; - renderTransformDimLabel(dim_font, t, lp); - } - } - // Right: bbox right → cell right - { - const span = cell_right - bbox_max_x; - if (@abs(span) > 0.001) { - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = bbox_max_x, .y = y_c }), - canvas.screenFromDataPoint(.{ .x = cell_right, .y = y_c }), - }, 1, dim_inner_h); - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = bbox_max_x, .y = y_c - arm_y_data }), - canvas.screenFromDataPoint(.{ .x = bbox_max_x, .y = y_c + arm_y_data }), - }, dim_tick_thick, dim_inner_h); - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = cell_right, .y = y_c - arm_y_data }), - canvas.screenFromDataPoint(.{ .x = cell_right, .y = y_c + arm_y_data }), - }, dim_tick_thick, dim_inner_h); - const t = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(span)))}) catch "—"; - var lp = canvas.screenFromDataPoint(.{ .x = (bbox_max_x + cell_right) * 0.5, .y = y_c }); - lp.x += label_off_screen; - renderTransformDimLabel(dim_font, t, lp); - } - } - // Top: horizontal center of top edge → cell top - { - const span = bbox_min_y - cell_top; - if (@abs(span) > 0.001) { - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = x_c, .y = cell_top }), - canvas.screenFromDataPoint(.{ .x = x_c, .y = bbox_min_y }), - }, 1, dim_inner_v); - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = x_c - arm_x_data, .y = cell_top }), - canvas.screenFromDataPoint(.{ .x = x_c + arm_x_data, .y = cell_top }), - }, dim_tick_thick, dim_inner_v); - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = x_c - arm_x_data, .y = bbox_min_y }), - canvas.screenFromDataPoint(.{ .x = x_c + arm_x_data, .y = bbox_min_y }), - }, dim_tick_thick, dim_inner_v); - const t = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(span)))}) catch "—"; - var lp = canvas.screenFromDataPoint(.{ .x = x_c, .y = (cell_top + bbox_min_y) * 0.5 }); - lp.y -= label_off_screen; - renderTransformDimLabel(dim_font, t, lp); - } - } - // Bottom: bbox bottom → cell bottom - { - const span = cell_bot - bbox_max_y; - if (@abs(span) > 0.001) { - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = x_c, .y = bbox_max_y }), - canvas.screenFromDataPoint(.{ .x = x_c, .y = cell_bot }), - }, 1, dim_inner_v); - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = x_c - arm_x_data, .y = bbox_max_y }), - canvas.screenFromDataPoint(.{ .x = x_c + arm_x_data, .y = bbox_max_y }), - }, dim_tick_thick, dim_inner_v); - doubleStrokeDimensionTickColor(&.{ - canvas.screenFromDataPoint(.{ .x = x_c - arm_x_data, .y = cell_bot }), - canvas.screenFromDataPoint(.{ .x = x_c + arm_x_data, .y = cell_bot }), - }, dim_tick_thick, dim_inner_v); - const t = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(span)))}) catch "—"; - var lp = canvas.screenFromDataPoint(.{ .x = x_c, .y = (bbox_max_y + cell_bot) * 0.5 }); - lp.y += label_off_screen; - renderTransformDimLabel(dim_font, t, lp); - } - } - - // Transform width (bottom edge) and height (left edge): labels only, no dimension lines. - { - const top_left_v = triangles.vertexes[0].pos; - const bottom_left_v = triangles.vertexes[3].pos; - const bottom_right_v = triangles.vertexes[2].pos; - - const offset_v = pixelart.math.rotate( - dvui.Point{ .x = label_off_screen, .y = 0 }, - .{ .x = 0, .y = 0 }, - transform.rotation, - ); - const off_v: dvui.Point.Physical = .{ .x = offset_v.x, .y = offset_v.y }; - - const center_v = top_left_v.plus(bottom_left_v).scale(0.5, dvui.Point.Physical); - const inner_h_f = transform.data_points[0].diff(transform.data_points[3]).length(); - 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( - dvui.Point{ .x = 0, .y = -label_off_screen }, - .{ .x = 0, .y = 0 }, - transform.rotation, - ); - const off_h: dvui.Point.Physical = .{ .x = offset_h.x, .y = offset_h.y }; - - const center_h = bottom_right_v.plus(bottom_left_v).scale(0.5, dvui.Point.Physical); - const inner_w_f = transform.data_points[3].diff(transform.data_points[2]).length(); - const simple_h = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(inner_w_f)))}) catch "—"; - renderTransformDimLabel(dim_font, simple_h, center_h.plus(off_h)); - } - } else { - const top_left = triangles.vertexes[0].pos; - const bottom_left = triangles.vertexes[3].pos; - const bottom_right = triangles.vertexes[2].pos; - - const offset_v = pixelart.math.rotate( - dvui.Point{ .x = label_off_screen, .y = 0 }, - .{ .x = 0, .y = 0 }, - transform.rotation, - ); - const off_v: dvui.Point.Physical = .{ .x = offset_v.x, .y = offset_v.y }; - - const center_v = top_left.plus(bottom_left).scale(0.5, dvui.Point.Physical); - const inner_h_f = transform.data_points[0].diff(transform.data_points[3]).length(); - const simple_v = std.fmt.allocPrint( - dvui.currentWindow().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( - dvui.Point{ .x = 0, .y = -label_off_screen }, - .{ .x = 0, .y = 0 }, - transform.rotation, - ); - const off_h: dvui.Point.Physical = .{ .x = offset_h.x, .y = offset_h.y }; - - const center_h = bottom_right.plus(bottom_left).scale(0.5, dvui.Point.Physical); - const inner_w_f = transform.data_points[3].diff(transform.data_points[2]).length(); - const simple_h = std.fmt.allocPrint( - dvui.currentWindow().arena(), - "{d}", - .{@as(i32, @intFromFloat(@round(inner_w_f)))}, - ) catch "—"; - renderTransformDimLabel(dim_font, simple_h, center_h.plus(off_h)); - } - } - - if (transform.active_point == .rotate and !show_ortho_dims) { - // Draw a stroke from transform.point(.rotate).* to the point on the circle at the midpoint of the rotation arc, - // but if the arc is > 180 degrees, the midpoint angle needs to be flipped 180 degrees. - const pivot = transform.point(.pivot).*; - const radius = transform.radius; - - // Find the angle of the start (drag) and end (current) rotation arms - const start_angle = blk: { - if (self.drag_data_point) |drag_data_point| { - const drag_diff = drag_data_point.diff(pivot); - break :blk std.math.atan2(drag_diff.y, drag_diff.x); - } else { - // Fallback: use current rotation - break :blk std.math.atan2(transform.point(.rotate).y - pivot.y, transform.point(.rotate).x - pivot.x); - } - }; - - // Compute the shortest arc between start and end - var delta_angle = transform.rotation - transform.start_rotation; - // Normalize to [-pi, pi] - if (delta_angle > std.math.pi) { - delta_angle -= 2.0 * std.math.pi; - } else if (delta_angle < -std.math.pi) { - delta_angle += 2.0 * std.math.pi; - } - - // The midpoint angle along the arc - var mid_angle = start_angle + delta_angle / 2.0; - - // If the arc is more than 180 degrees, flip the midpoint angle by 180 degrees - if (delta_angle < 0) { - mid_angle += std.math.pi; - } - - // Calculate the point on the circle at the midpoint angle - const center = file.editor.canvas.screenFromDataPoint(pivot.plus(.{ - .x = radius * (1.0 + 0.075 * dvui.currentWindow().natural_scale) * std.math.cos(mid_angle), - .y = radius * (1.0 + 0.075 * dvui.currentWindow().natural_scale) * std.math.sin(mid_angle), - })); - - var degrees = std.math.radiansToDegrees(delta_angle); - if (degrees < 0) degrees += 360.0; - - const angle_text = std.fmt.allocPrint( - dvui.currentWindow().arena(), - "{d}°", - .{@as(i32, @intFromFloat(@round(degrees)))}, - ) catch "—"; - - renderTransformDimLabel(dim_font, angle_text, center); - } - } - - for (transform.data_points[0..6], 0..) |*point, point_index| { - if (show_ortho_dims and point_index == 5) continue; - if (transform.active_point) |active_point| { - if (active_point == .pivot) { - if (point_index == 5) continue; // skip drawing the rotate point if we are dragging the pivot - } - } - - var screen_point = file.editor.canvas.screenFromDataPoint(point.*); - - // Use the triangle points for the corners - if (point_index < 4) - screen_point = triangles.vertexes[point_index].pos; - - var screen_rect = dvui.Rect.Physical.fromPoint(screen_point); - screen_rect.w = 16 * dvui.currentWindow().natural_scale; - screen_rect.h = 16 * dvui.currentWindow().natural_scale; - screen_rect.x -= screen_rect.w / 2; - screen_rect.y -= screen_rect.h / 2; - - screen_rect.fill(dvui.Rect.Physical.all(100000), .{ - .color = dvui.themeGet().color(.control, .fill), - }); - - screen_rect = screen_rect.inset(dvui.Rect.Physical.all(1 * dvui.currentWindow().natural_scale)); - - var color = dvui.themeGet().color(.window, .text); - - if (transform.active_point) |active_point| { - 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)) { - color = dvui.themeGet().color(.highlight, .fill); - } - - screen_rect.fill(dvui.Rect.Physical.all(100000), .{ - .color = color, - }); - - screen_rect = screen_rect.inset(dvui.Rect.Physical.all(2 * dvui.currentWindow().natural_scale)); - screen_rect.fill(dvui.Rect.Physical.all(100000), .{ - .color = dvui.themeGet().color(.control, .fill), - }); - } - } - } -} - -/// Text size in physical pixels for `renderText` with `.rs.s == render_s` (must stay in sync with -/// `dvui.renderText` / `Font.textSizeEx` fraction rules). -fn transformDimTextSizePhysical(font: dvui.Font, text: []const u8, render_s: f32) dvui.Size { - if (text.len == 0 or render_s == 0) return .{}; - const cw = dvui.currentWindow(); - const target_size = font.size * render_s; - const sized_font = font.withSize(target_size); - const fce = dvui.fontCacheGet(sized_font) catch return .{}; - const target_fraction = if (cw.snap_to_pixels) 1.0 else target_size / fce.em_height; - var opts: dvui.Font.TextSizeOptions = .{}; - opts.kerning = cw.kerning; - const s = fce.textSizeRaw(cw.gpa, text, opts) catch return .{}; - return s.scale(target_fraction, dvui.Size); -} - -/// Constant on-screen size: render at `natural_scale` only. -fn renderTransformDimLabel(font: dvui.Font, text: []const u8, center_phys: dvui.Point.Physical) void { - const ns = dvui.currentWindow().natural_scale; - const ts = transformDimTextSizePhysical(font, text, ns); - const pad = 2 * ns; - const text_rect = dvui.Rect.Physical.rect( - center_phys.x - ts.w / 2, - center_phys.y - ts.h / 2, - ts.w, - ts.h, - ); - var outline_rect = text_rect.outsetAll(pad); - const corner = @min(4 * ns, @min(outline_rect.w, outline_rect.h) * 0.48); - outline_rect.fill(dvui.Rect.Physical.all(corner), .{ - .color = dvui.themeGet().color(.control, .fill).opacity(0.85), - }); - dvui.renderText(.{ - .text = text, - .font = font, - .color = dvui.themeGet().color(.window, .text), - .rs = .{ .r = text_rect, .s = ns }, - }) catch { - dvui.log.err("Failed to render transform dimension label", .{}); - }; -} - -fn doubleStroke(points: []const dvui.Point.Physical, color: dvui.Color, thickness: f32) void { - dvui.Path.stroke(.{ - .points = points, - }, .{ - .thickness = thickness * 2 * dvui.currentWindow().natural_scale, - .color = dvui.themeGet().color(.control, .fill), - }); - dvui.Path.stroke(.{ - .points = points, - }, .{ - .thickness = thickness, - .color = color, - }); -} - -/// Double stroke for dimension lines: outer control fill, inner accent color. -fn doubleStrokeDimensionLike(points: []const dvui.Point.Physical, thickness: f32, inner_thickness: f32, inner_color: dvui.Color) void { - const ns = dvui.currentWindow().natural_scale; - dvui.Path.stroke(.{ - .points = points, - }, .{ - .thickness = thickness * 2 * ns, - .color = dvui.themeGet().color(.control, .fill), - }); - dvui.Path.stroke(.{ - .points = points, - }, .{ - .thickness = inner_thickness, - .color = inner_color, - }); -} - -fn doubleStrokeDimension(points: []const dvui.Point.Physical, thickness: f32) void { - doubleStrokeDimensionLike(points, thickness, thickness, .magenta); -} - -/// Tick marks: inner stroke is one physical pixel thicker for visibility. -fn doubleStrokeDimensionTick(points: []const dvui.Point.Physical, thickness: f32) void { - doubleStrokeDimensionLike(points, thickness, thickness + 1.0, .magenta); -} - -fn doubleStrokeDimensionTickColor(points: []const dvui.Point.Physical, thickness: f32, inner_color: dvui.Color) void { - doubleStrokeDimensionLike(points, thickness, thickness + 1.0, inner_color); -} - -/// Batches all grid lines into a single draw call. Each line becomes a thin -/// axis-aligned quad (4 vertices, 2 triangles) submitted via one `renderTriangles`. -fn drawBatchedGridLines( - self: *FileWidget, - file: *pixelart.internal.File, - columns: usize, - rows: usize, - grid_color: dvui.Color, - grid_thickness: f32, - grid_x0: f32, - grid_x1: f32, - grid_y0: f32, - grid_y1: f32, - vertical_inner: usize, -) void { - const canvas = &self.init_options.file.editor.canvas; - const half = @max(grid_thickness, 1.0) * 0.5; - - const cw = dvui.currentWindow(); - const pma_col: dvui.Color.PMA = .fromColor(grid_color.opacity(cw.alpha)); - - var max_lines: usize = 0; - if (vertical_inner > 1) max_lines += vertical_inner - 1; - if (columns > file.columns) max_lines += columns - file.columns; - max_lines += file.spriteCount(); - if (columns > file.columns) { - const row_horiz_end = if (rows > file.rows) file.rows else rows; - if (row_horiz_end > 1) max_lines += row_horiz_end - 1; - } - if (rows > file.rows) max_lines += rows - file.rows; - if (self.resize_data_point != null) max_lines += 2; - - if (max_lines == 0) return; - - var builder = dvui.Triangles.Builder.init(cw.arena(), max_lines * 4, max_lines * 6) catch return; - defer builder.deinit(cw.arena()); - - const screen_y0 = canvas.screenFromDataPoint(.{ .x = 0, .y = grid_y0 }).y; - const screen_y1 = canvas.screenFromDataPoint(.{ .x = 0, .y = grid_y1 }).y; - - const grid_pan = bubblePanSharedForGrid(self); - - // Vertical lines: inner columns - for (1..vertical_inner) |i| { - const x = @as(f32, @floatFromInt(i * file.column_width)); - const sx = canvas.screenFromDataPoint(.{ .x = x, .y = 0 }).x; - appendLineQuad(&builder, .{ .x = sx - half, .y = screen_y0 }, .{ .x = sx + half, .y = screen_y1 }, pma_col); - } - - // Vertical lines: preview columns beyond the sprite grid - if (columns > file.columns) { - for (file.columns..columns) |k| { - const x = @as(f32, @floatFromInt(k * file.column_width)); - const sx = canvas.screenFromDataPoint(.{ .x = x, .y = 0 }).x; - appendLineQuad(&builder, .{ .x = sx - half, .y = screen_y0 }, .{ .x = sx + half, .y = screen_y1 }, pma_col); - } - } - - // Horizontal lines: sprite row-top edges (visible rows/columns; coalesce runs without bubbles) - if (fileCanvasVisibleGridParams(file)) |gp| { - if (gp.vx1 > 0) { - var row: usize = @max(1, gp.first_vis_row); - while (row < gp.last_vis_row) : (row += 1) { - const row_start = row * gp.cols; - const row_end = @min(row_start + gp.cols, file.spriteCount()); - if (row_end <= row_start) continue; - - const row_span = row_end - row_start; - var col_lo: usize = 0; - if (gp.vx0 > 0) col_lo = @intFromFloat(@floor(gp.vx0 / gp.col_w)); - var col_hi_excl: usize = @intFromFloat(@ceil(gp.vx1 / gp.col_w)); - col_lo = @min(col_lo, row_span); - col_hi_excl = @min(col_hi_excl, row_span); - appendHorizontalGridRunsForRow(self, &builder, canvas, grid_pan, row, row_start, col_lo, col_hi_excl, gp.col_w, gp.row_h, half, pma_col); - } - } - } - - // Horizontal lines: extended strip rows (wider preview than sprite grid) - if (columns > file.columns) { - const x_strip = @as(f32, @floatFromInt(file.columns * file.column_width)); - const row_horiz_end = if (rows > file.rows) file.rows else rows; - for (1..row_horiz_end) |k| { - const y = @as(f32, @floatFromInt(k * file.row_height)); - const sl = canvas.screenFromDataPoint(.{ .x = x_strip, .y = y }); - const sr = canvas.screenFromDataPoint(.{ .x = grid_x1, .y = y }); - appendLineQuad(&builder, .{ .x = sl.x, .y = sl.y - half }, .{ .x = sr.x, .y = sr.y + half }, pma_col); - } - } - - // Horizontal lines: preview rows beyond the sprite grid - if (rows > file.rows) { - for (file.rows..rows) |k| { - const y = @as(f32, @floatFromInt(k * file.row_height)); - const sl = canvas.screenFromDataPoint(.{ .x = grid_x0, .y = y }); - const sr = canvas.screenFromDataPoint(.{ .x = grid_x1, .y = y }); - appendLineQuad(&builder, .{ .x = sl.x, .y = sl.y - half }, .{ .x = sr.x, .y = sr.y + half }, pma_col); - } - } - - // Resize guide lines - if (self.resize_data_point) |resize_data_point| { - const rx = canvas.screenFromDataPoint(.{ .x = resize_data_point.x, .y = 0 }).x; - appendLineQuad(&builder, .{ .x = rx - half, .y = screen_y0 }, .{ .x = rx + half, .y = screen_y1 }, pma_col); - - const ry = canvas.screenFromDataPoint(.{ .x = 0, .y = resize_data_point.y }).y; - const sx0 = canvas.screenFromDataPoint(.{ .x = grid_x0, .y = 0 }).x; - const sx1 = canvas.screenFromDataPoint(.{ .x = grid_x1, .y = 0 }).x; - appendLineQuad(&builder, .{ .x = sx0, .y = ry - half }, .{ .x = sx1, .y = ry + half }, pma_col); - } - - if (builder.vertexes.items.len == 0) return; - - const tris = builder.build_unowned(); - dvui.renderTriangles(tris, null) catch { - dvui.log.err("Failed to render batched grid lines", .{}); - }; -} - -/// Appends a single axis-aligned quad (4 vertices, 2 triangles) from `tl` to `br`. -fn appendLineQuad(builder: *dvui.Triangles.Builder, tl: dvui.Point.Physical, br: dvui.Point.Physical, col: dvui.Color.PMA) void { - const base: dvui.Vertex.Index = @intCast(builder.vertexes.items.len); - builder.appendVertex(.{ .pos = tl, .col = col }); - builder.appendVertex(.{ .pos = .{ .x = br.x, .y = tl.y }, .col = col }); - builder.appendVertex(.{ .pos = br, .col = col }); - builder.appendVertex(.{ .pos = .{ .x = tl.x, .y = br.y }, .col = col }); - builder.appendTriangles(&.{ base, base + 1, base + 2, base, base + 2, base + 3 }); -} - -/// Viewport in data space + row/column index range for culling (matches bubble / grid logic). -fn fileCanvasVisibleGridParams(file: *pixelart.internal.File) ?struct { - visible_data: dvui.Rect, - row_h: f32, - col_w: f32, - cols: usize, - first_vis_row: usize, - last_vis_row: usize, - vx0: f32, - vx1: f32, -} { - const canvas = &file.editor.canvas; - const visible_data = canvas.dataFromScreenRect(canvas.rect); - const total_rows = file.rows; - const cols = file.columns; - if (total_rows == 0 or cols == 0) return null; - const row_h: f32 = @floatFromInt(file.row_height); - const col_w: f32 = @floatFromInt(file.column_width); - if (row_h <= 0 or col_w <= 0) return null; - const bubble_headroom = @max(row_h, col_w); - const max_row_f: f32 = @floatFromInt(total_rows); - const first_vis_f = (visible_data.y - bubble_headroom) / row_h; - const first_vis_row: usize = if (first_vis_f > 0 and first_vis_f < max_row_f) - @intFromFloat(first_vis_f) - else if (first_vis_f >= max_row_f) - total_rows - else - 0; - const last_vis_f = (visible_data.y + visible_data.h) / row_h + 2.0; - const last_vis_row: usize = if (last_vis_f > 0 and last_vis_f < max_row_f) - @intFromFloat(last_vis_f) - else if (last_vis_f >= max_row_f) - total_rows - else - 0; - return .{ - .visible_data = visible_data, - .row_h = row_h, - .col_w = col_w, - .cols = cols, - .first_vis_row = first_vis_row, - .last_vis_row = last_vis_row, - .vx0 = visible_data.x, - .vx1 = visible_data.x + visible_data.w, - }; -} - -/// Horizontal grid segments along row tops: one quad per maximal run of sprites without a bubble arc. -fn appendHorizontalGridRunsForRow( - self: *FileWidget, - builder: *dvui.Triangles.Builder, - canvas: *CanvasWidget, - grid_pan: ?BubblePanShared, - row: usize, - row_start: usize, - col_lo: usize, - col_hi_excl: usize, - col_w: f32, - row_h: f32, - half: f32, - pma_col: dvui.Color.PMA, -) void { - if (col_lo >= col_hi_excl) return; - var col = col_lo; - while (col < col_hi_excl) { - const si0 = row_start + col; - if (self.spriteDrawsBubbleTopEdge(si0, grid_pan)) { - col += 1; - continue; - } - const run_start = col; - col += 1; - while (col < col_hi_excl) : (col += 1) { - if (self.spriteDrawsBubbleTopEdge(row_start + col, grid_pan)) break; - } - const run_end_excl = col; - const x_left = @as(f32, @floatFromInt(run_start)) * col_w; - const x_right = @as(f32, @floatFromInt(run_end_excl)) * col_w; - const y_top = @as(f32, @floatFromInt(row)) * row_h; - const tl = canvas.screenFromDataPoint(.{ .x = x_left, .y = y_top }); - const tr = canvas.screenFromDataPoint(.{ .x = x_right, .y = y_top }); - appendLineQuad(builder, .{ .x = tl.x, .y = tl.y - half }, .{ .x = tr.x, .y = tr.y + half }, pma_col); - } -} - -/// Batches grid lines for the resize-shrink overlay (original layer_rect shown in error tint). -fn drawBatchedResizeOverlayGrid( - self: *FileWidget, - file: *pixelart.internal.File, - columns: usize, - layer_rect: dvui.Rect, - grid_thickness: f32, -) void { - const canvas = &self.init_options.file.editor.canvas; - const half = @max(grid_thickness, 1.0) * 0.5; - const cw = dvui.currentWindow(); - const pma_col: dvui.Color.PMA = .fromColor(dvui.themeGet().color(.window, .fill).opacity(cw.alpha)); - - var max_lines: usize = 0; - if (columns > 1) max_lines += columns - 1; - max_lines += file.spriteCount(); - if (max_lines == 0) return; - - var builder = dvui.Triangles.Builder.init(cw.arena(), max_lines * 4, max_lines * 6) catch return; - defer builder.deinit(cw.arena()); - - const screen_y0 = canvas.screenFromDataPoint(.{ .x = 0, .y = layer_rect.y }).y; - const screen_y1 = canvas.screenFromDataPoint(.{ .x = 0, .y = layer_rect.y + layer_rect.h }).y; - - const grid_pan = bubblePanSharedForGrid(self); - - for (1..columns) |i| { - const gx = @as(f32, @floatFromInt(i * file.column_width)); - const sx = canvas.screenFromDataPoint(.{ .x = gx, .y = 0 }).x; - appendLineQuad(&builder, .{ .x = sx - half, .y = screen_y0 }, .{ .x = sx + half, .y = screen_y1 }, pma_col); - } - - if (fileCanvasVisibleGridParams(file)) |gp| { - if (gp.vx1 > 0) { - var row: usize = @max(1, gp.first_vis_row); - while (row < gp.last_vis_row) : (row += 1) { - const row_start = row * gp.cols; - const row_end = @min(row_start + gp.cols, file.spriteCount()); - if (row_end <= row_start) continue; - - const row_span = row_end - row_start; - var col_lo: usize = 0; - if (gp.vx0 > 0) col_lo = @intFromFloat(@floor(gp.vx0 / gp.col_w)); - var col_hi_excl: usize = @intFromFloat(@ceil(gp.vx1 / gp.col_w)); - col_lo = @min(col_lo, row_span); - col_hi_excl = @min(col_hi_excl, row_span); - appendHorizontalGridRunsForRow(self, &builder, canvas, grid_pan, row, row_start, col_lo, col_hi_excl, gp.col_w, gp.row_h, half, pma_col); - } - } - } - - if (builder.vertexes.items.len == 0) return; - - const tris = builder.build_unowned(); - dvui.renderTriangles(tris, null) catch { - dvui.log.err("Failed to render batched resize overlay grid", .{}); - }; -} - -fn checkerboardGridColorBilinear(c_tl: dvui.Color, c_tr: dvui.Color, c_bl: dvui.Color, c_br: dvui.Color, u: f32, v: f32) dvui.Color { - const top = c_tl.lerp(c_tr, u); - const bottom = c_bl.lerp(c_br, u); - return top.lerp(bottom, v); -} - -/// Near the smoothed mouse (mu, mv): flat `tone` (normal checkerboard tint). Far away: full bilinear corner colors at (u, v). -fn checkerboardVertexColor( - c_tl: dvui.Color, - c_tr: dvui.Color, - c_bl: dvui.Color, - c_br: dvui.Color, - u: f32, - v: f32, - mu: f32, - mv: f32, - tone: dvui.Color, -) dvui.Color { - const c_corner = checkerboardGridColorBilinear(c_tl, c_tr, c_bl, c_br, u, v); - - const du = u - mu; - const dv = v - mv; - const dist = math.sqrt(du * du + dv * dv); - // 0 at cursor → tone only; 1 far away → full corner UV gradient (scaled for visible falloff in 0..1 UV space) - var t = math.clamp(dist * 1.55, 0, 1); - t = t * t * (3.0 - 2.0 * t); - - return tone.lerp(c_corner, t); -} - -/// 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| { - var animation_index: ?usize = null; - - if (file.selected_animation_index) |selected_animation_index| { - for (file.animations.items(.frames)[selected_animation_index]) |frame| { - if (frame.sprite_index == sprite_index) { - animation_index = selected_animation_index; - break; - } - } - } - - if (animation_index == null) { - anim_blk: for (file.animations.items(.frames), 0..) |frames, i| { - for (frames) |frame| { - if (frame.sprite_index == sprite_index) { - animation_index = i; - break :anim_blk; - } - } - } - } - - if (animation_index) |ai| { - const id = file.animations.get(ai).id; - return palette.getDVUIColor(@intCast(id)); - } - } - return null; -} - -fn checkerboardCellCornerColor( - effect: pixelart.Settings.TransparencyEffect, - file: *pixelart.internal.File, - sprite_index: usize, - c_tl: dvui.Color, - c_tr: dvui.Color, - c_bl: dvui.Color, - c_br: dvui.Color, - u: f32, - v: f32, - mu: f32, - mv: f32, - tone: dvui.Color, -) dvui.Color { - switch (effect) { - .none => return tone, - .rainbow => return checkerboardVertexColor(c_tl, c_tr, c_bl, c_br, u, v, mu, mv, tone), - .animation => { - if (spriteAnimationPaletteColor(file, sprite_index)) |ac| { - const row = file.rowFromIndex(sprite_index); - const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0); - const v_cell_top = @as(f32, @floatFromInt(row)) / rows_f; - const v_cell_bot = @as(f32, @floatFromInt(row + 1)) / rows_f; - const v_mid = (v_cell_top + v_cell_bot) * 0.5; - // Top of cell: normal tone; bottom: animation tint (fade upward across the cell). - if (v <= v_mid) return tone; - return tone.lerp(ac, 0.4); - } - return tone; - }, - } -} - -fn checkerboardGridPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr: dvui.Color, c_bl: dvui.Color, c_br: dvui.Color } { - const tone = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(dvui.currentWindow().alpha); - const c_tl = tone; - const c_tr = tone.lerp(.red, 0.18); - const c_bl = tone.lerp(.blue, 0.12); - const c_br = c_tr.lerp(c_bl, 0.5); - return .{ .tone = tone, .c_tl = c_tl, .c_tr = c_tr, .c_bl = c_bl, .c_br = c_br }; -} - -/// 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 { - const pal = checkerboardGridPalette(); - const tone = pal.tone; - 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 }; - const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0); - const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0); - const col = file.columnFromIndex(sprite_index); - const row = file.rowFromIndex(sprite_index); - const u = (@as(f32, @floatFromInt(col)) + 0.5) / cols_f; - const v = (@as(f32, @floatFromInt(row)) + 0.5) / rows_f; - return checkerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u, v, mu_mv.x, mu_mv.y, tone); - }, - // Bubbles: base checkerboard tone only (no animation palette tint; that applies on the canvas grid). - .animation => return tone, - } -} - -/// 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 { - const n = file.spriteCount(); - if (n == 0) return; - - const te = Globals.state.settings.transparency_effect; - const pal = checkerboardGridPalette(); - const tone = pal.tone; - const rs = file.editor.canvas.screen_rect_scale; - - const gp = fileCanvasVisibleGridParams(file) orelse return; - if (gp.first_vis_row >= gp.last_vis_row or gp.vx1 <= 0) return; - - const arena = dvui.currentWindow().arena(); - var builder = dvui.Triangles.Builder.init(arena, n * 4, n * 6) catch { - dvui.log.err("Failed to allocate checkerboard batch", .{}); - return; - }; - defer builder.deinit(arena); - - const c_tl = pal.c_tl; - const c_tr = pal.c_tr; - const c_bl = pal.c_bl; - const c_br = pal.c_br; - - const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0); - const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0); - - const canvas = file.editor.canvas; - const mouse_screen = dvui.currentWindow().mouse_pt; - var target_mu: f32 = 0.5; - var target_mv: f32 = 0.5; - if (canvas.rect.contains(mouse_screen)) { - const md = canvas.screen_rect_scale.pointFromPhysical(mouse_screen); - const fw = @as(f32, @floatFromInt(file.width())); - const fh = @as(f32, @floatFromInt(file.height())); - if (fw > 0) target_mu = math.clamp(md.x / fw, 0, 1); - if (fh > 0) target_mv = math.clamp(md.y / fh, 0, 1); - } - - const prev_uv = dvui.dataGet(null, canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 }; - const smooth_t: f32 = 0.15; - const mu = prev_uv.x + (target_mu - prev_uv.x) * smooth_t; - const mv = prev_uv.y + (target_mv - prev_uv.y) * smooth_t; - dvui.dataSet(null, canvas.id, "checkerboard_mouse_uv", dvui.Point{ .x = mu, .y = mv }); - - var quad_idx: usize = 0; - var row: usize = gp.first_vis_row; - while (row < gp.last_vis_row) : (row += 1) { - const row_start = row * gp.cols; - const row_end = @min(row_start + gp.cols, n); - if (row_end <= row_start) continue; - - const row_span = row_end - row_start; - var col_lo: usize = 0; - if (gp.vx0 > 0) col_lo = @intFromFloat(@floor(gp.vx0 / gp.col_w)); - var col_hi_excl: usize = @intFromFloat(@ceil(gp.vx1 / gp.col_w)); - col_lo = @min(col_lo, row_span); - col_hi_excl = @min(col_hi_excl, row_span); - - var col = col_lo; - while (col < col_hi_excl) : (col += 1) { - const i = row_start + col; - const sr = file.spriteRect(i); - if (gp.visible_data.intersect(sr).empty()) continue; - - const r = rs.rectToPhysical(sr); - const tl = r.topLeft(); - const tr = r.topRight(); - const br = r.bottomRight(); - const bl = r.bottomLeft(); - - const col_i = file.columnFromIndex(i); - const row_i = file.rowFromIndex(i); - const u_left = @as(f32, @floatFromInt(col_i)) / cols_f; - const u_right = @as(f32, @floatFromInt(col_i + 1)) / cols_f; - const v_top = @as(f32, @floatFromInt(row_i)) / rows_f; - const v_bot = @as(f32, @floatFromInt(row_i + 1)) / rows_f; - - const pma_tl = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, i, c_tl, c_tr, c_bl, c_br, u_left, v_top, mu, mv, tone)); - const pma_tr = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, i, c_tl, c_tr, c_bl, c_br, u_right, v_top, mu, mv, tone)); - const pma_br = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, i, c_tl, c_tr, c_bl, c_br, u_right, v_bot, mu, mv, tone)); - const pma_bl = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, i, c_tl, c_tr, c_bl, c_br, u_left, v_bot, mu, mv, tone)); - - builder.appendVertex(.{ .pos = tl, .col = pma_tl, .uv = .{ 0, 0 } }); - builder.appendVertex(.{ .pos = tr, .col = pma_tr, .uv = .{ 1, 0 } }); - builder.appendVertex(.{ .pos = br, .col = pma_br, .uv = .{ 1, 1 } }); - builder.appendVertex(.{ .pos = bl, .col = pma_bl, .uv = .{ 0, 1 } }); - - const quad_base: dvui.Vertex.Index = @intCast(quad_idx * 4); - builder.appendTriangles(&.{ quad_base + 1, quad_base + 0, quad_base + 3, quad_base + 1, quad_base + 3, quad_base + 2 }); - quad_idx += 1; - } - } - - if (quad_idx == 0) return; - - const triangles = builder.build(); - dvui.renderTriangles(triangles, file.checkerboardTileTexture()) catch { - dvui.log.err("Failed to render batched checkerboard", .{}); - }; -} - -pub fn active(self: *FileWidget) bool { - if (Globals.state.docs.activeFile(Globals.state.host)) |file| { - if (file.id == self.init_options.file.id) { - return true; - } - } - return false; -} - -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 (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; - - var subtract = false; - var add = false; - - for (dvui.events()) |*e| { - if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - switch (e.evt) { - .key => |ke| { - if (ke.mod.matchBind("shift")) { - subtract = true; - } else if (ke.mod.matchBind("ctrl/cmd")) { - add = true; - } - }, - .mouse => |me| { - if (me.mod.matchBind("shift")) { - subtract = true; - } else if (me.mod.matchBind("ctrl/cmd")) { - add = true; - } - }, - else => {}, - } - } - - const mouse_point = dvui.currentWindow().mouse_pt; - if (!self.init_options.file.editor.canvas.pointerOverDrawable(mouse_point)) return; - if (self.sample_data_point != null) return; - - _ = dvui.cursorSet(.hidden); - - 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], - }; - - 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(Globals.state.host.uiAtlas().source) catch { - dvui.log.err("Failed to get atlas size", .{}); - return; - }; - - const uv = dvui.Rect{ - .x = (@as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w), - .y = (@as(f32, @floatFromInt(sprite.source[1])) / atlas_size.h), - .w = (@as(f32, @floatFromInt(sprite.source[2])) / atlas_size.w), - .h = (@as(f32, @floatFromInt(sprite.source[3])) / atlas_size.h), - }; - - const origin = dvui.Point{ - .x = sprite.origin[0] * 1 / self.init_options.file.editor.canvas.scale, - .y = sprite.origin[1] * 1 / self.init_options.file.editor.canvas.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(sprite.source[2])) * 1 / self.init_options.file.editor.canvas.scale, - .h = @as(f32, @floatFromInt(sprite.source[3])) * 1 / self.init_options.file.editor.canvas.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(); - - dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{ - .uv = uv, - }) catch { - dvui.log.err("Failed to render cursor image", .{}); - }; - } -} - -fn drawSamplePixelOutline(canvas: *CanvasWidget, data_point: dvui.Point) void { - const pixel_box_size = canvas.scale * dvui.currentWindow().rectScale().s; - const pixel_point: dvui.Point = .{ - .x = @round(data_point.x - 0.5), - .y = @round(data_point.y - 0.5), - }; - const pixel_box_point = canvas.screenFromDataPoint(pixel_point); - var pixel_box = dvui.Rect.Physical.fromSize(.{ .w = pixel_box_size, .h = pixel_box_size }); - pixel_box.x = pixel_box_point.x; - pixel_box.y = pixel_box_point.y; - dvui.Path.stroke(.{ .points = &.{ - pixel_box.topLeft(), - pixel_box.topRight(), - pixel_box.bottomRight(), - pixel_box.bottomLeft(), - } }, .{ .thickness = 2, .color = .white, .closed = true }); -} - -pub fn drawSample(self: *FileWidget) void { - const file = self.init_options.file; - if (self.sample_data_point) |data_point| { - if (!file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return; - drawSamplePixelOutline(&file.editor.canvas, data_point); - } -} - -/// Color-dropper magnifier: composite built to a render target, presented via RenderFrontToBack (not FloatingWidget). -/// Call after the file widget has finished drawing for the frame. -/// Map a data rect inside `region_data` to the corresponding physical pixels in `dest_phys`. -fn mapDataRectToPhysicalStrip(sr: dvui.Rect, parent_data: dvui.Rect, parent_phys: dvui.Rect.Physical) dvui.Rect.Physical { - const rel_x = sr.x - parent_data.x; - const rel_y = sr.y - parent_data.y; - return .{ - .x = parent_phys.x + rel_x / parent_data.w * parent_phys.w, - .y = parent_phys.y + rel_y / parent_data.h * parent_phys.h, - .w = sr.w / parent_data.w * parent_phys.w, - .h = sr.h / parent_data.h * parent_phys.h, - }; -} - -/// 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, - region_data: dvui.Rect, - dest_phys: dvui.Rect.Physical, - scale: f32, -) void { - const tile = file.checkerboardTileTexture() orelse return; - const cw: f32 = @floatFromInt(file.column_width); - const ch: f32 = @floatFromInt(file.row_height); - if (region_data.w <= 0 or region_data.h <= 0 or dest_phys.w <= 0 or dest_phys.h <= 0) return; - if (cw <= 0 or ch <= 0) return; - - dvui.renderTexture(tile, .{ .r = dest_phys, .s = scale }, .{ - .colormod = dvui.themeGet().color(.content, .fill).lighten(12.0), - .uv = .{ - .x = region_data.x / cw, - .y = region_data.y / ch, - .w = region_data.w / cw, - .h = region_data.h / ch, - }, - }) catch { - dvui.log.err("Failed to render magnifier checkerboard", .{}); - }; -} - -/// 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, - region_data: dvui.Rect, - content_rs: dvui.RectScale, - file_w: f32, - file_h: f32, -) ?dvui.Texture.Target { - if (region_data.w <= 0 or region_data.h <= 0 or content_rs.r.w <= 0 or content_rs.r.h <= 0) return null; - - const w: u32 = @intFromFloat(@max(@ceil(content_rs.r.w), 1)); - 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{ - .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 { - 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 { - dvui.log.err("Failed to create magnifier composite target", .{}); - return null; - }; - - target.clear(); - - const prev_target = dvui.renderTarget(.{ .texture = target, .offset = .{ .x = 0, .y = 0 } }); - defer _ = dvui.renderTarget(prev_target); - - const prev_clip = dvui.clipGet(); - defer dvui.clipSet(prev_clip); - dvui.clipSet(dvui.Rect.Physical{ .x = 0, .y = 0, .w = @floatFromInt(w), .h = @floatFromInt(h) }); - - const dest_phys = dvui.Rect.Physical{ .x = 0, .y = 0, .w = @floatFromInt(w), .h = @floatFromInt(h) }; - - drawSampleMagnifierCheckerboardTiles(file, region_data, dest_phys, 1.0); - - if (!layer_region.empty()) { - const layer_phys = mapDataRectToPhysicalStrip(layer_region, region_data, dest_phys); - const uv_rect = dvui.Rect{ - .x = layer_region.x / file_w, - .y = layer_region.y / file_h, - .w = layer_region.w / file_w, - .h = layer_region.h / file_h, - }; - pixelart.render.renderLayersMagnifierSample(.{ - .file = file, - .rs = .{ .r = layer_phys, .s = 1.0 }, - .uv = uv_rect, - .allow_peek = false, - }) catch { - dvui.log.err("Failed to render magnifier layers into composite", .{}); - }; - } - - return target; -} - -/// Present magnifier chrome + composite (deferred via RenderFrontToBack so it stacks above the canvas). -fn drawSampleMagnifierPresent( - composite: dvui.Texture.Target, - frame_phys: dvui.Rect.Physical, - content_rs: dvui.RectScale, - corner_radius: dvui.Rect, - border_nat: f32, -) void { - const window_fill = dvui.themeGet().color(.window, .fill); - const border_color = dvui.themeGet().color(.control, .text); - const ns = dvui.currentWindow().natural_scale; - - const corner_frame_phys = corner_radius.scale(content_rs.s, dvui.Rect.Physical); - const inner_corner = dvui.Rect{ - .x = @max(0, corner_radius.x - border_nat), - .y = @max(0, corner_radius.y - border_nat), - .w = @max(0, corner_radius.w - border_nat), - .h = @max(0, corner_radius.h - border_nat), - }; - - // Shadow (matches FloatingWidget magnifier styling). - const shadow_offset = dvui.Point.Physical{ .x = 2.0 / ns * content_rs.s, .y = 2.0 / ns * content_rs.s }; - const shadow_rect = dvui.Rect.Physical{ - .x = frame_phys.x + shadow_offset.x, - .y = frame_phys.y + shadow_offset.y, - .w = frame_phys.w, - .h = frame_phys.h, - }; - var shadow_path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - defer shadow_path.deinit(); - shadow_path.addRect(shadow_rect, corner_frame_phys); - dvui.Path.fillConvex(shadow_path.build(), .{ - .color = dvui.Color.black.opacity(0.2), - .fade = 15.0 / ns * content_rs.s, - }); - - // Window background behind content. - var bg_path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - defer bg_path.deinit(); - bg_path.addRect(frame_phys, corner_frame_phys); - dvui.Path.fillConvex(bg_path.build(), .{ .color = window_fill, .fade = 0 }); - - const tex = dvui.Texture.fromTargetTemp(composite) catch { - dvui.log.err("Failed to get magnifier composite texture", .{}); - return; - }; - - const source: dvui.ImageSource = .{ .texture = tex }; - dvui.renderImage(source, content_rs, .{ - .colormod = .white, - .uv = .{ .x = 0, .y = 0, .w = 1, .h = 1 }, - // Natural radii; `renderTexture` scales by `content_rs.s` once (not pre-scaled). - .corner_radius = inner_corner, - }) catch { - dvui.log.err("Failed to render magnifier composite", .{}); - }; - - // Border stroke centerline (matches FloatingWidget `borderAndBackground`). - const border_thickness = border_nat * content_rs.s; - const half = border_thickness * 0.5; - const border_stroke_rect = frame_phys.inset(dvui.Rect.Physical.all(half)); - border_stroke_rect.stroke(corner_frame_phys, .{ .thickness = border_thickness, .color = border_color }); - - const center_x = content_rs.r.x + content_rs.r.w / 2; - const center_y = content_rs.r.y + content_rs.r.h / 2; - const cross_size = @min(content_rs.r.w, content_rs.r.h) * 0.2; - - dvui.Path.stroke(.{ .points = &.{ - .{ .x = center_x - cross_size / 2, .y = center_y }, - .{ .x = center_x + cross_size / 2, .y = center_y }, - } }, .{ .thickness = 4, .color = .white }); - - dvui.Path.stroke(.{ .points = &.{ - .{ .x = center_x, .y = center_y - cross_size / 2 }, - .{ .x = center_x, .y = center_y + cross_size / 2 }, - } }, .{ .thickness = 4, .color = .white }); - - dvui.Path.stroke(.{ .points = &.{ - .{ .x = center_x - cross_size / 2 + 4, .y = center_y }, - .{ .x = center_x + cross_size / 2 - 4, .y = center_y }, - } }, .{ .thickness = 2, .color = .black }); - - dvui.Path.stroke(.{ .points = &.{ - .{ .x = center_x, .y = center_y - cross_size / 2 + 4 }, - .{ .x = center_x, .y = center_y + cross_size / 2 - 4 }, - } }, .{ .thickness = 2, .color = .black }); -} - -pub fn drawSampleMagnifier(file: *pixelart.internal.File, data_point: dvui.Point) void { - const canvas = &file.editor.canvas; - if (pixelart.core.dvui.canvasPointerInputSuppressed()) return; - if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return; - - _ = dvui.cursorSet(.hidden); - - const enlarged_scale: f32 = canvas.scale * (8.0 / (1.0 + canvas.scale)); - const sample_box_size: f32 = 200.0 * 1 / canvas.scale; - const sample_region_size: f32 = sample_box_size / enlarged_scale; - - // Home placement: bottom-left corner of the magnifier sits exactly at the sample point. - const default_magnifier_phys = canvas.screenFromDataRect(.{ - .x = data_point.x, - .y = data_point.y - sample_box_size, - .w = sample_box_size, - .h = sample_box_size, - }); - - // Slide the magnifier inside the OS window without flipping. Only the right and top edges - // can clip because home is up-and-right of the sample point. - const window_rect = dvui.windowRectPixels(); - const push_x_phys = @max(0, (default_magnifier_phys.x + default_magnifier_phys.w) - (window_rect.x + window_rect.w)); - const push_y_phys = @max(0, window_rect.y - default_magnifier_phys.y); - - const magnifier_phys = dvui.Rect.Physical{ - .x = default_magnifier_phys.x - push_x_phys, - .y = default_magnifier_phys.y + push_y_phys, - .w = default_magnifier_phys.w, - .h = default_magnifier_phys.h, - }; - const magnifier_nat = magnifier_phys.toNatural(); - - // Corner-radius rect maps {x: TL, y: TR, w: BR, h: BL}. At home BL is sharp (0) so it "points" - // at the sample. As the window pushes the magnifier away, grow BL toward `cr_max` so the - // rectangle's rounded edge slides tangent to the sample point — fully circular when far enough. - // `cr_max` is just under half-width because `Path.addRect` skips the apex when two adjacent - // radii both equal half the edge length. - const cr_max = magnifier_nat.w / 2 - 0.51; - const win_scale = dvui.windowRectScale().s; - const push_dist_phys = @sqrt(push_x_phys * push_x_phys + push_y_phys * push_y_phys); - const push_dist_nat = if (win_scale > 0) push_dist_phys / win_scale else push_dist_phys; - const bl_radius = @min(cr_max, push_dist_nat); - const corner_radius = dvui.Rect{ .x = cr_max, .y = cr_max, .w = cr_max, .h = bl_radius }; - - const win_rs = dvui.windowRectScale(); - const ns = dvui.currentWindow().natural_scale; - const border_nat = 2.0 / ns; - const border_phys = border_nat * win_rs.s; - - const frame_phys = magnifier_phys; - const content_phys = frame_phys.inset(dvui.Rect.Physical.all(border_phys)); - const content_rs = dvui.RectScale{ .r = content_phys, .s = win_rs.s }; - - const region_data = dvui.Rect{ - .x = data_point.x - sample_region_size / 2, - .y = data_point.y - sample_region_size / 2, - .w = sample_region_size, - .h = sample_region_size, - }; - - const file_w: f32 = @floatFromInt(file.width()); - const file_h: f32 = @floatFromInt(file.height()); - - const composite = drawSampleMagnifierCompositeBuild(file, region_data, content_rs, file_w, file_h) orelse return; - defer composite.destroyLater(); - - // Break out of canvas/file clipping (same as FloatingWidget) so the border is not cut off at the top. - const prev_clip = dvui.clipGet(); - defer dvui.clipSet(prev_clip); - dvui.clipSet(dvui.windowRectPixels()); - - // Draw on top of the canvas after normal widgets (FloatingWidget defers; this uses RenderFrontToBack). - var ftb: dvui.RenderFrontToBack = undefined; - ftb.init(); - defer ftb.deinit(); - - drawSampleMagnifierPresent(composite, frame_phys, content_rs, corner_radius, border_nat); -} - -pub fn updateActiveLayerMask(self: *FileWidget) void { - var file = self.init_options.file; - if (file.selected_layer_index >= file.layers.len) return; - - const source_hash = file.layers.items(.source)[file.selected_layer_index].hash(); - const cached = file.editor.mask_built_for_layer == file.selected_layer_index and - file.editor.mask_built_source_hash == source_hash and - dvui.textureGetCached(source_hash) != null; - - if (cached) return; - - var active_layer = file.layers.get(file.selected_layer_index); - active_layer.clearMask(); - active_layer.setMaskFromTransparency(true); - - file.editor.mask_built_for_layer = file.selected_layer_index; - file.editor.mask_built_source_hash = source_hash; -} - -pub fn drawLayers(self: *FileWidget) void { - const perf_t0 = pixelart.perf.drawLayersBegin(); - defer pixelart.perf.drawLayersEnd(perf_t0); - - var file = self.init_options.file; - var columns: usize = file.columns; - var rows: usize = file.rows; - - const layer_rect = self.init_options.file.editor.canvas.dataFromScreenRect(self.init_options.file.editor.canvas.rect); - var canvas_rect = layer_rect; - - if (self.resize_data_point) |resize_data_point| { - canvas_rect.w = resize_data_point.x; - canvas_rect.h = resize_data_point.y; - - if (resize_data_point.x < layer_rect.x + layer_rect.w or resize_data_point.y < layer_rect.y + layer_rect.h) { - const grid_thickness = std.math.clamp(dvui.currentWindow().natural_scale * self.init_options.file.editor.canvas.scale, 0, dvui.currentWindow().natural_scale); - self.init_options.file.editor.canvas.screenFromDataRect(layer_rect).fill(.all(0), .{ .color = dvui.themeGet().color(.err, .fill).opacity(0.5), .fade = 1.5 }); - drawBatchedResizeOverlayGrid(self, file, columns, layer_rect, grid_thickness); - } - - columns = @divTrunc(@as(u32, @intFromFloat(canvas_rect.w)), file.column_width); - rows = @divTrunc(@as(u32, @intFromFloat(canvas_rect.h)), file.row_height); - } - - const shadow_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = canvas_rect, - .border = dvui.Rect.all(0), - .box_shadow = .{ - .fade = 20 * 1 / self.init_options.file.editor.canvas.scale, - .corner_radius = dvui.Rect.all(2 * 1 / self.init_options.file.editor.canvas.scale), - .alpha = if (dvui.themeGet().dark) 0.4 else 0.2, - .offset = .{ - .x = 2 * 1 / self.init_options.file.editor.canvas.scale, - .y = 2 * 1 / self.init_options.file.editor.canvas.scale, - }, - }, - }); - shadow_box.deinit(); - - const fill_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = .{ .x = layer_rect.x, .y = layer_rect.y, .w = @min(canvas_rect.w, layer_rect.w), .h = @min(canvas_rect.h, layer_rect.h) }, - .border = dvui.Rect.all(0), - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - }); - fill_box.deinit(); - - // Content fill + batched checkerboard (including resize and column/row reorder preview; skip during cell reorder). - if (self.removed_sprite_indices == null) { - const bg_rect = dvui.Rect{ - .x = layer_rect.x, - .y = layer_rect.y, - .w = @min(canvas_rect.w, layer_rect.w), - .h = @min(canvas_rect.h, layer_rect.h), - }; - const bg_screen = self.init_options.file.editor.canvas.screenFromDataRect(bg_rect); - if (self.init_options.file.editor.canvas.scale < 0.1) { - bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 }); - } else { - bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 }); - drawCheckerboardCellsBatched(file); - } - } - - // Render all layers and update our bounding box; - { - if (self.removed_sprite_indices != null) { - self.drawCellReorderPreview(); - return; - } else if (self.columnRowReorderActive()) { - self.drawColumnRowReorderPreview(); - return; - } else { - pixelart.render.renderLayers(.{ - .file = file, - .rs = .{ - .r = self.init_options.file.editor.canvas.rect, - .s = self.init_options.file.editor.canvas.scale, - }, - }) catch { - dvui.log.err("Failed to render file image", .{}); - return; - }; - } - } - - // Draw the resize fill area if a resize is happening - if (self.resize_data_point) |resize_data_point| { - if (resize_data_point.x > layer_rect.x + layer_rect.w) { - const new_tiles_rect = dvui.Rect{ - .x = layer_rect.topRight().x, - .y = layer_rect.topRight().y, - .w = resize_data_point.x - layer_rect.topRight().x, - .h = @min(resize_data_point.y - layer_rect.topRight().y, layer_rect.h), - }; - - self.init_options.file.editor.canvas.screenFromDataRect(new_tiles_rect).fill(.all(0), .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), .fade = 0.0 }); - } - if (resize_data_point.y > layer_rect.y + layer_rect.h) { - const new_tiles_rect = dvui.Rect{ - .x = layer_rect.topLeft().x, - .y = layer_rect.bottomLeft().y, - .w = resize_data_point.x, - .h = resize_data_point.y - layer_rect.bottomLeft().y, - }; - - self.init_options.file.editor.canvas.screenFromDataRect(new_tiles_rect).fill(.all(0), .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), .fade = 0.0 }); - } - } - - // Draw the grid lines for the canvas as a single batched draw call. - { - const grid_color = dvui.themeGet().color(.control, .fill); - const c_scale = self.init_options.file.editor.canvas.scale; - const grid_thickness = std.math.clamp(dvui.currentWindow().natural_scale * c_scale, 0, dvui.currentWindow().natural_scale); - const grid_y0 = canvas_rect.y; - const grid_y1 = canvas_rect.y + canvas_rect.h; - const grid_x0 = canvas_rect.x; - const grid_x1 = canvas_rect.x + canvas_rect.w; - const vertical_inner = @min(columns, file.columns); - - drawBatchedGridLines(self, file, columns, rows, grid_color, grid_thickness, grid_x0, grid_x1, grid_y0, grid_y1, vertical_inner); - } - - // 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) { - 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)) { - 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 }; - const horizontal_line_end: dvui.Point = .{ .x = sprite_rect.topRight().x, .y = origin.y }; - const vertical_line_start: dvui.Point = .{ .x = origin.x, .y = sprite_rect.topLeft().y }; - const vertical_line_end: dvui.Point = .{ .x = origin.x, .y = sprite_rect.bottomLeft().y }; - - dvui.Path.stroke(.{ .points = &.{ - file.editor.canvas.screenFromDataPoint(horizontal_line_start), - file.editor.canvas.screenFromDataPoint(horizontal_line_end), - } }, .{ .thickness = 1, .color = dvui.themeGet().color(.err, .fill) }); - - dvui.Path.stroke(.{ .points = &.{ - file.editor.canvas.screenFromDataPoint(vertical_line_start), - file.editor.canvas.screenFromDataPoint(vertical_line_end), - } }, .{ .thickness = 1, .color = dvui.themeGet().color(.err, .fill) }); - } - - sprite_rect_physical.inset(.all(dvui.currentWindow().natural_scale * 1.5)).stroke(dvui.Rect.Physical.all(@min(sprite_rect_physical.w, sprite_rect_physical.h) / 8), .{ - .thickness = 1.5 * dvui.currentWindow().natural_scale, - .color = dvui.themeGet().color(.highlight, .fill), - .closed = true, - }); - } - } -} - -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, - removed_data_rect: dvui.Rect, - strip_phys: dvui.Rect.Physical, - axis: ReorderAxis, - removed_index: usize, -) void { - _ = self; - const pd = removed_data_rect; - if (pd.w <= 0 or pd.h <= 0) return; - if (strip_phys.w <= 0 or strip_phys.h <= 0) return; - - const n = switch (axis) { - .columns => file.rows, - .rows => file.columns, - }; - if (n == 0) return; - - const arena = dvui.currentWindow().arena(); - var builder = dvui.Triangles.Builder.init(arena, n * 4, n * 6) catch { - dvui.log.err("Failed to allocate reorder floating checkerboard", .{}); - return; - }; - defer builder.deinit(arena); - - const pal = checkerboardGridPalette(); - const tone = pal.tone; - const c_tl = pal.c_tl; - 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 cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0); - const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0); - - const mu_mv = dvui.dataGet(null, file.editor.canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 }; - const mu = mu_mv.x; - const mv = mu_mv.y; - - const half_op = dvui.Color.PMA{ .r = 128, .g = 128, .b = 128, .a = 128 }; - - var quad_i: usize = 0; - for (0..n) |i| { - const si = switch (axis) { - .columns => removed_index + i * file.columns, - .rows => i + removed_index * file.columns, - }; - const sr = file.spriteRect(si); - const phys = mapDataRectToPhysicalStrip(sr, pd, strip_phys); - const col = file.columnFromIndex(si); - const row = file.rowFromIndex(si); - const u_left = @as(f32, @floatFromInt(col)) / cols_f; - const u_right = @as(f32, @floatFromInt(col + 1)) / cols_f; - const v_top = @as(f32, @floatFromInt(row)) / rows_f; - const v_bot = @as(f32, @floatFromInt(row + 1)) / rows_f; - - const pma_tl = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, si, c_tl, c_tr, c_bl, c_br, u_left, v_top, mu, mv, tone)).multiply(half_op); - const pma_tr = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, si, c_tl, c_tr, c_bl, c_br, u_right, v_top, mu, mv, tone)).multiply(half_op); - const pma_br = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, si, c_tl, c_tr, c_bl, c_br, u_right, v_bot, mu, mv, tone)).multiply(half_op); - const pma_bl = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, si, c_tl, c_tr, c_bl, c_br, u_left, v_bot, mu, mv, tone)).multiply(half_op); - - const tl = phys.topLeft(); - const tr = phys.topRight(); - const br = phys.bottomRight(); - const bl = phys.bottomLeft(); - - builder.appendVertex(.{ .pos = tl, .col = pma_tl, .uv = .{ 0, 0 } }); - builder.appendVertex(.{ .pos = tr, .col = pma_tr, .uv = .{ 1, 0 } }); - builder.appendVertex(.{ .pos = br, .col = pma_br, .uv = .{ 1, 1 } }); - builder.appendVertex(.{ .pos = bl, .col = pma_bl, .uv = .{ 0, 1 } }); - - const quad_base: dvui.Vertex.Index = @intCast(quad_i * 4); - builder.appendTriangles(&.{ quad_base + 1, quad_base + 0, quad_base + 3, quad_base + 1, quad_base + 3, quad_base + 2 }); - quad_i += 1; - } - - const triangles = builder.build(); - dvui.renderTriangles(triangles, file.checkerboardTileTexture()) catch { - dvui.log.err("Failed to render reorder floating checkerboard", .{}); - }; -} - -/// Content fill + batched checkerboard for the file canvas (same as the normal `drawLayers` path). -fn drawCanvasCheckerboardBackground(self: *FileWidget) void { - const file = self.init_options.file; - const canvas = &file.editor.canvas; - const layer_rect = canvas.dataFromScreenRect(canvas.rect); - const bg_rect = dvui.Rect{ - .x = layer_rect.x, - .y = layer_rect.y, - .w = layer_rect.w, - .h = layer_rect.h, - }; - const bg_screen = canvas.screenFromDataRect(bg_rect); - if (canvas.scale < 0.1) { - bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 }); - } else { - bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 }); - drawCheckerboardCellsBatched(file); - } -} - -fn drawColumnRowReorderPreview(self: *FileWidget) void { - const file = self.init_options.file; - const cd = self.canvasData() orelse return; - if (cd.columns_drag_index == null and cd.rows_drag_index == null) return; - - const axis: ReorderAxis = if (cd.columns_drag_index != null) .columns else .rows; - const target_index = switch (axis) { - .columns => cd.columns_target_index, - .rows => cd.rows_target_index, - }; - const removed_index = switch (axis) { - .columns => cd.columns_drag_index, - .rows => cd.rows_drag_index, - } orelse return; - - self.drawReorderPreviewForAxis(file, axis, target_index, removed_index); -} - -fn renderLayersInDataRect( - self: *FileWidget, - file: *pixelart.internal.File, - data_rect: dvui.Rect, - screen_rect_override: ?dvui.Rect.Physical, -) void { - const scale = self.init_options.file.editor.canvas.scale; - 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(.{ - .file = file, - .rs = .{ .r = r, .s = scale }, - .uv = .{ - .x = data_rect.x / w, - .y = data_rect.y / h, - .w = data_rect.w / w, - .h = data_rect.h / h, - }, - }) catch dvui.log.err("Failed to render file image", .{}); -} - -fn reorderSegmentRects( - axis: ReorderAxis, - file: *pixelart.internal.File, - target_index: usize, - removed_index: usize, - target_rect: dvui.Rect, - removed_rect: dvui.Rect, -) struct { - first: dvui.Rect, - middle: ?dvui.Rect, - last: dvui.Rect, - middle_screen_offset: dvui.Point, -} { - const slot_size = switch (axis) { - .columns => file.column_width, - .rows => file.row_height, - }; - const slot_count = switch (axis) { - .columns => file.columns, - .rows => file.rows, - }; - const slot_f = @as(f32, @floatFromInt(slot_size)); - const extent_other = switch (axis) { - .columns => @as(f32, @floatFromInt(file.height())), - .rows => @as(f32, @floatFromInt(file.width())), - }; - - if (target_index <= removed_index) { - const first: dvui.Rect = switch (axis) { - .columns => .{ .x = 0.0, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(target_index)), .h = extent_other }, - .rows => .{ .x = 0.0, .y = 0.0, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(target_index)) }, - }; - const middle_n = removed_index - target_index; - const middle: ?dvui.Rect = if (middle_n >= 1) - switch (axis) { - .columns => .{ .x = target_rect.x, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(middle_n)), .h = extent_other }, - .rows => .{ .x = 0.0, .y = target_rect.y, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(middle_n)) }, - } - else - null; - const last: dvui.Rect = switch (axis) { - .columns => .{ .x = removed_rect.x + removed_rect.w, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(slot_count - removed_index - 1)), .h = extent_other }, - .rows => .{ .x = 0.0, .y = removed_rect.y + removed_rect.h, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(slot_count - removed_index - 1)) }, - }; - const middle_screen_offset: dvui.Point = switch (axis) { - .columns => .{ .x = slot_f, .y = 0.0 }, - .rows => .{ .x = 0.0, .y = slot_f }, - }; - return .{ .first = first, .middle = middle, .last = last, .middle_screen_offset = middle_screen_offset }; - } else { - const first: dvui.Rect = switch (axis) { - .columns => .{ .x = 0.0, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(removed_index)), .h = extent_other }, - .rows => .{ .x = 0.0, .y = 0.0, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(removed_index)) }, - }; - const middle_n = target_index - removed_index; - const middle: ?dvui.Rect = if (middle_n >= 1) - switch (axis) { - .columns => .{ .x = removed_rect.x + removed_rect.w, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(middle_n)), .h = extent_other }, - .rows => .{ .x = 0.0, .y = removed_rect.y + removed_rect.h, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(middle_n)) }, - } - else - null; - const last: dvui.Rect = switch (axis) { - .columns => .{ .x = target_rect.x + target_rect.w, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(slot_count - target_index - 1)), .h = extent_other }, - .rows => .{ .x = 0.0, .y = target_rect.y + target_rect.h, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(slot_count - target_index - 1)) }, - }; - const middle_screen_offset: dvui.Point = switch (axis) { - .columns => .{ .x = -slot_f, .y = 0.0 }, - .rows => .{ .x = 0.0, .y = -slot_f }, - }; - return .{ .first = first, .middle = middle, .last = last, .middle_screen_offset = middle_screen_offset }; - } -} - -fn drawReorderPreviewForAxis( - self: *FileWidget, - file: *pixelart.internal.File, - axis: ReorderAxis, - target_index: ?usize, - removed_index: usize, -) void { - self.drawCanvasCheckerboardBackground(); - - const canvas = &file.editor.canvas; - const layer_rect = canvas.dataFromScreenRect(canvas.rect); - const grid_y0 = layer_rect.y; - const grid_y1 = layer_rect.y + layer_rect.h; - const grid_x0 = layer_rect.x; - const grid_x1 = layer_rect.x + layer_rect.w; - const grid_thickness = std.math.clamp(dvui.currentWindow().natural_scale * canvas.scale, 0, dvui.currentWindow().natural_scale); - const grid_color = dvui.themeGet().color(.control, .fill); - - const removed_rect = switch (axis) { - .columns => file.columnRect(removed_index), - .rows => file.rowRect(removed_index), - }; - - if (target_index == null) { - // Dragging but not over canvas: draw full layers unchanged, then dim removed slot only - - { - for (1..file.columns) |i| { - const gx = @as(f32, @floatFromInt(i * file.column_width)); - dvui.Path.stroke(.{ .points = &.{ - canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y0 }), - canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y1 }), - } }, .{ .thickness = grid_thickness, .color = grid_color }); - } - - for (1..file.rows) |i| { - const gy = @as(f32, @floatFromInt(i * file.row_height)); - dvui.Path.stroke(.{ .points = &.{ - canvas.screenFromDataPoint(.{ .x = grid_x0, .y = gy }), - canvas.screenFromDataPoint(.{ .x = grid_x1, .y = gy }), - } }, .{ .thickness = grid_thickness, .color = grid_color }); - } - } - - const full_rect = dvui.Rect{ - .x = 0.0, - .y = 0.0, - .w = @floatFromInt(file.width()), - .h = @floatFromInt(file.height()), - }; - self.renderLayersInDataRect(file, full_rect, null); - return; - } - - const target_i = target_index.?; - const same_slot = removed_index == target_i; - - const target_rect = switch (axis) { - .columns => file.columnRect(target_i), - .rows => file.rowRect(target_i), - }; - - const scale = file.editor.canvas.scale; - const box_dir = switch (axis) { - .columns => dvui.enums.Direction.horizontal, - .rows => dvui.enums.Direction.vertical, - }; - - defer { - var target_box_rect = target_rect; - - const tl = dvui.currentWindow().mouse_pt.plus(dvui.dragOffset()); - const data_tl = file.editor.canvas.dataFromScreenPoint(tl); - - switch (axis) { - .columns => { - target_box_rect.x = data_tl.x; - }, - .rows => { - target_box_rect.y = data_tl.y; - }, - } - - var animated_target_box_rect = target_rect; - - { - const current_tl: dvui.Point = self.grid_reorder_point orelse .{ .x = 0.0, .y = 0.0 }; - - if (animated_target_box_rect.topLeft().x != current_tl.x or animated_target_box_rect.topLeft().y != current_tl.y) { - defer self.grid_reorder_point = animated_target_box_rect.topLeft(); - - if (self.grid_reorder_point != null) { - if (dvui.animationGet(self.init_options.file.editor.canvas.id, "reorder_target_rect_x")) |anim| { - if (anim.end_val != animated_target_box_rect.x) { - _ = dvui.currentWindow().animations.remove(self.init_options.file.editor.canvas.id.update("reorder_target_rect_x")); - dvui.animation(self.init_options.file.editor.canvas.id, "reorder_target_rect_x", .{ - .start_val = anim.value(), - .end_val = animated_target_box_rect.x, - .end_time = 350_000, - .easing = dvui.easing.outBack, - }); - } - } else if (animated_target_box_rect.x != current_tl.x) { - - // If we are here, we need to trigger a new animation to move the resize button rect to the new point - dvui.animation(self.init_options.file.editor.canvas.id, "reorder_target_rect_x", .{ - .start_val = current_tl.x, - .end_val = animated_target_box_rect.x, - .end_time = 350_000, - .easing = dvui.easing.outBack, - }); - } else if (dvui.animationGet(self.init_options.file.editor.canvas.id, "reorder_target_rect_y")) |anim| { - if (anim.end_val != animated_target_box_rect.y) { - _ = dvui.currentWindow().animations.remove(self.init_options.file.editor.canvas.id.update("reorder_target_rect_y")); - dvui.animation(self.init_options.file.editor.canvas.id, "reorder_target_rect_y", .{ - .start_val = anim.value(), - .end_val = animated_target_box_rect.y, - .end_time = 350_000, - .easing = dvui.easing.outBack, - }); - } - } else if (animated_target_box_rect.y != current_tl.y) { - - // If we are here, we need to trigger a new animation to move the resize button rect to the new point - dvui.animation(self.init_options.file.editor.canvas.id, "reorder_target_rect_y", .{ - .start_val = current_tl.y, - .end_val = animated_target_box_rect.y, - .end_time = 350_000, - .easing = dvui.easing.outBack, - }); - } - } - - if (dvui.animationGet(self.init_options.file.editor.canvas.id, "reorder_target_rect_x")) |anim| { - animated_target_box_rect.x = anim.value(); - } - - if (dvui.animationGet(self.init_options.file.editor.canvas.id, "reorder_target_rect_y")) |anim| { - animated_target_box_rect.y = anim.value(); - } - } - } - - file.editor.canvas.screenFromDataRect(animated_target_box_rect).fill(.all(3.0 / scale), .{ - .color = if (same_slot) - dvui.themeGet().color(.control, .fill).opacity(0.6) - else - dvui.themeGet().color(.highlight, .fill).opacity(0.6), - .fade = 1.0, - }); - - { - pixelart.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, .{ - .opacity = 0.5, - }); - } - - const target_box = dvui.box(@src(), .{ .dir = box_dir }, .{ - .expand = .none, - .rect = target_box_rect, - .border = dvui.Rect.all(0), - .background = true, - .color_fill = if (same_slot) - dvui.themeGet().color(.control, .fill).opacity(0.75) - else - dvui.themeGet().color(.control, .fill).opacity(0.75), - .box_shadow = .{ - .color = .black, - .offset = .{ - .x = -4 / scale, - .y = 0.0, - }, - .alpha = 0.25, - .fade = 16 / scale, - .corner_radius = dvui.Rect.all(target_rect.w / 2.0 / scale), - }, - }); - defer target_box.deinit(); - - self.renderLayersInDataRect(file, removed_rect, target_box.data().rectScale().r); - self.drawCheckerboardReorderFloatingStrip(file, removed_rect, target_box.data().rectScale().r, axis, removed_index); - } - - defer { - const err_color = dvui.themeGet().color(.err, .fill); - if (!same_slot) { - // Tint the original removed slot with err color so the canvas matches the - // dragged-from indicator used in our tree widgets (files / layers / animations). - file.editor.canvas.screenFromDataRect(removed_rect).fill(.all(0), .{ - .color = err_color.opacity(0.25), - .fade = 1.0, - }); - } - - if (removed_index != target_i) { - if (axis == .columns) { - const top = if (removed_index < target_i) removed_rect.topLeft() else removed_rect.topRight(); - const bottom = if (removed_index < target_i) removed_rect.bottomLeft() else removed_rect.bottomRight(); - dvui.Path.stroke(.{ .points = &.{ - file.editor.canvas.screenFromDataPoint(top), - file.editor.canvas.screenFromDataPoint(bottom), - } }, .{ .thickness = 3, .color = err_color }); - - dvui.Path.fillConvex(.{ - .points = &.{ - file.editor.canvas.screenFromDataPoint(top), - file.editor.canvas.screenFromDataPoint(top.plus(.{ .x = 5.0 / scale, .y = -10.0 / scale })), - file.editor.canvas.screenFromDataPoint(top.plus(.{ .x = -5.0 / scale, .y = -10.0 / scale })), - }, - }, .{ - .color = err_color, - .fade = 1.0, - }); - - dvui.Path.fillConvex(.{ - .points = &.{ - file.editor.canvas.screenFromDataPoint(bottom), - file.editor.canvas.screenFromDataPoint(bottom.plus(.{ .x = 5.0 / scale, .y = 10.0 / scale })), - file.editor.canvas.screenFromDataPoint(bottom.plus(.{ .x = -5.0 / scale, .y = 10.0 / scale })), - }, - }, .{ - .color = err_color, - .fade = 1.0, - }); - } else { - const left = if (removed_index < target_i) removed_rect.topLeft() else removed_rect.bottomLeft(); - const right = if (removed_index < target_i) removed_rect.topRight() else removed_rect.bottomRight(); - dvui.Path.stroke(.{ .points = &.{ - file.editor.canvas.screenFromDataPoint(left), - file.editor.canvas.screenFromDataPoint(right), - } }, .{ .thickness = 3, .color = err_color }); - - dvui.Path.fillConvex(.{ - .points = &.{ - file.editor.canvas.screenFromDataPoint(left), - file.editor.canvas.screenFromDataPoint(left.plus(.{ .x = -8.0 / scale, .y = -5.0 / scale })), - file.editor.canvas.screenFromDataPoint(left.plus(.{ .x = -8.0 / scale, .y = 5.0 / scale })), - }, - }, .{ - .color = err_color, - .fade = 1.0, - }); - dvui.Path.fillConvex(.{ - .points = &.{ - file.editor.canvas.screenFromDataPoint(right), - file.editor.canvas.screenFromDataPoint(right.plus(.{ .x = 8.0 / scale, .y = -5.0 / scale })), - file.editor.canvas.screenFromDataPoint(right.plus(.{ .x = 8.0 / scale, .y = 5.0 / scale })), - }, - }, .{ - .color = err_color, - .fade = 1.0, - }); - } - } - } - - const segments = reorderSegmentRects(axis, file, target_i, removed_index, target_rect, removed_rect); - - self.renderLayersInDataRect(file, segments.first, null); - if (segments.middle) |middle_rect| { - const screen_rect = canvas.screenFromDataRect(middle_rect.offsetPoint(segments.middle_screen_offset)); - self.renderLayersInDataRect(file, middle_rect, screen_rect); - } - if (segments.last.w > 0.0 and segments.last.h > 0.0) { - self.renderLayersInDataRect(file, segments.last, null); - } - - { - for (1..file.columns) |i| { - const gx = @as(f32, @floatFromInt(i * file.column_width)); - dvui.Path.stroke(.{ .points = &.{ - canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y0 }), - canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y1 }), - } }, .{ .thickness = grid_thickness, .color = grid_color }); - } - - for (1..file.rows) |i| { - const gy = @as(f32, @floatFromInt(i * file.row_height)); - dvui.Path.stroke(.{ .points = &.{ - canvas.screenFromDataPoint(.{ .x = grid_x0, .y = gy }), - canvas.screenFromDataPoint(.{ .x = grid_x1, .y = gy }), - } }, .{ .thickness = grid_thickness, .color = grid_color }); - } - } -} - -pub fn drawCellReorderPreview(self: *FileWidget) void { - const file = self.init_options.file; - self.drawCanvasCheckerboardBackground(); - - const canvas = &file.editor.canvas; - const layer_rect = canvas.dataFromScreenRect(canvas.rect); - const grid_y0 = layer_rect.y; - const grid_y1 = layer_rect.y + layer_rect.h; - const grid_x0 = layer_rect.x; - const grid_x1 = layer_rect.x + layer_rect.w; - const grid_thickness = std.math.clamp(dvui.currentWindow().natural_scale * canvas.scale, 0, dvui.currentWindow().natural_scale); - const grid_color = dvui.themeGet().color(.control, .fill); - - if (self.removed_sprite_indices) |removed_sprite_indices| { - const insert_before_sprite_indices = dvui.currentWindow().arena().alloc(usize, removed_sprite_indices.len) catch { - dvui.log.err("Failed to allocate insert before sprite indices", .{}); - return; - }; - - for (removed_sprite_indices, 0..) |removed_sprite_index, i| { - if (self.cell_reorder_point) |cell_reorder_point| { - const removed_sprite_rect = file.spriteRect(removed_sprite_index); - const current_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); - const difference = current_point.diff(cell_reorder_point); - - if (file.spriteIndex(removed_sprite_rect.center().plus(difference))) |index| { - insert_before_sprite_indices[i] = index; - } else { - insert_before_sprite_indices[i] = file.wrappedSpriteIndex(removed_sprite_rect.center().plus(difference)); - } - } - } - - const new_sprite_indices = file.getReorderIndices( - dvui.currentWindow().arena(), - removed_sprite_indices, - insert_before_sprite_indices, - .replace, - false, - ) catch |err| { - dvui.log.err("Failed to get reorder indices {any}", .{err}); - return; - }; - - const file_width = @as(f32, @floatFromInt(file.width())); - const file_height = @as(f32, @floatFromInt(file.height())); - - { // Draw all sprites except the ones that are being dragged - var builder = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), file.spriteCount() * 4, file.spriteCount() * 6) catch |err| { - dvui.log.err("Failed to initialize triangles builder: {any}", .{err}); - return; - }; - defer builder.deinit(dvui.currentWindow().arena()); - - for (0..file.spriteCount()) |i| { - const new_index = new_sprite_indices[i]; - const new_rect = file.spriteRect(new_index); - const new_rect_physical = file.editor.canvas.screenFromDataRect(new_rect); - const current_rect = file.spriteRect(i); - - const dragging: bool = file.editor.selected_sprites.isSet(i); - - // UVs: normalize sprite rect in data space to 0-1 over the layer texture (same size as file). - // 0: TopLeft → uv (umin, vmin) - // 1: TopRight → uv (umax, vmin) - // 2: BottomRight → uv (umax, vmax) - // 3: BottomLeft → uv (umin, vmax) - const umin = current_rect.x / file_width; - const vmin = current_rect.y / file_height; - const umax = (current_rect.x + current_rect.w) / file_width; - const vmax = (current_rect.y + current_rect.h) / file_height; - - const col = if (!dragging) dvui.Color.PMA.fromColor(dvui.Color.white) else dvui.Color.PMA.fromColor(dvui.Color.transparent); - - builder.appendVertex(.{ .pos = new_rect_physical.topLeft(), .col = col, .uv = .{ umin, vmin } }); - builder.appendVertex(.{ .pos = new_rect_physical.topRight(), .col = col, .uv = .{ umax, vmin } }); - builder.appendVertex(.{ .pos = new_rect_physical.bottomRight(), .col = col, .uv = .{ umax, vmax } }); - builder.appendVertex(.{ .pos = new_rect_physical.bottomLeft(), .col = col, .uv = .{ umin, vmax } }); - - const base: dvui.Vertex.Index = @intCast(i * 4); - builder.appendTriangles(&.{ base + 1, base + 0, base + 3, base + 1, base + 3, base + 2 }); - } - - { - var temp_selected_sprite = file.editor.selected_sprites.clone(dvui.currentWindow().arena()) catch { - dvui.log.err("Failed to clone selected sprites", .{}); - return; - }; - - var temp_insert_before_sprite = file.editor.selected_sprites.clone(dvui.currentWindow().arena()) catch { - dvui.log.err("Failed to clone selected sprites", .{}); - return; - }; - - temp_insert_before_sprite.setRangeValue(.{ .start = 0, .end = file.spriteCount() }, false); - - for (insert_before_sprite_indices) |insert_before_sprite_index| { - temp_selected_sprite.set(insert_before_sprite_index); - temp_insert_before_sprite.set(insert_before_sprite_index); - } - - var iter = temp_selected_sprite.iterator(.{ .kind = .set, .direction = .forward }); - while (iter.next()) |sprite_index| { - const image_rect = file.spriteRect(sprite_index); - - const image_rect_scale: dvui.RectScale = .{ - .r = self.init_options.file.editor.canvas.screenFromDataRect(image_rect), - .s = self.init_options.file.editor.canvas.scale, - }; - - const highlight = dvui.themeGet().color(.highlight, .fill).opacity(0.5); - const err = dvui.themeGet().color(.err, .fill).opacity(0.5); - - const color = if (temp_insert_before_sprite.isSet(sprite_index) and file.editor.selected_sprites.isSet(sprite_index)) highlight.average(err) else if (temp_insert_before_sprite.isSet(sprite_index)) highlight else if (file.editor.selected_sprites.isSet(sprite_index)) err else highlight; - - image_rect_scale.r.fill(.all(0), .{ .color = color, .fade = 1.5 }); - - const left_index = file.spriteIndex(image_rect.center().diff(.{ .x = @as(f32, @floatFromInt(file.column_width)) })); - const right_index = file.spriteIndex(image_rect.center().plus(.{ .x = @as(f32, @floatFromInt(file.column_width)) })); - const top_index = file.spriteIndex(image_rect.center().diff(.{ .y = @as(f32, @floatFromInt(file.row_height)) })); - const bottom_index = file.spriteIndex(image_rect.center().plus(.{ .y = @as(f32, @floatFromInt(file.row_height)) })); - - 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 }); - } - } - 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 }); - } - } - 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 }); - } - } - 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 }); - } - } - } - } - - { // Render once for each layer - const grid_triangles = builder.build(); - - var i: usize = file.layers.len; - - while (i > 0) { - i -= 1; - const source = file.layers.items(.source)[i]; - dvui.renderTriangles(grid_triangles, source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - return; - }; - } - } - - { - for (1..file.columns) |i| { - const gx = @as(f32, @floatFromInt(i * file.column_width)); - dvui.Path.stroke(.{ .points = &.{ - canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y0 }), - canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y1 }), - } }, .{ .thickness = grid_thickness, .color = grid_color }); - } - - for (1..file.rows) |i| { - const gy = @as(f32, @floatFromInt(i * file.row_height)); - dvui.Path.stroke(.{ .points = &.{ - canvas.screenFromDataPoint(.{ .x = grid_x0, .y = gy }), - canvas.screenFromDataPoint(.{ .x = grid_x1, .y = gy }), - } }, .{ .thickness = grid_thickness, .color = grid_color }); - } - } - } - - { // Render the sprites that are being dragged - var builder = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), file.spriteCount() * 4, file.spriteCount() * 6) catch |err| { - dvui.log.err("Failed to initialize triangles builder: {any}", .{err}); - return; - }; - defer builder.deinit(dvui.currentWindow().arena()); - - for (removed_sprite_indices, 0..) |removed_sprite_index, i| { - const base_quad: dvui.Vertex.Index = @intCast(i * 4); - - var shadow_path = dvui.Path.Builder.init(dvui.currentWindow().lifo()); - defer shadow_path.deinit(); - - const new_rect = file.spriteRect(removed_sprite_index); - var new_rect_physical = file.editor.canvas.screenFromDataRect(new_rect); - - if (self.cell_reorder_point) |cell_reorder_point| { - new_rect_physical = new_rect_physical.offsetPoint(dvui.currentWindow().mouse_pt.diff(file.editor.canvas.screenFromDataPoint(cell_reorder_point))); - } - - // UVs: normalize sprite rect in data space to 0-1 over the layer texture (same size as file). - // 0: TopLeft → uv (umin, vmin) - // 1: TopRight → uv (umax, vmin) - // 2: BottomRight → uv (umax, vmax) - // 3: BottomLeft → uv (umin, vmax) - const umin = new_rect.x / file_width; - const vmin = new_rect.y / file_height; - const umax = (new_rect.x + new_rect.w) / file_width; - const vmax = (new_rect.y + new_rect.h) / file_height; - - builder.appendVertex(.{ - .pos = new_rect_physical.topLeft(), - .col = .white, - .uv = .{ umin, vmin }, - }); - builder.appendVertex(.{ - .pos = new_rect_physical.topRight(), - .col = .white, - .uv = .{ umax, vmin }, - }); - builder.appendVertex(.{ - .pos = new_rect_physical.bottomRight(), - .col = .white, - .uv = .{ umax, vmax }, - }); - builder.appendVertex(.{ - .pos = new_rect_physical.bottomLeft(), - .col = .white, - .uv = .{ umin, vmax }, - }); - - builder.appendTriangles(&.{ base_quad + 1, base_quad + 0, base_quad + 3, base_quad + 1, base_quad + 3, base_quad + 2 }); - } - - const triangles = builder.build(); - - var i: usize = file.layers.len; - while (i > 0) { - i -= 1; - const source = file.layers.items(.source)[i]; - dvui.renderTriangles(triangles, source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - return; - }; - } - } - } -} - -/// Edge-auto-pan for the grid resize drag. Pushes the canvas viewport when the cursor -/// reaches/exceeds the scroll container edges; velocity ramps up as the cursor moves -/// further past the edge, so the user can grow the grid well beyond the current view. -fn autoPanForResize(self: *FileWidget, mouse_pt: dvui.Point.Physical) void { - const canvas = &self.init_options.file.editor.canvas; - const rs = canvas.scroll_container.data().contentRectScale(); - const r = rs.r; - const win = dvui.currentWindow(); - const win_r = win.rect_pixels; - - // Distance past the edge (in screen px) drives velocity. Once `over >= ramp`, we're at - // max speed; this prevents jitter from a cursor sitting exactly on the boundary. - const ramp: f32 = 80.0 * win.natural_scale; - const max_speed_px_per_sec: f32 = 2500.0 * win.natural_scale; - - // OS clamps the cursor at the window edge — if the canvas sits flush against that edge, - // the user can only push a couple of pixels past `r`, leaving the ramp linger near zero. - // Treat "cursor pinned to the window edge AND past the canvas edge" as full velocity. - const edge_eps: f32 = 2.0 * win.natural_scale; - - var vx: f32 = 0; - var vy: f32 = 0; - - if (mouse_pt.x < r.x) { - const pinned = mouse_pt.x <= win_r.x + edge_eps; - const t: f32 = if (pinned) 1.0 else @min((r.x - mouse_pt.x) / ramp, 1.0); - vx = -t * max_speed_px_per_sec; - } else if (mouse_pt.x > r.x + r.w) { - const pinned = mouse_pt.x >= win_r.x + win_r.w - edge_eps; - const t: f32 = if (pinned) 1.0 else @min((mouse_pt.x - (r.x + r.w)) / ramp, 1.0); - vx = t * max_speed_px_per_sec; - } - - if (mouse_pt.y < r.y) { - const pinned = mouse_pt.y <= win_r.y + edge_eps; - const t: f32 = if (pinned) 1.0 else @min((r.y - mouse_pt.y) / ramp, 1.0); - vy = -t * max_speed_px_per_sec; - } else if (mouse_pt.y > r.y + r.h) { - const pinned = mouse_pt.y >= win_r.y + win_r.h - edge_eps; - const t: f32 = if (pinned) 1.0 else @min((mouse_pt.y - (r.y + r.h)) / ramp, 1.0); - vy = t * max_speed_px_per_sec; - } - - if (vx == 0 and vy == 0) return; - if (rs.s <= 0) return; - - const dt = dvui.secondsSinceLastFrame(); - const si = &canvas.scroll_info; - si.viewport.x += vx * dt / rs.s; - si.viewport.y += vy * dt / rs.s; - - // Grow virtual_size eagerly when panning down/right so we stay inside `scrollMax` and - // the scroll container's bounce-back doesn't claw us back ~4 screen-px per frame. - // (The up/left side is implicitly handled by CanvasWidget.deinit's bbox normalization, - // which is why those directions already feel fast.) - if (vx > 0) { - const need_w = si.viewport.x + si.viewport.w; - if (si.virtual_size.w < need_w) si.virtual_size.w = need_w; - } - if (vy > 0) { - const need_h = si.viewport.y + si.viewport.h; - if (si.virtual_size.h < need_h) si.virtual_size.h = need_h; - } - - dvui.refresh(null, @src(), canvas.scroll_container.data().id); - // Force a motion event next frame so the resize handle keeps tracking against the - // newly-scrolled viewport even if the user holds the cursor stationary past the edge. - dvui.currentWindow().inject_motion_event = true; -} - -pub fn processResize(self: *FileWidget) void { - if (Globals.state.tools.current != .pointer) return; - if (self.init_options.file.editor.transform != null) return; - if (self.sample_data_point != null) return; - - const file = self.init_options.file; - const file_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) }); - - for (dvui.events()) |*e| { - if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) { - continue; - } - - switch (e.evt) { - .mouse => |me| { - if (me.action == .release and me.button.pointer()) { - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.id); - } - - // Auto-pan the canvas while dragging the resize handle so the user can - // grow the grid past the viewport edge without zooming out first. - // `dvui.scrollDrag` caps at ~5 screen px/frame which feels glacial on a - // wide canvas; we drive the canvas viewport directly with a velocity that - // ramps up as the cursor pushes past the edge. - if (me.action == .motion and dvui.dragName("resize_drag")) { - self.autoPanForResize(me.p); - } - }, - else => {}, - } - } - - { - const min_size: f32 = @as(f32, @floatFromInt(@min(file.column_width, file.row_height))); - const baseline_size: f32 = 64.0; - const baseline_scale: f32 = baseline_size / min_size; - const target_button_height: f32 = min_size / 3.0; - const button_size: f32 = std.math.clamp((target_button_height * 1.0 / self.init_options.file.editor.canvas.scale) * baseline_scale, 0.0, min_size); - var resize_button_rect = dvui.Rect{ - .x = file_rect.x + file_rect.w - button_size / 2.0, - .y = file_rect.y + file_rect.h - button_size / 2.0, - .w = button_size, - .h = button_size, - }; - - const offset_data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt).plus(.{ - .x = @as(f32, @floatFromInt(file.column_width)) / 2.0, - .y = @as(f32, @floatFromInt(file.row_height)) / 2.0, - }); - - const dragging = dvui.dragging(dvui.currentWindow().mouse_pt, "resize_drag") != null and self.active(); - - if (self.resize_data_point != null or dragging) { - const current_point: dvui.Point = self.resize_data_point orelse .{ .x = 0.0, .y = 0.0 }; - var new_point = self.init_options.file.spritePoint(offset_data_point); - - if (current_point.x != new_point.x or current_point.y != new_point.y) { - new_point.x = std.math.clamp(new_point.x, @as(f32, @floatFromInt(file.column_width)), std.math.floatMax(f32)); - new_point.y = std.math.clamp(new_point.y, @as(f32, @floatFromInt(file.row_height)), std.math.floatMax(f32)); - - if (self.resize_data_point != null) { - if (dvui.animationGet(self.init_options.file.editor.canvas.id, "resize_button_rect_x")) |anim| { - _ = dvui.currentWindow().animations.remove(self.init_options.file.editor.canvas.id.update("resize_button_rect_x")); - dvui.animation(self.init_options.file.editor.canvas.id, "resize_button_rect_x", .{ - .start_val = anim.value(), - .end_val = new_point.x, - .end_time = 250_000, - .easing = dvui.easing.outBack, - }); - } else { - - // If we are here, we need to trigger a new animation to move the resize button rect to the new point - dvui.animation(self.init_options.file.editor.canvas.id, "resize_button_rect_x", .{ - .start_val = current_point.x, - .end_val = new_point.x, - .end_time = 250_000, - .easing = dvui.easing.outBack, - }); - } - - if (dvui.animationGet(self.init_options.file.editor.canvas.id, "resize_button_rect_y")) |anim| { - _ = dvui.currentWindow().animations.remove(self.init_options.file.editor.canvas.id.update("resize_button_rect_y")); - dvui.animation(self.init_options.file.editor.canvas.id, "resize_button_rect_y", .{ - .start_val = anim.value(), - .end_val = new_point.y, - .end_time = 250_000, - .easing = dvui.easing.outBack, - }); - } else { - - // If we are here, we need to trigger a new animation to move the resize button rect to the new point - dvui.animation(self.init_options.file.editor.canvas.id, "resize_button_rect_y", .{ - .start_val = current_point.y, - .end_val = new_point.y, - .end_time = 250_000, - .easing = dvui.easing.outBack, - }); - } - } - - self.resize_data_point = new_point; - } - - if (dvui.animationGet(self.init_options.file.editor.canvas.id, "resize_button_rect_x")) |anim| { - resize_button_rect.x = anim.value(); - } else { - resize_button_rect.x = new_point.x; - } - - if (dvui.animationGet(self.init_options.file.editor.canvas.id, "resize_button_rect_y")) |anim| { - resize_button_rect.y = anim.value(); - } else { - resize_button_rect.y = new_point.y; - } - } - - var icon_button: dvui.ButtonWidget = undefined; - icon_button.init(@src(), .{ .draw_focus = false }, .{ - .rect = resize_button_rect, - .border = dvui.Rect.all(0), - .margin = .all(0), - .padding = .all(0), - .background = false, - }); - defer icon_button.deinit(); - icon_button.processEvents(); - - if (dragging) { - var bounds_rect = dvui.Rect.Physical.fromSize(.{ .w = @as(f32, @floatFromInt(file.column_width)), .h = @as(f32, @floatFromInt(file.row_height)) }); - bounds_rect = bounds_rect.scale(self.init_options.file.editor.canvas.scale * dvui.currentWindow().natural_scale, dvui.Rect.Physical); - bounds_rect.x = icon_button.data().contentRectScale().r.topLeft().x - bounds_rect.w / 2.0; - bounds_rect.y = icon_button.data().contentRectScale().r.topLeft().y - bounds_rect.h / 2.0; - - var path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - path.addRect(bounds_rect, .{ .x = bounds_rect.w / 2.0, .y = bounds_rect.h / 2.0, .w = bounds_rect.w / 2.0, .h = bounds_rect.h / 2.0 }); - const built = path.build(); - built.fillConvex(.{ .color = dvui.themeGet().color(.window, .fill).opacity(0.5), .fade = 1.5 }); - built.stroke(.{ .color = dvui.themeGet().color(.control, .text).opacity(0.5), .thickness = 1.0, .closed = true }); - - path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - path.addPoint(icon_button.data().contentRectScale().r.topLeft()); - path.addRect(.{ - .x = dvui.currentWindow().mouse_pt.x - icon_button.data().contentRectScale().r.w / 8.0, - .y = dvui.currentWindow().mouse_pt.y - icon_button.data().contentRectScale().r.h / 8.0, - .w = icon_button.data().contentRectScale().r.w / 4.0, - .h = icon_button.data().contentRectScale().r.h / 4.0, - }, .all(icon_button.data().contentRectScale().r.w / 8.0)); - path.build().fillConvex(.{ .color = dvui.themeGet().color(.control, .text).opacity(0.5), .fade = 1.5 }); - - path = dvui.Path.Builder.init(dvui.currentWindow().arena()); - path.addRect(.{ - .x = dvui.currentWindow().mouse_pt.x - icon_button.data().contentRectScale().r.w / 8.0, - .y = dvui.currentWindow().mouse_pt.y - icon_button.data().contentRectScale().r.h / 8.0, - .w = icon_button.data().contentRectScale().r.w / 4.0, - .h = icon_button.data().contentRectScale().r.h / 4.0, - }, .all(icon_button.data().contentRectScale().r.w / 8.0)); - path.build().fillConvex(.{ .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), .fade = 1.5 }); - } else { - dvui.icon(@src(), "resize", if (dragging) icons.tvg.lucide.move else icons.tvg.lucide.@"move-diagonal-2", .{ - .stroke_color = if (icon_button.hover) dvui.themeGet().color(.highlight, .fill) else dvui.themeGet().color(.control, .text), - }, .{ - .expand = .ratio, - .min_size_content = .{ .w = 1.0, .h = 1.0 }, - .gravity_x = 0.5, - .gravity_y = 0.5, - .border = dvui.Rect.all(0), - .margin = .all(0), - .padding = .all(0), - .background = false, - .rotation = dvui.math.degreesToRadians(0.0), - }); - } - - if (icon_button.pressed()) { - dvui.dragStart( - dvui.currentWindow().mouse_pt, - .{ .name = "resize_drag", .cursor = .hidden }, - ); - dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), 0); - } - - if (dragging == false) { - if (self.resize_data_point) |resize_data_point| { - self.init_options.file.resize(.{ - .columns = @divTrunc(@as(u32, @intFromFloat(resize_data_point.x)), self.init_options.file.column_width), - .rows = @divTrunc(@as(u32, @intFromFloat(resize_data_point.y)), self.init_options.file.row_height), - .history = true, - }) catch |err| { - dvui.log.err("Failed to resize file: {s}", .{@errorName(err)}); - }; - self.resize_data_point = null; - dvui.dragEnd(); - dvui.captureMouse(null, 0); - dvui.refresh(null, @src(), self.init_options.file.editor.canvas.id); - } - } - } -} - -pub fn processEvents(self: *FileWidget) void { - const transform = self.init_options.file.editor.transform != 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| { - if (self.init_options.file.animations.get(ai).frames.len > 0) { - if (self.init_options.file.selected_animation_frame_index >= self.init_options.file.animations.get(ai).frames.len) { - self.init_options.file.selected_animation_frame_index = self.init_options.file.animations.get(ai).frames.len - 1; - } - } else { - self.init_options.file.selected_animation_frame_index = 0; - } - } - - defer self.previous_mods = dvui.currentWindow().modifiers; - - defer if (self.drag_data_point) |drag_data_point| { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "drag_data_point", drag_data_point); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "drag_data_point"); - }; - - defer if (self.transform_aspect_w) |v| { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "transform_aspect_w", v); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "transform_aspect_w"); - }; - defer if (self.transform_aspect_h) |v| { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "transform_aspect_h", v); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "transform_aspect_h"); - }; - - defer if (self.sample_data_point) |sample_data_point| { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "sample_data_point", sample_data_point); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "sample_data_point"); - }; - - defer if (self.resize_data_point) |resize_data_point| { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "resize_data_point", resize_data_point); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "resize_data_point"); - }; - - defer if (self.grid_reorder_point) |grid_reorder_point| { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "grid_reorder_point", grid_reorder_point); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "grid_reorder_point"); - }; - - defer if (self.cell_reorder_point) |cell_reorder_point| { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "cell_reorder_point", cell_reorder_point); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "cell_reorder_point"); - }; - - defer if (self.sample_key_down) { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "sample_key_down", self.sample_key_down); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "sample_key_down"); - }; - - defer if (self.right_mouse_down) { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "right_mouse_down", self.right_mouse_down); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "right_mouse_down"); - }; - - defer if (self.left_mouse_down) { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "left_mouse_down", self.left_mouse_down); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "left_mouse_down"); - }; - - defer if (self.hide_distance_bubble) { - dvui.dataSet(null, self.init_options.file.editor.canvas.id, "hide_distance_bubble", self.hide_distance_bubble); - } else { - dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "hide_distance_bubble"); - }; - - 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.pointerOverDrawable(mouse_pt); - - // Cursor-leave: when hover transitions true → false, the last brush/fill preview - // pixels are still painted on the temp layer. Clear them exactly once on the way out - // (we deliberately do NOT clear every frame — the temp layer can be 64 MB on large - // files and clearing it each frame murders performance for nothing). Also restore the - // OS cursor — drawCursor hides it while over the image, and explorer empty areas do not - // call cursorSet, so it would otherwise stay hidden after crossing into the explorer. - if (canvas_ptr.prev_hovered and !canvas_ptr.hovered) { - _ = dvui.cursorSet(.arrow); - if (self.init_options.file.editor.temp_layer_has_content) { - resetTempLayerPreview(&self.init_options.file.editor); - dvui.refresh(null, @src(), canvas_ptr.scroll_container.data().id); - } - } - - // Input-mode flip (mouse ↔ touch): clear the temp preview exactly once. On touch - // there's no cursor to chase and the finger occludes the preview anyway — tools below - // skip drawing it altogether — but any pixels left over from the prior mode would - // otherwise sit there until the user moves a mouse / lifts a finger. - if (canvas_ptr.prev_last_input_was_touch != canvas_ptr.last_input_was_touch and - self.init_options.file.editor.temp_layer_has_content) - { - resetTempLayerPreview(&self.init_options.file.editor); - dvui.refresh(null, @src(), canvas_ptr.scroll_container.data().id); - } - - // Gesture takeover: if a 2-finger pan just activated while a stroke / fill drag was - // in progress, the release event for that touch was swallowed by the canvas — the - // pixels were drawn but the history entry never got appended. Finalize the stroke - // here so undo still works. - if (!canvas_ptr.prev_gesture_active and canvas_ptr.gesture_active and self.init_options.file.editor.active_drawing) { - self.cancelActiveDrawing(); - } - - // Hover alone is enough for brush/bucket/selection previews (e.g. sampling a color on one - // document while hovering another). Pixel edits are still gated inside each tool via `active()`. - // Skip everything when a 2-finger pan is active or while we're still deciding whether the - // 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); - - resetTempLayerPreview(&self.init_options.file.editor); - - { - const mask_t0 = pixelart.perf.updateMaskBegin(); - defer pixelart.perf.updateMaskEnd(mask_t0); - self.updateActiveLayerMask(); - } - - 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(); - - dvui.timer(self.init_options.file.editor.canvas.scroll_container.data().id, 500_000); - } - } - - if (self.init_options.file.editor.transform == null) { - const tool_t0 = pixelart.perf.toolProcessBegin(); - switch (Globals.state.tools.current) { - .bucket => self.processFill(), - .pencil, .eraser => self.processStroke(), - .selection => self.processSelection(), - else => {}, - } - 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 - // hover brush / fill preview from a prior frame is cleared so it doesn't linger - // under the panning fingers. - resetTempLayerPreview(&self.init_options.file.editor); - } - - // Use `active()`, not `hovered()`: `hovered` is the drawable artboard (`pointerOverDrawable`), - // not the full scroll viewport or unclipped `canvas.rect`. The transform quad can extend outside - // that rect; we still need presses/drags there and continued drags after the cursor leaves the - // image (capture + motion). - const suppress = self.init_options.file.editor.canvas.gestureActive(); - - if (self.active() and self.init_options.file.editor.transform != null and !suppress) { - self.processTransform(); - } - - // While the sample key is held, dim non-target layers like layer-list hover does. This - // must run before `drawLayers` because `renderLayers` picks dimmed vs normal triangles at - // call time based on `peek_layer_index`; `Editor.draw` resets peek at end of frame, so - // setting it inside `processSample` (after `drawLayers`) wouldn't take effect. - if (self.sample_key_down) { - const sample_mouse = dvui.currentWindow().mouse_pt; - if (self.init_options.file.editor.canvas.samplePointerInViewport(sample_mouse)) { - const sample_point = self.init_options.file.editor.canvas.dataFromScreenPoint(sample_mouse); - peekLayerAtPoint(self.init_options.file, sample_point); - } - } - - self.drawLayers(); - - if (self.hovered() or dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) { - self.drawBoxSelectionMarqueeOutline(); - } - - if ((self.active() or self.hovered()) and !transform and !reorder) { - self.drawSpriteBubbles(); - } - - if (self.active() and !suppress) { - self.processCellReorder(); - } - - if ((self.active() or self.hovered()) and !transform and !reorder and !suppress) { - self.processResize(); - - self.processAnimationSelection(); - - self.processSpriteSelection(); - self.drawSpriteSelection(); - } - - // 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, .{}); - - self.drawTransform(); - self.processSample(); - self.drawSample(); - if (self.hovered()) - self.drawCursor(); - - // Then process the scroll and zoom events last - self.init_options.file.editor.canvas.processEvents(); -} - -pub fn deinit(self: *FileWidget) void { - self.init_options.file.editor.canvas.deinit(); - - self.* = undefined; -} - -pub fn hovered(self: *FileWidget) bool { - if (pixelart.core.dvui.canvasPointerInputSuppressed()) return false; - return self.init_options.file.editor.canvas.hovered; -} - -/// Tear down an in-progress brush/eraser stroke when a 2-finger pan steals the touch. -/// The release event never reaches `processStroke`, so its history-commit code doesn't -/// run. We finalize the deferred undo snapshot here so the pixels already drawn are -/// preserved in the undo stack, and drop the related capture/drag state. -fn cancelActiveDrawing(self: *FileWidget) void { - const file = self.init_options.file; - if (!file.editor.active_drawing) return; - - // Commit whatever pixels were drawn so far — `strokeUndoCommit` is safe even if the - // stroke buffer ended up empty (it appends nothing in that case). - file.strokeUndoCommit(); - - if (dvui.captured(file.editor.canvas.scroll_container.data().id)) { - dvui.captureMouse(null, 0); - } - if (dvui.dragName("stroke_drag")) { - dvui.dragEnd(); - } - - resetTempLayerPreview(&file.editor); - - file.editor.active_drawing = false; - file.editor.layer_composite_dirty = true; - file.editor.layer_composite_frame_built = 0; - self.drag_data_point = null; - - dvui.refresh(null, @src(), file.editor.canvas.scroll_container.data().id); -} - -/// Computes the pixel bounding rect of a brush draw, clamped to image bounds. -fn tempBrushRect(point: dvui.Point, stroke_size: usize, img_w: u32, img_h: u32) dvui.Rect { - const s: i32 = @intCast(stroke_size); - const half: i32 = @divFloor(s, 2); - const px: i32 = @intFromFloat(@floor(point.x)); - const py: i32 = @intFromFloat(@floor(point.y)); - const w: i32 = @intCast(img_w); - const h: i32 = @intCast(img_h); - const x0 = @max(px - half, 0); - const y0 = @max(py - half, 0); - const x1 = @min(px - half + s, w); - const y1 = @min(py - half + s, h); - return .{ - .x = @floatFromInt(x0), - .y = @floatFromInt(y0), - .w = @floatFromInt(@max(x1 - x0, 0)), - .h = @floatFromInt(@max(y1 - y0, 0)), - }; -} - -/// 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 { - const vis = canvas.dataFromScreenRect(canvas.rect); - const m: f32 = @floatFromInt(stroke_size); - const inflated = vis.outsetAll(m); - const iw = @as(f32, @floatFromInt(file.width())); - const ih = @as(f32, @floatFromInt(file.height())); - return dvui.Rect.intersect(inflated, .{ .x = 0, .y = 0, .w = iw, .h = ih }); -} - -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 { - editor.temp_gpu_dirty_rect = rect; - } - // The temp layer's pixels just changed; let content-signature consumers - // (the sprite preview composite) see it even though the buffer ptr is stable. - editor.temp_layer_generation +%= 1; -} - -/// 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 { - if (editor.temp_preview_dirty_rect) |dirty| { - if (dirty.w > 0 and dirty.h > 0) { - pixelart.image.clearRect(editor.temporary_layer.source, dirty); - expandTempGpuDirtyRect(editor, dirty); - } - } - editor.temp_preview_dirty_rect = null; -} - -/// Clears the temporary brush preview layer and marks GPU/composite dirty. -fn resetTempLayerPreview(editor: *pixelart.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); - expandTempGpuDirtyRect(editor, dirty); - } - editor.temp_preview_dirty_rect = null; - } else if (editor.temp_layer_has_content) { - @memset(editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); - editor.temporary_layer.invalidate(); - editor.temp_gpu_dirty_rect = null; - editor.temp_layer_generation +%= 1; - } - editor.temp_layer_has_content = false; - editor.temporary_layer.clearMask(); -} - -test { - @import("std").testing.refAllDecls(@This()); -} diff --git a/src/plugins/pixelart/src/widgets/ImageWidget.zig b/src/plugins/pixelart/src/widgets/ImageWidget.zig deleted file mode 100644 index e314d129..00000000 --- a/src/plugins/pixelart/src/widgets/ImageWidget.zig +++ /dev/null @@ -1,478 +0,0 @@ -pub const ImageWidget = @This(); -const CanvasWidget = pixelart.core.dvui.CanvasWidget; -const CanvasBridge = @import("CanvasBridge.zig"); - -init_options: InitOptions, -options: Options, -last_mouse_event: ?dvui.Event = null, -drag_data_point: ?dvui.Point = null, -sample_data_point: ?dvui.Point = null, -previous_mods: dvui.enums.Mod = .none, -right_mouse_down: bool = false, -sample_key_down: bool = false, - -pub const InitOptions = struct { - canvas: *CanvasWidget, - source: dvui.ImageSource, - grouping: u64, -}; - -pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) ImageWidget { - const iw: ImageWidget = .{ - .init_options = init_opts, - .options = opts, - .last_mouse_event = if (dvui.dataGet(null, init_opts.canvas.id, "mouse_point", dvui.Event)) |event| event else null, - .drag_data_point = if (dvui.dataGet(null, init_opts.canvas.id, "drag_data_point", dvui.Point)) |point| point else null, - .sample_data_point = if (dvui.dataGet(null, init_opts.canvas.id, "sample_data_point", dvui.Point)) |point| point else null, - .sample_key_down = if (dvui.dataGet(null, init_opts.canvas.id, "sample_key_down", bool)) |key| key else false, - .right_mouse_down = if (dvui.dataGet(null, init_opts.canvas.id, "right_mouse_down", bool)) |key| key else false, - }; - - const size: dvui.Size = dvui.imageSize(init_opts.source) catch .{ .w = 0, .h = 0 }; - - init_opts.canvas.install(src, .{ - .id = init_opts.canvas.id, - .data_size = .{ - .w = size.w, - .h = size.h, - }, - .pan_zoom_scheme = CanvasBridge.scheme(), - .hooks = .{ .pointerInputSuppressed = CanvasBridge.mainSuppressed }, - }, opts); - - return iw; -} - -pub fn processSample(self: *ImageWidget) void { - const current_mods = dvui.currentWindow().modifiers; - defer self.previous_mods = current_mods; - - if (!current_mods.matchBind("sample")) { - self.sample_key_down = false; - if (!self.right_mouse_down) { - self.sample_data_point = null; - } - } else if (current_mods.matchBind("sample") and !self.previous_mods.matchBind("sample")) { - self.sample_key_down = true; - if (self.last_mouse_event) |event| { - const me = event.evt.mouse; - const current_point = self.init_options.canvas.dataFromScreenPoint(me.p); - self.sample(current_point, me.p); - } - } - - const canvas = self.init_options.canvas; - const scroll_container = canvas.scroll_container; - if (!canvas.installed) return; - - const scroll_id = scroll_container.data().id; - - for (dvui.events()) |*e| { - switch (e.evt) { - .mouse => |me| { - const sample_captured = dvui.captured(scroll_id); - if (!scroll_container.matchEvent(e) and !sample_captured) - continue; - - self.last_mouse_event = e.*; - const current_point = canvas.dataFromScreenPoint(me.p); - - if (me.action == .press and me.button == .right) { - self.right_mouse_down = true; - e.handle(@src(), self.init_options.canvas.scroll_container.data()); - dvui.captureMouse(self.init_options.canvas.scroll_container.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "sample_drag" }); - self.drag_data_point = current_point; - - self.sample(current_point, me.p); - } else if (me.action == .release and me.button == .right) { - self.right_mouse_down = false; - if (sample_captured) { - e.handle(@src(), scroll_container.data()); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - - if (!self.sample_key_down) { - self.drag_data_point = null; - self.sample_data_point = null; - } - } - } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) { - if (sample_captured and !canvas.samplePointerInViewport(me.p)) { - self.sample_data_point = null; - } - if (dvui.captured(scroll_id)) { - if (dvui.dragging(me.p, "sample_drag")) |diff| { - const previous_point = current_point.plus(self.init_options.canvas.dataFromScreenPoint(diff)); - // Construct a rect spanning between current_point and previous_point - const min_x = @min(previous_point.x, current_point.x); - const min_y = @min(previous_point.y, current_point.y); - const max_x = @max(previous_point.x, current_point.x); - const max_y = @max(previous_point.y, current_point.y); - const span_rect = dvui.Rect{ - .x = min_x, - .y = min_y, - .w = max_x - min_x + 5, - .h = max_y - min_y + 5, - }; - - const screen_rect = self.init_options.canvas.screenFromDataRect(span_rect); - - dvui.scrollDrag(.{ - .mouse_pt = me.p, - .screen_rect = screen_rect, - }); - - self.sample(current_point, me.p); - e.handle(@src(), self.init_options.canvas.scroll_container.data()); - } - } else if (self.right_mouse_down or self.sample_key_down) { - self.sample(current_point, me.p); - } - } - }, - else => {}, - } - } -} - -fn sample(self: *ImageWidget, point: dvui.Point, screen_p: dvui.Point.Physical) void { - if (!self.init_options.canvas.samplePointerInViewport(screen_p)) { - self.sample_data_point = null; - return; - } - - 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 (c[3] > 0) { - color = c; - } - } - - Globals.state.colors.primary = color; - self.sample_data_point = point; - - if (color[3] == 0) { - if (Globals.state.tools.current != .eraser) { - Globals.state.tools.set(.eraser); - } - } else { - Globals.state.tools.set(Globals.state.tools.previous_drawing_tool); - } -} - -pub fn drawCursor(self: *ImageWidget) void { - if (pixelart.core.dvui.canvasPointerInputSuppressed()) return; - for (dvui.events()) |*e| { - if (!self.init_options.canvas.scroll_container.matchEvent(e)) { - continue; - } - - switch (e.evt) { - .mouse => |me| { - if (self.init_options.canvas.rect.contains(me.p) and (self.right_mouse_down or self.sample_key_down)) { - _ = dvui.cursorSet(.hidden); - } - }, - else => {}, - } - } -} - -fn drawSamplePixelOutline(canvas: *CanvasWidget, data_point: dvui.Point) void { - const pixel_box_size = canvas.scale * dvui.currentWindow().rectScale().s; - const pixel_point: dvui.Point = .{ - .x = @round(data_point.x - 0.5), - .y = @round(data_point.y - 0.5), - }; - const pixel_box_point = canvas.screenFromDataPoint(pixel_point); - var pixel_box = dvui.Rect.Physical.fromSize(.{ .w = pixel_box_size, .h = pixel_box_size }); - pixel_box.x = pixel_box_point.x; - pixel_box.y = pixel_box_point.y; - dvui.Path.stroke(.{ .points = &.{ - pixel_box.topLeft(), - pixel_box.topRight(), - pixel_box.bottomRight(), - pixel_box.bottomLeft(), - } }, .{ .thickness = 2, .color = .white, .closed = true }); -} - -pub fn drawSample(self: *ImageWidget) void { - if (self.sample_data_point) |data_point| { - if (!self.init_options.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return; - drawSamplePixelOutline(self.init_options.canvas, data_point); - } -} - -pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data_point: dvui.Point) void { - if (pixelart.core.dvui.canvasPointerInputSuppressed()) return; - if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return; - - _ = dvui.cursorSet(.hidden); - - const enlarged_scale: f32 = canvas.scale * 2.0; - const sample_box_size: f32 = 200.0 * 1 / canvas.scale; - const sample_region_size: f32 = sample_box_size / enlarged_scale; - - // Home placement: bottom-left corner of the magnifier sits exactly at the sample point. - const default_magnifier_phys = canvas.screenFromDataRect(.{ - .x = data_point.x, - .y = data_point.y - sample_box_size, - .w = sample_box_size, - .h = sample_box_size, - }); - - // Slide the magnifier inside the OS window without flipping. Only right and top can clip. - const window_rect = dvui.windowRectPixels(); - const push_x_phys = @max(0, (default_magnifier_phys.x + default_magnifier_phys.w) - (window_rect.x + window_rect.w)); - const push_y_phys = @max(0, window_rect.y - default_magnifier_phys.y); - - const magnifier_phys = dvui.Rect.Physical{ - .x = default_magnifier_phys.x - push_x_phys, - .y = default_magnifier_phys.y + push_y_phys, - .w = default_magnifier_phys.w, - .h = default_magnifier_phys.h, - }; - const magnifier_nat = magnifier_phys.toNatural(); - - // Corner-radius rect maps {x: TL, y: TR, w: BR, h: BL}. BL is sharp (0) at home so it points at - // the sample; as the magnifier is pushed away from home, grow BL so the rectangle's edge slides - // tangent to the sample point — fully circular at `cr_max`. - const cr_max = magnifier_nat.w / 2; - const win_scale = dvui.windowRectScale().s; - const push_dist_phys = @sqrt(push_x_phys * push_x_phys + push_y_phys * push_y_phys); - const push_dist_nat = if (win_scale > 0) push_dist_phys / win_scale else push_dist_phys; - const bl_radius = @min(cr_max, push_dist_nat); - const corner_radius = dvui.Rect{ .x = cr_max, .y = cr_max, .w = cr_max, .h = bl_radius }; - - const ns = dvui.currentWindow().natural_scale; - const border_nat = 2.0 / ns; - - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{ .mouse_events = false }, .{ - .rect = dvui.Rect.cast(magnifier_nat), - .expand = .none, - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - .border = dvui.Rect.all(border_nat), - .color_border = dvui.themeGet().color(.control, .text), - .corner_radius = corner_radius, - .box_shadow = .{ - .fade = 15.0 / ns, - .corner_radius = corner_radius, - .alpha = 0.2, - .offset = .{ .x = 2.0 / ns, .y = 2.0 / ns }, - }, - }); - defer fw.deinit(); - - 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, - .w = sample_region_size / size.w, - .h = sample_region_size / size.h, - }; - - var rs = fw.data().borderRectScale(); - rs.r = rs.r.inset(dvui.Rect.Physical.all(2.0 * rs.s)); - - const corner_scaled = dvui.Rect{ - .x = corner_radius.x * rs.s, - .y = corner_radius.y * rs.s, - .w = corner_radius.w * rs.s, - .h = corner_radius.h * rs.s, - }; - - dvui.renderImage(source, rs, .{ - .uv = uv_rect, - .corner_radius = corner_scaled, - }) catch { - std.log.err("Failed to render image", .{}); - }; - - const center_x = rs.r.x + rs.r.w / 2; - const center_y = rs.r.y + rs.r.h / 2; - const cross_size = @min(rs.r.w, rs.r.h) * 0.2; - - dvui.Path.stroke(.{ .points = &.{ - .{ .x = center_x - cross_size / 2, .y = center_y }, - .{ .x = center_x + cross_size / 2, .y = center_y }, - } }, .{ .thickness = 4, .color = .white }); - - dvui.Path.stroke(.{ .points = &.{ - .{ .x = center_x, .y = center_y - cross_size / 2 }, - .{ .x = center_x, .y = center_y + cross_size / 2 }, - } }, .{ .thickness = 4, .color = .white }); - - dvui.Path.stroke(.{ .points = &.{ - .{ .x = center_x - cross_size / 2 + 4, .y = center_y }, - .{ .x = center_x + cross_size / 2 - 4, .y = center_y }, - } }, .{ .thickness = 2, .color = .black }); - - dvui.Path.stroke(.{ .points = &.{ - .{ .x = center_x, .y = center_y - cross_size / 2 + 4 }, - .{ .x = center_x, .y = center_y + cross_size / 2 - 4 }, - } }, .{ .thickness = 2, .color = .black }); -} - -fn packedAtlasCheckerboardTexture() ?dvui.Texture { - if (Globals.packer.atlas) |atlas| return atlas.checkerboard_tile; - return null; -} - -fn drawPackedAtlasCheckerboardBackground(canvas: *CanvasWidget, data_rect: dvui.Rect) void { - const bg_screen = canvas.screenFromDataRect(data_rect); - bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 }); - if (canvas.scale < 0.1) return; - - const tex = packedAtlasCheckerboardTexture() orelse return; - if (data_rect.w <= 0 or data_rect.h <= 0) return; - - const target_tiles_per_side: f32 = 16.0; - const min_data_tile: f32 = 32.0; - const max_tiles_per_side: f32 = 32.0; - const longest = @max(data_rect.w, data_rect.h); - const data_tile: f32 = @max(min_data_tile, longest / target_tiles_per_side); - if (data_rect.w / data_tile > max_tiles_per_side or data_rect.h / data_tile > max_tiles_per_side) return; - - dvui.renderTexture(tex, .{ .r = bg_screen, .s = canvas.screen_rect_scale.s }, .{ - .colormod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5), - .uv = .{ .w = data_rect.w / data_tile, .h = data_rect.h / data_tile }, - }) catch { - dvui.log.err("Failed to render packed atlas checkerboard", .{}); - }; -} - -pub fn drawImage(self: *ImageWidget) void { - const size: dvui.Size = dvui.imageSize(self.init_options.source) catch .{ .w = 0, .h = 0 }; - const image_rect = dvui.Rect{ .x = 0, .y = 0, .w = size.w, .h = size.h }; - - const shadow_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = image_rect, - .border = dvui.Rect.all(0), - .box_shadow = .{ - .fade = 20 * 1 / self.init_options.canvas.scale, - .corner_radius = dvui.Rect.all(2 * 1 / self.init_options.canvas.scale), - .alpha = if (dvui.themeGet().dark) 0.4 else 0.2, - .offset = .{ - .x = 2 * 1 / self.init_options.canvas.scale, - .y = 2 * 1 / self.init_options.canvas.scale, - }, - }, - }); - shadow_box.deinit(); - - const fill_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = image_rect, - .border = dvui.Rect.all(0), - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - }); - fill_box.deinit(); - - drawPackedAtlasCheckerboardBackground(self.init_options.canvas, image_rect); - - // Render the atlas image into the canvas's cached physical rect (NOT via dvui.image, - // which goes through the ScaleWidget — that widget's `screenRectScale` dereferences - // `&canvas.scale` live, so any scale mutation in `updateTouchGesture` (e.g. trackpad - // pinch) is reflected immediately for the image but NOT for the checkerboard - // background and outline, which use `canvas.screen_rect_scale` / `canvas.rect` cached - // 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` - // for the same reason. - dvui.renderImage(self.init_options.source, .{ - .r = self.init_options.canvas.rect, - .s = self.init_options.canvas.scale, - }, .{}) catch { - std.log.err("Failed to render packed atlas image", .{}); - }; - - // Outline the image with a rectangle - dvui.Path.stroke(.{ .points = &.{ - self.init_options.canvas.rect.topLeft(), - self.init_options.canvas.rect.topRight(), - self.init_options.canvas.rect.bottomRight(), - self.init_options.canvas.rect.bottomLeft(), - } }, .{ .thickness = 1, .color = dvui.themeGet().color(.control, .fill_hover), .closed = true }); -} - -pub fn processEvents(self: *ImageWidget) void { - defer if (self.last_mouse_event) |last_mouse_event| { - dvui.dataSet(null, self.init_options.canvas.id, "mouse_point", last_mouse_event); - } else { - dvui.dataRemove(null, self.init_options.canvas.id, "mouse_point"); - }; - defer if (self.drag_data_point) |drag_data_point| { - dvui.dataSet(null, self.init_options.canvas.id, "drag_data_point", drag_data_point); - } else { - dvui.dataRemove(null, self.init_options.canvas.id, "drag_data_point"); - }; - defer if (self.sample_data_point) |sample_data_point| { - dvui.dataSet(null, self.init_options.canvas.id, "sample_data_point", sample_data_point); - } else { - dvui.dataRemove(null, self.init_options.canvas.id, "sample_data_point"); - }; - defer if (self.sample_key_down) { - dvui.dataSet(null, self.init_options.canvas.id, "sample_key_down", self.sample_key_down); - } else { - dvui.dataRemove(null, self.init_options.canvas.id, "sample_key_down"); - }; - defer if (self.right_mouse_down) { - dvui.dataSet(null, self.init_options.canvas.id, "right_mouse_down", self.right_mouse_down); - } else { - dvui.dataRemove(null, self.init_options.canvas.id, "right_mouse_down"); - }; - - self.processSample(); - - 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 }); - - self.drawCursor(); - self.drawSample(); - - // Then process the scroll and zoom events last - self.init_options.canvas.processEvents(); -} - -pub fn deinit(self: *ImageWidget) void { - self.init_options.canvas.deinit(); - - self.* = undefined; -} - -pub fn hovered(self: *ImageWidget) ?dvui.Point { - return self.init_options.canvas.hovered(); -} - -const Options = dvui.Options; -const Rect = dvui.Rect; -const Point = dvui.Point; - -const BoxWidget = dvui.BoxWidget; -const ButtonWidget = dvui.ButtonWidget; -const ScrollAreaWidget = dvui.ScrollAreaWidget; -const ScrollContainerWidget = dvui.ScrollContainerWidget; -const ScaleWidget = dvui.ScaleWidget; - -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; - -test { - @import("std").testing.refAllDecls(@This()); -} 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/text/build.zig b/src/plugins/text/build.zig new file mode 100644 index 00000000..1f782fc4 --- /dev/null +++ b/src/plugins/text/build.zig @@ -0,0 +1,20 @@ +//! Standalone build for the text plugin — the canonical third-party shape. +//! `cd src/plugins/text && zig build` produces `text.`. 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 = "text", + .target = target, + .optimize = optimize, + .root_source_file = b.path("root.zig"), + }); + fizzy.plugin.install(b, lib, .{}); +} diff --git a/src/plugins/text/build.zig.zon b/src/plugins/text/build.zig.zon new file mode 100644 index 00000000..17c13c6c --- /dev/null +++ b/src/plugins/text/build.zig.zon @@ -0,0 +1,20 @@ +.{ + .name = .text, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "root.zig", + "text.zig", + "src", + "queries", + "static", + }, + .fingerprint = 0x77153098cc8cce17, + .dependencies = .{ + .fizzy = .{ + .path = "../../..", + }, + }, +} diff --git a/src/plugins/code/queries/zig.scm b/src/plugins/text/queries/zig.scm similarity index 100% rename from src/plugins/code/queries/zig.scm rename to src/plugins/text/queries/zig.scm diff --git a/src/plugins/text/root.zig b/src/plugins/text/root.zig new file mode 100644 index 00000000..f3868120 --- /dev/null +++ b/src/plugins/text/root.zig @@ -0,0 +1,7 @@ +//! Dylib entry for the text 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/CodeEditor.zig b/src/plugins/text/src/CodeEditor.zig similarity index 99% rename from src/plugins/code/src/CodeEditor.zig rename to src/plugins/text/src/CodeEditor.zig index 9d6b7610..a1f21448 100644 --- a/src/plugins/code/src/CodeEditor.zig +++ b/src/plugins/text/src/CodeEditor.zig @@ -1,6 +1,6 @@ //! Monospace code editor: line numbers + local `TextEntryWidget` with tree-sitter highlighting. const std = @import("std"); -const code = @import("../code.zig"); +const code = @import("../text.zig"); const dvui = code.dvui; const core = code.core; const Document = code.Document; diff --git a/src/plugins/code/src/Document.zig b/src/plugins/text/src/Document.zig similarity index 91% rename from src/plugins/code/src/Document.zig rename to src/plugins/text/src/Document.zig index 8831b3bc..d6b78421 100644 --- a/src/plugins/code/src/Document.zig +++ b/src/plugins/text/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("../text.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/State.zig b/src/plugins/text/src/State.zig similarity index 71% rename from src/plugins/code/src/State.zig rename to src/plugins/text/src/State.zig index 709cac28..db4bb32e 100644 --- a/src/plugins/code/src/State.zig +++ b/src/plugins/text/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/text/src/SyntaxHighlight.zig similarity index 98% rename from src/plugins/code/src/SyntaxHighlight.zig rename to src/plugins/text/src/SyntaxHighlight.zig index 289ee600..e7d31f15 100644 --- a/src/plugins/code/src/SyntaxHighlight.zig +++ b/src/plugins/text/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("../text.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/text/src/plugin.zig similarity index 72% rename from src/plugins/code/src/plugin.zig rename to src/plugins/text/src/plugin.zig index 8557b028..6e723252 100644 --- a/src/plugins/code/src/plugin.zig +++ b/src/plugins/text/src/plugin.zig @@ -1,21 +1,26 @@ -//! The code editor plugin: fallback owner for plain-text documents and renders them as +//! The text editor plugin: universal fallback owner for plain-text documents, rendered 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 CodeEditor = code.CodeEditor; +const internal = @import("../text.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 = "text", + .name = "Text", + .version = .{ .major = 0, .minor = 1, .patch = 0 }, +}; + var plugin: sdk.Plugin = .{ .state = undefined, .vtable = &vtable, - .id = "code", - .display_name = "Code", + .id = "text", + .display_name = "Text", }; const vtable: sdk.Plugin.VTable = .{ @@ -52,10 +57,20 @@ 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); + try host.registerFileIcon(.{ .owner = &plugin, .draw = drawFileIcon }); } /// Stable `*Plugin` for constructing `DocHandle.owner` fields / lookups. @@ -65,7 +80,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 ----------------------------------------------------- @@ -78,6 +95,30 @@ fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 { return sdk.Plugin.file_type_fallback_priority; } +/// Source/text extensions this editor draws a code glyph for in the file tree. Anything else +/// (archives, unknown binaries, …) returns false so the workbench draws its generic icon. +fn isTextIconExt(ext: []const u8) bool { + const text_exts = [_][]const u8{ + ".zig", ".json", ".txt", ".atlas", ".md", ".markdown", ".c", ".h", ".cpp", + ".hpp", ".cc", ".js", ".ts", ".jsx", ".tsx", ".html", ".htm", ".css", + ".xml", ".yml", ".yaml", ".toml", ".ini", ".sh", ".bash", ".zsh", ".py", + ".rs", ".go", ".lua", ".rb", ".java", ".cs", ".php", ".sql", ".csv", + ".log", ".conf", ".cfg", + }; + for (text_exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) return true; + return false; +} + +fn drawFileIcon(_: ?*anyopaque, ext: []const u8, _: []const u8, color: dvui.Color) bool { + if (!isTextIconExt(ext)) return false; + dvui.icon(@src(), "CodeFileIcon", dvui.entypo.code, .{ .stroke_color = color, .fill_color = color }, .{ + .gravity_y = 0.5, + .padding = dvui.Rect.all(3), + .background = false, + }); + return true; +} + // ---- document staging buffer ------------------------------------------------- fn documentStackSize(_: *anyopaque) usize { @@ -87,10 +128,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 +148,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 +177,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 +196,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 +221,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/text/src/widgets/TextEntryWidget.zig similarity index 99% rename from src/plugins/code/src/widgets/TextEntryWidget.zig rename to src/plugins/text/src/widgets/TextEntryWidget.zig index b3397e68..263c0e6a 100644 --- a/src/plugins/code/src/widgets/TextEntryWidget.zig +++ b/src/plugins/text/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("../../text.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/text/src/widgets/TreeSitterQueryPredicates.zig similarity index 98% rename from src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig rename to src/plugins/text/src/widgets/TreeSitterQueryPredicates.zig index e1ddd0c8..a8718746 100644 --- a/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig +++ b/src/plugins/text/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("../../text.zig"); +const dvui = internal.dvui; const c = dvui.c; diff --git a/src/plugins/text/static/integration.zig b/src/plugins/text/static/integration.zig new file mode 100644 index 00000000..32f188a3 --- /dev/null +++ b/src/plugins/text/static/integration.zig @@ -0,0 +1,59 @@ +//! Text 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 = "text"; +pub const installDylib = helpers.installDylib; + +const module_path = "src/plugins/text/text.zig"; +const dylib_path = "src/plugins/text/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("text")` 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 (`text.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/text/text.zig b/src/plugins/text/text.zig new file mode 100644 index 00000000..1c0228fa --- /dev/null +++ b/src/plugins/text/text.zig @@ -0,0 +1,22 @@ +//! Text plugin root module **and** intra-plugin import hub. +//! +//! - The shell resolves `@import("text")` 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 `../text.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 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"); +pub const SyntaxHighlight = @import("src/SyntaxHighlight.zig"); 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..3a91ff70 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,8 +197,7 @@ fn drawTabs(self: *Workspace) void { } for (0..files_len) |i| { - const doc = Globals.host.docByIndex(i) orelse continue; - const is_fizzy_file = doc.owner.documentHasNativeExtension(doc); + const doc = runtime.host().docByIndex(i) orelse continue; if (doc.owner.documentGrouping(doc) != self.grouping) continue; @@ -200,22 +206,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 +227,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); @@ -266,17 +256,13 @@ fn drawTabs(self: *Workspace) void { self.tabs_insert_before_index = i; } - if (is_fizzy_file) { - const ui_atlas = Globals.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, .{ - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - }); - } else { + // The owning plugin draws the tab icon for its file types (same hook as the file + // tree); the workbench falls back to a generic file glyph. + const tab_doc_path = doc.owner.documentPath(doc); + const tab_icon_color = dvui.themeGet().color(.control, .text); + if (!runtime.host().drawFileIcon(std.fs.path.extension(tab_doc_path), tab_doc_path, tab_icon_color)) { dvui.icon(@src(), "file_icon", icons.tvg.lucide.file, .{ - .stroke_color = if (is_fizzy_file) .transparent else dvui.themeGet().color(.control, .text), + .stroke_color = tab_icon_color, }, .{ .gravity_y = 0.5, .padding = dvui.Rect.all(4), @@ -290,13 +276,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 +321,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 +404,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 +429,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 +439,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 +493,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 +506,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 +528,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 +549,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 +572,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 +584,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 +604,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 +619,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 +630,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 +649,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 +664,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 +675,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 +704,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 +715,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 +728,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 +747,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); @@ -866,33 +840,10 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { .gravity_x = 0.5, }); - { - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{ .draw_focus = true }, .{ - .gravity_x = 0.5, - .expand = .horizontal, - .padding = dvui.Rect.all(2), - .color_fill = .transparent, - .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), - .color_fill_press = dvui.themeGet().color(.window, .fill_press), - }); - defer button.deinit(); - - button.processEvents(); - button.drawBackground(); - - wdvui.labelWithKeybind( - "New File", - dvui.currentWindow().keybinds.get("new_file") orelse .{}, - true, - .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, - .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, - ); - - if (button.clicked()) { - Globals.host.requestNewDocument(null, 0); - } - } + // NOTE: a "New File" button used to live here, but it dispatched to a specific editor + // plugin's new-document dialog (pixel-art), which isn't generic. It's removed so the + // homepage stays plugin-neutral; a future hook will let plugins contribute homepage + // entries / their own homepages. New File is still reachable via the menu + file-tree. { var button: dvui.ButtonWidget = undefined; button.init(@src(), .{ .draw_focus = true }, .{ @@ -917,7 +868,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 +896,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 +921,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 +933,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 +947,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 +1009,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 +1018,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..1484f9ad 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) { @@ -723,36 +724,22 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u .file => { const ext = extension(entry.name); //if (ext == .hidden) continue; - const icon = switch (ext) { - .fizzy, .psd => icons.tvg.lucide.@"file-pen-line", - .jpg, .png, .aseprite, .pyxel, .gif => icons.tvg.entypo.picture, - .pdf => icons.tvg.entypo.@"doc-text", - .json, .zig, .txt, .atlas => icons.tvg.entypo.code, - .tar, ._7z, .zip => icons.tvg.entypo.archive, - else => icons.tvg.entypo.archive, - }; - const icon_color = color; - const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color; - - if (ext == .fizzy) { - const ui_atlas = Globals.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, - .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false }, - ); - } else { + // The plugin that owns this file type draws its own icon (see + // `Host.registerFileIcon`); the workbench only falls back to generic + // filesystem icons when no plugin claims it. + if (!runtime.host().drawFileIcon(std.fs.path.extension(entry.name), abs_path, icon_color)) { + const icon = switch (ext) { + .pdf => icons.tvg.entypo.@"doc-text", + .tar, ._7z, .zip => icons.tvg.entypo.archive, + else => icons.tvg.entypo.archive, + }; dvui.icon( @src(), "FileIcon", icon, - .{ .stroke_color = file_icon_color, .fill_color = file_icon_color }, + .{ .stroke_color = icon_color, .fill_color = icon_color }, .{ .gravity_y = 0.5, .padding = padding, @@ -763,15 +750,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 +777,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 +844,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 +855,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 +884,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 +1032,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 +1162,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 +1185,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 +1200,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 +1243,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..232cc4d4 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"); @@ -9,10 +14,15 @@ 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; + +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..052fdf8f 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -12,18 +12,6 @@ const DocHandle = @import("DocHandle.zig"); 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 @@ -86,9 +74,6 @@ pub const VTable = struct { 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, /// 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. @@ -150,14 +135,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 +157,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 { @@ -232,9 +209,6 @@ pub fn showSaveDialog( self.vtable.showSaveDialog(self.ctx, cb, filters, default_filename, default_folder); } -pub fn uiAtlas(self: EditorAPI) UiAtlasView { - return self.vtable.uiAtlas(self.ctx); -} pub fn activeDoc(self: EditorAPI) ?DocHandle { return self.vtable.activeDoc(self.ctx); @@ -344,32 +318,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 +373,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..9c35b036 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 @@ -26,10 +29,33 @@ pub const PluginSettings = std.StringArrayHashMapUnmanaged([]const u8); /// 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 { + /// Contributing plugin (null = shell built-in). Used to scope teardown in + /// `unregisterPlugin` when a plugin is unloaded at runtime. + owner: ?*Plugin = null, ctx: ?*anyopaque = null, color: *const fn (ctx: ?*anyopaque, color_index: usize) ?dvui.Color, }; +/// A registered inter-plugin service plus the plugin that owns it, so a runtime +/// unload can remove the owner's services. `owner` is null for shell-registered +/// services with no single plugin owner. +pub const ServiceEntry = struct { + ptr: *anyopaque, + owner: ?*Plugin = null, +}; + +/// A file-tree row icon drawer. The workbench file tree calls registered drawers in order at +/// each file row's icon slot; the first that returns `true` wins, otherwise the workbench draws +/// a generic filesystem default. This lets the plugin that owns a file type draw its own icon (a +/// glyph, a thumbnail, anything) instead of the shell hardcoding per-extension icons. `ext` is +/// the extension including the dot, as on disk (compare case-insensitively); `path` is absolute; +/// `color` is the row's themed icon color. +pub const FileIcon = struct { + owner: ?*Plugin = null, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque, ext: []const u8, path: []const u8, color: dvui.Color) bool, +}; + allocator: std.mem.Allocator, /// All registered plugins (statically compiled in, or loaded from a runtime dylib). @@ -38,7 +64,7 @@ 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, +services: std.StringHashMapUnmanaged(ServiceEntry) = .empty, /// The shell's read/utility surface (arena, folder, shared settings, dirty mark), /// installed by the shell during startup. Null until installed (headless/test). @@ -50,6 +76,9 @@ plugin_settings: PluginSettings = .empty, /// File-tree row fill tints (workbench asks the Host; editor plugins register). file_row_fill_colors: std.ArrayListUnmanaged(FileRowFillColor) = .empty, +/// File-tree row icon drawers (workbench asks the Host; plugins register for their file types). +file_icons: std.ArrayListUnmanaged(FileIcon) = .empty, + // ---- 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. @@ -62,8 +91,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,8 +114,11 @@ 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); + self.file_icons.deinit(self.allocator); { var it = self.plugin_settings.iterator(); while (it.next()) |e| { @@ -159,11 +195,6 @@ pub fn showSaveDialog( 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(); -} - /// 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; @@ -273,34 +304,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 +360,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 +383,98 @@ 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"`, …). +/// Remove every contribution, service, and registry entry owned by `plugin`, then drop +/// the plugin itself. The inverse of `registerPlugin` + the `register*` calls a plugin +/// makes in its `register`. Used by the runtime unload path (the store's "disable" / +/// "uninstall"); built-in plugins are never unregistered. +/// +/// **Ordering matters for the dylib case:** a contribution's `id`/`title` slices and the +/// `*Plugin` itself live in the plugin image's static memory. The caller must invoke +/// this *before* `dlclose`, so that the active-selection ids (which may point into that +/// image) are compared and reset while the memory is still mapped. +pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void { + removeOwned(SidebarView, &self.sidebar_views, plugin); + removeOwned(BottomView, &self.bottom_views, plugin); + removeOwned(CenterProvider, &self.center_providers, plugin); + removeOwned(MenuContribution, &self.menus, plugin); + removeOwned(MenuSectionContribution, &self.menu_sections, plugin); + removeOwned(SettingsSection, &self.settings_sections, plugin); + removeOwned(Command, &self.commands, plugin); + removeOwned(FileRowFillColor, &self.file_row_fill_colors, plugin); + removeOwned(FileIcon, &self.file_icons, plugin); + + // Services: free the owned key strings and drop the entries. + { + var it = self.services.iterator(); + var doomed: std.ArrayListUnmanaged([]const u8) = .empty; + defer doomed.deinit(self.allocator); + while (it.next()) |e| { + if (e.value_ptr.owner == plugin) doomed.append(self.allocator, e.key_ptr.*) catch {}; + } + for (doomed.items) |name| _ = self.services.remove(name); + } + + // Drop the plugin from the registry (pointer identity; no `owner` field here). + for (self.plugins.items, 0..) |p, i| { + if (p == plugin) { + _ = self.plugins.orderedRemove(i); + break; + } + } + + // Active-selection ids may name a now-removed view; reset so the next frame falls + // back to a still-registered contribution (or none). + if (self.active_sidebar_view) |id| { + if (!self.hasSidebarView(id)) self.active_sidebar_view = null; + } + if (self.active_bottom_view) |id| { + if (!self.hasBottomView(id)) self.active_bottom_view = null; + } + if (self.active_center) |id| { + if (!self.hasCenterProvider(id)) self.active_center = null; + } +} + +/// Compact a registry in place, dropping every entry whose `owner` is `plugin`. +/// `T` must have an `owner: ?*Plugin` field (all contribution structs do). +fn removeOwned(comptime T: type, list: *std.ArrayListUnmanaged(T), plugin: *Plugin) void { + var w: usize = 0; + for (list.items) |item| { + const owned = if (item.owner) |o| o == plugin else false; + if (!owned) { + list.items[w] = item; + w += 1; + } + } + list.items.len = w; +} + +fn hasSidebarView(self: *Host, id: []const u8) bool { + for (self.sidebar_views.items) |*v| if (std.mem.eql(u8, v.id, id)) return true; + return false; +} + +fn hasBottomView(self: *Host, id: []const u8) bool { + for (self.bottom_views.items) |*v| if (std.mem.eql(u8, v.id, id)) return true; + return false; +} + +fn hasCenterProvider(self: *Host, id: []const u8) bool { + for (self.center_providers.items) |*p| if (std.mem.eql(u8, p.id, id)) return true; + return false; +} + +/// 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 +482,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); } @@ -401,12 +502,33 @@ pub fn fileRowFillColor(self: *Host, color_index: usize) ?dvui.Color { return null; } -pub fn registerService(self: *Host, name: []const u8, service: *anyopaque) !void { - try self.services.put(self.allocator, name, service); +pub fn registerFileIcon(self: *Host, drawer: FileIcon) !void { + try self.file_icons.append(self.allocator, drawer); +} + +/// Draw the file-tree row icon for `ext`/`path` via the first registered drawer that handles it. +/// Returns true if a plugin drew it; false means the caller should draw a generic default. +pub fn drawFileIcon(self: *Host, ext: []const u8, path: []const u8, color: dvui.Color) bool { + for (self.file_icons.items) |drawer| { + if (drawer.draw(drawer.ctx, ext, path, color)) return true; + } + return false; +} + +/// Register an inter-plugin service. `owner` is the contributing plugin (null for a +/// shell-registered service); it lets `unregisterPlugin` drop the service on unload. +pub fn registerService(self: *Host, name: []const u8, service: *anyopaque, owner: ?*Plugin) !void { + try self.services.put(self.allocator, name, .{ .ptr = service, .owner = owner }); } pub fn getService(self: *Host, name: []const u8) ?*anyopaque { - return self.services.get(name); + return if (self.services.get(name)) |entry| entry.ptr else null; +} + +/// 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) ------- @@ -421,6 +543,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 +628,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 +677,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; } @@ -519,3 +764,85 @@ pub fn requestNewDocument(self: *Host, parent_path: ?[]const u8, id_extra: usize } } } + +// ---- tests ----------------------------------------------------------------- + +const testing = std.testing; + +test "unregisterPlugin removes a plugin's contributions, service, and resets active ids" { + const noopDraw = struct { + fn f(_: ?*anyopaque) anyerror!void {} + }.f; + const noopCenter = struct { + fn f(_: ?*anyopaque) anyerror!dvui.App.Result { + return .ok; + } + }.f; + const noopRun = struct { + fn f(_: *anyopaque) anyerror!void {} + }.f; + const noColor = struct { + fn f(_: ?*anyopaque, _: usize) ?dvui.Color { + return null; + } + }.f; + const noIcon = struct { + fn f(_: ?*anyopaque, _: []const u8, _: []const u8, _: dvui.Color) bool { + return false; + } + }.f; + + var host = Host.init(testing.allocator); + defer host.deinit(); + + const vtable = Plugin.VTable{}; + var plugin = Plugin{ .state = undefined, .vtable = &vtable, .id = "victim", .display_name = "Victim" }; + var service_obj: u32 = 0; + + // A second, surviving plugin so we can prove only the victim's entries are removed. + var keeper = Plugin{ .state = undefined, .vtable = &vtable, .id = "keeper", .display_name = "Keeper" }; + + try host.registerPlugin(&keeper); + try host.registerPlugin(&plugin); + try host.registerSidebarView(.{ .id = "keeper.view", .owner = &keeper, .icon = "", .title = "K", .draw = noopDraw }); + try host.registerSidebarView(.{ .id = "victim.view", .owner = &plugin, .icon = "", .title = "V", .draw = noopDraw }); + try host.registerBottomView(.{ .id = "victim.bottom", .owner = &plugin, .title = "V", .draw = noopDraw }); + try host.registerCenterProvider(.{ .id = "victim.center", .owner = &plugin, .draw = noopCenter }); + try host.registerMenu(.{ .id = "victim.menu", .owner = &plugin, .draw = noopDraw }); + try host.registerMenuSection(.{ .id = "victim.section", .parent_menu_id = "shell.menu.view", .owner = &plugin, .draw = noopDraw }); + try host.registerSettingsSection(.{ .id = "victim.settings", .owner = &plugin, .title = "V", .draw = noopDraw }); + try host.registerCommand(.{ .id = "victim.cmd", .owner = &plugin, .title = "V", .run = noopRun }); + try host.registerFileRowFillColor(.{ .owner = &plugin, .color = noColor }); + try host.registerFileIcon(.{ .owner = &plugin, .draw = noIcon }); + try host.registerService("victim.svc", &service_obj, &plugin); + + // Active sidebar view points at the victim (keeper registered first, but force it). + host.setActiveSidebarView("victim.view"); + host.setActiveBottomView("victim.bottom"); + host.setActiveCenter("victim.center"); + + host.unregisterPlugin(&plugin); + + // The victim is gone; the keeper survives. + try testing.expect(host.pluginById("victim") == null); + try testing.expect(host.pluginById("keeper") != null); + + // Every victim contribution is gone; keeper's sidebar view remains. + try testing.expectEqual(@as(usize, 1), host.sidebar_views.items.len); + try testing.expectEqualStrings("keeper.view", host.sidebar_views.items[0].id); + try testing.expectEqual(@as(usize, 0), host.bottom_views.items.len); + try testing.expectEqual(@as(usize, 0), host.center_providers.items.len); + try testing.expectEqual(@as(usize, 0), host.menus.items.len); + try testing.expectEqual(@as(usize, 0), host.menu_sections.items.len); + try testing.expectEqual(@as(usize, 0), host.settings_sections.items.len); + try testing.expectEqual(@as(usize, 0), host.commands.items.len); + try testing.expectEqual(@as(usize, 0), host.file_row_fill_colors.items.len); + try testing.expectEqual(@as(usize, 0), host.file_icons.items.len); + try testing.expect(host.getService("victim.svc") == null); + + // Active selections that named removed contributions reset to null; the next frame + // falls back to a still-registered view. + try testing.expect(host.active_sidebar_view == null); + try testing.expect(host.active_bottom_view == null); + try testing.expect(host.active_center == null); +} 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..0238efef 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -5,43 +5,273 @@ //! 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; + +/// C ABI — `fizzy_plugin_name`; returns the plugin's user-facing display name (NUL-terminated). +/// Optional symbol queried *without* registering, so the host can show a sideloaded/disabled +/// plugin's real name without loading it. Absent on plugins built before this symbol existed — +/// the loader falls back to the id in that case. +pub const GetPluginNameFn = *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 symbol_plugin_name: [:0]const u8 = "fizzy_plugin_name"; + 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"; + const name_z = manifest.name ++ "\x00"; + fn pluginId() callconv(.c) [*:0]const u8 { + return id_z; + } + fn pluginName() callconv(.c) [*:0]const u8 { + return name_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(&IdEntry.pluginName, .{ .name = symbol_plugin_name }); + @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"; + const name_z = manifest.name ++ "\x00"; + fn pluginId() callconv(.c) [*:0]const u8 { + return id_z; + } + fn pluginName() callconv(.c) [*:0]const u8 { + return name_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(&IdEntry.pluginName, .{ .name = symbol_plugin_name }); + @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..fa7c8ff7 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,22 +20,53 @@ 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). 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; 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..4d8968bb --- /dev/null +++ b/src/sdk/version.zig @@ -0,0 +1,82 @@ +//! 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. +//! +//! **Cadence policy (decoupled from the app version).** The app version (`VERSION` / +//! `build.zig.zon`) ships often and is *not* an input to the ABI fingerprint or to +//! `sdk_version`. The fingerprint is a pure function of the plugin-boundary types — the SDK +//! vtables plus the `dvui` types they reference — so it only moves when one of those changes. +//! To keep prebuilt plugins valid across many app releases, `dvui` and the Zig toolchain are +//! **pinned** (see the `dvui` dependency in `build.zig.zon` and `ZIG_VERSION` in CI) and bumped +//! deliberately/batched, not per release. A Fizzy release that does not touch the boundary, the +//! pinned `dvui`, or the compiler keeps the **same fingerprint**, so the store's installed +//! plugins keep loading. The store matches plugin binaries on the fingerprint (see +//! `docs/PLUGINS.md` § Compatibility). +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 = 7, + .patch = 0, +}; + +/// Commit this literal alongside `sdk_version` when the ABI boundary changes. +/// 0.5.0: added `Host.FileRowFillColor.owner` + service-owner tracking for runtime unload. +/// 0.6.0: added `Host.registerFileIcon`/`FileIcon` (plugins draw their own file-tree icons). +/// 0.7.0: removed `host.uiAtlas`/`UiAtlasView`/`UiSprite` (plugins own their own sprite atlases). +pub const recorded_abi_fingerprint: u64 = 0x1bb54eb7506cbd78; + +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); +} + +test "abi fingerprint is decoupled from the app version" { + // The fingerprint is a pure function of the plugin-boundary types (SDK vtables + the + // pinned dvui types they reference). The app version is not in that set, so a routine + // app-version bump must leave the fingerprint — and therefore plugin compatibility — + // unchanged. This guards the cadence policy in the module doc comment: if someone ever + // wires an app-version-dependent value into a boundary type, the live fingerprint drifts + // from `recorded_abi_fingerprint` and both this lock and the comptime check above fail, + // forcing a deliberate `sdk_version` bump rather than a silent one. + try std.testing.expectEqual(recorded_abi_fingerprint, dylib.abi_fingerprint); +} diff --git a/src/web_main.zig b/src/web_main.zig index e810a970..77e1e585 100644 --- a/src/web_main.zig +++ b/src/web_main.zig @@ -11,8 +11,6 @@ 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. @@ -25,28 +23,6 @@ const Internal = pixelart.internal; comptime { // Pure constants / re-exports _ = fizzy.version; - _ = fizzy.atlas; - - // Algorithms — pure Zig + dvui - _ = pixelart.algorithms.brezenham; - _ = pixelart.algorithms.reduce; - - // Top-level data types (.pixi format on-disk shapes) - _ = pixelart.Animation; - _ = pixelart.Atlas; - _ = pixelart.File; - _ = pixelart.Layer; - _ = pixelart.Sprite; - - // Internal editor-side data types - _ = Internal.Animation; - _ = Internal.Atlas; - _ = Internal.Buffers; - _ = Internal.File.init; - _ = Internal.History; - _ = Internal.Layer; - _ = Internal.Palette; - _ = Internal.Sprite; // Math + graphics helpers _ = fizzy.math.checker; @@ -55,11 +31,9 @@ comptime { _ = fizzy.image.init; _ = fizzy.image.pixels; _ = fizzy.perf.record; - _ = pixelart.render; // Custom dvui wrapper + widgets — types compile even though the widget files // contain dead `@import("backend")` SDL3 imports at file scope. - _ = 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 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 deleted file mode 100644 index 7dbbf195..00000000 --- a/tests/plugin_loader_integration.zig +++ /dev/null @@ -1,33 +0,0 @@ -//! 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(); - - // 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, "pixelart", .{ - .gpa = &std.testing.allocator, - .state = &state_buf, - .packer = 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); - try std.testing.expect(host.sidebar_views.items.len >= 3); - - loaded.set_dvui_context(null, null, null, null); - loaded.set_globals(@ptrCast(&std.testing.allocator), &state_buf, null); -} diff --git a/tests/root.zig b/tests/root.zig index 1606de7c..d6f24970 100644 --- a/tests/root.zig +++ b/tests/root.zig @@ -9,12 +9,8 @@ comptime { // Phase 1: pure-logic unit tests. _ = @import("fizzy-direction"); _ = @import("fizzy-easing"); - _ = @import("fizzy-layer-order"); - _ = @import("fizzy-palette-parse"); _ = @import("fizzy-layout-anchor"); - _ = @import("fizzy-reduce"); - _ = @import("fizzy-grid-validate"); - _ = @import("fizzy-animation"); _ = @import("fizzy-window-layout"); _ = @import("fizzy-plugin-dylib"); + _ = @import("fizzy-plugin-store"); }