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
new file mode 100644
index 00000000..75f7222c
--- /dev/null
+++ b/HANDOFF.md
@@ -0,0 +1,712 @@
+# Fizzy Modular-Plugin Refactor — Handoff (Phase 4 COMPLETE → Phase 5: runtime dylib plugins)
+
+## TL;DR
+
+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.
+
+**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.
+
+---
+
+### 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;
+`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).
+
+**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`.
+
+**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:** 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:
+
+```
+zig build # native exe
+zig build check-web # wasm
+zig build test # unit/integration tests
+```
+
+Run all three after every stage. `zig build` for this repo currently needs to run outside
+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 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
+ 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 (`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)
+
+| Step | Work | Done when |
+|------|------|-----------|
+| **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`; `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
+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; 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 |
+
+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 | ✅ 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)
+
+- **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
+```
+
+### 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)
+
+**5c.1–5c.3** — done (built-in dylibs on native + Velopack packDir includes `plugins/`). **Next: 5d**.
+
+---
+
+## Plugin directory layout (convention)
+
+Every plugin follows the same shape:
+
+```
+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 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` (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) |
+
+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
+
+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
+```
+
+**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.
+
+---
+
+## 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.
+
+- **`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.
+
+### Part 3 — save/pack/editor-action decoupling
+
+Pixel-art dialogs and actions reach the shell through `host.*` / `EditorAPI`, not `fizzy.editor.*`.
+
+**EditorAPI additions** (all wired in `Editor.zig` shell vtable + `Host.zig` forwarders):
+
+`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
+```
+
+---
+
+## 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`
+
+`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)
+
+```
+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)
+```
+
+---
+
+## Stage D — remaining work — DONE (historical)
+
+All items below were completed in Stage D/E/W. Kept for archaeology only.
+
+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; shell code uses `@import("pixelart")` /
+`@import("workbench")` build modules.
+
+---
+
+## 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).
+- **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`).
+- **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.
+- **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`.
+- ✅ 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.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).
+
+---
+
+## Stage W — workbench lift — COMPLETE (signed off 2026-06-19)
+
+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 (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
+ 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 — 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.*` → 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 — 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.
+
+---
+
+## Next big rock: sprite / atlas → `core` — DONE
+
+End-state achieved. Verified this session:
+
+- **`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.
+- Workbench draws the logo via `Globals.host.uiAtlas()` (not `fizzy.editor.atlas`).
+
+---
+
+## 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 |
+| `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/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 |
+| `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; `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 |
+
+---
+
+## State of the tree
+
+**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):
+
+```
+# 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 and pixelart (5a.1 done)
+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 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
+```
+
+All three configs green: `zig build`, `zig build check-web`, `zig build test`.
diff --git a/build.zig b/build.zig
index 48476dd6..61a6daf6 100644
--- a/build.zig
+++ b/build.zig
@@ -1,139 +1,16 @@
const std = @import("std");
-const zip = @import("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("src/tools/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" }) },
- };
-}
+const ProcessAssetsStep = @import("process_assets.zig");
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,1070 +19,23 @@ pub fn build(b: *std.Build) !void {
b.graph.environ_map.get("FIZZY_MACOS_NOTARY_PROFILE");
const target = b.standardTargetOptions(.{});
- // Artifacts install to `zig-out/-/` (e.g. arm64-macos, x86-64-windows). Pass `-Dtarget=…` as usual.
const optimize = b.standardOptimizeOption(.{});
- const macos_sdl_paths = try macosSdlPathsForExplicitTarget(b, target);
- const zig_out_subdir = zigOutSubdirForTarget(b, target);
- const zig_out_install_dir: std.Build.InstallDir = .{ .custom = zig_out_subdir };
-
- const target_is_windows_msvc = target.result.os.tag == .windows and target.result.abi == .msvc;
- const cross_win_msvc = target_is_windows_msvc and b.graph.host.result.os.tag != .windows;
-
- // Auto-fetch defaults: on Windows hosts targeting *-windows-msvc, downloading the
- // MSVC SDK into .velopack-msvc/ is the deterministic path — Zig's auto-detection
- // of a system Visual Studio install picks up whatever's currently installed, which
- // makes packaged release builds non-reproducible. The same .velopack-msvc/ tree is
- // used on macOS/Linux cross-compile hosts, so all three triples land on the same
- // SDK headers + libs. Explicit `-Dfetch-msvc=false` opts out (use system VS); an
- // explicit `-Dwindows-msvc-libc=...` overrides the discovery entirely.
- const fetch_msvc = fetch_msvc_opt orelse (target_is_windows_msvc and windows_msvc_libc_opt == null);
- const win_libc = velopack.resolveWindowsMsvcLibc(b, target, .{
- .explicit_path = windows_msvc_libc_opt,
- .install_dir_name = ".velopack-msvc",
- .fetch_if_missing = fetch_msvc,
- });
-
- var effective_win_libc: ?[]const u8 = win_libc.libc_path;
- if (effective_win_libc == null) {
- if (cross_win_msvc) effective_win_libc = b.libc_file;
- }
-
- // Velopack in the dev/install exe is opt-in (`-Dvelopack=true`). Release
- // packaging (`zig build package`) still links Velopack when the ABI supports
- // it via a second compile, so `zig build` / `run` / `test` never pull dotnet
- // or the static Velopack lib unless you ask. Windows *-gnu targets are
- // unchanged (no Velopack prebuilt for that ABI).
- const velopack_supported_for_target = !(target.result.os.tag == .windows and target.result.abi != .msvc);
- const velopack_enabled = b.option(
+ const plugin_sdk = 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.",
+ "plugin_sdk",
+ "Export core/sdk modules for third-party plugin builds; skips the fizzy app",
) 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 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/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 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);
-
- // 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/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/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"),
- .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/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"};
- msf_gif_web_lib.root_module.addCSourceFile(.{
- .file = std.Build.path(b, "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);
-
- 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 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 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;
- };
-
- if (no_emit) {
- b.getInstallStep().dependOn(&exe.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);
- }
-
- 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");
- vpk_pkg_sh.addArg("--packDir");
- vpk_pkg_sh.addDirectoryArg(exe_for_package.getEmittedBin().dirname());
- 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(&strip_release_sh.step);
-
- 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 (added in Phase 2 of the testing plan)
- // will 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/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-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" },
- }) |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,
- });
-
- // 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"));
- }
- 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/tools/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 });
- }
- }
- }
-}
-
-const FizzyExecutable = struct {
- exe: *std.Build.Step.Compile,
- zstbi_module: *std.Build.Module,
- msf_gif_module: *std.Build.Module,
- known_folders: *std.Build.Module,
-};
-
-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 zstbi_lib = b.addLibrary(.{
- .name = "zstbi",
- .root_module = b.addModule("zstbi", .{
- .target = resolved_target,
- .optimize = optimize,
- .root_source_file = .{ .cwd_relative = "src/deps/stbi/zstbi.zig" },
- }),
- });
- const zstbi_module = zstbi_lib.root_module;
- zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "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/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") });
-
- 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"));
-
- const singleton_app_dep = b.dependency("dvui_singleton_app", .{
- .target = resolved_target,
- .optimize = optimize,
+ 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,
});
- 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"));
- }
-
- 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/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") });
- } 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,
- };
}
-
-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 faf88e34..43ae1c5d 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,8 +1,13 @@
.{
.paths = .{
"src",
+ "build",
"build.zig",
"build.zig.zon",
+ "process_assets.zig",
+ "update.zig",
+ "build",
+ "plugin_sdk.zig",
"assets",
"libs",
},
@@ -27,8 +32,8 @@
.lazy = true,
},
.dvui = .{
- .url = "https://github.com/foxnne/dvui-dev/archive/2f81423945d7076796023a7802f2680226dd9bd4.tar.gz",
- .hash = "dvui-0.5.0-dev-AQFJmdw09wCp9ts4oaBV7Rkn7YuMKxDiaCLaweO-HPuS",
+ .url = "https://github.com/foxnne/dvui-dev/archive/3dec1c1b56f71aff41e36588a715dea085d307f5.tar.gz",
+ .hash = "dvui-0.5.0-dev-AQFJmZhu9wAmUMx9414LO75l0R69z3d8udYXbj72-q3R",
//.path = "../dvui-dev",
},
.assetpack = .{
@@ -47,5 +52,9 @@
.dvui_singleton_app = .{
.path = "libs/dvui-singleton-app",
},
+ // Built-in plugins are NOT package dependencies: the root build embeds them by
+ // importing each plugin's `static/integration.zig` directly (see build/plugins.zig),
+ // which owns the module graph. Each plugin's own `build.zig` is only for the
+ // standalone third-party-shape build under `src/plugins//`.
},
}
diff --git a/build/app.zig b/build/app.zig
new file mode 100644
index 00000000..a2510ba6
--- /dev/null
+++ b/build/app.zig
@@ -0,0 +1,621 @@
+const std = @import("std");
+
+const plugin = @import("../plugin_sdk.zig");
+const dvui = @import("dvui");
+const velopack = @import("velopack_zig");
+const ProcessAssetsStep = @import("../process_assets.zig");
+
+pub const Options = struct {
+ windows_msvc_libc_opt: ?[]const u8 = null,
+ fetch_msvc_opt: ?bool = null,
+ macos_sign_app_identity: ?[]const u8 = null,
+ macos_sign_install_identity: ?[]const u8 = null,
+ macos_notary_profile: ?[]const u8 = null,
+};
+
+pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, opts: Options) !void {
+ const windows_msvc_libc_opt = opts.windows_msvc_libc_opt;
+ const fetch_msvc_opt = opts.fetch_msvc_opt;
+ const macos_sign_app_identity = opts.macos_sign_app_identity;
+ const macos_sign_install_identity = opts.macos_sign_install_identity;
+ const macos_notary_profile = opts.macos_notary_profile;
+
+ const common = @import("common.zig");
+ const plugins = @import("plugins.zig");
+ const sdk = @import("sdk.zig");
+ const fizzy_exe = @import("exe.zig");
+ const web = @import("web.zig");
+ const package = @import("package.zig");
+ const msvc = @import("msvc.zig");
+
+ const pixi_plugin = plugins.pixi;
+ const workbench_plugin = plugins.workbench;
+ const code_plugin = plugins.code;
+ const example_plugin = plugins.example;
+ const FizzyExecutable = fizzy_exe.FizzyExecutable;
+
+ // Built-in plugins are embedded by importing their `static/integration.zig` directly
+ // (via build/plugins.zig); the root build owns the module graph, so there is no plugin
+ // package dependency to resolve here. Their canonical `build.zig` is only for the
+ // standalone (`cd src/plugins/ && zig build`) third-party-shape build.
+
+ const macos_sdl_paths = try common.macosSdlPathsForExplicitTarget(b, target);
+ const zig_out_subdir = common.zigOutSubdirForTarget(b, target);
+ const zig_out_install_dir: std.Build.InstallDir = .{ .custom = zig_out_subdir };
+
+ const target_is_windows_msvc = target.result.os.tag == .windows and target.result.abi == .msvc;
+ const cross_win_msvc = target_is_windows_msvc and b.graph.host.result.os.tag != .windows;
+
+ // Auto-fetch defaults: on Windows hosts targeting *-windows-msvc, downloading the
+ // MSVC SDK into .velopack-msvc/ is the deterministic path — Zig's auto-detection
+ // of a system Visual Studio install picks up whatever's currently installed, which
+ // makes packaged release builds non-reproducible. The same .velopack-msvc/ tree is
+ // used on macOS/Linux cross-compile hosts, so all three triples land on the same
+ // SDK headers + libs. Explicit `-Dfetch-msvc=false` opts out (use system VS); an
+ // explicit `-Dwindows-msvc-libc=...` overrides the discovery entirely.
+ const fetch_msvc = fetch_msvc_opt orelse (target_is_windows_msvc and windows_msvc_libc_opt == null);
+
+ const win_libc = velopack.resolveWindowsMsvcLibc(b, target, .{
+ .explicit_path = windows_msvc_libc_opt,
+ .install_dir_name = ".velopack-msvc",
+ .fetch_if_missing = fetch_msvc,
+ });
+
+ var effective_win_libc: ?[]const u8 = win_libc.libc_path;
+ if (effective_win_libc == null) {
+ if (cross_win_msvc) effective_win_libc = b.libc_file;
+ }
+
+ // Velopack in the dev/install exe is opt-in (`-Dvelopack=true`). Release
+ // packaging (`zig build package`) still links Velopack when the ABI supports
+ // it via a second compile, so `zig build` / `run` / `test` never pull dotnet
+ // or the static Velopack lib unless you ask. Windows *-gnu targets are
+ // unchanged (no Velopack prebuilt for that ABI).
+ const velopack_supported_for_target = !(target.result.os.tag == .windows and target.result.abi != .msvc);
+ const velopack_enabled = b.option(
+ bool,
+ "velopack",
+ "Link Velopack runtime in the install/run exe (auto-update). Default: false. `package` still produces a Velopack-linked binary when supported.",
+ ) orelse false;
+
+ if (velopack_enabled and !velopack_supported_for_target) {
+ std.log.err(
+ "-Dvelopack=true is unsupported for target ABI {s}: Velopack on Windows requires -Dtarget=x86_64-windows-msvc or -Dtarget=aarch64-windows-msvc.",
+ .{@tagName(target.result.abi)},
+ );
+ return error.WindowsMsvcAbiRequired;
+ }
+
+ // Fail loudly when the *-windows-msvc target has no headers/libs to compile against.
+ // On a non-Windows host this happens whenever `.velopack-msvc/` is missing and the
+ // user didn't pass `-Dfetch-msvc` or `-Dwindows-msvc-libc=…`. On a Windows host the
+ // auto-fetch default makes this unreachable unless the user explicitly opted out
+ // with `-Dfetch-msvc=false` — in which case Zig falls back to system Visual Studio
+ // auto-detection, which we can't validate here.
+ const velopack_required_fail: ?*std.Build.Step = if (cross_win_msvc and effective_win_libc == null)
+ &b.addFail(
+ \\*-windows-msvc needs MSVC + Windows SDK headers/libs.
+ \\ One-shot install (macOS/Linux/Windows): zig build msvcup-setup
+ \\ Then: zig build package -Dtarget=x86_64-windows-msvc (auto-uses .velopack-msvc/zig-libc-x64.ini)
+ \\ Or auto-download in this build: add -Dfetch-msvc (default on Windows hosts; forwards through packageall)
+ \\ Or pass: --libc path.ini / -Dwindows-msvc-libc=path.ini
+ ).step
+ else
+ null;
+
+ const no_emit = b.option(bool, "no-emit", "Check for compile errors without emitting any code") orelse false;
+
+ const app_version_opt = b.option([]const u8, "app_version", "App version for vpk packVersion and startup log; defaults to VERSION file");
+
+ // GitHub repo URL baked into the binary so Velopack's auto-update can find
+ // the latest release via the GitHub Releases API. Override at build time
+ // with `-Drepo-url=...` (e.g. when shipping a fork). At runtime, the env
+ // var `FIZZY_AUTOUPDATE_URL` still overrides this for local feed testing.
+ const app_repo_url = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (e.g. https://github.com/fizzyedit/fizzy)") orelse "https://github.com/fizzyedit/fizzy";
+
+ // Comma-separated fallback repo URLs checked (in order) after `app_repo_url`
+ // yields no update. Lets a build survive a repo move/rename: ship a binary
+ // whose primary points at the new home and whose fallback points at the old
+ // one (where the transitional release is published), then transfer the repo.
+ // Empty by default (no fallback).
+ const app_repo_url_fallback = b.option([]const u8, "repo-url-fallback", "Comma-separated fallback GitHub repo URLs for Velopack auto-update, tried after -Drepo-url") orelse "";
+
+ var version_owned: ?[]u8 = null;
+ defer if (version_owned) |buf| b.allocator.free(buf);
+
+ const app_version: []const u8 = if (app_version_opt) |v| v else blk: {
+ const raw = b.build_root.handle.readFileAlloc(b.graph.io, "VERSION", b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e});
+ version_owned = raw;
+ break :blk std.mem.trimEnd(u8, raw, "\r\n");
+ };
+
+ const build_opts = b.addOptions();
+ build_opts.addOption([]const u8, "app_version", app_version);
+ build_opts.addOption([]const u8, "app_repo_url", app_repo_url);
+ build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback);
+ build_opts.addOption(bool, "velopack_enabled", velopack_enabled);
+ const static_pixi = b.option(
+ bool,
+ "static-pixi",
+ "Keep pixi statically registered on native (skip built-in dylib load)",
+ ) orelse false;
+ build_opts.addOption(bool, "static_pixi", static_pixi);
+ const static_workbench = b.option(
+ bool,
+ "static-workbench",
+ "Keep workbench statically registered on native (skip built-in dylib load)",
+ ) orelse false;
+ build_opts.addOption(bool, "static_workbench", static_workbench);
+ const static_code = b.option(
+ bool,
+ "static-code",
+ "Keep code plugin statically registered on native (skip built-in dylib load)",
+ ) orelse false;
+ build_opts.addOption(bool, "static_code", static_code);
+ const workbench_file_tree = b.option(
+ bool,
+ "workbench-file-tree",
+ "Register the workbench Files sidebar view (file tree)",
+ ) orelse true;
+ const workbench_opts = b.addOptions();
+ workbench_opts.addOption(bool, "file_tree", workbench_file_tree);
+
+ common.addUpdateStep(b);
+
+ const msvcup_before_compile = velopack.addMsvcupSetupStep(b, ".velopack-msvc");
+ const msvcup_setup_step = b.step("msvcup-setup", "Download MSVC SDK into .velopack-msvc/ via velopack-zig (writes zig-libc-*.ini)");
+ msvcup_setup_step.dependOn(&msvcup_before_compile.step);
+
+ const zip_pkg = pixi_plugin.zipPackage(b);
+
+ const accesskit = b.option(dvui.AccesskitOptions, "accesskit", "Enable accesskit") orelse .off;
+
+ const assetpack = @import("assetpack");
+ const assets_module = assetpack.pack(b, b.path("assets"), .{});
+
+ // Generated atlas / asset stubs (`src/generated/*.zig`) are imported
+ // unconditionally by `fizzy.zig`, so the process-assets step has to
+ // run before any target that touches fizzy.zig — exe, integration
+ // tests, etc.
+ const assets_processing = try ProcessAssetsStep.init(b, "assets", "src/core/generated/");
+ const process_assets_step = b.step("process-assets", "generates struct for all assets");
+ process_assets_step.dependOn(&assets_processing.step);
+
+ // ---------------------------------------------------------------
+ // Web (wasm) build — entirely separate from the native exe so it can't disturb
+ // packaging / SDL / Velopack paths. `zig build web` produces `zig-out/web/{web.wasm,
+ // web.js, index.html, NotoSansKR-Regular.ttf}`, deployable as-is to a static host.
+ //
+ // Checkpoint A: minimal placeholder app, no fizzy editor code yet. Later checkpoints
+ // will incrementally pull fizzy modules in, gating each native-only path behind a
+ // `arch != .wasm32` check.
+ // ---------------------------------------------------------------
+
+ web.addSteps(b, optimize, build_opts, workbench_opts, zip_pkg, assets_module);
+
+ const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, target, optimize, accesskit, build_opts, workbench_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, velopack_enabled);
+ const exe = main_fizzy.exe;
+ const zstbi_module = main_fizzy.zstbi_module;
+ const msf_gif_module = main_fizzy.msf_gif_module;
+ const known_folders = main_fizzy.known_folders;
+
+ const package_fizzy: FizzyExecutable = package_blk: {
+ if (velopack_enabled) break :package_blk main_fizzy;
+ if (!velopack_supported_for_target) break :package_blk main_fizzy;
+ const pack_opts = b.addOptions();
+ pack_opts.addOption([]const u8, "app_version", app_version);
+ pack_opts.addOption([]const u8, "app_repo_url", app_repo_url);
+ pack_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback);
+ pack_opts.addOption(bool, "velopack_enabled", true);
+ pack_opts.addOption(bool, "static_pixi", static_pixi);
+ pack_opts.addOption(bool, "static_workbench", static_workbench);
+ pack_opts.addOption(bool, "static_code", static_code);
+ break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, target, optimize, accesskit, pack_opts, workbench_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, true);
+ };
+ const exe_for_package = package_fizzy.exe;
+
+ if (no_emit) {
+ b.getInstallStep().dependOn(&exe.step);
+ if (main_fizzy.pixi_dylib) |pixi_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), pixi_dylib, "pixi", plugins_install_dir);
+ }
+ if (main_fizzy.workbench_dylib) |workbench_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), workbench_dylib, "workbench", plugins_install_dir);
+ }
+ if (main_fizzy.code_dylib) |code_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), code_dylib, "code", plugins_install_dir);
+ }
+ } else {
+ const install_artifact = b.addInstallArtifact(exe, .{
+ .dest_dir = .{ .override = zig_out_install_dir },
+ });
+
+ const run_cmd = b.addRunArtifact(exe);
+ const run_step = b.step("run", "Run the app (does not run Velopack)");
+
+ run_cmd.step.dependOn(&install_artifact.step);
+ run_step.dependOn(&run_cmd.step);
+ b.getInstallStep().dependOn(&install_artifact.step);
+
+ if (main_fizzy.pixi_dylib) |pixi_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), pixi_dylib, "pixi", plugins_install_dir);
+ common.attachBuiltinPluginInstall(b, &run_cmd.step, pixi_dylib, "pixi", plugins_install_dir);
+ }
+ if (main_fizzy.workbench_dylib) |workbench_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), workbench_dylib, "workbench", plugins_install_dir);
+ common.attachBuiltinPluginInstall(b, &run_cmd.step, workbench_dylib, "workbench", plugins_install_dir);
+ }
+ if (main_fizzy.code_dylib) |code_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), code_dylib, "code", plugins_install_dir);
+ common.attachBuiltinPluginInstall(b, &run_cmd.step, code_dylib, "code", plugins_install_dir);
+ }
+ }
+
+ if (main_fizzy.workbench_dylib) |workbench_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_workbench = plugin.installBuiltinPlugin(b, workbench_dylib, "workbench", plugins_install_dir);
+ const workbench_dylib_step = b.step(
+ "workbench-dylib",
+ "Build the workbench plugin as a dynamic library into zig-out//plugins/ (native only)",
+ );
+ workbench_dylib_step.dependOn(&install_workbench.step);
+ }
+
+ if (main_fizzy.pixi_dylib) |pixi_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_pixi = plugin.installBuiltinPlugin(b, pixi_dylib, "pixi", plugins_install_dir);
+
+ const pixi_dylib_step = b.step(
+ "pixi-dylib",
+ "Build the pixi plugin as a dynamic library into zig-out//plugins/ (native only)",
+ );
+ pixi_dylib_step.dependOn(&install_pixi.step);
+
+ const plugin_loader_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/editor/PluginLoader.zig"),
+ });
+ plugin_loader_module.addImport("sdk", main_fizzy.sdk_module);
+
+ const plugin_loader_test_opts = b.addOptions();
+ plugin_loader_test_opts.addOptionPath("pixi_dylib", pixi_dylib.getEmittedBin());
+
+ const plugin_loader_test_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tests/plugin_loader_integration.zig"),
+ });
+ plugin_loader_test_module.addImport("sdk", main_fizzy.sdk_module);
+ plugin_loader_test_module.addImport("plugin_loader", plugin_loader_module);
+ plugin_loader_test_module.addOptions("plugin_loader_test_opts", plugin_loader_test_opts);
+
+ const plugin_loader_tests = b.addTest(.{
+ .name = "plugin-loader-tests",
+ .root_module = plugin_loader_test_module,
+ });
+ const run_plugin_loader_tests = b.addRunArtifact(plugin_loader_tests);
+ run_plugin_loader_tests.step.dependOn(&pixi_dylib.step);
+
+ const test_plugin_loader_step = b.step(
+ "test-plugin-loader",
+ "Build pixi dylib and run dlopen/register integration test",
+ );
+ test_plugin_loader_step.dependOn(&run_plugin_loader_tests.step);
+ }
+
+ if (main_fizzy.code_dylib) |code_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_code = plugin.installBuiltinPlugin(b, code_dylib, "code", plugins_install_dir);
+ const code_dylib_step = b.step(
+ "code-dylib",
+ "Build the code plugin as a dynamic library into zig-out//plugins/ (native only)",
+ );
+ code_dylib_step.dependOn(&install_code.step);
+ }
+
+ _ = package.addSteps(.{
+ .b = b,
+ .target = target,
+ .optimize = optimize,
+ .app_version = app_version,
+ .zig_out_subdir = zig_out_subdir,
+ .zig_out_install_dir = zig_out_install_dir,
+ .no_emit = no_emit,
+ .velopack_required_fail = velopack_required_fail,
+ .exe_for_package = exe_for_package,
+ .package_fizzy = package_fizzy,
+ .macos_sign_app_identity = macos_sign_app_identity,
+ .macos_sign_install_identity = macos_sign_install_identity,
+ .macos_notary_profile = macos_notary_profile,
+ .windows_msvc_libc_opt = windows_msvc_libc_opt,
+ .fetch_msvc = fetch_msvc,
+ });
+
+ // ---------------------------------------------------------------
+ // Tests
+ // ---------------------------------------------------------------
+ //
+ // Fizzy has two test layers (see tests/README.md):
+ //
+ // 1. Unit tests — pure-logic only (math, palette parsing, layer
+ // order). The test root imports nothing but std + the pure
+ // modules under test, so it compiles in well under a second
+ // and never needs dvui/SDL/assets.
+ //
+ // 2. Integration tests use dvui's testing backend and exercise
+ // real fizzy drawing functions in a headless Window.
+ //
+ // Both share the same `zig build test` and `zig build check`
+ // entry points.
+
+ const test_filters = b.option(
+ []const []const u8,
+ "test-filter",
+ "Skip tests that do not match any filter",
+ ) orelse &[0][]const u8{};
+
+ const tests_module = b.addModule("fizzy-tests", .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tests/root.zig"),
+ });
+
+ // Wire each pure-logic source file as a named module on the test
+ // target. Zig 0.15 disallows importing source files outside the test
+ // module's own directory via relative paths, so we expose them by
+ // name. Each of these files imports only `std`, so they remain free
+ // of dvui / SDL / globals.
+ inline for (.{
+ .{ "fizzy-direction", "src/core/math/direction.zig" },
+ .{ "fizzy-easing", "src/core/math/easing.zig" },
+ .{ "fizzy-layer-order", "src/plugins/pixi/src/internal/layer_order.zig" },
+ .{ "fizzy-palette-parse", "src/plugins/pixi/src/internal/palette_parse.zig" },
+ .{ "fizzy-layout-anchor", "src/core/math/layout_anchor.zig" },
+ .{ "fizzy-reduce", "src/plugins/pixi/src/algorithms/reduce.zig" },
+ .{ "fizzy-grid-validate", "src/plugins/pixi/src/internal/grid_layout_validate.zig" },
+ .{ "fizzy-animation", "src/plugins/pixi/src/Animation.zig" },
+ .{ "fizzy-window-layout", "src/backend/window_layout.zig" },
+ .{ "fizzy-plugin-dylib", "src/sdk/dylib.zig" },
+ }) |entry| {
+ tests_module.addAnonymousImport(entry[0], .{
+ .root_source_file = b.path(entry[1]),
+ .target = target,
+ .optimize = optimize,
+ });
+ }
+
+ const unit_tests = b.addTest(.{
+ .name = "fizzy-unit-tests",
+ .root_module = tests_module,
+ .filters = test_filters,
+ });
+
+ // `zig build test` is the CI entry point and must stay self-contained: pure
+ // unit tests only, no dvui/SDL/Velopack/MSVC. Integration tests live under
+ // `zig build test-integration` (Velopack + dvui-testing + comctl32 on Windows
+ // → needs MSVC SDK on Windows hosts). `zig build test-all` runs both.
+ const test_step = b.step("test", "Run fizzy unit tests (pure-logic only, no dvui/SDL/Velopack)");
+ test_step.dependOn(&b.addRunArtifact(unit_tests).step);
+
+ // `check` mirrors the split so editor compile-error checking matches CI.
+ const check_step = b.step("check", "Compile fizzy unit tests without running them");
+ check_step.dependOn(&unit_tests.step);
+
+ // ---------------------------------------------------------------
+ // Layer 2: headless integration tests against dvui's testing
+ // backend. Wired under separate `test-integration` / `check-integration`
+ // steps so `zig build test` stays MSVC-free on Windows CI runners. Skipped
+ // when cross-compiling to *-windows-msvc without an MSVC libc INI.
+ // ---------------------------------------------------------------
+ const test_integration_step = b.step("test-integration", "Run fizzy headless integration tests (dvui-testing; needs MSVC on Windows)");
+ const check_integration_step = b.step("check-integration", "Compile fizzy integration tests without running them");
+ const test_all_step = b.step("test-all", "Run unit + integration tests");
+ test_all_step.dependOn(test_step);
+ test_all_step.dependOn(test_integration_step);
+
+ const test_sdk_version_step = b.step(
+ "test-sdk-version",
+ "Verify SDK version ↔ ABI fingerprint lock (compiles SDK + plugin dylib)",
+ );
+ if (main_fizzy.pixi_dylib) |dylib| {
+ test_sdk_version_step.dependOn(&dylib.step);
+ } else {
+ test_sdk_version_step.dependOn(&exe.step);
+ }
+ test_all_step.dependOn(test_sdk_version_step);
+
+ if (velopack_required_fail) |fail_step| {
+ test_integration_step.dependOn(fail_step);
+ check_integration_step.dependOn(fail_step);
+ return;
+ }
+
+ const dvui_testing_dep = b.dependency("dvui", .{
+ .target = target,
+ .optimize = optimize,
+ .backend = .testing,
+ .accesskit = accesskit,
+ });
+ const dvui_test_proxy_bridge = sdk.addProxyBridgeModule(b, target, optimize, dvui_testing_dep, dvui_testing_dep.module("dvui_testing"));
+
+ // Build a module rooted at `src/fizzy.zig` carrying all the same
+ // imports the production exe carries. Because fizzy.zig's transitive
+ // imports (App.zig, Editor.zig, …) reference `dvui`, `assets`,
+ // `known-folders`, etc. by name, those names must be wired here.
+ // We point dvui at the *testing* backend so calling drawing
+ // functions doesn't try to open a real OS window.
+ const fizzy_test_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/fizzy.zig"),
+ });
+ fizzy_test_module.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
+ fizzy_test_module.addImport("backend", dvui_testing_dep.module("testing"));
+ fizzy_test_module.addImport("assets", assets_module);
+ fizzy_test_module.addImport("known-folders", known_folders);
+ fizzy_test_module.addOptions("build_opts", build_opts);
+ fizzy_test_module.addImport("zstbi", zstbi_module);
+ fizzy_test_module.addImport("msf_gif", msf_gif_module);
+ fizzy_test_module.addImport("zip", zip_pkg.module);
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ fizzy_test_module.addImport("icons", dep.module("icons"));
+ }
+
+ // Shared `core` module for the test build (dvui testing backend variant).
+ const core_module_test = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ });
+ core_module_test.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
+ core_module_test.addImport("known-folders", known_folders);
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ core_module_test.addImport("icons", dep.module("icons"));
+ }
+ fizzy_test_module.addImport("core", core_module_test);
+ const sdk_module_test = sdk.wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), dvui_test_proxy_bridge, core_module_test, fizzy_test_module);
+ _ = pixi_plugin.addStaticModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ .assets = assets_module,
+ .zip = zip_pkg.module,
+ .zstbi = zstbi_module,
+ .msf_gif = msf_gif_module,
+ .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .backend = dvui_testing_dep.module("testing"),
+ }, fizzy_test_module);
+ _ = workbench_plugin.addStaticModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .backend = dvui_testing_dep.module("testing"),
+ }, workbench_opts, fizzy_test_module);
+ _ = code_plugin.addStaticModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ }, fizzy_test_module);
+ _ = example_plugin.addStaticModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ }, fizzy_test_module);
+
+ if (target.result.os.tag == .macos) {
+ if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| {
+ fizzy_test_module.addImport("objc", dep.module("objc"));
+ }
+ } else if (target.result.os.tag == .windows) {
+ if (b.lazyDependency("zigwin32", .{})) |dep| {
+ fizzy_test_module.addImport("win32", dep.module("win32"));
+ }
+ }
+
+ const integration_module = b.addModule("fizzy-integration-tests", .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tests/integration.zig"),
+ });
+ integration_module.addImport("fizzy", fizzy_test_module);
+ integration_module.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
+
+ const integration_tests = b.addTest(.{
+ .name = "fizzy-integration-tests",
+ .root_module = integration_module,
+ .filters = test_filters,
+ });
+
+ if (target.result.os.tag == .windows) {
+ integration_tests.root_module.linkSystemLibrary("comctl32", .{});
+ }
+ // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers from
+ // --libc (vcruntime_typeinfo.h vs libc++ type_info, etc.), so libc++ must be
+ // off for the msvc ABI regardless of host (cross or native Windows).
+ integration_tests.root_module.link_libcpp = !target_is_windows_msvc;
+ pixi_plugin.linkZipNative(integration_tests);
+ if (velopack_enabled) {
+ try velopack.linkVelopack(b, integration_tests, .{ .target = target, .optimize = optimize });
+ }
+
+ integration_tests.step.dependOn(process_assets_step);
+
+ test_integration_step.dependOn(&b.addRunArtifact(integration_tests).step);
+ check_integration_step.dependOn(&integration_tests.step);
+
+ if (win_libc.needs_setup) {
+ exe.step.dependOn(&msvcup_before_compile.step);
+ if (!velopack_enabled and velopack_supported_for_target) {
+ exe_for_package.step.dependOn(&msvcup_before_compile.step);
+ }
+ integration_tests.step.dependOn(&msvcup_before_compile.step);
+ unit_tests.step.dependOn(&msvcup_before_compile.step);
+ }
+
+ if (target.result.os.tag == .windows and target.result.abi == .msvc) {
+ var roots: [4]*std.Build.Step.Compile = undefined;
+ var n: usize = 0;
+ roots[n] = exe;
+ n += 1;
+ roots[n] = unit_tests;
+ n += 1;
+ roots[n] = integration_tests;
+ n += 1;
+ if (!velopack_enabled and velopack_supported_for_target) {
+ roots[n] = exe_for_package;
+ n += 1;
+ }
+
+ // Always apply the translate-c shim + SIZE_MAX define for windows-msvc, regardless of
+ // whether we're using a downloaded SDK or the host's system MSVC. translate-c uses aro
+ // (not MSVC cl.exe), and aro rejects literals like `0xffffffffffffffffui64` from MSVC's
+ // . The shim shadows stdint.h via `-I` (search order beats `-isystem`); the
+ // defineCMacro adds belt-and-suspenders by predefining SIZE_MAX before any include so
+ // MSVC's stdint.h `#ifndef SIZE_MAX` skips its own definition entirely.
+ msvc.applyMsvcTranslateCShim(b, roots[0..n]) catch |e| {
+ std.debug.panic("MSVC translate-c shim wiring failed: {s}", .{@errorName(e)});
+ };
+
+ if (effective_win_libc) |ini| {
+ if (cross_win_msvc) b.libc_file = null;
+ const libc_lp: std.Build.LazyPath = .{ .cwd_relative = ini };
+ velopack.applyWindowsMsvcLibcRecursive(b, roots[0..n], libc_lp);
+
+ const ini_exists = blk: {
+ b.build_root.handle.access(b.graph.io, ini, .{}) catch break :blk false;
+ break :blk true;
+ };
+ if (ini_exists) {
+ // Adds explicit MSVC/UCRT/SDK `-isystem` paths from the libc INI to each reachable
+ // translate-c step. Only relevant when cross-compiling with .velopack-msvc/; on a
+ // Windows host with system MSVC, Zig auto-discovers these paths itself.
+ msvc.applyMsvcIncludesToReachableTranslateC(b, roots[0..n], ini) catch |e| {
+ std.debug.panic("MSVC translate-c include fixup failed: {s}", .{@errorName(e)});
+ };
+ } else {
+ // The INI is written by `msvcup-setup` (a make-phase step), but the translate-c
+ // `-isystem` paths embed the SDK version subdir, which is only known after the SDK
+ // is installed — so they must be wired at configure time, before that step runs.
+ // A one-shot `zig build package -Dfetch-msvc` against a clean .velopack-msvc can't
+ // satisfy that ordering. Fail only the compiles that need it (not `msvcup-setup`,
+ // which has no such dependency), so running setup first still works.
+ const fail = &b.addFail(
+ \\*-windows-msvc has no .velopack-msvc/zig-libc INI yet, so translate-c can't be wired.
+ \\The SDK install must run as its own step before packaging (it can't be done in one
+ \\pass — the translate-c include paths depend on the installed SDK version):
+ \\ zig build msvcup-setup
+ \\ zig build package -Dtarget=x86_64-windows-msvc
+ ).step;
+ for (roots[0..n]) |rc| rc.step.dependOn(fail);
+ }
+ }
+ }
+}
diff --git a/build/common.zig b/build/common.zig
new file mode 100644
index 00000000..f0cad19c
--- /dev/null
+++ b/build/common.zig
@@ -0,0 +1,125 @@
+const std = @import("std");
+
+const plugin = @import("../plugin_sdk.zig");
+const update = @import("../update.zig");
+const GitDependency = update.GitDependency;
+
+/// Install `{id}.{ext}` flat under a `plugins/` directory (no `lib` prefix).
+pub fn attachBuiltinPluginInstall(
+ b: *std.Build,
+ parent: *std.Build.Step,
+ dylib: *std.Build.Step.Compile,
+ id: []const u8,
+ plugins_dir: std.Build.InstallDir,
+) void {
+ parent.dependOn(&plugin.installBuiltinPlugin(b, dylib, id, plugins_dir).step);
+}
+
+pub fn addUpdateStep(b: *std.Build) void {
+ const step = b.step("update", "update git dependencies");
+ step.makeFn = updateStep;
+}
+
+fn updateStep(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void {
+ const deps = &.{
+ GitDependency{
+ .url = "https://github.com/foxnne/zig-objc",
+ .branch = "main",
+ },
+ GitDependency{
+ .url = "https://github.com/kristoff-it/zigwin32",
+ .branch = "fix/zig16",
+ },
+ GitDependency{
+ .url = "https://github.com/foxnne/zig-lib-icons",
+ .branch = "dvui",
+ },
+ GitDependency{
+ .url = "https://github.com/foxnne/dvui-dev",
+ .branch = "main",
+ },
+ };
+ try update.update_dependency(step.owner.allocator, step.owner.graph.io, deps);
+}
+
+/// Installed artifacts go under `zig-out//…` so `packageall` and parallel targets never clobber each other.
+pub fn zigOutSubdirForTarget(b: *std.Build, rt: std.Build.ResolvedTarget) []const u8 {
+ const arch_name: []const u8 = switch (rt.result.cpu.arch) {
+ .x86_64 => "x86-64",
+ .aarch64 => "arm64",
+ else => @tagName(rt.result.cpu.arch),
+ };
+ const os_name: []const u8 = switch (rt.result.os.tag) {
+ .windows => "windows",
+ .linux => "linux",
+ .macos => "macos",
+ else => @tagName(rt.result.os.tag),
+ };
+ const base = b.fmt("{s}-{s}", .{ arch_name, os_name });
+ if (std.mem.indexOfScalar(u8, base, '_') == null)
+ return base;
+ const buf = b.allocator.alloc(u8, base.len) catch @panic("OOM");
+ @memcpy(buf, base);
+ for (buf) |*byte| {
+ if (byte.* == '_') byte.* = '-';
+ }
+ return buf;
+}
+
+/// SDL (via dvui → lazy `sdl3`) requires SDK layout when `-Dtarget=*-macos` is not "native".
+pub const MacosSdlPaths = struct {
+ include: std.Build.LazyPath,
+ framework: std.Build.LazyPath,
+ lib: std.Build.LazyPath,
+};
+
+fn resolveMacosSdkPath(b: *std.Build) ![]const u8 {
+ if (b.graph.environ_map.get("SDKROOT")) |sdk| {
+ const trimmed = std.mem.trim(u8, sdk, " \t\r\n");
+ if (trimmed.len > 0) {
+ return b.dupePath(trimmed);
+ }
+ }
+
+ const argv: []const []const u8 = &.{
+ "xcrun",
+ "--sdk",
+ "macosx",
+ "--show-sdk-path",
+ };
+ const run = try std.process.run(b.allocator, b.graph.io, .{
+ .argv = argv,
+ .stdout_limit = std.Io.Limit.limited(4096),
+ .stderr_limit = std.Io.Limit.limited(4096),
+ });
+ defer {
+ b.allocator.free(run.stdout);
+ b.allocator.free(run.stderr);
+ }
+ switch (run.term) {
+ .exited => |code| if (code != 0) {
+ std.log.err("SDL on macOS: explicit -Dtarget=*-macos needs an SDK path. xcrun exited with code {d}. Install Xcode Command Line Tools or set SDKROOT.", .{code});
+ return error.MacosSdkPath;
+ },
+ else => {
+ std.log.err("SDL on macOS: xcrun --show-sdk-path failed", .{});
+ return error.MacosSdkPath;
+ },
+ }
+ const path = std.mem.trimEnd(u8, run.stdout, " \t\r\n");
+ if (path.len == 0) return error.MacosSdkPath;
+ return b.dupePath(path);
+}
+
+pub fn macosSdlPathsForExplicitTarget(b: *std.Build, target: std.Build.ResolvedTarget) !?MacosSdlPaths {
+ if (target.result.os.tag != .macos) return null;
+ if (b.graph.host.result.os.tag != .macos) return null;
+ if (target.query.isNative()) return null;
+
+ const sdk = try resolveMacosSdkPath(b);
+ return MacosSdlPaths{
+ .include = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/include" }) },
+ .framework = .{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) },
+ .lib = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/lib" }) },
+ };
+}
diff --git a/build/exe.zig b/build/exe.zig
new file mode 100644
index 00000000..eea65f93
--- /dev/null
+++ b/build/exe.zig
@@ -0,0 +1,303 @@
+const std = @import("std");
+const dvui = @import("dvui");
+const velopack = @import("velopack_zig");
+const plugin = @import("../plugin_sdk.zig");
+const common = @import("common.zig");
+const plugins = @import("plugins.zig");
+const sdk = @import("sdk.zig");
+
+const pixi_plugin = plugins.pixi;
+const workbench_plugin = plugins.workbench;
+const code_plugin = plugins.code;
+const example_plugin = plugins.example;
+const ZipPackage = plugins.ZipPackage;
+const MacosSdlPaths = common.MacosSdlPaths;
+
+/// Install stripped exe + built-in plugin dylibs for `vpk pack --packDir`.
+pub fn addVelopackPackDirInstall(
+ b: *std.Build,
+ exe: *std.Build.Step.Compile,
+ fizzy: FizzyExecutable,
+ pack_input_subdir: []const u8,
+ pack_plugins_subdir: []const u8,
+ after_step: *std.Build.Step,
+) *std.Build.Step {
+ const pack_exe_install_dir: std.Build.InstallDir = .{ .custom = pack_input_subdir };
+ const pack_plugins_install_dir: std.Build.InstallDir = .{ .custom = pack_plugins_subdir };
+
+ const install_pack_exe = b.addInstallArtifact(exe, .{
+ .dest_dir = .{ .override = pack_exe_install_dir },
+ });
+ install_pack_exe.step.dependOn(after_step);
+
+ var tail: *std.Build.Step = &install_pack_exe.step;
+
+ if (fizzy.pixi_dylib) |dylib| {
+ const install_pixi = plugin.installBuiltinPlugin(b, dylib, "pixi", pack_plugins_install_dir);
+ install_pixi.step.dependOn(tail);
+ tail = &install_pixi.step;
+ }
+ if (fizzy.workbench_dylib) |dylib| {
+ const install_workbench = plugin.installBuiltinPlugin(b, dylib, "workbench", pack_plugins_install_dir);
+ install_workbench.step.dependOn(tail);
+ tail = &install_workbench.step;
+ }
+ if (fizzy.code_dylib) |dylib| {
+ const install_code = plugin.installBuiltinPlugin(b, dylib, "code", pack_plugins_install_dir);
+ install_code.step.dependOn(tail);
+ tail = &install_code.step;
+ }
+
+ return tail;
+}
+
+pub const FizzyExecutable = struct {
+ exe: *std.Build.Step.Compile,
+ zstbi_module: *std.Build.Module,
+ msf_gif_module: *std.Build.Module,
+ known_folders: *std.Build.Module,
+ sdk_module: *std.Build.Module,
+ /// Native-only; `null` on wasm targets.
+ pixi_dylib: ?*std.Build.Step.Compile = null,
+ workbench_dylib: ?*std.Build.Step.Compile = null,
+ code_dylib: ?*std.Build.Step.Compile = null,
+};
+
+pub fn addFizzyExecutableForTarget(
+ b: *std.Build,
+ resolved_target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ accesskit: dvui.AccesskitOptions,
+ build_opts: *std.Build.Step.Options,
+ workbench_opts: *std.Build.Step.Options,
+ zip_pkg: ZipPackage,
+ assets_module: *std.Build.Module,
+ process_assets_step: *std.Build.Step,
+ macos_sdl_paths: ?MacosSdlPaths,
+ velopack_enabled: bool,
+) !FizzyExecutable {
+ const dvui_dep = if (macos_sdl_paths) |p|
+ b.dependency("dvui", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ .backend = .sdl3,
+ .accesskit = accesskit,
+ .system_include_path = p.include,
+ .system_framework_path = p.framework,
+ .library_path = p.lib,
+ })
+ else
+ b.dependency("dvui", .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit });
+
+ const dvui_proxy_dep = b.dependency("dvui", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ .backend = .proxy,
+ .accesskit = .off,
+ });
+ const dvui_proxy_mod = dvui_proxy_dep.module("dvui_proxy");
+ const proxy_bridge_host_mod = sdk.addProxyBridgeModule(b, resolved_target, optimize, dvui_dep, dvui_dep.module("dvui_sdl3"));
+ const proxy_bridge_plugin_mod = dvui_proxy_dep.module("proxy_bridge");
+
+ const zstbi_module = pixi_plugin.addZstbiModule(b, resolved_target, optimize, false);
+ const msf_gif_module = pixi_plugin.addMsfGifModule(b, resolved_target, optimize, false);
+
+ const exe = b.addExecutable(.{
+ .name = "fizzy",
+ .root_module = b.addModule("App", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ .root_source_file = .{ .cwd_relative = "src/App.zig" },
+ }),
+ });
+ exe.root_module.strip = false;
+
+ exe.root_module.addImport("assets", assets_module);
+ const known_folders = b.dependency("known_folders", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ }).module("known-folders");
+ exe.root_module.addImport("known-folders", known_folders);
+ exe.root_module.addOptions("build_opts", build_opts);
+ exe.step.dependOn(process_assets_step);
+
+ if (optimize != .Debug) {
+ switch (resolved_target.result.os.tag) {
+ .windows => {
+ exe.subsystem = .Windows;
+ // MSVC's libcmt links `WinMainCRTStartup` (needs `WinMain`) for /SUBSYSTEM:WINDOWS.
+ // Fizzy exposes `main`, so force the C `main` entry which works for either subsystem.
+ if (resolved_target.result.abi == .msvc) {
+ exe.entry = .{ .symbol_name = "mainCRTStartup" };
+ }
+ },
+ else => exe.subsystem = .Posix,
+ }
+ }
+
+ exe.root_module.addImport("zstbi", zstbi_module);
+ exe.root_module.addImport("msf_gif", msf_gif_module);
+ exe.root_module.addImport("zip", zip_pkg.module);
+ exe.root_module.addImport("dvui", dvui_dep.module("dvui_sdl3"));
+ exe.root_module.addImport("backend", dvui_dep.module("sdl3"));
+
+ // Shared `core` module (gfx/math/fs/generated atlas/platform/paths/dvui hub +
+ // generic widgets). Imports only `dvui`, `icons`, and `known-folders`.
+ const core_module = b.createModule(.{
+ .target = resolved_target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ });
+ core_module.addImport("dvui", dvui_dep.module("dvui_sdl3"));
+ core_module.addImport("known-folders", known_folders);
+ exe.root_module.addImport("core", core_module);
+
+ var icons_module: ?*std.Build.Module = null;
+ if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| {
+ exe.root_module.addImport("icons", dep.module("icons"));
+ core_module.addImport("icons", dep.module("icons"));
+ icons_module = dep.module("icons");
+ }
+
+ const core_proxy_module = b.createModule(.{
+ .target = resolved_target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ });
+ core_proxy_module.addImport("dvui", dvui_proxy_mod);
+ core_proxy_module.addImport("known-folders", known_folders);
+ if (icons_module) |icons| core_proxy_module.addImport("icons", icons);
+
+ const sdk_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), proxy_bridge_host_mod, core_module, exe.root_module);
+ const sdk_proxy_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_proxy_mod, proxy_bridge_plugin_mod, core_proxy_module, null);
+ _ = pixi_plugin.addStaticModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ .assets = assets_module,
+ .zip = zip_pkg.module,
+ .zstbi = zstbi_module,
+ .msf_gif = msf_gif_module,
+ .icons = icons_module,
+ .backend = dvui_dep.module("sdl3"),
+ }, exe.root_module);
+ _ = workbench_plugin.addStaticModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ .icons = icons_module,
+ .backend = dvui_dep.module("sdl3"),
+ }, workbench_opts, exe.root_module);
+ _ = code_plugin.addStaticModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ }, exe.root_module);
+ _ = example_plugin.addStaticModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ }, exe.root_module);
+
+ const pixi_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
+ const dylib = pixi_plugin.addDylib(b, resolved_target, optimize, .{
+ .dvui = dvui_proxy_mod,
+ .core = core_proxy_module,
+ .sdk = sdk_proxy_module,
+ .proxy_bridge = proxy_bridge_plugin_mod,
+ .assets = assets_module,
+ .zip = zip_pkg.module,
+ .zstbi = zstbi_module,
+ .msf_gif = msf_gif_module,
+ .icons = icons_module,
+ .backend = null,
+ });
+ pixi_plugin.linkZipNative(dylib);
+ break :blk dylib;
+ } else null;
+
+ const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
+ break :blk workbench_plugin.addDylib(b, resolved_target, optimize, .{
+ .dvui = dvui_proxy_mod,
+ .core = core_proxy_module,
+ .sdk = sdk_proxy_module,
+ .proxy_bridge = proxy_bridge_plugin_mod,
+ .icons = icons_module,
+ .backend = null,
+ }, workbench_opts);
+ } else null;
+
+ const code_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
+ break :blk code_plugin.addDylib(b, resolved_target, optimize, .{
+ .dvui = dvui_proxy_mod,
+ .core = core_proxy_module,
+ .sdk = sdk_proxy_module,
+ .proxy_bridge = proxy_bridge_plugin_mod,
+ });
+ } else null;
+
+ const singleton_app_dep = b.dependency("dvui_singleton_app", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ });
+ exe.root_module.addImport("singleton_app", singleton_app_dep.module("singleton_app"));
+
+ if (resolved_target.result.os.tag == .macos) {
+ if (macos_sdl_paths) |p| {
+ // Non-"native" macOS targets (`-Dtarget=aarch64-macos` on Apple Silicon, etc.) need the
+ // same SDK layout for Obj-C sources as for SDL; zig-objc paths do not always reach .m
+ // compiles (e.g. Security.framework → ).
+ exe.root_module.addSystemIncludePath(p.include);
+ exe.root_module.addSystemFrameworkPath(p.framework);
+ exe.root_module.addLibraryPath(p.lib);
+ }
+ if (b.lazyDependency("zig_objc", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ })) |dep| {
+ exe.root_module.addImport("objc", dep.module("objc"));
+ }
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyVisualEffectView.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyMenuTarget.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyTrackpadGesture.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyWindowMonitor.m") });
+ } else if (resolved_target.result.os.tag == .windows) {
+ if (b.lazyDependency("zigwin32", .{})) |dep| {
+ exe.root_module.addImport("win32", dep.module("win32"));
+ }
+ exe.root_module.linkSystemLibrary("comctl32", .{});
+
+ // Embed assets/windows/fizzy.rc -> fizzy.ico into the exe so Explorer,
+ // Taskbar, Alt-Tab and the Velopack-generated Start Menu shortcut all
+ // show the right icon without any runtime work. fizzy.ico must be a
+ // multi-resolution ICO with 16/32/48/256 px frames (see the README in
+ // that directory).
+ exe.root_module.addWin32ResourceFile(.{
+ .file = b.path("assets/windows/fizzy.rc"),
+ });
+ }
+
+ // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers
+ // (vcruntime_typeinfo.h's ::type_info vs libc++'s own, redefined bad_cast,
+ // etc.). We always feed MSVC's own STL via --libc for *-windows-msvc — on a
+ // cross host and on a native Windows host using .velopack-msvc alike — so
+ // libc++ must be off for the msvc ABI regardless of host.
+ const exe_is_windows_msvc = resolved_target.result.os.tag == .windows and
+ resolved_target.result.abi == .msvc;
+ exe.root_module.link_libcpp = !exe_is_windows_msvc;
+ pixi_plugin.linkZipNative(exe);
+ if (velopack_enabled) {
+ try velopack.linkVelopack(b, exe, .{ .target = resolved_target, .optimize = optimize });
+ }
+
+ return .{
+ .exe = exe,
+ .zstbi_module = zstbi_module,
+ .msf_gif_module = msf_gif_module,
+ .known_folders = known_folders,
+ .sdk_module = sdk_module,
+ .pixi_dylib = pixi_dylib,
+ .workbench_dylib = workbench_dylib,
+ .code_dylib = code_dylib,
+ };
+}
diff --git a/build/msvc.zig b/build/msvc.zig
new file mode 100644
index 00000000..a4ec853c
--- /dev/null
+++ b/build/msvc.zig
@@ -0,0 +1,107 @@
+const std = @import("std");
+
+pub fn applyMsvcTranslateCShim(b: *std.Build, roots: []const *std.Build.Step.Compile) !void {
+ var seen = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator);
+ defer seen.deinit();
+ for (roots) |root_compile| {
+ const graph = root_compile.root_module.getGraph();
+ for (graph.modules) |mod| {
+ const root_src = mod.root_source_file orelse continue;
+ const gen = switch (root_src) {
+ .generated => |g| g,
+ else => continue,
+ };
+ const dep_step = gen.file.step;
+ if (dep_step.id != .translate_c) continue;
+ const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step);
+ const gop = try seen.getOrPut(tc);
+ if (gop.found_existing) continue;
+ const rt = tc.target.result;
+ if (rt.os.tag != .windows or rt.abi != .msvc) continue;
+ // `-I` searches before `-isystem`, so this shim wins over MSVC's .
+ tc.addIncludePath(b.path("src/backend/msvc_translatec_shim"));
+ // Pre-define SIZE_MAX so MSVC's stdint.h `#ifndef SIZE_MAX` block — which would
+ // otherwise install a `0xff…ui64` literal — skips itself. Belt-and-suspenders
+ // to the shim: covers the case where another header includes through
+ // a path that bypasses our shim.
+ tc.defineCMacro("SIZE_MAX", switch (rt.ptrBitWidth()) {
+ 32 => "4294967295U",
+ 64 => "18446744073709551615ULL",
+ else => "UINT_MAX",
+ });
+ }
+ }
+}
+
+/// Finds every `Step.TranslateC` reachable from each root compile's Zig module graph and adds
+/// MSVC / Windows SDK `-isystem` paths from the zig-libc INI. We walk `Module.getGraph()` (imports)
+/// rather than `Step.dependencies`: Zig wires `root_source_file` → `TranslateC` only in
+/// `createModuleDependencies`, which runs after `build()` returns, so a step BFS from `Compile`
+/// would miss DVUI's `dvui-c` / `sdl3-c` translate steps during Configure.
+pub fn applyMsvcIncludesToReachableTranslateC(
+ b: *std.Build,
+ roots: []const *std.Build.Step.Compile,
+ libc_ini_path: []const u8,
+) !void {
+ // `libc_ini_path` is absolute (resolved via `b.pathFromRoot`), so any Dir works as the base.
+ const data = try b.build_root.handle.readFileAlloc(b.graph.io, libc_ini_path, b.allocator, .unlimited);
+
+ var include_dir: ?[]const u8 = null;
+ var sys_include_dir: ?[]const u8 = null;
+ var line_it = std.mem.splitScalar(u8, data, '\n');
+ while (line_it.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \r\t");
+ if (std.mem.startsWith(u8, line, "include_dir=")) {
+ include_dir = std.mem.trim(u8, line["include_dir=".len..], " \r\t");
+ } else if (std.mem.startsWith(u8, line, "sys_include_dir=")) {
+ sys_include_dir = std.mem.trim(u8, line["sys_include_dir=".len..], " \r\t");
+ }
+ }
+ if (include_dir == null or sys_include_dir == null) return;
+
+ // `include_dir` points at `.../Windows Kits/10/Include//ucrt`. The Windows SDK's
+ // um/shared/winrt headers live as siblings of the `ucrt` directory.
+ const sdk_inc_root = std.fs.path.dirname(include_dir.?) orelse return;
+ const um_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "um" });
+ const shared_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "shared" });
+ const winrt_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "winrt" });
+
+ var seen_translate_c = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator);
+ defer seen_translate_c.deinit();
+
+ for (roots) |root_compile| {
+ const graph = root_compile.root_module.getGraph();
+ for (graph.modules) |mod| {
+ const root_src = mod.root_source_file orelse continue;
+ const gen = switch (root_src) {
+ .generated => |g| g,
+ else => continue,
+ };
+ const dep_step = gen.file.step;
+ if (dep_step.id != .translate_c) continue;
+
+ const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step);
+ const gop = try seen_translate_c.getOrPut(tc);
+ if (gop.found_existing) continue;
+
+ const rt = tc.target.result;
+ if (rt.os.tag == .windows and rt.abi == .msvc) {
+ // `translate-c` has no API to pass `--libc `, so `-lc` makes Zig
+ // auto-detect a system MSVC/SDK install — which fails on a Windows host
+ // that has no Visual Studio (we use the .velopack-msvc/ tree instead) with
+ // `WindowsSdkNotFound`. Drop `-lc` here: every MSVC/UCRT/SDK include dir is
+ // added explicitly below, so the headers still resolve, and the consuming
+ // exe links libc itself — the translated bindings don't need their own.
+ tc.link_libc = false;
+ // Shim + SIZE_MAX define are applied separately by `applyMsvcTranslateCShim`.
+ // Order matters: MSVC's own headers first (override Windows SDK declarations
+ // when both exist), then UCRT, then the Windows SDK trio.
+ tc.addSystemIncludePath(.{ .cwd_relative = sys_include_dir.? });
+ tc.addSystemIncludePath(.{ .cwd_relative = include_dir.? });
+ tc.addSystemIncludePath(.{ .cwd_relative = um_dir });
+ tc.addSystemIncludePath(.{ .cwd_relative = shared_dir });
+ tc.addSystemIncludePath(.{ .cwd_relative = winrt_dir });
+ }
+ }
+ }
+}
diff --git a/build/package.zig b/build/package.zig
new file mode 100644
index 00000000..267b5241
--- /dev/null
+++ b/build/package.zig
@@ -0,0 +1,261 @@
+const std = @import("std");
+const velopack = @import("velopack_zig");
+const exe = @import("exe.zig");
+
+pub const Options = struct {
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ app_version: []const u8,
+ zig_out_subdir: []const u8,
+ zig_out_install_dir: std.Build.InstallDir,
+ no_emit: bool,
+ velopack_required_fail: ?*std.Build.Step,
+ exe_for_package: *std.Build.Step.Compile,
+ package_fizzy: exe.FizzyExecutable,
+ macos_sign_app_identity: ?[]const u8,
+ macos_sign_install_identity: ?[]const u8,
+ macos_notary_profile: ?[]const u8,
+ windows_msvc_libc_opt: ?[]const u8,
+ fetch_msvc: bool,
+};
+
+pub fn addSteps(opts: Options) *std.Build.Step {
+ const b = opts.b;
+ const target = opts.target;
+ const optimize = opts.optimize;
+ const app_version = opts.app_version;
+ const zig_out_subdir = opts.zig_out_subdir;
+ const zig_out_install_dir = opts.zig_out_install_dir;
+ const no_emit = opts.no_emit;
+ const velopack_required_fail = opts.velopack_required_fail;
+ const exe_for_package = opts.exe_for_package;
+ const package_fizzy = opts.package_fizzy;
+ const macos_sign_app_identity = opts.macos_sign_app_identity;
+ const macos_sign_install_identity = opts.macos_sign_install_identity;
+ const macos_notary_profile = opts.macos_notary_profile;
+ const windows_msvc_libc_opt = opts.windows_msvc_libc_opt;
+ const fetch_msvc = opts.fetch_msvc;
+
+ const package_step = b.step("package", "Velopack release artifacts (strip + vpk); not part of install or run");
+ // The default native target on a Windows host resolves to x86_64-windows-gnu,
+ // for which `velopack_supported_for_target` is false — exe_for_package falls
+ // back to the plain (Velopack-less) exe. vpk would still wrap it as a Velopack
+ // installer, but the install hook never runs: Setup.exe hangs with "the
+ // application install hook failed". Fail loudly instead of shipping that trap.
+ const windows_non_msvc = target.result.os.tag == .windows and target.result.abi != .msvc;
+ if (velopack_required_fail) |fail_step| {
+ package_step.dependOn(fail_step);
+ } else if (windows_non_msvc) {
+ package_step.dependOn(&b.addFail(
+ \\`zig build package` for Windows requires the MSVC ABI so Velopack is linked.
+ \\The default native target resolves to x86_64-windows-gnu, which builds a binary
+ \\WITHOUT the Velopack runtime. vpk would still wrap it as a Velopack installer, but
+ \\the install hook never runs and Setup.exe hangs ("the application install hook failed").
+ \\
+ \\Build with the MSVC target instead:
+ \\ zig build package -Dtarget=x86_64-windows-msvc -Dfetch-msvc
+ \\(needs Windows SDK 10.0.26100+ for SDL's GameInput backend.)
+ ).step);
+ } else if (no_emit) {
+ package_step.dependOn(&b.addFail("cannot run `package` with -Dno-emit").step);
+ } else switch (target.result.os.tag) {
+ .linux, .macos, .windows => {
+ // Host strip can't process foreign object files when cross-compiling.
+ const cross_os = target.result.os.tag != b.graph.host.result.os.tag;
+ // Same-OS / different-arch (e.g. aarch64-linux from x86_64-linux) also
+ // breaks host strip — it errors with "Unable to recognise the format".
+ const cross_for_strip = cross_os or target.result.cpu.arch != b.graph.host.result.cpu.arch;
+ // Windows hosts don't ship `strip` or `touch`. Skip the external strip
+ // step entirely there — Zig's linker already drops debug info in
+ // release builds. Use `cmd /c exit 0` as the no-op and keep the
+ // dependency on exe_for_package via the step graph.
+ const host_is_windows = b.graph.host.result.os.tag == .windows;
+ const skip_strip = host_is_windows or optimize == .Debug or cross_for_strip;
+ const strip_release_sh = if (host_is_windows) blk: {
+ const sh = b.addSystemCommand(&.{ "cmd", "/c", "exit", "0" });
+ sh.step.dependOn(&exe_for_package.step);
+ break :blk sh;
+ } else blk: {
+ const sh = b.addSystemCommand(&.{if (skip_strip) "touch" else "strip"});
+ sh.addFileArg(exe_for_package.getEmittedBin());
+ break :blk sh;
+ };
+
+ //const dotnet_tool_restore = velopack.addDotnetToolRestoreStep(b);
+ //const vpk_vendor_repair = velopack.addVpkVendorRepairStep(b);
+ //vpk_vendor_repair.step.dependOn(&dotnet_tool_restore.step);
+
+ const vpk_pkg_sh = b.addSystemCommand(&.{"dotnet"});
+ vpk_pkg_sh.addArg("vpk");
+ // When packaging a foreign-OS bundle, vpk needs an OS directive (e.g. `vpk [win] pack ...`)
+ // because by default it auto-detects from the host OS.
+ if (cross_os) {
+ vpk_pkg_sh.addArg(switch (target.result.os.tag) {
+ .windows => "[win]",
+ .linux => "[linux]",
+ .macos => "[osx]",
+ else => unreachable,
+ });
+ }
+ vpk_pkg_sh.addArg("pack");
+ vpk_pkg_sh.addArg("--packId");
+ vpk_pkg_sh.addArg("fizzy");
+ vpk_pkg_sh.addArg("--packVersion");
+ vpk_pkg_sh.addArg(app_version);
+ // Channel = zig-out subdir (`-`, NuGet-safe — no underscores). Baked into
+ // the binary by vpk; the updater matches this to release assets. Distinct per triple
+ // so parallel `vpk pack` runs don't collide on RELEASES / nupkg names.
+ vpk_pkg_sh.addArg("--channel");
+ vpk_pkg_sh.addArg(zig_out_subdir);
+ vpk_pkg_sh.addArg("--mainExe");
+ vpk_pkg_sh.addArg(switch (target.result.os.tag) {
+ .windows => "fizzy.exe",
+ else => "fizzy",
+ });
+
+ vpk_pkg_sh.addArg("--delta");
+ vpk_pkg_sh.addArg("None");
+ vpk_pkg_sh.addArg("--yes");
+
+ vpk_pkg_sh.addArg("--outputDir");
+ // `addOutputDirectoryArg` takes a basename — Zig manages the actual
+ // path under the run step's cache dir. The `addInstallDirectory`
+ // below copies that into zig-out//. Previously this passed
+ // the full install path, which produced `.zig-cache\o\\C:\...`
+ // on Windows (BadPathName).
+ const vpk_pkg_out_dir = vpk_pkg_sh.addOutputDirectoryArg("desktop");
+ // Stage exe + built-in plugin dylibs under zig-out//.pack-input/
+ // so vpk ships plugins/ next to the main binary.
+ const pack_input_subdir = b.fmt("{s}/.pack-input", .{zig_out_subdir});
+ const pack_plugins_subdir = b.fmt("{s}/.pack-input/plugins", .{zig_out_subdir});
+ const pack_stage_tail = exe.addVelopackPackDirInstall(
+ b,
+ exe_for_package,
+ package_fizzy,
+ pack_input_subdir,
+ pack_plugins_subdir,
+ &strip_release_sh.step,
+ );
+ vpk_pkg_sh.addArg("--packDir");
+ vpk_pkg_sh.addArg(b.getInstallPath(.{ .custom = pack_input_subdir }, ""));
+ switch (target.result.os.tag) {
+ .windows => {
+ // Sets the installer's icon and the Start Menu shortcut icon. The
+ // exe's own icon is already embedded via assets/windows/fizzy.rc.
+ vpk_pkg_sh.addArg("--icon");
+ const ico_path = b.path("assets/windows/fizzy.ico").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("ico path: {}", .{e});
+ vpk_pkg_sh.addArg(ico_path);
+ // Velopack's installer is silent (no shortcut-choice UI). Default is
+ // Desktop,StartMenu; restrict to StartMenu so we don't drop an
+ // unrequested icon on the user's desktop.
+ vpk_pkg_sh.addArg("--shortcuts");
+ vpk_pkg_sh.addArg("StartMenu");
+ },
+ .macos => {
+ vpk_pkg_sh.addArg("--packTitle");
+ vpk_pkg_sh.addArg("fizzy");
+ // Bundle id / document types / versions: assets/macos/info.plist (vpk rejects --bundleId with --plist).
+ vpk_pkg_sh.addArg("--plist");
+ const plist_path = b.path("assets/macos/info.plist").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("plist path: {}", .{e});
+ vpk_pkg_sh.addArg(plist_path);
+ vpk_pkg_sh.addArg("--icon");
+ const icns_path = b.path("assets/macos/fizzy.icns").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("icns path: {}", .{e});
+ vpk_pkg_sh.addArg(icns_path);
+
+ if (macos_sign_app_identity) |id| {
+ vpk_pkg_sh.addArg("--signAppIdentity");
+ vpk_pkg_sh.addArg(id);
+ // Required for notarization: enables hardened runtime + secure timestamp on
+ // every nested binary (vpk forwards the file to `codesign --entitlements`).
+ // Without this, Apple's notary service rejects with "signature does not
+ // include a secure timestamp" / "hardened runtime not enabled".
+ vpk_pkg_sh.addArg("--signEntitlements");
+ const entitlements_path = b.path("assets/macos/Fizzy.entitlements").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("entitlements path: {}", .{e});
+ vpk_pkg_sh.addArg(entitlements_path);
+ }
+ if (macos_sign_install_identity) |id| {
+ vpk_pkg_sh.addArg("--signInstallIdentity");
+ vpk_pkg_sh.addArg(id);
+ }
+ if (macos_notary_profile) |profile| {
+ vpk_pkg_sh.addArg("--notaryProfile");
+ vpk_pkg_sh.addArg(profile);
+ }
+ },
+ else => {},
+ }
+ vpk_pkg_sh.setEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major");
+ // Stream vpk's stdout/stderr live so failures surface their actual
+ // diagnostic instead of just an exit-code-N message from the build
+ // runner. With `addOutputDirectoryArg` in play, `infer_from_args`
+ // can otherwise capture+drop stdio on certain runner configs.
+ vpk_pkg_sh.stdio = .inherit;
+ try velopack.attachMksquashfsToVpkRun(b, vpk_pkg_sh, target);
+
+ //vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step);
+ vpk_pkg_sh.step.dependOn(pack_stage_tail);
+
+ const build_package_install = b.addInstallDirectory(.{
+ .source_dir = vpk_pkg_out_dir,
+ .install_dir = zig_out_install_dir,
+ .install_subdir = "",
+ });
+
+ package_step.dependOn(&build_package_install.step);
+ },
+ else => {
+ package_step.dependOn(&b.addFail("Velopack packaging is only supported for Linux, macOS, and Windows targets").step);
+ },
+ }
+
+ const desktop_step = b.step("desktop", "Alias for `zig build package`");
+ desktop_step.dependOn(package_step);
+
+ const packageall_step = b.step("packageall", "Six zig build package runs; use -Dwindows-msvc-libc= or -Dfetch-msvc for Windows children from macOS/Linux");
+ if (no_emit) {
+ packageall_step.dependOn(&b.addFail("cannot run `packageall` with -Dno-emit").step);
+ } else {
+ const packageall_optimize_arg = b.fmt("-Doptimize={s}", .{@tagName(optimize)});
+
+ // Build order is deliberately fail-fast: Windows first (most likely to
+ // fail on a fresh CI runner because of MSVC SDK setup, libc.ini paths,
+ // and cross-compile ABI surprises), then Linux (mksquashfs / AppImage
+ // packaging quirks), then macOS last (native, lowest risk). When a
+ // release run is going to break, this ordering surfaces the failure
+ // 5-10 minutes sooner than the alphabetical order did.
+ const packageall_triples = [_][]const u8{
+ "x86_64-windows-msvc",
+ "aarch64-windows-msvc",
+ "x86_64-linux-gnu",
+ "aarch64-linux-gnu",
+ "x86_64-macos",
+ "aarch64-macos",
+ };
+
+ var prev_step: ?*std.Build.Step = null;
+ for (packageall_triples) |triple| {
+ const zig_pkg_run = b.addSystemCommand(&.{
+ b.graph.zig_exe,
+ "build",
+ "package",
+ packageall_optimize_arg,
+ b.fmt("-Dtarget={s}", .{triple}),
+ });
+ if (std.mem.endsWith(u8, triple, "-windows-msvc")) {
+ if (windows_msvc_libc_opt) |libc_path| {
+ zig_pkg_run.addArg(b.fmt("-Dwindows-msvc-libc={s}", .{libc_path}));
+ }
+ if (fetch_msvc) zig_pkg_run.addArg("-Dfetch-msvc");
+ }
+ zig_pkg_run.setCwd(b.path("."));
+ if (prev_step) |p| {
+ zig_pkg_run.step.dependOn(p);
+ }
+ prev_step = &zig_pkg_run.step;
+ }
+ packageall_step.dependOn(prev_step.?);
+ }
+
+ return package_step;
+}
diff --git a/build/plugins.zig b/build/plugins.zig
new file mode 100644
index 00000000..707ab413
--- /dev/null
+++ b/build/plugins.zig
@@ -0,0 +1,12 @@
+//! Built-in plugin build integration — the static-embed + bundled-dylib module graph.
+//!
+//! Each built-in plugin keeps its fizzy-internal static-embed glue self-contained in
+//! `src/plugins//static/integration.zig`, separate from the canonical third-party files
+//! at the plugin-folder root (the shell's `@import("")` resolves to the root
+//! `.zig`). Fizzy root aggregates those integration files here.
+pub const pixi = @import("../src/plugins/pixi/static/integration.zig");
+pub const workbench = @import("../src/plugins/workbench/static/integration.zig");
+pub const code = @import("../src/plugins/code/static/integration.zig");
+pub const example = @import("../src/plugins/example/static/integration.zig");
+
+pub const ZipPackage = pixi.ZipPackage;
diff --git a/build/sdk.zig b/build/sdk.zig
new file mode 100644
index 00000000..82490947
--- /dev/null
+++ b/build/sdk.zig
@@ -0,0 +1,38 @@
+const std = @import("std");
+
+pub fn addProxyBridgeModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ dvui_dep: *std.Build.Dependency,
+ dvui_module: *std.Build.Module,
+) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = dvui_dep.path("src/backends/proxy_bridge.zig"),
+ });
+ mod.addImport("dvui", dvui_module);
+ return mod;
+}
+
+pub fn wireSdkModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ dvui_module: *std.Build.Module,
+ proxy_bridge_module: *std.Build.Module,
+ core_module: *std.Build.Module,
+ consumer: ?*std.Build.Module,
+) *std.Build.Module {
+ const sdk_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/sdk/sdk.zig"),
+ });
+ sdk_module.addImport("dvui", dvui_module);
+ sdk_module.addImport("proxy_bridge", proxy_bridge_module);
+ sdk_module.addImport("core", core_module);
+ if (consumer) |c| c.addImport("sdk", sdk_module);
+ return sdk_module;
+}
diff --git a/build/web.zig b/build/web.zig
new file mode 100644
index 00000000..5d2ab5e2
--- /dev/null
+++ b/build/web.zig
@@ -0,0 +1,210 @@
+const std = @import("std");
+const plugins = @import("plugins.zig");
+const sdk = @import("sdk.zig");
+
+const pixi_plugin = plugins.pixi;
+const workbench_plugin = plugins.workbench;
+const code_plugin = plugins.code;
+const example_plugin = plugins.example;
+
+pub fn addSteps(
+ b: *std.Build,
+ optimize: std.builtin.OptimizeMode,
+ build_opts: *std.Build.Step.Options,
+ workbench_opts: *std.Build.Step.Options,
+ zip_pkg: plugins.ZipPackage,
+ assets_module: *std.Build.Module,
+) void {
+ const web_target = b.resolveTargetQuery(.{
+ .cpu_arch = .wasm32,
+ .os_tag = .freestanding,
+ .cpu_features_add = std.Target.wasm.featureSet(&.{
+ .atomics,
+ .multivalue,
+ .bulk_memory,
+ }),
+ });
+
+ const dvui_web_dep = b.dependency("dvui", .{
+ .target = web_target,
+ .optimize = optimize,
+ .backend = .web,
+ .freetype = false,
+ });
+ const dvui_web_proxy_bridge = sdk.addProxyBridgeModule(b, web_target, optimize, dvui_web_dep, dvui_web_dep.module("dvui_web"));
+
+ const web_exe = b.addExecutable(.{
+ .name = "web",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/web_main.zig"),
+ .target = web_target,
+ .optimize = optimize,
+ .link_libc = false,
+ .single_threaded = true,
+ .strip = optimize == .ReleaseFast or optimize == .ReleaseSmall,
+ }),
+ });
+ web_exe.entry = .disabled;
+ web_exe.root_module.addImport("dvui", dvui_web_dep.module("dvui_web"));
+ web_exe.root_module.addImport("web-backend", dvui_web_dep.module("web"));
+
+ // Extra wasm exports beyond dvui's own (`dvui_init`/`dvui_update`/etc.). The wasm
+ // linker only emits symbols listed here, so `export fn` in Zig isn't enough on its
+ // own — without this line our trackpad pinch entry point would compile cleanly but
+ // be missing from `instance.exports`, and the JS bootstrap in `web/shell.html`
+ // would never be able to forward pinch deltas into the canvas widget.
+ web_exe.root_module.export_symbol_names = &[_][]const u8{
+ "FizzyWebTrackpadMagnification",
+ };
+
+ // `icons` (pure-Zig icon data) is referenced at file scope in
+ // `src/dvui.zig` and `src/editor/Infobar.zig`. Wired in so any future
+ // wasm-reachable code that pulls those files in compiles cleanly.
+ if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| {
+ web_exe.root_module.addImport("icons", dep.module("icons"));
+ }
+
+ // `assets` is generated at build time by assetpack (pure `@embedFile`s,
+ // target-independent). Same instance as native — no extra build cost.
+ web_exe.root_module.addImport("assets", assets_module);
+
+ // `build_opts` (app_version, app_repo_url, velopack_enabled) — shared
+ // with native. velopack_enabled is whatever was passed via `-Dvelopack`;
+ // wasm path is gated by `arch != .wasm32` in `auto_update.impl`.
+ web_exe.root_module.addOptions("build_opts", build_opts);
+
+ // `zip` — Zig decls + miniz/zip.c compiled for wasm with `fizzy_zip_libc.c`
+ // (malloc → dvui_c_alloc). Enables `zip_stream_*` for .fiz open/save in browser.
+ web_exe.root_module.addImport("zip", zip_pkg.module);
+ pixi_plugin.linkZipWasm(web_exe);
+
+ // `known-folders` is referenced at file scope in a few editor files
+ // (AboutFizzy, Editor settings paths). It's a pure-Zig wrapper for
+ // OS-specific user-directory APIs — the file compiles fine on wasm even
+ // though runtime calls would fail (which we'll never reach on web).
+ const known_folders_web = b.dependency("known_folders", .{
+ .target = web_target,
+ .optimize = optimize,
+ }).module("known-folders");
+ web_exe.root_module.addImport("known-folders", known_folders_web);
+
+ // Shared `core` module for the wasm build (dvui web backend variant).
+ const core_module_web = b.createModule(.{
+ .target = web_target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ .link_libc = false,
+ .single_threaded = true,
+ });
+ core_module_web.addImport("dvui", dvui_web_dep.module("dvui_web"));
+ core_module_web.addImport("known-folders", known_folders_web);
+ if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| {
+ core_module_web.addImport("icons", dep.module("icons"));
+ }
+ web_exe.root_module.addImport("core", core_module_web);
+ const sdk_module_web = sdk.wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), dvui_web_proxy_bridge, core_module_web, web_exe.root_module);
+
+ // Three editor files have `const sdl3 = @import("backend").c;` at file
+ // scope. After refactoring all `sdl3.SDL_DialogFileFilter` references
+ // to `fizzy.backend.DialogFileFilter`, those decls became dead — Zig's
+ // lazy analysis skips file-scope consts that no reachable body uses.
+ // So no `backend` module is wired in for the web build.
+
+ const zstbi_web_lib_module = pixi_plugin.addZstbiModule(b, web_target, optimize, true);
+ web_exe.root_module.addImport("zstbi", zstbi_web_lib_module);
+
+ const msf_gif_web_lib_module = pixi_plugin.addMsfGifModule(b, web_target, optimize, true);
+ web_exe.root_module.addImport("msf_gif", msf_gif_web_lib_module);
+
+ _ = pixi_plugin.addStaticModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ .assets = assets_module,
+ .zip = zip_pkg.module,
+ .zstbi = zstbi_web_lib_module,
+ .msf_gif = msf_gif_web_lib_module,
+ .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .backend = null,
+ }, web_exe.root_module);
+ _ = workbench_plugin.addStaticModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .backend = null,
+ }, workbench_opts, web_exe.root_module);
+ _ = code_plugin.addStaticModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ }, web_exe.root_module);
+ _ = example_plugin.addStaticModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ }, web_exe.root_module);
+
+ const web_install_dir: std.Build.InstallDir = .{ .custom = "web" };
+ const install_wasm = b.addInstallArtifact(web_exe, .{
+ .dest_dir = .{ .override = web_install_dir },
+ });
+
+ // Cache-buster: stamps a 64-char hash into the index.html / web.js placeholders so
+ // the browser picks up new wasm builds without manual hard-reloads. Re-implements
+ // upstream DVUI's `addWebExample` machinery so we don't have to invoke its step.
+ const cb = b.addExecutable(.{
+ .name = "cacheBuster",
+ .root_module = b.createModule(.{
+ .root_source_file = dvui_web_dep.path("src/cacheBuster.zig"),
+ .target = b.graph.host,
+ }),
+ });
+ const cb_run = b.addRunArtifact(cb);
+ cb_run.addFileArg(b.path("web/shell.html"));
+ cb_run.addFileArg(dvui_web_dep.path("src/backends/web.js"));
+ cb_run.addFileArg(web_exe.getEmittedBin());
+ const index_html_with_hash = cb_run.captureStdOut(.{});
+
+ const web_step = b.step("web", "Build the fizzy web (wasm) app into zig-out/web/");
+ web_step.dependOn(&install_wasm.step);
+ web_step.dependOn(&b.addInstallFileWithDir(
+ index_html_with_hash,
+ web_install_dir,
+ "index.html",
+ ).step);
+ web_step.dependOn(&b.addInstallFileWithDir(
+ dvui_web_dep.path("src/backends/web.js"),
+ web_install_dir,
+ "web.js",
+ ).step);
+ web_step.dependOn(&b.addInstallFileWithDir(
+ dvui_web_dep.path("src/fonts/NotoSansKR-Regular.ttf"),
+ web_install_dir,
+ "NotoSansKR-Regular.ttf",
+ ).step);
+
+ // Compile-only smoke check for the wasm target. Pairs with `check` (unit
+ // tests). Catches regressions where someone reaches a wasm-incompatible
+ // code path (thread spawn, std.posix surface, missing module import)
+ // from the wasm root. No install — just compile.
+ const check_web_step = b.step("check-web", "Compile fizzy web (wasm) without installing artifacts");
+ check_web_step.dependOn(&web_exe.step);
+
+ // Copy zig-out/web into web/app/ for local preview at the production
+ // `/app/` path: `cd web && python3 -m http.server` then open
+ // http://localhost:8000/app/. The landing page lives in fizzyedit/website.
+ const web_docs_step = b.step("web-docs", "Build web app and copy into web/app/ for local /app/ preview");
+ web_docs_step.dependOn(web_step);
+ const cp_web_to_docs = b.addSystemCommand(&.{ "sh", "-c" });
+ cp_web_to_docs.addArg("mkdir -p web/app && cp -R zig-out/web/. web/app/");
+ cp_web_to_docs.step.dependOn(web_step);
+ web_docs_step.dependOn(&cp_web_to_docs.step);
+
+ const serve_web_cmd = b.addSystemCommand(&.{ "sh", "scripts/serve-web.sh" });
+ serve_web_cmd.step.dependOn(web_step);
+ _ = b.step(
+ "serve-web",
+ "Serve zig-out/web at http://127.0.0.1:8765/ (builds web first; frees stale :8765)",
+ ).dependOn(&serve_web_cmd.step);
+}
diff --git a/contributor.md b/contributor.md
deleted file mode 100644
index 9bde4d85..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/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.
- - ***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/docs/PLUGINS.md b/docs/PLUGINS.md
new file mode 100644
index 00000000..a5c3b451
--- /dev/null
+++ b/docs/PLUGINS.md
@@ -0,0 +1,684 @@
+# 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
+
+### 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
+
+```
+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) + Plugin vtable; owns its State
+ State.zig # optional but typical
+ …
+```
+
+No storage/`Globals` file: the host injects the allocator + `*Host` into the SDK, so plugin
+code reads them through `sdk.allocator()` / `sdk.host()`. The in-repo
+[`example`](../src/plugins/example/) plugin is a complete minimal example you can copy;
+[markdown](https://github.com/fizzyedit/markdown) is an external one.
+
+### What each file must contain
+
+#### `root.zig` (third-party only — copy, don't invent)
+
+The entire dylib entry is one call to `sdk.dylib.exportEntry`, which emits the five C
+symbols the host looks up:
+
+```zig
+const sdk = @import("sdk");
+
+comptime {
+ sdk.dylib.exportEntry(@import("src/plugin.zig"));
+}
+```
+
+| Export | Purpose |
+|--------|---------|
+| `fizzy_plugin_abi_fingerprint` | Must match host or load is rejected |
+| `fizzy_plugin_register` | Calls your `src/plugin.zig` `register(host)` |
+| `fizzy_plugin_set_dvui_context` | Host injects live dvui window/io before draw |
+| `fizzy_plugin_set_render_bridge` | Host injects dvui proxy render bridge |
+| `fizzy_plugin_set_globals` | Host injects allocator + `*Host` into the SDK (`sdk.allocator()` / `sdk.host()`) |
+
+Copy **`fizzy/src/plugins/root.zig`** into your project root; the `@import("src/plugin.zig")`
+is relative to **your** tree (not fizzy's). The export bodies live in the SDK
+(`sdk.dylib.exportEntry`), so there is nothing to maintain or keep in sync here.
+
+Built-in plugins use this **same** `root.zig` (their dylib build goes through it too); they no
+longer carry a separate `dylib.zig` or typed `Globals.zig` — they read `sdk.allocator()` /
+`sdk.host()` exactly like a third-party plugin.
+
+#### `src/plugin.zig` — **the contract you own**
+
+Must provide:
+
+1. A **`sdk.Plugin` value** — stable `id` (snake_case), `display_name`, `vtable`, and
+ `state` (set during `register`).
+2. **`pub fn register(host: *sdk.Host) !void`** — wire `plugin.state`, call
+ `host.registerPlugin(&plugin)`, then any `host.registerSidebarView` /
+ `registerBottomView` / `registerCenterProvider` / `registerMenu` /
+ `registerSettingsSection` / `registerService` contributions.
+3. A **`vtable: sdk.Plugin.VTable`** — only fill hooks your plugin needs; unset fields
+ stay `null`.
+
+Minimal skeleton (registers identity only — no documents, no panes):
+
+```zig
+const sdk = @import("sdk");
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .id = "my_plugin",
+ .display_name = "My Plugin",
+};
+
+const vtable: sdk.Plugin.VTable = .{
+ .deinit = deinit,
+};
+
+var plugin_state: State = .{}; // your own singleton; the SDK holds gpa/host for you
+
+pub fn register(host: *sdk.Host) !void {
+ plugin.state = @ptrCast(&plugin_state);
+ try host.registerPlugin(&plugin);
+}
+
+fn deinit(_: *anyopaque) void { plugin_state.deinit(sdk.allocator()); }
+```
+
+**Editor plugins** (open/save/draw files) also implement document vtable hooks —
+`fileTypePriority`, `loadDocument`, `drawDocument`, `saveDocument`, `isDirty`, etc.
+**Shell plugins** (workbench-style) skip document hooks and instead register a center
+provider or sidebar views. See `Plugin.VTable` in [`src/sdk/Plugin.zig`](../src/sdk/Plugin.zig)
+for the full hook list.
+
+#### Runtime access — **no storage file**
+
+The shell cannot be imported from plugin code, so the host pushes the allocator and the
+`*Host` across the dylib boundary at load (`fizzy_plugin_set_globals`). `exportEntry`
+catches them **into the SDK itself**, so plugin code just reads:
+
+- **`sdk.allocator()`** — the persistent host allocator (see *Memory* below).
+- **`sdk.host()`** — the shell `*Host`: registries, services, and the `EditorAPI` read
+ surface (open folder, active doc, arena allocator, save dialogs).
+
+Your **own** state is just a variable you own. A singleton is a module-level `var`:
+
+```zig
+var plugin_state: State = .{};
+// in register: plugin.state = @ptrCast(&plugin_state);
+// in deinit: plugin_state.deinit(sdk.allocator());
+```
+
+If your plugin uses `core`'s allocating helpers (most don't), sync that module's allocator
+once in `register`: `core.gpa = sdk.allocator();`.
+
+Built-in plugins do the same — they call `register(&host)` directly at startup and read
+`sdk.allocator()` / `sdk.host()`. (Earlier built-ins kept a typed `Globals.zig` poked from
+`App.zig`; that is gone — there is one injection path for everyone now.)
+
+#### `build.zig` / `build.zig.zon` (third-party)
+
+`build.zig.zon` — declare **fizzy** as the only shell dependency (dvui arrives
+transitively). List every shipped path in `.paths` (`root.zig`, `src`, …).
+
+`build.zig` — call `fizzy.plugin.create`, attach any extra libs on `lib.root_module`, then
+`fizzy.plugin.install` (which renames Zig's `libplugin.dylib` output to the `plugin.`
+the loader scans for — no shell `cp`/`mkdir`, works on every host):
+
+```zig
+const lib = fizzy.plugin.create(b, .{
+ .target = target,
+ .optimize = optimize,
+});
+lib.root_module.linkLibrary(…);
+lib.root_module.addIncludePath(…);
+fizzy.plugin.install(b, lib, .{});
+```
+
+Then `zig build install --prefix /` lands `plugin.` where the host
+expects it, e.g. `~/.config/fizzy/plugins//plugin.dylib` (macOS:
+`~/Library/Application Support/fizzy/plugins//plugin.dylib`).
+
+### Import discipline
+
+Files inside `src/**` must **not** `@import("fizzy")` or reach into the shell. Allowed:
+
+- `@import("sdk")`, `@import("core")`, `@import("dvui")` — wired on the dylib module by
+ `fizzy.plugin.create`
+- `@import("State.zig")`, … — sibling files in your `src/` tree
+- Built-in only: `@import("../.zig")` for an optional local hub file
+
+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 = @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
+ 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 — the universal editor protocol
+
+`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` /
+ `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 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`.
+- **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
+});
+```
+
+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`.
+
+**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 — 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
+
+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_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 the allocator +
+ `*Host` (into the SDK) and its live dvui context into the plugin image (host and plugin each
+ compile their own `dvui`/`sdk`/`core`; the host's pointers are pushed in before draw/tick).
+
+There is **no ABI version to bump.** `dylib.abi_fingerprint` is a compile-time structural hash
+over every type that crosses the boundary — the `Host`/`Plugin`/`DocHandle`/`EditorAPI` vtables,
+the dvui types passed through them, and the C entry-symbol signatures (see `src/sdk/fingerprint.zig`).
+Host and plugin each compute it from their own sources, so changing a vtable hook, a boundary
+struct's layout, or the dvui dependency changes the hash automatically and stale plugins are
+rejected at load. If you add a brand-new struct that crosses the boundary by value, add it to the
+root list in `dylib.zig` so its layout is folded in.
+
+### Third-party quick start
+
+Fastest path: **copy the in-repo [`example`](../src/plugins/example/) plugin folder**, rename
+the id/name, and replace `src/plugin.zig` with your feature. It is the canonical, always-
+compiling template and already has every required file in the right place. See **Required
+files**, **Layout**, and **What each file must contain** above. In short:
+
+1. Copy `fizzy/src/plugins/root.zig` (or `example/root.zig`) → `root.zig` (one `exportEntry`
+ call, never edited).
+2. Implement `src/plugin.zig` (`register` + vtable). Read the host allocator + `*Host` via
+ `sdk.allocator()` / `sdk.host()`; own your state as a plain `var`. No storage file.
+3. Add `build.zig` / `build.zig.zon` with a `fizzy` dependency, `fizzy.plugin.create`, and
+ `fizzy.plugin.install`.
+4. `zig build install --prefix //` so the host finds `plugin.`.
+
+`fizzy.plugin.create` options:
+
+| Option | Default | When to override |
+|--------|---------|------------------|
+| `root_source_file` | `root.zig` | Dylib entry is not at project root or not named `root.zig` |
+| `name` | `"plugin"` | Dylib artifact name (output is still `plugin.dylib` when installed) |
+
+Pin the **fizzy** dependency to the same revision as the host you run against; ABI
+mismatch surfaces as a failed load at `fizzy_plugin_abi_fingerprint`, not a semver check.
+
+### How built-in plugins are wired (fizzy-internal)
+
+The in-tree plugins (pixi, workbench, code, example) ship inside the signed app and compile
+**two ways** — statically into the native/web/test binaries *and* (for desktop) as a bundled
+dylib. **Their folder is, file-for-file, the same canonical third-party shape** described
+above (`build.zig` via `fizzy.plugin.create`, `build.zig.zon`, `root.zig` → `src/plugin.zig`,
+`src/…`), and each builds standalone with `cd src/plugins/ && zig build`. There is no
+embed-stub `build.zig` and no `build_standalone.zig` anymore.
+
+All the fizzy-internal glue is separated out so it never mixes into the plugin contract:
+
+```
+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
+
+**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/root.zig` | Stock dylib entry template — copy to third-party projects as `root.zig` |
+| `src/plugins/pixelart/` | Reference editor plugin (pixi id; owns documents, renders canvas) |
+| `src/plugins/workbench/` | Reference file-management plugin (tree + tabs/splits + service) |
+| `src/sdk/version.zig` | SDK version + ABI fingerprint CI lock |
+| `src/sdk/manifest.zig` | `PluginManifest` embedded in dylibs |
+| `src/sdk/document.zig` | Document staging helpers for editor plugins |
+| `templates/` | Author starter templates (editor / utility profiles) |
+
+---
+
+## Compatibility & versions
+
+Fizzy uses three independent **versions**:
+
+| Version | Owner | Purpose |
+|---------|-------|---------|
+| **App version** | Fizzy release (`build.zig.zon`) | User-facing editor release; does **not** gate plugin loading |
+| **SDK version** | `src/sdk/version.zig` | ABI contract; bumps when the plugin boundary changes |
+| **Plugin version** | Author `PluginManifest.version` | Plugin's own release semver |
+
+At load time the host checks, in order:
+
+1. **ABI fingerprint** (`fizzy_plugin_abi_fingerprint`) — hard reject on mismatch (memory safety)
+2. **SDK version** — `host.sdk_version` must satisfy `plugin.min_sdk_version`
+3. **Stale build warning** (debug) — optional soft warning when `built_with_sdk_version < host`
+
+CI enforces that any ABI fingerprint change updates `sdk_version` and `recorded_abi_fingerprint` together (`zig build test-sdk-version`).
+
+### Plugin dylib layout
+
+User and built-in plugins install as a **flat** file:
+
+```
+{config}/plugins/{id}.dylib # macOS
+{config}/plugins/{id}.so # Linux
+{config}/plugins/{id}.dll # Windows
+{exe}/plugins/{id}.{ext} # bundled built-ins
+```
+
+The declared `manifest.id` must match the filename basename. There is no legacy `{id}/plugin.dylib` layout.
+
+### Config folders (lowercase)
+
+```
+{config}/plugins/
+{config}/palettes/
+{config}/themes/
+```
+
+### Plugin manifest (dylib + optional sidecar)
+
+Each plugin embeds metadata via C exports from `PluginManifest`. Optional sidecar for store indexing:
+
+```json
+{
+ "id": "markdown",
+ "name": "Markdown Editor",
+ "version": "1.2.0",
+ "min_sdk_version": "0.1.0",
+ "abi_fingerprint": "0x05f167e314742930",
+ "author": "…",
+ "description": "…",
+ "homepage": "…"
+}
+```
+
+Install with:
+
+```sh
+zig build install --prefix ~/.config/fizzy/plugins
+# → ~/.config/fizzy/plugins/markdown.dylib
+```
+
+### Store registry schema (future)
+
+Hosted registry JSON (Phase 2 Extensions UI):
+
+```json
+{
+ "sdk_version": "0.1.0",
+ "plugins": [
+ {
+ "id": "markdown",
+ "name": "Markdown Editor",
+ "releases": [
+ {
+ "version": "1.2.0",
+ "min_sdk_version": "0.1.0",
+ "abi_fingerprint": "0x…",
+ "published": "2026-06-01",
+ "downloads": {
+ "macos-aarch64": "https://…/markdown-1.2.0-macos-aarch64.dylib"
+ }
+ }
+ ]
+ }
+ ]
+}
+```
+
+---
+
+## Plugin profiles (IDE-shaped contract)
+
+The shell is **IDE-shaped**: sidebar rail + explorer, menubar, center (`CenterProvider`), bottom panel, infobar. Plugins contribute via `Host.register*` — the shell never hardcodes feature panes.
+
+| Profile | Implements | Example |
+|---------|------------|---------|
+| **Editor** | Document vtable cluster + optional panes/commands | `pixi`, `code` |
+| **Shell** | Center provider + file tree, no documents | `workbench` |
+| **Utility** | Menus/commands/settings only, no document hooks | external markdown menu plugin |
+
+Use `Plugin.assertEditorVTable(vtable)` / `Plugin.assertUtilityVTable(vtable)` at compile time to catch profile mistakes.
+
+Built-in plugin id renames (pre-release): runtime id **`pixi`** (was `pixelart`); dylib `pixi.dylib`; settings key `plugins.pixi`; env `FIZZY_STATIC_PIXI`.
+
+| `src/editor/Editor.zig` | The shell: frame loop, `postInit` plugin registration, dylib loading |
diff --git a/docs/PLUGIN_ROUGH_EDGES.md b/docs/PLUGIN_ROUGH_EDGES.md
new file mode 100644
index 00000000..1431d722
--- /dev/null
+++ b/docs/PLUGIN_ROUGH_EDGES.md
@@ -0,0 +1,232 @@
+# Plugin Author Rough Edges
+
+A punch list of friction points a third-party author hits when building a *complex*
+editor plugin (a second real editor alongside pixelart). Ordered by pain, with file
+references and fix sketches. Cheap correctness fixes (#4, #6, #7) are being done first;
+the rest are tracked as backlog.
+
+Status legend: 🔴 not started · 🟡 in progress · 🟢 done
+
+---
+
+## 1. 🟢 The "stable contract" is pixel-art-shaped — *large* — DONE
+
+The intermediate `canvas_ext` (a relocated grab-bag that still *named* pixelart concepts) was
+replaced with two clean mechanisms, so the SDK names zero domain features:
+
+1. **Command registry** ([`regions.Command`](../src/sdk/regions.zig) + `Host.registerCommand` /
+ `runCommand` / `commandEnabled`). Invocable features register as namespaced commands the shell
+ triggers by id (`"pixelart.transform"`, `"pixelart.gridLayout"`, `"pixelart.packProject"`)
+ without knowing what they do. Folded into the ABI fingerprint.
+2. **Generic per-frame / lifecycle / save protocol** on `Plugin.VTable`, renamed from the
+ pixelart-flavored hooks: `prepareFrame`, `tickActiveDocument`, `drawOverlay`, `endFrame`,
+ `needsContinuousRepaint`, `persistProjectState`/`restoreProjectState`, and
+ `saveNeedsConfirmation`/`requestSaveConfirmation` (mode enum `SaveConfirmMode`).
+
+Pixelart's pack lifecycle (`tickPackJobs`/`runPackWorkers`) folded into its own `beginFrame`
+(the plugin self-drives background work); its pack-status check reads its own state instead of
+round-tripping through the host. Dead pack plumbing removed from `EditorAPI`/`Host`/`Editor`.
+`EditorAPI.requestCompositeWarmup` → `requestPrepareFrame` to match the new phase name.
+`Plugin.CanvasEditorExt` deleted. Verified: native build, `test`, `test-plugin-loader`, `check-web`
+all green; a grep of `src/sdk/` shows no residual domain vocabulary on the typed surface.
+
+Follow-up pass (hook honesty + docs): audited each renamed hook against its real call site —
+9/10 are genuinely generic across editor types; `prepareFrame` is borderline and is now
+documented as an opt-in `[requested]` pre-draw pass (only fires after `host.requestPrepareFrame`).
+Found & fixed a real generality bug: `tickKeybinds` was invoked only on `pixelartPlugin(editor)`,
+so a second plugin's per-frame keybinds would never fire — now broadcast to all plugins. Added a
+**required-vs-optional** map (the document cluster you must implement to be an editor) and a
+`[broadcast]`/`[active-doc]`/`[requested]` invocation tag + call-site/timing table to
+[`Plugin.zig`](../src/sdk/Plugin.zig) and [`PLUGINS.md`](PLUGINS.md). This also closes the
+original "no map of which of N hooks to implement" complaint.
+
+Active-doc owner dispatch + verbs-as-commands (done): a design review concluded the editing
+actions (`copy`/`paste`/`transform`/`acceptEdit`/`cancelEdit`/`deleteSelection`) are *not*
+universal — they're user-invoked and mean different things per editor — so they were **removed
+from `Plugin.VTable` and registered as `Command`s** (`"pixelart.copy"`, …). The shell's Edit
+menu / keybinds and *Grid Layout* dispatch to `"."` via
+`Editor.runActiveDocCommand`, so every editing action routes to whichever editor owns the focused
+tab; an owner that registered none is a clean no-op. The `EditorAPI` verb reach-backs are
+unchanged (they funnel through `editor.()`, now per-owner command dispatch).
+
+Folder lifecycle rename (done): the pixelart-flavored `persistProjectState`/`restoreProjectState`
+became the shell-event-named `onFolderClose` / `onFolderOpen` (the shell has a *folder* concept;
+"project" was pixelart's layer on top).
+
+**Still open (smaller follow-ups):**
+- **New File chooser** — with multiple `requestNewDocumentDialog` providers, present a typed "New > \" chooser (rough-edge #9 / existing `Plugin.zig` TODO). Single-provider dispatch via `Host.requestNewDocument` is done.
+
+**Resolved in SDK hardening pass:**
+- ~~**New File is single-owner**~~ — `Editor.requestNewFileDialog` dispatches via `Host.requestNewDocument`.
+- ~~**`initPlugin` not broadcast**~~ — `postInit` calls `initPlugin` on every registered plugin.
+- ~~**Menu enablement by owner**~~ — Edit menu gates on `commandEnabled` for active-doc owner commands.
+- ~~**No comptime editor profile check**~~ — `Plugin.assertEditorVTable` / `assertUtilityVTable` + templates.
+
+---
+
+### Original note
+
+[`Plugin.VTable`](../src/sdk/Plugin.zig) is ~60 optional hooks; a large fraction are
+pixel-art concepts presented as the neutral SDK: `transform`, `copy`, `paste`,
+`startPackProject`, `isPackingActive`, `tickPackJobs`, `runPackWorkers`,
+`persistProjectFolder`, `reloadProjectFolder`, `requestGridLayoutDialog`,
+`requestFlatRasterSaveWarning`, `shouldConfirmFlatRasterSave`,
+`warmupActiveDocumentComposites`, `resetDocumentPeekLayers`, `removeCanvasPane`,
+`radialMenu*`, `tickActiveDocumentPlayback`. [`EditorAPI`](../src/sdk/EditorAPI.zig) does
+the same (`transform`, `startPackProject`, `isPackingActive`, `requestCompositeWarmup`).
+
+Every hook is `?`-optional, so the compiler gives zero guidance — a missing hook surfaces
+at runtime as a feature silently doing nothing. There is no delineated "minimal editor
+plugin" subset.
+
+**Fix sketch:** split the vtable into a core *editor protocol* (the ~8 hooks every editor
+needs) and an optional *pixelart extension* surface; or at minimum document the required
+subset and add a comptime check that flags an editor plugin missing a core hook.
+
+## 2. 🔴 Document-load staging protocol is intricate and thread-unsafe-by-comment — *medium*
+
+Opening one file requires a correctly-ordered cluster of cooperating hooks whose contract
+lives only in field comments: `documentStackSize`/`documentStackAlign` → shell allocates a
+raw buffer → `loadDocument(path, out_doc)` constructs in place into shell-owned memory **on
+a worker thread** → `documentIdFromBuffer` → `registerOpenDocument` to move to a stable
+pointer → plus a separate `loadDocumentFromBytes` for web. Wrong size/align or touching
+dvui/globals from the worker thread is UB with no compile-time protection.
+
+**Fix sketch:** provide an SDK helper that owns the happy path (size/align from the doc
+type via comptime), and lift the threading rule out of a field comment into a documented
+contract / debug assertion.
+
+## 3. 🔴 ABI compatibility is all-or-nothing, opaque, pins to an exact commit — *large*
+
+The structural fingerprint ([`dylib.zig`](../src/sdk/dylib.zig)) rejects every third-party
+plugin on *any* dvui bump / boundary-struct tweak / new vtable hook, with a bare
+`error.AbiMismatch`. No version range, no skew tolerance, no tool telling the author what
+changed or which fizzy build their `.dylib` matches. A plugin is dead the instant the user
+updates fizzy.
+
+**Fix sketch:** keep the fingerprint as the hard gate but layer a human-readable
+(fizzy-version, dvui-version) tuple alongside it so diagnostics can say *why* and *what to
+rebuild against*; consider a documented "compatible host build" stamp.
+
+## 4. 🟢 Failure is invisible to the user — *cheap* — DONE
+
+Implemented: `Editor.loadUserPlugins` now records each failure into `editor.failed_user_plugins`
+(`{id, reason}`, owned strings, freed in `unloadPluginLibs`), logs at `.err` with an
+actionable reason (`pluginLoadFailureReason` maps each `LoadError` — e.g. AbiMismatch →
+"rebuild against this Fizzy build"), and a one-shot startup dialog
+(`dialogs/PluginLoadFailures.zig`) lists them so the author isn't left reading logs.
+
+---
+
+### Original note
+
+[`Editor.loadUserPlugins`](../src/editor/Editor.zig) logs `dvui.log.warn` and silently
+skips on every failure (open failed, ABI mismatch, register rejected, OOM). A user whose
+plugin doesn't load sees nothing in the UI. ABI mismatch — the most common case — surfaces
+only as a log line.
+
+**Fix sketch:** record `{plugin_id, path, error}` for each failed load on the Editor/Host,
+and surface it (settings panel section and/or a startup notice). At minimum keep a
+queryable list so the UI can show "N plugins failed to load."
+
+## 5. 🔴 No hot-reload / unload — brutal dev loop — *large*
+
+[`PluginLoader.loadAndRegister`](../src/editor/PluginLoader.zig) keeps the DynLib open for
+the app lifetime; `registerPlugin` only appends; `deinit` is never called mid-session. Plugin
+development means quit + relaunch (and reopen project/files) on every change.
+
+**Fix sketch:** an unregister path (drop registry entries owned by a plugin id, call
+`deinit`, close the lib) + a dev "reload plugin" affordance. Non-trivial because open
+documents may be owned by the plugin being unloaded.
+
+## 6. 🟢 `set_globals` slot overload is a latent footgun — *cheap* — DONE
+
+Implemented: the two post-`gpa` slots are renamed `arg_b`/`arg_c` across `sdk.dylib.SetGlobalsFn`,
+`PluginLoader.PreRegister`, and all `Editor.zig` call sites (matching the existing
+`syncLoadedPluginGlobals` vocabulary), each with a doc comment + inline comment stating the
+per-plugin convention (third-party: `arg_b` = `*Host`). No more field literally named `.state`
+carrying the host.
+
+---
+
+### Original note
+
+The C entry `set_globals(gpa, state, packer)` has three positional `*anyopaque` slots whose
+meaning differs per plugin. Third-party [`exportEntry`](../src/sdk/dylib.zig) reads them as
+`(gpa, host, state-ignored)`, so [`Editor.zig`](../src/editor/Editor.zig) smuggles `&host`
+through the field named `.state` and `.packer` is dead. Built-ins use the slots differently
+again. Works only by convention; it's a raw pointer reinterpret.
+
+**Fix sketch:** rename `PreRegister`/`SetGlobalsFn`/`installRuntime`/`exportEntry` params to
+a single clear contract — `gpa`, `host`, `plugin_state` — and update all call sites. Naming
+only; no behavior change.
+
+## 7. 🟢 Plugin identity vs folder name conflated; no dedup — *cheap* — DONE
+
+Implemented: `Host.registerPlugin` now rejects a duplicate declared `id` with
+`error.DuplicatePluginId` (built-ins register first, so they always win). The dylib loader
+turns that into a failed load surfaced via #4, and the declared `id` — not the folder name —
+is the source of truth for routing.
+
+---
+
+### Original note
+
+[`Editor.loadUserPlugins`](../src/editor/Editor.zig) derives `plugin_id` from the directory
+name and keys its collision guard on `pluginById(entry.name)`, but plugins register under
+their own declared `plugin.id`, and [`registerPlugin`](../src/sdk/Host.zig) does no dedup. A
+plugin in folder `foo` declaring `id = "pixelart"` passes the folder guard then
+double-registers `"pixelart"`; routing (`pluginById`/`pluginForExtension`) becomes
+ambiguous.
+
+**Fix sketch:** make `registerPlugin` reject a duplicate id (return an error the loader
+treats as a failed load — feeds #4), and treat the declared id as the source of truth.
+
+## 8. 🔴 Service discovery is stringly-typed and unversioned — *medium*
+
+[`Host.getService(name) -> ?*anyopaque`](../src/sdk/Host.zig) then
+`@ptrCast(@alignCast(...))`. The author must know the magic string and the exact cast type,
+with nothing binding the two, and the service struct's layout is not in the ABI fingerprint —
+so a shape change silently corrupts. Only workbench's service is documented.
+
+**Fix sketch:** a typed service helper (`getService(T)` keyed on `T.service_name`) and fold
+registered service struct layouts into the fingerprint, or attach a per-service version.
+
+## 9. 🔴 Smaller items — *cheap-ish, batched*
+
+- **`core.gpa` global** — docs say "sync `core.gpa = sdk.allocator()` if you use core
+ helpers," but `core` is a first-class import a complex plugin will use; forgetting is UB
+ with no reminder. Consider asserting/initializing it at load.
+- **"New File" is single-owner** — existing TODO in [`Plugin.zig`](../src/sdk/Plugin.zig):
+ `requestNewDocumentDialog` dispatches to "a plugin that provides one"; a second editor
+ collides. Needs a typed "New > \" chooser.
+- **Install ergonomics / no manifest** — `zig build install --prefix /plugins//` is hand-assembled; no `fizzy install-plugin`, no manifest declaring
+ name/version/author/min-fizzy-version. Identity comes from the folder the user drops it in.
+- **dvui globals across the boundary** — context is re-injected each frame
+ ([`syncLoadedPluginDvuiContexts`](../src/editor/Editor.zig)); a plugin caching
+ `currentWindow()`, a font, or an ft2 handle across frames is in undocumented territory.
+
+## 10. 🟢 Built-in plugins didn't look like third-party plugins — *medium* — DONE
+
+A built-in's folder used to carry files a third-party plugin never has (an embed-stub
+`build.zig` + a separate `build_standalone.zig`, `module.zig`, `dylib.zig`, `Globals.zig`)
+and its `build/integration.zig` ran from two roots via dual-path (`repo_paths`/`pkg_paths`)
+machinery — so "what files does a plugin need?" had two different answers.
+
+Now every plugin folder — the built-ins (pixi/workbench/code), the new in-repo `example`
+template, and external plugins like markdown — is the **same canonical third-party shape**
+(`build.zig` via `fizzy.plugin.create`, `build.zig.zon`, `root.zig` → `src/plugin.zig`,
+`src/…`) and builds standalone with `cd src/plugins/ && zig build`. The only
+fizzy-internal extras are a root `.zig` (the conventional package module + import hub,
+forced to the root by Zig's module-import boundary) and a self-contained `static/` subfolder
+(`static/integration.zig`) holding the static-embed + bundled-dylib build graph; the embed stub,
+`build_standalone.zig`, `module.zig`, `src/hub.zig`, `dylib.zig`, `Globals.zig`, and the
+dual-root path machinery are all gone. Vendored C deps use the reusable `fizzy.plugin.addCModule`
+helper. The [`example`](../src/plugins/example/) plugin is the always-compiling copy-me
+template. See [PLUGINS.md](PLUGINS.md) §2.
+
+**Caveat (monorepo only):** building a built-in that vendors C deps shared with fizzy's own
+build graph (pixi's `build/deps.zig`) standalone from *inside* the repo would put one file in
+two build modules, so pixi's `build.zig` inlines its vendored-dep wiring. A genuine
+third-party plugin in its own repo has no such overlap.
diff --git a/plugin_sdk.zig b/plugin_sdk.zig
new file mode 100644
index 00000000..e0e838f4
--- /dev/null
+++ b/plugin_sdk.zig
@@ -0,0 +1,243 @@
+//! Build helpers for third-party Fizzy plugin dylibs.
+//!
+//! Required in your project (see `docs/PLUGINS.md` §2):
+//! - `root.zig` — copy from `fizzy/src/plugins/root.zig` (one `sdk.dylib.exportEntry` call)
+//! - `src/plugin.zig` — `register(host)` + `Plugin` vtable + `manifest`; read `sdk.allocator()` / `sdk.host()`
+//! - `build.zig` / `build.zig.zon` — declare `fizzy`, call `fizzy.plugin.create` + `.install` below
+const std = @import("std");
+
+/// C-ABI entry symbols every plugin dylib must export.
+pub const dylib_exports = [_][]const u8{
+ "fizzy_plugin_abi_fingerprint",
+ "fizzy_plugin_sdk_version",
+ "fizzy_plugin_min_sdk_version",
+ "fizzy_plugin_version",
+ "fizzy_plugin_id",
+ "fizzy_plugin_register",
+ "fizzy_plugin_set_dvui_context",
+ "fizzy_plugin_set_render_bridge",
+ "fizzy_plugin_set_globals",
+};
+
+pub const Modules = struct {
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+ dvui: *std.Build.Module,
+ proxy_bridge: *std.Build.Module,
+};
+
+pub const ModulesOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+};
+
+pub const ModuleOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ root_source_file: std.Build.LazyPath,
+ link_libc: bool = true,
+};
+
+pub const CreateOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ /// Dylib artifact name and installed filename stem (e.g. `"markdown"` → `markdown.dylib`).
+ name: []const u8,
+ link_libc: bool = true,
+ root_source_file: ?std.Build.LazyPath = null,
+};
+
+fn fizzyDep(b: *std.Build, opts: ModulesOptions) *std.Build.Dependency {
+ return b.dependency("fizzy", .{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .plugin_sdk = true,
+ });
+}
+
+fn modulesFromDep(fizzy_dep: *std.Build.Dependency) Modules {
+ return .{
+ .core = fizzy_dep.module("core"),
+ .sdk = fizzy_dep.module("sdk"),
+ .dvui = fizzy_dep.module("dvui"),
+ .proxy_bridge = fizzy_dep.module("proxy_bridge"),
+ };
+}
+
+pub fn modules(b: *std.Build, opts: ModulesOptions) Modules {
+ return modulesFromDep(fizzyDep(b, opts));
+}
+
+pub fn addImports(mod: *std.Build.Module, plugin_modules: Modules) void {
+ mod.addImport("core", plugin_modules.core);
+ mod.addImport("sdk", plugin_modules.sdk);
+ mod.addImport("dvui", plugin_modules.dvui);
+ mod.addImport("proxy_bridge", plugin_modules.proxy_bridge);
+}
+
+fn module(
+ b: *std.Build,
+ plugin_modules: Modules,
+ opts: ModuleOptions,
+) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = opts.root_source_file,
+ .link_libc = opts.link_libc,
+ });
+ addImports(mod, plugin_modules);
+ return mod;
+}
+
+pub fn createModule(b: *std.Build, opts: ModuleOptions) *std.Build.Module {
+ return module(b, modules(b, .{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ }), opts);
+}
+
+pub const InstallOptions = struct {
+ /// Install under `/{name}.{ext}`. Defaults to `lib` compile artifact name.
+ name: ?[]const u8 = null,
+};
+
+/// Install `lib` as `{name}.{dylib,dll,so}` under the install prefix.
+///
+/// const lib = fizzy.plugin.create(b, .{ .name = "markdown", .target = target, .optimize = optimize });
+/// fizzy.plugin.install(b, lib, .{});
+pub fn install(b: *std.Build, lib: *std.Build.Step.Compile, opts: InstallOptions) void {
+ const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) {
+ .windows => "dll",
+ .macos => "dylib",
+ else => "so",
+ };
+ const name = opts.name orelse lib.name;
+ const dest = b.fmt("{s}.{s}", .{ name, ext });
+ const install_step = b.addInstallArtifact(lib, .{
+ .dest_dir = .{ .override = .prefix },
+ .dest_sub_path = dest,
+ });
+ b.getInstallStep().dependOn(&install_step.step);
+}
+
+/// A C source file + its compile flags, for `addCModule`.
+pub const CSourceFile = struct {
+ file: std.Build.LazyPath,
+ flags: []const []const u8 = &.{},
+};
+
+pub const CModuleOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ /// Zig bindings root (e.g. `zstbi.zig`).
+ root_source_file: std.Build.LazyPath,
+ /// C translation units compiled into the module.
+ c_sources: []const CSourceFile = &.{},
+ /// `-I` include dirs for the C sources.
+ include_paths: []const std.Build.LazyPath = &.{},
+ link_libc: bool = true,
+ single_threaded: bool = false,
+};
+
+/// Build a Zig module backed by vendored C sources (an image/codec/archive lib, etc.) and
+/// return it for `mod.addImport(...)`. The C compiles into whatever artifact imports the
+/// returned module. All inputs are caller-supplied `LazyPath`s, so this works unchanged whether
+/// invoked from the fizzy build root (static embed / bundled dylib) or a standalone plugin
+/// build — there is no shared, location-bound build file to collide between the two graphs.
+pub fn addCModule(b: *std.Build, opts: CModuleOptions) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = opts.root_source_file,
+ .link_libc = opts.link_libc,
+ .single_threaded = opts.single_threaded,
+ });
+ for (opts.include_paths) |path| mod.addIncludePath(path);
+ for (opts.c_sources) |c| mod.addCSourceFile(.{ .file = c.file, .flags = c.flags });
+ return mod;
+}
+
+pub fn create(b: *std.Build, opts: CreateOptions) *std.Build.Step.Compile {
+ const root_source = opts.root_source_file orelse b.path("root.zig");
+ const mod = module(b, modules(b, .{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ }), .{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = root_source,
+ .link_libc = opts.link_libc,
+ });
+
+ const lib = b.addLibrary(.{
+ .name = opts.name,
+ .linkage = .dynamic,
+ .root_module = mod,
+ });
+ lib.linker_allow_shlib_undefined = true;
+ lib.root_module.export_symbol_names = &dylib_exports;
+ return lib;
+}
+
+pub fn exportModules(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+) !void {
+ const dvui_dep = b.dependency("dvui", .{
+ .target = target,
+ .optimize = optimize,
+ .backend = .proxy,
+ .accesskit = .off,
+ });
+ const dvui_proxy_mod = dvui_dep.module("dvui_proxy");
+ const proxy_bridge_mod = dvui_dep.module("proxy_bridge");
+
+ const known_folders = b.dependency("known_folders", .{
+ .target = target,
+ .optimize = optimize,
+ }).module("known-folders");
+
+ const core_mod = b.addModule("core", .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ .link_libc = true,
+ });
+ core_mod.addImport("dvui", dvui_proxy_mod);
+ core_mod.addImport("known-folders", known_folders);
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ core_mod.addImport("icons", dep.module("icons"));
+ }
+
+ const sdk_mod = b.addModule("sdk", .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/sdk/sdk.zig"),
+ });
+ sdk_mod.addImport("dvui", dvui_proxy_mod);
+ sdk_mod.addImport("proxy_bridge", proxy_bridge_mod);
+ sdk_mod.addImport("core", core_mod);
+
+ b.modules.put(b.graph.arena, b.dupe("dvui"), dvui_proxy_mod) catch @panic("OOM");
+ b.modules.put(b.graph.arena, b.dupe("proxy_bridge"), proxy_bridge_mod) catch @panic("OOM");
+}
+
+/// Install a built-in plugin dylib as `{name}.{ext}` under `plugins/`.
+pub fn installBuiltinPlugin(
+ b: *std.Build,
+ lib: *std.Build.Step.Compile,
+ name: []const u8,
+ plugins_install_dir: std.Build.InstallDir,
+) *std.Build.Step.InstallArtifact {
+ const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) {
+ .windows => "dll",
+ .macos => "dylib",
+ else => "so",
+ };
+ return b.addInstallArtifact(lib, .{
+ .dest_dir = .{ .override = plugins_install_dir },
+ .dest_sub_path = b.fmt("{s}.{s}", .{ name, ext }),
+ });
+}
diff --git a/src/tools/process_assets.zig b/process_assets.zig
similarity index 99%
rename from src/tools/process_assets.zig
rename to process_assets.zig
index 3597b0eb..08a10636 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("../Atlas.zig");
+const Atlas = @import("src/plugins/pixi/src/Atlas.zig");
const ProcessAssetsStep = @This();
step: Step,
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/spikes/ts_highlight_test b/spikes/ts_highlight_test
new file mode 100755
index 00000000..374e8764
Binary files /dev/null and b/spikes/ts_highlight_test differ
diff --git a/src/App.zig b/src/App.zig
index 65e64e30..c1c1e5e8 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -8,15 +8,17 @@ 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 paths = @import("paths.zig");
+const workbench = @import("workbench");
+const pixi = @import("pixi");
+const code = @import("code");
+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();
const Editor = fizzy.Editor;
-const Packer = fizzy.Packer;
-//const Assets = fizzy.Assets;
+const Packer = pixi.Packer;
// App fields
allocator: std.mem.Allocator = undefined,
@@ -59,7 +61,14 @@ const start_options_base: dvui.App.StartOptions = .{
fn startOptions() dvui.App.StartOptions {
var opts = start_options_base;
+ // Create the dvui window with the *same* allocator the host hands to plugins
+ // (`fizzy.app.allocator`). Without this, dvui defaults the window to the runtime's
+ // `main_init.gpa`, a different allocator instance — so `dvui.currentWindow().gpa`
+ // and `host.allocator` would be distinct, and a plugin that allocated with one and
+ // freed with the other would corrupt the heap. Unifying them makes every allocator a
+ // plugin can reach the same instance. (No-op on wasm, which uses the page allocator.)
if (comptime builtin.target.cpu.arch != .wasm32) {
+ opts.gpa = appAllocator();
const main_init = dvui.App.main_init orelse return opts;
if (paths.configFolderZ(&pref_path_buf, main_init.io, fizzy.processEnviron(), ".")) |pref_path| {
pref_path_len = pref_path.len;
@@ -130,6 +139,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);
@@ -161,12 +175,27 @@ pub fn AppInit(win: *dvui.Window) !void {
fizzy.editor = try allocator.create(Editor);
fizzy.editor.* = Editor.init(fizzy.app) catch unreachable;
+ // Workbench + pixi shell-owned state: wire before plugin `register`.
+ workbench.runtime.setWorkbench(&fizzy.editor.workbench);
+
+ const pixi_state = try allocator.create(pixi.State);
+ pixi.runtime.adoptShellState(pixi_state);
+ pixi_state.* = pixi.State.init(allocator, &fizzy.editor.host) catch unreachable;
+ fizzy.editor.pixi_state = pixi_state;
+
+ // Second-stage init that needs the editor at its final heap address (e.g.
+ // registering the workbench-api service whose `ctx` is this pointer).
+ 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;
+ pixi.runtime.setPacker(fizzy.packer);
+ fizzy.editor.syncLoadedPixiGlobals();
+
// Hand the window to the listener thread and queue our own argv so the
// first frame opens any files / project folder supplied on the command line.
singleton.registerWindow(win, resolved_argv);
@@ -212,7 +241,13 @@ 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.
+ pixi.State.persistProject(fizzy.editor.pixi_state);
fizzy.editor.deinit() catch unreachable;
+ // Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs).
+ // After the editor so any editor teardown that still reads pixel-art state runs first.
+ fizzy.editor.pixi_state.deinit(fizzy.app.allocator);
+ fizzy.app.allocator.destroy(fizzy.editor.pixi_state);
// Tear down the singleton listener after the editor so any callback
// currently in flight finishes before we free state it touches.
singleton.deinit();
diff --git a/src/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/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 93d7c6ba..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");
@@ -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/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 98%
rename from src/singleton_native.zig
rename to src/backend/singleton_native.zig
index d52a071e..7e7d6044 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);
@@ -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.currentGroupingID());
}
/// Walk upward from `file_path`'s parent directory, returning the first
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/internal/window_layout.zig b/src/backend/window_layout.zig
similarity index 98%
rename from src/internal/window_layout.zig
rename to src/backend/window_layout.zig
index dd15f2f3..af3ec513 100644
--- a/src/internal/window_layout.zig
+++ b/src/backend/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/backend/window_layout.zig` beside `backend_native.zig` rather than under `internal/`.
const std = @import("std");
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/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/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
new file mode 100644
index 00000000..2fd4cd4b
--- /dev/null
+++ b/src/core/core.zig
@@ -0,0 +1,45 @@
+//! 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");
+
+/// 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/dvui.zig b/src/core/dvui.zig
similarity index 56%
rename from src/dvui.zig
rename to src/core/dvui.zig
index 9966490b..be10dfeb 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 {
@@ -101,17 +104,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 {
@@ -193,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 {
@@ -221,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,
@@ -245,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();
}
@@ -268,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 {
@@ -413,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 = .{
@@ -949,690 +965,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);
@@ -1671,7 +1003,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;
@@ -1692,7 +1024,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);
@@ -1706,7 +1038,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);
@@ -1789,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/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 90%
rename from src/gfx/image.zig
rename to src/core/gfx/image.zig
index b39c0110..124a7ee8 100644
--- a/src/gfx/image.zig
+++ b/src/core/gfx/image.zig
@@ -1,12 +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");
-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;
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);
@@ -34,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,
@@ -44,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,
@@ -64,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,
@@ -75,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,
@@ -92,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);
}
@@ -312,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.Internal.Layer.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();
}
}
@@ -329,32 +330,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/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 80%
rename from src/math/color.zig
rename to src/core/math/color.zig
index 76f2a011..6e6f8888 100644
--- a/src/math/color.zig
+++ b/src/core/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/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 96%
rename from src/math/math.zig
rename to src/core/math/math.zig
index 82589dcc..bc64c5b7 100644
--- a/src/math/math.zig
+++ b/src/core/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/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 95%
rename from src/platform.zig
rename to src/core/platform.zig
index 575b8fa6..0c809af7 100644
--- a/src/platform.zig
+++ b/src/core/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/editor/widgets/CanvasWidget.zig b/src/core/widgets/CanvasWidget.zig
similarity index 95%
rename from src/editor/widgets/CanvasWidget.zig
rename to src/core/widgets/CanvasWidget.zig
index c478bbce..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();
@@ -74,8 +75,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,
@@ -136,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
@@ -188,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,
@@ -253,10 +252,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 +722,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
@@ -731,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
@@ -906,10 +933,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 +1067,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 +1143,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 +1211,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/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/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/Colors.zig b/src/editor/Colors.zig
deleted file mode 100644
index 531f1cb4..00000000
--- a/src/editor/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/editor/Editor.zig b/src/editor/Editor.zig
index 1efd2106..9545a298 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -13,32 +13,46 @@ 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 pixi = @import("pixi");
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("Colors.zig");
-pub const Project = @import("Project.zig");
pub const Recents = @import("Recents.zig");
pub const Settings = @import("Settings.zig");
-pub const Tools = @import("Tools.zig");
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");
+const workbench_mod = @import("workbench");
+const code_mod = @import("code");
+const example_mod = @import("example");
+const PluginLoader = if (builtin.target.cpu.arch == .wasm32)
+ @import("PluginLoader_stub.zig")
+else
+ @import("PluginLoader.zig");
+const InstalledPlugins = @import("InstalledPlugins.zig");
+
+pub const Workspace = workbench_mod.Workspace;
pub const Explorer = @import("explorer/Explorer.zig");
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 PackJob = @import("PackJob.zig");
+pub const FileLoadJob = workbench_mod.FileLoadJob;
+
+pub const sdk = fizzy.sdk;
+pub const Host = sdk.Host;
+
+/// 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.
/// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame
@@ -47,7 +61,26 @@ 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,
+
+/// Pixel-art plugin runtime state (owned by App; wired into `Globals.state`).
+pixi_state: *pixi.State,
+
+/// File-management workbench (per-branch explorer decorations, …)
+workbench: Workbench,
+
+/// Keeps plugin dylibs mapped while their vtables are live (native only).
+loaded_plugin_libs: std.ArrayListUnmanaged(PluginLoader.LoadedLib) = .empty,
+
+/// User plugins that failed to load this session, so the UI can tell the author what
+/// went wrong instead of failing silently into the log. Populated by `loadUserPlugins`;
+/// strings are owned here and freed in `deinit`.
+failed_user_plugins: std.ArrayListUnmanaged(FailedPlugin) = .empty,
+/// One-shot guard so the startup "plugin load failures" dialog is raised only once.
+plugin_failures_dialog_shown: bool = false,
settings: Settings = undefined,
recents: Recents = undefined,
@@ -56,57 +89,32 @@ 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,
sidebar: Sidebar,
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 = .{},
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`
+/// 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,
-/// 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.
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,
-
-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
@@ -163,11 +171,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"),
@@ -243,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,
@@ -255,19 +258,27 @@ 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),
},
- .tools = try .init(app.allocator),
.themes = .empty,
+ .host = .init(app.allocator),
+ .pixi_state = undefined,
+ .workbench = .init(app.allocator),
};
- editor.settings = try Settings.load(app.allocator, try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" }));
+ try editor.workbench.registerBuiltins();
+
+ {
+ 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
- // worker spawns were causing one save to wedge under contention.
- try fizzy.Internal.File.initSaveQueue();
+ // Save-queue worker is owned by the pixel-art plugin (`initPlugin` in `postInit`).
{ // Setup themes
var fizzy_dark = dvui.themeGet();
@@ -428,26 +439,763 @@ 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();
- 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 `State.init` (App allocates
+ // `editor.pixi_state` just after this `Editor.init` returns).
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;
}
-/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes).
+/// 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`.)
+/// Stable shell-builtin contribution id.
+pub const view_settings = "shell.settings";
+
+fn loadPixiFromDylibEnabled() bool {
+ if (comptime builtin.target.cpu.arch == .wasm32) return false;
+ if (comptime build_opts.static_pixi) return false;
+ if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_PIXI")) |v| {
+ defer fizzy.app.allocator.free(v);
+ return v.len == 0 or v[0] == '0';
+ } else |_| {}
+ 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;
+}
+
+fn loadCodeFromDylibEnabled() bool {
+ if (comptime builtin.target.cpu.arch == .wasm32) return false;
+ if (comptime build_opts.static_code) return false;
+ if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_CODE")) |v| {
+ defer fizzy.app.allocator.free(v);
+ return v.len == 0 or v[0] == '0';
+ } else |_| {}
+ return true;
+}
+
+/// Stable workbench sidebar view id (matches `workbench.plugin.view_files`).
+pub const workbench_files_view = workbench_mod.plugin.view_files;
+
+/// 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 pixiPlugin(editor: *Editor) *sdk.Plugin {
+ return editor.host.pluginById("pixi") orelse @panic("pixelart plugin not registered");
+}
+
+/// Registered code plugin (dylib or static). Panics if missing after `postInit`.
+pub fn codePlugin(editor: *Editor) *sdk.Plugin {
+ return editor.host.pluginById("code") orelse @panic("code plugin not registered");
+}
+
+/// Push host dvui state into every loaded plugin dylib image.
+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);
+ }
+}
+
+/// 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| {
+ 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 + state into a loaded pixi dylib (e.g. after packer init on shared state).
+pub fn syncLoadedPixiGlobals(editor: *Editor) void {
+ syncLoadedPluginGlobals(editor, "pixi", @ptrCast(&editor.host), @ptrCast(editor.pixi_state));
+}
+
+/// Re-inject host-owned Globals into a loaded workbench dylib.
+pub fn syncLoadedWorkbenchGlobals(editor: *Editor) void {
+ syncLoadedPluginGlobals(editor, "workbench", @ptrCast(&editor.host), @ptrCast(&editor.workbench));
+}
+
+fn appendLoadedPluginLib(editor: *Editor, loaded: PluginLoader.LoadedLib) !void {
+ 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/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,
+ .arg_b = @ptrCast(&editor.host), // workbench convention: arg_b = *Host
+ .arg_c = @ptrCast(&editor.workbench), // arg_c = *Workbench
+ });
+ try appendLoadedPluginLib(editor, loaded);
+ syncLoadedPluginDvuiContexts(editor);
+ syncLoadedPluginRenderBridge(editor);
+}
+
+/// Load `{exe_dir}/plugins/pixi.{ext}` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry.
+pub fn loadPixiDylib(editor: *Editor, exe_dir: []const u8) !void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ const path = try PluginLoader.resolvePluginPath(fizzy.app.allocator, exe_dir, "pixi");
+ errdefer fizzy.app.allocator.free(path);
+ const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "pixi", .{
+ .gpa = &fizzy.app.allocator,
+ .arg_b = @ptrCast(&editor.host),
+ .arg_c = @ptrCast(editor.pixi_state),
+ });
+ try appendLoadedPluginLib(editor, loaded);
+ syncLoadedPluginDvuiContexts(editor);
+ syncLoadedPluginRenderBridge(editor);
+}
+
+/// Load `{exe_dir}/plugins/code.{ext}` and register via dylib entry.
+pub fn loadCodeDylib(editor: *Editor, exe_dir: []const u8) !void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ const path = try PluginLoader.builtinPluginPath(fizzy.app.allocator, exe_dir, "code");
+ errdefer fizzy.app.allocator.free(path);
+ const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "code", .{
+ .gpa = &fizzy.app.allocator,
+ .arg_b = @ptrCast(&editor.host),
+ .arg_c = null,
+ });
+ try appendLoadedPluginLib(editor, loaded);
+ syncLoadedPluginDvuiContexts(editor);
+ syncLoadedPluginRenderBridge(editor);
+}
+
+/// Scan `/plugins/` for user-installed plugin dylibs and load each one.
+///
+/// Each sub-directory that contains `plugin.` is attempted in iteration order.
+/// Failures are logged and skipped — a bad plugin never prevents the others from loading.
+/// Built-in plugin IDs ("pixi", "workbench", "code") are never overridden; any
+/// user directory whose name collides with an already-registered plugin is skipped.
+///
+/// On success each loaded lib is appended to `loaded_plugin_libs` and the dvui context
+/// + render bridge are synced once at the end. On wasm this is a no-op.
+///
+/// The user plugin directory does not need to exist; a missing directory is silently ignored.
+/// A user plugin that failed to load, retained so the UI can surface it. `id` and `reason`
+/// are heap-owned (app allocator) and freed in `deinit`.
+pub const FailedPlugin = struct {
+ id: []const u8,
+ reason: []const u8,
+ /// Optional version / SDK detail when the dylib could be opened for probing.
+ detail: ?[]const u8 = null,
+};
+
+/// Record a failed user-plugin load so the UI can surface it. `id` and `reason` are copied
+/// (the caller keeps ownership of its arguments). Best-effort: on OOM the failure is dropped
+/// after being logged at the call site.
+fn recordPluginFailure(editor: *Editor, id: []const u8, reason: []const u8, detail: ?[]const u8) void {
+ const id_owned = fizzy.app.allocator.dupe(u8, id) catch return;
+ const reason_owned = fizzy.app.allocator.dupe(u8, reason) catch {
+ fizzy.app.allocator.free(id_owned);
+ return;
+ };
+ const detail_owned: ?[]const u8 = if (detail) |d| fizzy.app.allocator.dupe(u8, d) catch null else null;
+ if (detail_owned == null and detail != null) {
+ fizzy.app.allocator.free(id_owned);
+ fizzy.app.allocator.free(reason_owned);
+ return;
+ }
+ editor.failed_user_plugins.append(fizzy.app.allocator, .{
+ .id = id_owned,
+ .reason = reason_owned,
+ .detail = detail_owned,
+ }) catch {
+ fizzy.app.allocator.free(id_owned);
+ fizzy.app.allocator.free(reason_owned);
+ if (detail_owned) |d| fizzy.app.allocator.free(d);
+ };
+}
+
+fn formatPluginProbeDetail(allocator: std.mem.Allocator, info: PluginLoader.PluginVersionInfo) ![]const u8 {
+ return std.fmt.allocPrint(allocator, "plugin {d}.{d}.{d}, min SDK {d}.{d}.{d}", .{
+ info.plugin_version.major,
+ info.plugin_version.minor,
+ info.plugin_version.patch,
+ info.min_sdk_version.major,
+ info.min_sdk_version.minor,
+ info.min_sdk_version.patch,
+ });
+}
+
+/// Human-readable, actionable explanation for a `PluginLoader.LoadError`.
+fn pluginLoadFailureReason(err: PluginLoader.LoadError) []const u8 {
+ return switch (err) {
+ error.AbiMismatch => "built against an incompatible Fizzy SDK — rebuild the plugin against this Fizzy build",
+ error.SdkVersionMismatch => "requires a newer Fizzy SDK — update Fizzy or install a matching plugin build",
+ error.PluginIdMismatch => "plugin id in the dylib does not match its filename — rename the file or fix manifest.id",
+ error.DylibOpenFailed => "the plugin library could not be opened (missing file, wrong architecture, or unresolved symbols)",
+ error.RegisterRejected => "the plugin's register() was rejected (often a duplicate plugin id — a built-in or another plugin already claims it)",
+ error.AbiFingerprintSymbolMissing,
+ error.RegisterSymbolMissing,
+ error.SetGlobalsSymbolMissing,
+ error.SetDvuiContextSymbolMissing,
+ error.SetRenderBridgeSymbolMissing,
+ error.SdkVersionSymbolMissing,
+ => "the plugin is missing required entry symbols — rebuild it from a current root.zig template",
+ };
+}
+
+pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+
+ const plugins_dir = std.fs.path.join(fizzy.app.allocator, &.{ config_folder, "plugins" }) catch return;
+ defer fizzy.app.allocator.free(plugins_dir);
+
+ var dir = std.Io.Dir.cwd().openDir(dvui.io, plugins_dir, .{ .iterate = true }) catch return;
+ defer dir.close(dvui.io);
+
+ const ext_suffix: []const u8 = switch (builtin.os.tag) {
+ .windows => ".dll",
+ .macos => ".dylib",
+ else => ".so",
+ };
+ var loaded_any = false;
+
+ var iter = dir.iterate();
+ while (iter.next(dvui.io) catch null) |entry| {
+ if (entry.kind != .file) continue;
+ if (!std.mem.endsWith(u8, entry.name, ext_suffix)) continue;
+
+ const dot = std.mem.lastIndexOf(u8, entry.name, ".") orelse continue;
+ const plugin_id = entry.name[0..dot];
+ if (plugin_id.len == 0) continue;
+
+ if (editor.host.pluginById(plugin_id) != null) {
+ dvui.log.err("user plugin '{s}': id already registered by a built-in; skipped", .{plugin_id});
+ editor.recordPluginFailure(plugin_id, "id already registered by a built-in plugin", null);
+ continue;
+ }
+
+ const path = std.fs.path.join(fizzy.app.allocator, &.{ plugins_dir, entry.name }) catch continue;
+
+ const loaded = PluginLoader.loadAndRegister(&editor.host, path, plugin_id, .{
+ .gpa = &fizzy.app.allocator,
+ .arg_b = @ptrCast(&editor.host),
+ .arg_c = null,
+ }) catch |err| {
+ const reason = pluginLoadFailureReason(err);
+ const probe = PluginLoader.probeVersionInfo(path);
+ const detail_owned: ?[]const u8 = if (probe) |info|
+ formatPluginProbeDetail(fizzy.app.allocator, info) catch null
+ else
+ null;
+ dvui.log.err("user plugin '{s}' ({s}): load failed: {s} — {s}", .{ plugin_id, path, @errorName(err), reason });
+ editor.recordPluginFailure(plugin_id, reason, detail_owned);
+ fizzy.app.allocator.free(path);
+ continue;
+ };
+
+ appendLoadedPluginLib(editor, loaded) catch {
+ dvui.log.err("user plugin '{s}': out of memory storing LoadedLib", .{plugin_id});
+ editor.recordPluginFailure(plugin_id, "ran out of memory while loading", null);
+ continue;
+ };
+ dvui.log.info("user plugin '{s}' loaded from {s}", .{ plugin_id, path });
+ loaded_any = true;
+ }
+
+ if (loaded_any) {
+ syncLoadedPluginDvuiContexts(editor);
+ syncLoadedPluginRenderBridge(editor);
+ }
+}
+
+fn unloadPluginLibs(editor: *Editor) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ for (editor.loaded_plugin_libs.items) |*entry| {
+ entry.lib.close();
+ fizzy.app.allocator.free(entry.plugin_id);
+ fizzy.app.allocator.free(entry.path);
+ }
+ editor.loaded_plugin_libs.deinit(fizzy.app.allocator);
+
+ for (editor.failed_user_plugins.items) |f| {
+ fizzy.app.allocator.free(f.id);
+ fizzy.app.allocator.free(f.reason);
+ if (f.detail) |d| fizzy.app.allocator.free(d);
+ }
+ editor.failed_user_plugins.deinit(fizzy.app.allocator);
+}
+
+pub fn postInit(editor: *Editor) !void {
+ sdk.installRuntime(&fizzy.app.allocator, &editor.host, null);
+
+ // Install the shell's read/utility surface so plugins reach shared shell state
+ // (per-frame arena, project folder, content opacity, settings dirty-mark) through
+ // the Host instead of importing the concrete Editor.
+ 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
+ // editor tick already runs on wasm. Order = sidebar order.
+ 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 (loadPixiFromDylibEnabled()) {
+ editor.loadPixiDylib(fizzy.app.root_path) catch |err| {
+ dvui.log.warn("pixi dylib load failed ({s}); falling back to static plugin", .{@errorName(err)});
+ try pixi.plugin.register(&editor.host);
+ };
+ } else {
+ try pixi.plugin.register(&editor.host);
+ }
+ if (loadCodeFromDylibEnabled()) {
+ editor.loadCodeDylib(fizzy.app.root_path) catch |err| {
+ dvui.log.warn("code dylib load failed ({s}); falling back to static plugin", .{@errorName(err)});
+ try code_mod.plugin.register(&editor.host);
+ };
+ } else {
+ try code_mod.plugin.register(&editor.host);
+ }
+ // Example plugin: the minimal built-in / template. Registered statically here; it also
+ // builds standalone as a dylib (`cd src/plugins/example && zig build`), so it exercises
+ // both link modes. See docs/PLUGINS.md.
+ try example_mod.plugin.register(&editor.host);
+
+ // User-installed plugins from `/plugins/{id}.{dylib,so,dll}`.
+ editor.loadUserPlugins(editor.config_folder);
+
+ try InstalledPlugins.register(&editor.host);
+
+ for (editor.host.plugins.items) |p| try p.initPlugin();
+
+ // Shell built-in: Settings (owner = null; not a plugin).
+ try editor.host.registerSidebarView(.{
+ .id = view_settings,
+ .icon = dvui.entypo.cog,
+ .title = "Settings",
+ .draw = drawSettingsPane,
+ });
+
+ // 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 = "pixi.menu.edit", .draw = Menu.drawEditMenu });
+ try editor.host.registerMenu(.{ .id = "shell.menu.view", .draw = Menu.drawViewMenu });
+ try editor.host.registerMenu(.{ .id = "shell.menu.help", .draw = Menu.drawHelpMenu });
+
+ // 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).
+ syncLoadedPluginDvuiContexts(editor);
+ 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 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.host);
+ try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api);
+ }
+}
+
+/// 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();
+}
+
+// ---- EditorAPI: the shell-provided read/utility surface for plugins ----------
+// Installed on the Host in `postInit`; `ctx` is this `*Editor`.
+
+const shell_api_vtable: sdk.EditorAPI.VTable = .{
+ .arena = shellArena,
+ .folder = shellFolder,
+ .paletteFolder = shellPaletteFolder,
+ .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,
+ .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,
+ .showOpenFolderDialog = shellShowOpenFolderDialog,
+ .showOpenFileDialog = shellShowOpenFileDialog,
+ .save = shellSave,
+ .requestPrepareFrame = shellRequestCompositeWarmup,
+ .refresh = shellRefresh,
+ .allocUntitledPath = shellAllocUntitledPath,
+ .createDocument = shellCreateDocument,
+ .setExplorerNewFilePath = shellSetExplorerNewFilePath,
+ .requestSaveAs = shellRequestSaveAs,
+ .requestWebSave = shellRequestWebSave,
+ .cancelPendingSaveDialog = shellCancelPendingSaveDialog,
+ .setPendingCloseDocId = shellSetPendingCloseDocId,
+ .queueCloseAfterSave = shellQueueCloseAfterSave,
+ .trackQuitSaveInFlight = shellTrackQuitSaveInFlight,
+ .resumeSaveAllQuit = shellResumeSaveAllQuit,
+ .abortSaveAllQuit = shellAbortSaveAllQuit,
+};
+
+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;
+}
+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;
+}
+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)),
+ };
+}
+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 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();
+}
+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 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 shellSave(ctx: *anyopaque) anyerror!void {
+ return shellCtx(ctx).save();
+}
+fn shellRequestCompositeWarmup(ctx: *anyopaque) void {
+ 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();
+}
+fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) anyerror!sdk.DocHandle {
+ return shellCtx(ctx).newFile(path, grid);
+}
+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();
+}
+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();
+}
+
+/// 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];
+}
+
+pub fn docById(editor: *Editor, id: u64) ?sdk.DocHandle {
+ return editor.open_files.get(id);
+}
+
+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);
+}
+
+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);
+}
+
+/// 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);
@@ -552,12 +1300,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 {
@@ -571,8 +1318,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.host.plugins.items) |plugin| {
+ if (plugin.needsContinuousRepaint()) return true;
}
return false;
}
@@ -589,7 +1336,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| {
@@ -602,7 +1349,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);
@@ -614,7 +1361,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: {
@@ -628,7 +1375,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| {
@@ -666,9 +1413,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| {
- 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;
@@ -691,8 +1437,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 (doc.owner.isDirty(doc)) dirty_n += 1;
}
if (dirty_n == 0) continue;
@@ -706,11 +1452,12 @@ 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();
- fizzy.render.frame_index +%= 1;
+ 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();
@@ -718,7 +1465,6 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
// workspace/file iteration so that a just-loaded file is visible to the rest of this frame.
editor.processLoadingJobs();
if (comptime builtin.target.cpu.arch == .wasm32) fizzy.backend.pollWebFileIo(editor);
- editor.processPackJob();
// Build workspaces AFTER reaping load jobs so a freshly-loaded file with a new grouping
// (e.g. "Open to the side") gets its workspace created on the same frame it lands.
@@ -730,30 +1476,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) {
- fizzy.render.warmupDrawingComposites(file) catch |err| {
- dvui.log.err("Composite warmup failed: {any}", .{err});
- };
- }
- }
- }
+ for (editor.host.plugins.items) |plugin| plugin.prepareFrame();
}
{
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| {
- 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.needsContinuousRepaint()) any_drawing = true;
}
fizzy.perf.drawFrameBegin(any_drawing);
}
@@ -940,37 +1670,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.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.open_files.values()) |*file| {
- 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.endFrame();
// Sidebar area
// Since sidebar is drawn before the explorer, and we want to allow expanding the explorer
@@ -1112,7 +1818,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
defer editor.panel.paned.deinit();
if (!editor.panel.paned.dragging) {
- if (editor.activeFile()) |_| {
+ 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);
}
@@ -1141,30 +1848,32 @@ 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 {
// 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| {
- ws.center = false;
- }
+ editor.clearAllWorkspaceCenter();
}
- { // Radial Menu
-
+ { // 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", .{});
};
- processHoldOpenRadialMenu(editor);
-
- if (editor.tools.radial_menu.visible) {
- editor.drawRadialMenu() catch {
- dvui.log.err("Failed to draw radial menu", .{});
+ for (editor.host.plugins.items) |plugin| {
+ plugin.drawOverlay() catch |err| {
+ dvui.log.err("Plugin overlay draw failed: {s}", .{@errorName(err)});
};
}
}
@@ -1195,14 +1904,17 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
// out and removes itself when the timer expires.
editor.drawSaveToasts();
+ // First frame after startup: if any user plugin failed to load, tell the user once
+ // (otherwise the only trace is a log line they'll never see).
+ if (!editor.plugin_failures_dialog_shown and editor.failed_user_plugins.items.len > 0) {
+ editor.plugin_failures_dialog_shown = true;
+ Dialogs.PluginLoadFailures.request();
+ }
+
editor.saveSettingsGuarded() catch |err| {
dvui.log.err("Failed to autosave settings ({s})", .{@errorName(err)});
};
- if (comptime builtin.target.cpu.arch == .wasm32) {
- runWasmPackWorkers(editor);
- }
-
_ = editor.arena.reset(.retain_capacity);
if (editor.pending_app_close) {
@@ -1260,7 +1972,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.currentGroupingID()) catch {
std.log.err("Failed to open file: {s}", .{file});
};
}
@@ -1283,42 +1995,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();
}
},
@@ -1348,7 +2060,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;
@@ -1360,400 +2072,20 @@ 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: *Editor) void {
- const rm = &editor.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(editor.tools.radial_menu.center);
-
- const tool_count: usize = std.meta.fields(Editor.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.editor.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(Editor.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 == editor.tools.current) dvui.themeGet().color(.content, .fill) else .transparent,
- .box_shadow = if (tool == editor.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),
- });
-
- {
- editor.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {};
- }
-
- const selection_sprite = switch (editor.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],
- };
-
- 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],
- .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()) {
- editor.tools.set(tool);
- }
- if (button.clicked()) {
- editor.tools.set(tool);
- editor.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 (editor.tools.radial_menu.opened_by_press) {
- editor.tools.radial_menu.close();
- }
- }
- }
- }
- }
-}
-
-pub fn rebuildWorkspaces(editor: *Editor) !void {
-
- // Create workspaces for each grouping ID
- for (editor.open_files.values()) |*file| {
- if (!editor.workspaces.contains(file.editor.grouping)) {
- var workspace: fizzy.Editor.Workspace = .init(file.editor.grouping);
- for (editor.open_files.values()) |*f| {
- if (f.editor.grouping == file.editor.grouping) {
- workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0;
- }
- }
-
- editor.workspaces.put(fizzy.app.allocator, file.editor.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()) |*file| {
- if (file.editor.grouping == 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;
- }
- }
- }
-
- _ = editor.workspaces.orderedRemove(workspace.grouping);
- break;
- }
- }
-
- // 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) {
- continue;
- }
- }
-
- var i: usize = editor.open_files.count();
- while (i > 0) {
- i -= 1;
-
- if (editor.getFile(i)) |file| {
- if (file.editor.grouping == workspace.grouping) {
- workspace.open_file_index = i;
- break;
- }
- }
- }
- }
-}
+pub fn rebuildWorkspaces(editor: *Editor) !void {
+ 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 {
- 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;
@@ -1774,9 +2106,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];
- const file_ptr = editor.open_files.getPtr(id);
- if (file_ptr) |f| {
- if (f.isSaving()) {
+ if (editor.docById(id)) |doc| {
+ if (doc.owner.isDocumentSaving(doc)) {
i += 1;
continue;
}
@@ -1805,16 +2136,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.open_files.getPtr(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 (!fizzy.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.
@@ -1824,17 +2155,16 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
editor.requestSaveAs();
return;
}
- if (file_ptr.shouldConfirmFlatRasterSave()) {
+ 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);
- Dialogs.FlatRasterSaveWarning.pending_from_save_all_quit = true;
- Dialogs.FlatRasterSaveWarning.request(id, .save_and_close);
+ doc.owner.requestSaveConfirmation(doc, .save_and_close, true);
return;
}
// 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;
@@ -1854,9 +2184,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];
- const file_ptr = editor.open_files.getPtr(id);
- if (file_ptr) |f| {
- if (f.isSaving()) {
+ if (editor.docById(id)) |doc| {
+ if (doc.owner.isDocumentSaving(doc)) {
i += 1;
continue;
}
@@ -1886,8 +2215,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 (doc.owner.isDirty(doc)) dirty_n += 1;
}
if (dirty_n > 0) {
Dialogs.AppQuitUnsaved.request();
@@ -1899,24 +2228,31 @@ 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| {
- project.save() catch {
- dvui.log.err("Failed to save project", .{});
- };
- }
+ 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.explorer.pane = .files;
+ if (editor.host.firstVisibleSidebarView()) |view| {
+ editor.host.setActiveSidebarView(view.id);
+ }
- editor.project = Project.load(fizzy.app.allocator) catch null;
+ 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);
+ for (editor.host.plugins.items) |plugin| plugin.onFolderClose();
+ fizzy.app.allocator.free(folder);
+ editor.folder = null;
+ }
+}
+
pub fn saving(editor: *Editor) bool {
- for (editor.open_files.values()) |file| {
- if (file.saving) return true;
+ for (editor.open_files.values()) |doc| {
+ if (doc.owner.isDocumentSaving(doc)) return true;
}
return false;
}
@@ -1931,9 +2267,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.open_files.values()[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;
}
@@ -1943,11 +2279,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
@@ -1956,8 +2289,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| {
- if (std.mem.eql(u8, file.path, path)) {
+ for (editor.open_files.values(), 0..) |doc, i| {
+ if (std.mem.eql(u8, editor.docPath(doc), path)) {
editor.setActiveFile(i);
return false;
}
@@ -1969,8 +2302,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);
@@ -1995,28 +2336,37 @@ 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) !fizzy.Internal.File {
- for (editor.open_files.values()) |*file| {
- 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 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;
+ };
+
+ 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;
};
- var file = loaded orelse {
+ if (!handled) {
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
@@ -2041,39 +2391,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;
-
- editor.open_files.put(fizzy.app.allocator, file.id, file) 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 });
@@ -2084,265 +2428,8 @@ 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.
-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.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.pack_jobs.append(fizzy.app.allocator, job);
- errdefer _ = editor.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();
- }
-}
-
-/// 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| {
- 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.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()) |*open_file| {
- 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 (!fizzy.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) ?*fizzy.Internal.File {
- if (editor.getFileFromPath(path)) |file| return file;
-
- const basename = std.fs.path.basename(path);
- for (editor.open_files.values()) |*file| {
- 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;
-}
-
-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.
-pub fn processPackJob(editor: *Editor) void {
- if (editor.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;
- while (i > 0) {
- i -= 1;
- const job = editor.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.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.explorer.pane = .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.pack_jobs.items.len;
- while (i > 0) {
- i -= 1;
- const job = editor.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.pack_jobs.items) |job| {
- if (!job.done.load(.acquire)) {
- editor.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.pack_jobs.shrinkRetainingCapacity(write);
-}
-
-/// 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;
+ return editor.workbench.activeWorkspaceCanvasRectPhysical();
}
/// Cancel every in-flight load. Workers exit at the next cancellation checkpoint (after
@@ -2424,7 +2511,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));
@@ -2526,32 +2613,38 @@ pub fn drawLoadingOverlay(editor: *Editor) void {
}
}
-pub fn requestCompositeWarmup(editor: *Editor) void {
+pub fn requestPrepareFrame(editor: *Editor) void {
editor.pending_composite_warmup = true;
}
-pub fn newFile(editor: *Editor, path: []const u8, options: fizzy.Internal.File.InitOptions) !*fizzy.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 = fizzy.Internal.File.init(path, options) catch {
+ const owner = editor.host.pluginWithCreateDocument() orelse return error.NoEditorPlugin;
+ 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.open_files.put(fizzy.app.allocator, file.id, file);
+ 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.open_files.getPtr(file.id) orelse return error.FailedToCreateFile;
+ return editor.docById(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| {
- const base = std.fs.path.basename(f.path);
+ for (editor.open_files.values()) |doc| {
+ 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;
@@ -2563,481 +2656,97 @@ 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.
+/// 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 file = editor.activeFile() orelse return;
-
- Dialogs.GridLayout.presetFromFile(file);
-
- 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", file.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);
-}
-
-pub fn setActiveFile(editor: *Editor, index: usize) void {
- if (index >= editor.open_files.values().len) return;
- const file = editor.open_files.values()[index];
- const grouping = file.editor.grouping;
-
- if (editor.workspaces.getPtr(grouping)) |workspace| {
- editor.open_workspace_grouping = grouping;
- workspace.open_file_index = index;
- }
+ editor.runActiveDocCommand("gridLayout") catch |err| {
+ dvui.log.err("Grid layout command failed: {s}", .{@errorName(err)});
+ };
}
-/// 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;
+/// 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 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];
+pub fn setActiveFile(editor: *Editor, index: usize) void {
+ editor.workbench.setActiveDocIndex(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 forceCloseFile(editor: *Editor, index: usize) !void {
+ if (editor.docAt(index) != null) {
+ return editor.rawCloseFile(index);
}
+}
- return null;
+/// 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);
}
-pub fn forceCloseFile(editor: *Editor, index: usize) !void {
- if (editor.getFile(index) != null) {
- return editor.rawCloseFile(index);
- }
+/// 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 {
- if (editor.activeFile()) |file| {
- if (file.editor.transform) |*t| {
- t.accept();
- }
- }
+ try editor.runActiveDocCommand("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;
- }
- }
+ try editor.runActiveDocCommand("cancelEdit");
}
pub fn copy(editor: *Editor) !void {
- if (editor.activeFile()) |file| {
- if (file.editor.transform != null) return;
-
- if (editor.sprite_clipboard) |*clipboard| {
- fizzy.app.allocator.free(fizzy.image.bytes(clipboard.source));
- editor.sprite_clipboard = null;
- }
-
- file.editor.transform_layer.clear();
-
- var selected_layer = file.layers.get(file.selected_layer_index);
- switch (editor.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.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);
- }
- }
- }
+ try editor.runActiveDocCommand("copy");
}
pub fn paste(editor: *Editor) !void {
- if (editor.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 = fizzy.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 = fizzy.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 = fizzy.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;
- }
- }
- }
- }
+ try editor.runActiveDocCommand("paste");
}
pub fn deleteSelectedContents(editor: *Editor) void {
- if (editor.activeFile()) |file| {
- file.deleteSelectedContents();
- }
+ 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 {
- if (editor.activeFile()) |file| {
- if (file.editor.transform) |*t| {
- t.cancel();
- }
-
- var selected_layer = file.layers.get(file.selected_layer_index);
-
- switch (editor.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 = fizzy.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;
- }
- }
- }
- }
+ try editor.runActiveDocCommand("transform");
}
/// 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 (!fizzy.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.saveNeedsConfirmation(doc)) {
+ doc.owner.requestSaveConfirmation(doc, .editor_save, false);
return;
}
if (comptime builtin.target.cpu.arch == .wasm32) {
editor.requestWebSaveDialog(.save);
return;
}
- try file.saveAsync();
+ 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.
@@ -3046,12 +2755,12 @@ 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| {
- if (!file.dirty()) continue;
- if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) continue;
- if (file.shouldConfirmFlatRasterSave()) continue;
- file.saveAsync() catch |err| {
- dvui.log.err("Save All: file {s} failed: {s}", .{ file.path, @errorName(err) });
+ for (editor.open_files.values()) |doc| {
+ if (!doc.owner.isDirty(doc)) continue;
+ if (!doc.owner.documentHasRecognizedSaveExtension(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) });
};
}
}
@@ -3064,13 +2773,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 = fizzy.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);
}
@@ -3088,16 +2797,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.open_files.getPtr(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) {
@@ -3125,85 +2834,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 (fizzy.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 (fizzy.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)});
@@ -3219,15 +2883,13 @@ fn processPendingSaveAs(editor: *Editor) void {
}
pub fn undo(editor: *Editor) !void {
- if (editor.activeFile()) |file| {
- try file.history.undoRedo(file, .undo);
- }
+ const doc = editor.activeDoc() orelse return;
+ try doc.owner.undo(doc);
}
pub fn redo(editor: *Editor) !void {
- if (editor.activeFile()) |file| {
- try file.history.undoRedo(file, .redo);
- }
+ const doc = editor.activeDoc() orelse return;
+ try doc.owner.redo(doc);
}
pub fn openInFileBrowser(_: *Editor, path: []const u8) !void {
@@ -3239,8 +2901,8 @@ 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()) {
+ if (editor.open_files.get(id)) |doc| {
+ if (doc.owner.isDirty(doc)) {
Dialogs.UnsavedClose.request(id);
return;
}
@@ -3249,58 +2911,57 @@ pub fn closeFileID(editor: *Editor, id: u64) !void {
}
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 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, doc: sdk.DocHandle) void {
+ _ = doc.owner.closeDocument(doc);
+ doc.owner.unregisterDocument(doc.id);
}
pub fn rawCloseFile(editor: *Editor, index: usize) !void {
- //editor.open_file_index = 0;
- var file = editor.open_files.values()[index];
-
- 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) {
- workspace.open_file_index = i;
- break;
- }
- }
+ const doc = editor.docAt(index) orelse return;
+ const grouping = editor.docGrouping(doc);
+
+ 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);
- file.deinit();
+ 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 index = editor.open_files.getIndex(id) orelse return;
+ const grouping = editor.docGrouping(doc);
+
+ 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;
}
- file.deinit();
- _ = editor.open_files.orderedRemove(id);
- }
-}
+ break :blk null;
+ };
+ editor.workbench.adjustOpenFileIndexAfterClose(grouping, index, replacement_index);
-pub fn closeReference(editor: *Editor, index: usize) !void {
- editor.open_reference_index = 0;
- var reference: fizzy.Internal.Reference = editor.open_references.orderedRemove(index);
- reference.deinit();
+ editor.closeDocumentResources(doc);
+ _ = editor.open_files.orderedRemove(id);
}
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();
+ 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.
@@ -3319,17 +2980,7 @@ 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;
- }
+ editor.workbench.clearFileTreeTabDragDropState();
if (editor.pending_save_as_path) |p| {
fizzy.app.allocator.free(p);
@@ -3340,9 +2991,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 {
@@ -3358,24 +3006,22 @@ 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();
+ editor.panel.deinit(fizzy.app.allocator);
+ fizzy.app.allocator.destroy(editor.panel);
+
+ editor.workbench.deinitWorkspaces();
+ editor.unloadPluginLibs();
+ editor.host.deinit();
+ editor.workbench.deinit();
- editor.tools.deinit(fizzy.app.allocator);
+ // Pixel-art state (tools/colors/project/pack jobs) is torn down by
+ // `State.deinit` in `App.AppDeinit`, after this returns.
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/FileLoadJob.zig b/src/editor/FileLoadJob.zig
deleted file mode 100644
index 22b06b50..00000000
--- a/src/editor/FileLoadJob.zig
+++ /dev/null
@@ -1,163 +0,0 @@
-//! 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`.
-//!
-//! 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.
-//!
-//! 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)`.
-//! - `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.
-
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const dvui = @import("dvui");
-const perf = @import("../gfx/perf.zig");
-
-const FileLoadJob = @This();
-
-pub const Phase = enum(u8) {
- queued = 0,
- reading = 1,
- ready = 2,
- failed = 3,
- cancelled = 4,
-};
-
-allocator: std.mem.Allocator,
-
-/// Absolute path. Owned by this job.
-path: []u8,
-
-/// 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: ?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 {
- const path_copy = try allocator.dupe(u8, path);
- errdefer allocator.free(path_copy);
-
- const job = try allocator.create(FileLoadJob);
- job.* = .{
- .allocator = allocator,
- .path = path_copy,
- .target_grouping = target_grouping,
- .window = dvui.currentWindow(),
- .started_at_ns = perf.nanoTimestamp(),
- };
- return job;
-}
-
-pub fn destroy(job: *FileLoadJob) void {
- const a = job.allocator;
- a.free(job.path);
- 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);
- }
-
- if (job.cancelled.load(.monotonic)) {
- job.phase.store(@intFromEnum(Phase.cancelled), .release);
- return;
- }
-
- job.phase.store(@intFromEnum(Phase.reading), .release);
-
- const maybe_file = fizzy.Internal.File.fromPath(job.path) catch |e| {
- job.err = e;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- };
-
- const file = maybe_file orelse {
- 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.
- if (job.cancelled.load(.monotonic)) {
- var f = file;
- f.deinit();
- 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;
-}
-
-pub fn currentPhase(job: *const FileLoadJob) Phase {
- const raw = job.phase.load(.acquire);
- return switch (raw) {
- 0 => .queued,
- 1 => .reading,
- 2 => .ready,
- 3 => .failed,
- 4 => .cancelled,
- else => .queued,
- };
-}
-
-pub fn phaseLabel(phase: Phase) []const u8 {
- return switch (phase) {
- .queued => "Queued",
- .reading => "Reading",
- .ready => "Done",
- .failed => "Failed",
- .cancelled => "Cancelled",
- };
-}
diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig
index 9110c24e..9e728177 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();
@@ -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/InstalledPlugins.zig b/src/editor/InstalledPlugins.zig
new file mode 100644
index 00000000..477faaea
--- /dev/null
+++ b/src/editor/InstalledPlugins.zig
@@ -0,0 +1,89 @@
+//! Settings → Plugins: local plugin inventory (no network store yet).
+const std = @import("std");
+const dvui = @import("dvui");
+const sdk = @import("sdk");
+const fizzy = @import("../fizzy.zig");
+
+const version = sdk.version;
+const dylib = sdk.dylib;
+
+pub fn register(host: *sdk.Host) !void {
+ try host.registerSettingsSection(.{
+ .id = "shell.settings.plugins",
+ .title = "Plugins",
+ .draw = drawPlugins,
+ });
+}
+
+fn isBundled(id: []const u8) bool {
+ return std.mem.eql(u8, id, "pixi") or
+ std.mem.eql(u8, id, "workbench") or
+ std.mem.eql(u8, id, "code");
+}
+
+fn drawPlugins(_: ?*anyopaque) anyerror!void {
+ var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal });
+ defer vbox.deinit();
+
+ var host_sdk_buf: [96]u8 = undefined;
+ const host_sdk = std.fmt.bufPrint(&host_sdk_buf, "Host SDK {d}.{d}.{d} · ABI 0x{x}", .{
+ version.sdk_version.major,
+ version.sdk_version.minor,
+ version.sdk_version.patch,
+ dylib.abi_fingerprint,
+ }) catch "Host SDK ?";
+ dvui.labelNoFmt(@src(), host_sdk, .{}, .{ .margin = .{ .h = 4 } });
+
+ dvui.labelNoFmt(@src(), "Registered plugins", .{}, .{
+ .font = dvui.Font.theme(.heading),
+ .margin = .{ .y = 8 },
+ });
+
+ for (fizzy.editor.host.plugins.items, 0..) |plugin, i| {
+ const tag: []const u8 = if (isBundled(plugin.id)) " (bundled)" else "";
+ dvui.label(@src(), "• {s} — {s}{s}", .{ plugin.display_name, plugin.id, tag }, .{ .id_extra = i });
+ }
+
+ if (fizzy.editor.loaded_plugin_libs.items.len > 0) {
+ dvui.labelNoFmt(@src(), "User dylibs", .{}, .{
+ .font = dvui.Font.theme(.heading),
+ .margin = .{ .y = 8 },
+ });
+ for (fizzy.editor.loaded_plugin_libs.items, 0..) |loaded, i| {
+ const vi = loaded.version_info;
+ var ver_buf: [32]u8 = undefined;
+ const ver = std.fmt.bufPrint(&ver_buf, "{d}.{d}.{d}", .{
+ vi.plugin_version.major,
+ vi.plugin_version.minor,
+ vi.plugin_version.patch,
+ }) catch "?";
+ dvui.label(
+ @src(),
+ "• {s} — v{s} (SDK {d}.{d}.{d})",
+ .{
+ loaded.plugin_id,
+ ver,
+ vi.built_with_sdk_version.major,
+ vi.built_with_sdk_version.minor,
+ vi.built_with_sdk_version.patch,
+ },
+ .{ .id_extra = i },
+ );
+ }
+ }
+
+ if (fizzy.editor.failed_user_plugins.items.len > 0) {
+ dvui.labelNoFmt(@src(), "Load failures", .{}, .{
+ .font = dvui.Font.theme(.heading),
+ .margin = .{ .y = 8 },
+ .color_text = dvui.themeGet().color(.err, .text),
+ });
+ for (fizzy.editor.failed_user_plugins.items, 0..) |f, i| {
+ if (f.detail) |detail| {
+ dvui.label(@src(), "• {s} — {s} ({s})", .{ f.id, f.reason, detail }, .{ .id_extra = i });
+ } else {
+ dvui.label(@src(), "• {s} — {s}", .{ f.id, f.reason }, .{ .id_extra = i });
+ }
+ }
+ }
+}
diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig
index cc66b279..39a8bee6 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 });
@@ -94,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.currentGroupingID()) catch {
std.log.err("Failed to open file: {s}", .{file});
};
}
@@ -102,34 +71,6 @@ pub fn tick() !void {
}
}
- if (ke.matchBind("quick_tools")) {
- const rm = &fizzy.editor.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.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;
-
- fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size);
- }
- }
-
if (ke.matchBind("save_as") and ke.action == .down) {
fizzy.editor.requestSaveAs();
}
@@ -140,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.editor.tools.current != .selection or fizzy.editor.tools.selection_mode == .pixel) {
- if (fizzy.editor.tools.stroke_size > 1)
- fizzy.editor.tools.stroke_size -= 1;
-
- fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size);
- }
- }
-
if (ke.matchBind("delete_selection_contents")) {
if (ke.action == .down) {
fizzy.editor.deleteSelectedContents();
@@ -236,27 +151,11 @@ 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();
}
}
}
-
- if (ke.matchBind("pencil") and ke.action == .down) {
- fizzy.editor.tools.set(.pencil);
- }
- if (ke.matchBind("eraser") and ke.action == .down) {
- fizzy.editor.tools.set(.eraser);
- }
- if (ke.matchBind("bucket") and ke.action == .down) {
- fizzy.editor.tools.set(.bucket);
- }
- if (ke.matchBind("pointer") and ke.action == .down) {
- fizzy.editor.tools.set(.pointer);
- }
- if (ke.matchBind("selection") and ke.action == .down) {
- fizzy.editor.tools.set(.selection);
- }
},
else => {},
}
diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig
index 47a3b99f..abd06f3c 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);
@@ -24,6 +23,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),
@@ -109,7 +121,7 @@ pub fn draw() !dvui.App.Result {
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),
@@ -121,8 +133,8 @@ pub fn draw() !dvui.App.Result {
_ = 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))
+ 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,
@@ -134,7 +146,7 @@ pub fn draw() !dvui.App.Result {
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) {
@@ -145,8 +157,8 @@ pub fn draw() !dvui.App.Result {
// 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| {
- if (f.dirty() and fizzy.Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true;
+ for (fizzy.editor.open_files.values()) |doc| {
+ if (doc.owner.isDirty(doc) and doc.owner.documentHasRecognizedSaveExtension(doc)) break :blk true;
}
break :blk false;
};
@@ -160,7 +172,10 @@ pub fn draw() !dvui.App.Result {
fw.close();
}
}
+}
+/// Edit menu (pixi contribution).
+pub fn drawEditMenu(_: ?*anyopaque) anyerror!void {
if (menuItem(
@src(),
"Edit",
@@ -168,7 +183,6 @@ pub fn draw() !dvui.App.Result {
.{
.expand = .horizontal,
.color_text = dvui.themeGet().color(.control, .text),
- //.style = .control,
},
)) |r| {
var animator = dvui.animate(@src(), .{
@@ -186,32 +200,28 @@ pub fn draw() !dvui.App.Result {
@src(),
"Copy",
dvui.currentWindow().keybinds.get("copy") orelse .{},
- if (fizzy.editor.activeFile() != null) true else false,
+ fizzy.editor.activeDocCommandEnabled("copy"),
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile() != 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 .{},
- if (fizzy.editor.activeFile() != null) true else false,
+ fizzy.editor.activeDocCommandEnabled("paste"),
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile() != 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 });
@@ -220,12 +230,12 @@ pub fn draw() !dvui.App.Result {
@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", .{});
};
}
@@ -235,12 +245,12 @@ pub fn draw() !dvui.App.Result {
@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", .{});
};
}
@@ -252,16 +262,14 @@ pub fn draw() !dvui.App.Result {
@src(),
"Transform",
dvui.currentWindow().keybinds.get("transform") orelse .{},
- if (fizzy.editor.activeFile() != null) true else false,
+ fizzy.editor.activeDocCommandEnabled("transform"),
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile() != 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 });
@@ -270,17 +278,20 @@ pub fn draw() !dvui.App.Result {
@src(),
"Grid Layout…",
dvui.currentWindow().keybinds.get("grid_layout") orelse .{},
- if (fizzy.editor.activeFile() != null) true else false,
+ fizzy.editor.activeDocCommandEnabled("gridLayout"),
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile() != null) {
- fizzy.editor.requestGridLayoutDialog();
- fw.close();
- }
+ fizzy.editor.requestGridLayoutDialog();
+ fw.close();
}
+
+ try drawMenuSections("pixi.menu.edit");
}
+}
+/// 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),
@@ -315,6 +326,8 @@ pub fn draw() !dvui.App.Result {
fw.close();
}
+ try drawMenuSections("shell.menu.view");
+
_ = dvui.separator(@src(), .{ .expand = .horizontal });
if (menuItem(@src(), "Show DVUI Demo", .{}, .{ .expand = .horizontal }) != null) {
@@ -322,8 +335,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 +370,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 {
@@ -438,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
new file mode 100644
index 00000000..951fd98d
--- /dev/null
+++ b/src/editor/PluginLoader.zig
@@ -0,0 +1,286 @@
+//! Native runtime loader for Fizzy plugin dylibs.
+//!
+//! 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");
+const builtin = @import("builtin");
+
+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,
+ 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: DynLib,
+ path: []const u8,
+ /// 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,
+};
+
+/// Host-owned pointers injected into the plugin image immediately before `register`.
+pub const PreRegister = struct {
+ gpa: ?*const std.mem.Allocator = null,
+ arg_b: ?*anyopaque = null,
+ arg_c: ?*anyopaque = null,
+};
+
+/// 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 = try pluginFilename(name, allocator);
+ 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.Environ.getAlloc(nativeEnviron(), allocator, "FIZZY_PLUGIN_PATH")) |override| {
+ return override;
+ } else |_| {}
+ return builtinPluginPath(allocator, exe_dir, builtin_name);
+}
+
+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 } };
+}
+
+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,
+ expected_id: []const u8,
+ pre: ?PreRegister,
+) LoadError!LoadedLib {
+ var lib = DynLib.open(path) catch return error.DylibOpenFailed;
+ errdefer lib.close();
+
+ 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,
+ 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;
+
+ 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,
+ inject.arg_b,
+ inject.arg_c,
+ );
+ }
+
+ const status: dylib_api.RegisterStatus = @enumFromInt(reg_fn(host));
+ 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 = expected_id,
+ .version_info = .{
+ .plugin_version = plugin_version,
+ .built_with_sdk_version = built_with,
+ .min_sdk_version = min_sdk,
+ .declared_id = if (get_plugin_id) |f| std.mem.span(f()) else null,
+ },
+ .set_globals = set_globals,
+ .set_dvui_context = set_ctx,
+ .set_render_bridge = set_bridge,
+ };
+}
+
+fn allowAbiWarn() bool {
+ if (builtin.mode != .Debug) return false;
+ if (std.c.getenv("FIZZY_PLUGIN_ABI_WARN")) |v| {
+ return std.mem.eql(u8, std.mem.span(v), "1");
+ }
+ return false;
+}
+
+/// Best-effort read of version exports from a dylib (for failure diagnostics).
+pub fn probeVersionInfo(path: []const u8) ?PluginVersionInfo {
+ var lib = DynLib.open(path) catch return null;
+ defer lib.close();
+ const get_sdk_version = lookupVersionFn(&lib, dylib_api.symbol_sdk_version);
+ const get_min_sdk = lookupVersionFn(&lib, dylib_api.symbol_min_sdk_version);
+ const get_plugin_version = lookupVersionFn(&lib, dylib_api.symbol_plugin_version);
+ return .{
+ .plugin_version = readVersionTriplet(get_plugin_version),
+ .built_with_sdk_version = readVersionTriplet(get_sdk_version),
+ .min_sdk_version = readVersionTriplet(get_min_sdk),
+ };
+}
+
+test "builtin plugin path joins exe_dir/plugins" {
+ const path = try builtinPluginPath(std.testing.allocator, "/app", "pixi");
+ defer std.testing.allocator.free(path);
+ switch (builtin.os.tag) {
+ .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
new file mode 100644
index 00000000..6ef28fc1
--- /dev/null
+++ b/src/editor/PluginLoader_stub.zig
@@ -0,0 +1,29 @@
+//! 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 {
+ return error.Unsupported;
+}
+
+pub fn loadAndRegister(_: anytype, _: []const u8) LoadError!void {
+ return error.Unsupported;
+}
diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig
index 83a012df..b506a23d 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,
@@ -84,41 +44,21 @@ 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,
-
-/// 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 },
+font_mono_size: f32 = 9,
/// 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, "pixi") 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/Sidebar.zig b/src/editor/Sidebar.zig
index d2cebba4..521995dc 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,21 @@ 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| {
+ if (view.hidden) continue;
+ 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 +54,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 +73,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 +92,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 +104,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 +138,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/WebFileIo.zig b/src/editor/WebFileIo.zig
index 2582e00d..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.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 });
}
@@ -79,19 +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| {
- editor.open_files.put(fizzy.app.allocator, file.id, file) 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/Workspace.zig b/src/editor/Workspace.zig
deleted file mode 100644
index a2b0e5de..00000000
--- a/src/editor/Workspace.zig
+++ /dev/null
@@ -1,2433 +0,0 @@
-const std = @import("std");
-const builtin = @import("builtin");
-
-const dvui = @import("dvui");
-const fizzy = @import("../fizzy.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.
-/// Workspaces can potentially draw open files, the project logo, or the project pane
-/// containing the packed atlas.
-pub const Workspace = @This();
-
-open_file_index: usize = 0,
-grouping: u64 = 0,
-center: bool = false,
-
-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,
-
-/// 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
-/// 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,
-
-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",
- };
-}
-
-const handle_size = 10;
-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 logo_colors: [12]fizzy.math.Color = [_]fizzy.math.Color{
- color_1, color_1, color_1,
- color_2, color_2, color_3,
- color_4, color_3, color_0,
- color_3, color_0, color_0,
-};
-
-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,
- .gravity_y = 0.0,
- .id_extra = @intCast(self.grouping),
- });
- defer vbox.deinit();
-
- // Set the active workspace grouping when the user clicks on the workspace rect
- for (dvui.events()) |*e| {
- if (!vbox.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"))) {
- fizzy.editor.open_workspace_grouping = self.grouping;
- }
- }
- }
-
- if (fizzy.editor.explorer.pane == .project) {
- self.drawProject();
- } else {
- self.drawTabs();
- try self.drawCanvas();
- }
-
- return .ok;
-}
-
-/// 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 {
- return dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .background = background,
- .color_fill = content_color,
- .id_extra = @intCast(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 {
- 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),
- });
-}
-
-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;
-
- // Handle dragging of tabs between workspace reorderables (tab bars)
- defer self.processTabsDrag();
-
- {
- var tabs_anim = dvui.animate(@src(), .{ .duration = 500_000, .kind = .vertical, .easing = dvui.easing.outBack }, .{});
- defer tabs_anim.deinit();
-
- var tabs_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .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,
- .corner_radius = dvui.Rect.all(0),
- .id_extra = @intCast(self.grouping),
- });
- defer scroll_area.deinit();
-
- {
- var tabs = dvui.reorder(@src(), .{ .drag_name = "tab_drag" }, .{
- .expand = .none,
- .background = false,
- });
- defer tabs.deinit();
-
- var tabs_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .id_extra = @intCast(self.grouping),
- });
- defer tabs_hbox.deinit();
-
- const files = fizzy.editor.open_files.values();
- const files_len = files.len;
-
- // 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 (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;
- 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;
- if (files[j].editor.grouping == 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) {
- if (files[j].editor.grouping == self.grouping) {
- next_same_group_index = j;
- break;
- }
- }
- }
-
- for (files, 0..) |file, i| {
- const is_fizzy_file = fizzy.Internal.File.isFizzyExtension(std.fs.path.extension(file.path));
-
- if (file.editor.grouping != self.grouping) continue;
-
- var reorderable = tabs.reorderable(@src(), .{}, .{
- .expand = .vertical,
- .id_extra = i,
- .padding = dvui.Rect.all(0),
- .margin = dvui.Rect.all(0),
- });
- defer reorderable.deinit();
-
- const selected = self.open_file_index == i and fizzy.editor.open_workspace_grouping == self.grouping;
-
- var anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{});
- defer anim.deinit();
-
- 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(fizzy.editor.settings.content_opacity),
- .background = true,
- .id_extra = i,
- .padding = dvui.Rect.all(2),
- .margin = dvui.Rect.all(0),
- });
-
- defer hbox.deinit();
-
- const tab_hovered = fizzy.dvui.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);
- }
- hbox.drawBackground();
-
- if (!selected and active_in_this_group and tabs.drag_point == null) {
- // Draw edge shadow between the active tab and its neighbours within this grouping.
- 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, .{});
- }
- }
-
- 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, .{});
- }
- }
- }
-
- if (reorderable.removed()) {
- self.tabs_removed_index = i;
- } else if (reorderable.insertBefore()) {
- self.tabs_insert_before_index = i;
- }
-
- if (is_fizzy_file) {
- _ = fizzy.dvui.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,
- .padding = dvui.Rect.all(4),
- });
- } else {
- dvui.icon(@src(), "file_icon", icons.tvg.lucide.file, .{
- .stroke_color = if (is_fizzy_file) .transparent else dvui.themeGet().color(.control, .text),
- }, .{
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- });
- }
-
- dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{
- .color_text = if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
- .padding = dvui.Rect.all(4),
- .gravity_y = 0.5,
- });
-
- const close_inner = fizzy.dvui.windowHeaderCloseInnerSide();
- const close_pad = fizzy.dvui.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 },
- });
- defer status_close_box.deinit();
-
- // Saving has priority over hover/close/dirty indicators: the user wants visible
- // confirmation that the save is in flight, and the slot's size matches the close
- // 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_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);
-
- if (save_blocks_tab_close) {
- fizzy.dvui.bubbleSpinner(@src(), .{
- .id_extra = i *% 16 + 5,
- .expand = .none,
- .min_size_content = .{ .w = close_inner, .h = close_inner },
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .color_text = dvui.themeGet().color(.window, .text),
- }, .{
- .complete_elapsed_ns = save_flash_elapsed,
- });
- } else if (save_in_check_phase and !tab_hovered) {
- fizzy.dvui.bubbleSpinner(@src(), .{
- .id_extra = i *% 16 + 5,
- .expand = .none,
- .min_size_content = .{ .w = close_inner, .h = close_inner },
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .color_text = dvui.themeGet().color(.window, .text),
- }, .{
- .complete_elapsed_ns = save_flash_elapsed,
- });
- } else if (tab_hovered) {
- var tab_close_button: dvui.ButtonWidget = undefined;
- tab_close_button.init(@src(), .{ .draw_focus = false }, fizzy.dvui.windowHeaderCloseButtonOptions(.{
- .expand = .none,
- .min_size_content = .{ .w = close_inner, .h = close_inner },
- .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()) {
- 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),
- }, .{
- .expand = .ratio,
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .id_extra = i *% 16 + 2,
- });
- }
-
- if (tab_close_button.clicked()) {
- fizzy.editor.closeFileID(file.id) catch |err| {
- dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) });
- };
- break;
- }
- } else if (selected and !file.dirty()) {
- const tab_text = dvui.themeGet().color(.window, .text);
- var ghost_close: dvui.ButtonWidget = undefined;
- ghost_close.init(@src(), .{ .draw_focus = false }, fizzy.dvui.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()) {
- fizzy.editor.closeFileID(file.id) catch |err| {
- dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) });
- };
- break;
- }
- } else if (file.dirty()) {
- 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,
- });
- }
-
- loop: for (dvui.events()) |*e| {
- if (!hbox.matchEvent(e)) {
- continue;
- }
-
- switch (e.evt) {
- .mouse => |me| {
- if (me.action == .press and me.button.pointer()) {
- fizzy.editor.setActiveFile(i);
- 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); // reorder grabs capture
- break :loop;
- }
- }
- }
- },
-
- else => {},
- }
- }
- }
- if (tabs.finalSlot()) {
- self.tabs_insert_before_index = fizzy.editor.open_files.values().len;
- }
- }
- }
-}
-
-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 > insert_before) {
- std.mem.swap(fizzy.Internal.File, &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(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(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
- fizzy.editor.setActiveFile(insert_before);
- }
- }
-
- self.tabs_removed_index = null;
- self.tabs_insert_before_index = null;
- } else { // Dragging from another workspace
- 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(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.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(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.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(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.setActiveFile(insert_before);
- }
- }
-
- self.tabs_removed_index = null;
- self.tabs_insert_before_index = null;
-
- workspace.tabs_removed_index = null;
- workspace.tabs_insert_before_index = null;
- }
- }
- }
- }
-}
-
-/// 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];
- if (tab_bar_workspace) |workspace| {
- if (workspace.open_file_index == editor.open_files.getIndex(dragged_file.id)) {
- for (editor.open_files.values()) |f| {
- 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;
- }
- }
- }
- } else {
- for (editor.workspaces.values()) |*w| {
- if (w.open_file_index == drag_index) {
- for (editor.open_files.values()) |f| {
- 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;
- }
- }
- }
- }
- }
-}
-
-const WorkspaceTabDragSrc = union(enum) {
- tab_bar: struct { ws: *Workspace, index: usize },
- tree_open: usize,
- tree_closed: []const u8,
- none,
-
- fn resolve(editor: *Editor) WorkspaceTabDragSrc {
- for (editor.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.getFileFromPath(p)) |f| {
- const idx = editor.open_files.getIndex(f.id) orelse return .none;
- return .{ .tree_open = idx };
- }
- return .{ .tree_closed = p };
- }
- return .none;
- }
-};
-
-/// Responsible for handling the cross-widget drag of tabs between multiple workspaces or between tabs and workspaces.
-/// 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();
- return;
- }
-
- const drag_src = WorkspaceTabDragSrc.resolve(fizzy.editor);
- switch (drag_src) {
- .none => return,
- else => {},
- }
-
- events_loop: for (dvui.events()) |*e| {
- if (!dvui.eventMatch(e, .{ .id = data.id, .r = data.rectScale().r, .drag_name = "tab_drag" })) continue;
-
- switch (drag_src) {
- .none => unreachable,
- .tab_bar => |tb| {
- const workspace = tb.ws;
- const drag_index = tb.index;
-
- var right_side = data.rectScale().r;
- 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 (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),
- });
- }
-
- if (e.evt == .mouse and 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);
- fizzy.editor.clearFileTreeTabDragDropState();
-
- repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
- var dragged_file = &fizzy.editor.open_files.values()[drag_index];
- dragged_file.editor.grouping = fizzy.editor.newGroupingID();
- fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
- }
- } else if (data.rectScale().r.contains(e.evt.mouse.p)) {
- if (e.evt == .mouse and 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 and 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);
- fizzy.editor.clearFileTreeTabDragDropState();
-
- repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
- var dragged_file = &fizzy.editor.open_files.values()[drag_index];
- 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;
- }
- }
- },
- .tree_open => |drag_index| {
- var right_side = data.rectScale().r;
- 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 (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),
- });
- }
-
- if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
- e.handle(@src(), data);
- dvui.dragEnd();
- dvui.refresh(null, @src(), data.id);
- fizzy.editor.clearFileTreeTabDragDropState();
-
- repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
- var dragged_file = &fizzy.editor.open_files.values()[drag_index];
- dragged_file.editor.grouping = fizzy.editor.newGroupingID();
- fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
- }
- } else if (data.rectScale().r.contains(e.evt.mouse.p)) {
- if (e.evt == .mouse and 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 and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
- e.handle(@src(), data);
- dvui.dragEnd();
- dvui.refresh(null, @src(), data.id);
- fizzy.editor.clearFileTreeTabDragDropState();
-
- repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
- var dragged_file = &fizzy.editor.open_files.values()[drag_index];
- 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;
- }
- }
- },
- .tree_closed => |path| {
- var right_side = data.rectScale().r;
- 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 (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),
- });
- }
-
- if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
- e.handle(@src(), data);
- dvui.dragEnd();
- dvui.refresh(null, @src(), data.id);
- const new_g = fizzy.editor.newGroupingID();
- const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, new_g) catch {
- fizzy.editor.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;
- }
- // 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();
- }
- } else if (data.rectScale().r.contains(e.evt.mouse.p)) {
- if (e.evt == .mouse and 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 and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
- e.handle(@src(), data);
- dvui.dragEnd();
- dvui.refresh(null, @src(), data.id);
- const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, self.grouping) catch {
- fizzy.editor.clearFileTreeTabDragDropState();
- continue :events_loop;
- };
- if (maybe_idx) |idx| {
- repointWorkspacesAfterTabDrag(fizzy.editor, 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();
- }
- }
- },
- }
- }
-}
-
-pub fn drawCanvas(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 has_files = fizzy.editor.open_files.values().len > 0;
-
- var canvas_vbox = workspaceMainCanvasVbox(content_color, has_files, 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();
- }
- 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;
- }
-
- const file = &fizzy.editor.open_files.values()[self.open_file_index];
- file.editor.canvas.id = canvas_vbox.data().id;
- file.editor.workspace = self;
-
- 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;
-
- 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);
- }
- }
- } else {
- var box = workspaceEmptyStateCard(content_color, self.grouping);
- defer box.deinit();
-
- // Make sure alpha is 1 before we draw the homepage, as the logo hover animation breaks if alpha is not 1
- const alpha = dvui.alpha(1.0);
- dvui.alphaSet(1.0);
- defer dvui.alphaSet(alpha);
-
- try self.drawHomePage(canvas_vbox);
- }
-}
-
-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, canvas_vbox: *dvui.BoxWidget) void {
- const file = &fizzy.editor.open_files.values()[self.open_file_index];
- if (file.editor.transform) |*transform| {
- var rect = canvas_vbox.data().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 },
- .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, canvas_vbox: *dvui.BoxWidget) 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(canvas_vbox.data().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 = canvas_vbox.data().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, canvas_vbox: *dvui.BoxWidget) 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 = canvas_vbox.data().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(canvas_vbox.data().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;
- const logo_height = 5;
-
- const logo_vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .none,
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .background = false,
- .padding = dvui.Rect.all(10),
- });
- defer logo_vbox.deinit();
-
- { // Logo
-
- const vbox2 = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .none,
- .gravity_x = 0.5,
- .min_size_content = .{ .w = logo_pixel_size * logo_width, .h = logo_pixel_size * logo_height },
- .padding = dvui.Rect.all(20),
- });
- defer vbox2.deinit();
-
- for (0..4) |i| {
- const hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .min_size_content = .{ .w = logo_pixel_size * logo_width, .h = logo_pixel_size },
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- .id_extra = i,
- });
- defer hbox.deinit();
-
- for (0..3) |j| {
- const index = i * logo_width + j;
- var fizzy_color = logo_colors[index];
-
- 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.value[3] = 1.0;
- }
-
- const color = fizzy_color.bytes();
-
- const pixel = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .min_size_content = .{ .w = logo_pixel_size, .h = logo_pixel_size },
- .id_extra = index,
- .background = false,
- .color_fill = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] },
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- });
-
- const rect = pixel.data().rect.outset(.{ .x = 0, .y = 0 });
- const rs = pixel.data().rectScale();
- pixel.deinit();
-
- if (fizzy_color.value[3] <= 0.0) continue;
-
- try drawBubble(rect, rs, color, index);
- }
- }
- }
-
- var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .none,
- .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();
-
- fizzy.dvui.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()) {
- fizzy.editor.requestNewFileDialog();
- }
- }
- {
- 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();
-
- fizzy.dvui.labelWithKeybind(
- "Open Folder",
- dvui.currentWindow().keybinds.get("open_folder") 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()) {
- fizzy.backend.showOpenFolderDialog(setProjectFolderCallback, null);
- }
- }
-
- {
- 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();
-
- fizzy.dvui.labelWithKeybind(
- "Open Files",
- dvui.currentWindow().keybinds.get("open_files") orelse .{},
- true,
- .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 },
- .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0, .font = dvui.Font.theme(.heading) },
- );
-
- 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, fizzy.editor.open_workspace_grouping) catch {
- // std.log.err("Failed to open file: {s}", .{file});
- // };
- // }
- // }
-
- fizzy.backend.showOpenFileDialog(openFilesCallback, &.{
- .{ .name = "Image Files", .pattern = "fizzy;png;jpg;jpeg" },
- }, "", null);
- }
- }
- vbox.deinit();
-
- const spacer = dvui.spacer(@src(), .{ .expand = .horizontal, .min_size_content = .{ .h = 30 } });
-
- {
- var recents_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .none,
- .gravity_x = 0.5,
- .max_size_content = .{ .h = (canvas_vbox.data().rect.h - spacer.rect.y) / 3.0, .w = canvas_vbox.data().rect.w / 2.0 },
- });
- defer recents_box.deinit();
-
- var scroll_area = dvui.scrollArea(@src(), .{}, .{
- .expand = .both,
- .color_border = dvui.themeGet().color(.control, .fill),
- .corner_radius = dvui.Rect.all(8),
- .color_fill = .transparent,
- });
- defer scroll_area.deinit();
-
- var i: usize = fizzy.editor.recents.folders.items.len;
- while (i > 0) : (i -= 1) {
- var anim = dvui.animate(@src(), .{
- .kind = .horizontal,
- .duration = 150_000 + 150_000 * @as(i32, @intCast(i)),
- .easing = dvui.easing.outBack,
- }, .{
- .id_extra = i,
- .expand = .horizontal,
- });
- defer anim.deinit();
-
- const folder = fizzy.editor.recents.folders.items[i - 1];
- if (dvui.button(@src(), folder, .{
- .draw_focus = false,
- }, .{
- .expand = .horizontal,
- .font = dvui.Font.theme(.mono).larger(-2.0),
- .id_extra = i,
- .margin = dvui.Rect.all(1),
- .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),
- .color_text = dvui.themeGet().color(.control, .text).opacity(0.5),
- })) {
- try fizzy.editor.setProjectFolder(folder);
- }
- }
- }
-}
-
-pub fn drawBubble(rect: dvui.Rect, rs: dvui.RectScale, color: [4]u8, _: usize) !void {
- var bubble_h: f32 = rect.h;
- for (dvui.events()) |evt| {
- switch (evt.evt) {
- .mouse => |me| {
- const dx = @abs(me.p.x - (rs.r.x + rs.r.w * 0.5)) / rs.s;
- const dy = @abs(me.p.y - (rs.r.y - rs.r.h * 0.5)) / rs.s;
- const distance = @sqrt(dx * dx + dy * dy);
- const max_distance: f32 = rect.h * 2.0;
-
- var t = distance / max_distance;
- if (t > 1.0) t = 1.0;
- if (t < 0.0) t = 0.0;
- bubble_h = @ceil(rect.h - rect.h * t);
- },
- else => {},
- }
- }
-
- // Derive the pill's physical rect directly from the base's physical rect
- // (no dvui.box layout round-trip). This guarantees identical left/right
- // edges between base and pill at any scale or splitter ratio.
- const base_phys = rs.r.outsetAll(1);
- const bubble_h_phys = @ceil(bubble_h * rs.s);
- const bubble_phys = dvui.Rect.Physical{
- .x = base_phys.x,
- .y = rs.r.y - bubble_h_phys,
- .w = base_phys.w,
- .h = bubble_h_phys,
- };
-
- var path = dvui.Path.Builder.init(dvui.currentWindow().lifo());
- defer path.deinit();
-
- path.addRect(base_phys, dvui.Rect.Physical.all(0));
-
- if (bubble_phys.h > 0) {
- const rad_x = rs.r.w / 2.0;
- const rad_y = rs.r.h / 2.0;
- const r = bubble_phys;
- const tl = dvui.Point.Physical{ .x = r.x + rad_x, .y = r.y + rad_x };
- const bl = dvui.Point.Physical{ .x = r.x, .y = r.y + r.h };
- const br = dvui.Point.Physical{ .x = r.x + r.w, .y = r.y + r.h };
- const tr = dvui.Point.Physical{ .x = r.x + r.w - rad_y, .y = r.y + rad_y };
- path.addArc(tl, rad_x, dvui.math.pi * 1.5, dvui.math.pi, true);
- path.addArc(bl, 0, dvui.math.pi, dvui.math.pi * 0.5, true);
- path.addArc(br, 0, dvui.math.pi * 0.5, 0, true);
- path.addArc(tr, rad_y, dvui.math.pi * 2.0, dvui.math.pi * 1.5, false);
- }
-
- path.build().fillConvex(.{ .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, .fade = 1.0 });
-}
-
-// 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 {
- dvui.log.err("Failed to set project folder: {s}", .{f[0]});
- };
- }
-}
-
-pub fn openFilesCallback(files: ?[][:0]const u8) void {
- if (files) |f| {
- for (f) |file| {
- _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch {
- dvui.log.err("Failed to open file: {s}", .{file});
- };
- }
- }
-}
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..48aafd04 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 (doc.owner.isDirty(doc)) n += 1;
}
return n;
}
@@ -112,8 +112,8 @@ fn onSaveAllAndQuit() !void {
fizzy.dvui.closeFloatingDialogAnchored();
fizzy.editor.quit_save_all_ids.clearRetainingCapacity();
- for (fizzy.editor.open_files.values()) |f| {
- if (f.dirty()) try fizzy.editor.quit_save_all_ids.append(fizzy.app.allocator, f.id);
+ for (fizzy.editor.open_files.values()) |doc| {
+ 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/Dialogs.zig b/src/editor/dialogs/Dialogs.zig
index cdcf0ab2..13920b3d 100644
--- a/src/editor/dialogs/Dialogs.zig
+++ b/src/editor/dialogs/Dialogs.zig
@@ -1,16 +1,15 @@
-const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
const Dialogs = @This();
-pub const NewFile = @import("NewFile.zig");
-pub const Export = @import("Export.zig");
+// 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 = @import("GridLayout.zig");
-pub const FlatRasterSaveWarning = @import("FlatRasterSaveWarning.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
@@ -30,75 +29,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 {
- {
- 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/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 b8aa3466..bcf0dc1e 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 dvui = @import("dvui");
-const FlatRasterSaveWarning = @import("FlatRasterSaveWarning.zig");
pub fn request(file_id: u64) void {
var mutex = fizzy.dvui.dialog(@src(), .{
@@ -21,8 +20,8 @@ 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 "?";
- 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 {
@@ -93,12 +92,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: *fizzy.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);
@@ -106,13 +101,13 @@ fn beginSaveAndClose(file: *fizzy.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.open_files.getPtr(file_id) orelse return;
- if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) {
+ const doc = fizzy.editor.docById(file_id) orelse return;
+ 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;
@@ -120,16 +115,12 @@ fn onSaveAndClose(file_id: u64) !void {
fizzy.editor.requestSaveAs();
return;
}
- if (file.shouldConfirmFlatRasterSave()) {
- FlatRasterSaveWarning.pending_from_save_all_quit = false;
+ if (doc.owner.saveNeedsConfirmation(doc)) {
fizzy.dvui.closeFloatingDialogAnchored();
- FlatRasterSaveWarning.request(file_id, .save_and_close);
+ doc.owner.requestSaveConfirmation(doc, .save_and_close, false);
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/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index 56bb771c..20bb8ec6 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -2,28 +2,24 @@ 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;
const App = fizzy.App;
const Editor = fizzy.Editor;
-const Packer = fizzy.Packer;
const nfd = @import("nfd");
pub const Explorer = @This();
-pub const files = @import("files.zig");
-pub const Tools = @import("tools.zig");
-pub const Sprites = @import("sprites.zig");
+pub const files = workbench.files;
// pub const animations = @import("animations.zig");
// pub const keyframe_animations = @import("keyframe_animations.zig");
-pub const project = @import("project.zig");
+// The pixel-art project view is contributed by the plugin via `Host.registerSidebarView`,
+// not re-exported here.
pub const settings = @import("settings.zig");
-sprites: Sprites = .{},
-tools: Tools = .{},
-pane: Pane = .files,
paned: *fizzy.dvui.PanedWidget = undefined,
scroll_info: dvui.ScrollInfo = .{
.horizontal = .auto,
@@ -31,8 +27,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,
@@ -43,16 +37,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 +48,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,21 +108,14 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result {
.background = false,
});
- if (explorer.pane != .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;
+ if (comptime workbench.plugin.has_file_tree) {
+ if (!fizzy.editor.host.isActiveSidebarView(fizzy.Editor.workbench_files_view)) {
+ fizzy.editor.resetFileTreeWhenFilesHidden();
}
}
- 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 +234,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/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/editor/panel/Panel.zig b/src/editor/panel/Panel.zig
index bb82654d..b5cedec9 100644
--- a/src/editor/panel/Panel.zig
+++ b/src/editor/panel/Panel.zig
@@ -1,63 +1,105 @@
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 Packer = fizzy.Packer;
+const panel_layout = @import("panel_layout.zig");
+const PanelWorkspace = @import("PanelWorkspace.zig");
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,
-};
+/// 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 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 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 => {},
- }
-
var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
.expand = .both,
- .background = true,
- .color_fill = content_color,
+ .background = false,
});
defer vbox.deinit();
- switch (panel.pane) {
- .sprites => try panel.sprites.draw(),
+ const host = &fizzy.editor.host;
+ if (host.bottom_views.items.len == 0) return .ok;
+
+ panel.ensureViewGroupings(host);
+ try panel_layout.rebuildWorkspaces(panel, host);
+
+ 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);
+}
+
+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/widgets/Widgets.zig b/src/editor/widgets/Widgets.zig
deleted file mode 100644
index 5ef58bf5..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("FileWidget.zig");
-pub const ImageWidget = @import("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 a1876ff3..c443f78c 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -1,6 +1,8 @@
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,
@@ -10,80 +12,50 @@ 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("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 perf = @import("gfx/perf.zig");
-pub const water_surface = @import("gfx/water_surface.zig");
-pub const math = @import("math/math.zig");
+pub const fs = core.fs;
+pub const image = core.image;
+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 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");
-pub const Packer = @import("tools/Packer.zig");
+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("pixi")` directly.
+pub const pixi_mod = @import("pixi");
+
// Global pointers
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
-/// 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");
-};
-
-/// Frame-by-frame sprite animation
-pub const Animation = @import("Animation.zig");
-
-/// Contains lists of sprites and animations
-pub const Atlas = @import("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");
-
-/// Contains information such as the name, visibility and collapse settings of a texture layer
-pub const Layer = @import("Layer.zig");
-
-/// Source location within the atlas texture and origin location
-pub const Sprite = @import("Sprite.zig");
+pub var packer: *pixi_mod.Packer = undefined;
/// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web
/// builds, where `builtin.os.tag` is always `.freestanding`.
-pub const platform = @import("platform.zig");
+pub const platform = core.platform;
+
+/// Plugin SDK surface
+pub const sdk = @import("sdk");
/// 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).
/// 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 = @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/code/build.zig b/src/plugins/code/build.zig
new file mode 100644
index 00000000..d1a87ba6
--- /dev/null
+++ b/src/plugins/code/build.zig
@@ -0,0 +1,20 @@
+//! Standalone build for the code plugin — the canonical third-party shape.
+//! `cd src/plugins/code && zig build` produces `code.`. Identical in form to
+//! any external plugin: declare `fizzy`, call `fizzy.plugin.create` + `.install`. The
+//! fizzy-internal static-embed build lives separately in `static/` and is driven by the
+//! root build. See docs/PLUGINS.md.
+const std = @import("std");
+const fizzy = @import("fizzy");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const lib = fizzy.plugin.create(b, .{
+ .name = "code",
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("root.zig"),
+ });
+ fizzy.plugin.install(b, lib, .{});
+}
diff --git a/src/plugins/code/build.zig.zon b/src/plugins/code/build.zig.zon
new file mode 100644
index 00000000..359e73c3
--- /dev/null
+++ b/src/plugins/code/build.zig.zon
@@ -0,0 +1,20 @@
+.{
+ .name = .code,
+ .version = "0.0.0",
+ .minimum_zig_version = "0.16.0",
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "root.zig",
+ "code.zig",
+ "src",
+ "queries",
+ "static",
+ },
+ .fingerprint = 0x77153098cc8cce17,
+ .dependencies = .{
+ .fizzy = .{
+ .path = "../../..",
+ },
+ },
+}
diff --git a/src/plugins/code/code.zig b/src/plugins/code/code.zig
new file mode 100644
index 00000000..b4af1e06
--- /dev/null
+++ b/src/plugins/code/code.zig
@@ -0,0 +1,22 @@
+//! Code plugin root module **and** intra-plugin import hub.
+//!
+//! - The shell resolves `@import("code")` to this file when the plugin is compiled into the app
+//! (static embed) and reaches its public surface here (`plugin`, document types).
+//! - Files under `src/` import it as `../code.zig` for the shared deps (`sdk`/`core`/`dvui`)
+//! and sibling types — the conventional `.zig` namespace.
+//!
+//! It must sit at the plugin root: a Zig module cannot import files above its root file's
+//! directory, so this has to be beside `src/` to re-export from it. The build-side static-embed
+//! glue lives in `static/`. A minimal/third-party plugin only needs this file if it embeds
+//! statically or wants a shared import hub.
+const std = @import("std");
+
+pub const sdk = @import("sdk");
+pub const core = @import("core");
+pub const dvui = @import("dvui");
+
+pub const 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/code/queries/zig.scm b/src/plugins/code/queries/zig.scm
new file mode 100644
index 00000000..f4d1217a
--- /dev/null
+++ b/src/plugins/code/queries/zig.scm
@@ -0,0 +1,289 @@
+; Variables — catch-all first; more specific rules below override (last capture wins).
+(identifier) @variable
+
+; Parameters
+(parameter
+ name: (identifier) @variable.parameter)
+
+(payload
+ (identifier) @variable.parameter)
+
+; Types
+(parameter
+ type: (identifier) @type)
+
+(variable_declaration
+ (identifier) @type
+ "="
+ [
+ (struct_declaration)
+ (enum_declaration)
+ (union_declaration)
+ (opaque_declaration)
+ ])
+
+[
+ (builtin_type)
+ "anyframe"
+] @type.builtin
+
+; Constants
+[
+ "null"
+ "unreachable"
+ "undefined"
+] @constant.builtin
+
+(field_expression
+ .
+ member: (identifier) @constant)
+
+(enum_declaration
+ (container_field
+ type: (identifier) @constant))
+
+; Labels
+(block_label
+ (identifier) @label)
+
+(break_label
+ (identifier) @label)
+
+; Fields
+(field_initializer
+ .
+ (identifier) @variable.member)
+
+(field_expression
+ (_)
+ member: (identifier) @variable.member)
+
+(container_field
+ name: (identifier) @variable.member)
+
+(initializer_list
+ (assignment_expression
+ left: (field_expression
+ .
+ member: (identifier) @variable.member)))
+
+; Functions
+(call_expression
+ function: (builtin_function
+ (builtin_identifier) @function.call))
+
+(call_expression
+ function: (identifier) @function.call)
+
+(call_expression
+ function: (field_expression
+ member: (identifier) @function.call))
+
+(function_declaration
+ name: (identifier) @function)
+
+; Modules (@import / @cImport — builtin stays @function.builtin)
+(variable_declaration
+ (identifier) @module
+ (builtin_function
+ (builtin_identifier) @function.builtin
+ (#any-of? @function.builtin "@import" "@cImport")))
+
+; Builtins
+[
+ "c"
+ "..."
+] @variable.builtin
+
+((identifier) @variable.builtin
+ (#eq? @variable.builtin "_"))
+
+(calling_convention
+ (identifier) @variable.builtin)
+
+; Keywords
+[
+ "asm"
+ "defer"
+ "errdefer"
+ "test"
+ "error"
+ "const"
+ "var"
+] @keyword
+
+[
+ "struct"
+ "union"
+ "enum"
+ "opaque"
+] @keyword.type
+
+[
+ "async"
+ "await"
+ "suspend"
+ "nosuspend"
+ "resume"
+] @keyword.coroutine
+
+"fn" @keyword.function
+
+[
+ "and"
+ "or"
+ "orelse"
+] @keyword.operator
+
+"return" @keyword.return
+
+[
+ "if"
+ "else"
+ "switch"
+] @keyword.conditional
+
+[
+ "for"
+ "while"
+ "break"
+ "continue"
+] @keyword.repeat
+
+[
+ "usingnamespace"
+ "export"
+] @keyword.import
+
+[
+ "try"
+ "catch"
+] @keyword.exception
+
+[
+ "volatile"
+ "allowzero"
+ "noalias"
+ "addrspace"
+ "align"
+ "callconv"
+ "linksection"
+ "pub"
+ "inline"
+ "noinline"
+ "extern"
+ "comptime"
+ "packed"
+ "threadlocal"
+] @keyword.modifier
+
+; Operator
+[
+ "="
+ "*="
+ "*%="
+ "*|="
+ "/="
+ "%="
+ "+="
+ "+%="
+ "+|="
+ "-="
+ "-%="
+ "-|="
+ "<<="
+ "<<|="
+ ">>="
+ "&="
+ "^="
+ "|="
+ "!"
+ "~"
+ "-"
+ "-%"
+ "&"
+ "=="
+ "!="
+ ">"
+ ">="
+ "<="
+ "<"
+ "&"
+ "^"
+ "|"
+ "<<"
+ ">>"
+ "<<|"
+ "+"
+ "++"
+ "+%"
+ "-%"
+ "+|"
+ "-|"
+ "*"
+ "/"
+ "%"
+ "**"
+ "*%"
+ "*|"
+ "||"
+ ".*"
+ ".?"
+ "?"
+ ".."
+] @operator
+
+; Literals
+(character) @character
+
+([
+ (string)
+ (multiline_string)
+] @string
+ (#set! "priority" 95))
+
+(integer) @number
+
+(float) @number.float
+
+(boolean) @boolean
+
+(escape_sequence) @string.escape
+
+; Punctuation
+[
+ "["
+ "]"
+ "("
+ ")"
+ "{"
+ "}"
+] @punctuation.bracket
+
+[
+ ";"
+ "."
+ ","
+ ":"
+ "=>"
+ "->"
+] @punctuation.delimiter
+
+(payload
+ "|" @punctuation.bracket)
+
+; Comments
+(comment) @comment
+
+((comment) @comment.documentation
+ (#lua-match? @comment.documentation "^//!"))
+
+; 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 "^@"))
diff --git a/src/plugins/code/root.zig b/src/plugins/code/root.zig
new file mode 100644
index 00000000..bd6a675b
--- /dev/null
+++ b/src/plugins/code/root.zig
@@ -0,0 +1,7 @@
+//! Dylib entry for the code plugin — identical in shape to the canonical third-party
+//! `src/plugins/root.zig`: one `exportEntry` call wired to `src/plugin.zig`.
+const sdk = @import("sdk");
+
+comptime {
+ sdk.dylib.exportEntry(@import("src/plugin.zig"));
+}
diff --git a/src/plugins/code/src/CodeEditor.zig b/src/plugins/code/src/CodeEditor.zig
new file mode 100644
index 00000000..9d6b7610
--- /dev/null
+++ b/src/plugins/code/src/CodeEditor.zig
@@ -0,0 +1,163 @@
+//! 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 core = code.core;
+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;
+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.
+const syntax_highlight_max_bytes: usize = 512 * 1024;
+
+const chromeless = dvui.Options{
+ .background = false,
+ .margin = dvui.Rect{},
+ .padding = null,
+ .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 line_height = font.lineHeight();
+ const line_num_col = lineNumberColumnWidth(doc.line_count, font);
+
+ 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,
+ .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)
+ else
+ null,
+ }, chromeless.override(.{
+ .expand = .both,
+ .font = font,
+ .padding = .{
+ .x = 0,
+ .y = editor_pad_y,
+ .w = editor_pad_right,
+ .h = editor_pad_y,
+ },
+ .color_text = text_color,
+ .id_extra = @intCast(id_extra + 1),
+ }));
+ defer te.deinit();
+ te.processEvents();
+ te.draw();
+
+ drawLineNumbers(
+ 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;
+}
+
+const max_text_bytes: usize = 64 * 1024 * 1024;
+
+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 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,
+ scroll_y: f32,
+ font: dvui.Font,
+ line_height: f32,
+) 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_pad_y) / line_height))));
+
+ var line: usize = first_line;
+ var y: f32 = editor_pad_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 + 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 = line_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
new file mode 100644
index 00000000..86e35d42
--- /dev/null
+++ b/src/plugins/code/src/Document.zig
@@ -0,0 +1,72 @@
+//! 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 internal = @import("../code.zig");
+const dvui = internal.dvui;
+const sdk = internal.sdk;
+
+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,
+/// 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,
+
+/// 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 = 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 = sdk.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.
+/// 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 = 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 = sdk.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/State.zig b/src/plugins/code/src/State.zig
new file mode 100644
index 00000000..db4bb32e
--- /dev/null
+++ b/src/plugins/code/src/State.zig
@@ -0,0 +1,28 @@
+//! Code plugin runtime state: open text document registry.
+const std = @import("std");
+const sdk = @import("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/SyntaxHighlight.zig b/src/plugins/code/src/SyntaxHighlight.zig
new file mode 100644
index 00000000..dc16e9d2
--- /dev/null
+++ b/src/plugins/code/src/SyntaxHighlight.zig
@@ -0,0 +1,129 @@
+//! Tree-sitter syntax highlighting via dvui's built-in TextEntry support.
+const std = @import("std");
+const internal = @import("../code.zig");
+const dvui = internal.dvui;
+const TextEntryWidget = @import("widgets/TextEntryWidget.zig");
+
+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;
+ }
+};
+
+fn rgb(r: u8, g: u8, b: u8) dvui.Color {
+ return .{ .r = r, .g = g, .b = b, .a = 255 };
+}
+
+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) TextEntryWidget.SyntaxHighlight {
+ return .{ .name = name, .opts = .{ .color_text = color } };
+}
+
+/// Zig — capture names match `queries/zig.scm`.
+const zig_highlights = [_]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)),
+};
+
+/// 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 = [_]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 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;
+} else struct {};
+
+pub fn treeSitterOption(path: []const u8) ?TextEntryWidget.InitOptions.TreeSitterOption {
+ if (!dvui.useTreeSitter) return null;
+ return switch (Language.fromPath(path)) {
+ .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,
+ };
+}
diff --git a/src/plugins/code/src/plugin.zig b/src/plugins/code/src/plugin.zig
new file mode 100644
index 00000000..3b9f26c5
--- /dev/null
+++ b/src/plugins/code/src/plugin.zig
@@ -0,0 +1,201 @@
+//! 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");
+const internal = @import("../code.zig");
+const sdk = internal.sdk;
+const dvui = internal.dvui;
+const State = internal.State;
+const Document = internal.Document;
+const CodeEditor = internal.CodeEditor;
+const DocHandle = sdk.DocHandle;
+
+pub const manifest = sdk.PluginManifest{
+ .id = "code",
+ .name = "Code",
+ .version = .{ .major = 0, .minor = 1, .patch = 0 },
+};
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .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,
+};
+
+comptime {
+ sdk.Plugin.assertEditorVTable(vtable);
+}
+
+pub fn register(host: *sdk.Host) !void {
+ const gpa = host.allocator;
+
+ const st = try gpa.create(State);
+ errdefer gpa.destroy(st);
+ st.* = .{};
+ plugin.state = @ptrCast(st);
+
+ 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));
+ const gpa = sdk.allocator();
+ st.deinit(gpa);
+ gpa.destroy(st);
+}
+
+// ---- file type ownership -----------------------------------------------------
+
+/// 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 {
+ _ = ext;
+ return sdk.Plugin.file_type_fallback_priority;
+}
+
+// ---- 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 {
+ try sdk.document.loadPathInto(Document, path, docBuf(out_doc));
+}
+fn loadDocumentFromBytes(_: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void {
+ try sdk.document.loadBytesInto(Document, path, bytes, docBuf(out_doc));
+}
+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(sdk.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 = sdk.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;
+ if (try CodeEditor.draw(doc, handle.id, sdk.allocator())) {
+ 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 -----------------------------------------------------------------
+
+fn docBuf(buf: *anyopaque) *Document {
+ return @ptrCast(@alignCast(buf));
+}
+fn docFrom(handle: DocHandle) ?*Document {
+ const st: *State = @ptrCast(@alignCast(plugin.state));
+ return st.docById(handle.id);
+}
diff --git a/src/plugins/code/src/widgets/TextEntryWidget.zig b/src/plugins/code/src/widgets/TextEntryWidget.zig
new file mode 100644
index 00000000..3f64d4f1
--- /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 internal = @import("../../code.zig");
+const dvui = internal.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..3f2d258b
--- /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 internal = @import("../../code.zig");
+const dvui = internal.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;
+}
diff --git a/src/plugins/code/static/integration.zig b/src/plugins/code/static/integration.zig
new file mode 100644
index 00000000..8c7fecc0
--- /dev/null
+++ b/src/plugins/code/static/integration.zig
@@ -0,0 +1,59 @@
+//! Code plugin — fizzy-internal static-embed + bundled-dylib module graph.
+//! Runs only from the fizzy build root, so paths are single fizzy-relative literals.
+const std = @import("std");
+const helpers = @import("../../shared/build/helpers.zig");
+
+pub const id = "code";
+pub const installDylib = helpers.installDylib;
+
+const module_path = "src/plugins/code/code.zig";
+const dylib_path = "src/plugins/code/root.zig";
+
+pub const ModuleImports = struct {
+ dvui: *std.Build.Module,
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+ proxy_bridge: ?*std.Build.Module = null,
+};
+
+fn applyImports(module: *std.Build.Module, imports: ModuleImports) void {
+ module.addImport("dvui", imports.dvui);
+ module.addImport("core", imports.core);
+ module.addImport("sdk", imports.sdk);
+ if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge);
+}
+
+/// Static `@import("code")` module for exe / web / tests.
+pub fn addStaticModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+ consumer: *std.Build.Module,
+) *std.Build.Module {
+ const mod = helpers.addStaticModule(b, .{
+ .import_name = id,
+ .root_source_file = b.path(module_path),
+ .target = target,
+ .optimize = optimize,
+ }, consumer);
+ applyImports(mod, imports);
+ return mod;
+}
+
+/// Native dynamic library bundled beside the app (`code.dylib` / `.dll` / `.so`).
+pub fn addDylib(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+) *std.Build.Step.Compile {
+ const lib = helpers.addDylib(b, .{
+ .name = id,
+ .root_source_file = b.path(dylib_path),
+ .target = target,
+ .optimize = optimize,
+ });
+ applyImports(lib.root_module, imports);
+ return lib;
+}
diff --git a/src/plugins/example/build.zig b/src/plugins/example/build.zig
new file mode 100644
index 00000000..d84c11fc
--- /dev/null
+++ b/src/plugins/example/build.zig
@@ -0,0 +1,19 @@
+//! Standalone build for the example plugin — the canonical third-party shape, and the simplest
+//! possible one: declare `fizzy`, call `fizzy.plugin.create` (defaults its root to `root.zig`),
+//! then `fizzy.plugin.install`. `cd src/plugins/example && zig build` produces
+//! `example.`. Copy this for a new pure-Zig plugin. See docs/PLUGINS.md.
+const std = @import("std");
+const fizzy = @import("fizzy");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const lib = fizzy.plugin.create(b, .{
+ .name = "example",
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("root.zig"),
+ });
+ fizzy.plugin.install(b, lib, .{});
+}
diff --git a/src/plugins/example/build.zig.zon b/src/plugins/example/build.zig.zon
new file mode 100644
index 00000000..77821601
--- /dev/null
+++ b/src/plugins/example/build.zig.zon
@@ -0,0 +1,19 @@
+.{
+ .name = .example,
+ .version = "0.0.0",
+ .minimum_zig_version = "0.16.0",
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "root.zig",
+ "example.zig",
+ "src",
+ "static",
+ },
+ .fingerprint = 0x6eec9b9f328e055f,
+ .dependencies = .{
+ .fizzy = .{
+ .path = "../../..",
+ },
+ },
+}
diff --git a/src/plugins/example/example.zig b/src/plugins/example/example.zig
new file mode 100644
index 00000000..b1428978
--- /dev/null
+++ b/src/plugins/example/example.zig
@@ -0,0 +1,14 @@
+//! Example plugin root module **and** intra-plugin import hub — the conventional `.zig`.
+//!
+//! - The shell resolves `@import("example")` to this file when the plugin is compiled into the
+//! app (static embed); `example.plugin` is its entry.
+//! - Files under `src/` import it as `../example.zig` for shared deps (`sdk`/`dvui`) and types.
+//!
+//! A minimal plugin keeps this tiny — it grows into the plugin's shared namespace as `src/`
+//! gains files. It must sit at the plugin root (a Zig module can't import above its root file's
+//! directory). The build-side static-embed glue lives in `static/`.
+pub const sdk = @import("sdk");
+pub const dvui = @import("dvui");
+
+pub const plugin = @import("src/plugin.zig");
+pub const State = @import("src/State.zig");
diff --git a/src/plugins/example/root.zig b/src/plugins/example/root.zig
new file mode 100644
index 00000000..49583a65
--- /dev/null
+++ b/src/plugins/example/root.zig
@@ -0,0 +1,8 @@
+//! Dylib entry for the example plugin — the canonical third-party shape (identical to
+//! `src/plugins/root.zig`): one `exportEntry` call wired to `src/plugin.zig`. Copy this verbatim
+//! into a new plugin; you never edit it.
+const sdk = @import("sdk");
+
+comptime {
+ sdk.dylib.exportEntry(@import("src/plugin.zig"));
+}
diff --git a/src/plugins/example/src/State.zig b/src/plugins/example/src/State.zig
new file mode 100644
index 00000000..79a6b72f
--- /dev/null
+++ b/src/plugins/example/src/State.zig
@@ -0,0 +1,11 @@
+//! Example plugin state. A plugin owns whatever state it needs; the host injects only the
+//! allocator and `*Host` (read via `sdk.allocator()` / `sdk.host()`), so this is just a plain
+//! struct the plugin holds. Trivial here — a real plugin keeps documents, caches, settings, etc.
+const std = @import("std");
+
+clicks: u64 = 0,
+
+pub fn deinit(self: *@This(), gpa: std.mem.Allocator) void {
+ _ = self;
+ _ = gpa;
+}
diff --git a/src/plugins/example/src/plugin.zig b/src/plugins/example/src/plugin.zig
new file mode 100644
index 00000000..7b7305f0
--- /dev/null
+++ b/src/plugins/example/src/plugin.zig
@@ -0,0 +1,80 @@
+//! Example plugin — the canonical, minimal Fizzy plugin and the copy-me template for new
+//! plugins. It registers a single sidebar view that renders a greeting and a click counter:
+//! the smallest useful shape, namely identity + `register` + one `Host.register*` contribution
+//! + plugin-owned state. The host injects only the allocator and `*Host` (read through
+//! `sdk.allocator()` / `sdk.host()`), so there is no storage file to write.
+//!
+//! This plugin implements no document hooks — it is a "shell" plugin (contributes a pane), not
+//! an "editor" plugin (opens/saves/draws files). For the editor shape, see the `code` plugin.
+//!
+//! To start a new plugin: copy this folder, rename the id/name, and implement your feature in
+//! `src/plugin.zig`. See docs/PLUGINS.md.
+const std = @import("std");
+// Shared deps + sibling types come through the plugin's `.zig` hub (`../example.zig`),
+// the conventional `@import("")` namespace. A single-file plugin could import `sdk`
+// and `dvui` directly; using the hub is what scales as `src/` grows.
+const example = @import("../example.zig");
+const sdk = example.sdk;
+const dvui = example.dvui;
+const State = example.State;
+
+/// Identity + versions embedded in the dylib (and read by the host on load).
+pub const manifest = sdk.PluginManifest{
+ .id = "example",
+ .name = "Example",
+ .version = .{ .major = 0, .minor = 1, .patch = 0 },
+};
+
+/// Stable, plugin-namespaced contribution id.
+const view_hello = "example.hello";
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .id = "example",
+ .display_name = "Example",
+};
+
+/// Only the hooks this plugin needs; every other vtable field stays `null`.
+const vtable: sdk.Plugin.VTable = .{
+ .deinit = deinit,
+};
+
+/// The plugin's own singleton state — just a variable it owns. The SDK holds gpa/host.
+var plugin_state: State = .{};
+
+/// Entry point the host calls once at startup (static) or after dlopen (dynamic). Wire state,
+/// register the plugin, then add any sidebar/bottom/center/menu/settings contributions.
+pub fn register(host: *sdk.Host) !void {
+ plugin.state = @ptrCast(&plugin_state);
+ try host.registerPlugin(&plugin);
+ try host.registerSidebarView(.{
+ .id = view_hello,
+ .owner = &plugin,
+ .icon = dvui.entypo.rocket,
+ .title = "Example",
+ .draw = drawHello,
+ });
+}
+
+/// Stable `*Plugin` for constructing `DocHandle.owner` / lookups (unused here, but part of the
+/// conventional plugin surface).
+pub fn pluginPtr() *sdk.Plugin {
+ return &plugin;
+}
+
+fn deinit(_: *anyopaque) void {
+ plugin_state.deinit(sdk.allocator());
+}
+
+/// Fills the left pane while this sidebar view is active.
+fn drawHello(_: ?*anyopaque) anyerror!void {
+ var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .margin = .all(8) });
+ defer box.deinit();
+
+ dvui.label(@src(), "Hello from the example plugin!", .{}, .{});
+ dvui.label(@src(), "Clicks: {d}", .{plugin_state.clicks}, .{});
+ if (dvui.button(@src(), "Click me", .{}, .{ .expand = .horizontal })) {
+ plugin_state.clicks += 1;
+ }
+}
diff --git a/src/plugins/example/static/integration.zig b/src/plugins/example/static/integration.zig
new file mode 100644
index 00000000..817906c8
--- /dev/null
+++ b/src/plugins/example/static/integration.zig
@@ -0,0 +1,59 @@
+//! Example plugin — fizzy-internal static-embed + bundled-dylib module graph.
+//! Runs only from the fizzy build root, so paths are single fizzy-relative literals.
+const std = @import("std");
+const helpers = @import("../../shared/build/helpers.zig");
+
+pub const id = "example";
+pub const installDylib = helpers.installDylib;
+
+const module_path = "src/plugins/example/example.zig";
+const dylib_path = "src/plugins/example/root.zig";
+
+pub const ModuleImports = struct {
+ dvui: *std.Build.Module,
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+ proxy_bridge: ?*std.Build.Module = null,
+};
+
+fn applyImports(module: *std.Build.Module, imports: ModuleImports) void {
+ module.addImport("dvui", imports.dvui);
+ module.addImport("core", imports.core);
+ module.addImport("sdk", imports.sdk);
+ if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge);
+}
+
+/// Static `@import("example")` module for exe / web / tests.
+pub fn addStaticModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+ consumer: *std.Build.Module,
+) *std.Build.Module {
+ const mod = helpers.addStaticModule(b, .{
+ .import_name = id,
+ .root_source_file = b.path(module_path),
+ .target = target,
+ .optimize = optimize,
+ }, consumer);
+ applyImports(mod, imports);
+ return mod;
+}
+
+/// Native dynamic library bundled beside the app (`example.dylib` / `.dll` / `.so`).
+pub fn addDylib(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+) *std.Build.Step.Compile {
+ const lib = helpers.addDylib(b, .{
+ .name = id,
+ .root_source_file = b.path(dylib_path),
+ .target = target,
+ .optimize = optimize,
+ });
+ applyImports(lib.root_module, imports);
+ return lib;
+}
diff --git a/src/plugins/pixi/build.zig b/src/plugins/pixi/build.zig
new file mode 100644
index 00000000..fa97a521
--- /dev/null
+++ b/src/plugins/pixi/build.zig
@@ -0,0 +1,57 @@
+//! Standalone build for the pixi plugin — the canonical third-party shape.
+//! `cd src/plugins/pixi && zig build` produces `pixi.`. Pixi has vendored C
+//! deps (stbi, msf_gif, zip) and a packed `assets` module, so its `build.zig` attaches a few
+//! extra modules onto the `fizzy.plugin.create` lib — exactly how any third-party plugin with
+//! native deps would.
+//!
+//! `zstbi`/`msf_gif` are built with the shared `fizzy.plugin.addCModule` helper (the same one a
+//! third-party C plugin uses), so the build logic isn't duplicated — only the plugin-relative
+//! paths are supplied here. `zip` is wired inline (its `zip.zig` module + C compiled into this
+//! dylib); it isn't shared with fizzy's own build graph, so there's no two-modules collision.
+const std = @import("std");
+const fizzy = @import("fizzy");
+const assetpack = @import("assetpack");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const lib = fizzy.plugin.create(b, .{
+ .name = "pixi",
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("root.zig"),
+ });
+
+ // Packed assets (the repo's assets/ tree, three levels up from this plugin).
+ lib.root_module.addImport("assets", assetpack.pack(b, b.path("../../../assets"), .{}));
+
+ // zstbi (image decode/resize + rect pack) + msf_gif (GIF export) via the shared helper.
+ lib.root_module.addImport("zstbi", fizzy.plugin.addCModule(b, .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/deps/stbi/zstbi.zig"),
+ .c_sources = &.{.{ .file = b.path("src/deps/stbi/zstbi.c") }},
+ }));
+ lib.root_module.addImport("msf_gif", fizzy.plugin.addCModule(b, .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/deps/msf_gif/msf_gif.zig"),
+ .c_sources = &.{.{ .file = b.path("src/deps/msf_gif/msf_gif.c") }},
+ }));
+
+ // zip (atlas/project archives).
+ lib.root_module.addImport("zip", b.createModule(.{ .root_source_file = b.path("src/deps/zip/zip.zig") }));
+ lib.root_module.link_libc = true;
+ lib.root_module.addIncludePath(b.path("src/deps/zip/src"));
+ lib.root_module.addCSourceFile(.{
+ .file = b.path("src/deps/zip/src/zip.c"),
+ .flags = &.{"-fno-sanitize=undefined"},
+ });
+
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ lib.root_module.addImport("icons", dep.module("icons"));
+ }
+
+ fizzy.plugin.install(b, lib, .{});
+}
diff --git a/src/plugins/pixi/build.zig.zon b/src/plugins/pixi/build.zig.zon
new file mode 100644
index 00000000..24c0e25e
--- /dev/null
+++ b/src/plugins/pixi/build.zig.zon
@@ -0,0 +1,28 @@
+.{
+ .name = .pixi,
+ .version = "0.0.0",
+ .minimum_zig_version = "0.16.0",
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "root.zig",
+ "pixi.zig",
+ "src",
+ "static",
+ },
+ .fingerprint = 0xdef5a52dbae70684,
+ .dependencies = .{
+ .fizzy = .{
+ .path = "../../..",
+ },
+ .assetpack = .{
+ .url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz",
+ .hash = "assetpack-0.2.0-5DA2d1ZkAADJanNVdWrUBOGMhOzUENhrUiqXcHADxY2x",
+ },
+ .icons = .{
+ .url = "https://github.com/foxnne/zig-lib-icons/archive/db034786a1286ab28dc35aba534c098aa4f1a3aa.tar.gz",
+ .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_",
+ .lazy = true,
+ },
+ },
+}
diff --git a/src/plugins/pixi/pixi.zig b/src/plugins/pixi/pixi.zig
new file mode 100644
index 00000000..3bb94b55
--- /dev/null
+++ b/src/plugins/pixi/pixi.zig
@@ -0,0 +1,80 @@
+//! Pixi plugin root module **and** intra-plugin import hub.
+//!
+//! - The shell resolves `@import("pixi")` to this file when the plugin is compiled into the app
+//! (static embed) and reaches its public surface here.
+//! - Files under `src/` import it as `../pixi.zig` for the shared deps (`sdk`/`core`/`dvui` +
+//! core conveniences) and sibling types — the conventional `.zig` namespace.
+//!
+//! It must sit at the plugin root: a Zig module cannot import files above its root file's
+//! directory, so this has to be beside `src/` to re-export from it. The build-side static-embed
+//! glue lives in `static/`.
+const std = @import("std");
+
+pub const sdk = @import("sdk");
+pub const core = @import("core");
+pub const dvui = @import("dvui");
+
+pub const atlas = core.atlas;
+pub const math = core.math;
+pub const image = core.image;
+pub const fs = core.fs;
+pub const perf = core.perf;
+pub const Fling = core.Fling;
+pub const water_surface = core.water_surface;
+pub const core_sprite = core.Sprite;
+
+/// On-disk file format version stamp (kept in sync with `fizzy.version`).
+pub const version: std.SemanticVersion = .{ .major = 0, .minor = 2, .patch = 0 };
+/// Layer rename buffer size (was `Editor.Constants.max_name_len`).
+pub const max_name_len = 256;
+
+pub const plugin = @import("src/plugin.zig");
+pub const runtime = @import("src/runtime.zig");
+
+pub const State = @import("src/State.zig");
+pub const Settings = @import("src/Settings.zig");
+pub const Docs = @import("src/Docs.zig");
+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 File = @import("src/File.zig");
+pub const Layer = @import("src/Layer.zig");
+pub const Sprite = @import("src/Sprite.zig");
+pub const Atlas = @import("src/Atlas.zig");
+pub const Animation = @import("src/Animation.zig");
+
+pub const render = @import("src/render.zig");
+pub const sprite_render = @import("src/sprite_render.zig");
+pub const algorithms = @import("src/algorithms/algorithms.zig");
+
+pub const dialogs = struct {
+ pub const NewFile = @import("src/dialogs/NewFile.zig");
+ 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 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/pixi/root.zig b/src/plugins/pixi/root.zig
new file mode 100644
index 00000000..08ff548e
--- /dev/null
+++ b/src/plugins/pixi/root.zig
@@ -0,0 +1,7 @@
+//! Dylib entry for the pixi plugin — canonical shape: one `exportEntry` wired to
+//! `src/plugin.zig` (see `src/plugins/root.zig`).
+const sdk = @import("sdk");
+
+comptime {
+ sdk.dylib.exportEntry(@import("src/plugin.zig"));
+}
diff --git a/src/Animation.zig b/src/plugins/pixi/src/Animation.zig
similarity index 100%
rename from src/Animation.zig
rename to src/plugins/pixi/src/Animation.zig
diff --git a/src/Atlas.zig b/src/plugins/pixi/src/Atlas.zig
similarity index 93%
rename from src/Atlas.zig
rename to src/plugins/pixi/src/Atlas.zig
index ff1a5346..6d159e43 100644
--- a/src/Atlas.zig
+++ b/src/plugins/pixi/src/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/pixi/src/CanvasData.zig b/src/plugins/pixi/src/CanvasData.zig
new file mode 100644
index 00000000..e4c26909
--- /dev/null
+++ b/src/plugins/pixi/src/CanvasData.zig
@@ -0,0 +1,1270 @@
+//! 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 Clipboard = @import("clipboard.zig");
+const TransformOp = @import("transform_op.zig");
+const DocLifecycle = @import("doc_lifecycle.zig");
+const pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+
+const File = pixi_mod.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(runtime.allocator(), "column_drag_{d}", .{grouping}) catch "column_drag",
+ .rows_drag_name = std.fmt.allocPrint(runtime.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 runtime.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 + runtime.state().settings.ruler_padding;
+
+ const ruler_thickness: f32 = switch (orientation) {
+ .horizontal => blk: {
+ self.horizontal_ruler_height = font.textSize("M").h + runtime.state().settings.ruler_padding;
+ break :blk self.horizontal_ruler_height;
+ },
+ .vertical => blk: {
+ self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + runtime.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 = pixi_mod.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: pixi_mod.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 (pixi_mod.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 = runtime.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 })) {
+ DocLifecycle.cancelEdit(runtime.state());
+ }
+ if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) {
+ DocLifecycle.acceptEdit(runtime.state());
+ }
+ }
+ }
+}
+
+/// 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 = runtime.state().docs.activeFile(runtime.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 (runtime.state().settings.show_rulers) self.horizontal_ruler_height else 0;
+ const ruler_left: f32 = if (runtime.state().settings.show_rulers) self.vertical_ruler_width else 0;
+ const canvas_nat = dvui.Rect{
+ .x = wb.x + ruler_left,
+ .y = wb.y + ruler_top,
+ .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 => runtime.state().host.save() catch {
+ dvui.log.err("Failed to save", .{});
+ },
+ .exportd => {
+ // Open the Export dialog (same configuration the `export` keybind uses).
+ var mutex = pixi_mod.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 => Clipboard.copy(runtime.state()) catch {
+ dvui.log.err("Failed to copy", .{});
+ },
+ .paste => Clipboard.paste(runtime.state()) catch {
+ dvui.log.err("Failed to paste", .{});
+ },
+ .transform => TransformOp.begin(runtime.state()) catch {
+ dvui.log.err("Failed to start transform", .{});
+ },
+ .grid_layout => {
+ if (runtime.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 = runtime.state().docs.activeFile(runtime.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 (runtime.state().settings.show_rulers) self.horizontal_ruler_height else 0;
+ const ruler_left: f32 = if (runtime.state().settings.show_rulers) self.vertical_ruler_width else 0;
+ const canvas_nat = dvui.Rect{
+ .x = wb.x + ruler_left,
+ .y = wb.y + ruler_top,
+ .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/pixi/src/Colors.zig b/src/plugins/pixi/src/Colors.zig
new file mode 100644
index 00000000..c55951a7
--- /dev/null
+++ b/src/plugins/pixi/src/Colors.zig
@@ -0,0 +1,11 @@
+const std = @import("std");
+const pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+
+const Self = @This();
+
+primary: [4]u8 = .{ 255, 255, 255, 255 },
+secondary: [4]u8 = .{ 0, 0, 0, 255 },
+height: u8 = 0,
+palette: ?pixi_mod.internal.Palette = null,
+file_tree_palette: ?pixi_mod.internal.Palette = null,
diff --git a/src/plugins/pixi/src/Docs.zig b/src/plugins/pixi/src/Docs.zig
new file mode 100644
index 00000000..19d845fa
--- /dev/null
+++ b/src/plugins/pixi/src/Docs.zig
@@ -0,0 +1,37 @@
+//! 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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const sdk = pixi_mod.sdk;
+const Internal = pixi_mod.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/File.zig b/src/plugins/pixi/src/File.zig
similarity index 84%
rename from src/File.zig
rename to src/plugins/pixi/src/File.zig
index bb4cd017..df2157cf 100644
--- a/src/File.zig
+++ b/src/plugins/pixi/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/tools/LDTKTileset.zig b/src/plugins/pixi/src/LDTKTileset.zig
similarity index 88%
rename from src/tools/LDTKTileset.zig
rename to src/plugins/pixi/src/LDTKTileset.zig
index 86c67b96..216a59d6 100644
--- a/src/tools/LDTKTileset.zig
+++ b/src/plugins/pixi/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/Layer.zig b/src/plugins/pixi/src/Layer.zig
similarity index 100%
rename from src/Layer.zig
rename to src/plugins/pixi/src/Layer.zig
diff --git a/src/editor/PackJob.zig b/src/plugins/pixi/src/PackJob.zig
similarity index 93%
rename from src/editor/PackJob.zig
rename to src/plugins/pixi/src/PackJob.zig
index d5202743..43d4ee5d 100644
--- a/src/editor/PackJob.zig
+++ b/src/plugins/pixi/src/PackJob.zig
@@ -7,7 +7,7 @@
//! worker only ever touches its own `PackFile` values plus the app allocator.
//!
//! The worker produces a finished `Internal.Atlas` (RGBA pixels + sprite/animation data). The
-//! main thread swaps it into `fizzy.packer.atlas` via `Editor.processPackJob` once `done` is
+//! main thread swaps it into `runtime.packer().atlas` via `Editor.processPackJob` once `done` is
//! published.
//!
//! Ownership / threading model:
@@ -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 = @import("../gfx/perf.zig");
-const reduce_alg = @import("../algorithms/reduce.zig");
+const perf = pixi_mod.perf;
+const reduce_alg = @import("algorithms/reduce.zig");
+const pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
const PackJob = @This();
@@ -60,7 +61,7 @@ pub const PackSprite = struct {
pub const PackAnimation = struct {
name: []u8,
- frames: []fizzy.Animation.Frame,
+ frames: []pixi_mod.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 pixi_mod.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 = pixi_mod.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(pixi_mod.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 pixi_mod.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: ?pixi_mod.internal.Atlas = null,
/// Set to `true` once the main thread has consumed `result_atlas` (so `destroy()` knows not
/// to free the moved-out atlas allocations).
@@ -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 `runtime.packer().atlas`; in that case the new owner is responsible for the
// allocations and we must not double-free.
if (job.result_atlas != null and !job.result_consumed) {
const atlas = job.result_atlas.?;
- a.free(fizzy.image.bytes(atlas.source));
+ a.free(pixi_mod.image.bytes(atlas.source));
for (atlas.data.animations) |*anim| a.free(anim.name);
a.free(atlas.data.animations);
a.free(atlas.data.sprites);
@@ -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 `runtime.allocator()`
// so they outlive the job; everything else (sprite refs, frames, animations, any
// `.path`-loaded `PackFile`s, collapse carry-overs) lives in `ws` and is freed below.
- const work = WorkerState.init(fizzy.app.allocator) catch |e| {
+ const work = WorkerState.init(runtime.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(runtime.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);
+ runtime.allocator().free(pixi_mod.image.bytes(atlas.source));
+ for (atlas.data.animations) |*anim| runtime.allocator().free(anim.name);
+ runtime.allocator().free(atlas.data.animations);
+ runtime.allocator().free(atlas.data.sprites);
job.phase.store(@intFromEnum(Phase.cancelled), .release);
return;
}
@@ -440,7 +441,7 @@ const WorkerSprite = struct {
const WorkerAnimation = struct {
name: []u8,
- frames: []fizzy.Animation.Frame,
+ frames: []pixi_mod.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(pixi_mod.Animation.Frame, anim.frames.len);
for (frames, anim.frames, 0..) |*current_frame, src_frame, i| {
current_frame.* = .{
.sprite_index = new_sprite_index + i,
@@ -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) !pixi_mod.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 runtime.allocator().alloc([4]u8, num_pixels);
+ errdefer runtime.allocator().free(pixels);
@memset(pixels, .{ 0, 0, 0, 0 });
const tex_w: usize = tex_size[0];
@@ -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 runtime.allocator().alloc(pixi_mod.Atlas.Sprite, self.sprites.items.len);
+ errdefer runtime.allocator().free(sprites_out);
for (sprites_out, self.sprites.items, self.frames.items) |*dst, src, src_rect| {
dst.source = .{ src_rect.x, src_rect.y, src_rect.w, src_rect.h };
dst.origin = src.origin;
}
- const animations_out = try fizzy.app.allocator.alloc(fizzy.Animation, self.animations.items.len);
+ const animations_out = try runtime.allocator().alloc(pixi_mod.Animation, self.animations.items.len);
var anims_initialized: usize = 0;
errdefer {
- for (animations_out[0..anims_initialized]) |*anim| fizzy.app.allocator.free(anim.name);
- fizzy.app.allocator.free(animations_out);
+ for (animations_out[0..anims_initialized]) |*anim| runtime.allocator().free(anim.name);
+ runtime.allocator().free(animations_out);
}
for (animations_out, self.animations.items) |*dst, src| {
- dst.name = try 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 runtime.allocator().dupe(u8, src.name);
+ errdefer runtime.allocator().free(dst.name);
+ dst.frames = try runtime.allocator().dupe(pixi_mod.Animation.Frame, src.frames);
anims_initialized += 1;
}
diff --git a/src/tools/Packer.zig b/src/plugins/pixi/src/Packer.zig
similarity index 81%
rename from src/tools/Packer.zig
rename to src/plugins/pixi/src/Packer.zig
index b6b5ef01..11db6f3f 100644
--- a/src/tools/Packer.zig
+++ b/src/plugins/pixi/src/Packer.zig
@@ -1,8 +1,9 @@
const std = @import("std");
const zstbi = @import("zstbi");
const dvui = @import("dvui");
+const pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
-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(pixi_mod.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(pixi_mod.internal.File),
target: PackTarget = .project,
//camera: fizzy.gfx.Camera = .{},
-atlas: ?fizzy.Internal.Atlas = null,
+atlas: ?pixi_mod.internal.Atlas = null,
-/// Monotonic time (`fizzy.perf.nanoTimestamp`) when the current in-memory atlas was last installed.
+/// Monotonic time (`pixi_mod.perf.nanoTimestamp`) when the current in-memory atlas was last installed.
last_packed_at_ns: ?i128 = null,
ldtk: bool = false,
@@ -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(pixi_mod.Animation).init(allocator),
+ .open_files = std.array_list.Managed(pixi_mod.internal.File).init(allocator),
.placeholder = .{ .width = 2, .height = 2, .pixels = pixels },
.ldtk_tilesets = std.array_list.Managed(LDTKTileset).init(allocator),
};
@@ -75,7 +76,7 @@ pub fn newId(self: *Packer) u32 {
}
pub fn deinit(self: *Packer) void {
- fizzy.app.allocator.free(self.placeholder.pixels);
+ runtime.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(runtime.allocator());
}
for (self.animations.items) |*animation| {
- fizzy.app.allocator.free(animation.name);
+ runtime.allocator().free(animation.name);
}
for (self.ldtk_tilesets.items) |*tileset| {
for (tileset.layer_paths) |path| {
- fizzy.app.allocator.free(path);
+ runtime.allocator().free(path);
}
- fizzy.app.allocator.free(tileset.sprites);
- fizzy.app.allocator.free(tileset.layer_paths);
+ runtime.allocator().free(tileset.sprites);
+ runtime.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: *pixi_mod.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: ?pixi_mod.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 pixi_mod.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 runtime.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 runtime.allocator().alloc(pixi_mod.Animation.Frame, animation.frames.len);
for (frames, animation.frames, 0..) |*current_frame, file_anim_frame, i| {
current_frame.sprite_index = new_sprite_index + i;
current_frame.ms = file_anim_frame.ms;
}
try self.animations.append(.{
- .name = try std.fmt.allocPrint(fizzy.app.allocator, "{s}_{s}", .{ animation.name, layer.name }),
+ .name = try std.fmt.allocPrint(runtime.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.editor.folder) |root_directory| {
+ if (runtime.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 (pixi_mod.internal.File.isFizzyExtension(ext)) {
+ const abs_path = try std.fs.path.joinZ(runtime.allocator(), &.{ directory, entry.name });
+ defer runtime.allocator().free(abs_path);
- if (fizzy.editor.getFileFromPath(abs_path)) |file| {
+ if (runtime.state().docs.fileFromPath(abs_path)) |file| {
try p.append(file);
} else {
- if (try fizzy.Internal.File.fromPath(abs_path)) |file| {
+ if (try pixi_mod.internal.File.fromPath(abs_path)) |file| {
try p.open_files.append(file);
try p.append(&p.open_files.items[p.open_files.items.len - 1]);
}
}
}
} else if (entry.kind == .directory) {
- const abs_path = try std.fs.path.joinZ(fizzy.app.allocator, &[_][]const u8{ directory, entry.name });
- defer fizzy.app.allocator.free(abs_path);
+ const abs_path = try std.fs.path.joinZ(runtime.allocator(), &[_][]const u8{ directory, entry.name });
+ defer runtime.allocator().free(abs_path);
try search(p, abs_path);
}
}
@@ -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 pixi_mod.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: pixi_mod.internal.Atlas = .{
+ .sprites = try runtime.allocator().alloc(pixi_mod.internal.Atlas.Sprite, packer.sprites.items.len),
+ .animations = try runtime.allocator().alloc(pixi_mod.Animation, packer.animations.items.len),
};
for (atlas.sprites, packer.sprites.items, packer.frames.items) |*dst, src, src_rect| {
@@ -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 runtime.allocator().dupe(u8, src.name);
+ dst.frames = try runtime.allocator().dupe(pixi_mod.Animation.Frame, src.frames);
//dst.length = src.length;
// dst.start = src.start;
}
@@ -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);
+ runtime.allocator().free(animation.name);
}
- fizzy.app.allocator.free(current_atlas.data.sprites);
- fizzy.app.allocator.free(current_atlas.data.animations);
+ runtime.allocator().free(current_atlas.data.sprites);
+ runtime.allocator().free(current_atlas.data.animations);
- fizzy.app.allocator.free(fizzy.image.bytes(current_atlas.source));
+ runtime.allocator().free(pixi_mod.image.bytes(current_atlas.source));
current_atlas.data = atlas;
current_atlas.source = atlas_layer.source;
@@ -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 = pixi_mod.perf.nanoTimestamp();
packer.clearAndFree();
}
}
diff --git a/src/editor/Project.zig b/src/plugins/pixi/src/Project.zig
similarity index 79%
rename from src/editor/Project.zig
rename to src/plugins/pixi/src/Project.zig
index f7c63df3..61b1666f 100644
--- a/src/editor/Project.zig
+++ b/src/plugins/pixi/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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
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.editor.folder) |folder| {
- const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" });
+ if (runtime.state().host.folder()) |folder| {
+ const file = try std.fs.path.join(runtime.state().host.arena(), &.{ folder, ".fizproject" });
- if (fizzy.fs.read(allocator, dvui.io, file) catch null) |r| {
+ if (pixi_mod.fs.read(allocator, dvui.io, file) catch null) |r| {
read = r;
const options = std.json.ParseOptions{ .duplicate_field_behavior = .use_first, .ignore_unknown_fields = true };
@@ -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.editor.folder) |folder| {
- const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" });
+ if (runtime.state().host.folder()) |folder| {
+ const file = try std.fs.path.join(runtime.allocator(), &.{ folder, ".fizproject" });
+ defer runtime.allocator().free(file);
const options = std.json.Stringify.Options{};
- const str = try std.json.Stringify.valueAlloc(fizzy.app.allocator, Project{
+ const str = try std.json.Stringify.valueAlloc(runtime.allocator(), Project{
.packed_atlas_output = project.packed_atlas_output,
.packed_image_output = project.packed_image_output,
//.packed_heightmap_output = project.packed_heightmap_output,
@@ -81,17 +83,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 = runtime.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.editor.arena.allocator(), &.{ parent_folder, packed_heightmap_output });
- // try fizzy.editor.atlas.save(path, .heightmap);
+ // const path = try std.fs.path.joinZ(runtime.state().host.arena(), &.{ parent_folder, packed_heightmap_output });
+ // try atlas.save(path, .heightmap);
// }
}
diff --git a/src/plugins/pixi/src/Settings.zig b/src/plugins/pixi/src/Settings.zig
new file mode 100644
index 00000000..95d8d155
--- /dev/null
+++ b/src/plugins/pixi/src/Settings.zig
@@ -0,0 +1,212 @@
+//! 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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const sdk = pixi_mod.sdk;
+
+const PixelArtSettings = @This();
+
+/// Per-plugin settings store key (matches `plugin.id`).
+pub const plugin_id = "pixi";
+
+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 = runtime.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/Sprite.zig b/src/plugins/pixi/src/Sprite.zig
similarity index 100%
rename from src/Sprite.zig
rename to src/plugins/pixi/src/Sprite.zig
diff --git a/src/plugins/pixi/src/State.zig b/src/plugins/pixi/src/State.zig
new file mode 100644
index 00000000..95a02fc1
--- /dev/null
+++ b/src/plugins/pixi/src/State.zig
@@ -0,0 +1,147 @@
+//! 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 `runtime.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 Packer = @import("Packer.zig");
+const ToolsPane = @import("explorer/tools.zig");
+const SpritesPane = @import("explorer/sprites.zig");
+const SpritesPanel = @import("panel/sprites.zig");
+const Palette = @import("internal/Palette.zig");
+const CanvasData = @import("CanvasData.zig");
+const runtime = @import("runtime.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,
+
+/// Project texture atlas packer (owned by App; wired after init).
+packer: ?*Packer = null,
+
+/// Per-workspace-pane canvas chrome (rulers, edit pill, grid reorder), keyed by grouping id.
+canvas_by_grouping: std.AutoArrayHashMapUnmanaged(u64, *CanvasData) = .{},
+
+pub fn canvasForGrouping(st: *State, grouping: u64) *CanvasData {
+ const gpa = runtime.allocator();
+ if (st.canvas_by_grouping.get(grouping)) |existing| return existing;
+ const cd = gpa.create(CanvasData) catch @panic("OOM allocating CanvasData");
+ cd.* = CanvasData.init(grouping);
+ 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/editor/Tools.zig b/src/plugins/pixi/src/Tools.zig
similarity index 93%
rename from src/editor/Tools.zig
rename to src/plugins/pixi/src/Tools.zig
index 68555989..9ea11f2c 100644
--- a/src/editor/Tools.zig
+++ b/src/plugins/pixi/src/Tools.zig
@@ -1,6 +1,7 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
+const pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
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();
+ runtime.state().host.requestPrepareFrame();
}
}
}
@@ -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.editor.tools.stroke_shape;
- const s: i32 = @intCast(fizzy.editor.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(
+ pixi_mod.core.dvui.labelWithKeybind(
tool_name,
switch (tool) {
.pointer => dvui.currentWindow().keybinds.get("pointer") orelse .{},
@@ -312,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),
},
);
@@ -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.editor.atlas.source) catch .{ .w = 0, .h = 0 };
+ const atlas_size: dvui.Size = dvui.imageSize(runtime.state().host.uiAtlas().source) catch .{ .w = 0, .h = 0 };
var mode_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (runtime.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.editor.tools.selection_mode == mode;
+ const selected = runtime.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.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 => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_default],
+ .pixel => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_default],
+ .color => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_default],
};
const uv = dvui.Rect{
.x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w,
@@ -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.editor.atlas.source, rs, .{
+ dvui.renderImage(runtime.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.editor.tools.selection_mode = mode;
+ runtime.state().tools.selection_mode = mode;
}
}
}
diff --git a/src/editor/Transform.zig b/src/plugins/pixi/src/Transform.zig
similarity index 85%
rename from src/editor/Transform.zig
rename to src/plugins/pixi/src/Transform.zig
index 38d58931..32835f42 100644
--- a/src/editor/Transform.zig
+++ b/src/plugins/pixi/src/Transform.zig
@@ -1,6 +1,7 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
+const pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
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 (runtime.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 (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0;
const layer_px: u64 = @as(u64, file.width()) * @as(u64, file.height());
const pix = dvui.textureReadTarget(dvui.currentWindow().arena(), self.target_texture) catch {
dvui.log.err("Failed to read target texture", .{});
return;
};
- const t_after_gpu: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_after_gpu: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0;
file.buffers.stroke.clearAndReserveCapacity(@intCast(layer_px)) catch {
dvui.log.err("Failed to reserve stroke map for transform accept", .{});
return;
};
- const t_loop: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_loop: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0;
// Two passes: undo keys use the pre-write layer; writes are independent per index, so order
// matches the original interleaved loop without mutating layer between undo decisions.
for (pix, file.editor.transform_layer.pixels(), layer.pixels(), 0..) |temp_pixel, transform_pixel, layer_pixel, pixel_index| {
@@ -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.editor.tools.current == .selection) {
+ if (runtime.state().tools.current == .selection) {
file.editor.selection_layer.clearMask();
for (pix, 0..) |temp_pixel, pixel_index| {
if (temp_pixel.a != 0) {
@@ -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 (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0;
- const t_to_change: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_to_change: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0;
const change = file.buffers.stroke.toChange(self.layer_id) catch null;
- const t_after_to_change: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_after_to_change: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0;
- const t_hist: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_hist: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0;
if (change) |c| {
file.history.append(c) catch {
dvui.log.err("Failed to append stroke change to history", .{});
};
}
- const t_end: i128 = if (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 (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0;
+
+ if (pixi_mod.perf.record) {
+ pixi_mod.perf.transform_accept_last_total_ns = @intCast(t_end - t_all);
+ pixi_mod.perf.transform_accept_last_gpu_read_ns = @intCast(t_after_gpu - t_all);
+ pixi_mod.perf.transform_accept_last_merge_loop_ns = @intCast(t_after_loop - t_loop);
+ pixi_mod.perf.transform_accept_last_to_change_ns = @intCast(t_after_to_change - t_to_change);
+ pixi_mod.perf.transform_accept_last_history_append_ns = @intCast(t_end - t_hist);
+ pixi_mod.perf.transform_accept_last_layer_pixels = layer_px;
+ pixi_mod.perf.logTransformAcceptIf();
}
layer.invalidate();
@@ -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));
+ runtime.allocator().free(pixi_mod.image.bytes(self.source));
self.* = undefined;
}
}
/// Cancels the transform and restores the layer to its original state
pub fn cancel(self: *Transform) void {
- if (fizzy.editor.open_files.getPtr(self.file_id)) |file| {
+ if (runtime.state().docs.fileById(self.file_id)) |file| {
var layer = file.getLayer(self.layer_id) orelse return;
var iterator = file.editor.transform_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
while (iterator.next()) |pixel_index| {
@@ -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));
+ runtime.allocator().free(pixi_mod.image.bytes(self.source));
self.* = undefined;
}
}
diff --git a/src/algorithms/algorithms.zig b/src/plugins/pixi/src/algorithms/algorithms.zig
similarity index 100%
rename from src/algorithms/algorithms.zig
rename to src/plugins/pixi/src/algorithms/algorithms.zig
diff --git a/src/algorithms/brezenham.zig b/src/plugins/pixi/src/algorithms/brezenham.zig
similarity index 85%
rename from src/algorithms/brezenham.zig
rename to src/plugins/pixi/src/algorithms/brezenham.zig
index f61ab318..189e68e6 100644
--- a/src/algorithms/brezenham.zig
+++ b/src/plugins/pixi/src/algorithms/brezenham.zig
@@ -1,10 +1,11 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
pub fn process(start: dvui.Point, end: dvui.Point) ![]dvui.Point {
// Bresenham's line algorithm for integer grid points
- var output = std.array_list.Managed(dvui.Point).init(fizzy.editor.arena.allocator());
+ var output = std.array_list.Managed(dvui.Point).init(runtime.state().host.arena());
// Round input points to nearest integer grid
const x0: i32 = @intFromFloat(@floor(start.x));
diff --git a/src/algorithms/reduce.zig b/src/plugins/pixi/src/algorithms/reduce.zig
similarity index 100%
rename from src/algorithms/reduce.zig
rename to src/plugins/pixi/src/algorithms/reduce.zig
diff --git a/src/plugins/pixi/src/clipboard.zig b/src/plugins/pixi/src/clipboard.zig
new file mode 100644
index 00000000..03496ce4
--- /dev/null
+++ b/src/plugins/pixi/src/clipboard.zig
@@ -0,0 +1,220 @@
+//! Sprite copy/paste for the pixel-art plugin. Backs the `pixi_mod.copy` / `pixi_mod.paste`
+//! commands and pixel-art's own canvas handlers; the shell never owns this logic.
+const std = @import("std");
+const dvui = @import("dvui");
+const pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const State = pixi_mod.State;
+const Internal = pixi_mod.internal;
+
+fn activeFile(st: *State) ?*Internal.File {
+ const doc = st.host.activeDoc() orelse return null;
+ 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| {
+ runtime.allocator().free(pixi_mod.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 = runtime.allocator();
+
+ st.sprite_clipboard = .{
+ .source = pixi_mod.image.fromPixelsPMA(
+ @ptrCast(file.editor.transform_layer.pixelsFromRect(gpa, reduced_data_rect)),
+ @intFromFloat(reduced_data_rect.w),
+ @intFromFloat(reduced_data_rect.h),
+ .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, pixi_mod.core.dvui.toastDisplay, 2_000_000);
+ const id = id_mutex.id;
+ const message = std.fmt.allocPrint(dvui.currentWindow().arena(), "Copied selection", .{}) catch "Copied selection.";
+ dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message);
+ 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(pixi_mod.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 = pixi_mod.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 = pixi_mod.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 = pixi_mod.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/deps/msf_gif/fizzy_msf_gif_wasm.c b/src/plugins/pixi/src/deps/msf_gif/fizzy_msf_gif_wasm.c
similarity index 100%
rename from src/deps/msf_gif/fizzy_msf_gif_wasm.c
rename to src/plugins/pixi/src/deps/msf_gif/fizzy_msf_gif_wasm.c
diff --git a/src/deps/msf_gif/msf_gif.c b/src/plugins/pixi/src/deps/msf_gif/msf_gif.c
similarity index 100%
rename from src/deps/msf_gif/msf_gif.c
rename to src/plugins/pixi/src/deps/msf_gif/msf_gif.c
diff --git a/src/deps/msf_gif/msf_gif.h b/src/plugins/pixi/src/deps/msf_gif/msf_gif.h
similarity index 100%
rename from src/deps/msf_gif/msf_gif.h
rename to src/plugins/pixi/src/deps/msf_gif/msf_gif.h
diff --git a/src/deps/msf_gif/msf_gif.zig b/src/plugins/pixi/src/deps/msf_gif/msf_gif.zig
similarity index 100%
rename from src/deps/msf_gif/msf_gif.zig
rename to src/plugins/pixi/src/deps/msf_gif/msf_gif.zig
diff --git a/src/deps/msf_gif/wasm_shim/string.h b/src/plugins/pixi/src/deps/msf_gif/wasm_shim/string.h
similarity index 100%
rename from src/deps/msf_gif/wasm_shim/string.h
rename to src/plugins/pixi/src/deps/msf_gif/wasm_shim/string.h
diff --git a/src/deps/stbi/fizzy_stbi_libc.c b/src/plugins/pixi/src/deps/stbi/fizzy_stbi_libc.c
similarity index 100%
rename from src/deps/stbi/fizzy_stbi_libc.c
rename to src/plugins/pixi/src/deps/stbi/fizzy_stbi_libc.c
diff --git a/src/deps/stbi/stb_image_resize2.h b/src/plugins/pixi/src/deps/stbi/stb_image_resize2.h
similarity index 100%
rename from src/deps/stbi/stb_image_resize2.h
rename to src/plugins/pixi/src/deps/stbi/stb_image_resize2.h
diff --git a/src/deps/stbi/stb_rect_pack.h b/src/plugins/pixi/src/deps/stbi/stb_rect_pack.h
similarity index 100%
rename from src/deps/stbi/stb_rect_pack.h
rename to src/plugins/pixi/src/deps/stbi/stb_rect_pack.h
diff --git a/src/deps/stbi/zstbi.c b/src/plugins/pixi/src/deps/stbi/zstbi.c
similarity index 100%
rename from src/deps/stbi/zstbi.c
rename to src/plugins/pixi/src/deps/stbi/zstbi.c
diff --git a/src/deps/stbi/zstbi.zig b/src/plugins/pixi/src/deps/stbi/zstbi.zig
similarity index 100%
rename from src/deps/stbi/zstbi.zig
rename to src/plugins/pixi/src/deps/stbi/zstbi.zig
diff --git a/src/deps/zip/build.zig b/src/plugins/pixi/src/deps/zip/build.zig
similarity index 100%
rename from src/deps/zip/build.zig
rename to src/plugins/pixi/src/deps/zip/build.zig
diff --git a/src/deps/zip/fizzy_zip_libc.c b/src/plugins/pixi/src/deps/zip/fizzy_zip_libc.c
similarity index 100%
rename from src/deps/zip/fizzy_zip_libc.c
rename to src/plugins/pixi/src/deps/zip/fizzy_zip_libc.c
diff --git a/src/deps/zip/fizzy_zip_strings.c b/src/plugins/pixi/src/deps/zip/fizzy_zip_strings.c
similarity index 100%
rename from src/deps/zip/fizzy_zip_strings.c
rename to src/plugins/pixi/src/deps/zip/fizzy_zip_strings.c
diff --git a/src/deps/zip/fizzy_zip_wasm.h b/src/plugins/pixi/src/deps/zip/fizzy_zip_wasm.h
similarity index 100%
rename from src/deps/zip/fizzy_zip_wasm.h
rename to src/plugins/pixi/src/deps/zip/fizzy_zip_wasm.h
diff --git a/src/deps/zip/src/miniz.h b/src/plugins/pixi/src/deps/zip/src/miniz.h
similarity index 100%
rename from src/deps/zip/src/miniz.h
rename to src/plugins/pixi/src/deps/zip/src/miniz.h
diff --git a/src/deps/zip/src/zip.c b/src/plugins/pixi/src/deps/zip/src/zip.c
similarity index 100%
rename from src/deps/zip/src/zip.c
rename to src/plugins/pixi/src/deps/zip/src/zip.c
diff --git a/src/deps/zip/src/zip.h b/src/plugins/pixi/src/deps/zip/src/zip.h
similarity index 100%
rename from src/deps/zip/src/zip.h
rename to src/plugins/pixi/src/deps/zip/src/zip.h
diff --git a/src/deps/zip/zip.zig b/src/plugins/pixi/src/deps/zip/zip.zig
similarity index 100%
rename from src/deps/zip/zip.zig
rename to src/plugins/pixi/src/deps/zip/zip.zig
diff --git a/src/editor/dialogs/Export.zig b/src/plugins/pixi/src/dialogs/Export.zig
similarity index 84%
rename from src/editor/dialogs/Export.zig
rename to src/plugins/pixi/src/dialogs/Export.zig
index 7f009fe4..03b99c46 100644
--- a/src/editor/dialogs/Export.zig
+++ b/src/plugins/pixi/src/dialogs/Export.zig
@@ -1,17 +1,16 @@
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("../WebFileIo.zig") else struct {};
+const DimensionsLabel = @import("dimensions_label.zig");
+const WebFileIo = @import("../web_file_io.zig");
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
const ExportImageFormat = enum { png, jpg };
-const Dialogs = @import("Dialogs.zig");
-
pub var mode: enum(usize) {
single,
animation,
@@ -39,7 +38,7 @@ pub const min_scale: u32 = 1;
pub var anim_frame_index: usize = 0;
/// Animation to export/preview: uses the animation selected in the editor.
-fn exportAnimationIndex(file: *fizzy.Internal.File) ?usize {
+fn exportAnimationIndex(file: *pixi_mod.internal.File) ?usize {
const idx = file.selected_animation_index orelse return null;
if (idx >= file.animations.len) return null;
return idx;
@@ -49,7 +48,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
// Export stays non-modal so the user can click the canvas to adjust selections. Switch to
// the pointer tool on open so marquee/sprite picks work; drawing tools stay off until close.
if (dvui.firstFrame(id)) {
- fizzy.editor.tools.set(.pointer);
+ runtime.state().tools.set(.pointer);
}
var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both });
@@ -145,7 +144,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 (runtime.state().docs.activeFile(runtime.state().host) != null);
}
pub fn singleDialog(_: dvui.Id) anyerror!bool {
@@ -153,14 +152,14 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool {
var max_scale: f32 = 16.0;
var valid: bool = false;
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
if (file.editor.selected_sprites.findFirstSet() != null) {
max_scale = @min(@divTrunc(max_gif_size[0], @as(f32, @floatFromInt(file.column_width))), @divTrunc(max_gif_size[1], @as(f32, @floatFromInt(file.row_height))));
valid = true;
}
}
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
if (file.editor.selected_sprites.findFirstSet()) |sprite_index| {
renderExportPreviewSprite(file, sprite_index);
}
@@ -168,7 +167,7 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool {
exportScaleSlider(max_scale);
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
if (file.editor.selected_sprites.findFirstSet() != null) {
const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale);
const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
@@ -184,7 +183,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 (runtime.state().docs.activeFile(runtime.state().host)) |file| {
max_scale = @min(
@divTrunc(max_gif_size[0], @as(f32, @floatFromInt(file.column_width))),
@divTrunc(max_gif_size[1], @as(f32, @floatFromInt(file.row_height))),
@@ -222,7 +221,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool {
}
}
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
if (preview_sprite) |sprite_index| {
renderExportPreviewSprite(file, sprite_index);
}
@@ -231,7 +230,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool {
exportScaleSlider(max_scale);
if (preview_sprite) |_| {
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale);
const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
exportDimensionsLabelForExport(column_width, row_height);
@@ -242,20 +241,20 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool {
}
pub fn layerDialog(_: dvui.Id) anyerror!bool {
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
renderExportPreview(file, .layer);
}
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
exportDimensionsLabelForExport(file.width(), file.height());
}
return true;
}
pub fn allDialog(_: dvui.Id) anyerror!bool {
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
renderExportPreview(file, .composite);
}
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
exportDimensionsLabelForExport(file.width(), file.height());
}
return true;
@@ -267,11 +266,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 = runtime.state().docs.activeFile(runtime.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(runtime.allocator(), "{s}.gif", .{
if (exportAnimationIndex(file)) |animation_index| file.animations.items(.name)[animation_index] else "animation",
}, 0) catch {
dvui.log.err("Failed to allocate filename", .{});
@@ -281,32 +280,32 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
break :blk default_filename;
};
- fizzy.backend.showSaveFileDialog(
+ runtime.state().host.showSaveDialog(
saveAnimationCallback,
- &[_]fizzy.backend.DialogFileFilter{.{ .name = "GIF", .pattern = "gif" }},
+ &[_]pixi_mod.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }},
default,
null, // Passing null here means use the last save folder location
);
},
.single => {
- const file = fizzy.editor.activeFile() orelse return;
+ const file = runtime.state().docs.activeFile(runtime.state().host) orelse return;
const sprite_index = file.editor.selected_sprites.findFirstSet() orelse return;
- const base = file.spriteExportName(fizzy.app.allocator, sprite_index) catch {
+ const base = file.spriteExportName(runtime.allocator(), sprite_index) catch {
dvui.log.err("Failed to allocate default export name", .{});
return;
};
- defer fizzy.app.allocator.free(base);
+ defer runtime.allocator().free(base);
- const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch {
+ const default = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{base}, 0) catch {
dvui.log.err("Failed to allocate filename", .{});
return;
};
- defer fizzy.app.allocator.free(default);
+ defer runtime.allocator().free(default);
- fizzy.backend.showSaveFileDialog(
+ runtime.state().host.showSaveDialog(
exportCurrentSpriteCallback,
- &[_]fizzy.backend.DialogFileFilter{
+ &[_]pixi_mod.sdk.SaveDialogFilter{
.{ .name = "PNG", .pattern = "png" },
.{ .name = "JPEG", .pattern = "jpg;jpeg" },
},
@@ -315,22 +314,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 = runtime.state().docs.activeFile(runtime.state().host) orelse return;
+ const base = file.layerExportBaseName(runtime.allocator()) catch {
dvui.log.err("Failed to allocate default export name", .{});
return;
};
- defer fizzy.app.allocator.free(base);
+ defer runtime.allocator().free(base);
- const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch {
+ const default = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{base}, 0) catch {
dvui.log.err("Failed to allocate filename", .{});
return;
};
- defer fizzy.app.allocator.free(default);
+ defer runtime.allocator().free(default);
- fizzy.backend.showSaveFileDialog(
+ runtime.state().host.showSaveDialog(
exportLayerCallback,
- &[_]fizzy.backend.DialogFileFilter{
+ &[_]pixi_mod.sdk.SaveDialogFilter{
.{ .name = "PNG", .pattern = "png" },
.{ .name = "JPEG", .pattern = "jpg;jpeg" },
},
@@ -339,22 +338,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 = runtime.state().docs.activeFile(runtime.state().host) orelse return;
+ const base = file.allExportBaseName(runtime.allocator()) catch {
dvui.log.err("Failed to allocate default export name", .{});
return;
};
- defer fizzy.app.allocator.free(base);
+ defer runtime.allocator().free(base);
- const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch {
+ const default = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{base}, 0) catch {
dvui.log.err("Failed to allocate filename", .{});
return;
};
- defer fizzy.app.allocator.free(default);
+ defer runtime.allocator().free(default);
- fizzy.backend.showSaveFileDialog(
+ runtime.state().host.showSaveDialog(
exportAllCallback,
- &[_]fizzy.backend.DialogFileFilter{
+ &[_]pixi_mod.sdk.SaveDialogFilter{
.{ .name = "PNG", .pattern = "png" },
.{ .name = "JPEG", .pattern = "jpg;jpeg" },
},
@@ -372,7 +371,7 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
/// One call site for the export preview scroll+tile so widget ids (and first-frame layout) stay
/// stable when switching between Single and Animation. Otherwise `renderLayers` early-outs for
/// one frame with `content_rs.s == 0` on a fresh scroll id.
-fn renderExportPreviewSprite(file: *fizzy.Internal.File, sprite_index: usize) void {
+fn renderExportPreviewSprite(file: *pixi_mod.internal.File, sprite_index: usize) void {
const sprite_rect = file.spriteRect(sprite_index);
const max_size_content: dvui.Size = .{
.w = (dvui.currentWindow().rect_pixels.w / dvui.currentWindow().natural_scale) / 2,
@@ -413,7 +412,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(.{
+ pixi_mod.render.renderLayers(.{
.file = file,
.rs = box.data().rectScale(),
.uv = uv,
@@ -441,8 +440,8 @@ 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 });
+ 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 };
@@ -497,8 +496,8 @@ fn exportCheckerboardVertexColor(
return tone.lerp(c_corner, t);
}
-fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color {
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+fn exportSpriteAnimationPaletteColor(file: *pixi_mod.internal.File, sprite_index: usize) ?dvui.Color {
+ if (runtime.state().colors.file_tree_palette) |*palette| {
var animation_index: ?usize = null;
if (file.selected_animation_index) |selected_animation_index| {
@@ -530,13 +529,13 @@ fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: u
}
fn exportCheckerboardCellCornerColor(
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
sprite_index: usize,
pal: CheckerboardPalette,
u: f32,
v: f32,
) dvui.Color {
- switch (fizzy.editor.settings.transparency_effect) {
+ switch (runtime.state().settings.transparency_effect) {
.none => return pal.tone,
.rainbow => return exportCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u, v, 0.5, 0.5, pal.tone),
.animation => {
@@ -558,7 +557,7 @@ fn exportCheckerboardCellCornerColor(
fn appendCheckerboardCellQuad(
builder: *dvui.Triangles.Builder,
quad_idx: *usize,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
sprite_index: usize,
pal: CheckerboardPalette,
geometry_natural: dvui.Rect,
@@ -597,7 +596,7 @@ fn appendCheckerboardCellQuad(
}
fn drawCheckerboardCell(
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
sprite_index: usize,
geometry_natural: dvui.Rect,
rs_box: dvui.RectScale,
@@ -619,7 +618,7 @@ fn drawCheckerboardCell(
};
}
-fn drawCheckerboardFileGrid(file: *fizzy.Internal.File, rs_box: dvui.RectScale) void {
+fn drawCheckerboardFileGrid(file: *pixi_mod.internal.File, rs_box: dvui.RectScale) void {
const n = file.spriteCount();
if (n == 0) return;
@@ -645,13 +644,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: *pixi_mod.internal.File, kind: ExportFullPreviewKind) void {
const w = file.width();
const h = file.height();
if (w == 0 or h == 0) return;
if (kind == .composite) {
- fizzy.render.syncLayerComposite(file) catch {
+ pixi_mod.render.syncLayerComposite(file) catch {
dvui.log.err("Export preview: failed to build layer composite", .{});
return;
};
@@ -689,13 +688,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(runtime.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(runtime.allocator(), .{ .color = .white, .fade = 0.0 }) catch {
return;
};
- defer tris.deinit(fizzy.app.allocator);
+ defer tris.deinit(runtime.allocator());
tris.uvFromRectuv(rs.r, full_uv);
switch (kind) {
@@ -724,20 +723,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(runtime.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 pixi_mod.image.writePngToWriter(source, &out.writer, 0),
+ .jpg => try pixi_mod.image.writeJpgPpiToWriter(source, &out.writer, 0),
}
const bytes = try out.toOwnedSlice();
- defer fizzy.app.allocator.free(bytes);
+ defer runtime.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 pixi_mod.image.writeToPngResolution(source, path, 0),
+ .jpg => try pixi_mod.image.writeToJpgPpi(source, path, 0),
}
}
@@ -751,7 +750,7 @@ fn writeGifBytes(path: []const u8, data: []const u8) !void {
/// Flatten visible layers for one sprite tile. Layer index `0` is the front (drawn last on canvas);
/// higher indices sit behind. `blitData` composites its **first** buffer (upper) over the **second** (lower).
-fn compositedSpritePixels(allocator: std.mem.Allocator, file: *fizzy.Internal.File, sprite_index: usize) ![][4]u8 {
+fn compositedSpritePixels(allocator: std.mem.Allocator, file: *pixi_mod.internal.File, sprite_index: usize) ![][4]u8 {
const sprite_rect = file.spriteRect(sprite_index);
const w: usize = @intFromFloat(sprite_rect.w);
const h: usize = @intFromFloat(sprite_rect.h);
@@ -772,7 +771,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);
+ pixi_mod.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true);
}
return pixels;
@@ -832,7 +831,7 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void {
return error.InvalidExtension;
}
- const file = fizzy.editor.activeFile() orelse {
+ const file = runtime.state().docs.activeFile(runtime.state().host) orelse {
dvui.log.err("Export: No active file", .{});
return error.NoActiveFile;
};
@@ -848,14 +847,14 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void {
export_height = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
}
- const pixels = try compositedSpritePixels(fizzy.app.allocator, file, sprite_index);
- defer fizzy.app.allocator.free(pixels);
+ const pixels = try compositedSpritePixels(runtime.allocator(), file, sprite_index);
+ defer runtime.allocator().free(pixels);
if (scale != 1.0) {
- const resized = fizzy.app.allocator.alloc([4]u8, export_width * export_height) catch {
+ const resized = runtime.allocator().alloc([4]u8, export_width * export_height) catch {
return error.OutOfMemory;
};
- defer fizzy.app.allocator.free(resized);
+ defer runtime.allocator().free(resized);
if (zstbi.resize(
pixels,
file.column_width,
@@ -894,7 +893,7 @@ pub fn exportLayerToPath(path: []const u8) anyerror!void {
return error.InvalidExtension;
}
- const file = fizzy.editor.activeFile() orelse {
+ const file = runtime.state().docs.activeFile(runtime.state().host) orelse {
dvui.log.err("Export: No active file", .{});
return error.NoActiveFile;
};
@@ -914,7 +913,7 @@ pub fn exportAllToPath(path: []const u8) anyerror!void {
return error.InvalidExtension;
}
- const file = fizzy.editor.activeFile() orelse {
+ const file = runtime.state().docs.activeFile(runtime.state().host) orelse {
dvui.log.err("Export: No active file", .{});
return error.NoActiveFile;
};
@@ -923,18 +922,18 @@ pub fn exportAllToPath(path: []const u8) anyerror!void {
const h = file.height();
if (w == 0 or h == 0) return error.InvalidImageSize;
- try fizzy.render.syncLayerComposite(file);
+ try pixi_mod.render.syncLayerComposite(file);
const target = file.editor.layer_composite_target orelse {
return error.NoLayerComposite;
};
- const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target);
+ const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target);
defer {
const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
+ runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
}
- var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr);
+ var tmp_layer: pixi_mod.internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr);
defer tmp_layer.deinit();
const format: ExportImageFormat = if (is_png) .png else .jpg;
@@ -950,7 +949,7 @@ pub fn createAnimationGif(path: []const u8) anyerror!void {
return error.InvalidExtension;
}
- const file = fizzy.editor.activeFile() orelse {
+ const file = runtime.state().docs.activeFile(runtime.state().host) orelse {
dvui.log.err("Export: No active file", .{});
return error.NoActiveFile;
};
@@ -962,7 +961,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: pixi_mod.internal.Animation = file.animations.get(animation_index);
var export_width = file.column_width;
var export_height = file.row_height;
@@ -981,11 +980,11 @@ pub fn createAnimationGif(path: []const u8) anyerror!void {
msf_gif.msf_gif_alpha_threshold = 240;
for (anim.frames) |frame| {
- const pixels = compositedSpritePixels(fizzy.app.allocator, file, frame.sprite_index) catch |err| {
+ const pixels = compositedSpritePixels(runtime.allocator(), file, frame.sprite_index) catch |err| {
if (err == error.NoPixels) continue;
return err;
};
- defer fizzy.app.allocator.free(pixels);
+ defer runtime.allocator().free(pixels);
{ // msf_gif will error if there are only transparent pixels
const valid = blk: {
@@ -1005,11 +1004,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 = runtime.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 runtime.allocator().free(resized_pixels);
_ = zstbi.resize(
pixels,
diff --git a/src/editor/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixi/src/dialogs/FlatRasterSaveWarning.zig
similarity index 69%
rename from src/editor/dialogs/FlatRasterSaveWarning.zig
rename to src/plugins/pixi/src/dialogs/FlatRasterSaveWarning.zig
index 26de3119..66cfcafa 100644
--- a/src/editor/dialogs/FlatRasterSaveWarning.zig
+++ b/src/plugins/pixi/src/dialogs/FlatRasterSaveWarning.zig
@@ -1,23 +1,18 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
-/// When `pending_mode == .save_and_close`, resume `Editor.advanceSaveAllQuit` after flat save.
-pub var pending_from_save_all_quit: bool = false;
+pub const Mode = pixi_mod.sdk.Plugin.SaveConfirmMode;
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 = fizzy.dvui.dialog(@src(), .{
+ var mutex = pixi_mod.core.dvui.dialog(@src(), .{
.displayFn = dialog,
.callafterFn = callAfter,
.title = "Save as .fiz or current extension?",
@@ -30,11 +25,12 @@ 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);
}
-fn fileRef(file_id: u64) ?*fizzy.Internal.File {
- return fizzy.editor.open_files.getPtr(file_id);
+fn fileRef(file_id: u64) ?*pixi_mod.internal.File {
+ return runtime.state().docs.fileById(file_id);
}
fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool {
@@ -61,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);
@@ -102,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)) {
@@ -113,24 +110,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 = runtime.state().host.docIndex(file_id) orelse return;
+ runtime.state().host.setActiveDocIndex(idx);
if (pending_mode == .save_and_close) {
- fizzy.editor.pending_close_file_id = file_id;
+ runtime.state().host.setPendingCloseDocId(file_id);
}
- fizzy.dvui.closeFloatingDialogAnchored();
- fizzy.editor.requestSaveAs();
+ pixi_mod.core.dvui.closeFloatingDialogAnchored();
+ runtime.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 => {
- fizzy.dvui.closeFloatingDialogAnchored();
+ pixi_mod.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 = runtime.state().host.docIndex(file_id) orelse return;
+ runtime.state().host.setActiveDocIndex(idx);
+ runtime.state().host.requestWebSave(.save);
} else {
try f.saveAsync();
}
@@ -143,32 +140,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 (from_save_all_quit) runtime.state().host.abortSaveAllQuit();
return;
};
- if (pending_from_save_all_quit) {
- fizzy.editor.quit_saves_in_flight.put(fizzy.app.allocator, file_id, {}) catch |err| {
+ if (from_save_all_quit) {
+ runtime.state().host.trackQuitSaveInFlight(file_id) catch |err| {
dvui.log.err("Save all quit track: {s}", .{@errorName(err)});
- fizzy.editor.abortSaveAllQuit();
+ runtime.state().host.abortSaveAllQuit();
return;
};
- fizzy.editor.pending_quit_continue = true;
+ runtime.state().host.resumeSaveAllQuit();
} else {
- try fizzy.editor.pending_close_after_save.put(fizzy.app.allocator, file_id, {});
+ try runtime.state().host.queueCloseAfterSave(file_id);
}
- fizzy.dvui.closeFloatingDialogAnchored();
+ pixi_mod.core.dvui.closeFloatingDialogAnchored();
},
}
}
fn onCancel() void {
- fizzy.editor.cancelPendingSaveDialog();
- fizzy.dvui.closeFloatingDialogAnchored();
+ runtime.state().host.cancelPendingSaveDialog();
+ pixi_mod.core.dvui.closeFloatingDialogAnchored();
}
pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) !void {
switch (response) {
- .cancel => fizzy.editor.cancelPendingSaveDialog(),
+ .cancel => runtime.state().host.cancelPendingSaveDialog(),
else => {},
}
}
diff --git a/src/editor/dialogs/GridLayout.zig b/src/plugins/pixi/src/dialogs/GridLayout.zig
similarity index 91%
rename from src/editor/dialogs/GridLayout.zig
rename to src/plugins/pixi/src/dialogs/GridLayout.zig
index bb55014e..bda528fd 100644
--- a/src/editor/dialogs/GridLayout.zig
+++ b/src/plugins/pixi/src/dialogs/GridLayout.zig
@@ -6,14 +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 = @import("../widgets/CanvasWidget.zig");
-const FloatingWindowWidget = @import("../widgets/FloatingWindowWidget.zig");
-const builtin = @import("builtin");
+const CanvasWidget = pixi_mod.core.dvui.CanvasWidget;
+const CanvasBridge = @import("../widgets/CanvasBridge.zig");
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
+const FloatingWindowWidget = pixi_mod.core.dvui.FloatingWindowWidget;
/// Editable grid fields for one mode (Slice vs Resize each keep their own backing).
pub const GridFormState = struct {
@@ -76,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]pixi_mod.math.layout_anchor.LayoutAnchor = .{
.nw, .n, .ne,
.w, .c, .e,
.sw, .s, .se,
@@ -84,8 +85,35 @@ const anchors: [9]fizzy.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 = runtime.state().docs.fileById(file_id) orelse return;
+ presetFromFile(file);
+
+ var mutex = pixi_mod.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: *fizzy.Internal.File) void {
+pub fn presetFromFile(file: *pixi_mod.internal.File) void {
resize_form = .{
.column_width = file.column_width,
.row_height = file.row_height,
@@ -108,7 +136,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;
@@ -124,14 +152,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.backend.isMaximized(dvui.currentWindow()))
- content_color.opacity(fizzy.editor.settings.content_opacity)
- else
- content_color;
- },
- else => {},
+ if (runtime.state().host.appliesNativeWindowOpacity()) {
+ content_color = if (!runtime.state().host.isMaximized())
+ content_color.opacity(runtime.state().host.contentOpacity())
+ else
+ content_color;
}
return content_color;
}
@@ -210,7 +235,7 @@ fn font() dvui.Font {
/// Checkerboard behind the preview: one quad per grid cell with UV 0..1 (same as
/// `FileWidget.drawCheckerboardCellsBatched`). Per-cell so vertex colors can vary.
fn drawCheckerboardPreviewTiled(
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
cv: *CanvasWidget,
rs_box: dvui.RectScale,
cols: u32,
@@ -221,7 +246,7 @@ fn drawCheckerboardPreviewTiled(
if (cell_w <= 0 or cell_h <= 0 or cols == 0 or rows == 0) return;
const pal = previewCheckerboardPalette();
- const te = fizzy.editor.settings.transparency_effect;
+ const te = runtime.state().settings.transparency_effect;
const cols_f = @max(@as(f32, @floatFromInt(cols)), 1.0);
const rows_f = @max(@as(f32, @floatFromInt(rows)), 1.0);
const nw = cell_w * cols_f;
@@ -393,7 +418,7 @@ fn appendTexturedRectQuad(
/// Samples the layer composite texture per **old grid cell**, mapping each sprite through `cellAnchoredBlit`
/// so the preview matches the result of `applyGridLayout` independently in every tile.
fn drawCompositePreviewPerCells(
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
rs_box: dvui.RectScale,
old_cols: u32,
old_rows: u32,
@@ -403,9 +428,9 @@ fn drawCompositePreviewPerCells(
new_rows: u32,
new_cw_: u32,
new_rh_: u32,
- anchor_vis: fizzy.math.layout_anchor.LayoutAnchor,
+ anchor_vis: pixi_mod.math.layout_anchor.LayoutAnchor,
) void {
- fizzy.render.syncLayerComposite(file) catch {
+ pixi_mod.render.syncLayerComposite(file) catch {
dvui.log.err("Grid layout preview: composite failed", .{});
return;
};
@@ -425,7 +450,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 = pixi_mod.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw_, new_rh_, anchor_vis);
if (blk.sw == 0 or blk.sh == 0) return;
var nrow: u32 = 0;
@@ -458,9 +483,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: *pixi_mod.internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void {
if (nw <= 0 or nh <= 0) return;
- fizzy.render.syncLayerComposite(file) catch {
+ pixi_mod.render.syncLayerComposite(file) catch {
dvui.log.err("Grid layout preview: composite failed", .{});
return;
};
@@ -484,7 +509,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: *pixi_mod.internal.File) void {
const canvas = file.canvasPixelSize();
const tw = canvas.w;
const th = canvas.h;
@@ -514,14 +539,14 @@ fn harmonizeSliceStateWithLayer(file: *fizzy.Internal.File) void {
fn renderPreview(
mutex_id: dvui.Id,
dlg_id: dvui.Id,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
nw: u32,
nh: u32,
new_cw_: u32,
new_rh_: u32,
new_cols: u32,
new_rows: u32,
- anchor_vis: fizzy.math.layout_anchor.LayoutAnchor,
+ anchor_vis: pixi_mod.math.layout_anchor.LayoutAnchor,
slice_full_layer: bool,
host_rect: dvui.Rect,
) void {
@@ -594,6 +619,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,
@@ -827,7 +854,7 @@ fn gridLayoutDrawModePill(dlg_id: dvui.Id) void {
if (button.clicked()) {
const new_mode: Mode = @enumFromInt(i);
if (new_mode == .slice and mode != .slice) {
- if (file_id_for_dialog) |fid| if (fizzy.editor.open_files.getPtr(fid)) |tf|
+ if (file_id_for_dialog) |fid| if (runtime.state().docs.fileById(fid)) |tf|
harmonizeSliceStateWithLayer(tf);
}
mode = new_mode;
@@ -843,8 +870,8 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
const form_font = font();
const file_id_for_dialog = dvui.dataGet(null, id, "_grid_layout_file_id", u64);
- const target_file: ?*fizzy.Internal.File = if (file_id_for_dialog) |fid|
- fizzy.editor.open_files.getPtr(fid)
+ const target_file: ?*pixi_mod.internal.File = if (file_id_for_dialog) |fid|
+ runtime.state().docs.fileById(fid)
else
null;
@@ -875,10 +902,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, .{});
+ pixi_mod.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, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .bottom, .{});
}
// Form (intrinsic width, full height) + preview (expands horizontally with the window).
@@ -938,18 +965,18 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
const v_scroll = left_scroll.offset(.vertical);
const h_scroll = left_scroll.offset(.horizontal);
if (v_scroll > 0.0) {
- fizzy.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{});
}
if (left_scroll.virtual_size.h > left_scroll.viewport.h) {
- fizzy.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{});
}
pane_left.deinit();
if (left_scroll.virtual_size.w > left_scroll.viewport.w) {
- fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{});
}
if (h_scroll > 0.0) {
- fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{});
}
shell_left.deinit();
}
@@ -985,7 +1012,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
slice_form.rows,
anchors[@min(anchor_ix, anchors.len - 1)],
};
- break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(fizzy.math.layout_anchor.LayoutAnchor, .nw) };
+ break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(pixi_mod.math.layout_anchor.LayoutAnchor, .nw) };
}
break :blk switch (mode) {
.slice => .{
@@ -1016,15 +1043,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, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .top, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .bottom, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .left, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .right, .{});
}
if (target_file) |tf| {
const host_rect = preview_host.data().contentRect();
- const dims_ok = fizzy.Internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows);
+ const dims_ok = pixi_mod.internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows);
if (dims_ok) {
renderPreview(
id,
@@ -1081,7 +1108,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
/// Resize-mode form: cell width (x), cell height (y), columns (x), rows (y); 9-way anchor; current vs after readout.
fn drawResizeForm(
unique_id: dvui.Id,
- target_file: ?*fizzy.Internal.File,
+ target_file: ?*pixi_mod.internal.File,
form_font: dvui.Font,
) bool {
var valid: bool = true;
@@ -1107,7 +1134,7 @@ fn drawResizeForm(
.color_text = dvui.themeGet().color(.control, .text),
});
- if (!fizzy.Internal.File.validateGridLayoutProposedDims(
+ if (!pixi_mod.internal.File.validateGridLayoutProposedDims(
resize_form.column_width,
resize_form.row_height,
resize_form.columns,
@@ -1300,7 +1327,7 @@ fn drawResizeForm(
/// multiply back to the locked total.
fn drawSliceForm(
unique_id: dvui.Id,
- target_file: ?*fizzy.Internal.File,
+ target_file: ?*pixi_mod.internal.File,
form_font: dvui.Font,
) bool {
var valid: bool = true;
@@ -1463,7 +1490,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 `pixi_mod.core.dvui.dialogWindow` (open
/// `autoSize()` animation, nudge + center on modal rect). `min_size_content` is half the main
/// window so the first layout pass does not collapse the shell; DVUI then grows to fit content
/// (see `FloatingWindowWidget` `Size.max(min_size, min_sizeGet)`). Do not use `max_size_content`
@@ -1476,7 +1503,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
};
if (modal) {
- fizzy.editor.dim_titlebar = true;
+ pixi_mod.core.dvui.modal_dim_titlebar = true;
}
const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse {
@@ -1489,8 +1516,8 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
};
const cancel_label = dvui.dataGetSlice(null, id, "_cancel_label", []u8);
const default = dvui.dataGet(null, id, "_default", dvui.enums.DialogResponse);
- const callafter = dvui.dataGet(null, id, "_callafter", fizzy.dvui.CallAfterFn);
- const displayFn = dvui.dataGet(null, id, "_displayFn", fizzy.dvui.DisplayFn);
+ const callafter = dvui.dataGet(null, id, "_callafter", pixi_mod.core.dvui.CallAfterFn);
+ const displayFn = dvui.dataGet(null, id, "_displayFn", pixi_mod.core.dvui.DisplayFn);
// Default shell: wide enough for form + preview; DVUI autoSize grows to content if larger.
const wr = dvui.windowRect();
@@ -1498,7 +1525,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
const init_h = @round(wr.h * 0.52);
const center_on = dvui.currentWindow().subwindows.current_rect;
- var win = fizzy.dvui.floatingWindow(@src(), .{
+ var win = pixi_mod.core.dvui.floatingWindow(@src(), .{
.modal = modal,
.center_on = center_on,
.window_avoid = .nudge,
@@ -1530,12 +1557,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;
+ pixi_mod.core.dvui.dialog_close_rect_override = null;
dvui.dialogRemove(id);
}
- } else if (fizzy.Editor.Explorer.files.new_file_close_rect) |close_rect| {
+ } else if (pixi_mod.core.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;
+ pixi_mod.core.dvui.dialog_close_rect_override = null;
} else {
// Call `autoSize` only while opening. Doing it every frame leaves `auto_size` true and the
// window keeps animating/snapping to content min size — user resize appears "locked".
@@ -1560,16 +1587,16 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
var shell = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both });
defer shell.deinit();
- const header_kind: 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: pixi_mod.core.dvui.DialogHeaderKind = switch (dvui.dataGet(null, id, "_header_kind", u8) orelse 0) {
+ @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.none) => .none,
+ @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.info) => .info,
+ @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.warning) => .warning,
+ @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.err) => .err,
else => .none,
};
var header_openflag = true;
- win.dragAreaSet(fizzy.dvui.windowHeader(title, "", &header_openflag, header_kind));
+ win.dragAreaSet(pixi_mod.core.dvui.windowHeader(title, "", &header_openflag, header_kind));
if (!header_openflag) {
if (callafter) |ca| {
ca(id, .cancel) catch {
@@ -1602,7 +1629,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
}
}
- { // Footer — match `fizzy.dvui.dialogWindow` (horizontal strip, gravity_x centered).
+ { // Footer — match `pixi_mod.core.dvui.dialogWindow` (horizontal strip, gravity_x centered).
var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
.gravity_x = 0.5,
.padding = .{ .y = 6, .h = 8 },
@@ -1692,12 +1719,12 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
switch (response) {
.ok => {
const file_id = dvui.dataGet(null, id, "_grid_layout_file_id", u64) orelse return;
- const file = fizzy.editor.open_files.getPtr(file_id) orelse return;
+ const file = runtime.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 (!pixi_mod.internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows))
return;
file.applyGridSliceOnly(.{
.column_width = s.column_width,
@@ -1711,7 +1738,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 (!pixi_mod.internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows))
return;
file.applyGridLayout(.{
.column_width = r.column_width,
@@ -1727,7 +1754,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
}
dvui.refresh(null, @src(), dvui.currentWindow().data().id);
- fizzy.editor.requestCompositeWarmup();
+ runtime.state().host.requestPrepareFrame();
},
.cancel => {},
else => {},
diff --git a/src/editor/dialogs/NewFile.zig b/src/plugins/pixi/src/dialogs/NewFile.zig
similarity index 83%
rename from src/editor/dialogs/NewFile.zig
rename to src/plugins/pixi/src/dialogs/NewFile.zig
index a4a0a462..10c068e3 100644
--- a/src/editor/dialogs/NewFile.zig
+++ b/src/plugins/pixi/src/dialogs/NewFile.zig
@@ -1,8 +1,9 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
-const Dialogs = @import("Dialogs.zig");
+const DimensionsLabel = @import("dimensions_label.zig");
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
pub var mode: enum(usize) {
single,
@@ -17,8 +18,29 @@ 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 = pixi_mod.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);
+ 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);
@@ -174,7 +196,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;
}
@@ -188,18 +210,16 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
switch (response) {
.ok => {
if (parent_path) |parent| {
- const new_path = try std.fs.path.join(fizzy.app.allocator, &.{ parent, "untitled.fiz" });
- defer fizzy.app.allocator.free(new_path);
+ const new_path = try std.fs.path.join(runtime.allocator(), &.{ parent, "untitled.fiz" });
+ defer runtime.allocator().free(new_path);
- const file = fizzy.editor.newFile(new_path, .{
+ const doc = try runtime.state().host.createDocument(new_path, .{
.column_width = column_width,
.row_height = row_height,
.columns = if (mode == .single) 1 else columns,
.rows = if (mode == .single) 1 else rows,
- }) catch {
- dvui.log.err("Failed to create file in folder: {s}", .{parent});
- return error.FailedToCreateFile;
- };
+ });
+ const file = runtime.state().docs.fileFrom(doc);
// Save synchronously so the tree's directory scan sees the new file on the next draw
// (saveAsync would finish later and the fly-to / rename row would never match).
@@ -208,23 +228,17 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
return error.FailedToSaveFile;
};
- if (fizzy.Editor.Explorer.files.new_file_path) |old| {
- fizzy.app.allocator.free(old);
- }
- fizzy.Editor.Explorer.files.new_file_path = try fizzy.app.allocator.dupe(u8, file.path);
+ try runtime.state().host.setExplorerNewFilePath(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 runtime.state().host.allocUntitledPath();
+ defer runtime.allocator().free(new_path);
+ _ = try runtime.state().host.createDocument(new_path, .{
.column_width = column_width,
.row_height = row_height,
.columns = if (mode == .single) 1 else columns,
.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/pixi/src/dialogs/dimensions_label.zig b/src/plugins/pixi/src/dialogs/dimensions_label.zig
new file mode 100644
index 00000000..42db8da8
--- /dev/null
+++ b/src/plugins/pixi/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/pixi/src/doc_bridge.zig b/src/plugins/pixi/src/doc_bridge.zig
new file mode 100644
index 00000000..64df28eb
--- /dev/null
+++ b/src/plugins/pixi/src/doc_bridge.zig
@@ -0,0 +1,93 @@
+//! 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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const State = pixi_mod.State;
+const Internal = pixi_mod.internal;
+const DocHandle = pixi_mod.sdk.DocHandle;
+
+fn docFile(st: *State, doc: DocHandle) ?*Internal.File {
+ return st.docs.fileById(doc.id);
+}
+
+pub fn bindDocumentToWorkspace(
+ 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 = runtime.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/pixi/src/doc_lifecycle.zig b/src/plugins/pixi/src/doc_lifecycle.zig
new file mode 100644
index 00000000..85eb1c15
--- /dev/null
+++ b/src/plugins/pixi/src/doc_lifecycle.zig
@@ -0,0 +1,155 @@
+//! 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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const State = pixi_mod.State;
+const Internal = pixi_mod.internal;
+const DocHandle = pixi_mod.sdk.DocHandle;
+const NewDocGrid = pixi_mod.sdk.EditorAPI.NewDocGrid;
+
+fn docFile(st: *State, doc: DocHandle) ?*Internal.File {
+ return st.docs.fileById(doc.id);
+}
+
+fn activeFile(st: *State) ?*Internal.File {
+ const doc = st.host.activeDoc() orelse return null;
+ return docFile(st, doc);
+}
+
+pub fn sizeOfDocument(_: *State) usize {
+ return @sizeOf(Internal.File);
+}
+
+pub fn alignOfDocument(_: *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;
+ pixi_mod.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/pixi/src/docs_registry.zig b/src/plugins/pixi/src/docs_registry.zig
new file mode 100644
index 00000000..c28ef928
--- /dev/null
+++ b/src/plugins/pixi/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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const State = pixi_mod.State;
+const Internal = pixi_mod.internal;
+
+pub fn registerOpenDocument(st: *State, file: *Internal.File) !*Internal.File {
+ const gpa = runtime.allocator();
+ try st.docs.files.put(gpa, file.id, file.*);
+ return st.docs.files.getPtr(file.id).?;
+}
+
+pub fn documentFromId(st: *State, id: u64) ?*Internal.File {
+ return st.docs.fileById(id);
+}
+
+pub fn documentFromPath(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/editor/explorer/project.zig b/src/plugins/pixi/src/explorer/project.zig
similarity index 88%
rename from src/editor/explorer/project.zig
rename to src/plugins/pixi/src/explorer/project.zig
index ccc1bfe5..0aec0f03 100644
--- a/src/editor/explorer/project.zig
+++ b/src/plugins/pixi/src/explorer/project.zig
@@ -2,8 +2,10 @@ const std = @import("std");
const builtin = @import("builtin");
const icons = @import("icons");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
+const PackProject = @import("../pack_project.zig");
pub fn draw() !void {
// On web there's no project folder concept. Render a simplified pane that
@@ -14,8 +16,8 @@ pub fn draw() !void {
return;
}
- if (fizzy.editor.folder) |folder| {
- if (fizzy.editor.project) |_| {
+ if (runtime.state().host.folder()) |folder| {
+ if (runtime.state().project) |_| {
const tl = dvui.textLayout(@src(), .{}, .{
.expand = .none,
.margin = dvui.Rect.all(0),
@@ -34,7 +36,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 = runtime.state().host.explorerVirtualSize().w, .h = std.math.floatMax(f32) },
});
defer box.deinit();
@@ -44,19 +46,19 @@ pub fn draw() !void {
tl.deinit();
if (dvui.button(@src(), "Create Project", .{}, .{ .expand = .horizontal })) {
- fizzy.editor.project = .{};
+ runtime.state().project = .{};
}
return;
}
- const packing = fizzy.editor.isPackingActive();
+ const packing = PackProject.isActive(runtime.state());
if (packProjectButton(packing)) {
- fizzy.editor.startPackProject() catch |err| {
+ PackProject.start(runtime.state()) catch |err| {
dvui.log.err("Failed to start project pack: {any}", .{err});
};
}
- if (fizzy.packer.atlas != null) {
+ if (runtime.packer().atlas != null) {
drawPackedAtlasStats();
}
@@ -67,8 +69,8 @@ pub fn draw() !void {
dvui.log.err("Failed to draw path text entry", .{});
};
- if (fizzy.editor.project) |project| {
- if (fizzy.packer.atlas) |atlas| {
+ if (runtime.state().project) |project| {
+ if (runtime.packer().atlas) |atlas| {
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } });
if (dvui.button(@src(), "Export Project", .{ .draw_focus = false }, .{
.expand = .horizontal,
@@ -129,13 +131,13 @@ pub fn draw() !void {
// break :blk true;
// };
- // if (dvui.dialogNativeFileSave(fizzy.app.allocator, .{
+ // if (dvui.dialogNativeFileSave(runtime.allocator(), .{
// .title = "Select Atlas Data Output",
// .filters = &.{".atlas"},
// .filter_description = "Atlas file",
// .path = if (valid_path) project.packed_atlas_output else null,
// }) catch null) |path| {
- // project.packed_atlas_output = fizzy.app.allocator.dupe(u8, path[0..]) catch null;
+ // project.packed_atlas_output = runtime.allocator().dupe(u8, path[0..]) catch null;
// set_text = true;
// } else {
// dvui.log.err("Project failed to copy new path", .{});
@@ -162,7 +164,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 = runtime.allocator().dupe(u8, t) catch null;
// } else {
// project.packed_atlas_output = null;
// }
@@ -210,13 +212,13 @@ pub fn draw() !void {
// break :blk true;
// };
- // if (dvui.dialogNativeFileSave(fizzy.app.allocator, .{
+ // if (dvui.dialogNativeFileSave(runtime.allocator(), .{
// .title = "Select Atlas Image Output",
// .filters = &.{".png"},
// .filter_description = "Image file",
// .path = if (valid_path) project.packed_image_output else null,
// }) catch null) |path| {
- // project.packed_image_output = fizzy.app.allocator.dupe(u8, path[0..]) catch null;
+ // project.packed_image_output = runtime.allocator().dupe(u8, path[0..]) catch null;
// set_text = true;
// } else {
// dvui.log.err("Project failed to copy new path", .{});
@@ -243,7 +245,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 = runtime.allocator().dupe(u8, t) catch null;
// } else {
// project.packed_image_output = null;
// }
@@ -258,7 +260,7 @@ const PathType = enum {
};
fn pathTextEntry(path_type: PathType) !void {
- if (fizzy.editor.project) |*project| {
+ if (runtime.state().project) |*project| {
const output_path = switch (path_type) {
.atlas => &project.packed_atlas_output,
.image => &project.packed_image_output,
@@ -315,7 +317,7 @@ fn pathTextEntry(path_type: PathType) !void {
break :blk true;
};
- fizzy.backend.showSaveFileDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{
+ runtime.state().host.showSaveDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{
if (path_type == .atlas) .{ .name = "Atlas Data", .pattern = "atlas" } else .{ .name = "Atlas Image", .pattern = "png;jpg;jpeg" },
}, "", if (valid_path) output_path.* else null);
set_text = true;
@@ -342,7 +344,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.* = runtime.allocator().dupe(u8, t) catch null;
} else {
output_path.* = null;
}
@@ -351,8 +353,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 = &runtime.packer().atlas.?;
+ const image_size = pixi_mod.image.size(atlas.source);
const atlas_w: u32 = @intFromFloat(image_size.w);
const atlas_h: u32 = @intFromFloat(image_size.h);
@@ -371,7 +373,7 @@ fn drawPackedAtlasStats() void {
const label_opts: dvui.Options = .{ .font = body, .color_text = label_color };
const value_opts: dvui.Options = .{ .font = body, .color_text = value_color };
- if (fizzy.packer.last_packed_at_ns) |packed_at_ns| {
+ if (runtime.packer().last_packed_at_ns) |packed_at_ns| {
var when_buf: [64]u8 = undefined;
const when = formatLastPacked(&when_buf, packed_at_ns);
tl.addText("Last packed: ", label_opts);
@@ -396,7 +398,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(pixi_mod.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s);
if (elapsed_s < 10) {
return std.fmt.bufPrint(buf, "just now", .{}) catch "recently";
}
@@ -442,7 +444,7 @@ fn packProjectButton(packing: bool) bool {
// Spinner overlays at the right edge — same content rect as the label, but anchored to
// `gravity_x = 1.0`. Sized to roughly match the cap height so it doesn't fight the label.
if (packing) {
- fizzy.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{
+ pixi_mod.core.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{
.min_size_content = .{ .w = 16, .h = 16 },
.gravity_x = 1.0,
.gravity_y = 0.5,
@@ -455,24 +457,24 @@ fn packProjectButton(packing: bool) bool {
}
pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void {
- if (fizzy.editor.project) |*project| {
+ if (runtime.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.* = runtime.allocator().dupe(u8, path) catch null;
}
}
}
}
pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void {
- if (fizzy.editor.project) |*project| {
+ if (runtime.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.* = runtime.allocator().dupe(u8, path) catch null;
}
}
}
@@ -482,7 +484,7 @@ pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void {
/// the Pack button (operates on currently-open files) and Download buttons for
/// the resulting atlas/image data.
fn drawWeb() !void {
- if (fizzy.editor.open_files.count() == 0) {
+ if (runtime.state().host.openDocCount() == 0) {
dvui.labelNoFmt(
@src(),
"Open one or more files to pack.",
@@ -500,19 +502,19 @@ fn drawWeb() !void {
.style = .highlight,
};
- const packing = fizzy.editor.isPackingActive();
+ const packing = PackProject.isActive(runtime.state());
if (packProjectButton(packing)) {
- fizzy.editor.startPackProject() catch |err| {
+ PackProject.start(runtime.state()) catch |err| {
dvui.log.err("Failed to pack open files: {any}", .{err});
};
}
- if (fizzy.packer.atlas != null) {
+ if (runtime.packer().atlas != null) {
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } });
drawPackedAtlasStats();
}
- if (fizzy.packer.atlas) |atlas| {
+ if (runtime.packer().atlas) |atlas| {
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } });
if (dvui.button(@src(), "Download Atlas JSON", .{ .draw_focus = false }, btn_opts)) {
atlas.save("atlas.atlas", .data) catch {
diff --git a/src/editor/explorer/sprites.zig b/src/plugins/pixi/src/explorer/sprites.zig
similarity index 90%
rename from src/editor/explorer/sprites.zig
rename to src/plugins/pixi/src/explorer/sprites.zig
index 8e0caea8..121571da 100644
--- a/src/editor/explorer/sprites.zig
+++ b/src/plugins/pixi/src/explorer/sprites.zig
@@ -1,9 +1,8 @@
const std = @import("std");
const dvui = @import("dvui");
const icons = @import("icons");
-
-const fizzy = @import("../../fizzy.zig");
-const Editor = fizzy.Editor;
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
const Sprites = @This();
@@ -91,7 +90,7 @@ pub fn init() Sprites {
return .{};
}
-fn selectionUiKey(file: *fizzy.Internal.File) u64 {
+fn selectionUiKey(file: *pixi_mod.internal.File) u64 {
const c = file.editor.selected_sprites.count();
if (c == 0) return 0;
const first = file.editor.selected_sprites.findFirstSet() orelse return 0;
@@ -101,7 +100,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: *pixi_mod.internal.File, indices: []const usize, old_vals: []const [2]f32) bool {
for (indices, old_vals) |si, ov| {
const cur = file.sprites.get(si).origin;
if (cur[0] != ov[0] or cur[1] != ov[1]) return true;
@@ -113,36 +112,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);
+ runtime.allocator().free(s);
self.origin_x_drag_indices = null;
}
if (self.origin_x_drag_old_vals) |v| {
- fizzy.app.allocator.free(v);
+ runtime.allocator().free(v);
self.origin_x_drag_old_vals = null;
}
},
.y => {
if (self.origin_y_drag_indices) |s| {
- fizzy.app.allocator.free(s);
+ runtime.allocator().free(s);
self.origin_y_drag_indices = null;
}
if (self.origin_y_drag_old_vals) |v| {
- fizzy.app.allocator.free(v);
+ runtime.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: *pixi_mod.internal.File, axis: enum { x, y }) !void {
switch (axis) {
.x => if (self.origin_x_drag_indices != null) return,
.y => if (self.origin_y_drag_indices != null) return,
}
const count = file.editor.selected_sprites.count();
- const indices = try 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 runtime.allocator().alloc(usize, count);
+ errdefer runtime.allocator().free(indices);
+ const old_vals = try runtime.allocator().alloc([2]f32, count);
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
var i: usize = 0;
while (iter.next()) |si| : (i += 1) {
@@ -161,15 +160,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: *pixi_mod.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);
+ runtime.allocator().free(indices);
+ runtime.allocator().free(old_vals);
return err;
};
}
-fn applySpriteOriginAxisNoHistory(file: *fizzy.Internal.File, axis: enum { x, y }, new_val: f32) void {
+fn applySpriteOriginAxisNoHistory(file: *pixi_mod.internal.File, axis: enum { x, y }, new_val: f32) void {
const cw = @as(f32, @floatFromInt(file.column_width));
const rh = @as(f32, @floatFromInt(file.row_height));
const max_v: f32 = switch (axis) {
@@ -186,7 +185,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: *pixi_mod.internal.File, axis: enum { x, y }, new_val: f32) !void {
const cw = @as(f32, @floatFromInt(file.column_width));
const rh = @as(f32, @floatFromInt(file.row_height));
const max_v: f32 = switch (axis) {
@@ -198,10 +197,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 runtime.allocator().alloc(usize, count);
+ errdefer runtime.allocator().free(indices);
+ const old_vals = try runtime.allocator().alloc([2]f32, count);
+ errdefer runtime.allocator().free(old_vals);
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
var i: usize = 0;
@@ -221,14 +220,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);
+ runtime.allocator().free(indices);
+ runtime.allocator().free(old_vals);
return err;
};
}
pub fn draw(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
const parent_height = dvui.parentGet().data().rect.h - 2.0 * dvui.currentWindow().natural_scale;
const parent_data = dvui.parentGet().data();
@@ -289,7 +288,7 @@ pub fn draw(self: *Sprites) !void {
}
pub fn drawOriginControls(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
if (file.editor.selected_sprites.count() == 0) return;
const key = selectionUiKey(file);
@@ -418,8 +417,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);
+ runtime.allocator().free(indices);
+ runtime.allocator().free(old_vals);
}
}
}
@@ -489,8 +488,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);
+ runtime.allocator().free(indices);
+ runtime.allocator().free(old_vals);
}
}
}
@@ -507,7 +506,7 @@ pub fn drawAnimationControls(self: *Sprites) !void {
const icon_color = dvui.themeGet().color(.control, .text);
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
{
var add_animation_button: dvui.ButtonWidget = undefined;
add_animation_button.init(@src(), .{}, .{
@@ -698,7 +697,7 @@ pub fn drawAnimations(self: *Sprites) !void {
controls_box.deinit();
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
// Make sure to update the prev anim count!
defer self.prev_anim_count = file.animations.len;
@@ -732,17 +731,17 @@ pub fn drawAnimations(self: *Sprites) !void {
defer {
if (file.editor.animations_scroll_info.viewport.w < file.editor.animations_scroll_info.virtual_size.w) {
if (file.editor.animations_scroll_info.offset(.horizontal) < file.editor.animations_scroll_info.scrollMax(.horizontal)) {
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{});
}
if (file.editor.animations_scroll_info.offset(.horizontal) > 0.0) {
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{});
}
}
}
const vertical_scroll = file.editor.animations_scroll_info.offset(.vertical);
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
+ var tree = pixi_mod.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
.expand = .horizontal,
.background = false,
});
@@ -769,8 +768,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 runtime.allocator().alloc(pixi_mod.internal.Animation, sources.len);
+ defer runtime.allocator().free(moved);
for (sources, 0..) |s, i| {
moved[i] = file.animations.get(s);
}
@@ -781,11 +780,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 = pixi_mod.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
const target = @min(target_raw, file.animations.len);
for (moved, 0..) |anim, i| {
- file.animations.insert(fizzy.app.allocator, target + i, anim) catch {
+ file.animations.insert(runtime.allocator(), target + i, anim) catch {
dvui.log.err("Failed to insert animation", .{});
};
}
@@ -796,7 +795,7 @@ pub fn drawAnimations(self: *Sprites) !void {
file.editor.selected_animation_indices.clearRetainingCapacity();
for (0..moved.len) |i| {
- file.editor.selected_animation_indices.append(fizzy.app.allocator, target + i) catch {
+ file.editor.selected_animation_indices.append(runtime.allocator(), target + i) catch {
dvui.log.err("Failed to update animation selection", .{});
};
}
@@ -829,7 +828,7 @@ pub fn drawAnimations(self: *Sprites) !void {
const selected = if (self.edit_anim_id) |id| id == anim_id else (is_primary_row or in_multi);
var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (runtime.state().colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(@intCast(anim_id));
}
@@ -994,13 +993,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 runtime.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());
+ runtime.allocator().free(file.animations.items(.name)[anim_index]);
+ file.animations.items(.name)[anim_index] = try runtime.allocator().dupe(u8, te.getText());
}
if (te.enter_pressed) {
file.selected_animation_index = anim_index;
@@ -1050,10 +1049,10 @@ pub fn drawAnimations(self: *Sprites) !void {
const anim_si = file.editor.animations_scroll_info;
const anim_v_max = anim_si.scrollMax(.vertical);
if (vertical_scroll > scroll_list_shadow_deadzone_ns)
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
if (anim_v_max > scroll_list_shadow_deadzone_ns and vertical_scroll < anim_v_max - scroll_list_shadow_deadzone_ns)
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
}
}
@@ -1063,7 +1062,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
});
defer box.deinit();
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
const index = if (file.selected_animation_index) |i| i else 0;
var animation = file.animations.get(index);
@@ -1109,8 +1108,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 runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames);
+ std.mem.sort(pixi_mod.Animation.Frame, animation.frames, {}, FrameSort.asc);
if (!animation.eqlFrames(prev_order)) {
file.history.append(.{
@@ -1124,7 +1123,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ runtime.allocator().free(prev_order);
}
}
}
@@ -1169,8 +1168,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 runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames);
+ std.mem.sort(pixi_mod.Animation.Frame, animation.frames, {}, FrameSort.desc);
if (!animation.eqlFrames(prev_order)) {
file.history.append(.{
@@ -1184,7 +1183,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ runtime.allocator().free(prev_order);
}
}
}
@@ -1232,7 +1231,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
if (add_sprite_button.clicked()) {
if (file.editor.selected_sprites.count() > 0) {
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- var frames = std.array_list.Managed(fizzy.Animation.Frame).init(dvui.currentWindow().arena());
+ var frames = std.array_list.Managed(pixi_mod.Animation.Frame).init(dvui.currentWindow().arena());
while (iter.next()) |sprite_index| {
frames.append(.{
.sprite_index = sprite_index,
@@ -1243,9 +1242,9 @@ pub fn drawFrameControls(_: *Sprites) !void {
};
}
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
+ const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames);
- animation.appendFrames(fizzy.app.allocator, frames.items) catch {
+ animation.appendFrames(runtime.allocator(), frames.items) catch {
dvui.log.err("Failed to append frames", .{});
};
@@ -1261,7 +1260,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ runtime.allocator().free(prev_order);
}
}
}
@@ -1320,12 +1319,12 @@ pub fn drawFrameControls(_: *Sprites) !void {
if (duplicate_animation_button.clicked()) {
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
+ const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames);
while (iter.next()) |sprite_index| {
for (animation.frames) |frame| {
if (frame.sprite_index == sprite_index) {
- try animation.appendFrame(fizzy.app.allocator, .{
+ try animation.appendFrame(runtime.allocator(), .{
.sprite_index = frame.sprite_index,
.ms = frame.ms,
});
@@ -1346,7 +1345,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.selected_animation_frame_index = 0;
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ runtime.allocator().free(prev_order);
}
}
}
@@ -1392,13 +1391,13 @@ pub fn drawFrameControls(_: *Sprites) !void {
if (delete_animation_button.clicked()) {
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
+ const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames);
while (iter.next()) |sprite_index| {
var i: usize = animation.frames.len;
while (i > 0) : (i -= 1) {
if (animation.frames[i - 1].sprite_index == sprite_index) {
- animation.removeFrame(fizzy.app.allocator, i - 1);
+ animation.removeFrame(runtime.allocator(), i - 1);
break;
}
}
@@ -1416,7 +1415,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.selected_animation_frame_index = 0;
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ runtime.allocator().free(prev_order);
}
}
}
@@ -1424,7 +1423,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
}
pub fn drawFrames(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
var anim = dvui.animate(@src(), .{ .kind = .horizontal, .duration = 450_000, .easing = dvui.easing.outBack }, .{});
defer anim.deinit();
@@ -1482,7 +1481,7 @@ pub fn drawFrames(self: *Sprites) !void {
defer self.prev_sprite_count = animation.frames.len;
defer self.prev_anim_id = animation.id;
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
+ var tree = pixi_mod.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
.expand = .horizontal,
.background = false,
});
@@ -1495,7 +1494,7 @@ pub fn drawFrames(self: *Sprites) !void {
if (removed_frame_indices_len > 0) {
const sources = removed_frame_indices_buf[0..removed_frame_indices_len];
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
+ const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames);
defer file.animations.set(animation_index, animation);
const primary_before = file.selected_animation_frame_index;
@@ -1509,14 +1508,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 runtime.allocator().alloc(pixi_mod.Animation.Frame, sources.len);
+ defer runtime.allocator().free(moved);
for (sources, 0..) |s, i| {
moved[i] = animation.frames[s];
}
- var remaining = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, animation.frames.len - sources.len);
- defer fizzy.app.allocator.free(remaining);
+ var remaining = try runtime.allocator().alloc(pixi_mod.Animation.Frame, animation.frames.len - sources.len);
+ defer runtime.allocator().free(remaining);
{
var ri: usize = 0;
var wi: usize = 0;
@@ -1535,7 +1534,7 @@ pub fn drawFrames(self: *Sprites) !void {
}
}
- const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
+ const target_raw = pixi_mod.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
const target = @min(target_raw, remaining.len);
var wi: usize = 0;
@@ -1558,7 +1557,7 @@ pub fn drawFrames(self: *Sprites) !void {
file.editor.selected_frame_indices.clearRetainingCapacity();
for (0..moved.len) |i| {
- file.editor.selected_frame_indices.append(fizzy.app.allocator, target + i) catch {
+ file.editor.selected_frame_indices.append(runtime.allocator(), target + i) catch {
dvui.log.err("Failed to update frame selection", .{});
};
}
@@ -1576,7 +1575,7 @@ pub fn drawFrames(self: *Sprites) !void {
dvui.log.err("Failed to append history", .{});
};
} else {
- fizzy.app.allocator.free(prev_order);
+ runtime.allocator().free(prev_order);
}
self.sprite_insert_before_index = null;
@@ -1600,7 +1599,7 @@ pub fn drawFrames(self: *Sprites) !void {
for (animation.frames, 0..) |*frame, frame_index| {
var anim_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (runtime.state().colors.file_tree_palette) |*palette| {
anim_color = palette.getDVUIColor(@intCast(animation.id));
}
@@ -1705,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,
@@ -1712,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,
});
@@ -1732,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 },
});
@@ -1782,10 +1782,10 @@ pub fn drawFrames(self: *Sprites) !void {
const frames_si = file.editor.sprites_scroll_info;
const frames_v_max = frames_si.scrollMax(.vertical);
if (vertical_scroll > scroll_list_shadow_deadzone_ns)
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
if (frames_v_max > scroll_list_shadow_deadzone_ns and vertical_scroll < frames_v_max - scroll_list_shadow_deadzone_ns)
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
}
}
@@ -1799,21 +1799,21 @@ const FrameRowHit = struct {
hbox_tl: dvui.Point.Physical,
};
-fn frameGestureMatches(file: *const fizzy.Internal.File, anim_id: u64) bool {
+fn frameGestureMatches(file: *const pixi_mod.internal.File, anim_id: u64) bool {
return frame_row_gesture != null and frame_row_gesture.?.file_id == file.id and frame_row_gesture.?.anim_id == anim_id;
}
-fn frameTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void {
+fn frameTreeClearGestureKeysOnly(_: *const pixi_mod.internal.File) void {
frame_row_gesture = null;
}
-fn frameTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void {
+fn frameTreeResetRowPointerGesture(_: *const pixi_mod.internal.File) void {
dvui.dragEnd();
frame_row_gesture = null;
}
/// After `selected_frame_indices` changes, make tile selection match exactly those frames' sprites.
-fn syncSpritesFromCurrentFrameSelection(file: *fizzy.Internal.File, anim_index: usize) void {
+fn syncSpritesFromCurrentFrameSelection(file: *pixi_mod.internal.File, anim_index: usize) void {
const frames = file.animations.get(anim_index).frames;
file.clearSelectedSprites();
for (file.editor.selected_frame_indices.items) |fi| {
@@ -1825,7 +1825,7 @@ fn syncSpritesFromCurrentFrameSelection(file: *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: *pixi_mod.internal.File, anim_index: usize, anim_id: u64) void {
const frames = file.animations.get(anim_index).frames;
if (file.editor.selected_frame_indices_for_animation_id != anim_id) {
@@ -1848,7 +1848,7 @@ fn ensureFrameSelection(file: *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(runtime.allocator(), i) catch return;
}
}
std.sort.pdq(usize, file.editor.selected_frame_indices.items, {}, std.sort.asc(usize));
@@ -1879,11 +1879,11 @@ fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id:
}
fn applyFrameClick(
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
anim_index: usize,
anim_id: u64,
clicked: usize,
- mode: fizzy.dvui.TreeSelection.ClickMode,
+ mode: pixi_mod.core.dvui.TreeSelection.ClickMode,
) !bool {
ensureFrameSelection(file, anim_index, anim_id);
@@ -1904,7 +1904,7 @@ fn applyFrameClick(
}
var out: std.ArrayList(usize) = .empty;
- defer out.deinit(fizzy.app.allocator);
+ defer out.deinit(runtime.allocator());
// When anchor is null, shift-extend uses `primary_opt` as the range endpoint. During playback
// that index is the animated playhead, not the editor's last stable focus — use a selection
@@ -1916,8 +1916,8 @@ fn applyFrameClick(
break :blk file.editor.selected_frame_indices.items[0];
} else file.selected_animation_frame_index;
- const res = try fizzy.dvui.TreeSelection.applyClickUsize(
- fizzy.app.allocator,
+ const res = try pixi_mod.core.dvui.TreeSelection.applyClickUsize(
+ runtime.allocator(),
prev_multi,
primary_for_tree,
file.editor.frame_selection_anchor,
@@ -1928,7 +1928,7 @@ fn applyFrameClick(
);
file.editor.selected_frame_indices.clearRetainingCapacity();
- try file.editor.selected_frame_indices.appendSlice(fizzy.app.allocator, out.items);
+ try file.editor.selected_frame_indices.appendSlice(runtime.allocator(), out.items);
file.editor.selected_frame_indices_for_animation_id = anim_id;
file.editor.frame_selection_anchor = res.anchor;
if (res.primary) |p| file.selected_animation_frame_index = p;
@@ -1936,16 +1936,16 @@ fn applyFrameClick(
return false;
}
-fn narrowFrameSelectionTo(file: *fizzy.Internal.File, anim_index: usize, anim_id: u64, clicked: usize) void {
+fn narrowFrameSelectionTo(file: *pixi_mod.internal.File, anim_index: usize, anim_id: u64, clicked: usize) void {
file.editor.selected_frame_indices.clearRetainingCapacity();
- file.editor.selected_frame_indices.append(fizzy.app.allocator, clicked) catch return;
+ file.editor.selected_frame_indices.append(runtime.allocator(), clicked) catch return;
file.editor.selected_frame_indices_for_animation_id = anim_id;
file.editor.frame_selection_anchor = clicked;
file.selected_animation_frame_index = clicked;
syncSpritesFromCurrentFrameSelection(file, anim_index);
}
-fn buildFrameMultiDragIds(file: *const fizzy.Internal.File, animation_index: usize, hits: []const FrameRowHit, out: []usize) []usize {
+fn buildFrameMultiDragIds(file: *const pixi_mod.internal.File, animation_index: usize, hits: []const FrameRowHit, out: []usize) []usize {
const frames = file.animations.get(animation_index).frames;
var len: usize = 0;
const playhead = file.selected_animation_frame_index;
@@ -1982,8 +1982,8 @@ fn buildFrameMultiDragIds(file: *const fizzy.Internal.File, animation_index: usi
}
fn processFrameTreePointerEvents(
- tree: *fizzy.dvui.TreeWidget,
- file: *fizzy.Internal.File,
+ tree: *pixi_mod.core.dvui.TreeWidget,
+ file: *pixi_mod.internal.File,
anim_id: u64,
animation_index: usize,
hits: []const FrameRowHit,
@@ -2013,7 +2013,7 @@ fn processFrameTreePointerEvents(
frameTreeClearGestureKeysOnly(file);
dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) });
- const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod);
+ const mode = pixi_mod.core.dvui.TreeSelection.clickModeFromMod(me.mod);
const narrow_on_release = applyFrameClick(file, animation_index, anim_id, h.frame_index, mode) catch blk: {
dvui.log.err("Failed to apply frame click", .{});
break :blk false;
@@ -2143,15 +2143,15 @@ const AnimationRowHit = struct {
hbox_tl: dvui.Point.Physical,
};
-fn animationGestureMatches(file: *const fizzy.Internal.File) bool {
+fn animationGestureMatches(file: *const pixi_mod.internal.File) bool {
return animation_row_gesture != null and animation_row_gesture.?.file_id == file.id;
}
-fn animationTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void {
+fn animationTreeClearGestureKeysOnly(_: *const pixi_mod.internal.File) void {
animation_row_gesture = null;
}
-fn animationTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void {
+fn animationTreeResetRowPointerGesture(_: *const pixi_mod.internal.File) void {
dvui.dragEnd();
animation_row_gesture = null;
}
@@ -2174,7 +2174,7 @@ fn animationPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Re
return true;
}
-fn animationTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
+fn animationTreePointerInTreeSurface(tree: *pixi_mod.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
if (floating_win != dvui.subwindowCurrentId()) return false;
const tr = tree.data().borderRectScale().r;
if (!tr.contains(p)) return false;
@@ -2182,12 +2182,12 @@ fn animationTreePointerInTreeSurface(tree: *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: *pixi_mod.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
if (floating_win != dvui.subwindowCurrentId()) return false;
return tree.data().borderRectScale().r.contains(p);
}
-fn animationTreeMotionAllowsReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event) bool {
+fn animationTreeMotionAllowsReorder(tree: *pixi_mod.core.dvui.TreeWidget, e: *dvui.Event) bool {
if (e.target_widgetId) |fwid| {
if (fwid == tree.data().id) return true;
}
@@ -2199,7 +2199,7 @@ fn animationTreeMotionAllowsReorder(tree: *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: *pixi_mod.internal.File, anim_index: usize) void {
const anim = file.animations.get(anim_index);
if (anim.frames.len > 0) {
if (file.selected_animation_frame_index >= anim.frames.len) {
@@ -2210,7 +2210,7 @@ fn syncAnimationSelectionFrames(file: *fizzy.Internal.File, anim_index: usize) v
}
}
-fn animationIndexInMulti(file: *const fizzy.Internal.File, anim_index: usize) bool {
+fn animationIndexInMulti(file: *const pixi_mod.internal.File, anim_index: usize) bool {
for (file.editor.selected_animation_indices.items) |i| {
if (i == anim_index) return true;
}
@@ -2220,7 +2220,7 @@ fn animationIndexInMulti(file: *const 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: *pixi_mod.internal.File) void {
const count = file.animations.len;
if (count == 0) {
file.editor.selected_animation_indices.clearRetainingCapacity();
@@ -2251,7 +2251,7 @@ fn ensureAnimationSelection(file: *fizzy.Internal.File) void {
}
}
if (!found) {
- file.editor.selected_animation_indices.append(fizzy.app.allocator, p) catch return;
+ file.editor.selected_animation_indices.append(runtime.allocator(), p) catch return;
std.sort.pdq(usize, file.editor.selected_animation_indices.items, {}, std.sort.asc(usize));
}
}
@@ -2263,7 +2263,7 @@ fn ensureAnimationSelection(file: *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: *pixi_mod.internal.File, clicked: usize, mode: pixi_mod.core.dvui.TreeSelection.ClickMode) !bool {
const prev_multi = file.editor.selected_animation_indices.items;
const was_in_multi = animationIndexInMulti(file, clicked);
const was_multi = prev_multi.len > 1;
@@ -2271,20 +2271,20 @@ fn applyAnimationClick(file: *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(runtime.allocator());
if (defer_narrow) {
- try out.appendSlice(fizzy.app.allocator, prev_multi);
+ try out.appendSlice(runtime.allocator(), prev_multi);
std.sort.pdq(usize, out.items, {}, std.sort.asc(usize));
file.editor.selected_animation_indices.clearRetainingCapacity();
- try file.editor.selected_animation_indices.appendSlice(fizzy.app.allocator, out.items);
+ try file.editor.selected_animation_indices.appendSlice(runtime.allocator(), out.items);
file.selected_animation_index = clicked;
syncAnimationSelectionFrames(file, clicked);
return true;
}
- const res = try fizzy.dvui.TreeSelection.applyClickUsize(
- fizzy.app.allocator,
+ const res = try pixi_mod.core.dvui.TreeSelection.applyClickUsize(
+ runtime.allocator(),
prev_multi,
file.selected_animation_index,
file.editor.animation_selection_anchor,
@@ -2295,16 +2295,16 @@ fn applyAnimationClick(file: *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(runtime.allocator(), out.items);
file.editor.animation_selection_anchor = res.anchor;
file.selected_animation_index = res.primary;
if (res.primary) |p| syncAnimationSelectionFrames(file, p);
return false;
}
-fn narrowAnimationSelectionTo(file: *fizzy.Internal.File, clicked: usize) void {
+fn narrowAnimationSelectionTo(file: *pixi_mod.internal.File, clicked: usize) void {
file.editor.selected_animation_indices.clearRetainingCapacity();
- file.editor.selected_animation_indices.append(fizzy.app.allocator, clicked) catch return;
+ file.editor.selected_animation_indices.append(runtime.allocator(), clicked) catch return;
file.editor.animation_selection_anchor = clicked;
file.selected_animation_index = clicked;
syncAnimationSelectionFrames(file, clicked);
@@ -2312,7 +2312,7 @@ fn narrowAnimationSelectionTo(file: *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 pixi_mod.internal.File, hits: []const AnimationRowHit, out: []usize) []usize {
var len: usize = 0;
const primary = file.selected_animation_index;
if (primary) |p| {
@@ -2341,7 +2341,7 @@ fn buildAnimationMultiDragIds(file: *const 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: *pixi_mod.core.dvui.TreeWidget, file: *pixi_mod.internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void {
if (!tree.init_options.enable_reordering) return;
for (dvui.events()) |*e| {
@@ -2367,7 +2367,7 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *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 = pixi_mod.core.dvui.TreeSelection.clickModeFromMod(me.mod);
const narrow_on_release = applyAnimationClick(file, h.anim_index, mode) catch blk: {
dvui.log.err("Failed to apply animation click", .{});
break :blk false;
@@ -2489,11 +2489,11 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget,
}
const FrameSort = struct {
- pub fn asc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool {
+ pub fn asc(_: void, a: pixi_mod.Animation.Frame, b: pixi_mod.Animation.Frame) bool {
return a.sprite_index < b.sprite_index;
}
- pub fn desc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool {
+ pub fn desc(_: void, a: pixi_mod.Animation.Frame, b: pixi_mod.Animation.Frame) bool {
return a.sprite_index > b.sprite_index;
}
};
diff --git a/src/editor/explorer/tools.zig b/src/plugins/pixi/src/explorer/tools.zig
similarity index 90%
rename from src/editor/explorer/tools.zig
rename to src/plugins/pixi/src/explorer/tools.zig
index 2ec7f3b8..72818f6e 100644
--- a/src/editor/explorer/tools.zig
+++ b/src/plugins/pixi/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 pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
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 (runtime.state().docs.activeFile(runtime.state().host)) |file| file.layers.len else 0;
defer prev_layer_count = layer_count;
- var paned = fizzy.dvui.paned(@src(), .{
+ var paned = pixi_mod.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.editor.explorer.layers_ratio = paned.split_ratio.*;
+ runtime.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.editor.explorer.pinned_palettes) {
+ if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !runtime.state().pinned_palettes) {
if (dvui.firstFrame(paned.data().id) and layer_count == 0)
paned.split_ratio.* = 0.0;
@@ -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.editor.explorer.layers_ratio = paned.split_ratio.*;
+ //runtime.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.editor.explorer.layers_ratio;
+ paned.split_ratio.* = runtime.state().layers_ratio;
- fizzy.editor.explorer.layers_ratio = paned.split_ratio.*;
+ runtime.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(pixi_mod.Tools.Tool).len) |i| {
+ const tool: pixi_mod.Tools.Tool = @enumFromInt(i);
const id_extra = i;
- const selected = fizzy.editor.tools.current == tool;
+ const selected = runtime.state().tools.current == tool;
var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (runtime.state().colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(i);
}
- const selection_sprite = switch (fizzy.editor.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],
+ const selection_sprite = switch (runtime.state().tools.selection_mode) {
+ .pixel => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_default],
+ .box => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_default],
+ .color => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_default],
};
const sprite = switch (tool) {
- .pointer => 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 => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.cursor_default],
+ .pencil => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pencil_default],
+ .eraser => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.eraser_default],
+ .bucket => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.bucket_default],
.selection => selection_sprite,
};
var button: dvui.ButtonWidget = undefined;
@@ -204,13 +205,13 @@ pub fn drawTools() !void {
});
defer button.deinit();
- fizzy.editor.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {};
+ runtime.state().tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {};
if (button.hovered()) {
button.data().options.color_border = color;
}
- const size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 };
+ const size: dvui.Size = dvui.imageSize(runtime.state().host.uiAtlas().source) catch .{ .w = 0, .h = 0 };
const uv = dvui.Rect{
.x = @as(f32, @floatFromInt(sprite.source[0])) / size.w,
@@ -232,7 +233,7 @@ pub fn drawTools() !void {
rs.r.w = width;
rs.r.h = height;
- dvui.renderImage(fizzy.editor.atlas.source, rs, .{
+ dvui.renderImage(runtime.state().host.uiAtlas().source, rs, .{
.uv = uv,
.fade = 0.0,
}) catch {
@@ -240,7 +241,7 @@ pub fn drawTools() !void {
};
if (button.clicked()) {
- fizzy.editor.tools.set(tool);
+ runtime.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 (runtime.state().docs.activeFile(runtime.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 (runtime.state().docs.activeFile(runtime.state().host)) |file| {
layer_rename_hit_te_id = null;
layer_rename_hit_rect = null;
file.editor.layer_drag_preview_removed = null;
@@ -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 = pixi_mod.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 runtime.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 runtime.allocator().alloc(pixi_mod.internal.Layer, sources.len);
+ defer runtime.allocator().free(moved);
for (sources, 0..) |s, i| {
moved[i] = file.layers.get(s);
}
@@ -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 = pixi_mod.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
const target = @min(target_raw, file.layers.len);
for (moved, 0..) |layer, i| {
- file.layers.insert(fizzy.app.allocator, target + i, layer) catch {
+ file.layers.insert(runtime.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(runtime.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);
+ runtime.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.editor.colors.file_tree_palette) |*palette| {
+ if (runtime.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.editor.explorer.tools.layersHovered()) {
+ } else if (!runtime.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 runtime.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());
+ runtime.allocator().free(file.layers.items(.name)[layer_index]);
+ file.layers.items(.name)[layer_index] = try runtime.allocator().dupe(u8, te.getText());
}
if (te.enter_pressed) {
file.selected_layer_index = layer_index;
@@ -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, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
if (file.editor.layers_scroll_info.virtual_size.h > file.editor.layers_scroll_info.viewport.h + 1 and vertical_scroll < file.editor.layers_scroll_info.scrollMax(.vertical))
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
}
- if (fizzy.dvui.hovered(vbox.data())) {
+ if (pixi_mod.core.dvui.hovered(vbox.data())) {
const mp = dvui.currentWindow().mouse_pt;
if (tools.layers_scroll_viewport_rect) |vr| {
if (!vr.contains(mp)) return null;
@@ -945,8 +946,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 = runtime.state().colors.primary[0], .g = runtime.state().colors.primary[1], .b = runtime.state().colors.primary[2], .a = runtime.state().colors.primary[3] };
+ const secondary: dvui.Color = .{ .r = runtime.state().colors.secondary[0], .g = runtime.state().colors.secondary[1], .b = runtime.state().colors.secondary[2], .a = runtime.state().colors.secondary[3] };
const button_opts: dvui.Options = .{
.expand = .both,
@@ -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.editor.colors.primary, 0);
+ try drawColorPicker(primary_button.data().rectScale().r, &runtime.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.editor.colors.secondary, 1);
+ try drawColorPicker(secondary_button.data().rectScale().r, &runtime.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.editor.colors.primary, &fizzy.editor.colors.secondary);
+ std.mem.swap([4]u8, &runtime.state().colors.primary, &runtime.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.editor.explorer.pinned_palettes) .highlight else .control,
+ .style = if (runtime.state().pinned_palettes) .highlight else .control,
})) {
- fizzy.editor.explorer.pinned_palettes = !fizzy.editor.explorer.pinned_palettes;
+ runtime.state().pinned_palettes = !runtime.state().pinned_palettes;
}
}
@@ -1103,7 +1104,7 @@ pub fn drawPalettes() !void {
.gravity_x = 1.0,
});
- if (fizzy.editor.colors.palette) |*palette| {
+ if (runtime.state().colors.palette) |*palette| {
dvui.label(@src(), "{s}", .{palette.name}, .{ .margin = .all(0), .padding = .all(0) });
} else {
dvui.label(@src(), "Palette Search", .{}, .{ .margin = .all(0), .padding = .all(0) });
@@ -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.editor.colors.palette = fizzy.Internal.Palette.loadFromBytes(fizzy.app.allocator, entry.name, data) catch |err| {
+ runtime.state().colors.palette = pixi_mod.internal.Palette.loadFromBytes(runtime.allocator(), entry.name, data) catch |err| {
dvui.log.err("Failed to load palette: {s}", .{@errorName(err)});
return error.FailedToLoadPalette;
};
@@ -1157,12 +1158,12 @@ pub fn drawPalettes() !void {
}
{
- if (fizzy.editor.colors.palette) |*palette| {
+ if (runtime.state().colors.palette) |*palette| {
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 = runtime.state().host.explorerRect().w - 20 * dvui.currentWindow().natural_scale,
+ .h = runtime.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.editor.colors.primary, &color);
+ @memcpy(&runtime.state().colors.primary, &color);
} else if (mouse_evt.button == .right) {
- @memcpy(&fizzy.editor.colors.secondary, &color);
+ @memcpy(&runtime.state().colors.secondary, &color);
}
},
else => {},
@@ -1267,7 +1268,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 = runtime.state().host.paletteFolder() orelse return;
+ var dir_opt = std.Io.Dir.cwd().openDir(io, palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null;
if (dir_opt) |*dir| {
defer dir.close(io);
var iter = dir.iterate();
@@ -1277,12 +1279,12 @@ 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.editor.colors.palette) |*palette|
+ if (runtime.state().colors.palette) |*palette|
palette.deinit();
- fizzy.editor.colors.palette = fizzy.Internal.Palette.loadFromFile(fizzy.app.allocator, abs_path) catch |err| {
+ runtime.state().colors.palette = pixi_mod.internal.Palette.loadFromFile(runtime.allocator(), abs_path) catch |err| {
dvui.log.err("Failed to load palette: {s}", .{@errorName(err)});
return error.FailedToLoadPalette;
};
@@ -1316,12 +1318,12 @@ fn pointerReleaseInRectWithoutSelectionModifier(r: dvui.Rect.Physical) bool {
return false;
}
-fn layerGestureMatches(file: *const fizzy.Internal.File) bool {
+fn layerGestureMatches(file: *const pixi_mod.internal.File) bool {
return layer_row_gesture != null and layer_row_gesture.?.file_id == file.id;
}
/// True if `layer_index` is present in the multi-selection set (the primary index is always implicitly selected).
-fn layerIndexInMulti(file: *const fizzy.Internal.File, layer_index: usize) bool {
+fn layerIndexInMulti(file: *const pixi_mod.internal.File, layer_index: usize) bool {
for (file.editor.selected_layer_indices.items) |i| {
if (i == layer_index) return true;
}
@@ -1330,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: *pixi_mod.internal.File) void {
var sel = &file.editor.selected_layer_indices;
// Drop out-of-range entries.
@@ -1357,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(runtime.allocator(), file.selected_layer_index) catch return;
std.sort.pdq(usize, sel.items, {}, std.sort.asc(usize));
}
}
@@ -1372,9 +1374,9 @@ const LayerClickApplied = struct {
};
fn applyLayerClick(
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
clicked: usize,
- mode: fizzy.dvui.TreeSelection.ClickMode,
+ mode: pixi_mod.core.dvui.TreeSelection.ClickMode,
) LayerClickApplied {
const count_before = file.editor.selected_layer_indices.items.len;
@@ -1385,10 +1387,10 @@ fn applyLayerClick(
}
var tmp: std.ArrayList(usize) = .empty;
- defer tmp.deinit(fizzy.app.allocator);
+ defer tmp.deinit(runtime.allocator());
- const res = fizzy.dvui.TreeSelection.applyClickUsize(
- fizzy.app.allocator,
+ const res = pixi_mod.core.dvui.TreeSelection.applyClickUsize(
+ runtime.allocator(),
file.editor.selected_layer_indices.items,
file.selected_layer_index,
file.editor.layer_selection_anchor,
@@ -1399,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(runtime.allocator(), tmp.items) catch {};
const new_primary = res.primary orelse clicked;
file.selected_layer_index = new_primary;
@@ -1410,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: *pixi_mod.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(runtime.allocator(), clicked) catch {};
file.selected_layer_index = clicked;
file.editor.layer_selection_anchor = clicked;
}
@@ -1422,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 pixi_mod.internal.File,
hits: []const LayerRowHit,
out: []usize,
) usize {
@@ -1442,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 pixi_mod.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 pixi_mod.internal.File) void {
dvui.dragEnd();
layer_row_gesture = null;
}
@@ -1474,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: *pixi_mod.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
if (floating_win != dvui.subwindowCurrentId()) return false;
const tr = tree.data().borderRectScale().r;
if (!tr.contains(p)) return false;
@@ -1482,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: *pixi_mod.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
if (floating_win != dvui.subwindowCurrentId()) return false;
return tree.data().borderRectScale().r.contains(p);
}
/// While another widget holds capture, `target_widgetId` may not be the tree. Allow starting a reorder drag
/// when the pointer is over the tree border (scroll clip can disagree with visible row geometry).
-fn layerTreeMotionAllowsLayerReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event) bool {
+fn layerTreeMotionAllowsLayerReorder(tree: *pixi_mod.core.dvui.TreeWidget, e: *dvui.Event) bool {
if (e.target_widgetId) |fwid| {
if (fwid == tree.data().id) return true;
}
@@ -1503,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: *pixi_mod.core.dvui.TreeWidget, file: *pixi_mod.internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void {
if (!tree.init_options.enable_reordering) return;
for (dvui.events()) |*e| {
@@ -1529,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 = pixi_mod.core.dvui.TreeSelection.clickModeFromMod(me.mod);
const applied = applyLayerClick(file, h.layer_index, mode);
layer_row_gesture = .{
diff --git a/src/plugins/pixi/src/infobar_status.zig b/src/plugins/pixi/src/infobar_status.zig
new file mode 100644
index 00000000..32e66474
--- /dev/null
+++ b/src/plugins/pixi/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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const State = pixi_mod.State;
+const Internal = pixi_mod.internal;
+const DocHandle = pixi_mod.sdk.DocHandle;
+const DimensionsLabel = @import("dialogs/dimensions_label.zig");
+
+fn docFile(st: *State, doc: DocHandle) ?*Internal.File {
+ 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/internal/Animation.zig b/src/plugins/pixi/src/internal/Animation.zig
similarity index 100%
rename from src/internal/Animation.zig
rename to src/plugins/pixi/src/internal/Animation.zig
diff --git a/src/internal/Atlas.zig b/src/plugins/pixi/src/internal/Atlas.zig
similarity index 76%
rename from src/internal/Atlas.zig
rename to src/plugins/pixi/src/internal/Atlas.zig
index 676e9f1f..3e59872c 100644
--- a/src/internal/Atlas.zig
+++ b/src/plugins/pixi/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 pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
const alpha_checkerboard_count: u32 = 8;
/// The packed atlas texture
source: dvui.ImageSource,
-canvas: fizzy.dvui.CanvasWidget = .{},
+canvas: pixi_mod.core.dvui.CanvasWidget = .{},
/// Checkerboard tile for the project-tab atlas preview (not tied to open files).
checkerboard_tile: ?dvui.Texture = null,
@@ -22,11 +23,11 @@ data: ExternalAtlas,
pub fn initCheckerboardTile(atlas: *Atlas) void {
deinitCheckerboardTile(atlas);
- atlas.checkerboard_tile = fizzy.image.checkerboardTile(
+ atlas.checkerboard_tile = pixi_mod.image.checkerboardTile(
alpha_checkerboard_count,
alpha_checkerboard_count,
- fizzy.editor.settings.checker_color_even,
- fizzy.editor.settings.checker_color_odd,
+ runtime.state().settings.checker_color_even,
+ runtime.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.editor.arena.allocator();
+ const allocator = runtime.state().host.arena();
switch (selector) {
.source => {
const ext = std.fs.path.extension(path);
var out = std.Io.Writer.Allocating.init(allocator);
errdefer out.deinit();
if (std.mem.eql(u8, ext, ".png")) {
- try fizzy.image.writePngToWriter(atlas.source, &out.writer, 72);
+ try pixi_mod.image.writePngToWriter(atlas.source, &out.writer, 72);
} else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
- try fizzy.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72);
+ try pixi_mod.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72);
} else {
std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{});
return error.InvalidExtension;
}
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))) {
@@ -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("../web_file_io.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.editor.arena.allocator(), "{s}", .{path}, 0) catch unreachable;
+ const write_path = std.fmt.allocPrintSentinel(runtime.state().host.arena(), "{s}", .{path}, 0) catch unreachable;
if (std.mem.eql(u8, ext, ".png")) {
- try fizzy.image.writeToPng(atlas.source, write_path);
+ try pixi_mod.image.writeToPng(atlas.source, write_path);
} else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
- try fizzy.image.writeToJpg(atlas.source, write_path);
+ try pixi_mod.image.writeToJpg(atlas.source, write_path);
} else {
std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{});
return error.InvalidExtension;
@@ -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.editor.arena.allocator(), atlas.data, options);
+ const output = try std.json.Stringify.valueAlloc(runtime.state().host.arena(), atlas.data, options);
std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = output }) catch return error.CouldNotWriteAtlasData;
},
diff --git a/src/internal/Buffers.zig b/src/plugins/pixi/src/internal/Buffers.zig
similarity index 76%
rename from src/internal/Buffers.zig
rename to src/plugins/pixi/src/internal/Buffers.zig
index 88bb3c4f..48b85a61 100644
--- a/src/internal/Buffers.zig
+++ b/src/plugins/pixi/src/internal/Buffers.zig
@@ -1,7 +1,8 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
const History = @import("History.zig");
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
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: pixi_mod.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 (pixi_mod.perf.record) {
+ pixi_mod.perf.stroke_append_calls += 1;
+ if (!ptr.found_existing) pixi_mod.perf.stroke_append_new_keys += 1;
}
if (!ptr.found_existing)
ptr.value_ptr.* = value;
@@ -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 (pixi_mod.perf.record) {
+ pixi_mod.perf.stroke_append_calls += 1;
+ if (!gop.found_existing) pixi_mod.perf.stroke_append_new_keys += 1;
}
if (!gop.found_existing)
gop.value_ptr.* = value;
@@ -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 (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0;
const n = stroke.pixels.count();
// Exact-size allocations; transform accept pre-reserves the hash map to avoid rehash during fills.
- var indices = 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 = runtime.allocator().alloc(usize, n) catch return error.MemoryAllocationFailed;
+ errdefer runtime.allocator().free(indices);
+ var values = runtime.allocator().alloc([4]u8, n) catch return error.MemoryAllocationFailed;
+ errdefer runtime.allocator().free(values);
var it = stroke.pixels.iterator();
@@ -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 (pixi_mod.perf.record) {
+ pixi_mod.perf.stroke_to_change_ns +%= @intCast(pixi_mod.perf.nanoTimestamp() - t0);
+ pixi_mod.perf.stroke_to_change_calls += 1;
+ pixi_mod.perf.stroke_to_change_pixels_out +%= n;
}
return .{ .pixels = .{
diff --git a/src/internal/File.zig b/src/plugins/pixi/src/internal/File.zig
similarity index 86%
rename from src/internal/File.zig
rename to src/plugins/pixi/src/internal/File.zig
index 0747db78..01db172b 100644
--- a/src/internal/File.zig
+++ b/src/plugins/pixi/src/internal/File.zig
@@ -1,15 +1,18 @@
const std = @import("std");
-const fizzy = @import("../fizzy.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 pixi_mod = @import("../../pixi.zig");
+const plugin = @import("../plugin.zig");
+const runtime = @import("../runtime.zig");
const alpha_checkerboard_count: u32 = 8;
@@ -52,14 +55,21 @@ 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,
- canvas: fizzy.dvui.CanvasWidget = .{},
+ /// 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: pixi_mod.core.dvui.CanvasWidget = .{},
layers_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
sprites_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
animations_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
animations_scroll_to_index: ?usize = null,
- transform: ?Editor.Transform = null,
+ transform: ?Transform = null,
playing: bool = false,
saving: bool = false,
@@ -176,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 (`pixi_mod.perf.nanoTimestamp`): save-complete affordance in tab / tree.
save_complete_show_duration: ?i128 = null,
/// Set with `save_complete_show_duration` when the flash arms (`isSaving` → false).
save_complete_show_start: ?i128 = null,
@@ -192,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) !pixi_mod.internal.File {
+ var internal: pixi_mod.internal.File = .{
+ .id = runtime.state().host.allocDocId(),
+ .path = try runtime.allocator().dupe(u8, path),
.columns = options.columns,
.rows = options.rows,
.column_width = options.column_width,
.row_height = options.row_height,
- .history = fizzy.Internal.File.History.init(fizzy.app.allocator),
- .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator),
+ .history = pixi_mod.internal.File.History.init(runtime.allocator()),
+ .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()),
};
// Initialize editor layers and selected sprites
internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount());
+ internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount());
- internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height());
+ internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height());
// Create a layer-sized checkerboard pattern for selection tools
for (0..internal.width() * internal.height()) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
+ const value = pixi_mod.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
internal.editor.checkerboard.setValue(i, value);
}
{
// Create a single layer for the file
- const layer: 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(runtime.allocator(), layer) catch return error.LayerCreateError;
}
// Initialize sprites
for (0..internal.spriteCount()) |_| {
- internal.sprites.append(fizzy.app.allocator, .{
+ internal.sprites.append(runtime.allocator(), .{
.origin = .{ 0.0, 0.0 },
}) catch return error.FileLoadError;
}
@@ -252,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 = pixi_mod.image.checkerboardTile(
want.w,
want.h,
- fizzy.editor.settings.checker_color_even,
- fizzy.editor.settings.checker_color_odd,
+ runtime.state().settings.checker_color_even,
+ runtime.state().settings.checker_color_odd,
);
return file.editor.checkerboard_tile;
}
@@ -284,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 = pixi_mod.perf.nanoTimestamp();
file.editor.save_complete_show_start = now;
file.editor.save_complete_show_duration = now + save_done_flash_duration_ns;
file.editor.save_complete.store(false, .monotonic);
@@ -310,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 = pixi_mod.perf.nanoTimestamp();
const saving = file.isSaving();
const pending = file.editor.save_complete.swap(false, .monotonic);
if (!saving and (pending or file.editor.was_saving) and file.editor.save_complete_show_duration == null) {
@@ -341,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 [`pixi_mod.core.dvui.bubbleSpinner`]'s
/// finish animation (sync → pop → check).
pub fn timeSinceSaveComplete(file: *const File) ?i128 {
const until = file.editor.save_complete_show_duration orelse return null;
const st = file.editor.save_complete_show_start orelse return null;
- const now = fizzy.perf.nanoTimestamp();
+ const now = pixi_mod.perf.nanoTimestamp();
if (now >= until) return null;
return @max(@as(i128, 0), now - st);
}
@@ -378,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) !?pixi_mod.internal.File {
const extension = std.fs.path.extension(path);
if (isFlatImageExtension(extension)) {
return fromBytesFlatImage(path, file_bytes);
@@ -390,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) !?pixi_mod.internal.File {
const extension = std.fs.path.extension(path[0..path.len]);
if (isFlatImageExtension(extension)) {
const file = fromPathFlatImage(path) catch |err| {
@@ -416,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) !?pixi_mod.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) !?pixi_mod.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) !?pixi_mod.internal.File {
if (!isFizzyExtension(std.fs.path.extension(path[0..path.len])))
return error.InvalidExtension;
const null_terminated_path = if (file_bytes == null)
- try fizzy.app.allocator.dupeZ(u8, path)
+ try runtime.allocator().dupeZ(u8, path)
else
"";
- defer if (file_bytes == null) fizzy.app.allocator.free(null_terminated_path);
+ defer if (file_bytes == null) runtime.allocator().free(null_terminated_path);
zip_open: {
const fizzy_file = if (file_bytes) |bytes|
@@ -465,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(pixi_mod.File) = null;
+ try_parse = std.json.parseFromSlice(pixi_mod.File, runtime.allocator(), content, options) catch null;
- var ext: fizzy.File = if (try_parse) |parsed| parsed.value else undefined;
+ var ext: pixi_mod.File = if (try_parse) |parsed| parsed.value else undefined;
if (try_parse == null) {
// If we are here, we have tried to load the file but hit an issue because the old animation format
- if (std.json.parseFromSlice(fizzy.File.FileV3, fizzy.app.allocator, content, options) catch null) |old_file| {
+ if (std.json.parseFromSlice(pixi_mod.File.FileV3, runtime.allocator(), content, options) catch null) |old_file| {
std.log.info("Loading file v3: {s}", .{path});
- const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len);
+ const animations = try runtime.allocator().alloc(pixi_mod.Animation, old_file.value.animations.len);
for (animations, old_file.value.animations) |*animation, old_animation| {
- animation.name = try fizzy.app.allocator.dupe(u8, old_animation.name);
- animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_animation.frames.len);
+ animation.name = try runtime.allocator().dupe(u8, old_animation.name);
+ animation.frames = try runtime.allocator().alloc(Animation.Frame, old_animation.frames.len);
for (animation.frames, old_animation.frames) |*frame, old_frame| {
frame.sprite_index = old_frame;
frame.ms = @intFromFloat(1000 / old_animation.fps);
@@ -494,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(pixi_mod.File.FileV2, runtime.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 runtime.allocator().alloc(pixi_mod.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 runtime.allocator().dupe(u8, old_animation.name);
+ animation.frames = try runtime.allocator().alloc(Animation.Frame, old_animation.frames.len);
for (animation.frames, old_animation.frames) |*frame, old_frame| {
frame.sprite_index = old_frame;
frame.ms = @intFromFloat(1000 / old_animation.fps);
@@ -516,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(pixi_mod.File.FileV1, runtime.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 runtime.allocator().alloc(pixi_mod.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 runtime.allocator().dupe(u8, old_file.value.animations[i].name);
+ animation.frames = try runtime.allocator().alloc(Animation.Frame, old_file.value.animations[i].length);
for (animation.frames, 0..old_file.value.animations[i].length) |*frame, j| {
frame.sprite_index = old_file.value.animations[i].start + j;
frame.ms = @intFromFloat(1000 / old_file.value.animations[i].fps);
@@ -545,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: pixi_mod.internal.File = .{
+ .id = runtime.state().host.allocDocId(),
+ .path = try runtime.allocator().dupe(u8, path),
.columns = ext.columns,
.rows = ext.rows,
.column_width = ext.column_width,
.row_height = ext.row_height,
- .history = fizzy.Internal.File.History.init(fizzy.app.allocator),
- .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator),
+ .history = pixi_mod.internal.File.History.init(runtime.allocator()),
+ .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()),
};
//Initialize editor layers and selected sprites
@@ -562,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(runtime.allocator(), internal.spriteCount());
- internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height());
+ internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height());
// Create a layer-sized checkerboard pattern for selection tools
for (0..internal.width() * internal.height()) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
+ const value = pixi_mod.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
internal.editor.checkerboard.setValue(i, value);
}
var set_layer_index: bool = false;
for (ext.layers, 0..) |l, i| {
- const layer_image_name = std.fmt.allocPrintSentinel(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(runtime.allocator(), "{s}.layer", .{l.name}, 0) catch "Memory Allocation Failed";
+ defer runtime.allocator().free(layer_image_name);
+ const png_image_name = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{l.name}, 0) catch "Memory Allocation Failed";
+ defer runtime.allocator().free(png_image_name);
var img_buf: ?*anyopaque = null;
var img_len: usize = 0;
@@ -585,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())],
@@ -599,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(runtime.allocator(), new_layer) catch return error.FileLoadError;
if (l.visible and !set_layer_index) {
internal.selected_layer_index = i;
@@ -609,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],
@@ -621,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(runtime.allocator(), new_layer) catch return error.FileLoadError;
if (l.visible and !set_layer_index) {
internal.selected_layer_index = i;
@@ -635,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(runtime.allocator(), .{
.origin = .{ 0, 0 },
}) catch return error.FileLoadError;
} else {
- internal.sprites.append(fizzy.app.allocator, .{
+ internal.sprites.append(runtime.allocator(), .{
.origin = .{ ext.sprites[sprite_index].origin[0], ext.sprites[sprite_index].origin[1] },
}) catch return error.FileLoadError;
}
}
for (ext.animations) |animation| {
- internal.animations.append(fizzy.app.allocator, .{
+ internal.animations.append(runtime.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 runtime.allocator().dupe(u8, animation.name),
+ .frames = try runtime.allocator().dupe(Animation.Frame, animation.frames),
}) catch return error.FileLoadError;
}
return internal;
@@ -658,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 (pixi_mod.fs.read(runtime.allocator(), path) catch null) |file_bytes| {
// std.log.debug("Read file bytes!", .{});
// var input = std.io.fixedBufferStream(file_bytes);
// var iter = std.tar.iterator(input.reader(), .{
@@ -666,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(runtime.allocator());
// defer json_content.deinit();
// while (try iter.next()) |entry| {
@@ -681,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(pixi_mod.File, runtime.allocator(), json_content.items, options) catch null) |parsed| {
// defer parsed.deinit();
// std.log.debug("Parsed fizzydata.json!", .{});
// const ext = parsed.value;
- // var internal: fizzy.Internal.File = .{
- // .id = fizzy.editor.newFileID(),
- // .path = try fizzy.app.allocator.dupe(u8, path),
+ // var internal: pixi_mod.internal.File = .{
+ // .id = runtime.state().host.allocDocId(),
+ // .path = try runtime.allocator().dupe(u8, path),
// .width = ext.width,
// .height = ext.height,
// .tile_width = ext.tile_width,
// .tile_height = ext.tile_height,
- // .history = fizzy.Internal.File.History.init(fizzy.app.allocator),
- // .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator),
- // .checkerboard = fizzy.image.init(
+ // .history = pixi_mod.internal.File.History.init(runtime.allocator()),
+ // .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()),
+ // .checkerboard = pixi_mod.image.init(
// ext.tile_width * 2,
// ext.tile_height * 2,
// .{ .r = 0, .g = 0, .b = 0, .a = 0 },
@@ -706,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,
+ // runtime.allocator(),
// @divExact(ext.width, ext.tile_width) * @divExact(ext.height, ext.tile_height),
// ),
// };
@@ -729,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(runtime.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: ?pixi_mod.Layer = pixi_mod.Layer.fromPixels(internal.newID(), runtime.allocator().dupe(u8, ext_layer.name) catch ext_layer.name, layer_content.items, ext.width, ext.height, .ptr) catch null;
// if (cond) |*new_layer| {
// new_layer.visible = ext_layer.visible;
// new_layer.collapse = ext_layer.collapse;
- // internal.layers.append(fizzy.app.allocator, new_layer.*) catch return error.FileLoadError;
+ // internal.layers.append(runtime.allocator(), new_layer.*) catch return error.FileLoadError;
// } else {
// std.log.err("Failed to create layer from pixels", .{});
// }
@@ -753,7 +763,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");
@@ -783,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) !?pixi_mod.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(
+ runtime.state().host.allocDocId(),
"Layer",
file_bytes,
.ptr,
@@ -798,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) !?pixi_mod.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(runtime.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) !?pixi_mod.internal.File {
const size = image_layer.size();
const column_width: u32 = @intFromFloat(size.w);
const row_height: u32 = @intFromFloat(size.h);
- var internal: fizzy.Internal.File = .{
- .id = fizzy.editor.newFileID(),
- .path = try fizzy.app.allocator.dupe(u8, path),
+ var internal: pixi_mod.internal.File = .{
+ .id = runtime.state().host.allocDocId(),
+ .path = try runtime.allocator().dupe(u8, path),
.columns = 1,
.rows = 1,
.column_width = column_width,
.row_height = row_height,
- .history = fizzy.Internal.File.History.init(fizzy.app.allocator),
- .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator),
+ .history = pixi_mod.internal.File.History.init(runtime.allocator()),
+ .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()),
};
- internal.layers.append(fizzy.app.allocator, image_layer) catch return error.LayerCreateError;
+ internal.layers.append(runtime.allocator(), image_layer) catch return error.LayerCreateError;
// Initialize editor layers and selected sprites
internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount());
+ internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount());
- internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height());
+ internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height());
// Create a layer-sized checkerboard pattern for selection tools
for (0..internal.width() * internal.height()) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
+ const value = pixi_mod.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
internal.editor.checkerboard.setValue(i, value);
}
@@ -845,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: ?[][]pixi_mod.Animation.Frame = null, // If provided, the animation data will be applied to the animations after resizing
sprite_data: ?[][2]f32 = null, // If provided, the sprite data will be applied to the sprites after resizing
};
@@ -868,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 runtime.allocator().alloc([][4]u8, file.layers.len);
for (0..file.layers.len) |layer_index| {
var layer = file.layers.get(layer_index);
- layer_data[layer_index] = fizzy.app.allocator.dupe([4]u8, layer.pixels()) catch return error.MemoryAllocationFailed;
+ layer_data[layer_index] = runtime.allocator().dupe([4]u8, layer.pixels()) catch return error.MemoryAllocationFailed;
}
file.history.undo_layer_data_stack.append(layer_data) catch return error.MemoryAllocationFailed;
// Store all the animations before the resize event
- var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len);
+ var anim_data = try runtime.allocator().alloc([]pixi_mod.Animation.Frame, file.animations.len);
for (0..file.animations.len) |anim_index| {
const animation = file.animations.get(anim_index);
- anim_data[anim_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames) catch return error.MemoryAllocationFailed;
+ anim_data[anim_index] = runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames) catch return error.MemoryAllocationFailed;
}
file.history.undo_animation_data_stack.append(anim_data) catch return error.MemoryAllocationFailed;
- var sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount());
+ var sprite_data = try runtime.allocator().alloc([2]f32, file.spriteCount());
for (0..file.spriteCount()) |sprite_index| {
sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index];
}
@@ -895,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(runtime.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(runtime.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(runtime.allocator(), .{ .sprite_index = frame.sprite_index, .ms = frame.ms }) catch return error.AnimationFrameAppendError;
}
}
} else for (0..file.animations.len) |anim_index| {
var animation = file.animations.get(anim_index);
- var new_animation = fizzy.Internal.Animation.init(fizzy.app.allocator, animation.id, animation.name, &.{}) catch return error.AnimationCreateError;
+ var new_animation = Animation.init(runtime.allocator(), animation.id, animation.name, &.{}) catch return error.AnimationCreateError;
defer file.animations.set(anim_index, new_animation);
- defer animation.deinit(fizzy.app.allocator);
+ defer animation.deinit(runtime.allocator());
for (0..animation.frames.len) |frame_index| {
const old_sprite_index = animation.frames[frame_index].sprite_index;
if (file.getResizedIndex(old_sprite_index, new_columns, new_rows)) |new_sprite_index| {
- new_animation.appendFrame(fizzy.app.allocator, .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError;
+ new_animation.appendFrame(runtime.allocator(), .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError;
}
}
}
@@ -919,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| runtime.allocator().free(s);
if (options.sprite_data == null) {
- const snapshot = try fizzy.app.allocator.alloc([2]f32, old_sprite_count);
+ const snapshot = try runtime.allocator().alloc([2]f32, old_sprite_count);
for (0..old_sprite_count) |i| {
snapshot[i] = file.sprites.items(.origin)[i];
}
@@ -930,7 +940,7 @@ pub fn resize(file: *File, options: ResizeOptions) !void {
}
file.sprites.resize(
- fizzy.app.allocator,
+ runtime.allocator(),
new_sprite_count,
) catch return error.MemoryAllocationFailed;
@@ -974,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 = pixi_mod.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i);
file.editor.checkerboard.setValue(i, value);
}
@@ -1302,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);
+ pixi_mod.render.destroyLayerCompositeResources(file);
strokeUndoFreeSnapshot(file);
@@ -1310,15 +1320,15 @@ pub fn deinit(file: *File) void {
file.buffers.deinit();
for (file.layers.items(.name)) |name| {
- fizzy.app.allocator.free(name);
+ runtime.allocator().free(name);
}
for (file.animations.items(.name)) |name| {
- fizzy.app.allocator.free(name);
+ runtime.allocator().free(name);
}
for (file.animations.items(.frames)) |frames| {
- fizzy.app.allocator.free(frames);
+ runtime.allocator().free(frames);
}
file.editor.temporary_layer.deinit();
@@ -1329,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(runtime.allocator());
+ file.editor.selected_animation_indices.deinit(runtime.allocator());
+ file.editor.selected_frame_indices.deinit(runtime.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(runtime.allocator());
+ file.deleted_layers.deinit(runtime.allocator());
+ file.sprites.deinit(runtime.allocator());
+ file.animations.deinit(runtime.allocator());
+ file.deleted_animations.deinit(runtime.allocator());
+ runtime.allocator().free(file.path);
}
pub fn dirty(self: File) bool {
@@ -1608,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(runtime.allocator(), p) catch return;
file.editor.animation_selection_anchor = p;
}
}
@@ -1670,9 +1680,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 = runtime.state().tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
+ const offset = runtime.state().tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (select_options.constrain_to_tile) {
@@ -1716,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.editor.tools.stroke;
+ const center: dvui.Point = .{ .x = @floor(Tools.max_brush_size_float / 2), .y = @floor(Tools.max_brush_size_float / 2) };
+ var mask = runtime.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.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
+ if (runtime.state().tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
mask.unset(i);
}
}
}
- if (fizzy.algorithms.brezenham.process(point1, point2) catch null) |points| {
+ if (pixi_mod.algorithms.brezenham.process(point1, point2) catch null) |points| {
for (points, 0..) |point, point_i| {
- if (select_options.stroke_size < 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.editor.tools.stroke else mask;
+ var stroke = if (point_i == 0) runtime.state().tools.stroke else mask;
var iter = stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
+ const offset = runtime.state().tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (select_options.constrain_to_tile) {
@@ -1796,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 = pixi_mod.image.pixelIndex(read_layer.source, p) orelse return;
const original_color = read_layer.pixels()[start_idx];
const n = read_layer.pixels().len;
if (selection_layer.mask.capacity() != n) return;
- var visited = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, n);
+ var visited = try std.DynamicBitSet.initEmpty(runtime.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(runtime.allocator());
defer queue.deinit();
try queue.append(p);
@@ -1819,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 = pixi_mod.image.pixelIndex(read_layer.source, qp) orelse continue;
if (!std.meta.eql(original_color, read_layer.pixels()[idx])) continue;
selection_layer.mask.setValue(idx, value);
@@ -1827,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 (pixi_mod.image.pixelIndex(read_layer.source, np)) |ni| {
if (visited.isSet(ni)) continue;
if (!std.meta.eql(original_color, read_layer.pixels()[ni])) continue;
visited.set(ni);
@@ -1933,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);
+ runtime.allocator().free(p);
file.editor.stroke_undo_pixels = null;
}
file.editor.stroke_undo_x = 0;
@@ -1960,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 runtime.allocator().alloc(u8, n);
const layer = file.layers.get(file.selected_layer_index);
const pix = layer.pixels();
@@ -2011,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 runtime.allocator().alloc(u8, new_n);
const layer = file.layers.get(file.selected_layer_index);
const pix = layer.pixels();
@@ -2037,7 +2047,7 @@ pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void {
}
}
- fizzy.app.allocator.free(old_buf);
+ runtime.allocator().free(old_buf);
file.editor.stroke_undo_pixels = new_buf;
file.editor.stroke_undo_x = tx;
file.editor.stroke_undo_y = ty;
@@ -2331,9 +2341,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 = runtime.state().tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
+ const offset = runtime.state().tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (clip_rect) |cr| {
@@ -2419,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.editor.tools.stroke;
+ const center: dvui.Point = .{ .x = @floor(Tools.max_brush_size_float / 2), .y = @floor(Tools.max_brush_size_float / 2) };
+ var mask = runtime.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.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
+ if (runtime.state().tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
mask.unset(i);
}
}
}
- if (fizzy.algorithms.brezenham.process(point1, point2) catch null) |points| {
+ if (pixi_mod.algorithms.brezenham.process(point1, point2) catch null) |points| {
for (points, 0..) |point, point_i| {
if (clip_rect) |cr| {
const br = brushRect(point, draw_options.stroke_size, iw, ih);
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,
@@ -2449,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.editor.tools.stroke else mask;
+ var stroke = if (point_i == 0) runtime.state().tools.stroke else mask;
var iter = stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
+ const offset = runtime.state().tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (clip_rect) |cr| {
@@ -2601,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(runtime.allocator(), self.layers.slice().get(index));
self.layers.orderedRemove(index);
self.editor.layer_composite_dirty = true;
self.editor.split_composite_dirty = true;
@@ -2637,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 runtime.allocator().dupe([4]u8, dest.pixels());
+ errdefer runtime.allocator().free(dest_pixels_before);
- var dest_mask_before = try dest.mask.clone(fizzy.app.allocator);
+ var dest_mask_before = try dest.mask.clone(runtime.allocator());
errdefer dest_mask_before.deinit();
for (0..pix_n) |i| {
@@ -2655,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(runtime.allocator(), self.layers.slice().get(src_i));
self.layers.orderedRemove(src_i);
self.editor.layer_composite_dirty = true;
@@ -2674,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.editor.explorer.pane = .tools;
+ runtime.state().host.setActiveSidebarView(plugin.view_tools);
}
pub fn duplicateLayer(self: *File, index: usize) !u64 {
@@ -2689,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(runtime.allocator(), 0, new_layer) catch {
dvui.log.err("Failed to append layer", .{});
};
@@ -2710,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(runtime.allocator(), 0, layer) catch {
dvui.log.err("Failed to append layer", .{});
};
self.selected_layer_index = 0;
@@ -2735,14 +2745,14 @@ pub fn createLayer(self: *File) !u64 {
pub fn createAnimation(self: *File) !usize {
var animation = Animation.init(
- fizzy.app.allocator,
+ runtime.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 runtime.allocator().alloc(Animation.Frame, self.editor.selected_sprites.count());
var iter = self.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
var i: usize = 0;
@@ -2751,7 +2761,7 @@ pub fn createAnimation(self: *File) !usize {
}
}
- self.animations.append(fizzy.app.allocator, animation) catch {
+ self.animations.append(runtime.allocator(), animation) catch {
dvui.log.err("Failed to append animation", .{});
};
return self.animations.len - 1;
@@ -2760,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(runtime.allocator(), self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation;
+ self.animations.insert(runtime.allocator(), index + 1, new_animation) catch {
dvui.log.err("Failed to append animation", .{});
};
return index + 1;
}
pub fn deleteAnimation(self: *File, index: usize) !void {
- try self.deleted_animations.append(fizzy.app.allocator, self.animations.slice().get(index));
+ try self.deleted_animations.append(runtime.allocator(), self.animations.slice().get(index));
self.animations.orderedRemove(index);
try self.history.append(.{ .animation_restore_delete = .{
.action = .restore,
@@ -2787,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(runtime.allocator());
+ defer ext.deinit(runtime.allocator());
- const output_path = try fizzy.editor.arena.allocator().dupeZ(u8, self.path);
+ const output_path = try runtime.state().host.arena().dupeZ(u8, self.path);
var handle = try std.fs.cwd().createFile(output_path, .{});
defer handle.close();
var wrt = std.tar.writer(handle.writer());
- var json = std.array_list.Managed(u8).init(fizzy.app.allocator);
+ var json = std.array_list.Managed(u8).init(runtime.allocator());
const out_stream = json.writer();
const options = std.json.StringifyOptions{};
@@ -2818,14 +2828,14 @@ 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(runtime.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, pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Saved {s}", .{std.fs.path.basename(self.path)}) catch "Saved file";
dvui.dataSetSlice(window, id, "_message", message);
@@ -2842,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 pixi_mod.render.syncLayerComposite(self);
const target = self.editor.layer_composite_target orelse return error.NoLayerComposite;
- const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target);
+ const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target);
defer {
const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
+ runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
}
- var tmp_layer: 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 pixi_mod.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 pixi_mod.image.writeToJpgPpi(tmp_layer.source, out_path, ppi);
},
}
}
@@ -2876,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)), pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file";
dvui.dataSetSlice(window, id, "_message", message);
@@ -2897,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)), pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file";
dvui.dataSetSlice(window, id, "_message", message);
@@ -2916,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, runtime.allocator());
+ defer snap.deinit(runtime.allocator());
try writeSnapshotToZip(self.id, window, &snap);
}
@@ -2926,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: pixi_mod.File,
layer_bytes: [][]u8,
layer_entry_names: [][:0]const u8,
null_terminated_path: [:0]u8,
@@ -2992,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(runtime.allocator(), job);
self.cond.signal(dvui.io);
}
};
@@ -3025,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(runtime.allocator());
+ runtime.allocator().destroy(job.snap);
}
- save_queue.queue.deinit(fizzy.app.allocator);
+ save_queue.queue.deinit(runtime.allocator());
}
fn saveQueueWorker() void {
@@ -3052,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(runtime.allocator());
+ runtime.allocator().destroy(job.snap);
+ if (runtime.state().docs.fileById(job.file_id)) |f| f.setSaving(false);
dvui.refresh(job.window, @src(), null);
}
writeSnapshotToZip(job.file_id, job.window, job.snap) catch |err| {
@@ -3087,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 (runtime.state().docs.fileById(file_id)) |f| f.history.bookmark = 0;
}
fn zipEntryOk(rc: c_int) !void {
@@ -3096,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(runtime.allocator(), snap.ext, options);
+ defer runtime.allocator().free(output);
try zipEntryOk(zip.zip_entry_open(z, "fizzydata.json"));
try zipEntryOk(zip.zip_entry_write(z, output.ptr, output.len));
@@ -3139,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, runtime.allocator());
+ defer snap.deinit(runtime.allocator());
+ const bytes = try writeSnapshotToZipBytes(&snap, runtime.allocator());
+ defer runtime.allocator().free(bytes);
+ try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".fiz", bytes);
} else if (std.mem.eql(u8, ext, ".png")) {
const bytes = try flattenedImageBytes(self, window, .png);
- defer fizzy.app.allocator.free(bytes);
- try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes);
+ defer runtime.allocator().free(bytes);
+ try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".png", bytes);
} else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
const bytes = try flattenedImageBytes(self, window, .jpg);
- defer fizzy.app.allocator.free(bytes);
- try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes);
+ defer runtime.allocator().free(bytes);
+ try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".jpg", bytes);
} else {
return;
}
self.history.bookmark = 0;
- const id_mutex = dvui.toastAdd(window, @src(), 0, fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000);
+ const id_mutex = dvui.toastAdd(window, @src(), 0, pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Downloaded {s}", .{basename}) catch "Downloaded file";
dvui.dataSetSlice(window, id, "_message", message);
@@ -3169,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 pixi_mod.render.syncLayerComposite(self);
const target = self.editor.layer_composite_target orelse return error.NoLayerComposite;
- const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target);
+ const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target);
defer {
const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
+ runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
}
- var tmp_layer: 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(runtime.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 pixi_mod.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 pixi_mod.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi);
},
}
return out.toOwnedSlice();
@@ -3206,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 runtime.allocator().dupe(u8, new_path);
self.path = new_owned;
errdefer {
- fizzy.app.allocator.free(self.path[0..self.path.len]);
+ runtime.allocator().free(self.path[0..self.path.len]);
self.path = old_path;
}
if (comptime @import("builtin").target.cpu.arch == .wasm32) {
@@ -3217,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]);
+ runtime.allocator().free(old_path[0..old_path.len]);
}
/// Default filename (with `.fiz`) for a Save As dialog, derived from the current path.
@@ -3241,10 +3251,10 @@ fn deinitAllUserLayers(self: *File) void {
fn clearAnimationsForSaveAs(self: *File) void {
for (self.animations.items(.name)) |n| {
- fizzy.app.allocator.free(n);
+ runtime.allocator().free(n);
}
for (self.animations.items(.frames)) |frames| {
- fizzy.app.allocator.free(frames);
+ runtime.allocator().free(frames);
}
self.animations.clearRetainingCapacity();
self.deleted_animations.clearRetainingCapacity();
@@ -3268,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(runtime.allocator(), self.spriteCount());
- self.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, self.width() * self.height());
+ self.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), self.width() * self.height());
for (0..self.width() * self.height()) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(self.width()), .h = @floatFromInt(self.height()) }, i);
+ const value = pixi_mod.math.checker(.{ .w = @floatFromInt(self.width()), .h = @floatFromInt(self.height()) }, i);
self.editor.checkerboard.setValue(i, value);
}
self.editor.selected_layer_indices.clearRetainingCapacity();
- try self.editor.selected_layer_indices.append(fizzy.app.allocator, 0);
+ try self.editor.selected_layer_indices.append(runtime.allocator(), 0);
}
/// Flattens visible layers (via GPU composite), writes PNG or JPEG to `output_path`, and replaces
@@ -3294,16 +3304,16 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo
return error.InvalidImageSize;
}
- try fizzy.render.syncLayerComposite(self);
+ try pixi_mod.render.syncLayerComposite(self);
const target = self.editor.layer_composite_target orelse {
self.setSaving(false);
return error.NoLayerComposite;
};
- const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target);
+ const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target);
defer {
const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
+ runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
}
const ext = std.fs.path.extension(output_path);
@@ -3314,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(runtime.allocator());
errdefer out.deinit();
- try fizzy.image.writePngToWriter(single_layer.source, &out.writer, r);
+ try pixi_mod.image.writePngToWriter(single_layer.source, &out.writer, r);
break :blk try out.toOwnedSlice();
} else blk: {
const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0));
- var out = std.Io.Writer.Allocating.init(fizzy.app.allocator);
+ var out = std.Io.Writer.Allocating.init(runtime.allocator());
errdefer out.deinit();
- try fizzy.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi);
+ try pixi_mod.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi);
break :blk try out.toOwnedSlice();
};
- defer fizzy.app.allocator.free(bytes);
+ defer runtime.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 fizzy.image.writeToPngResolution(single_layer.source, output_path, r);
+ try pixi_mod.image.writeToPngResolution(single_layer.source, output_path, r);
} else {
const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0));
- try fizzy.image.writeToJpgPpi(single_layer.source, output_path, ppi);
+ try pixi_mod.image.writeToJpgPpi(single_layer.source, output_path, ppi);
}
- fizzy.render.destroyLayerCompositeResources(self);
- fizzy.render.destroySplitCompositeResources(self);
+ pixi_mod.render.destroyLayerCompositeResources(self);
+ pixi_mod.render.destroySplitCompositeResources(self);
deinitAllUserLayers(self);
clearAnimationsForSaveAs(self);
self.sprites.clearRetainingCapacity();
for (0..self.spriteCount()) |_| {
- self.sprites.append(fizzy.app.allocator, .{ .origin = .{ 0, 0 } }) catch {
+ self.sprites.append(runtime.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 runtime.allocator().dupe(u8, output_path);
+ runtime.allocator().free(self.path[0..self.path.len]);
self.path = new_path;
self.columns = 1;
self.rows = 1;
@@ -3364,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(runtime.allocator(), single_layer) catch {
single_layer.deinit();
return error.LayerCreateError;
};
self.history.deinit();
- self.history = .init(fizzy.app.allocator);
+ self.history = .init(runtime.allocator());
try reinitEditorSurfaceForFlatDocument(self);
self.editor.layer_composite_dirty = true;
@@ -3379,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)), pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file";
dvui.dataSetSlice(window, id, "_message", message);
id_mutex.mutex.unlock(dvui.io);
}
- fizzy.editor.requestCompositeWarmup();
+ runtime.state().host.requestPrepareFrame();
}
pub const GridLayoutOptions = struct {
@@ -3393,7 +3403,7 @@ pub const GridLayoutOptions = struct {
row_height: u32,
columns: u32,
rows: u32,
- anchor: fizzy.math.layout_anchor.LayoutAnchor,
+ anchor: pixi_mod.math.layout_anchor.LayoutAnchor,
/// When true (default), `applyGridLayout` snapshots the previous state and pushes a
/// `grid_layout` change to the file's history before mutating. Internal callers driving
/// undo/redo restoration should pass `false` so the swap doesn't loop into itself.
@@ -3402,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 runtime.allocator().alloc(u64, layer_count);
+ errdefer runtime.allocator().free(layer_ids);
- var layer_pixels = try fizzy.app.allocator.alloc([][4]u8, layer_count);
+ var layer_pixels = try runtime.allocator().alloc([][4]u8, layer_count);
var allocated: usize = 0;
errdefer {
- for (layer_pixels[0..allocated]) |buf| fizzy.app.allocator.free(buf);
- fizzy.app.allocator.free(layer_pixels);
+ for (layer_pixels[0..allocated]) |buf| runtime.allocator().free(buf);
+ runtime.allocator().free(layer_pixels);
}
for (0..layer_count) |i| {
layer_ids[i] = file.layers.items(.id)[i];
const src = file.layers.get(i).pixels();
std.debug.assert(src.len == total);
- const dst = try fizzy.app.allocator.alloc([4]u8, total);
+ const dst = try runtime.allocator().alloc([4]u8, total);
@memcpy(dst, src);
layer_pixels[i] = dst;
allocated += 1;
}
const sprite_count = file.sprites.len;
- var sprite_origins = try fizzy.app.allocator.alloc([2]f32, sprite_count);
- errdefer fizzy.app.allocator.free(sprite_origins);
+ var sprite_origins = try runtime.allocator().alloc([2]f32, sprite_count);
+ errdefer runtime.allocator().free(sprite_origins);
for (0..sprite_count) |i| sprite_origins[i] = file.sprites.items(.origin)[i];
return .{
@@ -3449,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);
@@ -3465,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,
@@ -3486,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(runtime.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(runtime.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(runtime.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 = pixi_mod.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx);
file.editor.checkerboard.setValue(idx, value);
}
@@ -3520,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);
+ pixi_mod.render.destroyLayerCompositeResources(file);
file.invalidateActiveLayerTransparencyMaskCache();
}
@@ -3559,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();
};
@@ -3575,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(runtime.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed;
if (new_sprite_count > old_sprite_count) {
var i: usize = old_sprite_count;
@@ -3584,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(runtime.allocator(), new_sprite_count);
const sel_copy = @min(old_sprite_count, new_sprite_count);
for (0..sel_copy) |i| {
if (file.editor.selected_sprites.isSet(i)) new_selected.set(i);
@@ -3597,7 +3607,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void {
file.columns = new_cols;
file.rows = new_rows;
- fizzy.render.destroyLayerCompositeResources(file);
+ pixi_mod.render.destroyLayerCompositeResources(file);
file.invalidateActiveLayerTransparencyMaskCache();
if (snapshot_opt) |snap| {
@@ -3630,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();
};
@@ -3664,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,
@@ -3685,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 = pixi_mod.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw, new_rh, options.anchor);
if (blk.sw == 0 or blk.sh == 0) continue;
const src_x0: u32 = ncol * old_cw + blk.sx;
@@ -3715,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(runtime.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(runtime.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(runtime.allocator(), @as(usize, new_w) * @as(usize, new_h)) catch return error.MemoryAllocationFailed;
for (0..@as(usize, new_w) * @as(usize, new_h)) |idx| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx);
+ const value = pixi_mod.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx);
file.editor.checkerboard.setValue(idx, value);
}
@@ -3748,7 +3758,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void {
file.columns = new_cols;
file.rows = new_rows;
- fizzy.render.destroyLayerCompositeResources(file);
+ pixi_mod.render.destroyLayerCompositeResources(file);
file.invalidateActiveLayerTransparencyMaskCache();
if (snapshot_opt) |snap| {
@@ -3780,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 = runtime.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, runtime.allocator()) catch |err| {
+ runtime.allocator().destroy(snap_ptr);
self.setSaving(false);
return err;
};
@@ -3804,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(runtime.allocator());
+ runtime.allocator().destroy(snap_ptr);
self.setSaving(false);
return err;
};
@@ -3817,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) !pixi_mod.File {
+ const layers = try allocator.alloc(pixi_mod.Layer, self.layers.slice().len);
+ const sprites = try allocator.alloc(pixi_mod.Sprite, self.sprites.slice().len);
+ const animations = try allocator.alloc(pixi_mod.Animation, self.animations.slice().len);
for (layers, 0..) |*working_layer, i| {
working_layer.name = try allocator.dupe(u8, self.layers.items(.name)[i]);
@@ -3838,7 +3848,7 @@ pub fn external(self: File, allocator: std.mem.Allocator) !fizzy.File {
}
return .{
- .version = fizzy.version,
+ .version = pixi_mod.version,
.columns = self.columns,
.rows = self.rows,
.column_width = self.column_width,
diff --git a/src/internal/History.zig b/src/plugins/pixi/src/internal/History.zig
similarity index 87%
rename from src/internal/History.zig
rename to src/plugins/pixi/src/internal/History.zig
index dc0efa2c..e19563a1 100644
--- a/src/internal/History.zig
+++ b/src/plugins/pixi/src/internal/History.zig
@@ -1,10 +1,11 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
const zgui = @import("zgui");
const History = @This();
-const Editor = fizzy.Editor;
const dvui = @import("dvui");
const Layer = @import("Layer.zig");
+const pixi_mod = @import("../../pixi.zig");
+const plugin = @import("../plugin.zig");
+const runtime = @import("../runtime.zig");
pub const Action = enum { undo, redo };
pub const RestoreDelete = enum { restore, delete };
@@ -57,7 +58,7 @@ pub const Change = union(ChangeType) {
pub const AnimationFrames = struct {
index: usize,
- frames: []fizzy.Animation.Frame,
+ frames: []pixi_mod.Animation.Frame,
};
pub const AnimationRestoreDelete = struct {
@@ -187,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} ** pixi_mod.max_name_len,
.index = 0,
} },
else => error.NotSupported,
@@ -197,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);
+ runtime.allocator().free(pixels.indices);
+ runtime.allocator().free(pixels.values);
},
.origins => |*origins| {
- fizzy.app.allocator.free(origins.indices);
- fizzy.app.allocator.free(origins.values);
+ runtime.allocator().free(origins.indices);
+ runtime.allocator().free(origins.values);
},
.layers_order => |*layers_order| {
- fizzy.app.allocator.free(layers_order.order);
+ runtime.allocator().free(layers_order.order);
},
.layer_merge => |*layer_merge| {
- fizzy.app.allocator.free(layer_merge.dest_pixels_before);
+ runtime.allocator().free(layer_merge.dest_pixels_before);
layer_merge.dest_mask_before.deinit();
},
.grid_layout => |*gl| {
- for (gl.layer_pixels) |buf| 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| runtime.allocator().free(buf);
+ runtime.allocator().free(gl.layer_pixels);
+ runtime.allocator().free(gl.layer_ids);
+ runtime.allocator().free(gl.sprite_origins);
},
else => {},
}
@@ -229,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([][]pixi_mod.Animation.Frame),
+redo_animation_data_stack: std.array_list.Managed([][]pixi_mod.Animation.Frame),
undo_sprite_data_stack: std.array_list.Managed([][2]f32),
redo_sprite_data_stack: std.array_list.Managed([][2]f32),
@@ -243,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([][]pixi_mod.Animation.Frame).init(allocator),
+ .redo_animation_data_stack = std.array_list.Managed([][]pixi_mod.Animation.Frame).init(allocator),
.undo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator),
.redo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator),
@@ -252,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 = pixi_mod.perf.record and std.meta.activeTag(change) == .pixels;
const pixel_slots: usize = if (track_pixels) switch (change) {
.pixels => |p| p.indices.len,
else => 0,
} else 0;
- const t_hist: i128 = if (track_pixels) fizzy.perf.nanoTimestamp() else 0;
+ const t_hist: i128 = if (track_pixels) pixi_mod.perf.nanoTimestamp() else 0;
if (self.redo_stack.items.len > 0) {
for (self.redo_stack.items) |*c| {
@@ -269,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);
+ runtime.allocator().free(layer);
}
- fizzy.app.allocator.free(data);
+ runtime.allocator().free(data);
}
self.redo_layer_data_stack.clearRetainingCapacity();
}
@@ -363,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;
+ pixi_mod.perf.history_append_pixels_ns +%= @intCast(pixi_mod.perf.nanoTimestamp() - t_hist);
+ pixi_mod.perf.history_append_pixels_calls += 1;
+ pixi_mod.perf.history_append_pixels_slots +%= pixel_slots;
}
}
-fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
+fn layerMergeUndo(file: *pixi_mod.internal.File, lm: *Change.LayerMerge) !void {
const dest_i = for (file.layers.items(.id), 0..) |id, i| {
if (id == lm.dest_layer_id) break i;
} else return error.InvalidLayerMerge;
@@ -377,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(runtime.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(runtime.allocator(), lm.source_index, restored);
file.editor.layer_composite_dirty = true;
file.editor.split_composite_dirty = true;
file.selected_layer_index = lm.source_index;
- fizzy.editor.explorer.pane = .tools;
+ runtime.state().host.setActiveSidebarView(plugin.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
}
-fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
+fn layerMergeRedo(file: *pixi_mod.internal.File, lm: *Change.LayerMerge) !void {
const src_i = for (file.layers.items(.id), 0..) |id, i| {
if (id == lm.source_layer_id) break i;
} else return error.InvalidLayerMerge;
@@ -419,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(runtime.allocator(), file.layers.slice().get(src_i));
file.layers.orderedRemove(src_i);
file.editor.layer_composite_dirty = true;
@@ -429,13 +430,13 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
.up => dest_i,
.down => dest_i - 1,
};
- fizzy.editor.explorer.pane = .tools;
+ runtime.state().host.setActiveSidebarView(plugin.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
}
// Handling cases in this function details how an undo/redo action works, and must be symmetrical.
// This means that `change` needs to be modified to contain the active state prior to changing the active state
-pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !void {
+pub fn undoRedo(self: *History, file: *pixi_mod.internal.File, action: Action) !void {
var active_stack = switch (action) {
.undo => &self.undo_stack,
.redo => &self.redo_stack,
@@ -458,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(pixi_mod.perf.nanoTimestamp(), 1000));
+ const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), @truncate(ts_us), file.editor.canvas.id, pixi_mod.core.dvui.toastDisplay, 2_000_000);
const id = id_mutex.id;
const action_text = switch (action) {
.undo => "Undo:",
@@ -618,14 +619,14 @@ 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;
+ runtime.state().host.setActiveSidebarView(plugin.view_sprites);
},
.layers_order => |*layers_order| {
file.editor.layer_composite_dirty = true;
file.editor.split_composite_dirty = true;
// `new_order` holds layer ids (u64 in the on-disk format), not
// indices — `layers_order.order` below is `[]u64` so this matches.
- var new_order = try fizzy.app.allocator.alloc(u64, layers_order.order.len);
+ var new_order = try runtime.allocator().alloc(u64, layers_order.order.len);
for (0..file.layers.len) |layer_index| {
new_order[layer_index] = file.layers.items(.id)[layer_index];
}
@@ -655,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);
+ runtime.allocator().free(new_order);
file.invalidateActiveLayerTransparencyMaskCache();
},
.layer_restore_delete => |*layer_restore_delete| {
@@ -664,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(runtime.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(runtime.allocator(), file.layers.slice().get(layer_restore_delete.index));
file.layers.orderedRemove(layer_restore_delete.index);
layer_restore_delete.action = .restore;
},
}
- fizzy.editor.explorer.pane = .tools;
+ runtime.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 runtime.allocator().dupe(u8, file.layers.items(.name)[layer_name.index]);
+ runtime.allocator().free(file.layers.items(.name)[layer_name.index]);
+ file.layers.items(.name)[layer_name.index] = try runtime.allocator().dupe(u8, layer_name.name);
layer_name.name = name;
- fizzy.editor.explorer.pane = .tools;
+ runtime.state().host.setActiveSidebarView(plugin.view_tools);
},
.layer_settings => |*layer_settings| {
const idx = layer_settings.index;
@@ -700,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.editor.explorer.pane = .tools;
+ runtime.state().host.setActiveSidebarView(plugin.view_tools);
},
.animation_restore_delete => |*animation_restore_delete| {
const a = animation_restore_delete.action;
switch (a) {
.restore => {
const animation = file.deleted_animations.pop().?;
- try file.animations.insert(fizzy.app.allocator, animation_restore_delete.index, animation);
+ try file.animations.insert(runtime.allocator(), animation_restore_delete.index, animation);
animation_restore_delete.action = .delete;
file.selected_animation_index = animation_restore_delete.index;
},
.delete => {
const animation = file.animations.slice().get(animation_restore_delete.index);
file.animations.orderedRemove(animation_restore_delete.index);
- try file.deleted_animations.append(fizzy.app.allocator, animation);
+ try file.deleted_animations.append(runtime.allocator(), animation);
animation_restore_delete.action = .restore;
if (file.selected_animation_index) |selected_animation_index| {
@@ -726,14 +727,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
}
},
}
- fizzy.editor.explorer.pane = .sprites;
+ runtime.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 runtime.allocator().dupe(u8, file.animations.items(.name)[animation_name.index]);
+ runtime.allocator().free(file.animations.items(.name)[animation_name.index]);
+ file.animations.items(.name)[animation_name.index] = try runtime.allocator().dupe(u8, animation_name.name);
animation_name.name = name;
- fizzy.editor.explorer.pane = .sprites;
+ runtime.state().host.setActiveSidebarView(plugin.view_sprites);
},
.animation_settings => {},
.animation_order => |*animation_order| {
@@ -771,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([]pixi_mod.Animation.Frame, history_frames, current_frames);
file.selected_animation_index = animation_frames.index;
},
@@ -782,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: ?[][]pixi_mod.Animation.Frame = null;
var sprite_data: ?[][2]f32 = null;
switch (action) {
@@ -795,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 runtime.allocator().alloc([]pixi_mod.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] = runtime.allocator().dupe(pixi_mod.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed;
}
try self.redo_animation_data_stack.append(anim_data);
}
@@ -805,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 runtime.allocator().alloc([2]f32, file.spriteCount());
for (0..file.spriteCount()) |sprite_index| {
new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index];
}
@@ -820,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 runtime.allocator().alloc([]pixi_mod.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] = runtime.allocator().dupe(pixi_mod.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed;
}
try self.undo_animation_data_stack.append(anim_data);
}
if (self.redo_sprite_data_stack.pop()) |sd| {
sprite_data = sd;
- const new_sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount());
+ const new_sprite_data = try runtime.allocator().alloc([2]f32, file.spriteCount());
for (0..file.spriteCount()) |sprite_index| {
new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index];
}
@@ -848,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);
+ runtime.allocator().free(ad);
}
if (sprite_data) |sd| {
- fizzy.app.allocator.free(sd);
+ runtime.allocator().free(sd);
}
file.invalidateActiveLayerTransparencyMaskCache();
@@ -944,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);
+ runtime.allocator().free(layer);
}
- fizzy.app.allocator.free(data);
+ runtime.allocator().free(data);
}
for (self.redo_layer_data_stack.items) |data| {
for (data) |layer| {
- fizzy.app.allocator.free(layer);
+ runtime.allocator().free(layer);
}
- fizzy.app.allocator.free(data);
+ runtime.allocator().free(data);
}
self.undo_layer_data_stack.deinit();
diff --git a/src/internal/Layer.zig b/src/plugins/pixi/src/internal/Layer.zig
similarity index 79%
rename from src/internal/Layer.zig
rename to src/plugins/pixi/src/internal/Layer.zig
index 73816b93..5cd26482 100644
--- a/src/internal/Layer.zig
+++ b/src/plugins/pixi/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 pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
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 = runtime.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 = runtime.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(runtime.allocator(), num_pixels) catch return error.MemoryAllocationFailed,
};
}
pub fn fromImageFilePath(id: u64, name: []const u8, path: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
- const source = 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 = pixi_mod.image.fromImageFilePath(name, path, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed,
.source = source,
.mask = mask,
};
}
pub fn fromImageFileBytes(id: u64, name: []const u8, image_bytes: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
- const source = 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 = pixi_mod.image.fromImageFileBytes(name, image_bytes, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = runtime.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 = pixi_mod.image.fromPixelsPMA(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = runtime.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 = pixi_mod.image.fromPixels(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed,
.source = source,
.mask = mask,
};
}
pub fn fromTexture(id: u64, name: []const u8, texture: dvui.Texture, invalidation: dvui.ImageSource.InvalidationStrategy) Layer {
- const source = 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 = pixi_mod.fs.sourceFromTexture(name, texture, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = runtime.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| runtime.allocator().free(image.bytes),
+ .pixels => |p| runtime.allocator().free(p.rgba),
+ .pixelsPMA => |p| runtime.allocator().free(p.rgba),
.texture => |t| dvui.textureDestroyLater(t),
}
- fizzy.app.allocator.free(self.name);
+ runtime.allocator().free(self.name);
self.mask.deinit();
}
/// Casts the source pixels into a slice of [4]u8
pub fn pixels(self: *const Layer) [][4]u8 {
- return fizzy.image.pixels(self.source);
+ return pixi_mod.image.pixels(self.source);
}
/// Caller owns memory that must be freed!
pub fn pixelsFromRect(self: *const Layer, allocator: std.mem.Allocator, rect: dvui.Rect) ?[][4]u8 {
- return fizzy.image.pixelsFromRect(allocator, self.source, rect);
+ return pixi_mod.image.pixelsFromRect(allocator, self.source, rect);
}
/// Casts the source pixels into a slice of bytes
pub fn bytes(self: *const Layer) []u8 {
- return fizzy.image.bytes(self.source);
+ return pixi_mod.image.bytes(self.source);
}
/// Returns the index of the pixel at the given point
/// returns null if the point is out of bounds
pub fn pixelIndex(self: *Layer, p: dvui.Point) ?usize {
- return fizzy.image.pixelIndex(self.source, p);
+ return pixi_mod.image.pixelIndex(self.source, p);
}
/// Returns the point at the given index
/// returns null if the index is out of bounds
pub fn point(self: *Layer, index: usize) ?dvui.Point {
- return fizzy.image.point(self.source, index);
+ return pixi_mod.image.point(self.source, index);
}
/// Returns the color at the given point
/// returns null if the point is out of bounds
pub fn pixel(self: *Layer, p: dvui.Point) ?[4]u8 {
- return fizzy.image.pixel(self.source, p);
+ return pixi_mod.image.pixel(self.source, p);
}
/// Sets the color at the given point
/// does not invalidate the layer
pub fn setPixel(self: *Layer, p: dvui.Point, color: [4]u8) void {
- fizzy.image.setPixel(self.source, p, color);
+ pixi_mod.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(runtime.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);
+ pixi_mod.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.editor.tools.stroke_shape;
- const s: i32 = @intCast(fizzy.editor.tools.stroke_size);
+ const shape = runtime.state().tools.stroke_shape;
+ const s: i32 = @intCast(runtime.state().tools.stroke_size);
if (s == 1) {
if (current_index != 0)
@@ -320,27 +321,17 @@ 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 = pixi_mod.math.blendPmaSrcOver;
pub fn clearRect(self: *Layer, rect: dvui.Rect) void {
- fizzy.image.clearRect(self.source, rect);
+ pixi_mod.image.clearRect(self.source, rect);
self.invalidate();
}
pub fn setRect(self: *Layer, rect: dvui.Rect, color: [4]u8) void {
- fizzy.image.setRect(self.source, rect, color);
+ pixi_mod.image.setRect(self.source, rect, color);
self.invalidate();
}
@@ -416,11 +407,24 @@ 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(runtime.state().host.arena());
+
+ try pixi_mod.image.ensurePngWriterBuffer(&writer.writer);
+ try dvui.PNGEncoder.writeWithResolution(&writer.writer, pixi_mod.image.bytes(source), @intCast(w), @intCast(h), resolution);
+
+ if (@as(?*zip.struct_zip_t, @ptrCast(zip_file))) |z| {
+ _ = zip.zip_entry_write(z, writer.written().ptr, @as(usize, writer.written().len));
+ }
}
pub fn writeSourceToPng(layer: *const Layer, path: []const u8) !void {
- return fizzy.fs.writeSourceToPng(layer.source, path);
+ return pixi_mod.fs.writeSourceToPng(layer.source, path);
}
pub fn resize(layer: *Layer, new_size: dvui.Size) !void {
@@ -429,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,
+ runtime.allocator().dupe(u8, layer.name) catch return error.MemoryAllocationFailed,
@as(u32, @intFromFloat(new_size.w)),
@as(u32, @intFromFloat(new_size.h)),
.{ .r = 0, .g = 0, .b = 0, .a = 0 },
@@ -457,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 `pixi_mod.algorithms.reduce.reduce` so it can be exercised by
/// unit tests without dvui / fizzy globals — see that module for the contract details.
pub fn reduce(layer: *Layer, src: dvui.Rect) ?dvui.Rect {
const sz = layer.size();
const layer_w: u32 = @intFromFloat(sz.w);
const layer_h: u32 = @intFromFloat(sz.h);
- const r = fizzy.algorithms.reduce.reduce(layer.pixels(), layer_w, layer_h, .{
+ const r = pixi_mod.algorithms.reduce.reduce(layer.pixels(), layer_w, layer_h, .{
.x = @intFromFloat(src.x),
.y = @intFromFloat(src.y),
.w = @intFromFloat(src.w),
diff --git a/src/internal/Palette.zig b/src/plugins/pixi/src/internal/Palette.zig
similarity index 81%
rename from src/internal/Palette.zig
rename to src/plugins/pixi/src/internal/Palette.zig
index cefe2c2c..a2bbf77f 100644
--- a/src/internal/Palette.zig
+++ b/src/plugins/pixi/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 pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
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 (pixi_mod.fs.read(runtime.allocator(), dvui.io, file) catch null) |read| {
+ defer runtime.allocator().free(read);
return loadFromBytes(allocator, std.fs.path.basename(file), read);
}
@@ -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);
+ runtime.allocator().free(self.name);
+ runtime.allocator().free(self.colors);
}
diff --git a/src/internal/Sprite.zig b/src/plugins/pixi/src/internal/Sprite.zig
similarity index 100%
rename from src/internal/Sprite.zig
rename to src/plugins/pixi/src/internal/Sprite.zig
diff --git a/src/internal/grid_layout_validate.zig b/src/plugins/pixi/src/internal/grid_layout_validate.zig
similarity index 100%
rename from src/internal/grid_layout_validate.zig
rename to src/plugins/pixi/src/internal/grid_layout_validate.zig
diff --git a/src/internal/layer_order.zig b/src/plugins/pixi/src/internal/layer_order.zig
similarity index 100%
rename from src/internal/layer_order.zig
rename to src/plugins/pixi/src/internal/layer_order.zig
diff --git a/src/internal/palette_parse.zig b/src/plugins/pixi/src/internal/palette_parse.zig
similarity index 100%
rename from src/internal/palette_parse.zig
rename to src/plugins/pixi/src/internal/palette_parse.zig
diff --git a/src/plugins/pixi/src/keybind_ticks.zig b/src/plugins/pixi/src/keybind_ticks.zig
new file mode 100644
index 00000000..ba6ca046
--- /dev/null
+++ b/src/plugins/pixi/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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const Tools = pixi_mod.Tools;
+const Export = @import("dialogs/Export.zig");
+
+pub fn tick() !void {
+ for (dvui.events()) |e| {
+ if (e.handled) continue;
+
+ switch (e.evt) {
+ .key => |ke| {
+ if (ke.matchBind("quick_tools")) {
+ const rm = &runtime.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 (runtime.state().tools.current != .selection or runtime.state().tools.selection_mode == .pixel) {
+ if (runtime.state().tools.stroke_size < Tools.max_brush_size - 1)
+ runtime.state().tools.stroke_size += 1;
+ runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size);
+ }
+ }
+
+ if (ke.matchBind("export") and ke.action == .down) {
+ var mutex = pixi_mod.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 (runtime.state().tools.current != .selection or runtime.state().tools.selection_mode == .pixel) {
+ if (runtime.state().tools.stroke_size > 1)
+ runtime.state().tools.stroke_size -= 1;
+ runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size);
+ }
+ }
+
+ if (ke.matchBind("pencil") and ke.action == .down) {
+ runtime.state().tools.set(.pencil);
+ }
+ if (ke.matchBind("eraser") and ke.action == .down) {
+ runtime.state().tools.set(.eraser);
+ }
+ if (ke.matchBind("bucket") and ke.action == .down) {
+ runtime.state().tools.set(.bucket);
+ }
+ if (ke.matchBind("pointer") and ke.action == .down) {
+ runtime.state().tools.set(.pointer);
+ }
+ if (ke.matchBind("selection") and ke.action == .down) {
+ runtime.state().tools.set(.selection);
+ }
+ },
+ else => {},
+ }
+ }
+}
diff --git a/src/plugins/pixi/src/pack_project.zig b/src/plugins/pixi/src/pack_project.zig
new file mode 100644
index 00000000..c72edc33
--- /dev/null
+++ b/src/plugins/pixi/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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const State = pixi_mod.State;
+const PackJob = @import("PackJob.zig");
+const Internal = pixi_mod.internal;
+
+fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void {
+ const anchor = canvas_id orelse blk: {
+ if (runtime.state().host.activeDoc()) |doc| {
+ if (runtime.state().docs.fileById(doc.id)) |file| break :blk file.editor.canvas.id;
+ }
+ break :blk dvui.currentWindow().data().id;
+ };
+ const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, anchor, pixi_mod.core.dvui.toastDisplay, 2_500_000);
+ const id = id_mutex.id;
+ const msg_copy = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{message}) catch message;
+ dvui.dataSetSlice(dvui.currentWindow(), id, "_message", msg_copy);
+ id_mutex.mutex.unlock(dvui.io);
+}
+
+fn appendOpenPackInputs(st: *State, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void {
+ const gpa = runtime.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 = runtime.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 = runtime.allocator();
+ const io = dvui.io;
+ var dir = try std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true });
+ defer dir.close(io);
+
+ 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 = runtime.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 = runtime.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 (runtime.packer().atlas) |*current_atlas| {
+ current_atlas.deinitCheckerboardTile();
+ for (current_atlas.data.animations) |*anim| gpa.free(anim.name);
+ gpa.free(current_atlas.data.sprites);
+ gpa.free(current_atlas.data.animations);
+ gpa.free(pixi_mod.image.bytes(current_atlas.source));
+
+ current_atlas.source = new_atlas.source;
+ current_atlas.data = new_atlas.data;
+ current_atlas.initCheckerboardTile();
+ } else {
+ runtime.packer().atlas = new_atlas;
+ runtime.packer().atlas.?.initCheckerboardTile();
+ }
+ runtime.packer().last_packed_at_ns = pixi_mod.perf.nanoTimestamp();
+ job.result_consumed = true;
+ st.host.setActiveSidebarView("pixi_mod.project");
+ const toast_canvas: ?dvui.Id = if (st.host.activeDoc()) |doc|
+ if (st.docs.fileById(doc.id)) |file| file.editor.canvas.id else null
+ else
+ 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/editor/panel/sprites.zig b/src/plugins/pixi/src/panel/sprites.zig
similarity index 96%
rename from src/editor/panel/sprites.zig
rename to src/plugins/pixi/src/panel/sprites.zig
index e685370b..8c47d777 100644
--- a/src/editor/panel/sprites.zig
+++ b/src/plugins/pixi/src/panel/sprites.zig
@@ -1,11 +1,11 @@
const std = @import("std");
const icons = @import("icons");
const dvui = @import("dvui");
-const fizzy = @import("../../fizzy.zig");
-const Editor = fizzy.Editor;
-const ReflectionLagSample = fizzy.dvui.ReflectionLagSample;
-const reflection_surface_cols = fizzy.dvui.reflection_surface_cols;
-const wsurf = fizzy.water_surface;
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
+const ReflectionLagSample = pixi_mod.sprite_render.ReflectionLagSample;
+const reflection_surface_cols = pixi_mod.sprite_render.reflection_surface_cols;
+const wsurf = pixi_mod.water_surface;
const Sprites = @This();
@@ -114,12 +114,12 @@ const SpriteSlot = struct {
}
};
-/// Cover-flow scrub momentum tuning (sprite-index units). See `fizzy.Fling`.
+/// Cover-flow scrub momentum tuning (sprite-index units). See `pixi_mod.Fling`.
/// Mouse/trackpad release velocity is measured over a position/time window
/// (`releaseWindowed`), not a per-frame EMA — the EMA converged per frame, so a quick
/// flick built up too little velocity at 60 Hz (e.g. Safari on a deployed build) even
/// though it worked at 120 Hz. The window is wall-clock based, so it's refresh-independent.
-const sprite_fling: fizzy.Fling.Tuning = .{
+const sprite_fling: pixi_mod.Fling.Tuning = .{
.decay = 4.0,
.min_start = 1.2,
.stop = 0.6,
@@ -131,7 +131,7 @@ const sprite_fling_window_s: f32 = 0.08;
/// Touch scrub: a finger flick is short and bursty, so start coasting at a lower
/// speed and tolerate the small gap the browser leaves before `touchend`. Velocity is
/// measured over a position/time window (`releaseWindowed`) rather than the last frame.
-const sprite_fling_touch: fizzy.Fling.Tuning = .{
+const sprite_fling_touch: pixi_mod.Fling.Tuning = .{
.decay = 4.0,
.min_start = 0.6,
.stop = 0.6,
@@ -186,7 +186,7 @@ moved_since_press: bool = false,
/// True when the active scrub began with a touch press (not mouse).
drag_was_touch: bool = false,
/// Release momentum for the scrub: coasts the flow after a flick, then snaps.
-fling: fizzy.Fling = .{},
+fling: pixi_mod.Fling = .{},
/// Set once we've seeded `scroll_pos` from the initial selection.
initialized: bool = false,
/// Previous "flown" state (see `sideCardsFlown`), so we can fire the fly-out /
@@ -209,26 +209,18 @@ prev_scroll_pos: f32 = 0.0,
shelf_vel: f32 = 0.0,
pub fn draw(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
- const prev_clip = dvui.clip(dvui.parentGet().data().rectScale().r);
- defer dvui.clipSet(prev_clip);
-
- if (dvui.parentGet().data().rect.h < 32.0) {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
+ const content_slot = dvui.parentGet().data();
+ const parent = content_slot.contentRect();
+ if (parent.h < 32.0) {
return;
}
- self.drawAnimationControlsDialog();
+ const prev_clip = dvui.clip(content_slot.rectScale().r);
+ defer dvui.clipSet(prev_clip);
- // Since not all panel screens will likely want shadows, which should be reserved for canvases?
- // Text editors, consoles, etc would likely want flat panels or to handle shadows themselves.
- defer {
- 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 });
- }
+ self.drawAnimationControlsDialog();
- const parent = dvui.parentGet().data().rect;
const parent_height = parent.h;
const mode = scrollMode(file);
@@ -243,7 +235,7 @@ pub fn draw(self: *Sprites) !void {
// the frame playback starts/stops. ----
const playing = file.editor.playing;
const flown = sideCardsFlown(playing);
- const panel_id = dvui.parentGet().data().id;
+ const panel_id = content_slot.id;
if (flown != self.was_flown) {
const cur: f32 = if (dvui.animationGet(panel_id, "play_fly")) |a| a.value() else (if (self.was_flown) 1.0 else 0.0);
self.fly_anim_out = flown;
@@ -275,7 +267,7 @@ pub fn draw(self: *Sprites) !void {
// ---- Animated fit-scale: aim the front sprite at a fraction of the
// pane so several neighbours are visible at once. ----
const scale = blk: {
- const steps = fizzy.editor.settings.zoom_steps;
+ const steps = runtime.state().settings.zoom_steps;
const sprite_width = src_rect.w;
const sprite_height = src_rect.h;
const target_width = parent.w * 0.34;
@@ -438,12 +430,23 @@ pub fn draw(self: *Sprites) !void {
return;
}
- const perf_sp = fizzy.perf.spritePreviewBegin();
- defer fizzy.perf.spritePreviewEnd(perf_sp);
+ const perf_sp = pixi_mod.perf.spritePreviewBegin();
+ defer pixi_mod.perf.spritePreviewEnd(perf_sp);
const center_x = parent.center().x;
- // Lift the row a little so the reflection has room below it.
- const center_y = parent.center().y - item_h * 0.10;
+ // Card rects are positioned in the content slot's *content-local* space, where
+ // y = 0 is the top of the content area (below the tab strip). So the vertical
+ // center is half the content height, NOT `parent.center().y`: `parent.y` is the
+ // slot's offset under the tabs, and including it would push the cards down by a
+ // fixed tab-height that grows as a fraction of the pane as it shrinks (the
+ // "drifts off the bottom when small" bug). Horizontal centering uses
+ // `parent.center().x` only because the slot has no left offset (`parent.x ≈ 0`).
+ //
+ // Nudge the centerline up by a fraction of the card height so the reflection
+ // hanging below the baseline doesn't read as bottom-heavy. The nudge scales
+ // with `item_h`, so it stays proportional across pane sizes (a fixed pixel
+ // offset would drift the cards as the pane shrank).
+ const center_y = parent.h / 2.0 - item_h * 0.10;
// The waterline: the shared bottom edge every card stands on (the focus
// card's full-height bottom). Side cards pin their bottom here too.
const baseline_y = center_y + item_h / 2.0;
@@ -749,7 +752,7 @@ pub fn draw(self: *Sprites) !void {
const tiltness = if (max_depth > 0.0) std.math.clamp(@abs(cd.depth) / max_depth, 0.0, 1.0) else 0.0;
const refl_detail = std.math.lerp(1.0, skewed_reflection_detail, tiltness);
- _ = fizzy.dvui.sprite(SpriteSlot.src(), .{
+ _ = pixi_mod.sprite_render.sprite(SpriteSlot.src(), .{
.source = file.layers.items(.source)[file.selected_layer_index],
.file = file,
.alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null,
@@ -803,12 +806,12 @@ pub fn draw(self: *Sprites) !void {
/// Side cards lift away during playback, while a drawing tool is active, or when
/// `settings.scrolling_cards` is off (focus mode; toggled in settings or the sprites pane).
fn sideCardsFlown(playing: bool) bool {
- return playing or drawingToolActive() or !fizzy.editor.settings.scrolling_cards;
+ return playing or drawingToolActive() or !runtime.state().settings.scrolling_cards;
}
/// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee).
fn drawingToolActive() bool {
- return switch (fizzy.editor.tools.current) {
+ return switch (runtime.state().tools.current) {
.pointer, .selection => false,
.pencil, .eraser, .bucket => true,
};
@@ -1050,7 +1053,7 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px
// Dialogs/subwindows stack above the sprites pane in z-order but share the same
// screen rect — don't capture clicks meant for their footer or chrome.
- if (fizzy.dvui.canvasPointerInputSuppressed()) {
+ if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) {
if (dvui.captured(id)) {
for (dvui.events()) |*e| {
if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
@@ -1190,7 +1193,7 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px
}
pub fn drawAnimationControlsDialog(_: *Sprites) void {
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
const rect = dvui.parentGet().data().rectScale().r;
if (dvui.parentGet().data().rect.h < 48.0) {
@@ -1237,8 +1240,8 @@ pub fn drawAnimationControlsDialog(_: *Sprites) void {
!fly_forced,
flown,
) and !fly_forced) {
- fizzy.editor.settings.scrolling_cards = !fizzy.editor.settings.scrolling_cards;
- fizzy.editor.markSettingsDirty();
+ runtime.state().settings.scrolling_cards = !runtime.state().settings.scrolling_cards;
+ runtime.state().settings.save(runtime.state().host);
dvui.refresh(null, @src(), dvui.parentGet().data().id);
}
}
diff --git a/src/plugins/pixi/src/plugin.zig b/src/plugins/pixi/src/plugin.zig
new file mode 100644
index 00000000..ac91820e
--- /dev/null
+++ b/src/plugins/pixi/src/plugin.zig
@@ -0,0 +1,696 @@
+//! 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 internal = @import("../pixi.zig");
+const sdk = internal.sdk;
+const runtime = @import("runtime.zig");
+const State = internal.State;
+const CanvasData = @import("CanvasData.zig");
+const FileWidget = @import("widgets/FileWidget.zig");
+const ImageWidget = @import("widgets/ImageWidget.zig");
+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 = internal.internal;
+
+pub const manifest = sdk.PluginManifest{
+ .id = "pixi",
+ .name = "pixi",
+ .version = .{ .major = 0, .minor = 1, .patch = 0 },
+};
+
+/// Stable contribution ids (plugin-namespaced) referenced across modules.
+pub const view_tools = "internal.tools";
+pub const view_sprites = "internal.sprites";
+pub const view_project = "internal.project";
+pub const bottom_sprites = "internal.sprites_panel";
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .id = "pixi",
+ .display_name = "pixi",
+};
+
+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,
+ .saveDocumentAsync = saveDocumentAsync,
+ .timeSinceSaveCompleteNs = timeSinceSaveCompleteNs,
+ .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename,
+ .saveDocumentAs = saveDocumentAs,
+ .resetDocumentSaveUIState = resetDocumentSaveUIState,
+ .requestNewDocumentDialog = requestNewDocumentDialog,
+ .drawDocument = drawDocument,
+ .drawDocumentInfobar = drawDocumentInfobar,
+ // universal per-frame phases (pixel-art does its raster/canvas work inside them)
+ .beginFrame = beginFrame,
+ .prepareFrame = warmupActiveDocumentComposites,
+ .tickKeybinds = tickKeybinds,
+ .tickOpenDocuments = tickOpenDocuments,
+ .tickActiveDocument = tickActiveDocumentPlayback,
+ .drawOverlay = drawOverlay,
+ .endFrame = resetDocumentPeekLayers,
+ .needsContinuousRepaint = isAnyDocumentActivelyDrawing,
+ // folder lifecycle + save protocol
+ .onFolderClose = pluginPersistProjectFolder,
+ .onFolderOpen = pluginReloadProjectFolder,
+ .saveNeedsConfirmation = shouldConfirmFlatRasterSave,
+ .requestSaveConfirmation = requestSaveConfirmation,
+};
+
+/// A `DocHandle` for one of this plugin's open `*Internal.File`s. Resolved by `doc.id`
+/// because `docs.files` may reallocate and stale `doc.ptr` values.
+fn docFile(doc: DocHandle) *Internal.File {
+ return runtime.state().docs.fileById(doc.id).?;
+}
+
+/// Priority for opening `ext` (lower wins). pixi owns its native `.fiz`/`.pixi`
+/// and flat-image `.png`/`.jpg`/`.jpeg`; native formats win over flat images when
+/// some future plugin also claims an image type.
+fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 {
+ 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; pixi owns the
+/// entire region: rulers, the canvas hbox, the transform/edit/sample overlays, the editing
+/// widget, and the sample magnifier. The per-pane ruler/overlay/reorder state + draw helpers
+/// live on the pixel-art-owned `CanvasData` (keyed by workbench pane `grouping` on `State`).
+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);
+
+ internal.perf.canvasPaneDrawn();
+
+ if (runtime.state().settings.show_rulers and !dvui.firstFrame(container.id)) {
+ defer internal.core.dvui.drawEdgeShadow(container.rectScale(), .top, .{});
+ chrome.drawRuler(file, .horizontal);
+ }
+
+ var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both });
+ defer canvas_hbox.deinit();
+
+ if (runtime.state().settings.show_rulers and !dvui.firstFrame(container.id)) {
+ defer internal.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 (runtime.state().host.appliesNativeWindowOpacity()) {
+ content_color = if (!runtime.state().host.isMaximized())
+ content_color.opacity(runtime.state().host.contentOpacity())
+ else
+ content_color;
+ }
+
+ const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32)
+ runtime.packer().atlas != null
+ else
+ runtime.state().host.folder() != null and runtime.packer().atlas != null;
+
+ var canvas_vbox = sdk.pane_layout.mainCanvasVbox(content_color, show_packed_atlas, pane.grouping);
+ defer {
+ 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 = &runtime.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 (runtime.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 {
+ plugin.state = @ptrCast(@alignCast(runtime.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,
+ .persistent = true,
+ });
+ try host.registerSettingsSection(.{
+ .id = "internal.settings",
+ .owner = &plugin,
+ .title = "pixi",
+ .draw = PixelArtSettings.draw,
+ });
+
+ // Pixel-art's invocable, plugin-specific features. The shell/menus/keybinds trigger these
+ // by id via `host.runCommand` without naming them. (Generic active-doc editing verbs like
+ // `transform`/`copy`/`paste` are *not* commands — they are `Plugin.VTable` hooks the shell
+ // dispatches to the focused document's owner.)
+ try host.registerCommand(.{
+ .id = "internal.gridLayout",
+ .owner = &plugin,
+ .title = "Grid Layout…",
+ .run = gridLayoutCommand,
+ });
+ try host.registerCommand(.{
+ .id = "internal.packProject",
+ .owner = &plugin,
+ .title = "Pack Project",
+ .run = packProjectCommand,
+ .isEnabled = packProjectEnabled,
+ });
+
+ // Editing verbs the shell's Edit menu / keybinds dispatch to per active-doc owner
+ // (`.`). These are pixel-art's answers; another editor registers its own.
+ try host.registerCommand(.{ .id = "internal.copy", .owner = &plugin, .title = "Copy", .run = pluginCopy });
+ try host.registerCommand(.{ .id = "internal.paste", .owner = &plugin, .title = "Paste", .run = pluginPaste });
+ try host.registerCommand(.{ .id = "internal.transform", .owner = &plugin, .title = "Transform", .run = pluginTransform });
+ try host.registerCommand(.{ .id = "internal.acceptEdit", .owner = &plugin, .title = "Accept Edit", .run = pluginAcceptEdit });
+ try host.registerCommand(.{ .id = "internal.cancelEdit", .owner = &plugin, .title = "Cancel Edit", .run = pluginCancelEdit });
+ try host.registerCommand(.{ .id = "internal.deleteSelection", .owner = &plugin, .title = "Delete Selection", .run = pluginDeleteSelection });
+}
+
+/// Stable `*Plugin` for constructing `DocHandle.owner` fields.
+pub fn pluginPtr() *sdk.Plugin {
+ return &plugin;
+}
+
+fn fileRowFillColor(_: ?*anyopaque, color_index: usize) ?dvui.Color {
+ if (runtime.state().colors.palette) |*palette| {
+ return palette.getDVUIColor(color_index);
+ }
+ return null;
+}
+
+fn drawTools(_: ?*anyopaque) anyerror!void {
+ try runtime.state().tools_pane.draw();
+}
+fn drawSprites(_: ?*anyopaque) anyerror!void {
+ try runtime.state().sprites_pane.draw();
+}
+fn drawProject(_: ?*anyopaque) anyerror!void {
+ try internal.explorer.project.draw();
+}
+fn drawSpritesPanel(_: ?*anyopaque) anyerror!void {
+ try runtime.state().sprites_panel.draw();
+}
+
+fn tickKeybinds(_: *anyopaque) anyerror!void {
+ try KeybindTicks.tick();
+}
+
+/// Pixel-art's per-frame overlay: the radial tool menu (processes its hold-to-open input,
+/// then draws while visible). Wired to the universal `Plugin.drawOverlay` phase.
+fn drawOverlay(_: *anyopaque) anyerror!void {
+ RadialMenu.processHoldOpenInput();
+ if (RadialMenu.visible()) 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.documentFromId(st, id);
+}
+
+fn documentByPath(state: *anyopaque, path: []const u8) ?*anyopaque {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocsRegistry.documentFromPath(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.bindDocumentToWorkspace(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.sizeOfDocument(st);
+}
+
+fn documentStackAlign(state: *anyopaque) usize {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.alignOfDocument(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);
+}
+
+/// Command body for `internal.gridLayout` — opens the grid-layout dialog for the active doc.
+fn gridLayoutCommand(_: *anyopaque) anyerror!void {
+ const doc = runtime.state().host.activeDoc() orelse return;
+ GridLayout.request(doc.id);
+}
+
+fn requestSaveConfirmation(_: *anyopaque, doc: DocHandle, mode: sdk.Plugin.SaveConfirmMode, from_save_all_quit: bool) void {
+ FlatRasterSaveWarning.request(doc.id, mode, from_save_all_quit);
+}
+
+fn beginFrame(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ // Advance the per-frame render clock used as a composite-cache invalidation key.
+ internal.render.frame_index +%= 1;
+ // Sweep any in-flight atlas-pack jobs. The shell no longer orchestrates packing — the
+ // plugin drives its own background work from this universal per-frame phase.
+ PackProject.tick(st);
+ if (comptime @import("builtin").target.cpu.arch == .wasm32) PackProject.runWasmWorkers(st);
+}
+
+/// Command body for `internal.packProject`.
+fn packProjectCommand(state: *anyopaque) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ try PackProject.start(st);
+}
+
+/// `internal.packProject` is enabled only when no pack is already in flight.
+fn packProjectEnabled(state: *anyopaque) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return !PackProject.isActive(st);
+}
+
+fn tickOpenDocuments(state: *anyopaque) bool {
+ 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);
+}
+
+// Editing-verb command bodies (registered in `register`). `anyerror!void` to match `Command.run`.
+fn pluginAcceptEdit(state: *anyopaque) anyerror!void {
+ DocLifecycle.acceptEdit(@ptrCast(@alignCast(state)));
+}
+
+fn pluginCancelEdit(state: *anyopaque) anyerror!void {
+ DocLifecycle.cancelEdit(@ptrCast(@alignCast(state)));
+}
+
+fn pluginDeleteSelection(state: *anyopaque) anyerror!void {
+ DocLifecycle.deleteSelection(@ptrCast(@alignCast(state)));
+}
+
+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);
+}
+
+/// 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/pixi/src/radial_menu.zig b/src/plugins/pixi/src/radial_menu.zig
new file mode 100644
index 00000000..a277989f
--- /dev/null
+++ b/src/plugins/pixi/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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const Tools = pixi_mod.Tools;
+
+pub fn visible() bool {
+ return runtime.state().tools.radial_menu.visible;
+}
+
+pub fn processHoldOpenInput() void {
+ const rm = &runtime.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(runtime.state().tools.radial_menu.center);
+ const tool_count: usize = std.meta.fields(Tools.Tool).len;
+ const radius: f32 = 50.0;
+ const width: f32 = radius * 2.0;
+ 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 = runtime.state().host.uiAtlas();
+
+ for (0..tool_count) |i| {
+ var anim = dvui.animate(@src(), .{ .duration = 100_000 + 50_000 * @as(i32, @intCast(i)), .kind = .alpha, .easing = dvui.easing.linear }, .{
+ .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 (runtime.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 == runtime.state().tools.current) dvui.themeGet().color(.content, .fill) else .transparent,
+ .box_shadow = if (tool == runtime.state().tools.current) .{
+ .color = .black,
+ .offset = .{ .x = -2.5, .y = 2.5 },
+ .fade = 4.0,
+ .alpha = 0.25,
+ .corner_radius = dvui.Rect.all(1000),
+ } else null,
+ .padding = .all(0),
+ .margin = .all(0),
+ });
+
+ runtime.state().tools.drawTooltip(tool, button.data().rectScale().r, i) catch {};
+
+ const selection_sprite = switch (runtime.state().tools.selection_mode) {
+ .box => ui_atlas.sprites[pixi_mod.atlas.sprites.box_selection_default],
+ .pixel => ui_atlas.sprites[pixi_mod.atlas.sprites.pixel_selection_default],
+ .color => ui_atlas.sprites[pixi_mod.atlas.sprites.color_selection_default],
+ };
+
+ const sprite = switch (tool) {
+ .pointer => ui_atlas.sprites[pixi_mod.atlas.sprites.cursor_default],
+ .pencil => ui_atlas.sprites[pixi_mod.atlas.sprites.pencil_default],
+ .eraser => ui_atlas.sprites[pixi_mod.atlas.sprites.eraser_default],
+ .bucket => ui_atlas.sprites[pixi_mod.atlas.sprites.bucket_default],
+ .selection => selection_sprite,
+ };
+
+ 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()) {
+ runtime.state().tools.set(tool);
+ }
+ if (button.clicked()) {
+ runtime.state().tools.set(tool);
+ runtime.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 (runtime.state().host.activeDoc()) |doc| {
+ if (runtime.state().docs.fileById(doc.id)) |file| {
+ if (dvui.buttonIcon(@src(), "Play", if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, .{}, .{}, .{
+ .expand = .none,
+ .corner_radius = dvui.Rect.all(1000),
+ .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 (runtime.state().tools.radial_menu.opened_by_press) {
+ runtime.state().tools.radial_menu.close();
+ }
+ }
+ }
+ }
+}
diff --git a/src/gfx/render.zig b/src/plugins/pixi/src/render.zig
similarity index 92%
rename from src/gfx/render.zig
rename to src/plugins/pixi/src/render.zig
index 3631ee62..4ce6bfe9 100644
--- a/src/gfx/render.zig
+++ b/src/plugins/pixi/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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const perf = pixi_mod.perf;
/// Monotonic frame counter, incremented once per frame from Editor.tick.
pub var frame_index: u64 = 0;
pub const RenderFileOptions = struct {
- file: *fizzy.Internal.File,
+ file: *pixi_mod.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,
+ pixi_mod.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,
+ pixi_mod.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.editor.explorer.tools.layersHovered()) {
+ } else if (!runtime.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: *pixi_mod.internal.File, buf: []usize) ?[]const usize {
const r = file.editor.layer_drag_preview_removed orelse return null;
const ins = file.editor.layer_drag_preview_insert_before orelse return null;
if (file.layers.len == 0 or file.layers.len > buf.len) return null;
- fizzy.Internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]);
+ pixi_mod.internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]);
return buf[0..file.layers.len];
}
@@ -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(runtime.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(runtime.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade });
+ defer triangles.deinit(runtime.allocator());
triangles.uvFromRectuv(init_opts.rs.r, init_opts.uv);
var dimmed_triangles: ?dvui.Triangles = null;
defer {
- if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator);
+ if (dimmed_triangles) |*dt| dt.deinit(runtime.allocator());
}
if (vs.needs_dimmed) {
- var dt = try triangles.dupe(fizzy.app.allocator);
+ var dt = try triangles.dupe(runtime.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: *pixi_mod.internal.File) struct { w: u32, h: u32 } {
const c = file.canvasPixelSize();
if (c.w > 0 and c.h > 0) return .{ .w = c.w, .h = c.h };
const w = file.width();
@@ -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: *pixi_mod.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: *pixi_mod.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: *pixi_mod.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: *pixi_mod.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(runtime.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(runtime.allocator(), .{ .color = .white, .fade = 0 });
+ defer tris.deinit(runtime.allocator());
tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 });
var order_buf: [1024]usize = undefined;
@@ -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: *pixi_mod.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(runtime.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(runtime.allocator(), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 0 });
+ defer tris.deinit(runtime.allocator());
dvui.renderTriangles(tris, null) catch {};
}
// 2) Checkerboard tile — one tile per sprite cell (uv repeats columns × rows).
if (file.checkerboardTileTexture()) |checker| {
- var path: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path: dvui.Path.Builder = .init(runtime.allocator());
defer path.deinit();
path.addRect(image_rect, dvui.Rect.Physical.all(0));
const tint = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5);
- var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = tint, .fade = 0 });
- defer tris.deinit(fizzy.app.allocator);
+ var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = tint, .fade = 0 });
+ defer tris.deinit(runtime.allocator());
tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = @floatFromInt(file.columns), .h = @floatFromInt(file.rows) });
dvui.renderTriangles(tris, checker) catch {};
}
// 3) Flattened layers, then selection + temp overlays — sampled 1:1.
- var path: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path: dvui.Path.Builder = .init(runtime.allocator());
defer path.deinit();
path.addRect(image_rect, dvui.Rect.Physical.all(0));
- var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0 });
- defer tris.deinit(fizzy.app.allocator);
+ var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = .white, .fade = 0 });
+ defer tris.deinit(runtime.allocator());
tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 });
if (file.editor.layer_composite_target) |ct| {
@@ -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: *pixi_mod.internal.File) ?dvui.Texture {
if (file.peek_layer_index != null) return null;
if (file.editor.isolate_layer) return null;
if (file.editor.transform != null) return null;
@@ -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: *pixi_mod.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: *pixi_mod.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(runtime.allocator());
defer qpath.deinit();
qpath.addPoint(q[0]);
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 pixi_mod.sprite_render.pathToSubdividedQuad(qpath.build(), runtime.allocator(), .{
.subdivisions = init_opts.quad_subdivisions,
.uv = init_opts.uv,
.color_mod = init_opts.color_mod,
});
} else blk: {
- var path: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path: dvui.Path.Builder = .init(runtime.allocator());
defer path.deinit();
path.addRect(content_rs.r, init_opts.corner_radius.scale(content_rs.s, dvui.Rect.Physical));
- var t = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = init_opts.color_mod, .fade = init_opts.fade });
+ var t = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade });
t.uvFromRectuv(content_rs.r, init_opts.uv);
break :blk t;
};
- defer triangles.deinit(fizzy.app.allocator);
+ defer triangles.deinit(runtime.allocator());
var dimmed_triangles: ?dvui.Triangles = null;
defer {
- if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator);
+ if (dimmed_triangles) |*dt| dt.deinit(runtime.allocator());
}
if (needs_dimmed) {
- var dt = try triangles.dupe(fizzy.app.allocator);
+ var dt = try triangles.dupe(runtime.allocator());
dt.color(.gray);
dimmed_triangles = dt;
}
diff --git a/src/plugins/pixi/src/runtime.zig b/src/plugins/pixi/src/runtime.zig
new file mode 100644
index 00000000..47f28cef
--- /dev/null
+++ b/src/plugins/pixi/src/runtime.zig
@@ -0,0 +1,35 @@
+//! Runtime accessors — backed by `sdk.runtime` and shell-owned state.
+const std = @import("std");
+const sdk = @import("sdk");
+const State = @import("State.zig");
+const Packer = @import("Packer.zig");
+
+var shell_state: ?*State = null;
+
+/// Static embed: App creates state and calls this before `postInit`.
+pub fn adoptShellState(st: *State) void {
+ shell_state = st;
+}
+
+pub fn allocator() std.mem.Allocator {
+ return sdk.allocator();
+}
+
+pub fn host() *sdk.Host {
+ return sdk.host();
+}
+
+pub fn state() *State {
+ if (shell_state) |s| return s;
+ if (sdk.injectedState(State)) |s| return s;
+ const pl = sdk.host().pluginById("pixi") orelse @panic("pixi plugin not registered");
+ return @ptrCast(@alignCast(pl.state));
+}
+
+pub fn packer() *Packer {
+ return state().packer orelse @panic("pixi packer not wired");
+}
+
+pub fn setPacker(p: *Packer) void {
+ if (shell_state) |s| s.packer = p;
+}
diff --git a/src/plugins/pixi/src/sprite_render.zig b/src/plugins/pixi/src/sprite_render.zig
new file mode 100644
index 00000000..b59a639f
--- /dev/null
+++ b/src/plugins/pixi/src/sprite_render.zig
@@ -0,0 +1,694 @@
+//! 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
+//! `pixi_mod.core_sprite.draw` from core instead of this module.
+const std = @import("std");
+const dvui = @import("dvui");
+const pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+
+pub const SpriteInitOptions = struct {
+ source: dvui.ImageSource,
+ file: ?*pixi_mod.internal.File = null,
+ alpha_source: ?dvui.ImageSource = null,
+ sprite: pixi_mod.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 = pixi_mod.water_surface.reflection_surface_cols;
+
+/// Reflection-only waterline sample across the card width (logical px). `cols_dx`
+/// is horizontal refraction from surface slope; `cols_dy` is vertical height at
+/// 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| pixi_mod.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 = pixi_mod.render.RenderFileOptions{
+ .file = file,
+ .rs = .{
+ .r = wd.contentRectScale().r,
+ .s = wd.contentRectScale().s,
+ },
+ .uv = uv,
+ .corner_radius = .all(0),
+ };
+ pixi_mod.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| {
+ dvui.log.err("Failed to render reflection layer stack: {any}", .{err});
+ };
+
+ 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| {
+ pixi_mod.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: pixi_mod.core_sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void {
+ const atlas_size = dvui.imageSize(source) catch {
+ std.log.err("Failed to get atlas size", .{});
+ return;
+ };
+
+ 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/pixi/src/transform_op.zig b/src/plugins/pixi/src/transform_op.zig
new file mode 100644
index 00000000..193c6d94
--- /dev/null
+++ b/src/plugins/pixi/src/transform_op.zig
@@ -0,0 +1,123 @@
+//! Begin a transform on the active document (selection → transform handles).
+const dvui = @import("dvui");
+const pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+const State = pixi_mod.State;
+const Internal = pixi_mod.internal;
+
+fn activeFile(st: *State) ?*Internal.File {
+ const doc = st.host.activeDoc() orelse return null;
+ 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 = runtime.allocator();
+ file.editor.transform = .{
+ .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ dvui.log.err("Failed to create target texture", .{});
+ return;
+ },
+ .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 = pixi_mod.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/pixi/src/web_file_io.zig b/src/plugins/pixi/src/web_file_io.zig
new file mode 100644
index 00000000..2e009273
--- /dev/null
+++ b/src/plugins/pixi/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 pixi_mod = @import("../pixi.zig");
+const runtime = @import("runtime.zig");
+
+fn downloadNameWithExtension(allocator: std.mem.Allocator, filename: []const u8, ext: []const u8) ![]const u8 {
+ if (std.ascii.eqlIgnoreCase(std.fs.path.extension(filename), ext)) {
+ 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(runtime.allocator(), filename, ext);
+ defer runtime.allocator().free(name);
+ try downloadBytes(name, data);
+}
diff --git a/src/plugins/pixi/src/widgets/CanvasBridge.zig b/src/plugins/pixi/src/widgets/CanvasBridge.zig
new file mode 100644
index 00000000..3218788a
--- /dev/null
+++ b/src/plugins/pixi/src/widgets/CanvasBridge.zig
@@ -0,0 +1,24 @@
+//! 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 pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
+const CanvasWidget = pixi_mod.core.dvui.CanvasWidget;
+
+/// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum.
+pub fn scheme() CanvasWidget.PanZoomScheme {
+ return switch (pixi_mod.Settings.resolvedPanZoomScheme(&runtime.state().settings, runtime.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 pixi_mod.core.dvui.canvasPointerInputSuppressed();
+}
+
+/// Suppression hook for a dialog-scope canvas (embedded previews like Grid Layout).
+pub fn dialogSuppressed(_: ?*anyopaque) bool {
+ return pixi_mod.core.dvui.dialogCanvasPointerInputSuppressed();
+}
diff --git a/src/editor/widgets/FileWidget.zig b/src/plugins/pixi/src/widgets/FileWidget.zig
similarity index 94%
rename from src/editor/widgets/FileWidget.zig
rename to src/plugins/pixi/src/widgets/FileWidget.zig
index f9a6989c..88637f46 100644
--- a/src/editor/widgets/FileWidget.zig
+++ b/src/plugins/pixi/src/widgets/FileWidget.zig
@@ -1,9 +1,7 @@
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;
const Options = dvui.Options;
const Rect = dvui.Rect;
@@ -16,8 +14,39 @@ const ScrollContainerWidget = dvui.ScrollContainerWidget;
const ScaleWidget = dvui.ScaleWidget;
pub const FileWidget = @This();
-const CanvasWidget = @import("CanvasWidget.zig");
+const CanvasWidget = pixi_mod.core.dvui.CanvasWidget;
+const CanvasBridge = @import("CanvasBridge.zig");
+const CanvasData = @import("../CanvasData.zig");
+const DocLifecycle = @import("../doc_lifecycle.zig");
const icons = @import("icons");
+const pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
+
+// ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is
+// otherwise a generic viewport; these supply the editor's behavior at install time. ----
+
+/// Off-artboard tap (no move, no hold) → clear the current selection.
+fn onEmptyTap(_: ?*anyopaque) void {
+ DocLifecycle.cancelEdit(runtime.state());
+}
+
+/// Off-artboard hold past the hold-menu duration → open the radial tool menu at the press
+/// point. The canvas releases its own capture afterward so the menu buttons can be hovered.
+fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void {
+ const rm = &runtime.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 runtime.state().tools.current == .pointer;
+}
init_options: InitOptions,
options: Options,
@@ -47,7 +76,7 @@ const SpriteReorderMode = enum {
};
pub const InitOptions = struct {
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
center: bool = false,
};
@@ -78,6 +107,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;
@@ -205,7 +241,7 @@ pub fn processSample(self: *FileWidget) void {
/// Set `file.peek_layer_index` to the visible layer with an opaque pixel at `point`, mirroring
/// `sampleColorAtPoint`'s selection rule (bottommost match wins). Called every frame while the
/// sample key is held so other layers dim like during layer-list hover.
-pub fn peekLayerAtPoint(file: *fizzy.Internal.File, point: dvui.Point) void {
+pub fn peekLayerAtPoint(file: *pixi_mod.internal.File, point: dvui.Point) void {
if (file.editor.isolate_layer) return;
var layer_index: usize = file.layers.len;
@@ -225,7 +261,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: *pixi_mod.internal.File,
point: dvui.Point,
change_layer: bool,
apply_primary: bool,
@@ -237,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 (!runtime.state().tools_pane.layersHovered()) {
min_layer_index = file.selected_layer_index;
}
}
@@ -257,7 +293,7 @@ pub fn sampleColorAtPoint(
// Sample acts as a focused layer-pick: narrow multi-selection to just this layer
// so the ctrl modifier (also the layer-list multi-select toggle) doesn't accumulate.
file.editor.selected_layer_indices.clearRetainingCapacity();
- file.editor.selected_layer_indices.append(fizzy.app.allocator, layer_index) catch {};
+ file.editor.selected_layer_indices.append(runtime.allocator(), layer_index) catch {};
file.editor.layer_selection_anchor = layer_index;
}
}
@@ -271,27 +307,27 @@ pub fn sampleColorAtPoint(
if (off_canvas) {
// Sampling the empty margin outside the artboard isn't an erase — drop back
// to the pointer tool so the click reads as "leave drawing mode".
- if (fizzy.editor.tools.current != .pointer) {
- fizzy.editor.tools.set(.pointer);
+ if (runtime.state().tools.current != .pointer) {
+ runtime.state().tools.set(.pointer);
}
} else if (color[3] == 0) {
- if (fizzy.editor.tools.current != .eraser) {
- fizzy.editor.tools.set(.eraser);
+ if (runtime.state().tools.current != .eraser) {
+ runtime.state().tools.set(.eraser);
}
} else {
- fizzy.editor.colors.primary = color;
- if (switch (fizzy.editor.tools.current) {
+ runtime.state().colors.primary = color;
+ if (switch (runtime.state().tools.current) {
.pencil, .bucket => false,
else => true,
})
- fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool);
+ runtime.state().tools.set(runtime.state().tools.previous_drawing_tool);
}
} else if (apply_primary and color[3] > 0) {
- fizzy.editor.colors.primary = color;
+ runtime.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: *pixi_mod.internal.File, point: dvui.Point, screen_p: dvui.Point.Physical, change_layer: bool, change_tool: bool) void {
if (!file.editor.canvas.samplePointerInViewport(screen_p)) {
self.sample_data_point = null;
return;
@@ -313,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 (runtime.state().tools.current != .pointer and self.sample_data_point == null)) {
if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| {
var found: bool = false;
for (file.animations.items(.frames), 0..) |frames, anim_index| {
@@ -342,7 +378,7 @@ pub fn processAnimationSelection(self: *FileWidget) void {
}
pub fn processCellReorder(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .pointer) return;
+ if (runtime.state().tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
if (self.drag_data_point != null) return;
@@ -408,12 +444,12 @@ pub fn processCellReorder(self: *FileWidget) void {
if (self.removed_sprite_indices) |removed_sprite_indices| {
if (self.insert_before_sprite_indices) |insert_before_sprite_indices| {
- fizzy.app.allocator.free(insert_before_sprite_indices);
+ runtime.allocator().free(insert_before_sprite_indices);
self.insert_before_sprite_indices = null;
}
// This will actually trigger the drag/drop
- var insert_before_sprite_indices = fizzy.app.allocator.alloc(usize, file.editor.selected_sprites.count()) catch {
+ var insert_before_sprite_indices = runtime.allocator().alloc(usize, file.editor.selected_sprites.count()) catch {
dvui.log.err("Failed to allocate insert before sprite indices", .{});
return;
};
@@ -438,11 +474,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 = runtime.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 = runtime.allocator().dupe(usize, insert_before_sprite_indices) catch {
dvui.log.err("Failed to duplicate insert before sprite indices", .{});
return;
},
@@ -466,7 +502,7 @@ pub fn processCellReorder(self: *FileWidget) void {
dvui.cursorSet(.hand);
defer e.handle(@src(), file.editor.canvas.scroll_container.data());
if (self.removed_sprite_indices == null and file.editor.selected_sprites.count() > 0) {
- var removed_sprite_indices = fizzy.app.allocator.alloc(usize, file.editor.selected_sprites.count()) catch {
+ var removed_sprite_indices = runtime.allocator().alloc(usize, file.editor.selected_sprites.count()) catch {
dvui.log.err("Failed to allocate removed sprite indices", .{});
return;
};
@@ -493,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 (runtime.state().tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
@@ -568,9 +604,7 @@ pub fn processSpriteSelection(self: *FileWidget) void {
file.editor.primary_sprite_index = sprite_index;
}
} else if (!file.editor.canvas.hovered) {
- fizzy.editor.cancel() catch {
- dvui.log.err("Failed to cancel", .{});
- };
+ DocLifecycle.cancelEdit(runtime.state());
}
}
@@ -641,18 +675,29 @@ const BubblePanShared = struct {
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 runtime.state().canvas_by_grouping.get(self.init_options.file.editor.grouping);
+}
+
+/// True while a column or row is mid-drag in this pane's rulers.
+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.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.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 = fizzy.editor.tools.current != .pointer;
+ const tool_not_pointer = runtime.state().tools.current != .pointer;
const mod_shift = cw.modifiers.matchBind("shift");
const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd");
const sample_active = self.sample_data_point != null;
@@ -824,10 +869,10 @@ pub fn drawSpriteBubbles(self: *FileWidget) void {
const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id;
const cw = dvui.currentWindow();
const drag_sprite_selection = dvui.dragName("sprite_selection_drag");
- const tool_not_pointer = fizzy.editor.tools.current != .pointer;
+ const tool_not_pointer = runtime.state().tools.current != .pointer;
const mod_shift = cw.modifiers.matchBind("shift");
const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd");
- const radial_visible = fizzy.editor.tools.radial_menu.visible;
+ const radial_visible = runtime.state().tools.radial_menu.visible;
const sample_active = self.sample_data_point != null;
const canvas_gesturing = self.init_options.file.editor.canvas.trackpadPinching() or
self.init_options.file.editor.canvas.gestureActive();
@@ -1043,7 +1088,7 @@ fn bubbleSpriteDataRect(col_in_row: usize, base_y: f32, col_w: f32, row_h: f32)
/// When `accs` is null and `shadow_only` is false, only UI elements are drawn.
fn drawSpriteBubbleForRow(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
sprite_index: usize,
sprite_rect: dvui.Rect,
accs: ?*BubbleAccs,
@@ -1080,7 +1125,7 @@ fn drawSpriteBubbleForRow(
if (animation_index) |ai| {
const id = file.animations.get(ai).id;
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (runtime.state().colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(@intCast(id));
}
if (file.selected_animation_index == ai) {
@@ -1386,7 +1431,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 (runtime.state().colors.file_tree_palette) |*palette| {
if (self.init_options.file.selected_animation_index) |index| {
border_color = palette.getDVUIColor(@intCast(self.init_options.file.animations.get(index).id));
add_rem_message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{self.init_options.file.animations.get(index).name}) catch {
@@ -1489,7 +1534,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(pixi_mod.Animation.Frame).init(runtime.allocator());
frames.appendSlice(anim.frames) catch {
dvui.log.err("Failed to append frames", .{});
return false;
@@ -1566,7 +1611,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 = runtime.allocator().dupe(pixi_mod.Animation.Frame, anim.frames) catch {
dvui.log.err("Failed to dupe frames", .{});
return false;
},
@@ -1575,7 +1620,7 @@ pub fn drawSpriteBubble(
dvui.log.err("Failed to append history", .{});
};
- fizzy.app.allocator.free(anim.frames);
+ runtime.allocator().free(anim.frames);
anim.frames = frames.toOwnedSlice() catch {
dvui.log.err("Failed to free frames", .{});
return false;
@@ -1588,12 +1633,12 @@ pub fn drawSpriteBubble(
self.init_options.file.selected_animation_index = anim_index;
self.init_options.file.collapseAnimationSelectionToPrimary();
self.init_options.file.editor.animations_scroll_to_index = anim_index;
- fizzy.editor.explorer.sprites.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index];
- fizzy.editor.explorer.pane = .sprites;
+ runtime.state().sprites_pane.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index];
+ runtime.state().host.setActiveSidebarView(@import("../plugin.zig").view_sprites);
var anim = self.init_options.file.animations.get(anim_index);
if (anim.frames.len == 0) {
- anim.appendFrame(fizzy.app.allocator, .{ .sprite_index = sprite_index, .ms = temp_ms }) catch {
+ anim.appendFrame(runtime.allocator(), .{ .sprite_index = sprite_index, .ms = temp_ms }) catch {
dvui.log.err("Failed to append frame", .{});
return false;
};
@@ -1727,7 +1772,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 (runtime.state().tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
@@ -1857,8 +1902,8 @@ fn strokePolylineDashedPhysical(
}
fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .selection) return;
- if (fizzy.editor.tools.selection_mode != .box) return;
+ if (runtime.state().tools.current != .selection) return;
+ if (runtime.state().tools.selection_mode != .box) return;
const start = self.drag_data_point orelse return;
if (dvui.dragging(dvui.currentWindow().mouse_pt, "stroke_drag") == null) return;
@@ -1903,8 +1948,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: *pixi_mod.internal.File,
+ active_layer: *const pixi_mod.internal.Layer,
start: dvui.Point,
end: dvui.Point,
mod: dvui.enums.Mod,
@@ -1947,7 +1992,7 @@ fn applySelectionBoxPreview(
/// This selection is pixel-based, and includes shift/ctrl/cmd modifiers to support add/remove.
/// The selection uses the same logic as the stroke tool to brush the selection over existing pixels.
pub fn processSelection(self: *FileWidget) void {
- if (switch (fizzy.editor.tools.current) {
+ if (switch (runtime.state().tools.current) {
.selection,
=> false,
else => true,
@@ -1970,7 +2015,7 @@ pub fn processSelection(self: *FileWidget) void {
// Pixel mode: draw the committed selection before handling events (brush preview layers on top).
// Box mode: skip — the mask is updated on mouse release in the same frame as this paint; drawing
// here would use stale data until the next frame. Box repaints from the current mask after events.
- if (fizzy.editor.tools.selection_mode == .pixel or fizzy.editor.tools.selection_mode == .color) {
+ if (runtime.state().tools.selection_mode == .pixel or runtime.state().tools.selection_mode == .color) {
@memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 });
file.editor.temporary_layer.clearMask();
@@ -1990,21 +2035,21 @@ pub fn processSelection(self: *FileWidget) void {
switch (e.evt) {
.key => |ke| {
var update: bool = false;
- if (fizzy.editor.tools.selection_mode == .pixel) {
+ if (runtime.state().tools.selection_mode == .pixel) {
if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.editor.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1)
- fizzy.editor.tools.stroke_size += 1;
+ if (runtime.state().tools.stroke_size < pixi_mod.Tools.max_brush_size - 1)
+ runtime.state().tools.stroke_size += 1;
- fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size);
+ runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size);
e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
update = true;
}
if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.editor.tools.stroke_size > 1)
- fizzy.editor.tools.stroke_size -= 1;
+ if (runtime.state().tools.stroke_size > 1)
+ runtime.state().tools.stroke_size -= 1;
- fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size);
+ runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size);
e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
update = true;
}
@@ -2027,7 +2072,7 @@ pub fn processSelection(self: *FileWidget) void {
.temporary,
.{
.mask_only = true,
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = runtime.state().tools.stroke_size,
},
);
@@ -2045,8 +2090,8 @@ pub fn processSelection(self: *FileWidget) void {
const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p);
if (me.action == .position) {
- const box_mode = fizzy.editor.tools.selection_mode == .box;
- const color_mode = fizzy.editor.tools.selection_mode == .color;
+ const box_mode = runtime.state().tools.selection_mode == .box;
+ const color_mode = runtime.state().tools.selection_mode == .color;
const is_drag = dvui.dragging(me.p, "stroke_drag") != null;
const box_drag = box_mode and is_drag and self.drag_data_point != null;
@@ -2097,7 +2142,7 @@ pub fn processSelection(self: *FileWidget) void {
.temporary,
.{
.mask_only = true,
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = runtime.state().tools.stroke_size,
},
);
@@ -2128,7 +2173,7 @@ pub fn processSelection(self: *FileWidget) void {
if (!widget_active) continue;
e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- if (fizzy.editor.tools.selection_mode == .color) {
+ if (runtime.state().tools.selection_mode == .color) {
// Only clear the mask if we don't have ctrl/cmd pressed
if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift"))
file.editor.selection_layer.clearMask();
@@ -2146,14 +2191,14 @@ pub fn processSelection(self: *FileWidget) void {
if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift"))
file.editor.selection_layer.clearMask();
- if (fizzy.editor.tools.selection_mode == .box) {
+ if (runtime.state().tools.selection_mode == .box) {
self.drag_data_point = current_point;
} else {
file.selectPoint(
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = runtime.state().tools.stroke_size,
},
);
@@ -2166,23 +2211,23 @@ pub fn processSelection(self: *FileWidget) void {
dvui.captureMouse(null, e.num);
dvui.dragEnd();
- if (fizzy.editor.tools.selection_mode == .box) {
+ if (runtime.state().tools.selection_mode == .box) {
if (self.drag_data_point) |start| {
file.selectRectBetweenPoints(
start,
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = runtime.state().tools.stroke_size,
},
);
}
- } else if (fizzy.editor.tools.selection_mode != .color) {
+ } else if (runtime.state().tools.selection_mode != .color) {
file.selectPoint(
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = runtime.state().tools.stroke_size,
},
);
}
@@ -2214,14 +2259,14 @@ pub fn processSelection(self: *FileWidget) void {
});
}
- if (fizzy.editor.tools.selection_mode == .pixel) {
+ if (runtime.state().tools.selection_mode == .pixel) {
if (self.drag_data_point) |previous_point| {
file.selectLine(
previous_point,
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = runtime.state().tools.stroke_size,
},
);
}
@@ -2236,7 +2281,7 @@ pub fn processSelection(self: *FileWidget) void {
}
}
- if (fizzy.editor.tools.selection_mode == .box) {
+ if (runtime.state().tools.selection_mode == .box) {
const mouse_pt = dvui.currentWindow().mouse_pt;
const is_drag = dvui.dragging(mouse_pt, "stroke_drag") != null;
if (!(is_drag and self.drag_data_point != null)) {
@@ -2258,7 +2303,7 @@ pub fn processSelection(self: *FileWidget) void {
fn processStrokeDragSegment(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
previous_point: dvui.Point,
current_point: dvui.Point,
screen_pt: dvui.Point.Physical,
@@ -2319,7 +2364,7 @@ fn processStrokeDragSegment(
.stroke_size = stroke_size,
},
);
- fizzy.perf.draw_event_count += 1;
+ pixi_mod.perf.draw_event_count += 1;
} else |err| {
dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err});
}
@@ -2334,7 +2379,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 (runtime.state().tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
file.drawPoint(
current_point,
.temporary,
@@ -2355,12 +2400,12 @@ fn processStrokeDragSegment(
/// Supports using shift to draw a line between two points, and increasing/decreasing stroke size
pub fn processStroke(self: *FileWidget) void {
const file = self.init_options.file;
- const stroke_size = fizzy.editor.tools.stroke_size;
+ const stroke_size = runtime.state().tools.stroke_size;
const widget_active = self.active();
if (self.cell_reorder_point != null) return;
- if (switch (fizzy.editor.tools.current) {
+ if (switch (runtime.state().tools.current) {
.pencil,
.eraser,
=> false,
@@ -2369,8 +2414,8 @@ pub fn processStroke(self: *FileWidget) void {
if (self.sample_key_down or self.right_mouse_down) return;
- const color: [4]u8 = switch (fizzy.editor.tools.current) {
- .pencil => fizzy.editor.colors.primary,
+ const color: [4]u8 = switch (runtime.state().tools.current) {
+ .pencil => runtime.state().colors.primary,
.eraser => [_]u8{ 0, 0, 0, 0 },
else => unreachable,
};
@@ -2515,7 +2560,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 (runtime.state().tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
file.drawPoint(
current_point,
.temporary,
@@ -2541,10 +2586,10 @@ pub fn processStroke(self: *FileWidget) void {
/// Supports using ctrl/cmd to replace all existing pixels of the same color with the new color,
/// or without modifiers to flood fill the layer with the new color.
pub fn processFill(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .bucket) return;
+ if (runtime.state().tools.current != .bucket) return;
if (self.sample_key_down) return;
const file = self.init_options.file;
- const color = fizzy.editor.colors.primary;
+ const color = runtime.state().colors.primary;
const widget_active = self.active();
// Skip the cursor-follow temp preview on touch: the finger occludes the pixel and
@@ -2554,7 +2599,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 (runtime.state().tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
const fill_preview_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
file.drawPoint(
fill_preview_pt,
@@ -2627,7 +2672,7 @@ pub fn processTransform(self: *FileWidget) void {
triangles.rotate(.{ .x = transform.point(.pivot).x, .y = transform.point(.pivot).y }, transform.rotation);
for (transform.data_points[0..6], 0..) |*data_point, point_index| {
- const transform_point = @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index));
+ const transform_point = @as(pixi_mod.Transform.TransformPoint, @enumFromInt(point_index));
const screen_point = if (point_index < 4) file.editor.canvas.screenFromDataPoint(.{ .x = triangles.vertexes[point_index].pos.x, .y = triangles.vertexes[point_index].pos.y }) else file.editor.canvas.screenFromDataPoint(data_point.*);
var screen_rect = dvui.Rect.Physical.fromPoint(screen_point);
@@ -2644,7 +2689,7 @@ pub fn processTransform(self: *FileWidget) void {
if (screen_rect.contains(dvui.currentWindow().mouse_pt)) {
dvui.cursorSet(.hand);
} else if (transform.active_point) |active_point| {
- if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) {
+ if (active_point == @as(pixi_mod.Transform.TransformPoint, @enumFromInt(point_index))) {
dvui.cursorSet(.hand);
}
}
@@ -2739,7 +2784,7 @@ pub fn processTransform(self: *FileWidget) void {
new_point.y = @round(new_point.y);
// Now we have to un-rotate the vertex and set the original location
- new_point = fizzy.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation);
+ new_point = pixi_mod.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation);
const opposite_index: usize = switch (point_index) {
0 => 2,
@@ -2790,8 +2835,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 = pixi_mod.math.rotate(dvui.Point{ .x = 1, .y = 0 }, transform.point(.pivot).*, 0);
+ var rotation_perp: dvui.Point = pixi_mod.math.rotate(dvui.Point{ .x = 0, .y = 1 }, transform.point(.pivot).*, 0);
// Calculate the difference between the adjacent points and the new point
@@ -2845,7 +2890,7 @@ pub fn processTransform(self: *FileWidget) void {
transform.rotation = std.math.degreesToRadians(@round(std.math.radiansToDegrees(transform.start_rotation + (angle - drag_angle))));
if (me.mod.matchBind("ctrl/cmd")) { // Lock rotation to cardinal directions
- const direction = fizzy.math.Direction.fromRadians(transform.rotation);
+ const direction = pixi_mod.math.Direction.fromRadians(transform.rotation);
transform.rotation = switch (direction) {
.n => std.math.pi / 2.0,
.ne => std.math.pi / 4.0,
@@ -2983,7 +3028,7 @@ pub fn drawTransform(self: *FileWidget) void {
}
var centroid = transform.centroid();
- centroid = fizzy.math.rotate(centroid, transform.point(.pivot).*, transform.rotation);
+ centroid = pixi_mod.math.rotate(centroid, transform.point(.pivot).*, transform.rotation);
// Full-sprite center guides (magenta). When ortho cell dimensions are shown, centering is
// indicated on those dimension lines (blue) instead — avoids overlapping magenta guides.
@@ -3111,7 +3156,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;
@@ -3254,7 +3299,7 @@ pub fn drawTransform(self: *FileWidget) void {
const bottom_left_v = triangles.vertexes[3].pos;
const bottom_right_v = triangles.vertexes[2].pos;
- const offset_v = fizzy.math.rotate(
+ const offset_v = pixi_mod.math.rotate(
dvui.Point{ .x = label_off_screen, .y = 0 },
.{ .x = 0, .y = 0 },
transform.rotation,
@@ -3266,7 +3311,7 @@ pub fn drawTransform(self: *FileWidget) void {
const simple_v = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(inner_h_f)))}) catch "—";
renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v));
- const offset_h = fizzy.math.rotate(
+ const offset_h = pixi_mod.math.rotate(
dvui.Point{ .x = 0, .y = -label_off_screen },
.{ .x = 0, .y = 0 },
transform.rotation,
@@ -3283,7 +3328,7 @@ pub fn drawTransform(self: *FileWidget) void {
const bottom_left = triangles.vertexes[3].pos;
const bottom_right = triangles.vertexes[2].pos;
- const offset_v = fizzy.math.rotate(
+ const offset_v = pixi_mod.math.rotate(
dvui.Point{ .x = label_off_screen, .y = 0 },
.{ .x = 0, .y = 0 },
transform.rotation,
@@ -3299,7 +3344,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 = pixi_mod.math.rotate(
dvui.Point{ .x = 0, .y = -label_off_screen },
.{ .x = 0, .y = 0 },
transform.rotation,
@@ -3399,7 +3444,7 @@ pub fn drawTransform(self: *FileWidget) void {
var color = dvui.themeGet().color(.window, .text);
if (transform.active_point) |active_point| {
- if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) {
+ if (active_point == @as(pixi_mod.Transform.TransformPoint, @enumFromInt(point_index))) {
color = dvui.themeGet().color(.highlight, .fill);
}
} else if (screen_rect.contains(dvui.currentWindow().mouse_pt)) {
@@ -3509,7 +3554,7 @@ fn doubleStrokeDimensionTickColor(points: []const dvui.Point.Physical, thickness
/// axis-aligned quad (4 vertices, 2 triangles) submitted via one `renderTriangles`.
fn drawBatchedGridLines(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
columns: usize,
rows: usize,
grid_color: dvui.Color,
@@ -3635,7 +3680,7 @@ fn appendLineQuad(builder: *dvui.Triangles.Builder, tl: dvui.Point.Physical, br:
}
/// Viewport in data space + row/column index range for culling (matches bubble / grid logic).
-fn fileCanvasVisibleGridParams(file: *fizzy.Internal.File) ?struct {
+fn fileCanvasVisibleGridParams(file: *pixi_mod.internal.File) ?struct {
visible_data: dvui.Rect,
row_h: f32,
col_w: f32,
@@ -3722,7 +3767,7 @@ fn appendHorizontalGridRunsForRow(
/// Batches grid lines for the resize-shrink overlay (original layer_rect shown in error tint).
fn drawBatchedResizeOverlayGrid(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
columns: usize,
layer_rect: dvui.Rect,
grid_thickness: f32,
@@ -3809,8 +3854,8 @@ fn checkerboardVertexColor(
}
/// Animation color for transparency tint; matches bubble arc palette lookup order (selected animation first, else first containing animation).
-fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color {
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+fn spriteAnimationPaletteColor(file: *pixi_mod.internal.File, sprite_index: usize) ?dvui.Color {
+ if (runtime.state().colors.file_tree_palette) |*palette| {
var animation_index: ?usize = null;
if (file.selected_animation_index) |selected_animation_index| {
@@ -3842,8 +3887,8 @@ fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize)
}
fn checkerboardCellCornerColor(
- effect: fizzy.Editor.Settings.TransparencyEffect,
- file: *fizzy.Internal.File,
+ effect: pixi_mod.Settings.TransparencyEffect,
+ file: *pixi_mod.internal.File,
sprite_index: usize,
c_tl: dvui.Color,
c_tr: dvui.Color,
@@ -3884,10 +3929,10 @@ fn checkerboardGridPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr:
}
/// Same tint as the batched checkerboard for the cell under `sprite_index` (center UV), for bubbles etc.
-fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index: usize) dvui.Color {
+fn checkerboardTintAtSpriteCellCenter(file: *pixi_mod.internal.File, sprite_index: usize) dvui.Color {
const pal = checkerboardGridPalette();
const tone = pal.tone;
- switch (fizzy.editor.settings.transparency_effect) {
+ switch (runtime.state().settings.transparency_effect) {
.none => return tone,
.rainbow => {
const mu_mv = dvui.dataGet(null, file.editor.canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 };
@@ -3906,11 +3951,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: *pixi_mod.internal.File) void {
const n = file.spriteCount();
if (n == 0) return;
- const te = fizzy.editor.settings.transparency_effect;
+ const te = runtime.state().settings.transparency_effect;
const pal = checkerboardGridPalette();
const tone = pal.tone;
const rs = file.editor.canvas.screen_rect_scale;
@@ -4009,7 +4054,7 @@ fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void {
}
pub fn active(self: *FileWidget) bool {
- if (fizzy.editor.activeFile()) |file| {
+ if (runtime.state().docs.activeFile(runtime.state().host)) |file| {
if (file.id == self.init_options.file.id) {
return true;
}
@@ -4018,9 +4063,9 @@ 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 (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return;
+ if (runtime.state().tools.current == .pointer and self.sample_data_point == null) return;
+ if (runtime.state().tools.radial_menu.visible) return;
if (self.init_options.file.editor.transform != null) return;
if (self.init_options.file.editor.canvas.gestureActive()) return;
if (self.init_options.file.editor.canvas.trackpadPinching()) return;
@@ -4059,20 +4104,20 @@ pub fn drawCursor(self: *FileWidget) void {
const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point);
- const selection_sprite = switch (fizzy.editor.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],
+ const selection_sprite = switch (runtime.state().tools.selection_mode) {
+ .box => if (subtract) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_rem_default] else if (add) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_add_default] else runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_default],
+ .pixel => if (subtract) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_rem_default] else if (add) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_add_default] else runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_default],
+ .color => if (subtract) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_rem_default] else if (add) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_add_default] else runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_default],
};
- if (switch (fizzy.editor.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],
+ if (switch (runtime.state().tools.current) {
+ .pencil => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pencil_default],
+ .eraser => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.eraser_default],
+ .bucket => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.bucket_default],
.selection => selection_sprite,
else => null,
}) |sprite| {
- const atlas_size = dvui.imageSize(fizzy.editor.atlas.source) catch {
+ const atlas_size = dvui.imageSize(runtime.state().host.uiAtlas().source) catch {
dvui.log.err("Failed to get atlas size", .{});
return;
};
@@ -4110,7 +4155,7 @@ pub fn drawCursor(self: *FileWidget) void {
const rs = box.data().rectScale();
- dvui.renderImage(fizzy.editor.atlas.source, rs, .{
+ dvui.renderImage(runtime.state().host.uiAtlas().source, rs, .{
.uv = uv,
}) catch {
dvui.log.err("Failed to render cursor image", .{});
@@ -4161,7 +4206,7 @@ fn mapDataRectToPhysicalStrip(sr: dvui.Rect, parent_data: dvui.Rect, parent_phys
/// Draw the checkerboard alpha pattern into `dest_phys`. Uses wrap=.repeat on the tile texture so
/// the entire region is one quad with UV scaled so each `cw × ch` of data space spans one tile.
fn drawSampleMagnifierCheckerboardTiles(
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
region_data: dvui.Rect,
dest_phys: dvui.Rect.Physical,
scale: f32,
@@ -4188,7 +4233,7 @@ fn drawSampleMagnifierCheckerboardTiles(
/// Build checkerboard + layers into an offscreen target. Layer composites are synced on the screen
/// target first so `renderLayers` does not rebind this target via `syncLayerComposite`.
fn drawSampleMagnifierCompositeBuild(
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
region_data: dvui.Rect,
content_rs: dvui.RectScale,
file_w: f32,
@@ -4200,18 +4245,18 @@ fn drawSampleMagnifierCompositeBuild(
const h: u32 = @intFromFloat(@max(@ceil(content_rs.r.h), 1));
const layer_region = region_data.intersect(dvui.Rect{ .x = 0, .y = 0, .w = file_w, .h = file_h });
- const layer_opts_base = fizzy.render.RenderFileOptions{
+ const layer_opts_base = pixi_mod.render.RenderFileOptions{
.file = file,
.rs = content_rs,
.allow_peek = false,
};
// Refresh cached layer composites on the screen target (not the magnifier target).
- fizzy.render.ensureLayerCompositesForPreview(layer_opts_base) catch {
+ pixi_mod.render.ensureLayerCompositesForPreview(layer_opts_base) catch {
dvui.log.err("Failed to sync layer composites for magnifier", .{});
};
- const target = dvui.textureCreateTarget(.{ .width = w, .height = h, .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ const target = dvui.textureCreateTarget(.{ .width = w, .height = h, .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
dvui.log.err("Failed to create magnifier composite target", .{});
return null;
};
@@ -4237,7 +4282,7 @@ fn drawSampleMagnifierCompositeBuild(
.w = layer_region.w / file_w,
.h = layer_region.h / file_h,
};
- fizzy.render.renderLayersMagnifierSample(.{
+ pixi_mod.render.renderLayersMagnifierSample(.{
.file = file,
.rs = .{ .r = layer_phys, .s = 1.0 },
.uv = uv_rect,
@@ -4338,9 +4383,9 @@ fn drawSampleMagnifierPresent(
} }, .{ .thickness = 2, .color = .black });
}
-pub fn drawSampleMagnifier(file: *fizzy.Internal.File, data_point: dvui.Point) void {
+pub fn drawSampleMagnifier(file: *pixi_mod.internal.File, data_point: dvui.Point) void {
const canvas = &file.editor.canvas;
- if (fizzy.dvui.canvasPointerInputSuppressed()) return;
+ if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return;
if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return;
_ = dvui.cursorSet(.hidden);
@@ -4438,8 +4483,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 = pixi_mod.perf.drawLayersBegin();
+ defer pixi_mod.perf.drawLayersEnd(perf_t0);
var file = self.init_options.file;
var columns: usize = file.columns;
@@ -4509,11 +4554,11 @@ 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.columnRowReorderActive()) {
self.drawColumnRowReorderPreview();
return;
} else {
- fizzy.render.renderLayers(.{
+ pixi_mod.render.renderLayers(.{
.file = file,
.rs = .{
.r = self.init_options.file.editor.canvas.rect,
@@ -4565,14 +4610,14 @@ 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 (runtime.state().tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) {
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
const sprite_rect = file.spriteRect(i);
const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect);
// Draw the origins when in the sprites pane
- if (fizzy.editor.explorer.pane == .sprites) {
+ if (runtime.state().host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) {
const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] };
const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y };
@@ -4605,7 +4650,7 @@ const ReorderAxis = enum { columns, rows };
/// Checkerboard alpha over each cell of the floating column/row, matching `drawCheckerboardCellsBatched` tint/UVs at half opacity.
fn drawCheckerboardReorderFloatingStrip(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
removed_data_rect: dvui.Rect,
strip_phys: dvui.Rect.Physical,
axis: ReorderAxis,
@@ -4635,7 +4680,7 @@ fn drawCheckerboardReorderFloatingStrip(
const c_tr = pal.c_tr;
const c_bl = pal.c_bl;
const c_br = pal.c_br;
- const te = fizzy.editor.settings.transparency_effect;
+ const te = runtime.state().settings.transparency_effect;
const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0);
const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0);
@@ -4709,17 +4754,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 cd = self.canvasData() orelse return;
+ if (cd.columns_drag_index == null and cd.rows_drag_index == null) return;
- const axis: ReorderAxis = if (workspace.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 => workspace.columns_target_index,
- .rows => workspace.rows_target_index,
+ .columns => cd.columns_target_index,
+ .rows => cd.rows_target_index,
};
const removed_index = switch (axis) {
- .columns => workspace.columns_drag_index,
- .rows => workspace.rows_drag_index,
+ .columns => cd.columns_drag_index,
+ .rows => cd.rows_drag_index,
} orelse return;
self.drawReorderPreviewForAxis(file, axis, target_index, removed_index);
@@ -4727,7 +4772,7 @@ fn drawColumnRowReorderPreview(self: *FileWidget) void {
fn renderLayersInDataRect(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
data_rect: dvui.Rect,
screen_rect_override: ?dvui.Rect.Physical,
) void {
@@ -4735,7 +4780,7 @@ fn renderLayersInDataRect(
const w = @as(f32, @floatFromInt(file.width()));
const h = @as(f32, @floatFromInt(file.height()));
const r = screen_rect_override orelse file.editor.canvas.screenFromDataRect(data_rect);
- fizzy.render.renderLayers(.{
+ pixi_mod.render.renderLayers(.{
.file = file,
.rs = .{ .r = r, .s = scale },
.uv = .{
@@ -4749,7 +4794,7 @@ fn renderLayersInDataRect(
fn reorderSegmentRects(
axis: ReorderAxis,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
target_index: usize,
removed_index: usize,
target_rect: dvui.Rect,
@@ -4823,7 +4868,7 @@ fn reorderSegmentRects(
fn drawReorderPreviewForAxis(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixi_mod.internal.File,
axis: ReorderAxis,
target_index: ?usize,
removed_index: usize,
@@ -4973,10 +5018,10 @@ fn drawReorderPreviewForAxis(
});
{
- fizzy.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{
+ pixi_mod.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{
.opacity = 0.5,
});
- fizzy.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{
+ pixi_mod.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{
.opacity = 0.5,
});
}
@@ -5234,22 +5279,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 });
+ pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .left, .{ .opacity = 0.35 });
}
}
if (right_index) |right_index_value| {
if (!temp_selected_sprite.isSet(right_index_value)) {
- fizzy.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 });
+ pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 });
}
}
if (top_index) |top_index_value| {
if (!temp_selected_sprite.isSet(top_index_value)) {
- fizzy.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 });
+ pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 });
}
}
if (bottom_index) |bottom_index_value| {
if (!temp_selected_sprite.isSet(bottom_index_value)) {
- fizzy.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 });
+ pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 });
}
}
}
@@ -5429,7 +5474,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 (runtime.state().tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
@@ -5629,7 +5674,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.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| {
@@ -5711,7 +5756,7 @@ pub fn processEvents(self: *FileWidget) void {
const canvas_ptr = &self.init_options.file.editor.canvas;
const mouse_pt = dvui.currentWindow().mouse_pt;
- canvas_ptr.hovered = !fizzy.dvui.canvasPointerInputSuppressed() and
+ canvas_ptr.hovered = !pixi_mod.core.dvui.canvasPointerInputSuppressed() and
canvas_ptr.pointerOverDrawable(mouse_pt);
// Cursor-leave: when hover transitions true → false, the last brush/fill preview
@@ -5753,18 +5798,18 @@ pub fn processEvents(self: *FileWidget) void {
// current single touch will become one — otherwise the bucket/pencil hover preview would
// flash on the pinned finger as the user starts a pan gesture.
if (self.hovered() and !self.init_options.file.editor.canvas.gestureActive()) {
- const pe_t0 = fizzy.perf.processEventsBegin();
- defer fizzy.perf.processEventsEnd(pe_t0);
+ const pe_t0 = pixi_mod.perf.processEventsBegin();
+ defer pixi_mod.perf.processEventsEnd(pe_t0);
resetTempLayerPreview(&self.init_options.file.editor);
{
- const mask_t0 = fizzy.perf.updateMaskBegin();
- defer fizzy.perf.updateMaskEnd(mask_t0);
+ const mask_t0 = pixi_mod.perf.updateMaskBegin();
+ defer pixi_mod.perf.updateMaskEnd(mask_t0);
self.updateActiveLayerMask();
}
- if (fizzy.editor.tools.current == .selection) {
+ if (runtime.state().tools.current == .selection) {
if (dvui.timerDoneOrNone(self.init_options.file.editor.canvas.scroll_container.data().id)) {
self.init_options.file.editor.checkerboard.toggleAll();
@@ -5773,14 +5818,14 @@ 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) {
+ const tool_t0 = pixi_mod.perf.toolProcessBegin();
+ switch (runtime.state().tools.current) {
.bucket => self.processFill(),
.pencil, .eraser => self.processStroke(),
.selection => self.processSelection(),
else => {},
}
- fizzy.perf.toolProcessEnd(tool_t0);
+ pixi_mod.perf.toolProcessEnd(tool_t0);
}
} else if (self.hovered() and self.init_options.file.editor.canvas.gestureActive()) {
// A 2-finger gesture (or its pending evaluation) just took over. Make sure any
@@ -5835,10 +5880,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, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .top, .{ .opacity = 0.15 });
+ pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .bottom, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .left, .{ .opacity = 0.15 });
+ pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .right, .{});
self.drawTransform();
self.processSample();
@@ -5857,7 +5902,7 @@ pub fn deinit(self: *FileWidget) void {
}
pub fn hovered(self: *FileWidget) bool {
- if (fizzy.dvui.canvasPointerInputSuppressed()) return false;
+ if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return false;
return self.init_options.file.editor.canvas.hovered;
}
@@ -5911,7 +5956,7 @@ fn tempBrushRect(point: dvui.Point, stroke_size: usize, img_w: u32, img_h: u32)
}
/// Data-space rect of the on-screen canvas, outset by brush size so edge stamps are not clipped.
-fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const fizzy.Internal.File, stroke_size: usize) dvui.Rect {
+fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const pixi_mod.internal.File, stroke_size: usize) dvui.Rect {
const vis = canvas.dataFromScreenRect(canvas.rect);
const m: f32 = @floatFromInt(stroke_size);
const inflated = vis.outsetAll(m);
@@ -5920,7 +5965,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: *pixi_mod.internal.File.EditorData, rect: dvui.Rect) void {
if (editor.temp_gpu_dirty_rect) |existing| {
editor.temp_gpu_dirty_rect = existing.unionWith(rect);
} else {
@@ -5934,10 +5979,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: *pixi_mod.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);
+ pixi_mod.image.clearRect(editor.temporary_layer.source, dirty);
expandTempGpuDirtyRect(editor, dirty);
}
}
@@ -5945,10 +5990,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: *pixi_mod.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);
+ pixi_mod.image.clearRect(editor.temporary_layer.source, dirty);
expandTempGpuDirtyRect(editor, dirty);
}
editor.temp_preview_dirty_rect = null;
diff --git a/src/editor/widgets/ImageWidget.zig b/src/plugins/pixi/src/widgets/ImageWidget.zig
similarity index 92%
rename from src/editor/widgets/ImageWidget.zig
rename to src/plugins/pixi/src/widgets/ImageWidget.zig
index 12e8ed47..9448eb42 100644
--- a/src/editor/widgets/ImageWidget.zig
+++ b/src/plugins/pixi/src/widgets/ImageWidget.zig
@@ -1,5 +1,6 @@
pub const ImageWidget = @This();
-const CanvasWidget = @import("CanvasWidget.zig");
+const CanvasWidget = pixi_mod.core.dvui.CanvasWidget;
+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;
@@ -141,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 (pixi_mod.image.pixelIndex(self.init_options.source, point)) |index| {
+ const c = pixi_mod.image.pixels(self.init_options.source)[index];
if (c[3] > 0) {
color = c;
}
}
- fizzy.editor.colors.primary = color;
+ runtime.state().colors.primary = color;
self.sample_data_point = point;
if (color[3] == 0) {
- if (fizzy.editor.tools.current != .eraser) {
- fizzy.editor.tools.set(.eraser);
+ if (runtime.state().tools.current != .eraser) {
+ runtime.state().tools.set(.eraser);
}
} else {
- fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool);
+ runtime.state().tools.set(runtime.state().tools.previous_drawing_tool);
}
}
pub fn drawCursor(self: *ImageWidget) void {
- if (fizzy.dvui.canvasPointerInputSuppressed()) return;
+ if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return;
for (dvui.events()) |*e| {
if (!self.init_options.canvas.scroll_container.matchEvent(e)) {
continue;
@@ -204,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 (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return;
if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return;
_ = dvui.cursorSet(.hidden);
@@ -265,7 +268,7 @@ pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data
});
defer fw.deinit();
- const size = fizzy.image.size(source);
+ const size = pixi_mod.image.size(source);
const uv_rect = dvui.Rect{
.x = (data_point.x - sample_region_size / 2) / size.w,
.y = (data_point.y - sample_region_size / 2) / size.h,
@@ -316,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 (runtime.packer().atlas) |atlas| return atlas.checkerboard_tile;
return null;
}
@@ -382,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 `pixi_mod.render.renderLayers` against the cached `canvas.rect`
// for the same reason.
dvui.renderImage(self.init_options.source, .{
.r = self.init_options.canvas.rect,
@@ -431,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 });
+ pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .top, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .bottom, .{ .opacity = 0.15 });
+ pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .left, .{});
+ pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .right, .{ .opacity = 0.15 });
self.drawCursor();
self.drawSample();
@@ -466,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 pixi_mod = @import("../../pixi.zig");
+const runtime = @import("../runtime.zig");
test {
@import("std").testing.refAllDecls(@This());
diff --git a/src/plugins/pixi/static/integration.zig b/src/plugins/pixi/static/integration.zig
new file mode 100644
index 00000000..c88440b7
--- /dev/null
+++ b/src/plugins/pixi/static/integration.zig
@@ -0,0 +1,134 @@
+//! Pixi plugin — fizzy-internal static-embed + bundled-dylib module graph.
+//! Runs only from the fizzy build root, so paths are single fizzy-relative literals.
+//!
+//! The vendored `zstbi`/`msf_gif` modules are built via the reusable `fizzy.plugin.addCModule`
+//! helper (same one a third-party C plugin uses, and the one pixi's own standalone `build.zig`
+//! calls) — so the build *logic* lives in one place. `zip` keeps its purpose-built
+//! `src/deps/zip/build.zig` (a distinct "C into the consumer + wasm libc shim" pattern).
+const std = @import("std");
+const helpers = @import("../../shared/build/helpers.zig");
+const fizzy_plugin = @import("../../../../plugin_sdk.zig");
+const zip_mod = @import("../src/deps/zip/build.zig");
+
+pub const id = "pixi";
+pub const installDylib = helpers.installDylib;
+
+const deps_root = "src/plugins/pixi/src/deps";
+
+pub const ZipPackage = zip_mod.Package;
+pub fn zipPackage(b: *std.Build) ZipPackage {
+ return zip_mod.package(b, .{});
+}
+pub fn linkZipNative(exe: *std.Build.Step.Compile) void {
+ zip_mod.link(exe);
+}
+pub fn linkZipWasm(exe: *std.Build.Step.Compile) void {
+ zip_mod.linkWasm(exe);
+}
+
+pub fn addZstbiModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ web: bool,
+) *std.Build.Module {
+ const web_cflags = [_][]const u8{ "-DSTBI_NO_STDLIB=1", "-DSTBI_NO_SIMD=1" };
+ const c_sources = if (web) &[_]fizzy_plugin.CSourceFile{
+ .{ .file = b.path(deps_root ++ "/stbi/zstbi.c"), .flags = &web_cflags },
+ .{ .file = b.path(deps_root ++ "/stbi/fizzy_stbi_libc.c"), .flags = &web_cflags },
+ } else &[_]fizzy_plugin.CSourceFile{
+ .{ .file = b.path(deps_root ++ "/stbi/zstbi.c") },
+ };
+ return fizzy_plugin.addCModule(b, .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path(deps_root ++ "/stbi/zstbi.zig"),
+ .c_sources = c_sources,
+ .link_libc = !web,
+ .single_threaded = web,
+ });
+}
+
+pub fn addMsfGifModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ web: bool,
+) *std.Build.Module {
+ const web_cflags = [_][]const u8{"-I" ++ deps_root ++ "/msf_gif/wasm_shim"};
+ const c_sources = if (web) &[_]fizzy_plugin.CSourceFile{
+ .{ .file = b.path(deps_root ++ "/msf_gif/fizzy_msf_gif_wasm.c"), .flags = &web_cflags },
+ } else &[_]fizzy_plugin.CSourceFile{
+ .{ .file = b.path(deps_root ++ "/msf_gif/msf_gif.c") },
+ };
+ return fizzy_plugin.addCModule(b, .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path(deps_root ++ "/msf_gif/msf_gif.zig"),
+ .c_sources = c_sources,
+ .link_libc = !web,
+ .single_threaded = web,
+ });
+}
+
+const module_path = "src/plugins/pixi/pixi.zig";
+const dylib_path = "src/plugins/pixi/root.zig";
+
+pub const ModuleImports = struct {
+ dvui: *std.Build.Module,
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+ proxy_bridge: ?*std.Build.Module = null,
+ assets: *std.Build.Module,
+ zip: *std.Build.Module,
+ zstbi: *std.Build.Module,
+ msf_gif: *std.Build.Module,
+ icons: ?*std.Build.Module = null,
+ backend: ?*std.Build.Module = null,
+};
+
+fn applyImports(module: *std.Build.Module, imports: ModuleImports) void {
+ module.addImport("dvui", imports.dvui);
+ module.addImport("core", imports.core);
+ module.addImport("sdk", imports.sdk);
+ if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge);
+ module.addImport("assets", imports.assets);
+ module.addImport("zip", imports.zip);
+ module.addImport("zstbi", imports.zstbi);
+ module.addImport("msf_gif", imports.msf_gif);
+ if (imports.icons) |icons| module.addImport("icons", icons);
+ if (imports.backend) |backend| module.addImport("backend", backend);
+}
+
+pub fn addStaticModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+ consumer: *std.Build.Module,
+) *std.Build.Module {
+ const mod = helpers.addStaticModule(b, .{
+ .import_name = id,
+ .root_source_file = b.path(module_path),
+ .target = target,
+ .optimize = optimize,
+ }, consumer);
+ applyImports(mod, imports);
+ return mod;
+}
+
+pub fn addDylib(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+) *std.Build.Step.Compile {
+ const lib = helpers.addDylib(b, .{
+ .name = id,
+ .root_source_file = b.path(dylib_path),
+ .target = target,
+ .optimize = optimize,
+ });
+ applyImports(lib.root_module, imports);
+ return lib;
+}
diff --git a/src/plugins/shared/build/helpers.zig b/src/plugins/shared/build/helpers.zig
new file mode 100644
index 00000000..3551f235
--- /dev/null
+++ b/src/plugins/shared/build/helpers.zig
@@ -0,0 +1,93 @@
+//! Fizzy-internal build helpers for the static-embed + bundled-dylib graph of built-in
+//! plugins. These always run from the fizzy build root, so every path is a single
+//! fizzy-relative `b.path(...)` — there is no plugin-package root to disambiguate.
+//! Third-party plugins never touch this; they use `fizzy.plugin.create` / `.install`.
+const std = @import("std");
+
+/// C-ABI entry symbols the host looks up. Kept in sync with `plugin_sdk.dylib_exports`
+/// (the third-party path); duplicated here to avoid a deep relative import.
+pub const dylib_exports = [_][]const u8{
+ "fizzy_plugin_abi_fingerprint",
+ "fizzy_plugin_sdk_version",
+ "fizzy_plugin_min_sdk_version",
+ "fizzy_plugin_version",
+ "fizzy_plugin_id",
+ "fizzy_plugin_register",
+ "fizzy_plugin_set_dvui_context",
+ "fizzy_plugin_set_render_bridge",
+ "fizzy_plugin_set_globals",
+};
+
+pub const StaticModuleOptions = struct {
+ import_name: []const u8,
+ root_source_file: std.Build.LazyPath,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ options_name: ?[]const u8 = null,
+ options: ?*std.Build.Step.Options = null,
+};
+
+pub fn addStaticModule(
+ b: *std.Build,
+ opts: StaticModuleOptions,
+ consumer: *std.Build.Module,
+) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = opts.root_source_file,
+ .link_libc = opts.target.result.cpu.arch != .wasm32,
+ .single_threaded = opts.target.result.cpu.arch == .wasm32,
+ });
+ if (opts.options_name) |name| {
+ if (opts.options) |o| mod.addOptions(name, o);
+ }
+ consumer.addImport(opts.import_name, mod);
+ return mod;
+}
+
+pub const DylibOptions = struct {
+ name: []const u8,
+ root_source_file: std.Build.LazyPath,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ options_name: ?[]const u8 = null,
+ options: ?*std.Build.Step.Options = null,
+};
+
+pub fn addDylib(
+ b: *std.Build,
+ opts: DylibOptions,
+) *std.Build.Step.Compile {
+ const dylib_module = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = opts.root_source_file,
+ .link_libc = true,
+ });
+ if (opts.options_name) |name| {
+ if (opts.options) |o| dylib_module.addOptions(name, o);
+ }
+ const lib = b.addLibrary(.{
+ .name = opts.name,
+ .linkage = .dynamic,
+ .root_module = dylib_module,
+ });
+ lib.linker_allow_shlib_undefined = true;
+ lib.root_module.export_symbol_names = &dylib_exports;
+ return lib;
+}
+
+pub fn installDylib(b: *std.Build, lib: *std.Build.Step.Compile, name: []const u8) void {
+ const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) {
+ .windows => "dll",
+ .macos => "dylib",
+ else => "so",
+ };
+ const dest = b.fmt("{s}.{s}", .{ name, ext });
+ const install_step = b.addInstallArtifact(lib, .{
+ .dest_dir = .{ .override = .prefix },
+ .dest_sub_path = dest,
+ });
+ b.getInstallStep().dependOn(&install_step.step);
+}
diff --git a/src/plugins/workbench/build.zig b/src/plugins/workbench/build.zig
new file mode 100644
index 00000000..fe3d88a5
--- /dev/null
+++ b/src/plugins/workbench/build.zig
@@ -0,0 +1,34 @@
+//! Standalone build for the workbench plugin — the canonical third-party shape.
+//! `cd src/plugins/workbench && zig build` produces `workbench.`. The
+//! `-Dworkbench-file-tree` option feeds a `workbench_opts` module the plugin imports;
+//! attaching a build-options module to a `fizzy.plugin.create` lib is exactly how any
+//! third-party plugin would expose compile-time flags. See docs/PLUGINS.md.
+const std = @import("std");
+const fizzy = @import("fizzy");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const file_tree = b.option(
+ bool,
+ "workbench-file-tree",
+ "Register the Files sidebar view at compile time",
+ ) orelse true;
+ const workbench_opts = b.addOptions();
+ workbench_opts.addOption(bool, "file_tree", file_tree);
+
+ const lib = fizzy.plugin.create(b, .{
+ .name = "workbench",
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("root.zig"),
+ });
+ lib.root_module.addOptions("workbench_opts", workbench_opts);
+
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ lib.root_module.addImport("icons", dep.module("icons"));
+ }
+
+ fizzy.plugin.install(b, lib, .{});
+}
diff --git a/src/plugins/workbench/build.zig.zon b/src/plugins/workbench/build.zig.zon
new file mode 100644
index 00000000..9e850490
--- /dev/null
+++ b/src/plugins/workbench/build.zig.zon
@@ -0,0 +1,24 @@
+.{
+ .name = .workbench,
+ .version = "0.0.0",
+ .minimum_zig_version = "0.16.0",
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "root.zig",
+ "workbench.zig",
+ "src",
+ "static",
+ },
+ .fingerprint = 0xc23e2206858de248,
+ .dependencies = .{
+ .fizzy = .{
+ .path = "../../..",
+ },
+ .icons = .{
+ .url = "https://github.com/foxnne/zig-lib-icons/archive/db034786a1286ab28dc35aba534c098aa4f1a3aa.tar.gz",
+ .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_",
+ .lazy = true,
+ },
+ },
+}
diff --git a/src/plugins/workbench/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/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig
new file mode 100644
index 00000000..eee291d7
--- /dev/null
+++ b/src/plugins/workbench/src/FileLoadJob.zig
@@ -0,0 +1,147 @@
+//! 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: 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()`.
+//! - `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 `doc_buf`/`err` fields.
+
+const std = @import("std");
+const wb = @import("../workbench.zig");
+const dvui = wb.dvui;
+const perf = wb.perf;
+const sdk = wb.sdk;
+
+const FileLoadJob = @This();
+
+pub const Phase = enum(u8) {
+ queued = 0,
+ reading = 1,
+ ready = 2,
+ failed = 3,
+ cancelled = 4,
+};
+
+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).
+owner: *sdk.Plugin,
+
+/// Workspace grouping the file should land in once loaded.
+target_grouping: u64,
+
+window: *dvui.Window,
+started_at_ns: i128,
+
+phase: std.atomic.Value(u8) = .init(@intFromEnum(Phase.queued)),
+progress_num: std.atomic.Value(u32) = .init(0),
+progress_den: std.atomic.Value(u32) = .init(0),
+cancelled: std.atomic.Value(bool) = .init(false),
+done: std.atomic.Value(bool) = .init(false),
+
+/// Plugin-document staging buffer (size/align from `owner.documentStackSize/Align`).
+doc_slab: []u8,
+doc_buf: []u8,
+
+err: ?anyerror = null,
+
+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);
+
+ 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,
+ .owner = owner,
+ .target_grouping = target_grouping,
+ .window = dvui.currentWindow(),
+ .started_at_ns = perf.nanoTimestamp(),
+ .doc_slab = staging.backing,
+ .doc_buf = staging.buf,
+ };
+ return job;
+}
+
+pub fn destroy(job: *FileLoadJob) void {
+ const a = job.allocator;
+ a.free(job.path);
+ a.free(job.doc_slab);
+ a.destroy(job);
+}
+
+pub fn workerMain(job: *FileLoadJob) void {
+ defer {
+ job.done.store(true, .release);
+ dvui.refresh(job.window, @src(), null);
+ }
+
+ if (job.cancelled.load(.monotonic)) {
+ job.phase.store(@intFromEnum(Phase.cancelled), .release);
+ return;
+ }
+
+ job.phase.store(@intFromEnum(Phase.reading), .release);
+
+ const handled = job.owner.loadDocument(job.path, job.doc_buf.ptr) catch |e| {
+ job.err = e;
+ job.phase.store(@intFromEnum(Phase.failed), .release);
+ return;
+ };
+ if (!handled) {
+ job.err = error.InvalidFile;
+ job.phase.store(@intFromEnum(Phase.failed), .release);
+ return;
+ }
+
+ if (job.cancelled.load(.monotonic)) {
+ job.owner.deinitDocumentBuffer(job.doc_buf.ptr);
+ job.phase.store(@intFromEnum(Phase.cancelled), .release);
+ return;
+ }
+
+ job.phase.store(@intFromEnum(Phase.ready), .release);
+}
+
+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;
+}
+
+pub fn currentPhase(job: *const FileLoadJob) Phase {
+ const raw = job.phase.load(.acquire);
+ return switch (raw) {
+ 0 => .queued,
+ 1 => .reading,
+ 2 => .ready,
+ 3 => .failed,
+ 4 => .cancelled,
+ else => .queued,
+ };
+}
+
+pub fn phaseLabel(phase: Phase) []const u8 {
+ return switch (phase) {
+ .queued => "Queued",
+ .reading => "Reading",
+ .ready => "Done",
+ .failed => "Failed",
+ .cancelled => "Cancelled",
+ };
+}
diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig
new file mode 100644
index 00000000..ba813ed2
--- /dev/null
+++ b/src/plugins/workbench/src/Workbench.zig
@@ -0,0 +1,230 @@
+//! 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
+//! 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 files = @import("files.zig");
+const Workspace = @import("Workspace.zig");
+const runtime = @import("runtime.zig");
+const workbench_layout = @import("workbench_layout.zig");
+const sdk = @import("sdk");
+
+pub const Api = sdk.services.workbench.Api;
+pub const BranchDecorator = Api.BranchDecorator;
+
+pub const Workbench = @This();
+
+allocator: std.mem.Allocator,
+decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty,
+
+/// 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,
+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),
+/// 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 };
+}
+
+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 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);
+}
+
+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 runtime.host().docByIndex(workspace.open_file_index);
+ }
+ return null;
+}
+
+pub fn setActiveDocIndex(self: *Workbench, index: usize) void {
+ 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;
+ 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. `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
+/// 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 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", .{
+ .stroke_color = dvui.themeGet().color(.window, .text),
+ }, .{
+ .gravity_x = 1.0,
+ .gravity_y = 0.5,
+ .padding = dvui.Rect.all(2),
+ .id_extra = id_extra,
+ });
+}
+
+// ============================================================================
+// workbench-api — the formal Host service (layout defined in sdk/services/workbench.zig)
+// ============================================================================
+
+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 hostOf(ctx: *anyopaque) *sdk.Host {
+ return @ptrCast(@alignCast(ctx));
+}
+
+fn svcOpen(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool {
+ return hostOf(ctx).openFilePath(path, grouping);
+}
+fn svcCurrentGrouping(_: *anyopaque) u64 {
+ return runtime.workbench().currentGroupingID();
+}
+fn svcNewGrouping(_: *anyopaque) u64 {
+ return runtime.workbench().newGroupingID();
+}
+fn svcClose(ctx: *anyopaque, id: u64) anyerror!void {
+ return hostOf(ctx).closeDocById(id);
+}
+fn svcSave(ctx: *anyopaque) anyerror!void {
+ return hostOf(ctx).save();
+}
+fn svcIsOpen(ctx: *anyopaque, path: []const u8) bool {
+ return hostOf(ctx).docFromPath(path) != null;
+}
+fn svcOpenCount(ctx: *anyopaque) usize {
+ return hostOf(ctx).openDocCount();
+}
+fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 {
+ 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);
+}
+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(_: *anyopaque, decorator: BranchDecorator) anyerror!void {
+ return runtime.workbench().registerBranchDecorator(decorator);
+}
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
new file mode 100644
index 00000000..96f19304
--- /dev/null
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -0,0 +1,1054 @@
+const std = @import("std");
+const builtin = @import("builtin");
+
+const wb = @import("../workbench.zig");
+const dvui = wb.dvui;
+const wdvui = wb.wdvui;
+const sdk = wb.sdk;
+const runtime = @import("runtime.zig");
+const icons = @import("icons");
+
+/// 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.
+/// Workspaces can potentially draw open files, the project logo, or the project pane
+/// containing the packed atlas.
+pub const Workspace = @This();
+
+open_file_index: usize = 0,
+grouping: u64 = 0,
+center: bool = false,
+
+tabs_drag_index: ?usize = null,
+tabs_removed_index: ?usize = null,
+tabs_insert_before_index: ?usize = 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
+/// 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,
+
+pub fn init(grouping: u64) Workspace {
+ return .{ .grouping = grouping };
+}
+
+/// 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 (runtime.host().plugins.items) |plugin| {
+ plugin.removeCanvasPane(self.grouping, runtime.allocator());
+ }
+}
+
+const handle_size = 10;
+const handle_dist = 60;
+
+const opacity = 60;
+
+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]wb.math.Color = [_]wb.math.Color{
+ color_1, color_1, color_1,
+ color_2, color_2, color_3,
+ color_4, color_3, color_0,
+ color_3, color_0, color_0,
+};
+
+var dragging: bool = false;
+
+pub fn draw(self: *Workspace) !dvui.App.Result {
+ // Canvas Area
+ var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .both,
+ .gravity_y = 0.0,
+ .id_extra = @intCast(self.grouping),
+ });
+ defer vbox.deinit();
+
+ // Set the active workspace grouping when the user clicks on the workspace rect
+ for (dvui.events()) |*e| {
+ if (!vbox.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"))) {
+ runtime.workbench().open_workspace_grouping = self.grouping;
+ }
+ }
+ }
+
+ // 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 = runtime.host().activeSidebarView();
+ if (active != null and active.?.draw_workspace != null) {
+ 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();
+ }
+
+ return .ok;
+}
+
+/// 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. Delegates to `sdk.pane_layout` for a single definition.
+pub fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget {
+ return sdk.pane_layout.mainCanvasVbox(content_color, background, grouping);
+}
+
+/// 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 sdk.pane_layout.emptyStateCard(content_color, grouping);
+}
+
+fn drawTabs(self: *Workspace) void {
+ if (runtime.host().openDocCount() == 0) return;
+
+ // Handle dragging of tabs between workspace reorderables (tab bars)
+ defer self.processTabsDrag();
+
+ {
+ var tabs_anim = dvui.animate(@src(), .{ .duration = 500_000, .kind = .vertical, .easing = dvui.easing.outBack }, .{});
+ defer tabs_anim.deinit();
+
+ 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,
+ .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),
+ });
+ defer scroll_area.deinit();
+
+ {
+ var tabs = dvui.reorder(@src(), .{ .drag_name = "tab_drag" }, .{
+ .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 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 (runtime.workbench().open_workspace_grouping != self.grouping) break :blk false;
+ if (self.open_file_index >= files_len) 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;
+ };
+
+ if (active_in_this_group) {
+ const active_index = self.open_file_index;
+
+ var j: usize = active_index;
+ while (j > 0) {
+ j -= 1;
+ const tab_doc = runtime.host().docByIndex(j) orelse continue;
+ if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) {
+ prev_same_group_index = j;
+ break;
+ }
+ }
+
+ j = active_index + 1;
+ while (j < files_len) : (j += 1) {
+ const tab_doc = runtime.host().docByIndex(j) orelse continue;
+ if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) {
+ next_same_group_index = j;
+ break;
+ }
+ }
+ }
+
+ for (0..files_len) |i| {
+ const doc = runtime.host().docByIndex(i) orelse continue;
+ const is_fizzy_file = doc.owner.documentHasNativeExtension(doc);
+
+ if (doc.owner.documentGrouping(doc) != 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 = 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 = 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 = .{ .x = 2, .y = 2, .w = 2, .h = 0 },
+ .margin = dvui.Rect.all(0),
+ });
+
+ defer hbox.deinit();
+
+ const tab_hovered = wdvui.hovered(hbox.data());
+
+ if (reorderable.floating()) {
+ self.tabs_drag_index = i;
+ hbox.data().options.color_fill = dvui.themeGet().color(.control, .fill);
+ }
+ hbox.drawBackground();
+
+ if (!selected and active_in_this_group and tabs.drag_point == null) {
+ // Draw edge shadow between the active tab and its neighbours within this grouping.
+ if (prev_same_group_index) |prev_index| {
+ if (i == prev_index) {
+ // This tab is directly to the left of the active tab.
+ 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.
+ wdvui.drawEdgeShadow(hbox.data().rectScale(), .left, .{});
+ }
+ }
+ }
+
+ if (reorderable.removed()) {
+ self.tabs_removed_index = i;
+ } else if (reorderable.insertBefore()) {
+ self.tabs_insert_before_index = i;
+ }
+
+ if (is_fizzy_file) {
+ const ui_atlas = runtime.host().uiAtlas();
+ const ui_sprite = ui_atlas.sprites[wb.atlas.sprites.logo_default];
+ const logo_sprite = wb.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source };
+ _ = wb.Sprite.draw(logo_sprite, @src(), ui_atlas.source, 2.0, .{
+ .gravity_y = 0.5,
+ .padding = dvui.Rect.all(4),
+ });
+ } else {
+ dvui.icon(@src(), "file_icon", icons.tvg.lucide.file, .{
+ .stroke_color = if (is_fizzy_file) .transparent else dvui.themeGet().color(.control, .text),
+ }, .{
+ .gravity_y = 0.5,
+ .padding = dvui.Rect.all(4),
+ });
+ }
+
+ 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,
+ });
+
+ const close_inner = wdvui.windowHeaderCloseInnerSide();
+
+ const status_close_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .none,
+ .gravity_y = 0.5,
+ .margin = dvui.Rect.all(0),
+ .padding = wdvui.tab_status_inset,
+ .min_size_content = .{ .w = close_inner, .h = close_inner },
+ });
+ defer status_close_box.deinit();
+
+ // Saving has priority over hover/close/dirty indicators: the user wants visible
+ // confirmation that the save is in flight, and the slot's size matches the close
+ // 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 = doc.owner.timeSinceSaveCompleteNs(doc);
+ const save_in_check_phase = if (save_flash_elapsed) |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) {
+ wdvui.bubbleSpinner(@src(), .{
+ .id_extra = i *% 16 + 5,
+ .expand = .none,
+ .min_size_content = .{ .w = close_inner, .h = close_inner },
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .color_text = dvui.themeGet().color(.window, .text),
+ }, .{
+ .complete_elapsed_ns = save_flash_elapsed,
+ });
+ } else if (save_in_check_phase and !tab_hovered) {
+ wdvui.bubbleSpinner(@src(), .{
+ .id_extra = i *% 16 + 5,
+ .expand = .none,
+ .min_size_content = .{ .w = close_inner, .h = close_inner },
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .color_text = dvui.themeGet().color(.window, .text),
+ }, .{
+ .complete_elapsed_ns = save_flash_elapsed,
+ });
+ } else {
+ var tab_close_button: dvui.ButtonWidget = undefined;
+ 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();
+
+ 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 = icon_color,
+ .fill_color = icon_color,
+ }, .{
+ .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()) {
+ runtime.host().closeDocById(doc.id) catch |err| {
+ dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) });
+ };
+ break;
+ }
+ }
+
+ if (selected and !reorderable.floating()) {
+ wdvui.drawTabActiveIndicator(
+ reorderable.data().borderRectScale(),
+ dvui.themeGet().color(.window, .text),
+ );
+ }
+
+ loop: for (dvui.events()) |*e| {
+ if (!hbox.matchEvent(e)) {
+ continue;
+ }
+
+ switch (e.evt) {
+ .mouse => |me| {
+ if (me.action == .press and me.button.pointer()) {
+ runtime.host().setActiveDocIndex(i);
+ 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); // reorder grabs capture
+ break :loop;
+ }
+ }
+ }
+ },
+
+ else => {},
+ }
+ }
+ }
+ if (tabs.finalSlot()) {
+ self.tabs_insert_before_index = runtime.host().openDocCount();
+ }
+ }
+ }
+}
+
+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 > runtime.host().openDocCount()) return;
+ if (removed > insert_before) {
+ runtime.host().swapDocs(removed, insert_before);
+ runtime.host().setActiveDocIndex(insert_before);
+ } else {
+ if (insert_before > 0) {
+ runtime.host().swapDocs(removed, insert_before - 1);
+ runtime.host().setActiveDocIndex(insert_before - 1);
+ } else {
+ 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 (runtime.workbench().workspaces.values()) |*workspace| {
+ if (workspace.tabs_removed_index) |removed| {
+ if (removed > insert_before) {
+ runtime.host().swapDocs(removed, insert_before);
+ if (runtime.host().docByIndex(insert_before)) |d| {
+ d.owner.setDocumentGrouping(d, self.grouping);
+ }
+ runtime.host().setActiveDocIndex(insert_before);
+ } else {
+ if (insert_before > 0) {
+ runtime.host().swapDocs(removed, insert_before - 1);
+ if (runtime.host().docByIndex(insert_before - 1)) |d| {
+ d.owner.setDocumentGrouping(d, self.grouping);
+ }
+ runtime.host().setActiveDocIndex(insert_before - 1);
+ } else {
+ runtime.host().swapDocs(removed, insert_before);
+ if (runtime.host().docByIndex(insert_before)) |d| {
+ d.owner.setDocumentGrouping(d, self.grouping);
+ }
+ runtime.host().setActiveDocIndex(insert_before);
+ }
+ }
+
+ self.tabs_removed_index = null;
+ self.tabs_insert_before_index = null;
+
+ workspace.tabs_removed_index = null;
+ workspace.tabs_insert_before_index = null;
+ }
+ }
+ }
+ }
+}
+
+/// 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 = runtime.host().docByIndex(drag_index) orelse return;
+ if (tab_bar_workspace) |workspace| {
+ if (workspace.open_file_index == runtime.host().docIndex(dragged_doc.id)) {
+ var i: usize = 0;
+ 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;
+ }
+ }
+ }
+ } else {
+ for (runtime.workbench().workspaces.values()) |*w| {
+ if (w.open_file_index == drag_index) {
+ var i: usize = 0;
+ 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;
+ }
+ }
+ }
+ }
+ }
+}
+
+const WorkspaceTabDragSrc = union(enum) {
+ tab_bar: struct { ws: *Workspace, index: usize },
+ tree_open: usize,
+ tree_closed: []const u8,
+ none,
+
+ fn resolve() WorkspaceTabDragSrc {
+ for (runtime.workbench().workspaces.values()) |*w| {
+ if (w.tabs_drag_index) |i| return .{ .tab_bar = .{ .ws = w, .index = i } };
+ }
+ if (runtime.workbench().tab_drag_from_tree_path) |p| {
+ var i: usize = 0;
+ while (i < runtime.host().openDocCount()) : (i += 1) {
+ const doc = runtime.host().docByIndex(i).?;
+ if (doc.owner.documentByPath(p) != null) {
+ return .{ .tree_open = i };
+ }
+ }
+ return .{ .tree_closed = p };
+ }
+ return .none;
+ }
+};
+
+/// Responsible for handling the cross-widget drag of tabs between multiple workspaces or between tabs and workspaces.
+/// 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")) {
+ runtime.workbench().clearFileTreeTabDragDropState();
+ return;
+ }
+
+ const drag_src = WorkspaceTabDragSrc.resolve();
+ switch (drag_src) {
+ .none => return,
+ else => {},
+ }
+
+ events_loop: for (dvui.events()) |*e| {
+ if (!dvui.eventMatch(e, .{ .id = data.id, .r = data.rectScale().r, .drag_name = "tab_drag" })) continue;
+
+ switch (drag_src) {
+ .none => unreachable,
+ .tab_bar => |tb| {
+ const workspace = tb.ws;
+ const drag_index = tb.index;
+
+ var right_side = data.rectScale().r;
+ right_side.w /= 2;
+ right_side.x += right_side.w;
+
+ 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),
+ });
+ }
+
+ if (e.evt == .mouse and 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);
+ runtime.workbench().clearFileTreeTabDragDropState();
+
+ repointWorkspacesAfterTabDrag(workspace, drag_index);
+ const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue;
+ const new_g = runtime.workbench().newGroupingID();
+ dragged_doc.owner.setDocumentGrouping(dragged_doc, 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) {
+ 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 and 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);
+ runtime.workbench().clearFileTreeTabDragDropState();
+
+ repointWorkspacesAfterTabDrag(workspace, drag_index);
+ const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue;
+ dragged_doc.owner.setDocumentGrouping(dragged_doc, self.grouping);
+ runtime.workbench().open_workspace_grouping = self.grouping;
+ self.open_file_index = runtime.host().docIndex(dragged_doc.id) orelse 0;
+ }
+ }
+ },
+ .tree_open => |drag_index| {
+ var right_side = data.rectScale().r;
+ right_side.w /= 2;
+ right_side.x += right_side.w;
+
+ 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),
+ });
+ }
+
+ if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
+ e.handle(@src(), data);
+ dvui.dragEnd();
+ dvui.refresh(null, @src(), data.id);
+ runtime.workbench().clearFileTreeTabDragDropState();
+
+ repointWorkspacesAfterTabDrag(null, drag_index);
+ const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue;
+ const new_g = runtime.workbench().newGroupingID();
+ dragged_doc.owner.setDocumentGrouping(dragged_doc, 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) {
+ 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 and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
+ e.handle(@src(), data);
+ dvui.dragEnd();
+ dvui.refresh(null, @src(), data.id);
+ runtime.workbench().clearFileTreeTabDragDropState();
+
+ repointWorkspacesAfterTabDrag(null, drag_index);
+ const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue;
+ dragged_doc.owner.setDocumentGrouping(dragged_doc, self.grouping);
+ runtime.workbench().open_workspace_grouping = self.grouping;
+ self.open_file_index = runtime.host().docIndex(dragged_doc.id) orelse 0;
+ }
+ }
+ },
+ .tree_closed => |path| {
+ var right_side = data.rectScale().r;
+ right_side.w /= 2;
+ right_side.x += right_side.w;
+
+ 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),
+ });
+ }
+
+ if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
+ e.handle(@src(), data);
+ dvui.dragEnd();
+ dvui.refresh(null, @src(), data.id);
+ 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);
+ 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.
+ runtime.workbench().clearFileTreeTabDragDropState();
+ }
+ } else if (data.rectScale().r.contains(e.evt.mouse.p)) {
+ if (e.evt == .mouse and 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 and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
+ e.handle(@src(), data);
+ dvui.dragEnd();
+ dvui.refresh(null, @src(), data.id);
+ const maybe_idx = runtime.host().openOrFocusFileAtGrouping(path, self.grouping) catch {
+ runtime.workbench().clearFileTreeTabDragDropState();
+ continue :events_loop;
+ };
+ if (maybe_idx) |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.
+ runtime.workbench().clearFileTreeTabDragDropState();
+ }
+ }
+ },
+ }
+ }
+}
+
+pub fn drawCanvas(self: *Workspace) !void {
+ var content_color = dvui.themeGet().color(.window, .fill);
+
+ switch (builtin.os.tag) {
+ .macos => {
+ content_color = if (!runtime.host().isMaximized()) content_color.opacity(runtime.host().contentOpacity()) else content_color;
+ },
+ .windows => {
+ content_color = if (!runtime.host().isMaximized()) content_color.opacity(runtime.host().contentOpacity()) else content_color;
+ },
+ else => {},
+ }
+
+ const has_files = runtime.host().openDocCount() > 0;
+
+ var canvas_vbox = workspaceMainCanvasVbox(content_color, has_files, 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();
+ }
+ defer self.processTabDrag(canvas_vbox.data());
+
+ if (has_files) {
+ if (self.open_file_index >= runtime.host().openDocCount()) {
+ self.open_file_index = runtime.host().openDocCount() - 1;
+ }
+
+ 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);
+ defer box.deinit();
+
+ // Make sure alpha is 1 before we draw the homepage, as the logo hover animation breaks if alpha is not 1
+ const alpha = dvui.alpha(1.0);
+ dvui.alphaSet(1.0);
+ defer dvui.alphaSet(alpha);
+
+ try self.drawHomePage(canvas_vbox);
+ }
+}
+
+pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
+ const logo_pixel_size = 32;
+ const logo_width = 3;
+ const logo_height = 5;
+
+ const logo_vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .none,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .background = false,
+ .padding = dvui.Rect.all(10),
+ });
+ defer logo_vbox.deinit();
+
+ { // Logo
+
+ const vbox2 = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .none,
+ .gravity_x = 0.5,
+ .min_size_content = .{ .w = logo_pixel_size * logo_width, .h = logo_pixel_size * logo_height },
+ .padding = dvui.Rect.all(20),
+ });
+ defer vbox2.deinit();
+
+ for (0..4) |i| {
+ const hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .none,
+ .min_size_content = .{ .w = logo_pixel_size * logo_width, .h = logo_pixel_size },
+ .margin = dvui.Rect.all(0),
+ .padding = dvui.Rect.all(0),
+ .id_extra = i,
+ });
+ defer hbox.deinit();
+
+ for (0..3) |j| {
+ const index = i * logo_width + j;
+ var fizzy_color = logo_colors[index];
+
+ 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(wb.math.Color.initBytes(theme_bg.r, theme_bg.g, theme_bg.b, 255), fizzy_color.value[3]);
+ fizzy_color.value[3] = 1.0;
+ }
+
+ const color = fizzy_color.bytes();
+
+ const pixel = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .none,
+ .min_size_content = .{ .w = logo_pixel_size, .h = logo_pixel_size },
+ .id_extra = index,
+ .background = false,
+ .color_fill = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] },
+ .margin = dvui.Rect.all(0),
+ .padding = dvui.Rect.all(0),
+ });
+
+ const rect = pixel.data().rect.outset(.{ .x = 0, .y = 0 });
+ const rs = pixel.data().rectScale();
+ pixel.deinit();
+
+ if (fizzy_color.value[3] <= 0.0) continue;
+
+ try drawBubble(rect, rs, color, index);
+ }
+ }
+ }
+
+ var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .none,
+ .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()) {
+ runtime.host().requestNewDocument(null, 0);
+ }
+ }
+ {
+ 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(
+ "Open Folder",
+ dvui.currentWindow().keybinds.get("open_folder") 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()) {
+ runtime.host().showOpenFolderDialog(setProjectFolderCallback, null);
+ }
+ }
+
+ {
+ 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(
+ "Open Files",
+ dvui.currentWindow().keybinds.get("open_files") orelse .{},
+ true,
+ .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 },
+ .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0, .font = dvui.Font.theme(.heading) },
+ );
+
+ if (button.clicked()) {
+ runtime.host().showOpenFileDialog(openFilesCallback, &.{
+ .{ .name = "Image Files", .pattern = "fizzy;png;jpg;jpeg" },
+ }, "", null);
+ }
+ }
+ vbox.deinit();
+
+ const spacer = dvui.spacer(@src(), .{ .expand = .horizontal, .min_size_content = .{ .h = 30 } });
+
+ {
+ var recents_box = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .none,
+ .gravity_x = 0.5,
+ .max_size_content = .{ .h = (canvas_vbox.data().rect.h - spacer.rect.y) / 3.0, .w = canvas_vbox.data().rect.w / 2.0 },
+ });
+ defer recents_box.deinit();
+
+ var scroll_area = dvui.scrollArea(@src(), .{}, .{
+ .expand = .both,
+ .color_border = dvui.themeGet().color(.control, .fill),
+ .corner_radius = dvui.Rect.all(8),
+ .color_fill = .transparent,
+ });
+ defer scroll_area.deinit();
+
+ var i: usize = runtime.host().recentFolderCount();
+ while (i > 0) : (i -= 1) {
+ var anim = dvui.animate(@src(), .{
+ .kind = .horizontal,
+ .duration = 150_000 + 150_000 * @as(i32, @intCast(i)),
+ .easing = dvui.easing.outBack,
+ }, .{
+ .id_extra = i,
+ .expand = .horizontal,
+ });
+ defer anim.deinit();
+
+ const folder = runtime.host().recentFolderAt(i - 1) orelse continue;
+ if (dvui.button(@src(), folder, .{
+ .draw_focus = false,
+ }, .{
+ .expand = .horizontal,
+ .font = dvui.Font.theme(.mono),
+ .id_extra = i,
+ .margin = dvui.Rect.all(1),
+ .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),
+ .color_text = dvui.themeGet().color(.control, .text).opacity(0.5),
+ })) {
+ try runtime.host().setProjectFolder(folder);
+ }
+ }
+ }
+}
+
+pub fn drawBubble(rect: dvui.Rect, rs: dvui.RectScale, color: [4]u8, _: usize) !void {
+ var bubble_h: f32 = rect.h;
+ for (dvui.events()) |evt| {
+ switch (evt.evt) {
+ .mouse => |me| {
+ const dx = @abs(me.p.x - (rs.r.x + rs.r.w * 0.5)) / rs.s;
+ const dy = @abs(me.p.y - (rs.r.y - rs.r.h * 0.5)) / rs.s;
+ const distance = @sqrt(dx * dx + dy * dy);
+ const max_distance: f32 = rect.h * 2.0;
+
+ var t = distance / max_distance;
+ if (t > 1.0) t = 1.0;
+ if (t < 0.0) t = 0.0;
+ bubble_h = @ceil(rect.h - rect.h * t);
+ },
+ else => {},
+ }
+ }
+
+ // Derive the pill's physical rect directly from the base's physical rect
+ // (no dvui.box layout round-trip). This guarantees identical left/right
+ // edges between base and pill at any scale or splitter ratio.
+ const base_phys = rs.r.outsetAll(1);
+ const bubble_h_phys = @ceil(bubble_h * rs.s);
+ const bubble_phys = dvui.Rect.Physical{
+ .x = base_phys.x,
+ .y = rs.r.y - bubble_h_phys,
+ .w = base_phys.w,
+ .h = bubble_h_phys,
+ };
+
+ var path = dvui.Path.Builder.init(dvui.currentWindow().lifo());
+ defer path.deinit();
+
+ path.addRect(base_phys, dvui.Rect.Physical.all(0));
+
+ if (bubble_phys.h > 0) {
+ const rad_x = rs.r.w / 2.0;
+ const rad_y = rs.r.h / 2.0;
+ const r = bubble_phys;
+ const tl = dvui.Point.Physical{ .x = r.x + rad_x, .y = r.y + rad_x };
+ const bl = dvui.Point.Physical{ .x = r.x, .y = r.y + r.h };
+ const br = dvui.Point.Physical{ .x = r.x + r.w, .y = r.y + r.h };
+ const tr = dvui.Point.Physical{ .x = r.x + r.w - rad_y, .y = r.y + rad_y };
+ path.addArc(tl, rad_x, dvui.math.pi * 1.5, dvui.math.pi, true);
+ path.addArc(bl, 0, dvui.math.pi, dvui.math.pi * 0.5, true);
+ path.addArc(br, 0, dvui.math.pi * 0.5, 0, true);
+ path.addArc(tr, rad_y, dvui.math.pi * 2.0, dvui.math.pi * 1.5, false);
+ }
+
+ path.build().fillConvex(.{ .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, .fade = 1.0 });
+}
+
+// This should never be able to return more than one folder
+pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void {
+ if (folder) |f| {
+ runtime.host().setProjectFolder(f[0]) catch {
+ dvui.log.err("Failed to set project folder: {s}", .{f[0]});
+ };
+ }
+}
+
+pub fn openFilesCallback(files: ?[][:0]const u8) void {
+ if (files) |f| {
+ for (f) |file| {
+ _ = runtime.host().openFilePath(file, runtime.workbench().open_workspace_grouping) catch {
+ dvui.log.err("Failed to open file: {s}", .{file});
+ };
+ }
+ }
+}
diff --git a/src/editor/explorer/files.zig b/src/plugins/workbench/src/files.zig
similarity index 79%
rename from src/editor/explorer/files.zig
rename to src/plugins/workbench/src/files.zig
index 18b2f379..05741780 100644
--- a/src/editor/explorer/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -1,21 +1,18 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
-const dvui = @import("dvui");
-const Editor = fizzy.Editor;
const builtin = @import("builtin");
-
+const wb = @import("../workbench.zig");
+const runtime = @import("runtime.zig");
+const dvui = wb.dvui;
+const wdvui = wb.wdvui;
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;
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 `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;
@@ -31,10 +28,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";
@@ -65,7 +60,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)
@@ -81,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 (fizzy.editor.folder) |path| {
+ if (runtime.host().folder()) |path| {
try drawFiles(path, tree);
} else {
- fizzy.editor.file_tree_data_id = null;
+ runtime.workbench().file_tree_data_id = null;
dvui.labelNoFmt(
@src(),
"Open a project folder to begin.",
@@ -94,17 +89,17 @@ 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 runtime.host().setProjectFolder(folder);
}
}
}
}
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 = fizzy.editor.explorer.scroll_info.viewport.w;
+ const viewport_w = runtime.host().explorerViewportWidth();
const wrap_w: f32 = if (viewport_w > 0) viewport_w else 200;
{
@@ -131,7 +126,7 @@ fn drawWeb() !void {
.style = .highlight,
.min_size_content = .{ .w = 110, .h = 0 },
})) {
- fizzy.backend.showOpenFileDialog(
+ runtime.host().showOpenFileDialog(
struct {
fn cb(_: ?[][:0]const u8) void {}
}.cb,
@@ -142,11 +137,12 @@ 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);
- fizzy.editor.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",
@@ -253,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 },
@@ -268,11 +264,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;
- }
+ runtime.host().closeProjectFolder();
fw2.close();
}
@@ -280,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) {
- fizzy.editor.openInFileBrowser(project_path) catch {
+ runtime.host().openInFileBrowser(project_path) catch {
dvui.log.err("Failed to open file browser", .{});
};
@@ -290,20 +282,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);
+ runtime.host().requestNewDocument(project_path, root_branch_id.asUsize());
}
if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) {
@@ -408,33 +387,30 @@ 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) {
+ // 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,
+ });
+ runtime.workbench().drawBranchDecorations(full_path, id_extra);
} else {
dvui.label(@src(), "{s}", .{label}, .{
.color_text = color,
@@ -448,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;
@@ -456,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);
@@ -484,8 +460,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 (runtime.host().folder()) |proj_root| {
+ if (runtime.host().isPathIgnored(proj_root, abs_path, entry.name, entry.kind)) {
continue;
}
}
@@ -500,11 +476,11 @@ 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(runtime.allocator(), .{ .id = inner_id_extra.*, .path = abs_path });
var color = dvui.themeGet().color(.control, .fill);
- if (fizzy.editor.colors.palette) |*palette| {
- color = palette.getDVUIColor(color_id.*);
+ if (runtime.host().fileRowFillColor(color_id.*)) |tint| {
+ color = tint;
}
const padding = dvui.Rect.all(2);
@@ -517,7 +493,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 (runtime.host().explorerBranchIsOpen(branch_id)) {
expanded = true;
}
@@ -555,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);
- new_file_close_rect = close_rect;
+ wdvui.dialog_close_rect_override = close_rect;
new_file_path = null;
}
}
@@ -597,13 +573,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 (runtime.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;
+ runtime.allocator().free(old);
+ runtime.workbench().tab_drag_from_tree_path = runtime.allocator().dupe(u8, abs_path) catch null;
}
} else {
- fizzy.editor.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null;
+ runtime.workbench().tab_drag_from_tree_path = runtime.allocator().dupe(u8, abs_path) catch null;
}
}
}
@@ -616,7 +592,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 {};
+ runtime.host().setExplorerBranchOpen(branch_id, true);
}
{ // Add right click context menu for item options
@@ -652,7 +628,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| {
+ _ = runtime.host().openFilePath(p, runtime.workbench().currentGroupingID()) catch |e| {
dvui.log.err("Failed to open file: {any} ({s})", .{ e, p });
};
}
@@ -672,13 +648,13 @@ 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)
- fizzy.editor.currentGroupingID()
+ side_grouping = if (runtime.host().openDocCount() == 0)
+ runtime.workbench().currentGroupingID()
else
- fizzy.editor.newGroupingID();
+ runtime.workbench().newGroupingID();
have_grouping = true;
}
- _ = fizzy.editor.openFilePath(p, side_grouping) catch {
+ _ = runtime.host().openFilePath(p, side_grouping) catch {
dvui.log.err("Failed to open file: {s}", .{p});
};
}
@@ -690,7 +666,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 {
+ 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", .{});
};
@@ -701,22 +677,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);
+ runtime.host().requestNewDocument(parent_dir, branch_id.asUsize());
}
if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) {
@@ -753,13 +714,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);
}
}
}
@@ -783,9 +738,14 @@ 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(
+ const ui_atlas = runtime.host().uiAtlas();
+ const ui_sprite = ui_atlas.sprites[wb.atlas.sprites.logo_default];
+ const logo_sprite = wb.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source };
+ _ = wb.Sprite.draw(
+ logo_sprite,
@src(),
- .{ .source = fizzy.editor.atlas.source, .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default], .scale = 2.0 },
+ ui_atlas.source,
+ 2.0,
.{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false },
);
} else {
@@ -804,24 +764,17 @@ 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 (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 (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()) {
- fizzy.dvui.bubbleSpinner(@src(), .{
+ if (runtime.host().docFromPath(abs_path)) |doc| {
+ if (doc.owner.showsSaveStatusIndicator(doc)) {
+ wdvui.bubbleSpinner(@src(), .{
.id_extra = inner_id_extra.* +% 4001,
.expand = .none,
.min_size_content = .{ .w = 14, .h = 14 },
@@ -829,35 +782,18 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
.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 (file.dirty()) {
- _ = 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,
- },
- );
}
}
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 => {
- _ = fizzy.editor.openFilePath(abs_path, fizzy.editor.currentGroupingID()) catch |err| {
- dvui.log.err("{any}: {s}", .{ err, abs_path });
- };
- },
- else => {},
- }
+ if (mode == .replace and openablePath(abs_path)) {
+ _ = runtime.host().openFilePath(abs_path, runtime.workbench().currentGroupingID()) catch |err| {
+ dvui.log.err("{any}: {s}", .{ err, abs_path });
+ };
}
}
},
@@ -922,9 +858,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!", .{});
- };
+ runtime.host().setExplorerBranchOpen(branch_id, true);
try search(
abs_path,
tree,
@@ -935,13 +869,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 (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) {
- fizzy.editor.explorer.open_branches.put(branch_id, {}) catch {};
+ runtime.host().setExplorerBranchOpen(branch_id, true);
}
color_id.* = color_id.* + 1;
},
@@ -964,33 +898,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| 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;
- fizzy.app.allocator.free(existing.*);
- existing.* = fizzy.app.allocator.dupe(u8, path) catch return;
+ runtime.allocator().free(existing.*);
+ existing.* = runtime.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 = 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| {
- fizzy.app.allocator.free(kv.value);
+ runtime.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();
@@ -1054,14 +988,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;
}
@@ -1109,13 +1043,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 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 {
@@ -1185,7 +1116,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()`.
@@ -1235,7 +1166,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;
@@ -1245,9 +1176,8 @@ fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.A
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 (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;
};
@@ -1255,6 +1185,67 @@ 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) });
+
+ var di: usize = 0;
+ 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(runtime.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 (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;
+ };
+ }
+ },
+ 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;
@@ -1266,7 +1257,7 @@ pub fn pruneMissingSelections() void {
continue;
};
if (selected_id == removed.key) selected_id = null;
- fizzy.app.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
new file mode 100644
index 00000000..f4d6d64c
--- /dev/null
+++ b/src/plugins/workbench/src/plugin.zig
@@ -0,0 +1,78 @@
+//! The workbench plugin: file management. Registered from `Editor.postInit`.
+const std = @import("std");
+const dvui = @import("dvui");
+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";
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .id = "workbench",
+ .display_name = "Workbench",
+};
+
+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);
+ 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,
+ .draw = drawCenter,
+ });
+}
+
+fn drawFiles(_: ?*anyopaque) anyerror!void {
+ try files.draw();
+}
+
+fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result {
+ 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.
+fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void {
+ 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 });
+ 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 });
+ }
+}
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
new file mode 100644
index 00000000..b92afd6d
--- /dev/null
+++ b/src/plugins/workbench/src/workbench_layout.zig
@@ -0,0 +1,137 @@
+//! Workspace map maintenance + recursive split drawing.
+const std = @import("std");
+const dvui = @import("dvui");
+const wb_mod = @import("../workbench.zig");
+const runtime = @import("runtime.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 = runtime.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(runtime.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 = 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,
+ .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/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
new file mode 100644
index 00000000..96310f63
--- /dev/null
+++ b/src/plugins/workbench/workbench.zig
@@ -0,0 +1,30 @@
+//! Workbench plugin root module **and** intra-plugin import hub.
+//!
+//! - 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");
+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/DocHandle.zig b/src/sdk/DocHandle.zig
new file mode 100644
index 00000000..5af748ab
--- /dev/null
+++ b/src/sdk/DocHandle.zig
@@ -0,0 +1,14 @@
+//! 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 `*pixelart.internal.File`; a text plugin would point it at its own type.
+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/EditorAPI.zig b/src/sdk/EditorAPI.zig
new file mode 100644
index 00000000..c35f2027
--- /dev/null
+++ b/src/sdk/EditorAPI.zig
@@ -0,0 +1,393 @@
+//! 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 dvui = @import("dvui");
+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
+/// 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;
+
+/// 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,
+ 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,
+
+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,
+ /// 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,
+ /// 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,
+ /// 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,
+ /// 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,
+
+ /// 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,
+ /// 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,
+
+ save: *const fn (ctx: *anyopaque) anyerror!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.
+ 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,
+ 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,
+};
+
+pub fn arena(self: EditorAPI) std.mem.Allocator {
+ return self.vtable.arena(self.ctx);
+}
+
+pub fn folder(self: EditorAPI) ?[]const u8 {
+ return self.vtable.folder(self.ctx);
+}
+
+pub fn paletteFolder(self: EditorAPI) ?[]const u8 {
+ return self.vtable.paletteFolder(self.ctx);
+}
+
+pub fn markSettingsDirty(self: EditorAPI) void {
+ self.vtable.markSettingsDirty(self.ctx);
+}
+
+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 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);
+}
+
+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);
+}
+
+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 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);
+}
+
+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 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 save(self: EditorAPI) !void {
+ return self.vtable.save(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 {
+ 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 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);
+}
+
+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);
+}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
new file mode 100644
index 00000000..64ac080a
--- /dev/null
+++ b/src/sdk/Host.zig
@@ -0,0 +1,648 @@
+//! The services the shell exposes to plugins, and the registries it owns. Plugins
+//! 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 regions = @import("regions.zig");
+const EditorAPI = @import("EditorAPI.zig");
+const DocHandle = @import("DocHandle.zig");
+const WorkbenchPaneView = @import("WorkbenchPane.zig").WorkbenchPaneView;
+
+pub const Host = @This();
+
+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
+/// 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 (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
+/// 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,
+
+/// 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: ?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 -----------------------------------------------
+// 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,
+/// 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,
+active_bottom_view: ?[]const u8 = null,
+active_center: ?[]const u8 = null,
+
+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);
+ self.sidebar_views.deinit(self.allocator);
+ self.bottom_views.deinit(self.allocator);
+ self.center_providers.deinit(self.allocator);
+ self.menus.deinit(self.allocator);
+ self.menu_sections.deinit(self.allocator);
+ self.settings_sections.deinit(self.allocator);
+ self.commands.deinit(self.allocator);
+ self.file_row_fill_colors.deinit(self.allocator);
+ {
+ var it = self.plugin_settings.iterator();
+ 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: EditorAPI) 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;
+}
+
+/// 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;
+}
+
+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 .{};
+}
+
+/// 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 .{};
+}
+
+/// 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();
+}
+
+/// 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 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;
+}
+
+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 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 save(self: *Host) !void {
+ if (self.shell_api) |a| return a.save();
+}
+
+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;
+}
+
+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 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();
+}
+
+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();
+}
+
+// ---- 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();
+}
+
+/// 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 (`"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;
+ }
+ 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);
+}
+
+/// 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);
+}
+
+pub fn getService(self: *Host, name: []const u8) ?*anyopaque {
+ return self.services.get(name);
+}
+
+/// Typed service lookup. `Service` must declare `service_name` and match the registered layout.
+pub fn getServiceTyped(self: *Host, comptime Service: type) ?*Service {
+ const ptr = self.getService(Service.service_name) orelse return null;
+ return @ptrCast(@alignCast(ptr));
+}
+
+// ---- region registration (called from a plugin's register / postInit) -------
+
+pub fn registerSidebarView(self: *Host, view: SidebarView) !void {
+ 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;
+}
+
+/// 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;
+}
+
+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 {
+ 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 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;
+ }
+ }
+ 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;
+}
+
+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 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;
+ for (self.plugins.items) |plugin| {
+ if (plugin.fileTypePriority(ext)) |p| {
+ if (best == null or p < best_priority) {
+ best = plugin;
+ best_priority = p;
+ }
+ }
+ }
+ 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: 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| {
+ 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
new file mode 100644
index 00000000..528938df
--- /dev/null
+++ b/src/sdk/Plugin.zig
@@ -0,0 +1,468 @@
+//! 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. 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 need `callconv(.c)`.
+const std = @import("std");
+const dvui = @import("dvui");
+const DocHandle = @import("DocHandle.zig");
+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,
+
+/// Stable, unique identifier (snake_case), e.g. "pixelart", "workbench".
+id: []const u8,
+/// User-facing name shown in UI.
+display_name: []const u8,
+
+/// 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,
+ /// 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", 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) ----
+ /// 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,
+ /// 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).
+ /// 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,
+
+ /// 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,
+ 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,
+ /// 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,
+ 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,
+ /// 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: 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,
+
+ // ---- 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
+ // 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 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 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,
+ /// [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 {
+ 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);
+}
+
+pub fn tickKeybinds(self: Plugin) !void {
+ if (self.vtable.tickKeybinds) |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 {
+ 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 onFolderClose(self: Plugin) void {
+ if (self.vtable.onFolderClose) |f| f(self.state);
+}
+
+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 {
+ 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 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 "";
+}
+
+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 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;
+}
+
+pub fn isDocumentSaving(self: Plugin, doc: DocHandle) bool {
+ return if (self.vtable.isDocumentSaving) |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 {
+ 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
+/// 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);
+}
+
+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
+/// 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 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 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 beginFrame(self: Plugin) void {
+ if (self.vtable.beginFrame) |f| f(self.state);
+}
+
+pub fn prepareFrame(self: Plugin) void {
+ if (self.vtable.prepareFrame) |f| f(self.state);
+}
+
+pub fn endFrame(self: Plugin) void {
+ if (self.vtable.endFrame) |f| f(self.state);
+}
+
+pub fn tickOpenDocuments(self: Plugin) bool {
+ return if (self.vtable.tickOpenDocuments) |f| f(self.state) else false;
+}
+
+pub fn tickActiveDocument(self: Plugin, timer_host_id: dvui.Id) void {
+ if (self.vtable.tickActiveDocument) |f| f(self.state, timer_host_id);
+}
+
+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`.
+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] };
+}
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/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/dvui_context.zig b/src/sdk/dvui_context.zig
new file mode 100644
index 00000000..f13ad8f9
--- /dev/null
+++ b/src/sdk/dvui_context.zig
@@ -0,0 +1,44 @@
+//! 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`).
+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
new file mode 100644
index 00000000..34087a03
--- /dev/null
+++ b/src/sdk/dylib.zig
@@ -0,0 +1,260 @@
+//! Runtime dynamic-library contract for Fizzy plugins.
+//!
+//! 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.
+//!
+//! **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,
+ arg_b: ?*anyopaque,
+ arg_c: ?*anyopaque,
+) callconv(.c) void;
+
+/// C ABI — host loader pushes its render bridge into the plugin's proxy backend.
+pub const SetRenderBridgeFn = *const fn (?*const proxy_bridge.RenderBridge) callconv(.c) void;
+
+/// C ABI — `fizzy_plugin_register`.
+pub const RegisterFn = *const fn (?*Host) callconv(.c) u32;
+
+/// C ABI — `fizzy_plugin_abi_fingerprint`; the loader rejects any value != `abi_fingerprint`.
+pub const GetAbiFingerprintFn = *const fn () callconv(.c) u64;
+
+pub const VersionTriplet = extern struct {
+ major: u32,
+ minor: u32,
+ patch: u32,
+};
+
+/// C ABI — returns SDK version this plugin was built against.
+pub const GetSdkVersionFn = *const fn () callconv(.c) VersionTriplet;
+
+/// C ABI — returns the plugin's declared minimum host SDK version.
+pub const GetMinSdkVersionFn = *const fn () callconv(.c) VersionTriplet;
+
+/// C ABI — returns the plugin's own release version.
+pub const GetPluginVersionFn = *const fn () callconv(.c) VersionTriplet;
+
+/// C ABI — returns the plugin's stable id (NUL-terminated).
+pub const GetPluginIdFn = *const fn () callconv(.c) [*:0]const u8;
+
+/// dvui data/handle types that cross the boundary by value or through the render bridge.
+const dvui_boundary_types = .{
+ dvui.Window,
+ dvui.Debug,
+ dvui.Vertex,
+ dvui.Vertex.Index,
+ dvui.Texture,
+ dvui.TextureTarget,
+ dvui.Rect.Physical,
+ dvui.Id,
+};
+
+/// SDK types whose full structure is part of the contract.
+const sdk_boundary_types = .{
+ Host,
+ Plugin,
+ Plugin.VTable,
+ DocHandle,
+ EditorAPI,
+ EditorAPI.VTable,
+ regions.SidebarView,
+ regions.BottomView,
+ regions.CenterProvider,
+ regions.MenuContribution,
+ regions.MenuSectionContribution,
+ regions.SettingsSection,
+ regions.Command,
+ Host.FileRowFillColor,
+ proxy_bridge.RenderBridge,
+ workbench_service.Api,
+ workbench_service.Api.VTable,
+ VersionTriplet,
+};
+
+const entry_symbol_types = .{
+ RegisterFn,
+ SetGlobalsFn,
+ SetRenderBridgeFn,
+ GetAbiFingerprintFn,
+ GetSdkVersionFn,
+ GetMinSdkVersionFn,
+ GetPluginVersionFn,
+ GetPluginIdFn,
+ dvui_context.SetContextFn,
+};
+
+pub const abi_fingerprint: u64 = blk: {
+ @setEvalBranchQuota(1_000_000);
+ var h = fingerprint.seed;
+ h = fingerprint.hashAll(h, dvui_boundary_types, 0);
+ h = fingerprint.hashAll(h, sdk_boundary_types, 6);
+ h = fingerprint.hashAll(h, entry_symbol_types, 3);
+ break :blk h;
+};
+
+pub const symbol_register: [:0]const u8 = "fizzy_plugin_register";
+pub const symbol_set_dvui_context: [:0]const u8 = "fizzy_plugin_set_dvui_context";
+pub const symbol_set_render_bridge: [:0]const u8 = "fizzy_plugin_set_render_bridge";
+pub const symbol_set_globals: [:0]const u8 = "fizzy_plugin_set_globals";
+pub const symbol_abi_fingerprint: [:0]const u8 = "fizzy_plugin_abi_fingerprint";
+pub const symbol_sdk_version: [:0]const u8 = "fizzy_plugin_sdk_version";
+pub const symbol_min_sdk_version: [:0]const u8 = "fizzy_plugin_min_sdk_version";
+pub const symbol_plugin_version: [:0]const u8 = "fizzy_plugin_version";
+pub const symbol_plugin_id: [:0]const u8 = "fizzy_plugin_id";
+
+pub const RegisterStatus = enum(u32) {
+ ok = 0,
+ err_register = 1,
+ err_null_host = 2,
+ err_abi_mismatch = 3,
+ err_sdk_version = 4,
+};
+
+pub fn fingerprintMatches(plugin_fp: u64) bool {
+ return plugin_fp == abi_fingerprint;
+}
+
+pub fn tripletFromSemver(v: std.SemanticVersion) VersionTriplet {
+ return .{
+ .major = @intCast(v.major),
+ .minor = @intCast(v.minor),
+ .patch = @intCast(v.patch),
+ };
+}
+
+pub fn semverFromTriplet(t: VersionTriplet) std.SemanticVersion {
+ return .{ .major = t.major, .minor = t.minor, .patch = t.patch };
+}
+
+/// Emit version/id C exports for a built-in dylib that does not use `exportEntry`.
+pub fn exportManifestSymbols(comptime manifest: PluginManifest) void {
+ const IdEntry = struct {
+ const id_z = manifest.id ++ "\x00";
+ fn pluginId() callconv(.c) [*:0]const u8 {
+ return id_z;
+ }
+ };
+ const ManifestEntry = struct {
+ fn sdkVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(version.sdk_version);
+ }
+ fn minSdkVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(manifest.min_sdk_version);
+ }
+ fn pluginVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(manifest.version);
+ }
+ };
+ @export(&IdEntry.pluginId, .{ .name = symbol_plugin_id });
+ @export(&ManifestEntry.sdkVersion, .{ .name = symbol_sdk_version });
+ @export(&ManifestEntry.minSdkVersion, .{ .name = symbol_min_sdk_version });
+ @export(&ManifestEntry.pluginVersion, .{ .name = symbol_plugin_version });
+}
+
+/// Emit the C entry symbols every plugin dylib must export, wired to the plugin's
+/// own `register` and `manifest`.
+///
+/// `plugin_mod` must expose:
+/// - `pub fn register(*Host) !void`
+/// - `pub const manifest: PluginManifest`
+pub fn exportEntry(comptime plugin_mod: type) void {
+ comptime {
+ if (@hasDecl(plugin_mod, "manifest") == false) {
+ @compileError("plugin module must declare `pub const manifest: sdk.PluginManifest`");
+ }
+ }
+ const manifest = plugin_mod.manifest;
+ const IdEntry = struct {
+ const id_z = manifest.id ++ "\x00";
+ fn pluginId() callconv(.c) [*:0]const u8 {
+ return id_z;
+ }
+ };
+
+ const Entry = struct {
+ fn abiFingerprint() callconv(.c) u64 {
+ return abi_fingerprint;
+ }
+ fn sdkVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(version.sdk_version);
+ }
+ fn minSdkVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(manifest.min_sdk_version);
+ }
+ fn pluginVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(manifest.version);
+ }
+ fn register(host: ?*Host) callconv(.c) u32 {
+ if (host == null) return @intFromEnum(RegisterStatus.err_null_host);
+ if (!version.sdkVersionSatisfies(version.sdk_version, manifest.min_sdk_version)) {
+ return @intFromEnum(RegisterStatus.err_sdk_version);
+ }
+ plugin_mod.register(host.?) catch return @intFromEnum(RegisterStatus.err_register);
+ return @intFromEnum(RegisterStatus.ok);
+ }
+ fn setDvuiContext(
+ window: ?*dvui.Window,
+ io: ?*anyopaque,
+ ft2lib: ?*anyopaque,
+ debug: ?*dvui.Debug,
+ ) callconv(.c) void {
+ dvui_context.inject(window, io, ft2lib, debug);
+ }
+ fn setRenderBridge(bridge: ?*const proxy_bridge.RenderBridge) callconv(.c) void {
+ proxy_bridge.setBridge(bridge);
+ }
+ fn setGlobals(gpa: ?*const anyopaque, host: ?*anyopaque, state: ?*anyopaque) callconv(.c) void {
+ runtime.installRuntime(
+ if (gpa) |p| @ptrCast(@alignCast(p)) else null,
+ if (host) |p| @ptrCast(@alignCast(p)) else null,
+ state,
+ );
+ }
+ };
+ @export(&Entry.abiFingerprint, .{ .name = symbol_abi_fingerprint });
+ @export(&Entry.sdkVersion, .{ .name = symbol_sdk_version });
+ @export(&Entry.minSdkVersion, .{ .name = symbol_min_sdk_version });
+ @export(&Entry.pluginVersion, .{ .name = symbol_plugin_version });
+ @export(&IdEntry.pluginId, .{ .name = symbol_plugin_id });
+ @export(&Entry.register, .{ .name = symbol_register });
+ @export(&Entry.setDvuiContext, .{ .name = symbol_set_dvui_context });
+ @export(&Entry.setRenderBridge, .{ .name = symbol_set_render_bridge });
+ @export(&Entry.setGlobals, .{ .name = symbol_set_globals });
+}
+
+test "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/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
new file mode 100644
index 00000000..82d99e26
--- /dev/null
+++ b/src/sdk/regions.zig
@@ -0,0 +1,106 @@
+//! 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");
+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.
+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,
+ /// 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
+ /// content region instead of the normal document tabs+canvas. The workbench calls this
+ /// 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;
+/// the active one's `draw` fills the panel body.
+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,
+};
+
+/// 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,
+};
+
+/// 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
+/// 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/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/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
new file mode 100644
index 00000000..2890b3f7
--- /dev/null
+++ b/src/sdk/sdk.zig
@@ -0,0 +1,75 @@
+//! Fizzy plugin SDK — the surface a plugin module depends on.
+//!
+//! 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.
+
+// 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");
+
+/// 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 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");
+
+/// 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.
+pub const render_bridge = @import("render_bridge.zig");
diff --git a/src/sdk/services/workbench.zig b/src/sdk/services/workbench.zig
new file mode 100644
index 00000000..f759283f
--- /dev/null
+++ b/src/sdk/services/workbench.zig
@@ -0,0 +1,78 @@
+//! Workbench inter-plugin service — SDK-facing definition of the `"workbench"` service.
+//!
+//! The workbench plugin registers an instance via `host.registerService`. Plugin code
+//! uses `host.getServiceTyped(workbench.Api)`. The layout is part of the ABI fingerprint.
+const std = @import("std");
+const dvui = @import("dvui");
+
+pub const Api = struct {
+ pub const service_name = "workbench";
+
+ ctx: *anyopaque,
+ vtable: *const VTable,
+
+ pub const BranchDecorator = struct {
+ ctx: ?*anyopaque = null,
+ draw: *const fn (ctx: ?*anyopaque, path: []const u8, id_extra: usize) void,
+ };
+
+ pub const VTable = struct {
+ open: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool,
+ currentGrouping: *const fn (ctx: *anyopaque) u64,
+ newGrouping: *const fn (ctx: *anyopaque) u64,
+ close: *const fn (ctx: *anyopaque, id: u64) anyerror!void,
+ save: *const fn (ctx: *anyopaque) anyerror!void,
+ isOpen: *const fn (ctx: *anyopaque, path: []const u8) bool,
+ openCount: *const fn (ctx: *anyopaque) usize,
+ openPathAt: *const fn (ctx: *anyopaque, index: usize) ?[]const u8,
+ createFile: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void,
+ createDir: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void,
+ rename: *const fn (ctx: *anyopaque, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) anyerror!void,
+ delete: *const fn (ctx: *anyopaque, path: []const u8) void,
+ move: *const fn (ctx: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool,
+ registerBranchDecorator: *const fn (ctx: *anyopaque, decorator: BranchDecorator) anyerror!void,
+ };
+
+ pub fn open(self: Api, path: []const u8, grouping: u64) !bool {
+ return self.vtable.open(self.ctx, path, grouping);
+ }
+ pub fn currentGrouping(self: Api) u64 {
+ return self.vtable.currentGrouping(self.ctx);
+ }
+ pub fn newGrouping(self: Api) u64 {
+ return self.vtable.newGrouping(self.ctx);
+ }
+ pub fn close(self: Api, id: u64) !void {
+ return self.vtable.close(self.ctx, id);
+ }
+ pub fn save(self: Api) !void {
+ return self.vtable.save(self.ctx);
+ }
+ pub fn isOpen(self: Api, path: []const u8) bool {
+ return self.vtable.isOpen(self.ctx, path);
+ }
+ pub fn openCount(self: Api) usize {
+ return self.vtable.openCount(self.ctx);
+ }
+ pub fn openPathAt(self: Api, index: usize) ?[]const u8 {
+ return self.vtable.openPathAt(self.ctx, index);
+ }
+ pub fn createFile(self: Api, path: []const u8) !void {
+ return self.vtable.createFile(self.ctx, path);
+ }
+ pub fn createDir(self: Api, path: []const u8) !void {
+ return self.vtable.createDir(self.ctx, path);
+ }
+ pub fn rename(self: Api, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) !void {
+ return self.vtable.rename(self.ctx, path, new_path, kind);
+ }
+ pub fn delete(self: Api, path: []const u8) void {
+ return self.vtable.delete(self.ctx, path);
+ }
+ pub fn move(self: Api, path: []const u8, target_dir: []const u8) !bool {
+ return self.vtable.move(self.ctx, path, target_dir);
+ }
+ pub fn registerBranchDecorator(self: Api, decorator: BranchDecorator) !void {
+ return self.vtable.registerBranchDecorator(self.ctx, decorator);
+ }
+};
diff --git a/src/sdk/version.zig b/src/sdk/version.zig
new file mode 100644
index 00000000..008198c1
--- /dev/null
+++ b/src/sdk/version.zig
@@ -0,0 +1,57 @@
+//! SDK version and ABI fingerprint lock.
+//!
+//! `sdk_version` is bumped when the plugin ABI boundary changes. `recorded_abi_fingerprint`
+//! must be updated in the same commit — CI fails at compile time if the live fingerprint
+//! drifts without an intentional version bump.
+const std = @import("std");
+const builtin = @import("builtin");
+const dylib = @import("dylib.zig");
+
+pub const VersionTriplet = dylib.VersionTriplet;
+
+/// ABI contract version. Bump minor (or major for breaking changes) when
+/// `recorded_abi_fingerprint` changes.
+pub const sdk_version = std.SemanticVersion{
+ .major = 0,
+ .minor = 4,
+ .patch = 0,
+};
+
+/// Commit this literal alongside `sdk_version` when the ABI boundary changes.
+pub const recorded_abi_fingerprint: u64 = 0x868c117d77f99593;
+
+comptime {
+ // The ABI fingerprint guards the *dynamic* plugin-loading boundary, which is native-only
+ // (no `dlopen` on wasm; web plugins are statically linked into the app). The fingerprint is
+ // target-dependent — pointer width, etc. — so the recorded literal tracks native targets;
+ // enforcing it on wasm would fail spuriously. Skip the lock there.
+ if (builtin.target.cpu.arch != .wasm32 and dylib.abi_fingerprint != recorded_abi_fingerprint) {
+ @compileError(std.fmt.comptimePrint(
+ "ABI fingerprint is 0x{x} — bump sdk_version and set recorded_abi_fingerprint in src/sdk/version.zig",
+ .{dylib.abi_fingerprint},
+ ));
+ }
+}
+
+pub fn sdkVersionTriplet() VersionTriplet {
+ return .{
+ .major = sdk_version.major,
+ .minor = sdk_version.minor,
+ .patch = sdk_version.patch,
+ };
+}
+
+/// True when `required` (plugin min SDK) is satisfied by `host` (this Fizzy build).
+pub fn sdkVersionSatisfies(host: std.SemanticVersion, required: std.SemanticVersion) bool {
+ if (host.major != required.major) return host.major > required.major;
+ if (host.minor != required.minor) return host.minor > required.minor;
+ return host.patch >= required.patch;
+}
+
+pub fn formatVersion(v: std.SemanticVersion, writer: *std.Io.Writer) !void {
+ try writer.print("{d}.{d}.{d}", .{ v.major, v.minor, v.patch });
+}
+
+test "sdk version lock is self-consistent" {
+ try std.testing.expect(dylib.abi_fingerprint == recorded_abi_fingerprint);
+}
diff --git a/src/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/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;
diff --git a/src/web_main.zig b/src/web_main.zig
index 63524544..e2f05e34 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 pixi = @import("pixi");
+const Internal = pixi.internal;
// Wasm-cleanliness probes. Referencing each symbol forces semantic analysis of its
// module graph; any compile error pinpoints what to gate next. Zero-cost at runtime.
@@ -23,29 +25,28 @@ const fizzy = @import("fizzy.zig");
comptime {
// Pure constants / re-exports
_ = fizzy.version;
- _ = fizzy.fa.adjust;
_ = fizzy.atlas;
// Algorithms — pure Zig + dvui
- _ = fizzy.algorithms.brezenham;
- _ = fizzy.algorithms.reduce;
+ _ = pixi.algorithms.brezenham;
+ _ = pixi.algorithms.reduce;
// Top-level data types (.pixi format on-disk shapes)
- _ = fizzy.Animation;
- _ = fizzy.Atlas;
- _ = fizzy.File;
- _ = fizzy.Layer;
- _ = fizzy.Sprite;
+ _ = pixi.Animation;
+ _ = pixi.Atlas;
+ _ = pixi.File;
+ _ = pixi.Layer;
+ _ = pixi.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;
@@ -54,11 +55,11 @@ comptime {
_ = fizzy.image.init;
_ = fizzy.image.pixels;
_ = fizzy.perf.record;
- _ = fizzy.render;
+ _ = pixi.render;
// Custom dvui wrapper + widgets — types compile even though the widget files
// contain dead `@import("backend")` SDL3 imports at file scope.
- _ = fizzy.dvui.FileWidget;
+ _ = pixi.widgets.FileWidget;
_ = fizzy.dvui.CanvasWidget;
// The big ones: Editor + App. Type-level reference only — passes because Zig
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).
diff --git a/tests/fizzy_shim.zig b/tests/fizzy_shim.zig
index 13f7a0f6..5493d3cb 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.pixi_state.deinit(gpa);
+ gpa.destroy(self.editor.pixi_state);
self.editor.arena.deinit();
gpa.destroy(self.editor);
gpa.destroy(self.app);
@@ -51,10 +53,17 @@ 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 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 };
+
return .{ .t = t, .app = app_ptr, .editor = editor_ptr };
}
diff --git a/tests/integration.zig b/tests/integration.zig
index fcbc2361..97ba64f5 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 pixi = fizzy.pixi_mod;
-const Internal = fizzy.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` /
@@ -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 `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 `fizzy.File` types and
+// renames or retypes a field on the public `pixi.File` types and
// silently breaks loading older saves.
// -------------------------------------------------------------------
-test "fizzy.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 },
@@ -234,7 +235,7 @@ test "fizzy.File parses current-format JSON and round-trips" {
;
const parsed = try std.json.parseFromSlice(
- fizzy.File,
+ pixi.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,
+ pixi.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 "pixi.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,
+ pixi.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 "pixi.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,
+ pixi.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 "pixi.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,
+ pixi.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 pixi.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 pixi.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 pixi.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 pixi.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 pixi.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 = [_]pixi.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 = [_]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 = [_]fizzy.Animation.Frame{
+ var expect_three = [_]pixi.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 = [_]pixi.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 pixi.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;
+ 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
new file mode 100644
index 00000000..6f3ec2aa
--- /dev/null
+++ b/tests/plugin_loader_integration.zig
@@ -0,0 +1,33 @@
+//! 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 `pixi.State` — register only stores the pointer.
+ var state_buf: [8192]u8 align(16) = undefined;
+
+ const before = host.plugins.items.len;
+ var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixi_dylib, "pixi", .{
+ .gpa = &std.testing.allocator,
+ .arg_b = &state_buf, // pixelart convention: arg_b = *State
+ .arg_c = null,
+ });
+ defer loaded.lib.close();
+
+ try std.testing.expect(host.plugins.items.len == before + 1);
+ const pa = host.pluginById("pixi") orelse return error.TestExpectedEqual;
+ try std.testing.expectEqualStrings("pixi", pa.id);
+ try std.testing.expect(host.sidebar_views.items.len >= 3);
+
+ loaded.set_dvui_context(null, null, null, null);
+ loaded.set_globals(@ptrCast(&std.testing.allocator), &state_buf, null);
+}
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");
}