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/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..85bcaf49
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,85 @@
+# Fizzy
+
+Cross-platform, open-source general editor written in Zig, UI via [DVUI](https://github.com/david-vanderson/dvui). Targets native (macOS/Linux/Windows) and web (wasm32). Layout/UX is IDE-shaped (VSCode-like): sidebar rail + explorer, menubar, center tabs/splits, bottom panel, infobar.
+
+**Read this file first, then go deeper via the links below — don't re-derive the architecture from scratch.**
+
+## The core idea: shell + plugins
+
+Fizzy the app is a near-empty **shell** (window, frame loop, menu/sidebar/panel layout, document model) that owns **no editing features**. Everything the user sees — pixel-art editing, the file explorer/tabs/splits, text editing — is contributed by **plugins** that register against a stable SDK. Plugins never import each other; they meet only at the SDK.
+
+```
+Shell (Editor) ←── Host registries + EditorAPI ──→ Plugin (register(host) + vtable)
+```
+
+- **`src/sdk/`** — the entire contract. `Host` (registries + service locator), `Plugin` (identity + vtable of hooks the shell calls), `DocHandle` (opaque `{ptr, id, owner}` — shell routes every doc op to `owner`, never inspects `ptr`), `EditorAPI` (shell read/util surface plugins reach back through), `regions.zig` (sidebar/bottom/center/menu/settings/command contribution structs), `dylib.zig`/`dvui_context.zig` (runtime-library C-ABI + dvui injection).
+- **`src/editor/`** — the shell itself: `Editor.zig` (frame loop, plugin registration/loading), `PluginLoader.zig` (dlopen), `Menu.zig`, `Sidebar.zig`, `Settings.zig`, etc.
+- **`src/core/`** — shared infra (Atlas/Sprite, math, gfx, fs, paths, platform detection) used by shell *and* plugins. Not plugin-owned; don't move it.
+- **`src/plugins/`** — bundled built-in plugins. Each is file-for-file the **same shape a third-party plugin would use**: `build.zig`, `build.zig.zon`, `root.zig` (dylib entry, copy-only), `src/plugin.zig` (the one file you actually implement: `register(host)` + `Plugin.VTable`), plus fizzy-internal glue isolated in a `static/` subfolder + a root `.zig` hub. Builds standalone with `cd src/plugins/ && zig build`.
+
+**Two link modes, one source:** built-in plugins compile **static** (linked directly, all targets incl. web) or **dynamic** (`.dylib`/`.so`/`.dll`, desktop-only, `dlopen`'d — this is how third-party plugins ship too). `FIZZY_STATIC_=1` env var forces static for a given built-in (useful when debugging dylib loading).
+
+## Currently bundled plugins (check `ls src/plugins/` — this list moves)
+
+- **`workbench`** — file tree, tabs/splits, center provider; owns no documents. Exposes a `workbench-api` service other plugins use to open/close/manage files without importing workbench.
+- **`text`** — generic text/code editor; fallback owner for any file extension nothing else claims. (Recently renamed from `code`.)
+- **`example`** — minimal always-compiling template; **copy this folder to start a new plugin**.
+- `shared` — build helpers used across plugins' `static/integration.zig` (not a plugin itself).
+
+**Pixi (pixel-art editor) has been extracted out of this repo** into an external plugin (`~/dev/fizzyedit/pixi`, loaded like any third-party dylib) — see [`PIXI_EXTRACTION_PLAN.md`](PIXI_EXTRACTION_PLAN.md) for why/status. Older docs/handoffs (`HANDOFF.md`) still describe pixi as in-tree — that's historical, not current. **Trust `ls src/plugins/` and `git log` over any doc's plugin list.**
+
+## Writing a plugin
+
+1. Copy `src/plugins/example/` as your template.
+2. Implement `src/plugin.zig`: a `Plugin` value (id, display_name, vtable), `register(host)` (wires state, calls `host.registerPlugin` + any `host.register{SidebarView,BottomView,CenterProvider,Menu,SettingsSection,Command,Service}`), and a `VTable` with only the hooks you need.
+3. Editor plugins (open/save/draw files) implement the document vtable cluster: `fileTypePriority`, `loadDocument`, `drawDocument`, `saveDocument`, `isDirty`, undo/redo, etc. Shell plugins (workbench-style) skip all of that and register a center provider + sidebar views instead.
+4. User-invoked actions (copy/paste/transform/delete, plugin-specific features) are **`Command`s**, not vtable hooks — registered by id, dispatched by the shell via `host.runCommand("")` without knowing what they do. Editing verbs follow the convention `"."`.
+5. `zig build install` builds for the current OS and drops the plugin straight into the fizzy plugins dir (`~/Library/Application Support/fizzy/plugins/` on macOS) — no manual copying, just relaunch.
+6. Memory: `host.allocator` (persistent, you own frees) vs `host.arena()` (per-frame scratch, never hold past the frame). Never touch `dvui.currentWindow().gpa` directly.
+7. There's no ABI version negotiation — a structural **fingerprint** over every boundary type is computed at compile time on both sides; any mismatch is a hard reject at load (`fizzy_plugin_abi_fingerprint`). Fingerprint bumps are meant to be rare/deliberate (pinned dvui + zig version).
+
+Full contract, hook-by-hook lifecycle table, and the pixelart/workbench worked example: **[`docs/PLUGINS.md`](docs/PLUGINS.md)**.
+
+## Known friction / active work (check before assuming something is a hard blocker)
+
+- **[`docs/PLUGIN_ROUGH_EDGES.md`](docs/PLUGIN_ROUGH_EDGES.md)** — punch list of SDK friction points for third-party plugin authors, with status markers (🔴/🟡/🟢). Most of the "large" items from the original pass are done; check status before treating an item as still open.
+- **[`PIXI_EXTRACTION_PLAN.md`](PIXI_EXTRACTION_PLAN.md)** — active migration: pulling pixi out to an external repo, stripping pixi-specific coupling from the shell, `code`→`text` rename. Has a live status checklist at the bottom — read that section for current state, not the narrative above it.
+- **[`PLUGINS_PLAN.md`](PLUGINS_PLAN.md)** — plugin store design (registry, install flow) — mostly forward-looking/not-yet-built.
+- **`HANDOFF.md`** — historical Phase 4 handoff (compile-time modular separation). Superseded by the two docs above for anything plugin-related; useful only for the older "how did we get here" narrative.
+
+## Build
+
+```sh
+zig build # native exe
+zig build check-web # wasm
+zig build test # unit/integration tests
+zig build test-sdk-version # CI lock: ABI fingerprint bump must bump sdk_version too
+```
+
+Run all of these after touching the SDK boundary (`src/sdk/**`) or a plugin's vtable usage.
+
+### Keep the plugin build free of app-only dependencies
+
+Every plugin build (including third-party ones like pixi) compiles this repo's root `build.zig` via `b.dependency("fizzy", .{ .plugin_sdk = true })`. Because `@import` is **comptime + transitive**, anything the root `build()` can reach at comptime is pulled into *every* plugin build — even code guarded by a runtime `if (plugin_sdk) return;`. So an app-only dependency reached through a plain top-level `@import("")` (like Velopack, the host's self-installer/updater) leaks its whole graph — wrapper + prebuilt archives — into plugin builds that never use it.
+
+Rule: **app-only deps must never be reachable via comptime `@import` from the root build graph.** The pattern (see Velopack):
+
+- Mark the dep `.lazy = true` in `build.zig.zon`.
+- Never `@import("velopack_zig")` anywhere in the build graph. The helper surface is **vendored** in `build/velopack.zig` (pure `std.Build` glue) and takes the dependency as a handle.
+- Resolve it lazily *inside the app build only*: `const vz = b.lazyDependency("velopack_zig", .{}) orelse return;` in `build/app.zig`, then thread `vz` through `build/exe.zig` / `build/package.zig`.
+
+Acceptance test after any build-graph change: cross-build the example plugin and confirm no leak —
+
+```sh
+cd src/plugins/example && rm -rf .zig-cache zig-out zig-pkg && zig build -Doptimize=ReleaseFast
+ls zig-pkg | grep -i velo # must be empty
+```
+
+CI builds plugins for all 6 host targets by cross-compiling with `-Dtarget=` (see `fizzyedit/plugin-build-action`); pure-Zig + vendored-C plugins don't need per-arch runners.
+
+## When you need more than this file
+
+- Full plugin contract + lifecycle/hook tables → `docs/PLUGINS.md`
+- SDK friction backlog / what's still rough for third-party authors → `docs/PLUGIN_ROUGH_EDGES.md`
+- What's actively being restructured right now → `PIXI_EXTRACTION_PLAN.md` (check its Status section, it moves fast)
+- Don't trust a doc's list of "current plugins" or "current architecture state" over `ls src/plugins/` + recent `git log` — this codebase is mid-refactor and docs lag.
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/PIXI_EXTRACTION_PLAN.md b/PIXI_EXTRACTION_PLAN.md
new file mode 100644
index 00000000..0e2fed00
--- /dev/null
+++ b/PIXI_EXTRACTION_PLAN.md
@@ -0,0 +1,252 @@
+# Pixi Extraction & Shell Genericization — Plan & Agent Handbook
+
+> Living doc for extracting the pixel-art editor (`pixi`) out of the Fizzy repo into a
+> store-installable external plugin, stripping all pixi-specific functionality from the shell,
+> and reducing the bundled built-ins to **workbench + example + text** (text = renamed `code`).
+> Update the Status boxes as chunks land. Companion to `PLUGINS_PLAN.md` (the store).
+
+## 0. Context & end state
+
+Today the shell is hard-coupled to pixi: it holds `editor.pixi_state: *pixi.State`, `@import("pixi")`,
+a special packer injection, and the build system embeds pixi's C deps. The shell also owns a
+spritesheet (`fizzy.atlas`) served via `host.uiAtlas()`, used by pixi (tool cursors) **and**
+workbench (the fizzy logo). File-type icons are a hardcoded `switch (ext)` in workbench that knows
+pixel-art + code extensions.
+
+**End state:** pixi is an external repo (`~/dev/fizzyedit/pixi`), installed via the store like
+markdown. The shell bundles only **workbench, example, text**, has **no** `uiAtlas`/sprite concept,
+and no built-in knows about another's file types. `core/` (Atlas/Sprite/math/gfx/fs/paths) stays as
+shared infra — external plugins use it via the `fizzy` SDK dep.
+
+### Confirmed decisions
+1. **Remove `host.uiAtlas()` from the SDK entirely.** pixi ships its own tool atlas; the logo moves
+ into workbench's own assets; `editor.atlas` is deleted. File icons become a **plugin draw hook**
+ (the owning plugin draws the icon for the file types it claims; workbench draws a generic default).
+2. **Decouple in place, then extract.** Strip every shell↔pixi coupling while pixi still lives in
+ `src/plugins/pixi` (trivially testable), then physically move it.
+3. **`code` → `text`** — keep built-in, generic text editor.
+
+---
+
+## 1. Work breakdown
+
+`[ ]` todo · `[~]` in progress · `[x]` done · **Dep:** prerequisite chunks
+
+### Phase A — decouple in place (pixi stays in `src/plugins/pixi`, gains zero shell refs)
+
+#### A1 — Genericize the homepage · Status: [x]
+Removed the pixel-art "New File" button from `workbench/src/Workspace.zig` `drawHomePage` (it
+dispatched `requestNewDocument` → pixi). New File still reachable via menu + file-tree. Builds green.
+Future: a hook for plugins to contribute homepage entries / their own homepage (not now).
+
+#### A2 — File-type icon draw hook · Status: [x] **Dep:** none
+> **Done.** Added a `Host.FileIcon` **draw** resolver (mirrors `FileRowFillColor`): a registry of
+> `draw(ctx, ext, path, color) bool` drawers; `host.drawFileIcon` calls them in order, first
+> `true` wins. Owner-scoped, so `unregisterPlugin` drops it (test extended).
+> - `workbench/src/files.zig`: the per-extension icon `switch` + the `.fizzy`→`uiAtlas` logo branch
+> are gone; it calls `host.drawFileIcon(...)` and only falls back to generic filesystem icons
+> (`.pdf`→doc-text, archives→archive, else→archive) when no plugin handled it. One `uiAtlas`
+> consumer removed.
+> - `pixi/src/plugin.zig`: `registerFileIcon` → draws `entypo.brush` for `.fiz/.pixi`,
+> `entypo.image` for flat images (reusing `Internal.File.isFizzyExtension`/`isFlatImageExtension`);
+> defers otherwise. (`.fiz` still a glyph for now; A3 can swap it to pixi's own logo sprite.)
+> - `code/src/plugin.zig`: `registerFileIcon` → draws `entypo.code` for a source/text extension set;
+> defers binaries to the workbench default. Glyphs come from `dvui.entypo.*` (always wired) — no
+> `icons` dep needed.
+> - `openablePath` was already plugin-driven (`host.pluginForExtension`); the leftover `.fiz/.png/…`
+> arms in workbench's `extension()`→`Extension` enum are now vestigial (icon switch falls them to
+> `else`) — harmless, optional cleanup.
+> - **SDK boundary change:** bumped `sdk_version` 0.5.0 → **0.6.0**, `recorded_abi_fingerprint =
+> 0xd6a131bddefe4fe1`. (markdown will need a rebuild against ≥0.6.0.)
+>
+> **Accept:** ✅ build + `test` + `test-sdk-version` green; no plugin-specific icon literals left in
+> workbench. (Visual confirmation of the rendered tree is a GUI check for the user.)
+
+#### A3 — Split the atlas + delete `uiAtlas` · Status: [x] **Dep:** A2 (icons no longer via atlas)
+> **Done.** Simpler than feared: workbench used `uiAtlas` only once (the `logo_default` tab icon
+> for `.fiz` files), and `fox_default` was unused — so workbench ends up needing **no atlas**.
+> - **pixi** self-loads its tool/cursor spritesheet into `State.ui_atlas: core.Atlas` (from the
+> `assets` module it already depends on, via `core.Atlas.loadSpritesFromBytes` +
+> `core.image.fromImageFileBytes`); new `runtime.uiAtlas()` accessor; all ~28
+> `runtime.state().host.uiAtlas()` call sites in `Tools/radial_menu/explorer.tools/FileWidget`
+> now read pixi's own atlas (which is `core.Sprite`-native — pixi already uses `core_sprite`).
+> - **workbench**: the `is_fizzy_file`→logo tab icon now routes through the A2 `host.drawFileIcon`
+> hook (pixi draws its `.fiz` glyph), with a generic file-glyph fallback; removed the
+> `documentHasNativeExtension` coupling + `wb.atlas`/`wb.Sprite` use. `.fiz` files now show
+> pixi's brush glyph in tree + tabs (consistent; the logo sprite is no longer used).
+> - **Deleted**: `EditorAPI.{UiSprite,UiAtlasView,uiAtlas}` + the vtable field, `Host.uiAtlas`,
+> `sdk.{UiSprite,UiAtlasView}`, `shellUiAtlas`, and `editor.atlas` (field/load/deinit). The shell
+> no longer carries a sprite atlas.
+> - **SDK boundary change:** `sdk_version` 0.6.0 → **0.7.0**, `recorded_abi_fingerprint =
+> 0x1bb54eb7506cbd78`.
+>
+> **Accept:** ✅ `grep uiAtlas src/` finds only pixi's own `runtime.uiAtlas()`; build + `test` +
+> `test-sdk-version` green. (`fizzy.atlas`/`fizzy.png` + `core/generated/atlas.zig` still live in
+> the shared assets/core for now; they move into pixi in **B1**. Tool/cursor + tab/tree icon
+> rendering is a GUI check for the user.)
+- **pixi** ships its own tool spritesheet under `src/plugins/pixi/assets/` and loads/draws tools from
+ it (it already uses `core.Atlas`/`Sprite`); replace every `runtime.state().host.uiAtlas()` in
+ `pixi/src/{Tools,radial_menu,explorer/tools}.zig` with pixi's own atlas.
+- **workbench** moves the fizzy **logo** into its own bundled asset + loads it directly; replace the
+ `host.uiAtlas()` logo read in `Workspace.zig:~261`.
+- Delete `EditorAPI.{uiAtlas,UiAtlasView,UiSprite}`, `Host.uiAtlas`, `sdk.{UiSprite,UiAtlasView}`,
+ the `EditorAPI.VTable.uiAtlas` field + `shellUiAtlas`, and `editor.atlas` (field, load at
+ `Editor.zig:266`, deinit at `:3257`). **SDK boundary change → bump `sdk_version` +
+ `recorded_abi_fingerprint`** (and update markdown's pin note). **Accept:** `grep uiAtlas` is empty
+ in `src/`; `test-sdk-version` green; tools + logo render from their own assets.
+
+#### A4 — Remove shell↔pixi concrete coupling · Status: [x] **Dep:** A3
+> **Done.** Pixi is now a normal generic-dylib plugin that owns its own state — the shell has zero
+> pixi *source* coupling (only the bundled id string `"pixi"` remains, removed in B1).
+> - **pixi** owns `State` + `Packer` as plugin-image statics, created in `register` (`State.init`
+> + `Packer.init`, both context-free → safe before dvui injection; texture upload is lazy) and
+> torn down in `pluginDeinit` (which also persists the `.fizproject`). `runtime.adoptState`
+> replaces `adoptShellState`; `setPacker` removed.
+> - **Deleted from the shell:** `Editor.{pixi_state, @import("pixi"), pixiPlugin, loadPixiDylib,
+> loadPixiFromDylibEnabled, syncLoadedPixiGlobals}`, `fizzy.{pixi_mod, packer}`, and all of
+> `App.zig`'s pixi allocation/`adoptShellState`/`setPacker`/`persistProject`/teardown. Pixi loads
+> via a new generic `Editor.loadBundledPluginDylib(id)` (`arg_b=*Host, arg_c=null`), **no static
+> fallback** (so web, which can't dlopen, simply has no pixi — consistent with extraction).
+> - `FIZZY_STATIC_PIXI` path removed; `"pixi.menu.edit"` → `"shell.menu.edit"`.
+> - **No SDK boundary change** (these are shell internals) → fingerprint unchanged (0.7.0).
+>
+> **Accept:** ✅ `grep` finds no `@import("pixi")`/`pixi.State`/`pixi_state`/`fizzy.packer` in the
+> shell; build + `test` + `test-sdk-version` (which builds the pixi dylib) all green.
+>
+> **Shutdown-crash fix (A4 regression, found in GUI smoke test):** moving pixi's `State` free into
+> `pluginDeinit` exposed a shell ordering bug — `Editor.deinit` ran the plugin-`deinit` loop
+> *before* `workbench.deinitWorkspaces()`, which calls back into the owner via `removeCanvasPane`
+> → use-after-free of the just-freed pixi state (`0xaa…`). Fixed by tearing down workspaces
+> **before** the plugin-deinit loop (don't invoke plugin hooks after a plugin's `deinit`); removed
+> the stale "App.AppDeinit frees pixi state" comment. Verified: clean `NSRunningApplication.terminate()`
+> quit, no segfault, no leak reports.
+>
+> **Deferred (small, non-blocking):** `Editor.save_as_dialog_filters` still hardcodes
+> `"fiz;pixi"`/png/jpg. Genericizing needs an owner-supplied-filters vtable hook (an SDK change);
+> tracked as a follow-up — it doesn't block extraction (the dialog works as-is). Runtime GUI check
+> (pixi loads as a dylib, tools/canvas render, `.fiz` open/save) is for the user.
+Make pixi a normal generic-dylib plugin (owns its own state, like `example`/`markdown`):
+- Pixi owns its `State` + `Packer` internally (module-level, set in `register`) instead of the shell
+ allocating them. Remove the special `arg_c = *Packer` / `arg_b = *State` injection → load via the
+ generic convention (`arg_b = *Host`, `arg_c = null`).
+- Delete from `Editor.zig`/`App.zig`: `pixi_state` field, `@import("pixi")`, `loadPixiDylib`,
+ `loadPixiFromDylibEnabled`, `syncLoadedPixiGlobals`, `pixiPlugin()`, the `FIZZY_STATIC_PIXI` path,
+ and the `"pixi"` arm of `isBundledPluginId`/`registerMenu("pixi.menu.edit")`/save-dialog filters
+ that name `.fiz/.pixi`. The Edit-menu + New-File dialog dispatch already route by active-owner /
+ `requestNewDocument`, so they stay generic.
+- Pixi loads through the same path as any user plugin (bundled dylib in `{exe}/plugins/pixi.`
+ during this phase). **Accept:** `grep -i pixi src/editor src/App.zig` finds nothing; app runs with
+ pixi loaded as a plain dylib; open/edit/save a `.fizzy` doc works.
+
+#### A5 — Verify fully decoupled · Status: [ ] **Dep:** A4
+`grep -rin pixi src/editor src/sdk src/App.zig` ⇒ empty. App builds + runs with pixi bundled via the
+generic dylib path; `zig build test` + `test-sdk-version` green. Pixi is now a drop-in external
+candidate.
+
+### Phase B — extract & publish
+
+#### B1 — Move pixi to `~/dev/fizzyedit/pixi` · Status: [x] **Dep:** A5
+> **B1a done (external pixi builds).** Copied the pixi tree + its assets (`fizzy.atlas`,
+> `fizzy.png`, `assets/src/*.pixi`, `palettes/`, `.fizproject`) + the generated sprite table
+> (`core/generated/atlas.zig` → `pixi/src/generated/atlas.zig`) into `~/dev/fizzyedit/pixi`.
+> Adjusted: `pixi.zig` `atlas` now imports its own `src/generated/atlas.zig` (not `core.atlas`);
+> `build.zig` packs its own `assets/`; `build.zig.zon` pins `fizzy` at `../../fizzy`; dropped the
+> fizzy-internal `static/`. **`zig build` produces `pixi.dylib`, probed fingerprint
+> `0x1bb54eb7506cbd78` = fizzy 0.7.0, id `pixi`** — a valid store plugin. fizzy untouched + still
+> green.
+>
+> **B1b done (fizzy's build stripped of pixi).** Focused pass; `zig build` + `test` +
+> `test-sdk-version` + `check-web` all green with only workbench/example/code bundled.
+> 1. ✅ Deleted `src/plugins/pixi/` (it owned the vendored `zstbi`/`msf_gif`/`zip` C deps).
+> 2. ✅ `build/plugins.zig`: dropped `pixi` import + `ZipPackage`.
+> 3. ✅ `build/exe.zig`: removed `pixi_plugin`, the `zstbi`/`msf_gif`/`zip` exe imports +
+> `addStaticModule(pixi)` + `pixi_dylib` + `linkZipNative` + the
+> `zstbi_module`/`msf_gif_module`/`pixi_dylib`/`zip_pkg`/`process_assets_step`/`sdk_module`
+> fields/params on `FizzyExecutable` + `addFizzyExecutableForTarget`.
+> 4. ✅ `build/web.zig`: removed `pixi_plugin` + `zip`/`zstbi`/`msf_gif` wiring + `zip_pkg` param.
+> 5. ✅ `build/app.zig`: removed `pixi_plugin`, `static-pixi` option, `zip_pkg`, the bundled-pixi
+> installs, the `pixi-dylib` step; **dropped** `test-plugin-loader` (+ `tests/plugin_loader_integration.zig`,
+> a pixi-specific dlopen test — belongs in pixi's repo now); removed the pixi pure-logic test
+> entries; repointed `test-sdk-version` at the **workbench** dylib; removed `linkZipNative` and the
+> **`process-assets` step**.
+> 6. ✅ `tests/root.zig`: dropped the pixi pure-logic `@import`s.
+> 7. ✅ `src/core/core.zig`: removed `pub const atlas` re-export; deleted `src/core/generated/`.
+> Removed the dead `atlas`/`Sprite` re-exports in `workbench.zig` + the `atlas` one in `fizzy.zig`
+> (`core.Atlas`/`core.Sprite` generic types stay — shared infra). Stripped pixi probes from
+> `web_main.zig`.
+> 8. ✅ fizzy `assets/`: deleted `fizzy.atlas`, `fizzy.png`, `assets/src/*.pixi`, `.fizproject`, and
+> `assets/palettes/` (kept fonts/icon/fox/macos/windows).
+> 9. ✅ `Editor`: removed the `loadBundledPluginDylib` helper + its `"pixi"` call; dropped the `"pixi"`
+> arm from both `Editor.isBundledPluginId` **and** `PluginStore.isBundled` (so the store can manage
+> pixi); deleted `process_assets.zig` (+ its `build.zig`/`build.zig.zon` references).
+> 10. ✅ Verified green.
+>
+> **Deferred (unchanged from A4):** shell save/open dialog filters still hardcode `.fiz`/`.pixi`/png/jpg
+> (`Editor.zig`, `Keybinds.zig`, `WebFileIo.zig`, `file_assoc.zig`) — needs the owner-supplied-filters
+> vtable hook; doesn't block extraction. `Settings.zig`'s legacy-migration `"pixi"` key kept for
+> backwards-compat. `PluginLoader.zig`'s path-format unit test still uses `"pixi"` as a sample string
+> (pure string test, no dep).
+- Move `src/plugins/pixi/*` → the external repo (canonical third-party shape, like markdown). Its
+ `build.zig` owns the C deps (`zstbi`, `msf_gif`, `zip`) + its atlas asset; pin `fizzy` to a git
+ url+hash.
+- Remove pixi from `build/{plugins,exe,app,web}.zig`: the static integration import, `pixi_dylib`,
+ `static-pixi` option, bundled-plugin install, `pixi-dylib`/`test-plugin-loader` steps (repoint the
+ loader test at `example`), and the pure-logic test entries (`fizzy-layer-order`, `…palette-parse`,
+ `…reduce`, `…grid-validate`, `…animation`) — those move to pixi's own tests.
+- **Accept:** fizzy builds with only workbench/example/text; `grep pixi build/ src/` empty.
+
+#### B2 — Publish pixi to the store · Status: [ ] **Dep:** B1, store live
+Registry entry in `fizzyedit/plugins` + author `manifest.json` + `release.yml` (mirror markdown,
+Chunk 7 of `PLUGINS_PLAN.md`). Pixi installs from the store on a matching SDK fingerprint.
+
+### Phase C — text editor (independent, can run any time)
+
+#### C1 — Rename `code` → `text`, generic built-in · Status: [ ] **Dep:** none
+Rename `src/plugins/code` → `src/plugins/text`, module `code`→`text`, id `"code"`→`"text"`, settings
+key, `FIZZY_STATIC_CODE`→`…TEXT`, build wiring in `build/{plugins,exe,app,web}.zig` + `Editor.zig`
+(`loadCode*`, `codePlugin`, `isBundledPluginId`). Drop the stray pixi comment in `code/src/plugin.zig`.
+Keep it the fallback editor (claims every extension at `file_type_fallback_priority`). **Accept:**
+text editing works; `grep -i \\bcode\\b` shows no leftover plugin-id references; built-ins =
+workbench/example/text.
+
+---
+
+## 1b. Plugin install = `zig build install` + shared config-path resolver
+`fizzy.plugin.install(b, lib, .{})` (build-side only — **no fingerprint bump**) now does both:
+emits `zig-out/.` for packaging/store-CI **and** copies `.` into the fizzy
+plugins dir the editor scans. Skips silently when no config home resolves (bare CI runner). One
+call; `zig build install` is the canonical "test a plugin" command.
+
+**Single source of truth for the location:** added `core.paths.localConfigRoot(os, …)` (pure
+per-OS rule: macOS `~/Library/Application Support`, Linux `$XDG_CONFIG_HOME`/`~/.config`, Windows
+`%LOCALAPPDATA%`). Both the runtime loader (`paths.configRoot`) and the build installer
+(`plugin_sdk.fizzyPluginsDir`) call it, so install + load can't drift. **Dropped the
+`known-folders` dependency entirely** (was used only by `paths.zig`); removed its wiring from
+`build.zig.zon`, `exe.zig`, `app.zig`, `web.zig`, `plugin_sdk.zig`. Docs updated. (pixi + markdown
+currently installed in the dev plugins dir, ready for the GUI smoke test.)
+
+## 2. Risks & notes
+- **A3 + A4 bump the ABI fingerprint** (SDK surface + boundary). Bundled built-ins rebuild; markdown
+ needs a rebuild against the new SDK before its store entry matches again.
+- The shell allocates pixi's `State`/`Packer` in `App.zig` today — A4 must move that ownership into
+ pixi without breaking the save-queue worker (`pixi/src/internal/File.zig` `SaveQueue`).
+- Keep `core/` in the editor — it's shared by workbench/text/shell and reached by external plugins
+ via the SDK dep. Do **not** move it with pixi.
+- Save/open dialogs in the shell currently hardcode image filters (`fizzy;png;jpg;jpeg`) — generalize
+ or let the active plugin supply filters during A4.
+
+## 3. Verification per phase
+- After each chunk: `zig build`, `zig build test`, `zig build test-sdk-version`.
+- A5 / B1: run the app, open + edit + save a `.fizzy` doc (pixi as dylib), confirm tools/cursors/logo
+ render and the file tree shows icons.
+- B2: store install of pixi via the local registry server (PLUGINS_PLAN §8 pattern).
+
+## 4. Status
+- [x] A1 — Genericize homepage
+- [x] A2 — File-type icon draw hook *(Host.FileIcon resolver; pixi/code draw their own; SDK 0.6.0, fp 0xd6a131bddefe4fe1)*
+- [x] A3 — Split atlas + delete uiAtlas *(pixi self-loads its atlas; shell + SDK lose uiAtlas; SDK 0.7.0, fp 0x1bb54eb7506cbd78)*
+- [x] A4 — Remove shell↔pixi coupling *(pixi owns State+Packer; generic dylib load; no @import("pixi") in shell)*
+- [ ] A5 — Verify decoupled *(mostly done inline in A4; save-filter follow-up + GUI check remain)*
+- [x] B1 — Move pixi to external repo *(B1a: external pixi builds + ABI-verified; B1b: fizzy build stripped — only workbench/example/code bundled; build/test/test-sdk-version/check-web green)*
+- [ ] B2 — Publish pixi to store
+- [ ] C1 — Rename code → text
diff --git a/PLUGINS_PLAN.md b/PLUGINS_PLAN.md
new file mode 100644
index 00000000..8d28b73b
--- /dev/null
+++ b/PLUGINS_PLAN.md
@@ -0,0 +1,529 @@
+# Plugin Store — Implementation Plan & Agent Handbook
+
+> Living document for building Fizzy's **Plugin Store**. Self-contained: a cold agent should be
+> able to read the relevant sections and execute a chunk without prior context. Update the
+> **Status** checkboxes as chunks land.
+
+---
+
+## 0. Orientation — what Fizzy is (read first)
+
+Fizzy is a **shell-first native editor** (Zig + [dvui]). The shell (`src/editor/`) owns only a
+window, frame loop, menu/sidebar/panel layout, and a document model. **Every feature is a plugin**
+that registers against a stable SDK (`src/sdk/`). The pixel-art editor (`pixi`), file explorer +
+tabs/splits (`workbench`), and text editor (`code`) are all plugins.
+
+The same plugin source compiles **two ways**: statically into the app, or as a runtime native
+**dylib** loaded via `dlopen`. Built-ins ship inside the signed app; third-party plugins ship as
+loadable dylibs.
+
+### Key SDK types (`src/sdk/`)
+
+| Type | File | Role |
+|------|------|------|
+| `Host` | `Host.zig` | What the shell hands every plugin. Holds the **registries** (sidebar/bottom/center/menu/settings/commands) + a **service locator** + `register*` methods. Embedded in `Editor`. |
+| `Plugin` | `Plugin.zig` | A plugin's identity (`id`, `display_name`) + a **vtable** of ~30 optional hooks. |
+| `DocHandle` | `DocHandle.zig` | Opaque open-document handle `{ ptr, id, owner: *Plugin }`. The shell routes *every* doc op to `owner.(doc)` — it never inspects `ptr`. |
+| `EditorAPI` | `EditorAPI.zig` | The shell's read/utility surface plugins reach back through (arena, folder, docs, dialogs). Reached via `Host`. |
+| `regions` | `regions.zig` | Contribution structs: `SidebarView`, `BottomView`, `CenterProvider`, `MenuContribution`, `SettingsSection`, `Command`. |
+| `dylib` / `dvui_context` / `render_bridge` | `dylib.zig`, … | The C-ABI entry contract + dvui/render injection used for runtime dylibs. |
+
+**The shell owns no features.** Each frame it iterates the `Host` registries and draws whatever
+plugins contributed. Adding a pane/panel/menu/settings section is a `Host.register*` call from a
+plugin's `register(host)` — never a shell edit.
+
+Authoritative reference: **`docs/PLUGINS.md`** (full architecture). Don't duplicate it — read it.
+
+### How a runtime plugin loads (today)
+
+`src/editor/PluginLoader.zig` → `loadAndRegister(host, path, expected_id, pre)`:
+1. `dlopen` the dylib (`std.DynLib`; Windows wrapper included).
+2. Read `fizzy_plugin_abi_fingerprint` — **hard reject** if it ≠ host's (`error.AbiMismatch`).
+3. Read `min_sdk_version`, `plugin_version`, `plugin_id` C exports; check SDK version + id==filename.
+4. Inject globals (allocator + `*Host`), then call `fizzy_plugin_register(host)`.
+5. Caller syncs dvui context + render bridge, appends to `editor.loaded_plugin_libs`.
+
+Discovery: `Editor.loadUserPlugins(config_folder)` scans `{config}/plugins/*.{dylib|so|dll}`, where
+each file's basename = its plugin id. Failures recorded in `editor.failed_user_plugins`.
+
+Install path layout (flat): `{config}/plugins/{id}.{ext}` —
+macOS `~/Library/Application Support/fizzy/plugins/`, Linux `~/.config/fizzy/plugins/`,
+Windows `%APPDATA%/fizzy/plugins/`.
+
+### The three versions (don't conflate)
+
+| Version | Owner | Gates plugin load? |
+|---------|-------|--------------------|
+| **App version** (`VERSION`, `build.zig.zon`) | Fizzy release | No |
+| **SDK version** (`src/sdk/version.zig` `sdk_version`) | ABI contract | Soft (min_sdk_version) |
+| **Plugin version** (`PluginManifest.version`) | plugin author | No |
+
+The **hard** gate is the **ABI fingerprint** (`src/sdk/dylib.zig` `abi_fingerprint`,
+`src/sdk/fingerprint.zig`): a comptime structural hash over every boundary type. No semver
+tolerance. A plugin binary is valid for exactly one `(zig version, dvui version, SDK contract)`
+tuple. This is central to the whole distribution design — see §A below.
+
+---
+
+## 1. Goal
+
+Add a **Plugin Store**: a sidebar icon above Settings opening an explorer tab of plugin **cards**
+(available + installed, with versions + active state), with **live install / update / enable /
+disable / unload** in-session, backed by a **decentralized** registry where authors host their own
+binaries.
+
+### Confirmed decisions
+
+1. **Store is a shell built-in** (`src/editor/`, like `InstalledPlugins.zig`) — needs shell
+ internals not exposed to sandboxed plugins. Presents *as* a plugin (own icon + tab).
+2. **Decentralized distribution, no child-CI triggering.** Authors build + host their own binaries;
+ the central `fizzyedit/plugins` repo is registration + index **aggregation** only.
+3. **Full live load + unload now** — implement `Host.unregisterPlugin`.
+4. **SDK hardening = cadence + pinning only** (no boundary refactor this round).
+
+---
+
+## A. Resilience model (why fingerprint cadence matters)
+
+The fingerprint is a hard gate, and the dvui/zig coupling is **inherent**: a plugin links its own
+dvui and operates on host-injected dvui globals (`dvui_context.zig` sets
+`dvui.current_window = host_ptr`), so host + plugin must share the same dvui + compiler. We
+**cannot** make a prebuilt native dylib survive a dvui/zig change. The lever is making fingerprint
+bumps **rare and deliberate** so plugins rebuild only on intentional SDK bumps, not every release:
+
+- **Pin dvui + zig** to explicit versions; bump deliberately/batched. (Biggest lever — tracking
+ dvui-dev tip flips the fingerprint constantly.)
+- **Decouple app-version from SDK-version**: a Fizzy release that doesn't touch zig/dvui/boundary
+ keeps the **same fingerprint**, so installed plugins stay valid across many app releases.
+- The fingerprint stays the store's match key. Authors rebuild when the SDK version is announced to
+ bump; the store shows "needs a rebuild for Fizzy SDK x.y" otherwise.
+
+> Deferred (not this plan): freezing the dvui/zig value surface behind Fizzy-owned POD types.
+
+---
+
+## 2. Work breakdown (chunks)
+
+Each chunk is sized for one agent, lists files, dependencies, and acceptance criteria. **Suggested
+order top-to-bottom**, but parallelizable where noted. Mark Status as you go.
+
+### Legend
+`[ ]` todo · `[~]` in progress · `[x]` done · **Dep:** prerequisite chunks
+
+---
+
+### Chunk 1 — SDK/version cadence + lock test · Status: [x]
+**Dep:** none. Small, isolated, safe to do first.
+
+> **Done.** dvui + zig were **already pinned** (dvui → `build.zig.zon` commit
+> `3dec1c1b…`; zig `0.16.0` via `minimum_zig_version` + CI `ZIG_VERSION`). Added the
+> cadence-policy doc comment to `src/sdk/version.zig`, a matching "Cadence" section to
+> `docs/PLUGINS.md` § Compatibility, and a `version.zig` regression test
+> ("abi fingerprint is decoupled from the app version"). `zig build test` +
+> `test-sdk-version` green.
+
+Make fingerprint bumps deliberate and prove app-version is decoupled.
+- Confirm `VERSION` / `build.zig.zon` app version is independent of `src/sdk/version.zig`
+ `sdk_version` + `recorded_abi_fingerprint`. Document the relationship in a comment.
+- Pin dvui + zig to explicit versions in `build.zig.zon` (and toolchain config if any). If already
+ pinned, just record the pinned revision + note the "bump deliberately" policy in `docs/PLUGINS.md`
+ Compatibility section.
+- Add a unit test asserting an app-version bump does **not** change `recorded_abi_fingerprint`
+ (e.g. a test that the fingerprint constant is computed only from SDK/dvui boundary types, not any
+ app-version input). Keep `zig build test-sdk-version` green.
+
+**Accept:** `zig build test` + `zig build test-sdk-version` pass; doc note added.
+
+---
+
+### Chunk 2 — `Host.unregisterPlugin` + ownership gaps · Status: [x]
+**Dep:** none (pure SDK). Parallel with Chunk 1.
+
+> **Done.** Implemented in `src/sdk/Host.zig`:
+> - Added `owner: ?*Plugin` to `FileRowFillColor`; pixi sets it
+> (`src/plugins/pixi/src/plugin.zig` `registerFileRowFillColor`).
+> - Changed `services` to `StringHashMapUnmanaged(ServiceEntry{ ptr, owner })` —
+> **`registerService` now takes a 3rd arg `owner: ?*Plugin`** (see ⚠ below). `getService`
+> external behavior unchanged (returns `entry.ptr`).
+> - `unregisterPlugin(plugin)` — generic `removeOwned` filter over every registry +
+> service removal + plugin drop + active-id reset (`hasSidebarView`/`hasBottomView`/
+> `hasCenterProvider`), in that order (ids compared before any `dlclose`).
+> - Unit test "unregisterPlugin removes a plugin's contributions, service, and resets active
+> ids" (uses a victim + keeper plugin to prove scoping). Runs under `zig build test`.
+>
+> **⚠ Downstream impacts:**
+> - **ABI fingerprint shifted** (adding `FileRowFillColor.owner`): `sdk_version` bumped
+> **0.4.0 → 0.5.0**, `recorded_abi_fingerprint = 0x0146eaf7c2f9605a`. Bundled plugin dylibs
+> rebuild automatically; any external plugin must rebuild against ≥0.5.0.
+> - **`registerService` signature changed** to `(name, service, owner)`. Only in-tree caller
+> updated (`Editor.zig:844`, passes the workbench plugin). Third-party plugins that register a
+> service must add the `owner` arg. `docs/PLUGINS.md` mentions it only with `…`, so no doc edit
+> needed, but call this out in any author migration notes when 0.5.0 ships.
+> - Chunk 3's `unloadPlugin` calls `host.unregisterPlugin` **after** closing the plugin's docs +
+> re-registering remaining keybinds, and **before** `lib.close()`.
+
+The teardown engine for live unload. In `src/sdk/Host.zig`:
+- Add `owner: ?*Plugin` to `FileRowFillColor` (`Host.zig:31`); set it at the lone caller (pixi
+ `registerFileRowFillColor`). This shifts the ABI fingerprint → **bump `sdk_version` +
+ `recorded_abi_fingerprint`** in the same commit (Chunk 1 policy).
+- Add internal service-ownership tracking: record `{name → *Plugin}` in `registerService` so a
+ plugin's services can be removed.
+- Implement `pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void`:
+ - Retain-in-place filter each registry by `owner == plugin`: `sidebar_views`, `bottom_views`,
+ `center_providers`, `menus`, `menu_sections`, `settings_sections`, `commands`,
+ `file_row_fill_colors`; remove the plugin's services; remove it from `plugins`.
+ - Reset `active_sidebar_view` / `active_bottom_view` / `active_center` if they referenced a
+ removed id (use `firstVisibleSidebarView()` / first bottom / first center / null). These ids
+ point into dylib static memory — reset **before** `dlclose`.
+
+**Accept:** unit test (see §3) — register a fake plugin with one of each contribution + a service +
+an active sidebar selection pointing at it; `unregisterPlugin`; assert every registry is cleared of
+its entries, the service is gone, and active pointers reset. `test-sdk-version` updated + green.
+
+---
+
+### Chunk 3 — Editor lifecycle orchestration · Status: [x]
+**Dep:** Chunk 2.
+
+> **Done.** In `src/editor/Editor.zig` (+ `Settings.zig`):
+> - `installAndLoadPlugin(id)` / `loadUserPluginById(id)` — load `{config}/plugins/{id}.{ext}`
+> live via `PluginLoader.loadAndRegister` + the existing dvui/render-bridge sync + a keybind
+> rebuild. No restart.
+> - `unloadPlugin(id, force)` — collects + `rawCloseFileID`s the plugin's owned docs (dirty docs
+> abort with `error.DirtyDocuments` unless `force`), `host.unregisterPlugin` → `plugin.deinit()`
+> → `rebuildKeybinds` → `lib.close()` + free bookkeeping. **Ordering:** docs close → unregister
+> (resets active ids) → deinit → keybind rebuild → dlclose.
+> - `setPluginEnabled(id, enabled, force)` + `updatePlugin(id, force)`.
+> - **Disabled set:** new `Settings.disabled_plugins: []const []const u8` (JSON array), backed at
+> runtime by Editor-owned `disabled_plugin_ids` (`seedDisabledPlugins` re-points the settings
+> slice at the owned list; `setDisabledPersisted` mutates + marks dirty). `loadUserPlugins` skips
+> disabled ids; `seedDisabledPlugins` runs in `postInit` before the scan.
+> - **Keybind teardown:** `rebuildKeybinds` clears `window.keybinds` and re-runs `Keybinds.register`
+> + every surviving plugin's `contributeKeybinds` (drops the unloaded plugin's binds, whose key
+> strings live in the about-to-`dlclose` image). Safe because the map is `StringHashMapUnmanaged`
+> with POD values + literal keys.
+> - **Scope guard:** `isBundledPluginId` (pixi/workbench/code) + `isUnloadablePlugin` (must be in
+> `loaded_plugin_libs`). Bundled built-ins reject every mutating op with `error.NotUnloadable`.
+>
+> No unit test (these need dvui/dlopen + a live `Editor`); behavior is covered by the Chunk 5/7
+> E2E. `zig build` + `test` + `test-sdk-version` all green; no boundary change so the fingerprint
+> is unchanged.
+>
+> **For Chunk 5 (UI):** the dirty-doc seam is `unloadPlugin(id, force=false)` → on
+> `error.DirtyDocuments`, the store should prompt (reuse the save-confirmation dialog) and retry
+> with `force=true` (or save first). `installAndLoadPlugin` assumes the file is already in the
+> plugins dir — Chunk 4's `download` puts it there first.
+
+In `src/editor/Editor.zig`, wire the live operations (reuse existing helpers — don't duplicate):
+- **`installAndLoadPlugin(id, path)`** — call `PluginLoader.loadAndRegister` + the existing
+ `syncLoadedPluginDvuiContexts` + `syncLoadedPluginRenderBridge` + `appendLoadedPluginLib` path
+ (already factored in `loadUserPlugins`). Loads live, no restart.
+- **`unloadPlugin(id)`** —
+ 1. Close every `open_files` entry with `DocHandle.owner == plugin` via the existing close path /
+ workbench. Dirty docs → reuse `saveNeedsConfirmation` / `requestSaveConfirmation`; abort on
+ cancel.
+ 2. Re-register remaining plugins' keybinds (drop the unloaded plugin's `contributeKeybinds`), same
+ as startup.
+ 3. `host.unregisterPlugin(plugin)` → `plugin.vtable.deinit(plugin.state)` → find `LoadedLib`,
+ `lib.close()`, free `path`/`id`, remove from `loaded_plugin_libs`.
+- **`setPluginEnabled(id, bool)`** — persist a `disabled_plugins: [][]const u8` set in
+ `src/editor/Settings.zig`; disable → `unloadPlugin` + add to set; enable → load live. Make
+ `loadUserPlugins` skip ids in the set at startup.
+- **`updatePlugin(id, new_path)`** — `unloadPlugin` → swap file → load.
+- **Scope guard:** only ids present in `loaded_plugin_libs` are unloadable; exclude bundled
+ built-ins (reuse `isBundled` from `InstalledPlugins.zig`).
+
+**Accept:** builds; manual unload of a loaded user plugin closes its docs + removes contributions
+with no crash/leak under the debug allocator (covered in E2E, Chunk 7).
+
+---
+
+### Chunk 4 — Store networking backend · Status: [x]
+**Dep:** none for code; integrates in Chunk 6. Parallel with 1–3.
+
+> **Done.** New `src/backend/plugin_store/` (pure of dvui/globals — caller passes `allocator` +
+> `std.Io`):
+> - `registry.zig` — typed `Index`/`PluginEntry`/`Release`/`Download` (downloads are a dynamic
+> object via `std.json.ArrayHashMap`), `parseIndex` (ignore-unknown-fields), `fetchIndex`
+> (HTTPS GET → parse).
+> - `compat.zig` — `hostKey()` → `os-arch`; `parseFingerprint`; `selectRelease(entry, host_fp,
+> host_key)` = newest release matching `abi_fingerprint` **and** shipping the host arch.
+> - `download.zig` — `sha256Hex`/`matchesSha256` + `download(url, expected_sha256, dest_path)`:
+> GET to memory, verify SHA-256, atomic temp-file + `renameAbsolute` into the plugins dir.
+> - `store.zig` — aggregator + `Status{ idle, fetching, ready, failed }` + threaded `Catalog`
+> (owns the latest `Parsed(Index)`; `refresh` spawns a detached worker; `withIndex(ctx, f)`
+> reads under the mutex). Per-plugin *install* status is UI-side (Chunk 5).
+> - Wired into the pure-logic test target (`build/app.zig` list + `tests/root.zig`) as
+> `fizzy-plugin-store`, so the whole module **compiles** under `zig build test` (validates the
+> 0.16 HTTP/IO/Thread APIs) and its tests run. Tests: `parseIndex` ×2, `selectRelease` ×3 +
+> `parseFingerprint`, `sha256`/`matchesSha256` ×2.
+>
+> **0.16 API notes for Chunk 5 (these are settled — reuse, don't re-derive):**
+> - `var client: std.http.Client = .{ .allocator = a, .io = io };` then `client.fetch(.{
+> .location = .{ .url = url }, .response_writer = &aw.writer })`; `client.deinit()` (no args).
+> TLS **auto-rescans system roots** — no manual `Certificate.Bundle` needed.
+> - Response body → `var aw: std.Io.Writer.Allocating = .init(allocator); … aw.written()`.
+> - File IO: `std.Io.Dir.cwd().writeFile(io, .{ .sub_path, .data })`;
+> `std.Io.Dir.renameAbsolute(old, new, io)`.
+> - `Catalog` API: `init(allocator, io, url)` / `refresh()` / `status()` / `withIndex(ctx, f)` /
+> `deinit()`. The store will live in `Editor` (app-lifetime, outlives the detached worker).
+
+New `src/backend/plugin_store/` (off the UI thread; mirror `src/backend/update_notify.zig`'s
+non-blocking pattern):
+- **`registry.zig`** — `fetchIndex(url)`: `std.http.Client` TLS GET (`std.crypto.Certificate.Bundle`
+ roots), `std.json.parseFromSlice` (`ignore_unknown_fields = true`, like `src/core/Atlas.zig`)
+ into a typed `Registry { plugins: []PluginEntry }` with `releases[]` carrying
+ `version`, `min_sdk_version`, `abi_fingerprint`, `downloads: map`.
+- **`compat.zig`** — `hostKey()` → `os-arch` from `builtin.target` (e.g. `macos-aarch64`);
+ `selectRelease(entry)` → newest release with `abi_fingerprint == sdk.dylib.abi_fingerprint` **and**
+ a `downloads[hostKey()]`. Returns null = incompatible.
+- **`download.zig`** — `download(url, expected_sha256, dest_dir)`: GET to a temp file, verify
+ `std.crypto.hash.sha2.Sha256`, atomic-rename to `{config}/plugins/{id}.{ext}` (reuse
+ `PluginLoader.pluginExtension()` + the config path logic in `loadUserPlugins`). Reject + delete on
+ hash mismatch.
+- A small worker + per-plugin status enum: `not_installed | downloading | installed |
+ update_available | incompatible | failed`.
+
+**Accept:** unit tests for `compat.selectRelease` (match / wrong-fingerprint / missing-arch /
+newest-wins) and `download` SHA mismatch rejection (§3). No UI yet.
+
+---
+
+### Chunk 5 — Store UI module · Status: [x]
+**Dep:** Chunks 3 + 4 (calls their APIs). UI can be stubbed against fakes first.
+
+> **Done.** New `src/editor/PluginStore.zig`, wired into `Editor`:
+> - Sidebar view `"shell.store"` (icon `dvui.entypo.shop`, title "Plugins") registered in
+> `postInit` **immediately before** the Settings view → sits directly above the cog.
+> - Cards via `dvui.groupBox` merging the fetched registry with locally-present plugins
+> (bundled built-ins + sideloaded dylibs, drawn once) + a load-failures section. Buttons by
+> state: Install / Disable+Uninstall(+Update when newer) / Enable+Uninstall / greyed
+> "Needs a rebuild for Fizzy SDK x.y". Header shows host SDK+ABI + Refresh + a transient
+> status line.
+> - **Async install model:** Install/Update spawn a worker (`Job`) that runs `download.download`
+> (network+fs, off the UI thread); `PluginStore.tick()` (called from `Editor.tick` **before**
+> the host-registry loops) completes finished downloads by loading them live on the main
+> thread (`installAndLoadPlugin`/`updatePlugin`). Dirty-doc disable/uninstall surfaces
+> "save first" via the error seam.
+> - **`InstalledPlugins.zig` folded** to a one-line pointer ("…in the Plugins tab"); `Editor`
+> added `uninstallPlugin(id, force)` (unload + clear disabled flag + delete the dylib).
+>
+> **Threading model:** background work uses a real `std.Thread` worker (this Zig's `dvui.io` is a
+> blocking GUI io, not a concurrent event loop, so `io.async` wouldn't run a fetch off the UI
+> thread — every worker in the app uses `std.Thread.spawn`). Shared state is guarded with
+> `std.Io.Mutex` + `dvui.io`, which **does** work across `std.Thread` boundaries — this is the
+> codebase's standard pattern (see pixi `SaveQueue`: `std.Thread` worker + `std.Io.Mutex` +
+> `std.Io.Condition`, joined on shutdown). So `Catalog` guards `parsed` with a `std.Io.Mutex`,
+> frees the previous index on swap (no leak), exposes `acquire`/`release` (hold across the read),
+> and `join`s the worker on `deinit`.
+>
+> *(Correction: an earlier draft of this note wrongly claimed `std.Io.Mutex` doesn't work with raw
+> OS threads and used a lock-free atomic-pointer + leak-on-refresh hack instead. That conflated Io
+> sync primitives with Io async tasks; the mutex version above replaced it.)*
+>
+> **0.16 API corrections made (note for later chunks):** `std.Io.Dir.createDirAbsolute(io, path,
+> .default_dir)` (not `makeDirAbsolute`); `deleteFileAbsolute(io, path)`; theme fonts are
+> `body/heading/title/mono` only (no `caption`).
+>
+> Verified: `zig build`, `zig build test`, `zig build test-sdk-version` all green. Interactive
+> install/enable/disable/uninstall is the Chunk 7 E2E (native SDL app + local registry).
+
+New `src/editor/PluginStore.zig`, modeled on `InstalledPlugins.zig` (imports `dvui`, `sdk`,
+`../fizzy.zig`):
+- Register its `SidebarView` in `Editor.postInit` **immediately before** the `view_settings`
+ registration (order = sidebar order). Id `"shell.store"`, icon (entypo or a `lucide` TVG via the
+ `icons` pkg), title `"Plugins"`.
+- `draw`: scroll area of **cards** via `dvui.groupBox` (pattern in `src/editor/explorer/settings.zig`)
+ merging the fetched registry with locally installed (`loaded_plugin_libs` + `failed_user_plugins`
+ + bundled `host.plugins`). Each card: name, description, author, installed vs available version,
+ active/disabled badge. Buttons by state: **Install** / **Disable**+**Uninstall**+**Update** /
+ **Enable** / **Reinstall**. Incompatible → greyed "needs a rebuild for Fizzy SDK x.y". Header:
+ host SDK + ABI (reuse `InstalledPlugins` formatting) + **Refresh**.
+- Fold `InstalledPlugins.zig` into the store tab (or reduce its Settings section to a pointer).
+
+**Accept:** icon appears directly above Settings; tab renders cards; buttons call the Chunk 3/4 APIs.
+
+---
+
+### Chunk 6 — Distribution repo `fizzyedit/plugins` (external) · Status: [x]
+**Dep:** schema agreed (this doc). Independent of the app code; can proceed in parallel.
+
+> **Done** in `~/dev/fizzyedit/plugins` (origin `github.com/fizzyedit/plugins`). Builds nothing;
+> aggregates only. Not committed (jj-managed; left to the user).
+> - `scripts/aggregate.py` — stdlib-only aggregator: reads `registry/*.json`, fetches each
+> `manifest_url`, validates, merges into `plugins/index.json`. **Malformed registry file →
+> hard error (exit 1)** (reviewed PRs must be valid); **unreachable/bad manifest → skip +
+> retain last-known-good** from the existing index. `--check` validates without writing;
+> `file://` URLs work locally. All three paths tested green.
+> - `registry/markdown.json` — example registration pointing at the author's `manifest_url`
+> (the real `~/dev/fizzyedit/markdown` plugin, built against our SDK 0.5.0 / ABI
+> `0x0146eaf7c2f9605a`).
+> - `plugins/{index.json,CNAME,index.html}` — generated index + `plugins.fizzyed.it` domain +
+> landing page (served via Pages).
+> - `.github/workflows/aggregate.yml` — regen on `registry/**` change + 6-hourly cron +
+> dispatch; commits index.json back (path filter excludes `plugins/**` so no re-trigger
+> loop); uploads `plugins/` as the Pages artifact + deploys.
+> - `.github/workflows/validate.yml` — PR check (`aggregate.py --check`).
+> - `docs/manifest.example.json` + README publishing guide.
+>
+> **Current state:** `index.json` is intentionally empty — the markdown author hasn't published
+> their `manifest.json` yet (the aggregator hit a real 404 and skipped gracefully). It populates
+> once Chunk 7's author build action publishes the manifest.
+>
+> **Beyond the plan:** added `validate.yml` (PR validation) and `plugins/index.html` (landing).
+
+New repo (not in this tree):
+- `registry/.json` — one-time author submission (PR): `{ id, name, author, homepage, tags,
+ manifest_url }`.
+- `plugins/index.json` — CI-generated aggregate, served via GitHub Pages (`plugins.fizzyed.it`,
+ CNAME like the main site).
+- `.github/workflows/aggregate.yml` — on schedule + `registry/*` change: fetch each `manifest_url`,
+ validate, merge into `index.json`, deploy to Pages. Skip unreachable manifests (retain
+ last-known-good). **Builds nothing; dispatches nothing.**
+
+**Accept:** a sample `registry/example.json` + a hand-written author `manifest.json` aggregate into a
+valid `index.json` served by Pages.
+
+---
+
+### Chunk 7 — Author build helper + E2E verification · Status: [x]
+**Dep:** Chunks 3–6.
+
+> **Done.** Kept **self-contained in the markdown plugin repo** (`~/dev/fizzyedit/markdown`)
+> rather than a separate `plugin-build-action` repo — simpler and immediately runnable for one
+> author; the logic can be extracted to a reusable action when a second plugin needs it.
+> - `scripts/make_manifest.py` — `probe` (dlopen a dylib via **ctypes**, read the
+> `fizzy_plugin_*` C exports for fingerprint/id/versions, SHA-256 it → a fragment) +
+> `assemble` (merge per-target fragments into one release, enforcing they share build identity;
+> `--merge-into` preserves prior releases). Stdlib only.
+> - `.github/workflows/release.yml` — on a `v*` tag: matrix of **native runners** per target
+> (macos-14/13, ubuntu, windows) builds + probes its own binary (no cross-probe), uploads
+> binaries to the GitHub Release, and a final job assembles + publishes `manifest.json` to the
+> author's Pages (which the registry entry's `manifest_url` points at).
+> *Prereq:* `build.zig.zon` must pin `fizzy` to a git url+hash (it's a local path today) and the
+> matching Fizzy SDK commit must be on GitHub.
+>
+> **E2E verified locally (everything except the GUI click):** built `markdown.dylib` → probed
+> (fingerprint `0x0146eaf7c2f9605a`, **matches the host**) → assembled `manifest.json` → ran the
+> Chunk 6 aggregator against it over **real HTTP** → `index.json` populated → client fetched the
+> index and **downloaded the binary + SHA-256 verified** (the app's exact verify step). The
+> `assemble` mismatch guard and the aggregator's strict/last-known-good paths were also exercised.
+> The only un-automatable step is clicking **Install** in the native SDL app — manual commands
+> below.
+
+- (Optional) `fizzyedit/plugin-build-action` — reusable GitHub Action: on tag, build the 6 os-arch
+ targets against a given Fizzy SDK tag (reuse `release.yml` matrix shape), probe each binary's
+ `abi_fingerprint` C export (via `nm`/tiny loader), `sha256`, upload to the author's Release,
+ regenerate `manifest.json`.
+- Run the full **Verification** flow (§4) end-to-end.
+
+**Accept:** §4 passes.
+
+---
+
+## 3. Unit tests to add
+
+- `Host.unregisterPlugin` (Chunk 2): fake plugin with one of each contribution + a service + active
+ sidebar selection → unregister → every registry cleared of its entries, service gone, active
+ pointers reset.
+- `compat.selectRelease` (Chunk 4): matching fingerprint+arch chosen; wrong fingerprint or missing
+ arch rejected; newest matching version wins.
+- `download` (Chunk 4): SHA-256 mismatch rejects + leaves no file.
+- `version.zig` (Chunk 1): app-version bump does not change `recorded_abi_fingerprint`;
+ `zig build test-sdk-version` green.
+
+Run: `zig build test` and `zig build test-sdk-version`.
+
+---
+
+## 4. End-to-end verification
+
+1. Build the in-repo `example` plugin as a dylib (`cd src/plugins/example && zig build`).
+2. Serve a hand-written `index.json` (real `abi_fingerprint` + `sha256`) over `localhost`.
+3. `zig build run`; open the **Plugins** sidebar tab — confirm the icon sits directly above
+ Settings.
+4. **Install** → downloads + loads live (Example's sidebar view appears, **no restart**).
+5. **Disable** → its view disappears + it leaves `loaded_plugin_libs`; relaunch → stays unloaded
+ (persisted in `disabled_plugins`); **Enable** → loads live again.
+6. Open a document owned by a loadable editor plugin, then **Uninstall** → tabs close (dirty →
+ confirm dialog), contributions vanish, no crash/leak under the debug allocator.
+7. Point the client at the deployed `plugins.fizzyed.it/index.json` and repeat Install.
+8. **Distribution:** dry-run the author action on one target for `example`; confirm the dylib's
+ probed `abi_fingerprint` matches the Fizzy build, `manifest.json` is valid, and `aggregate.yml`
+ merges it into `index.json`.
+
+---
+
+## 5. Reuse map (don't rebuild)
+
+| Need | Use |
+|------|-----|
+| Load/register a dylib | `PluginLoader.loadAndRegister`, `resolvePluginPath`, `pluginExtension`, `probeVersionInfo` |
+| Sync dvui/render into a loaded lib | `Editor.syncLoadedPluginDvuiContexts`, `syncLoadedPluginRenderBridge` |
+| Track a loaded lib | `Editor.appendLoadedPluginLib`, `loaded_plugin_libs`, `failed_user_plugins` |
+| Plugin lookup / routing | `Host.pluginById`, `DocHandle.owner` |
+| Parse JSON | `std.json.parseFromSlice` (see `src/core/Atlas.zig`) |
+| Non-blocking worker | pattern in `src/backend/update_notify.zig` |
+| Card UI | `dvui.groupBox` (see `src/editor/explorer/settings.zig`) |
+| Plugin inventory UI baseline | `src/editor/InstalledPlugins.zig` |
+| Release build matrix (author action template) | `.github/workflows/release.yml` |
+
+---
+
+## 6. Risks & notes
+
+- **ABI fingerprint shifts from Chunk 2** (adding `FileRowFillColor.owner`): expected — bump
+ `sdk_version` + `recorded_abi_fingerprint` together; rebuild bundled plugin dylibs.
+- **Dangling `[]const u8` ids** after `dlclose`: the registry entries' `id`/`title` slices live in
+ the plugin's static memory. Safe only because `unregisterPlugin` removes them and resets active
+ pointers *before* `lib.close()`. Keep that ordering.
+- **Dirty documents on unload/disable**: must honor the save-confirmation protocol and allow abort;
+ never silently drop unsaved work.
+- **TLS root certs** for `std.http.Client`: use `std.crypto.Certificate.Bundle`; verify it loads
+ system roots on all three desktop targets.
+- **wasm**: plugin dylib loading is native-only (`PluginLoader_stub.zig` on wasm). The store tab
+ should degrade gracefully / hide install controls on web.
+- Bundled built-ins (`pixi`/`workbench`/`code`) are never store-managed — guard every mutating op.
+
+---
+
+## 7. Status summary
+
+- [x] Chunk 1 — SDK/version cadence + lock test
+- [x] Chunk 2 — `Host.unregisterPlugin` + ownership gaps *(→ SDK 0.5.0, fp `0x0146eaf7c2f9605a`; `registerService` gained `owner` arg)*
+- [x] Chunk 3 — Editor lifecycle orchestration *(install/unload/enable/disable/update + disabled-set persistence + keybind rebuild)*
+- [x] Chunk 4 — Store networking backend *(registry/compat/download/store; HTTPS fetch + sha256 + Catalog; tests green)*
+- [x] Chunk 5 — Store UI module *(Plugins sidebar tab above Settings; cards; async install via worker+tick; Io.Mutex Catalog; InstalledPlugins folded to a pointer)*
+- [x] Chunk 6 — Distribution repo `fizzyedit/plugins` *(aggregator + registry entry + Pages workflow + PR validation; tested; not committed)*
+- [x] Chunk 7 — Author build helper + E2E verification *(markdown make_manifest.py + release.yml; full distribution chain verified over HTTP incl. sha256)*
+
+---
+
+## 8. Manual GUI E2E (the one un-automatable step)
+
+The native SDL app can't be driven by the test harness, so the final install click is manual.
+With the markdown plugin built (`cd ~/dev/fizzyedit/markdown && zig build`):
+
+```sh
+# 1. Stage a local registry + binary served over HTTP.
+ROOT=/tmp/fizzy_store; rm -rf $ROOT; mkdir -p $ROOT
+cp ~/dev/fizzyedit/markdown/zig-out/markdown.dylib $ROOT/markdown-macos-aarch64.dylib
+cd ~/dev/fizzyedit/markdown
+python3 scripts/make_manifest.py probe $ROOT/markdown-macos-aarch64.dylib \
+ --os-arch macos-aarch64 --url http://localhost:8099/markdown-macos-aarch64.dylib --out $ROOT/frag.json
+python3 scripts/make_manifest.py assemble $ROOT/frag.json --published 2026-06-26 --out $ROOT/manifest.json
+# hand-write index.json pointing at the manifest, or run the aggregator with a file:// registry entry
+printf '{"schema":1,"plugins":[{"id":"markdown","name":"Markdown","releases":%s}]}' \
+ "$(python3 -c "import json;print(json.dumps(json.load(open('$ROOT/manifest.json'))['releases']))")" > $ROOT/index.json
+cd $ROOT && python3 -m http.server 8099 &
+
+# 2. Launch fizzy pointed at the local registry, with the bundled markdown disabled if present.
+cd ~/dev/fizzy
+FIZZY_PLUGIN_REGISTRY_URL=http://localhost:8099/index.json zig build run
+```
+
+Then: open the **Plugins** sidebar tab (bag icon, above Settings) → the markdown card shows
+**Install** → click it → the worker downloads + verifies, and `PluginStore.tick` loads it live
+(its menu/sidebar contributions appear with no restart). Verify **Disable** (relaunch → stays
+unloaded), **Enable**, open a `.md` doc then **Uninstall** (tabs close, contributions vanish, no
+crash under the debug allocator).
diff --git a/STORE_TEXT_SIDEBAR_PLAN.md b/STORE_TEXT_SIDEBAR_PLAN.md
new file mode 100644
index 00000000..7ea770eb
--- /dev/null
+++ b/STORE_TEXT_SIDEBAR_PLAN.md
@@ -0,0 +1,724 @@
+# Plugin Store, Text Editor Platform & Sidebar Reorder — Plan & Agent Handbook
+
+> Living doc for reworking the plugin store into a **VSCode-extensions-style** experience (flat card
+> list, per-plugin metadata, repo `README.md` rendering, logos), renaming `code` → `text` as the
+> universal plain-text fallback, default-disabling the example plugin, extracting a shared SDK text
+> editor, bringing the **markdown** plugin **in-tree as a built-in** (so the store can always render
+> READMEs), adding a Host syntax-provider registry (zig plugin registers highlighters only), and
+> making the sidebar icon rail reorderable with persisted order.
+> Update the Status boxes as chunks land. Companion to `PLUGINS_PLAN.md` (the store) and
+> `PIXI_EXTRACTION_PLAN.md` (pixi extraction — overlaps on the `code`→`text` rename as Phase C1).
+
+## 0. Context & end state
+
+Today the plugin store is a flat card stack whose order depends on registry JSON, registration
+order, and filesystem iteration — not deterministic. Cards expand to reveal actions. The built-in
+**code** plugin claims every file extension as a fallback but vendors its own `TextEntryWidget` and
+tree-sitter stack; **markdown** and future **zig** plugins fork duplicate copies. The markdown plugin
+lives in a separate repo (`~/dev/fizzyedit/markdown`). The sidebar icon rail follows registration
+order with no reorder or persistence. The **example** plugin is always visible.
+
+**End state:**
+
+- Plugin store is a **flat, non-expanding card list** (VSCode-extensions feel), **A→Z** by display
+ name, with the same **Filter** row as the workbench file tree. Each card shows an optional **logo**,
+ a **title**, and a **short description**; the right edge holds a **fixed-width action area** with an
+ **enabled checkbox** + **uninstall (trash)** for installed plugins (and an Install affordance for
+ available ones). The reserved width never changes, so cards don't reflow on install/uninstall.
+- **Hovering anywhere on a card** highlights the whole card, matching the file-tree row hover
+ (`color_fill_hover = themeGet().color(.control, .fill).opacity(0.5)`).
+- **Selecting a card shows that plugin's `README.md`** rendered by the in-tree **markdown render
+ library** (read-only preview pane/tab) — a **direct render call**, not a document opened through a
+ plugin. README bytes are **fetched from the plugin's repository over HTTPS and cached locally**.
+- Each plugin carries **repository link, author, version** (required) and an optional **logo**, plus
+ **store metadata**: when it was **installed** (local) and when it was **added to the store**
+ (registry).
+- **Reusable engines live in the host as libraries:** `sdk/text` (`TextEditor` + `TextEntryWidget`)
+ and an in-tree **markdown render library** (`cmark` + `render_ast` + preview draw). First-party
+ text/markdown are **static-only** (no separately-buildable dylib). `example` + external
+ `pixi`/`ghostty`/`zig` are the third-party authoring references.
+- **text** opens **any** file type as plain monospace + line numbers (VSCode-like baseline).
+- The markdown render library is **native-only** (cmark needs libc; gated out of the wasm build —
+ text owns `.md` on web). The store is desktop-only anyway, so README rendering never runs on web.
+- **zig** plugin registers syntax providers only (text remains document owner for `.zig`/`.zon`).
+- Sidebar icons are **reorderable**; order persisted in settings.
+
+### Confirmed decisions
+
+1. **Store cards are a flat list, not a tree.** No per-card expander. The Phase 1 unified-model work
+ (dedup, A→Z, filter) is **kept**; the `TreeWidget`/`Branch`/expander layout is **replaced** by a
+ plain scrollable list of full-width card buttons (Phase 1R).
+2. **Reuse lives in engines, not plugin wrappers.** Each "plugin" is an *engine* (the reusable
+ widget/render code) plus a thin *wrapper* (manifest + vtable that registers a document owner). All
+ reuse value is in the engines, so they move into shared host-side libraries: **`sdk/text`**
+ (`TextEditor` + `TextEntryWidget` + options) and a **markdown render library** (`cmark` parse +
+ `render_ast` + preview-draw). The wrappers stay thin (or disappear) — see #3, #4.
+3. **README renders via a direct markdown-library call** — no document/plugin detour. The store
+ fetches the README, caches the bytes, and calls the markdown render library to draw a **read-only
+ preview** (its own pane/tab). No cache-`.md`-file + open-as-document dance, and no dependency on a
+ markdown *plugin* being registered.
+4. **First-party text/markdown are static-only built-ins; drop the separately-buildable dylib.**
+ There is no real benefit to a dylib double-build for code that is always compiled in (the ABI
+ boundary is meaningless when statically linked). Keep **`example`** + the genuinely-external
+ `pixi`/`ghostty`/`zig` as the third-party authoring references. The markdown render code moves
+ **in-tree as a library** (native-only; gated out of the wasm build — cmark needs libc). A thin
+ `.md`-editing wrapper over the shared libs is optional/low-priority (Phase MD); README rendering
+ does **not** depend on it.
+5. **README source = repository fetch + local cache.** Derive the raw README URL from the plugin's
+ `repository` link (GitHub: `raw.githubusercontent.com///HEAD/README.md`), fetch async
+ on a worker, cache to disk keyed by plugin id. Works for both available and installed plugins.
+6. **Optional logo = remote URL, fetched + cached async** with a generic-icon placeholder fallback.
+7. **Manifest gains `repository`/`author` (required) + `logo` (optional).** This is an SDK ABI change,
+ so it is **folded into the single ABI bump** (Phase 4a) together with the syntax registry and the
+ sidebar-reorder Host API — external plugins (pixi, ghostty) rebuild **once**. Until then, built-in
+ cards source repo/author from small in-tree descriptors and available cards from the registry index.
+8. **Store metadata storage.** `installed_at` is local → a sidecar `plugin_meta.json` in the plugins
+ dir (id → install timestamp; built-ins use first-run time). `added`/`published` come from the
+ registry index (`Release.published`, plus a new `PluginEntry.added` field — registry JSON only, no
+ ABI impact).
+9. **Syntax via registry, not document ownership.** Zig plugin registers `Host.registerSyntaxProvider`
+ for `.zig`/`.zon`; text plugin stays the document owner and calls `host.syntaxForPath(path)` when
+ drawing. (Not: zig plugin owning `.zig` docs at priority 50.)
+10. **Markdown `.md` editing (if kept) owns `.md`/`.markdown`** at priority 50 for split-pane preview;
+ both the source pane (`sdk.text`) and the preview pane (markdown render lib) reuse the shared
+ engines — no forked `TextEntryWidget`, no duplicated renderer.
+11. **Example disable = sidebar hidden**, not dylib unload (example is static built-in).
+
+---
+
+## 1. Work breakdown
+
+`[ ]` todo · `[~]` in progress · `[x]` done · **Dep:** prerequisite chunks
+
+### Phase 0 — Lifecycle hardening (load/unload safety) · Status: [x] **Dep:** none (prereq for safe unload)
+
+**Goal:** Unloading a plugin while it has work in flight must never call into freed dylib memory.
+
+`FileLoadJob` (`src/plugins/workbench/src/FileLoadJob.zig`) holds a raw `owner: *sdk.Plugin`
+resolved on the main thread before the worker spawns. `Editor.unloadPlugin` closes owned docs,
+`unregisterPlugin`s, then `dlclose`s — but does **not** cancel in-flight load jobs. Opening a file
+and immediately Disable/Uninstall-ing its owning plugin lets the worker call
+`owner.loadDocument` / `deinitDocumentBuffer` into a `dlclose`d image → use-after-free / crash.
+
+- In `unloadPlugin`, before `unregisterPlugin`/`plugin.deinit`/`dlclose`, cancel or await every
+ `FileLoadJob` whose `owner == plugin` (mirror the existing `waitForPluginSaves` wait loop).
+- This risk concentrates on unloadable specialized plugins (markdown / pixi / zig). `text` is
+ bundled / non-unloadable so it is not directly exposed, but Phase 2 makes `text` the universal
+ fallback owner for most files — keep the cancellation owner-scoped so unrelated jobs survive.
+
+**Accept:** disable/uninstall a plugin mid file-load → no crash, the partial open is dropped cleanly.
+
+### Phase 1 — Plugin store unified model + filter · Status: [x] **Dep:** none (UI-only)
+
+> **Layout superseded by Phase 1R.** The 1a unified `StoreEntry` model (dedup, A→Z sort) and the 1b
+> filter row are **kept**. The 1c `TreeWidget` + `Branch` + expander layout (and the card-wrapping
+> box work) is **replaced** by the flat card list in Phase 1R — the cards no longer expand.
+
+**Goal:** Deterministic A→Z listing, file-tree-style filter, one row per plugin.
+
+#### 1a — Unified display model
+Replace four sequential draw loops in `src/editor/PluginStore.zig` with one merge pass:
+
+```zig
+const StoreEntry = struct {
+ id: []const u8, // sort key tie-breaker
+ title: []const u8, // display name
+ kind: enum { registry, local, disabled, failed },
+ // payload union for draw actions
+};
+```
+
+- Dedup rules unchanged: registry wins; skip local/disabled if id in catalog index.
+- Sort: case-insensitive ASCII on `title`, tie-break on `id` (`std.sort.pdq`).
+- Stable widget ids: `id_extra = hash(plugin_id)` — not loop index.
+
+> **Lifetime rule (must-have).** The merge, `std.sort.pdq`, and the draw loop must all run inside
+> a single `catalog.acquire()` / `catalog.release()` scope, building the `StoreEntry` list into the
+> dvui arena. `StoreEntry.title`/`id` are **borrowed** slices with three different lifetimes:
+> registry strings (valid only under the catalog lock — the worker frees the arena on `refresh`),
+> `plugin.display_name`/`plugin.id` (live in dylib static memory — valid only while the plugin is
+> loaded), and `disabled_plugin_ids` (app-allocator-owned). Never retain these across the lock
+> release or across `tick()`. `PluginStore.tick()` must keep running before the draw pass so
+> `host.plugins` is never mutated mid-merge.
+
+> **Per-id dedup before `branch_id` (must-have).** Today the four loops use disjoint numeric ranges
+> (`i`, `1000+i`, `3000+i`, `2000+i`) so widget ids never collide. With one `branch_id = hash(id)`
+> the same id can surface from multiple sources (registry + disabled-on-disk, or failed + disabled),
+> and two branches sharing a `branch_id` triggers a dvui id collision (warning + visual glitch).
+> Collapse to exactly one row per id using explicit precedence — **loaded/local > registry >
+> disabled > failed** — and fold `failed_user_plugins` into the same dedup pass.
+
+#### 1b — Filter row (mirror file tree)
+Pattern from `src/plugins/workbench/src/files.zig`:
+
+- Horizontal box + lucide search icon + `dvui.textEntry(.{ .placeholder = "Filter..." })`.
+- Match (case-insensitive): `id`, `name`/`display_name`, `description`, `author`, registry `tags`.
+- Import tree via `const wdvui = fizzy.dvui`.
+
+#### 1c — Tree layout (SUPERSEDED by Phase 1R)
+
+The original top-level-`TreeWidget` layout shipped, then the design changed to a flat, non-expanding
+card list. See Phase 1R. Keep async jobs, `pending_actions`, and `tick()` plumbing unchanged.
+
+**Accept (historical):** plugins always A→Z; filter like Files tab.
+
+---
+
+### Phase 1R — Store card list redesign (VSCode-extensions style) · Status: [x] **Dep:** 1 (reuses unified model + filter)
+
+> **Implemented design (2026-06-30) — supersedes the sketch below.** Cards are now a flat list of
+> `dvui.ButtonWidget` containers (no `TreeWidget`, no expander). Each card is a horizontal box:
+> **(1)** a `gravity_y=0.5`, `gravity_x=0` 32×32 logo (generic `package` icon placeholder; real
+> logos are Phase META); **(2)** an info column — large `Font.theme(.title)` plugin name over a dim
+> `Font.theme(.mono)` `id · version · date` subtitle; **(3)** right-justified state controls.
+> There is **no per-card README button** — clicking the card body selects the plugin (controls
+> consume their own clicks, so a control click never selects). The store keeps a **single selected
+> plugin** (the `readme.zig` `current` is the source of truth via `Readme.selectedId()`); selecting
+> the already-selected card deselects it. While the **store tab is active and a plugin is selected**,
+> `PluginStore.tick → syncReadmeCenter` swaps the Host **active center** to a new `shell.store.readme`
+> center provider that renders the README (the previous center — normally the workbench — is restored
+> on deselect / tab switch). This is the "selected_plugin → README in the **center pane**, like the
+> project tab" behaviour, replacing the old in-sidebar README-with-back-button.
+>
+> **Controls by state** (`drawCardControls`): *available in store* → single install button
+> (`arrow-down-to-line`); *installed (loaded dylib / disabled-on-disk / sideloaded)* → `Enabled`
+> checkbox + `trash-2` uninstall; *static built-in (example)* → `Enabled` checkbox only (nothing to
+> uninstall); *protected bundled fallback (text/workbench)* → a muted "Built-in" label (never
+> disablable/uninstallable); *registry row with no compatible release / load failure* → muted
+> "Needs rebuild" / "Failed". In-flight installs show "Installing…"; failed installs show a Retry icon.
+>
+> **Still TODO from the original 1R-c reserved-width rule:** the action area is *not* yet a constant
+> reserved width, so cards can still jitter horizontally as state changes. Logos are placeholders.
+
+**Goal:** Replace the expanding tree rows with a **flat list of cards**; whole-card hover; a
+fixed-width action area; selecting a card opens the plugin README (README open lands in Phase META).
+
+#### 1R-a — Card list (drop TreeWidget)
+
+Inside the existing `catalog.acquire()` scope + `StoreEntry` merge/sort/filter (Phase 1a/1b), draw a
+plain vertical list — no `TreeWidget`, no `Branch`, no expander:
+
+```zig
+for (entries.items) |entry| {
+ if (!matchesFilter(entry, filter_text)) continue;
+ // One clickable card; hover highlights the entire card like a file-tree row.
+ var card = dvui.button(@src(), ..., .{
+ .id_extra = hashId(entry.id),
+ .expand = .horizontal,
+ .corner_radius = dvui.Rect.all(8),
+ .color_fill_hover = dvui.themeGet().color(.control, .fill).opacity(0.5),
+ .background = false, // idle: flat; hover/active fills (matches files.zig:519)
+ });
+ defer card.deinit();
+ // Left: optional logo image (or generic icon placeholder) + title + 1-line description.
+ // Right: FIXED-WIDTH action area (see 1R-c).
+ if (card.clicked()) selectPlugin(entry.id); // opens README tab (Phase META)
+}
+```
+
+- Whole-card hover feedback uses the file-tree value `themeGet().color(.control, .fill).opacity(0.5)`.
+- Stable widget id `id_extra = hashId(entry.id)` (unchanged from Phase 1).
+- The selected card gets a persistent fill (selection state kept in a module var keyed by id).
+
+#### 1R-b — Card content
+
+- **Logo:** optional image at a fixed box (e.g. 32×32) on the left; generic lucide/entypo icon when
+ absent or not yet fetched (logo fetch in Phase META).
+- **Title:** `display_name`/registry `name`, single line, ellipsized.
+- **Description:** registry `description` (available) or manifest/short text (installed), 1 line,
+ ellipsized. Author + version shown as a small dim subtitle.
+
+#### 1R-c — Fixed-width action area (reserved, never reflows)
+
+Right-justified `dvui.box` of constant width regardless of plugin state, so the card shape is stable:
+
+- **Installed:** an **enabled checkbox** (toggles `setPluginEnabled`) + a **trash** uninstall button.
+- **Available (not installed):** an **Install/Get** button occupying the same reserved width.
+- **Fallback/built-in guards (from Phase 2a):** the active file-type fallback (`text`) and other
+ protected built-ins show **no** uninstall (and no disable) — render a disabled/placeholder glyph so
+ the reserved width is still consumed and alignment holds.
+- Route actions through the existing `queueSetEnabled` / `queueUninstall` → `pending_actions` →
+ `tick()` path (no direct registry mutation during draw).
+
+> **Reserved-width rule (must-have).** Compute the action area from the **max** of all states (checkbox
+> + trash) and always lay out that width, drawing placeholders where a control is absent. Never make the
+> width state-dependent or the list will jitter horizontally as plugins install/uninstall/enable.
+
+**Accept:** flat A→Z cards; hover highlights the whole card; action area stays put across
+install/uninstall/enable; clicking a card selects it (and, after Phase META, opens its README).
+
+---
+
+### Phase 2 — code → text + example disabled by default · Status: [x] **Dep:** none (can parallel Phase 1)
+
+#### 2a — Rename `code` → `text` (PIXI plan C1)
+
+| Area | Changes |
+|------|---------|
+| Directory | `src/plugins/code/` → `src/plugins/text/` |
+| Module hub | `code.zig` → `text.zig`; `@import("code")` → `@import("text")` |
+| Plugin id | `"code"` → `"text"`; dylib `text.{dylib,so,dll}` |
+| Build | `build/plugins.zig`, `build/exe.zig`, `build/app.zig`, `build/web.zig`: `code_plugin` → `text_plugin`, `-Dstatic-code` → `-Dstatic-text` |
+| Editor | `Editor.zig`: `loadCodeDylib` → `loadTextDylib`, `isBundledPluginId` lists `"text"` |
+| PluginStore | `PluginStore.zig` `isBundled`: `"text"` |
+
+Keep `sdk.Plugin.file_type_fallback_priority` constant name/value (100).
+
+> **Protect the universal fallback (must-have).** `text` must be added to **both**
+> `Editor.isBundledPluginId` *and* `PluginStore.isBundled` when flipping from `code`. Missing either
+> one lets the store render Disable/Uninstall on the text card; disabling the priority-100 fallback
+> makes `openablePath` (`files.zig`) return false for **every** file, so the file tree silently opens
+> nothing and the app looks broken. Add a hard guard: the active file-type fallback can never be
+> disabled/uninstalled — omit its Disable/Uninstall buttons in the store and reject it in
+> `setPluginEnabled`/`uninstallPlugin`. Verify every `code` reference flips together:
+> `isBundledPluginId`, store `isBundled`, `loadCodeFromDylibEnabled`, `FIZZY_STATIC_CODE`,
+> `-Dstatic-code`, and the dylib install path — a half-rename can leave a stale `code.dylib`
+> loaded while the new checks look for `text`.
+
+#### 2b — Universal file open (verify, minimal work)
+
+Text plugin returns priority 100 for all extensions (including extensionless). With text loaded:
+
+- `openablePath` in `files.zig` → true for virtually every file.
+- File tree name filter stays extension-agnostic.
+
+**Accept:** fresh install opens any file as plain text when no specialized plugin claims it.
+
+#### 2c — Example plugin disabled by default
+
+Example is static (not dylib-unloadable). Use sidebar visibility:
+
+1. Reuse `disabled_plugins` with static-plugin interpretation, or add dedicated list.
+2. After `example_mod.plugin.register` in `Editor.postInit`:
+
+```zig
+if (editor.isPluginDisabled("example"))
+ editor.host.setSidebarViewHidden("example.hello", true);
+```
+
+3. Plugin store Enable/Disable for example toggles persisted flag + `setSidebarViewHidden`.
+4. Default settings / first-run: example disabled.
+
+> **Static-plugin disable wiring (must-have).** The store buttons route
+> `queueSetEnabled` → `applySetEnabled` → `Editor.setPluginEnabled`, whose current `else` branch
+> calls `unloadPlugin`. For a static plugin like `example` (registered unconditionally in `postInit`,
+> never in `loaded_plugin_libs`) `unloadPlugin` cannot find it and errors — so today Disable would
+> persist the id *and* fail without hiding anything. Add a dedicated static-plugin branch in
+> `setPluginEnabled`: persist the disabled flag + `setSidebarViewHidden("example.hello", true/false)`
+> instead of load/unload. Note `isBundledPluginId` only lists `workbench`/`code`(→`text`), so
+> `example` is treated as static here — keep it out of that list but special-case it as
+> hide-not-unload.
+>
+> Suppress the **Uninstall** button for static built-ins in `drawLocalCard` (there is no dylib file
+> to delete; `uninstallPlugin` would try to unload + remove a nonexistent file). Apply the first-run
+> hidden state immediately after `example_mod.plugin.register` in `postInit` (the `if
+> (editor.isPluginDisabled("example")) editor.host.setSidebarViewHidden("example.hello", true)` shown
+> above). When a hidden view is the active one, `Host.activeSidebarView` already falls back to the
+> first visible view, so hiding the active example icon is safe.
+
+**Accept:** fresh install has no Example icon; enabling from store shows it.
+
+---
+
+### Phase 3 — Extract reusable engines into the host (text + markdown render) · Status: [ ] **Dep:** 2a
+
+**Goal:** Put the reuse where it belongs — shared host-side libraries — so the thin wrappers (and the
+store) consume one canonical copy each. Two engines:
+
+#### 3a — `sdk/text` (plain text editor engine)
+
+Add to `src/sdk/sdk.zig` exports:
+
+```
+sdk/text/
+ TextEditor.zig // draw(backing, path, id_extra, gpa, options) -> bool changed
+ TextEntryWidget.zig // move from text plugin (one canonical copy)
+ Options.zig // chrome, line-number gutter, optional tree_sitter slot
+```
+
+**text plugin** (`src/plugins/text/src/TextEditor.zig`, renamed from CodeEditor) becomes a thin
+delegator: line numbers + `TextEntryWidget` via `sdk.text`; remove the in-plugin `SyntaxHighlight.zig`
+and tree-sitter deps; `drawDocument` calls
+`sdk.text.TextEditor.draw(..., .{ .tree_sitter = host.syntaxForPath(path) })`.
+
+#### 3b — markdown render library (in-tree, native-only) — see Phase MD-lib
+
+The markdown rendering engine (`cmark` parse + `render_ast` + preview draw) moves in-tree as a
+host-side library the **store calls directly** for READMEs (and a future `.md` wrapper reuses). This
+is the immediate dependency for README rendering and is detailed in **Phase MD-lib** below; the ABI
+note (3c) applies to `sdk/text` and the syntax types, not to the markdown render lib (it is a normal
+in-tree module, no SDK boundary).
+
+#### 3c — ABI note
+
+Adding `sdk/text`, the manifest fields, the Host syntax registry, and the sidebar-reorder API all
+**bump `sdk_version` + `recorded_abi_fingerprint`** — do them as **one** bump in Phase 4a. `sdk/text`
+itself can land in Phase 3 if the bump is taken there, but to rebuild externals only once, prefer
+batching the fingerprint change into Phase 4a (declare the `sdk/text` types in Phase 3 behind the
+same single bump).
+
+> **Atomic bump (must-have).** Update `sdk_version` *and* `recorded_abi_fingerprint` in
+> `src/sdk/version.zig` in the **same commit** that adds the new SDK types/Host hooks/manifest fields
+> — the comptime guard in `version.zig` plus `zig build test-sdk-version` fail the host build
+> otherwise. Until an external plugin is rebuilt against the new fingerprint it is rejected at load
+> with `error.AbiMismatch` and surfaces as a failed / "Needs a rebuild" card. Markdown is now in-tree
+> so it rebuilds automatically; pixi/ghostty (and the new zig plugin) must be rebuilt against the new
+> fingerprint.
+
+**Accept:** text plugin plain-only; widget lives in SDK once.
+
+---
+
+### Phase MD-lib — Markdown render library in-tree (native-only) · Status: [x] **Dep:** none (immediate; README enabler)
+
+**Goal:** Bring the markdown **rendering** engine in-tree as a host-side library the store calls
+directly — *not* a document-owning plugin. This is the minimum needed to render READMEs.
+
+#### MD-lib-a — Move the render code in-tree
+
+From `~/dev/fizzyedit/markdown/src` take only the **render** path (no plugin/document/editor glue):
+
+```
+src/markdown/ (host-side library; @import-able by editor/store)
+ markdown.zig // lib hub: pub fn drawPreview(bytes, opts) / parse+render
+ md/ (cmark_parse.zig, render_ast.zig, cmark_headers.h)
+ MarkDownPreviewWidget.zig // read-only preview draw
+```
+
+- Expose a small API, e.g. `pub fn drawPreview(gpa, bytes: []const u8, id_extra, opts) !void` (parse
+ with cmark → `render_ast` → draw), plus a cached-parse variant if needed.
+- **Leave behind** `plugin.zig`, `Document.zig`, `State.zig`, `MarkdownEditor.zig`, and the vendored
+ `widgets/TextEntryWidget.zig` — those belong to the optional editing wrapper (Phase MD-wrapper),
+ which reuses `sdk.text` for its source pane when/if we do it.
+
+#### MD-lib-b — cmark-gfm C dependency (native-only)
+
+- Add `cmark_gfm` to fizzy's `build.zig.zon`. Link `cmark-gfm` + `cmark-gfm-extensions` into the
+ module(s) that import `src/markdown` (native exe/app), with include paths for the dep's
+ `src`/`extensions` and `src/markdown/md`.
+
+> **Web/wasm (must-have).** cmark needs libc; the wasm build is `freestanding` + `link_libc = false`,
+> so the markdown render lib is **native-only**. The store is desktop-only anyway. Gate `src/markdown`
+> out of `build/web.zig` and behind `arch != .wasm32` at the import site (or provide a stub) so
+> `zig build` / `check-web` stay green. `.md` files on web simply open via `text`.
+
+**Accept:** `src/markdown.drawPreview(bytes)` renders markdown into the current dvui parent on
+native; `zig build` + `zig build check-web` both green; no document/plugin machinery involved.
+
+---
+
+### Phase MD-wrapper — Optional thin `.md` editing plugin · Status: [ ] **Dep:** 3a, MD-lib (low priority)
+
+**Goal (optional):** Restore `.md` split source/preview *editing* as a thin static-only built-in that
+reuses both shared engines — `sdk.text` (source pane) + `src/markdown` (preview). No vendored
+`TextEntryWidget`, no separately-buildable dylib. Skip until editing (not just README viewing) is
+wanted; READMEs do not need this.
+
+---
+
+### Phase META — Plugin metadata + repo README + logos · Status: [~] **Dep:** 1R, MD-lib
+
+> **Partly done:** META-c (README fetch + cache-in-memory + direct `markdown.drawPreview` render) is
+> implemented in `src/editor/readme.zig` and wired to card selection + the `shell.store.readme` center
+> provider. **Pending:** META-a manifest/descriptor metadata (still using registry `homepage` as the
+> repo URL — no `repository`/`logo`/`added` fields yet), META-b install/added date sidecar
+> (`plugin_meta.json`; the card subtitle currently shows the registry release `published` date only),
+> and META-d logos (cards draw a generic `package` placeholder).
+
+**Goal:** Per-plugin repository/author/version/logo + install/added dates; selecting a card renders
+the repo `README.md` via the markdown render library; logos fetched + cached.
+
+#### META-a — Registry + descriptor metadata
+
+- `registry.PluginEntry`: add `repository: []const u8 = ""`, `logo: []const u8 = ""`,
+ `added: []const u8 = ""` (JSON only — `ignore_unknown_fields` already tolerates old indexes; **no
+ ABI impact**). Keep existing `author`/`homepage`/`description`/`tags`/`releases[].published`.
+- Built-ins (no registry row): add a tiny `store_meta` descriptor in each
+ `static/integration.zig` (`repository`, `author`, optional `logo`) so their cards render full
+ metadata before the manifest carries it (Phase 4a).
+- Card subtitle shows **author · version**; a detail line shows **repository**, **installed** date,
+ **added** date.
+
+> **Design note — exported identity (consider before committing to descriptors/registry index).**
+> The same dylib **export mechanism** used elsewhere is the natural home for this metadata: rather than
+> a registry index or in-tree `store_meta` descriptors, each plugin could **export its full identity**
+> (`repository`, `author`, `version`, `logo`, …) as a queryable symbol — readable **without loading the
+> plugin**. That would collapse the registry-index / in-tree-descriptor / manifest split into a single
+> source of truth and is worth evaluating here before locking in the descriptor approach above.
+
+#### META-b — Install / added dates
+
+- `installed_at`: sidecar `plugin_meta.json` in the plugins dir (`id -> RFC3339 timestamp`). Written
+ when an install `Job` completes; built-ins stamped on first run if absent. Read at store draw.
+- `added`: from `PluginEntry.added` (fallback: earliest `Release.published`).
+
+#### META-c — README fetch + cache + direct render (no document detour)
+
+- Derive raw URL from `repository` (GitHub → `raw.githubusercontent.com///HEAD/README.md`;
+ fall back to `main`/`master`). Non-GitHub hosts: best-effort or skip with a friendly message.
+- Async worker (mirror `store.Catalog`): fetch bytes, cache to `/plugin-readme/.md`, expose
+ status (idle/fetching/ready/failed) + the in-memory bytes.
+- On card select (Phase 1R `selectPlugin`): kick the fetch; when ready, render the bytes with
+ **`src/markdown.drawPreview(bytes)`** (Phase MD-lib) into a read-only preview surface — a dedicated
+ store detail pane/tab, **not** a document opened through a plugin. Show "Loading README…" /
+ "No README found" placeholders by status.
+
+> **Lifetime + threading (must-have).** README/logo workers are `std.Thread`s like `Catalog`; never
+> touch dvui or `host.plugins` off-thread. Guard the bytes with a mutex (or hand-off atomic) and only
+> read them on the UI thread while rendering. No document/tab lifecycle, no save path — it is a pure
+> read-only render of fetched bytes.
+
+#### META-d — Logos
+
+- Optional `logo` URL (registry or built-in descriptor). Async fetch + cache to
+ `/plugin-logo/`; decode to a dvui image; draw in the 1R-b logo box. Generic icon
+ placeholder while fetching / on failure.
+
+**Accept:** cards show logo + author + version + repository + dates; clicking a card renders its
+README via the markdown library; logos load lazily; everything degrades gracefully offline.
+
+---
+
+### Phase 4 — Syntax provider registry + plugin refactors · Status: [ ] **Dep:** 3
+
+#### 4a — Single atomic ABI bump (syntax registry + manifest fields + sidebar Host API)
+
+This is the **one** SDK fingerprint bump. It folds together every ABI-affecting change so external
+plugins (pixi, ghostty) rebuild exactly once; the in-tree markdown built-in rebuilds automatically:
+
+**(i) Manifest schema** (`src/sdk/manifest.zig`) — add the store-required identity fields:
+
+```zig
+pub const PluginManifest = struct {
+ id: []const u8,
+ name: []const u8,
+ version: std.SemanticVersion,
+ repository: []const u8, // required: source repo link (README source)
+ author: []const u8, // required
+ logo: []const u8 = "", // optional: logo image URL
+ min_sdk_version: std.SemanticVersion = version.sdk_version,
+};
+```
+
+Update every in-tree plugin manifest (workbench, text, example, markdown) + external (pixi, ghostty,
+zig). Store cards prefer manifest metadata for installed plugins once this lands, falling back to the
+in-tree `store_meta` descriptors / registry index.
+
+**(ii) Host syntax provider registry** (`src/sdk/Host.zig`):
+
+```zig
+pub const SyntaxProvider = struct {
+ owner: ?*Plugin,
+ treeSitterForPath: *const fn (?*anyopaque, path: []const u8) ?sdk.text.TreeSitterOption,
+};
+
+syntax_providers: ArrayListUnmanaged(SyntaxProvider),
+
+pub fn registerSyntaxProvider(self: *Host, provider: SyntaxProvider) !void
+pub fn syntaxForPath(self: *Host, path: []const u8) ?sdk.text.TreeSitterOption
+```
+
+Walk providers in registration order; first non-null wins. `unregisterPlugin` drops owned providers.
+
+**(iii) Sidebar reorder Host API** (the Phase 5 boundary types — declared here so the rail UI in
+Phase 5 needs no further bump): `reorderSidebarView` / `applySidebarViewOrder` (see Phase 5a).
+
+> **No caching of `TreeSitterOption` (must-have).** The `TreeSitterOption` returned by
+> `syntaxForPath` carries function pointers that live inside the provider's dylib (e.g. the zig
+> plugin). The text editor / `TextEntryWidget` must **re-query** `host.syntaxForPath(path)` every
+> frame and never stash the result across frames. `tick()` applies an unload before the draw pass,
+> so within a frame it stays consistent — but a cached option from a previous frame would point into
+> a `dlclose`d image after the zig plugin is unloaded → use-after-free. `unregisterPlugin` dropping
+> owned providers (matched on `SyntaxProvider.owner`) covers the registry side; the no-cache rule
+> covers the consumer side.
+
+#### 4b — Markdown plugin refactor — SUPERSEDED
+
+Markdown rendering is now an in-tree host library (Phase MD-lib), and the optional `.md` editing
+wrapper (Phase MD-wrapper) reuses `sdk.text` + that lib. No external-repo edit or SDK-hash re-pin
+needed. (Pixi/ghostty still rebuild against the new fingerprint — see 4a.)
+
+#### 4c — Zig plugin (`~/dev/fizzyedit/zig`)
+
+Scaffold third-party plugin (mirror markdown/pixi layout):
+
+- `register`: `host.registerSyntaxProvider` for `.zig` and `.zon`.
+- Move/adapt `SyntaxHighlight.zig` + `queries/zig.scm` + tree-sitter deps from old code plugin.
+- Does **not** implement document vtable — text remains owner.
+- `zig build install` → dev plugins dir.
+
+**Accept:** the `.md` wrapper (if built) + zig share the SDK text engine; no duplicated
+TextEntryWidget forks.
+
+---
+
+### Phase 5 — Reorderable sidebar · Status: [ ] **Dep:** none (can parallel earlier phases)
+
+#### 5a — Host API (`src/sdk/Host.zig`)
+
+```zig
+pub fn reorderSidebarView(self: *Host, from_index: usize, to_index: usize) void
+pub fn applySidebarViewOrder(self: *Host, ids: []const []const u8) void
+```
+
+Mirror `Panel.swapBottomViews` pattern.
+
+#### 5b — Settings persistence (`src/editor/Settings.zig`)
+
+```zig
+sidebar_view_order: []const []const u8 = &.{},
+```
+
+After all plugins register in `postInit`, `applySidebarViewOrder` from saved ids (append new views).
+On reorder, update list + `markSettingsDirty`.
+
+#### 5c — Sidebar UI (`src/editor/Sidebar.zig`)
+
+- Wrap icons in `dvui.reorder` / `Reorderable` (vertical), like `PanelWorkspace.zig`.
+- Drag threshold so click-to-select still works.
+- Hidden views excluded from rail.
+
+> **Id + index safety (must-have).** Today `Sidebar.zig` draws icons with `id_extra = index` (the
+> position in `sidebar_views`, which still counts hidden entries). `Reorderable` needs a widget id
+> that is stable across array position, so use `id_extra = hash(view.id)` (same fix as the store),
+> otherwise the dragged icon's identity shifts mid-drag. Separately, `reorderBottomView` /
+> `swapBottomViews` operate on **raw** indices into the full list, but the rail skips
+> `view.hidden` entries — translate a "visible rail position" to its `sidebar_views` index before
+> swapping, or the wrong entries move. `applySidebarViewOrder` must skip ids that are missing
+> (plugin unloaded) or hidden when restoring the persisted order, and append any new views not in
+> the saved list (per 5b).
+
+**Accept:** drag Plugins above Settings; order survives restart.
+
+---
+
+## 2. Target architecture
+
+```mermaid
+flowchart TB
+ subgraph store [Plugin Store UI]
+ Filter["Filter textEntry"]
+ Cards["Flat card list A-Z (logo + title + desc + reserved actions)"]
+ Meta["Metadata: repo / author / version / installed+added dates"]
+ Readme["Select card -> fetch repo README -> cache -> markdown.drawPreview()"]
+ Filter --> Cards
+ Cards --> Meta
+ Cards --> Readme
+ Readme --> MDLib["src/markdown render lib (cmark, native-only)"]
+ end
+
+ subgraph routing [File open routing]
+ Open["openFilePath"] --> PFE["Host.pluginForExtension"]
+ PFE --> Spec["Specialized owner e.g. markdown prio 50"]
+ PFE --> Text["text plugin prio 100 fallback"]
+ end
+
+ subgraph editor [Shared engines]
+ SDKText["sdk.text.TextEditor"]
+ TEW["TextEntryWidget single copy"]
+ SDKText --> TEW
+ Text --> SDKText
+ MDLibE["src/markdown render lib (cmark)"]
+ Store2["Plugin store README"] --> MDLibE
+ MDWrap["optional .md wrapper"] --> SDKText
+ MDWrap --> MDLibE
+ end
+
+ subgraph syntax [Syntax providers]
+ ZigPlug["zig plugin"] --> Reg["Host.syntaxProviders"]
+ Reg --> SDKText
+ end
+
+ subgraph sidebar [Sidebar rail]
+ Reorder["ReorderWidget vertical"]
+ Persist["settings.sidebar_view_order"]
+ Reorder --> Persist
+ end
+```
+
+---
+
+## 3. Recommended execution order
+
+| Step | Phase | Risk |
+|------|-------|------|
+| 0 | Phase 0 — Lifecycle hardening (job cancel on unload) | Low; isolated, prereq for safe unload — **done** |
+| 1 | Phase 1 — Store unified model + filter | Low; UI only — **done** |
+| 2 | Phase 2a — code→text rename | Medium; wide mechanical diff — **done** |
+| 3 | Phase 2b/c — example default off | Low — **done** |
+| 4 | **Phase MD-lib** — Markdown render library in-tree (cmark dep, native-only/gated) | Medium; C dep + build wiring + wasm gate — **README enabler** |
+| 5 | **Phase META** — repo README fetch + cache + direct render via MD-lib | Medium; threaded fetch/cache |
+| 6 | Phase 1R — Store card list redesign (flat cards, hover, reserved actions, select→README) | Low; UI only |
+| 7 | Phase 3a — `sdk/text` engine extraction | Medium; moves widget |
+| 8 | Phase MD-wrapper — optional thin `.md` editing plugin over shared libs | Low; optional |
+| 9 | Phase 4a — **Single ABI bump**: manifest fields + syntax registry + sidebar Host API | Medium; ABI (rebuild pixi/ghostty/zig once) |
+| 10 | Phase 4c — zig plugin scaffold | Depends on 4a |
+| 11 | Phase 5 — sidebar reorder UI | Depends on 4a (Host API); new persistence |
+
+README rendering lands first (MD-lib → META) without any ABI change — the markdown render lib is a
+plain in-tree module, not an SDK boundary. Phase 1R wires card-select to the README pane. All
+ABI-affecting changes still land together in Phase 4a so external plugins rebuild **once**.
+
+---
+
+## 4. Verification
+
+- After each chunk: `zig build`, `zig build test`, `zig build test-sdk-version`
+- Plugin store: mixed registry + sideloaded plugins sort A→Z; filter matches id/name/description
+- **Cards:** flat list, no expanders; hovering anywhere on a card highlights the whole card; the
+ right action area stays a constant width across install/uninstall/enable (no horizontal jitter)
+- **Card metadata:** each card shows logo (or placeholder) + title + 1-line description + author ·
+ version; detail line shows repository, installed date, added date
+- **README:** selecting a card opens its repo `README.md` as a markdown tab in the center; offline /
+ no-README shows a graceful placeholder, not a crash
+- **Markdown render lib:** `src/markdown.drawPreview(bytes)` renders on native; gated out of wasm;
+ `zig build` + `zig build check-web` both green. (Optional `.md` editing wrapper reuses sdk.text +
+ this lib.)
+- Fresh settings: example hidden; text opens `.foo`, extensionless, and binary paths as plain text
+- With zig installed: `.zig`/`.zon` in text tabs show syntax; text still owns documents
+- Sidebar: reorder persists; disabled example absent until enabled
+
+### Load/unload stability checks (must-pass)
+
+- Disable/uninstall a plugin **while one of its files is mid-load** → no crash; the partial open is
+ dropped (Phase 0 job cancellation).
+- The `text` fallback cannot be disabled or uninstalled (no buttons; API rejects); opening any file
+ still works after toggling other plugins on/off.
+- Reorder the sidebar **while a view is hidden** → the correct (visible) items move; order survives
+ restart; a saved order that references an unloaded/missing id loads cleanly.
+- Rapid enable → disable → uninstall on the same id leaves exactly **one** stable store row (no
+ duplicate branches, no id-collision warning, no flicker).
+- After the SDK fingerprint bump, an un-rebuilt external plugin shows as a single "Needs a rebuild" /
+ failed row and does not crash the store.
+
+---
+
+## 5. Status summary
+
+- [x] Phase 0 — Lifecycle hardening (cancel in-flight FileLoadJobs on unload)
+- [x] Phase 1 — Store unified model + filter + A→Z (layout superseded by 1R)
+- [x] Phase 2a — code → text rename
+- [x] Phase 2b/c — Universal open + example disabled by default
+- [x] Phase MD-lib — Markdown render library in-tree (`src/markdown`, native-only) — **README enabler**
+- [~] Phase META — README fetch/cache + direct render done (`readme.zig`); **logos, install/added dates,
+ and manifest `repository`/`author`/`logo` fields still pending** (repo URL still sourced from the
+ registry `homepage` field via `repoUrl`).
+- [x] Phase 1R — Store card list redesign (flat cards, whole-card hover, state-based controls,
+ select→README **in the center pane**). See note below.
+- [ ] Phase 3a — `sdk/text` engine extraction (thin text plugin)
+- [ ] Phase MD-wrapper — optional thin `.md` editing plugin over shared libs
+- [ ] Phase 4a — Single ABI bump: manifest fields + syntax registry + sidebar Host API
+- [ ] ~~Phase 4b~~ — Superseded (markdown render is an in-tree lib; no external refactor)
+- [ ] Phase 4c — Zig syntax plugin scaffold
+- [ ] Phase 5 — Reorderable sidebar UI
+
+---
+
+## 6. Out of scope (future)
+
+- Grouped sections (Installed / Available headers) within the flat card list
+- Rich README features beyond what the markdown built-in renders (e.g. relative image rewriting to
+ absolute repo URLs is best-effort)
+- Non-GitHub repository hosts get only best-effort raw-README derivation
+- Dedicated file-type dropdown in file tree (name filter already shows all types once text is fallback)
+- Ghostty/terminal plugin integration
diff --git a/VELOPACK_PLUGIN_SPLIT_HANDOFF.md b/VELOPACK_PLUGIN_SPLIT_HANDOFF.md
new file mode 100644
index 00000000..7e603ddc
--- /dev/null
+++ b/VELOPACK_PLUGIN_SPLIT_HANDOFF.md
@@ -0,0 +1,316 @@
+# Handoff: remove velopack from plugin builds (SDK/app package split) + 6-target CI
+
+**Status:** design approved, implementation NOT started. Repos are pristine (see "Current
+repo state"). This doc is self-contained — read it top to bottom before touching anything.
+
+**Author context:** written 2026-07-01 for the next agent. The human is `foxnne`
+(owns fizzy, pixi, plugin-build-action, and velopack-zig).
+
+---
+
+## 1. Where this came from (the original ask)
+
+Two related goals surfaced, in order:
+
+1. **CI only builds 3 of 6 targets for plugins.** `pixi/.github/workflows/release.yml`
+ calls the reusable workflow `fizzyedit/plugin-build-action/.github/workflows/build.yml@v1`,
+ whose matrix has only `macos-aarch64`, `linux-x86_64`, `windows-x86_64`. Missing:
+ **macos-x86_64, windows-aarch64, linux-aarch64.** Fizzy supports all 6, and a plugin is a
+ native dylib/.so/.dll — each host target needs its own build, so those 3 arches currently
+ have no loadable pixi binary and no `manifest.json` entry.
+
+2. **While fixing that, we found plugin builds drag in velopack** (Velopack — the host app's
+ self-installer/updater). CI even carries a workaround comment about velopack's prebuilt
+ zip + a Zig 0.16 zip-unpack bug (`mkdir -p .../tmp`). The human wants velopack to **not
+ appear in a plugin build at all**, and approved restructuring to achieve it.
+
+**This handoff is primarily about goal #2 (the hard part). Goal #1 is straightforward and
+described in §7 — do it too, but it's independent.**
+
+---
+
+## 2. Root cause (proven, not theorized)
+
+A Fizzy plugin (e.g. pixi) reaches fizzy's SDK modules by calling, inside
+`plugin_sdk.zig`:
+
+```zig
+// plugin_sdk.zig:53-59 (fizzyDep)
+b.dependency("fizzy", .{ .target = ..., .optimize = ..., .plugin_sdk = true });
+```
+
+`b.dependency(...)` **compiles fizzy's `build.zig`**. Fizzy's `build.zig` `build()` does:
+
+```zig
+// build.zig (root), simplified
+if (plugin_sdk) { try plugin.exportModules(b, target, optimize); return; }
+try @import("build/app.zig").build(b, target, optimize, .{ ... }); // <-- line ~31
+```
+
+`build/app.zig` top-level-imports velopack:
+
+```zig
+// build/app.zig:5
+const velopack = @import("velopack_zig");
+```
+
+**The killer fact:** `@import` is **comptime and transitive**, and Zig analyzes the *entire*
+`build()` body regardless of the runtime `if (plugin_sdk) return;`. So compiling fizzy's
+build script *always* pulls `build/app.zig`, which *always* pulls `velopack_zig`. The runtime
+early-return does nothing to prevent it.
+
+### What was tested and RULED OUT (don't re-try these)
+
+All tested locally using `fizzy/src/plugins/example` as a harness (it imports fizzy exactly
+like pixi does, via local `.path = "../../.."`), with velopack purged from the global cache
+(`~/.cache/zig/p`) to simulate cold CI:
+
+- **Marking `velopack_zig` `.lazy = true` in fizzy's `build.zig.zon`** — still pulled. When
+ the global cache also lacks it, the plugin build *hard-errors*:
+ `error: no module named 'velopack_zig' available ... build/app.zig:5 ... referenced by
+ build.zig:31`. (Earlier "successes" were just global-cache reuse.)
+- **`if (plugin_sdk) return;` before the app call** — app.zig still analyzed (that's the
+ whole point — comptime analysis ignores the runtime branch).
+- **Wrapping the app call in `if (b.lazyDependency("velopack_zig", ...)) |_| { ... }`** —
+ still analyzed, still errors. Runtime guards cannot gate comptime `@import`.
+- **Making velopack-zig's own 87 MB prebuilt zip lazy** (option C) — would cut the fetch to
+ the inert 84 KB wrapper, but the human explicitly wants *zero* velopack, so rejected.
+
+**Conclusion:** the velopack `@import` must physically leave the build script that the plugin
+sub-build compiles. There is no flag-level fix.
+
+### Verified-clean facts that make the split feasible
+
+- `src/core` and `src/sdk` import only `dvui`, `icons`, `proxy_bridge` — **no velopack, no app
+ coupling** (`grep -rn velopack src/core src/sdk` → nothing).
+- `exportModules` (`plugin_sdk.zig:260-296`) needs only: `dvui` dep (backend=.proxy,
+ accesskit=.off), `icons` (lazy), `src/core/core.zig`, `src/sdk/sdk.zig`. It publishes
+ modules `core`, `sdk`, `dvui`, `proxy_bridge`.
+- Only **3** build files import velopack: `build/app.zig`, `build/exe.zig`,
+ `build/package.zig`. Helpers used: `linkVelopack`, `resolveWindowsMsvcLibc`,
+ `applyWindowsMsvcLibcRecursive` (app.zig), `linkVelopack` (exe.zig),
+ `attachMksquashfsToVpkRun` (package.zig).
+
+---
+
+## 3. Why the "obvious" splits don't work (pinning constraint)
+
+**pixi pins fizzy by repo-tarball URL:**
+
+```
+# pixi/build.zig.zon
+.fizzy = .{ .url = "https://github.com/fizzyedit/fizzy/archive/.tar.gz", .hash = "..." }
+```
+
+Zig identifies a package by the `build.zig.zon` at the **tarball root**. Zig **cannot** point
+a dependency at a *subdirectory* of a tarball. Therefore:
+
+- A `sdk/` **subfolder** package is NOT URL-consumable by pixi. ❌
+- A **separate `fizzy-sdk` repo** would work but means moving `src/core`+`src/sdk` out of
+ fizzy and publishing a new repo (agent can't create repos; bigger blast radius). ❌ (for now)
+
+So the chosen design keeps the **repo root** as the package pixi pins, and makes *that* root
+velopack-free, pushing the app build DOWN into a sub-package. **pixi, all other plugins, and
+the build action stay completely unchanged** (they keep `@import("fizzy")`, keep pinning the
+fizzy repo, keep passing `fizzy-sdk-version`).
+
+---
+
+## 4. APPROVED DESIGN — internal inversion (root = SDK-only; app = sub-package)
+
+Folder for the app sub-package: **`build/app/`** (human's choice). If a second sub-package is
+later needed, use `build/plugin/`.
+
+### 4a. Root `build.zig` (what every plugin compiles) becomes velopack-free
+
+`build()` dispatches three ways, and **never `@import`s velopack or `build/app.zig`**:
+
+- `plugin_sdk == true` → `plugin.exportModules(...)`; return. *(unchanged)*
+- `as_lib == true` → same `exportModules(...)`; return. *(NEW option — lets the app
+ sub-package pull core/sdk/dvui back from the root without recursing into the app build.)*
+- otherwise (top-level `zig build`) → `const app = b.dependency("app", .{ .target, .optimize,
+ ...forward user options... }); ` then forward/install its artifacts + re-expose its steps
+ (`web`, `package`, `test`, `msvcup-setup`, etc.).
+
+Root `build.zig.zon`:
+- **Keep:** `dvui`, `icons` (both needed by exportModules).
+- **Add:** `.app = .{ .path = "build/app", .lazy = true }`.
+- **Remove:** `velopack_zig` (moves to the sub-package). Also move `assetpack`,
+ `cmark_gfm`, `zig_objc`, `zigwin32`, `dvui_singleton_app` to the sub-package IF they're only
+ used by the app build — verify with grep; `dvui`/`icons` must stay at root.
+
+### 4b. New sub-package `build/app/` owns the app build (+ velopack)
+
+- `build/app/build.zig` — contains the real app build. The current `build/*.zig` files move
+ here (see §5 for the file list). Its `@import("velopack_zig")` lives HERE, so it is compiled
+ **only** when `b.dependency("app", ...)` runs (the `zig build` app path), never during a
+ plugin's `b.dependency("fizzy", .{.plugin_sdk=true})`.
+- `build/app/build.zig.zon` — declares `velopack_zig` + the app-only deps (assetpack,
+ cmark_gfm, zig_objc, zigwin32, dvui_singleton_app, dvui, icons as needed) + the fizzy root:
+ `.fizzy = .{ .path = "../.." }`.
+- **Source access:** the app sub-package reads fizzy's sources via the root dependency:
+ `const fizzy = b.dependency("fizzy", .{ .as_lib = true, .target, .optimize });` then
+ `fizzy.path("src/editor/Editor.zig")`, `fizzy.module("core")`, `fizzy.module("sdk")`, etc.
+ **Every `b.path("src/…")` in the moved build files becomes `fizzy.path("src/…")`.** This is
+ the bulk of the mechanical work.
+
+### 4c. Recursion guard
+
+`zig build` at root → root calls `b.dependency("app")` → app calls
+`b.dependency("fizzy", .{.as_lib=true})` → root runs again but `as_lib` short-circuits to
+`exportModules` and returns, so it does NOT call `b.dependency("app")` again. No infinite loop.
+**This is the load-bearing invariant — verify it holds after wiring.**
+
+### Net effect
+
+Plugin builds compile only the root's velopack-free `build()` → **zero velopack in the graph,
+no fetch, no error.** `zig build` (app) works as before. pixi / plugins / build action:
+untouched.
+
+---
+
+## 5. Concrete file inventory (as of this handoff)
+
+`build/` currently contains (velopack importers marked ⚠):
+
+| file | imports of note | role |
+|---|---|---|
+| `build/app.zig` ⚠ | `../plugin_sdk.zig`, `dvui`, `velopack_zig`, `assetpack` | main native app orchestrator; builds core mod at `b.path("src/core/core.zig")`, wires exe/web/tests/package |
+| `build/exe.zig` ⚠ | `dvui`, `velopack_zig`, `../plugin_sdk.zig` | the native exe compile; core mod at lines 124/139 |
+| `build/package.zig` ⚠ | `velopack_zig` | vpk packaging; `attachMksquashfsToVpkRun` at ~194 |
+| `build/web.zig` | (none external shown) | wasm build; core mod at line 78 |
+| `build/sdk.zig` | — | builds sdk module at `b.path("src/sdk/sdk.zig")` line 31 |
+| `build/common.zig` | `../plugin_sdk.zig`, `../update.zig` | shared build opts |
+| `build/msvc.zig` | — | MSVC libc plumbing |
+| `build/markdown.zig` | — | cmark_gfm wiring |
+| `build/plugins.zig` | `../src/plugins/*/static/integration.zig` | static built-in plugin integrations (workbench, text, example) |
+
+All of these except the pure-SDK path move under `build/app/`. `plugin_sdk.zig` stays at root
+(it IS the SDK surface). `build/plugins.zig` references `../src/plugins/...` — after moving to
+`build/app/`, those become `fizzy.path("src/plugins/...")` or `@import` via the root dep.
+
+**Relative-import fixups when files move to `build/app/`:** `../plugin_sdk.zig` →
+`../../plugin_sdk.zig` (or better, get it from the fizzy root dep), `../update.zig` →
+`../../update.zig`, `../src/...` → `fizzy.path("src/...")`.
+
+### Velopack surface to preserve (from `~/dev/velopack-zig/build.zig`)
+
+`linkVelopack(b, compile, .{target, optimize})`, `resolveWindowsMsvcLibc(b, target, opts)`,
+`applyWindowsMsvcLibcRecursive(b, roots, libc_lp)`, `attachMksquashfsToVpkRun(b, run, target)`.
+These stay called from the moved files via `@import("velopack_zig")` in `build/app/` — no need
+to inline/vendor them (that was the rejected option A).
+
+---
+
+## 6. Test plan (what the agent CAN and CANNOT verify locally)
+
+Local harness: `fizzy/src/plugins/example` (`cd fizzy/src/plugins/example && zig build`).
+Zig is 0.16.0 via anyzig; global cache at `~/.cache/zig/p`.
+
+**Must pass locally:**
+1. **Plugin build is velopack-free.** After the split, purge velopack from global cache
+ (`ls ~/.cache/zig/p | grep -iE 'velo|N-V-__8AAGLSaQX1'` then `rm -rf` those) AND
+ `rm -rf src/plugins/example/{.zig-cache,zig-out,zig-pkg}`, then `zig build`. It must
+ **succeed** and NOT re-materialize velopack:
+ `ls src/plugins/example/zig-pkg | grep -i velo` → empty. This is THE acceptance test.
+ (Cross-check: inspect the generated `.../dependencies.zig` — no `velopack` entries.)
+2. **macOS app still builds:** `cd fizzy && zig build` (native exe) and `zig build check-web`.
+3. `zig build test` and `zig build test-sdk-version` (SDK boundary locks).
+
+**Cannot be verified locally (flag for the human / CI):**
+- Windows/Linux cross-compile, `*-windows-msvc` libc resolution, vpk / mksquashfs / signing /
+ notarization packaging. These exercise the moved velopack paths on non-macOS. Confirm on CI.
+
+---
+
+## 7. Goal #1 — build action: add the 3 missing targets (independent, do this too)
+
+File: `~/dev/fizzyedit/plugin-build-action/.github/workflows/build.yml` (repo on branch
+`main`, local + on GitHub as `fizzyedit/plugin-build-action`, consumed by pixi at `@v1`).
+
+Current matrix (`build.yml` ~line 48):
+```yaml
+matrix:
+ include:
+ - { os_arch: macos-aarch64, runner: macos-14, ext: dylib }
+ - { os_arch: linux-x86_64, runner: ubuntu-latest, ext: so }
+ - { os_arch: windows-x86_64, runner: windows-latest, ext: dll }
+```
+Add the missing 3. Plugins are pure Zig + vendored C (stbi/msf_gif/zip all compile from
+source), so **cross-compilation via `-Dtarget` should just work** — no need for scarce
+arm64 runners:
+- `macos-x86_64` → runner `macos-14`, build `-Dtarget=x86_64-macos`, ext `dylib`
+- `linux-aarch64` → runner `ubuntu-latest`, build `-Dtarget=aarch64-linux-gnu`, ext `so`
+- `windows-aarch64`→ runner `windows-latest`, build `-Dtarget=aarch64-windows`, ext `dll`
+
+Details:
+- The build step is currently `zig build -Doptimize=ReleaseFast` (native). Add a matrix
+ `zig_target` field and pass `-Dtarget=${{ matrix.zig_target }}` (leave native entries without
+ it, or set their explicit triples for consistency).
+- `os_arch` string flows into the asset name + `manifest.json` (`assemble_manifest.py` reads
+ the `frag/*.json`), so keep the exact `os_arch` values above — they must match what the host
+ loader expects for each target.
+- **Blocker to check FIRST:** velopack is irrelevant to plugins (that's goal #2), but confirm
+ the plugin itself cross-compiles cleanly for arm64 Windows/Linux. The Zig 0.16 zip-unpack
+ workaround (`mkdir -p "${ZIG_GLOBAL_CACHE_DIR:-$HOME/.cache/zig}/tmp"`) was added because of
+ velopack's .zip dep — once goal #2 lands and plugins no longer pull velopack, that workaround
+ may be removable, but leave it until goal #2 is merged & the pin is updated.
+- After adding targets, re-pin: pixi's `release.yml` uses `@v1`; if you cut the change on a
+ branch, the human must move/retag `v1` (or pixi bumps the ref) for it to take effect.
+
+---
+
+## 8. Current repo state (IMPORTANT — leave these as noted)
+
+- **fizzy** (`~/dev/fizzy`): **detached HEAD from `055843404`** (not on a branch). My
+ experimental edits to `build.zig` and `build.zig.zon` were **reverted** (`git checkout --`),
+ so those two files are pristine.
+ - **Pre-existing uncommitted change NOT mine:** `build/app.zig` has an unstaged diff removing
+ some "Checkpoint A" / Zig-0.15 comments. This is the human's work — **do not revert it**,
+ build around it.
+ - There is a `git stash@{0}` ("frame-based window geometry ownership …") unrelated to this
+ work — leave it.
+- **pixi** (`~/dev/fizzyedit/pixi`): clean.
+- **plugin-build-action** (`~/dev/fizzyedit/plugin-build-action`): clean, branch `main`.
+- **velopack-zig** (`~/dev/velopack-zig`): the human owns it; unchanged. We are NOT modifying it
+ (option C rejected).
+- **Caches:** I purged velopack from `~/.cache/zig/p` during testing — the next fizzy app build
+ will re-fetch it (harmless). `fizzy/src/plugins/example` has leftover `.zig-cache`/`zig-pkg`
+ build artifacts from tests (harmless; delete freely).
+
+---
+
+## 9. Key references
+
+- Root build: `fizzy/build.zig` (dispatch), `fizzy/plugin_sdk.zig` (`fizzyDep` @53,
+ `exportModules` @260).
+- App build (to be moved): `fizzy/build/app.zig`, `exe.zig`, `package.zig`, `web.zig`,
+ `sdk.zig`, `common.zig`, `msvc.zig`, `markdown.zig`, `plugins.zig`.
+- SDK version / ABI fingerprint: `fizzy/src/sdk/version.zig` (`sdk_version`,
+ `recorded_sdk_shape_fingerprint`); `zig build test-sdk-version` locks it.
+- Architecture overview: `fizzy/CLAUDE.md` (shell+plugins model, `src/sdk`, `src/core`,
+ `src/plugins`), `fizzy/docs/PLUGINS.md`.
+- Plugin consumer example: `fizzy/src/plugins/example/build.zig` + `build.zig.zon`
+ (local `.path` fizzy dep — use as the fast test harness).
+- pixi plugin: `~/dev/fizzyedit/pixi/build.zig`, `build.zig.zon`, `.github/workflows/release.yml`.
+- Build action: `~/dev/fizzyedit/plugin-build-action/.github/workflows/build.yml`,
+ `scripts/assemble_manifest.py`.
+
+---
+
+## 10. Suggested execution order
+
+1. **Prove the mechanism small (PoC)** before moving real code: make root `build.zig` velopack
+ -free with the 3-way dispatch + `as_lib`; create a minimal `build/app/` that just builds a
+ trivial exe and reads one file via `fizzy.path(...)`; wire the recursion guard. Run the §6
+ acceptance test (plugin build velopack-free) + `zig build`. If green, the design holds.
+2. **Move the real app build** into `build/app/` incrementally: exe first (`exe.zig`,
+ `app.zig`), then web, then packaging/msvc/signing. Rebuild + example-plugin check after each.
+3. Run full local test matrix (§6). Hand off Windows/Linux/packaging verification to CI/human.
+4. **Goal #1 build action** (§7) — independent, can be done anytime.
+5. Update `fizzy/CLAUDE.md` "Build" section to document the root=SDK / `build/app` split, and
+ (per an earlier request) add a `CLAUDE.md` to `~/dev/fizzyedit/pixi` pointing at
+ `~/dev/fizzy` and its CLAUDE.md.
+
+**Do NOT commit or push without the human's go-ahead. fizzy is on a detached HEAD — create a
+branch before committing.**
diff --git a/assets/.fizproject b/assets/.fizproject
deleted file mode 100644
index fc780b0b..00000000
--- a/assets/.fizproject
+++ /dev/null
@@ -1 +0,0 @@
-{"packed_image_output":"/Users/foxnne/dev/proj/pixi/assets/fizzy.png","packed_atlas_output":"/Users/foxnne/dev/proj/pixi/assets/fizzy.atlas","pack_on_save":true}
\ No newline at end of file
diff --git a/assets/fizzy.atlas b/assets/fizzy.atlas
deleted file mode 100644
index 91545d05..00000000
--- a/assets/fizzy.atlas
+++ /dev/null
@@ -1 +0,0 @@
-{"sprites":[{"origin":[0,22],"source":[240,0,14,18]},{"origin":[0,22],"source":[92,0,22,22]},{"origin":[0,22],"source":[48,0,22,22]},{"origin":[0,14],"source":[129,21,15,16]},{"origin":[-1,21],"source":[220,0,20,20]},{"origin":[-1,21],"source":[180,0,20,20]},{"origin":[-1,21],"source":[200,0,20,20]},{"origin":[0,21],"source":[138,0,21,21]},{"origin":[0,21],"source":[159,0,21,21]},{"origin":[0,21],"source":[114,0,24,21]},{"origin":[0,14],"source":[114,21,15,16]},{"origin":[0,14],"source":[207,20,24,17]},{"origin":[0,14],"source":[231,20,24,16]},{"origin":[0,22],"source":[70,0,22,22]},{"origin":[0,22],"source":[0,0,24,22]},{"origin":[0,22],"source":[24,0,24,22]},{"origin":[3,20],"source":[180,20,27,18]},{"origin":[-10,13],"source":[144,21,3,4]}],"animations":[{"name":"cursor_default","frames":[{"sprite_index":0,"ms":1000}]},{"name":"pencil_default","frames":[{"sprite_index":1,"ms":1000}]},{"name":"eraser_default","frames":[{"sprite_index":2,"ms":1000}]},{"name":"bucket_default","frames":[{"sprite_index":3,"ms":1000}]},{"name":"box_selection_default","frames":[{"sprite_index":4,"ms":1000}]},{"name":"box_selection_add_default","frames":[{"sprite_index":5,"ms":1000}]},{"name":"box_selection_rem_default","frames":[{"sprite_index":6,"ms":1000}]},{"name":"color_selection_default","frames":[{"sprite_index":10,"ms":1000}]},{"name":"color_selection_add_default","frames":[{"sprite_index":11,"ms":1000}]},{"name":"color_selection_rem_default","frames":[{"sprite_index":12,"ms":1000}]},{"name":"pixel_selection_default","frames":[{"sprite_index":13,"ms":1000}]},{"name":"pixel_selection_add_default","frames":[{"sprite_index":14,"ms":1000}]},{"name":"pixel_selection_rem_default","frames":[{"sprite_index":15,"ms":1000}]},{"name":"fox_default","frames":[{"sprite_index":16,"ms":125}]},{"name":"logo_default","frames":[{"sprite_index":17,"ms":1000}]}]}
\ No newline at end of file
diff --git a/assets/fizzy.png b/assets/fizzy.png
deleted file mode 100644
index 55631d1d..00000000
Binary files a/assets/fizzy.png and /dev/null differ
diff --git a/assets/palettes/apollo.hex b/assets/palettes/apollo.hex
deleted file mode 100644
index 3d00dac9..00000000
--- a/assets/palettes/apollo.hex
+++ /dev/null
@@ -1,46 +0,0 @@
-172038
-253a5e
-3c5e8b
-4f8fba
-73bed3
-a4dddb
-19332d
-25562e
-468232
-75a743
-a8ca58
-d0da91
-4d2b32
-7a4841
-ad7757
-c09473
-d7b594
-e7d5b3
-341c27
-602c2c
-884b2b
-be772b
-de9e41
-e8c170
-241527
-411d31
-752438
-a53030
-cf573c
-da863e
-1e1d39
-402751
-7a367b
-a23e8c
-c65197
-df84a5
-090a14
-10141f
-151d28
-202e37
-394a50
-577277
-819796
-a8b5b2
-c7cfcc
-ebede9
diff --git a/assets/palettes/downgraded-32.hex b/assets/palettes/downgraded-32.hex
deleted file mode 100644
index 0f317e5f..00000000
--- a/assets/palettes/downgraded-32.hex
+++ /dev/null
@@ -1,32 +0,0 @@
-7b334c
-a14d55
-c77369
-e3a084
-f2cb9b
-d37b86
-af5d8b
-804085
-5b3374
-412051
-5c486a
-887d8d
-b8b4b2
-dcdac9
-ffffe0
-b6f5db
-89d9d9
-72b6cf
-5c8ba8
-4e6679
-464969
-44355d
-3d003d
-621748
-942c4b
-c7424f
-e06b51
-f2a561
-fcef8d
-b1d480
-80b878
-658d78
diff --git a/assets/palettes/eighexplore.hex b/assets/palettes/eighexplore.hex
deleted file mode 100644
index 84a225a5..00000000
--- a/assets/palettes/eighexplore.hex
+++ /dev/null
@@ -1,32 +0,0 @@
-582838
-882838
-a83848
-c85868
-d86858
-e88858
-d8b868
-d8c8a8
-284848
-386858
-689858
-a8b858
-486878
-7898a8
-a8a8b8
-d8d8d8
-382848
-584878
-686898
-6888b8
-583858
-884868
-c86888
-c89898
-381828
-884848
-986868
-b88888
-281828
-483848
-785868
-a89898
diff --git a/assets/palettes/endesga-16.hex b/assets/palettes/endesga-16.hex
deleted file mode 100644
index 2f93eb90..00000000
--- a/assets/palettes/endesga-16.hex
+++ /dev/null
@@ -1,16 +0,0 @@
-e4a672
-b86f50
-743f39
-3f2832
-9e2835
-e53b44
-fb922b
-ffe762
-63c64d
-327345
-193d3f
-4f6781
-afbfd2
-ffffff
-2ce8f4
-0484d1
diff --git a/assets/palettes/endesga-32.hex b/assets/palettes/endesga-32.hex
deleted file mode 100644
index 42bf9a92..00000000
--- a/assets/palettes/endesga-32.hex
+++ /dev/null
@@ -1,32 +0,0 @@
-be4a2f
-d77643
-ead4aa
-e4a672
-b86f50
-733e39
-3e2731
-a22633
-e43b44
-f77622
-feae34
-fee761
-63c74d
-3e8948
-265c42
-193c3e
-124e89
-0099db
-2ce8f5
-ffffff
-c0cbdc
-8b9bb4
-5a6988
-3a4466
-262b44
-181425
-ff0044
-68386c
-b55088
-f6757a
-e8b796
-c28569
diff --git a/assets/palettes/fizzy.hex b/assets/palettes/fizzy.hex
deleted file mode 100644
index 906a2bf0..00000000
--- a/assets/palettes/fizzy.hex
+++ /dev/null
@@ -1,10 +0,0 @@
-5d275d
-b13e53
-ef7d57
-ffcd75
-a7f070
-38b764
-257179
-29366f
-3b5dc9
-41a6f6
diff --git a/assets/palettes/journey.hex b/assets/palettes/journey.hex
deleted file mode 100644
index 0b1acf7d..00000000
--- a/assets/palettes/journey.hex
+++ /dev/null
@@ -1,64 +0,0 @@
-050914
-110524
-3b063a
-691749
-9c3247
-d46453
-f5a15d
-ffcf8e
-ff7a7d
-ff417d
-d61a88
-94007a
-42004e
-220029
-100726
-25082c
-3d1132
-73263d
-bd4035
-ed7b39
-ffb84a
-fff540
-c6d831
-77b02a
-429058
-2c645e
-153c4a
-052137
-0e0421
-0c0b42
-032769
-144491
-488bd4
-78d7ff
-b0fff1
-faffff
-c7d4e1
-928fb8
-5b537d
-392946
-24142c
-0e0f2c
-132243
-1a466b
-10908e
-28c074
-3dff6e
-f8ffb8
-f0c297
-cf968c
-8f5765
-52294b
-0f022e
-35003b
-64004c
-9b0e3e
-d41e3c
-ed4c40
-ff9757
-d4662f
-9c341a
-691b22
-450c28
-2d002e
diff --git a/assets/palettes/lospec500.hex b/assets/palettes/lospec500.hex
deleted file mode 100644
index 7c5f7617..00000000
--- a/assets/palettes/lospec500.hex
+++ /dev/null
@@ -1,42 +0,0 @@
-10121c
-2c1e31
-6b2643
-ac2847
-ec273f
-94493a
-de5d3a
-e98537
-f3a833
-4d3533
-6e4c30
-a26d3f
-ce9248
-dab163
-e8d282
-f7f3b7
-1e4044
-006554
-26854c
-5ab552
-9de64e
-008b8b
-62a477
-a6cb96
-d3eed3
-3e3b65
-3859b3
-3388de
-36c5f4
-6dead6
-5e5b8c
-8c78a5
-b0a7b8
-deceed
-9a4d76
-c878af
-cc99ff
-fa6e79
-ffa2ac
-ffd1d5
-f6e8e0
-ffffff
diff --git a/assets/palettes/pear36.hex b/assets/palettes/pear36.hex
deleted file mode 100644
index 7bcaf73b..00000000
--- a/assets/palettes/pear36.hex
+++ /dev/null
@@ -1,36 +0,0 @@
-5e315b
-8c3f5d
-ba6156
-f2a65e
-ffe478
-cfff70
-8fde5d
-3ca370
-3d6e70
-323e4f
-322947
-473b78
-4b5bab
-4da6ff
-66ffe3
-ffffeb
-c2c2d1
-7e7e8f
-606070
-43434f
-272736
-3e2347
-57294b
-964253
-e36956
-ffb570
-ff9166
-eb564b
-b0305c
-73275c
-422445
-5a265e
-80366b
-bd4882
-ff6b97
-ffb5b5
diff --git a/assets/palettes/pico-8.hex b/assets/palettes/pico-8.hex
deleted file mode 100644
index a8dbfeae..00000000
--- a/assets/palettes/pico-8.hex
+++ /dev/null
@@ -1,16 +0,0 @@
-000000
-1D2B53
-7E2553
-008751
-AB5236
-5F574F
-C2C3C7
-FFF1E8
-FF004D
-FFA300
-FFEC27
-00E436
-29ADFF
-83769C
-FF77A8
-FFCCAA
diff --git a/assets/palettes/resurrect-64.hex b/assets/palettes/resurrect-64.hex
deleted file mode 100644
index e02f18f5..00000000
--- a/assets/palettes/resurrect-64.hex
+++ /dev/null
@@ -1,64 +0,0 @@
-2e222f
-3e3546
-625565
-966c6c
-ab947a
-694f62
-7f708a
-9babb2
-c7dcd0
-ffffff
-6e2727
-b33831
-ea4f36
-f57d4a
-ae2334
-e83b3b
-fb6b1d
-f79617
-f9c22b
-7a3045
-9e4539
-cd683d
-e6904e
-fbb954
-4c3e24
-676633
-a2a947
-d5e04b
-fbff86
-165a4c
-239063
-1ebc73
-91db69
-cddf6c
-313638
-374e4a
-547e64
-92a984
-b2ba90
-0b5e65
-0b8a8f
-0eaf9b
-30e1b9
-8ff8e2
-323353
-484a77
-4d65b4
-4d9be6
-8fd3ff
-45293f
-6b3e75
-905ea9
-a884f3
-eaaded
-753c54
-a24b6f
-cf657f
-ed8099
-831c5d
-c32454
-f04f78
-f68181
-fca790
-fdcbb0
diff --git a/assets/palettes/sweetie-16.hex b/assets/palettes/sweetie-16.hex
deleted file mode 100644
index 759c118c..00000000
--- a/assets/palettes/sweetie-16.hex
+++ /dev/null
@@ -1,16 +0,0 @@
-1a1c2c
-5d275d
-b13e53
-ef7d57
-ffcd75
-a7f070
-38b764
-257179
-29366f
-3b5dc9
-41a6f6
-73eff7
-f4f4f4
-94b0c2
-566c86
-333c57
diff --git a/assets/src/cursors.pixi b/assets/src/cursors.pixi
deleted file mode 100644
index 4531d80d..00000000
Binary files a/assets/src/cursors.pixi and /dev/null differ
diff --git a/assets/src/misc.pixi b/assets/src/misc.pixi
deleted file mode 100644
index 83bfba5f..00000000
Binary files a/assets/src/misc.pixi and /dev/null differ
diff --git a/build.zig b/build.zig
index 48476dd6..6968539d 100644
--- a/build.zig
+++ b/build.zig
@@ -1,139 +1,11 @@
const std = @import("std");
-const zip = @import("src/deps/zip/build.zig");
-
-const dvui = @import("dvui");
-const velopack = @import("velopack_zig");
-
-const content_dir = "assets/";
-
-const ProcessAssetsStep = @import("src/tools/process_assets.zig");
-
-const update = @import("update.zig");
-const GitDependency = update.GitDependency;
-fn update_step(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void {
- const deps = &.{
- GitDependency{
- // zig_objc
- .url = "https://github.com/foxnne/zig-objc",
- .branch = "main",
- },
- GitDependency{
- // zigwin32 (kristoff-it fork has the zig 0.16 fix branch)
- .url = "https://github.com/kristoff-it/zigwin32",
- .branch = "fix/zig16",
- },
- GitDependency{
- // icons
- .url = "https://github.com/foxnne/zig-lib-icons",
- .branch = "dvui",
- },
- GitDependency{
- // dvui
- .url = "https://github.com/foxnne/dvui-dev",
- .branch = "main",
- },
- };
- try update.update_dependency(step.owner.allocator, step.owner.graph.io, deps);
-}
-
-/// Installed artifacts go under `zig-out//…` so `packageall` and parallel targets never clobber each other.
-/// Uses `arm64` (not `aarch64`) for Apple Silicon / arm64 Linux and Windows to match the six release triples.
-///
-/// Segment separator is `-` only: `vpk pack --channel` is merged into filenames that get parsed as NuGet
-/// versions (e.g. `1.2.3--full.nupkg`), and NuGet prerelease labels must not contain `_`.
-fn zigOutSubdirForTarget(b: *std.Build, rt: std.Build.ResolvedTarget) []const u8 {
- const arch_name: []const u8 = switch (rt.result.cpu.arch) {
- .x86_64 => "x86-64",
- .aarch64 => "arm64",
- else => @tagName(rt.result.cpu.arch),
- };
- const os_name: []const u8 = switch (rt.result.os.tag) {
- .windows => "windows",
- .linux => "linux",
- .macos => "macos",
- else => @tagName(rt.result.os.tag),
- };
- const base = b.fmt("{s}-{s}", .{ arch_name, os_name });
- if (std.mem.indexOfScalar(u8, base, '_') == null)
- return base;
- const buf = b.allocator.alloc(u8, base.len) catch @panic("OOM");
- @memcpy(buf, base);
- for (buf) |*byte| {
- if (byte.* == '_') byte.* = '-';
- }
- return buf;
-}
-
-/// SDL (via dvui → lazy `sdl3`) requires SDK layout when `-Dtarget=*-macos` is not "native"
-/// (`target.query.isNative()` is false). Do not set the root `b.sysroot` for that: it skews
-/// the main link (objc, libc paths). Forward include / framework / lib paths into dvui instead.
-const MacosSdlPaths = struct {
- include: std.Build.LazyPath,
- framework: std.Build.LazyPath,
- lib: std.Build.LazyPath,
-};
-
-fn resolveMacosSdkPath(b: *std.Build) ![]const u8 {
- if (b.graph.environ_map.get("SDKROOT")) |sdk| {
- const trimmed = std.mem.trim(u8, sdk, " \t\r\n");
- if (trimmed.len > 0) {
- return b.dupePath(trimmed);
- }
- }
-
- const argv: []const []const u8 = &.{
- "xcrun",
- "--sdk",
- "macosx",
- "--show-sdk-path",
- };
- const run = try std.process.run(b.allocator, b.graph.io, .{
- .argv = argv,
- .stdout_limit = std.Io.Limit.limited(4096),
- .stderr_limit = std.Io.Limit.limited(4096),
- });
- defer {
- b.allocator.free(run.stdout);
- b.allocator.free(run.stderr);
- }
- switch (run.term) {
- .exited => |code| if (code != 0) {
- std.log.err("SDL on macOS: explicit -Dtarget=*-macos needs an SDK path. xcrun exited with code {d}. Install Xcode Command Line Tools or set SDKROOT.", .{code});
- return error.MacosSdkPath;
- },
- else => {
- std.log.err("SDL on macOS: xcrun --show-sdk-path failed", .{});
- return error.MacosSdkPath;
- },
- }
- const path = std.mem.trimEnd(u8, run.stdout, " \t\r\n");
- if (path.len == 0) return error.MacosSdkPath;
- return b.dupePath(path);
-}
-
-fn macosSdlPathsForExplicitTarget(b: *std.Build, target: std.Build.ResolvedTarget) !?MacosSdlPaths {
- if (target.result.os.tag != .macos) return null;
- if (b.graph.host.result.os.tag != .macos) return null;
- if (target.query.isNative()) return null;
-
- const sdk = try resolveMacosSdkPath(b);
- return MacosSdlPaths{
- .include = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/include" }) },
- .framework = .{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) },
- .lib = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/lib" }) },
- };
-}
+pub const plugin = @import("plugin_sdk.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 +14,29 @@ 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"),
+ // NOTE (velopack split): `build/app.zig` and everything it reaches MUST stay free of
+ // `@import("velopack_zig")`. `@import` is comptime + transitive, so any such import here
+ // would pull Velopack (wrapper + prebuilt archives) into every plugin build — which
+ // compiles this very script via `b.dependency("fizzy", .{ .plugin_sdk = true })`. Velopack
+ // is instead a LAZY dep reached through `b.lazyDependency("velopack_zig", …)` inside the
+ // app `build()` (see build/velopack.zig), so it is fetched only on app builds.
+ 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,
});
- 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,
- });
- 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..c8773f98 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,8 +1,12 @@
.{
.paths = .{
"src",
+ "build",
"build.zig",
"build.zig.zon",
+ "update.zig",
+ "build",
+ "plugin_sdk.zig",
"assets",
"libs",
},
@@ -27,25 +31,38 @@
.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 = .{
.url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz",
.hash = "assetpack-0.2.0-5DA2d1ZkAADJanNVdWrUBOGMhOzUENhrUiqXcHADxY2x",
},
- .known_folders = .{
- .url = "https://github.com/ziglibs/known-folders/archive/d6d03830968cca6b7b9f24fd97ee348346a6905d.tar.gz",
- .hash = "known_folders-0.0.0-Fy-PJk3KAACzUg2us_0JvQQmod1ZA8jBt7MuoKCihq88",
- },
+ // Velopack (host self-installer/updater) is used ONLY by the app build, never by
+ // plugins. MUST stay `.lazy = true` and MUST NOT be `@import`ed anywhere reachable
+ // from the root build script: `@import` is comptime + transitive and would drag
+ // Velopack into every plugin build (which compiles this repo's build.zig via
+ // `.plugin_sdk = true`). The app build reaches it through
+ // `b.lazyDependency("velopack_zig", …)` + build/velopack.zig instead.
.velopack_zig = .{
.url = "https://github.com/graphl-tech/velopack-zig/archive/0c2f20635a97fde38cc8000e9fb1a75f891cf37d.tar.gz",
.hash = "velopack_zig-0.0.1-LzeGcengAACU1f33uDAuLKlWXtek0KCC6i_b3XWeJMjd",
+ .lazy = true,
//.path = "../velopack-zig",
},
.dvui_singleton_app = .{
.path = "libs/dvui-singleton-app",
},
+ // C library powering the in-tree markdown render engine (src/markdown). Native-only —
+ // the wasm build never links it (see build/markdown.zig).
+ .cmark_gfm = .{
+ .url = "git+https://github.com/kristoff-it/cmark-gfm#cd0aba87cc89f0c8aa9393e59505db271ea30239",
+ .hash = "cmark_gfm-0.1.0-uQgTKymaFwDK0jVZszXfDtQtW6BnS44Bp0GS55TVBD4p",
+ },
+ // Built-in plugins are NOT package dependencies: the root build embeds them by
+ // importing each plugin's `static/integration.zig` directly (see build/plugins.zig),
+ // which owns the module graph. Each plugin's own `build.zig` is only for the
+ // standalone third-party-shape build under `src/plugins//`.
},
}
diff --git a/build/app.zig b/build/app.zig
new file mode 100644
index 00000000..de560dda
--- /dev/null
+++ b/build/app.zig
@@ -0,0 +1,525 @@
+const std = @import("std");
+
+const plugin = @import("../plugin_sdk.zig");
+const dvui = @import("dvui");
+const velopack = @import("velopack.zig");
+
+pub const Options = struct {
+ windows_msvc_libc_opt: ?[]const u8 = null,
+ fetch_msvc_opt: ?bool = null,
+ macos_sign_app_identity: ?[]const u8 = null,
+ macos_sign_install_identity: ?[]const u8 = null,
+ macos_notary_profile: ?[]const u8 = null,
+};
+
+pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, opts: Options) !void {
+ const windows_msvc_libc_opt = opts.windows_msvc_libc_opt;
+ const fetch_msvc_opt = opts.fetch_msvc_opt;
+ const macos_sign_app_identity = opts.macos_sign_app_identity;
+ const macos_sign_install_identity = opts.macos_sign_install_identity;
+ const macos_notary_profile = opts.macos_notary_profile;
+
+ // Resolve Velopack lazily. This runs only on app builds (never on the plugin-SDK path,
+ // which returns from the root build before reaching here), so plugin builds never fetch
+ // it. On the first configure pass this returns null → Zig fetches velopack_zig and
+ // re-runs build(); the second pass proceeds with a valid handle.
+ const vz = b.lazyDependency("velopack_zig", .{}) orelse return;
+
+ const common = @import("common.zig");
+ const plugins = @import("plugins.zig");
+ const sdk = @import("sdk.zig");
+ const fizzy_exe = @import("exe.zig");
+ const web = @import("web.zig");
+ const package = @import("package.zig");
+ const msvc = @import("msvc.zig");
+
+ const workbench_plugin = plugins.workbench;
+ const text_plugin = plugins.text;
+ const example_plugin = plugins.example;
+ const FizzyExecutable = fizzy_exe.FizzyExecutable;
+
+ // Built-in plugins are embedded by importing their `static/integration.zig` directly
+ // (via build/plugins.zig); the root build owns the module graph, so there is no plugin
+ // package dependency to resolve here. Their canonical `build.zig` is only for the
+ // standalone (`cd src/plugins/ && zig build`) third-party-shape build.
+
+ const macos_sdl_paths = try common.macosSdlPathsForExplicitTarget(b, target);
+ const zig_out_subdir = common.zigOutSubdirForTarget(b, target);
+ const zig_out_install_dir: std.Build.InstallDir = .{ .custom = zig_out_subdir };
+
+ const target_is_windows_msvc = target.result.os.tag == .windows and target.result.abi == .msvc;
+ const cross_win_msvc = target_is_windows_msvc and b.graph.host.result.os.tag != .windows;
+
+ // Auto-fetch defaults: on Windows hosts targeting *-windows-msvc, downloading the
+ // MSVC SDK into .velopack-msvc/ is the deterministic path — Zig's auto-detection
+ // of a system Visual Studio install picks up whatever's currently installed, which
+ // makes packaged release builds non-reproducible. The same .velopack-msvc/ tree is
+ // used on macOS/Linux cross-compile hosts, so all three triples land on the same
+ // SDK headers + libs. Explicit `-Dfetch-msvc=false` opts out (use system VS); an
+ // explicit `-Dwindows-msvc-libc=...` overrides the discovery entirely.
+ const fetch_msvc = fetch_msvc_opt orelse (target_is_windows_msvc and windows_msvc_libc_opt == null);
+
+ const win_libc = velopack.resolveWindowsMsvcLibc(b, target, .{ // vendored: pure path logic, no velopack dep needed
+ .explicit_path = windows_msvc_libc_opt,
+ .install_dir_name = ".velopack-msvc",
+ .fetch_if_missing = fetch_msvc,
+ });
+
+ var effective_win_libc: ?[]const u8 = win_libc.libc_path;
+ if (effective_win_libc == null) {
+ if (cross_win_msvc) effective_win_libc = b.libc_file;
+ }
+
+ // Velopack in the dev/install exe is opt-in (`-Dvelopack=true`). Release
+ // packaging (`zig build package`) still links Velopack when the ABI supports
+ // it via a second compile, so `zig build` / `run` / `test` never pull dotnet
+ // or the static Velopack lib unless you ask. Windows *-gnu targets are
+ // unchanged (no Velopack prebuilt for that ABI).
+ const velopack_supported_for_target = !(target.result.os.tag == .windows and target.result.abi != .msvc);
+ const velopack_enabled = b.option(
+ bool,
+ "velopack",
+ "Link Velopack runtime in the install/run exe (auto-update). Default: false. `package` still produces a Velopack-linked binary when supported.",
+ ) orelse false;
+
+ if (velopack_enabled and !velopack_supported_for_target) {
+ std.log.err(
+ "-Dvelopack=true is unsupported for target ABI {s}: Velopack on Windows requires -Dtarget=x86_64-windows-msvc or -Dtarget=aarch64-windows-msvc.",
+ .{@tagName(target.result.abi)},
+ );
+ return error.WindowsMsvcAbiRequired;
+ }
+
+ // Fail loudly when the *-windows-msvc target has no headers/libs to compile against.
+ // On a non-Windows host this happens whenever `.velopack-msvc/` is missing and the
+ // user didn't pass `-Dfetch-msvc` or `-Dwindows-msvc-libc=…`. On a Windows host the
+ // auto-fetch default makes this unreachable unless the user explicitly opted out
+ // with `-Dfetch-msvc=false` — in which case Zig falls back to system Visual Studio
+ // auto-detection, which we can't validate here.
+ const velopack_required_fail: ?*std.Build.Step = if (cross_win_msvc and effective_win_libc == null)
+ &b.addFail(
+ \\*-windows-msvc needs MSVC + Windows SDK headers/libs.
+ \\ One-shot install (macOS/Linux/Windows): zig build msvcup-setup
+ \\ Then: zig build package -Dtarget=x86_64-windows-msvc (auto-uses .velopack-msvc/zig-libc-x64.ini)
+ \\ Or auto-download in this build: add -Dfetch-msvc (default on Windows hosts; forwards through packageall)
+ \\ Or pass: --libc path.ini / -Dwindows-msvc-libc=path.ini
+ ).step
+ else
+ null;
+
+ const no_emit = b.option(bool, "no-emit", "Check for compile errors without emitting any code") orelse false;
+
+ const app_version_opt = b.option([]const u8, "app_version", "App version for vpk packVersion and startup log; defaults to VERSION file");
+
+ // GitHub repo URL baked into the binary so Velopack's auto-update can find
+ // the latest release via the GitHub Releases API. Override at build time
+ // with `-Drepo-url=...` (e.g. when shipping a fork). At runtime, the env
+ // var `FIZZY_AUTOUPDATE_URL` still overrides this for local feed testing.
+ const app_repo_url = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (e.g. https://github.com/fizzyedit/fizzy)") orelse "https://github.com/fizzyedit/fizzy";
+
+ // Comma-separated fallback repo URLs checked (in order) after `app_repo_url`
+ // yields no update. Lets a build survive a repo move/rename: ship a binary
+ // whose primary points at the new home and whose fallback points at the old
+ // one (where the transitional release is published), then transfer the repo.
+ // Empty by default (no fallback).
+ const app_repo_url_fallback = b.option([]const u8, "repo-url-fallback", "Comma-separated fallback GitHub repo URLs for Velopack auto-update, tried after -Drepo-url") orelse "";
+
+ var version_owned: ?[]u8 = null;
+ defer if (version_owned) |buf| b.allocator.free(buf);
+
+ const app_version: []const u8 = if (app_version_opt) |v| v else blk: {
+ const raw = b.build_root.handle.readFileAlloc(b.graph.io, "VERSION", b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e});
+ version_owned = raw;
+ break :blk std.mem.trimEnd(u8, raw, "\r\n");
+ };
+
+ const build_opts = b.addOptions();
+ build_opts.addOption([]const u8, "app_version", app_version);
+ build_opts.addOption([]const u8, "app_repo_url", app_repo_url);
+ build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback);
+ build_opts.addOption(bool, "velopack_enabled", velopack_enabled);
+ const static_workbench = b.option(
+ bool,
+ "static-workbench",
+ "Keep workbench statically registered on native (skip built-in dylib load)",
+ ) orelse false;
+ build_opts.addOption(bool, "static_workbench", static_workbench);
+ const static_text = b.option(
+ bool,
+ "static-text",
+ "Keep text plugin statically registered on native (skip built-in dylib load)",
+ ) orelse false;
+ build_opts.addOption(bool, "static_text", static_text);
+ const workbench_file_tree = b.option(
+ bool,
+ "workbench-file-tree",
+ "Register the workbench Files sidebar view (file tree)",
+ ) orelse true;
+ const workbench_opts = b.addOptions();
+ workbench_opts.addOption(bool, "file_tree", workbench_file_tree);
+
+ common.addUpdateStep(b);
+
+ const msvcup_before_compile = velopack.addMsvcupSetupStep(b, vz, ".velopack-msvc");
+ const msvcup_setup_step = b.step("msvcup-setup", "Download MSVC SDK into .velopack-msvc/ via velopack-zig (writes zig-libc-*.ini)");
+ msvcup_setup_step.dependOn(&msvcup_before_compile.step);
+
+ const accesskit = b.option(dvui.AccesskitOptions, "accesskit", "Enable accesskit") orelse .off;
+
+ const assetpack = @import("assetpack");
+ const assets_module = assetpack.pack(b, b.path("assets"), .{});
+
+ // ---------------------------------------------------------------
+ // Web (wasm) build — entirely separate from the native exe so it can't disturb
+ // packaging / SDL / Velopack paths. `zig build web` produces `zig-out/web/{web.wasm,
+ // web.js, index.html, NotoSansKR-Regular.ttf}`, deployable as-is to a static host.
+ // ---------------------------------------------------------------
+
+ web.addSteps(b, optimize, build_opts, workbench_opts, assets_module);
+
+ const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, build_opts, workbench_opts, assets_module, macos_sdl_paths, velopack_enabled);
+ const exe = main_fizzy.exe;
+
+ const package_fizzy: FizzyExecutable = package_blk: {
+ if (velopack_enabled) break :package_blk main_fizzy;
+ if (!velopack_supported_for_target) break :package_blk main_fizzy;
+ const pack_opts = b.addOptions();
+ pack_opts.addOption([]const u8, "app_version", app_version);
+ pack_opts.addOption([]const u8, "app_repo_url", app_repo_url);
+ pack_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback);
+ pack_opts.addOption(bool, "velopack_enabled", true);
+ pack_opts.addOption(bool, "static_workbench", static_workbench);
+ pack_opts.addOption(bool, "static_text", static_text);
+ break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, pack_opts, workbench_opts, assets_module, macos_sdl_paths, true);
+ };
+ const exe_for_package = package_fizzy.exe;
+
+ if (no_emit) {
+ b.getInstallStep().dependOn(&exe.step);
+ if (main_fizzy.workbench_dylib) |workbench_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), workbench_dylib, "workbench", plugins_install_dir);
+ }
+ if (main_fizzy.text_dylib) |text_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), text_dylib, "text", plugins_install_dir);
+ }
+ } else {
+ const install_artifact = b.addInstallArtifact(exe, .{
+ .dest_dir = .{ .override = zig_out_install_dir },
+ });
+
+ const run_cmd = b.addRunArtifact(exe);
+ const run_step = b.step("run", "Run the app (does not run Velopack)");
+
+ run_cmd.step.dependOn(&install_artifact.step);
+ run_step.dependOn(&run_cmd.step);
+ b.getInstallStep().dependOn(&install_artifact.step);
+
+ if (main_fizzy.workbench_dylib) |workbench_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), workbench_dylib, "workbench", plugins_install_dir);
+ common.attachBuiltinPluginInstall(b, &run_cmd.step, workbench_dylib, "workbench", plugins_install_dir);
+ }
+ if (main_fizzy.text_dylib) |text_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ common.attachBuiltinPluginInstall(b, b.getInstallStep(), text_dylib, "text", plugins_install_dir);
+ common.attachBuiltinPluginInstall(b, &run_cmd.step, text_dylib, "text", plugins_install_dir);
+ }
+ }
+
+ if (main_fizzy.workbench_dylib) |workbench_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_workbench = plugin.installBuiltinPlugin(b, workbench_dylib, "workbench", plugins_install_dir);
+ const workbench_dylib_step = b.step(
+ "workbench-dylib",
+ "Build the workbench plugin as a dynamic library into zig-out//plugins/ (native only)",
+ );
+ workbench_dylib_step.dependOn(&install_workbench.step);
+ }
+
+ if (main_fizzy.text_dylib) |text_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_text = plugin.installBuiltinPlugin(b, text_dylib, "text", plugins_install_dir);
+ const text_dylib_step = b.step(
+ "text-dylib",
+ "Build the text plugin as a dynamic library into zig-out//plugins/ (native only)",
+ );
+ text_dylib_step.dependOn(&install_text.step);
+ }
+
+ _ = package.addSteps(.{
+ .b = b,
+ .vz = vz,
+ .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"),
+ });
+
+ inline for (.{
+ .{ "fizzy-direction", "src/core/math/direction.zig" },
+ .{ "fizzy-easing", "src/core/math/easing.zig" },
+ .{ "fizzy-layout-anchor", "src/core/math/layout_anchor.zig" },
+ .{ "fizzy-window-layout", "src/backend/window_layout.zig" },
+ .{ "fizzy-plugin-dylib", "src/sdk/dylib.zig" },
+ .{ "fizzy-plugin-store", "src/backend/plugin_store/store.zig" },
+ }) |entry| {
+ tests_module.addAnonymousImport(entry[0], .{
+ .root_source_file = b.path(entry[1]),
+ .target = target,
+ .optimize = optimize,
+ });
+ }
+
+ const unit_tests = b.addTest(.{
+ .name = "fizzy-unit-tests",
+ .root_module = tests_module,
+ .filters = test_filters,
+ });
+
+ // `zig build test` is the CI entry point and must stay self-contained: pure
+ // unit tests only, no dvui/SDL/Velopack/MSVC. Integration tests live under
+ // `zig build test-integration` (Velopack + dvui-testing + comctl32 on Windows
+ // → needs MSVC SDK on Windows hosts). `zig build test-all` runs both.
+ const test_step = b.step("test", "Run fizzy unit tests (pure-logic only, no dvui/SDL/Velopack)");
+ test_step.dependOn(&b.addRunArtifact(unit_tests).step);
+
+ // `check` mirrors the split so editor compile-error checking matches CI.
+ const check_step = b.step("check", "Compile fizzy unit tests without running them");
+ check_step.dependOn(&unit_tests.step);
+
+ // ---------------------------------------------------------------
+ // Layer 2: headless integration tests against dvui's testing
+ // backend. Wired under separate `test-integration` / `check-integration`
+ // steps so `zig build test` stays MSVC-free on Windows CI runners. Skipped
+ // when cross-compiling to *-windows-msvc without an MSVC libc INI.
+ // ---------------------------------------------------------------
+ const test_integration_step = b.step("test-integration", "Run fizzy headless integration tests (dvui-testing; needs MSVC on Windows)");
+ const check_integration_step = b.step("check-integration", "Compile fizzy integration tests without running them");
+ const test_all_step = b.step("test-all", "Run unit + integration tests");
+ test_all_step.dependOn(test_step);
+ test_all_step.dependOn(test_integration_step);
+
+ const test_sdk_version_step = b.step(
+ "test-sdk-version",
+ "Verify SDK version ↔ ABI fingerprint lock (compiles SDK + plugin dylib)",
+ );
+ if (main_fizzy.workbench_dylib) |dylib| {
+ test_sdk_version_step.dependOn(&dylib.step);
+ } else {
+ test_sdk_version_step.dependOn(&exe.step);
+ }
+ test_all_step.dependOn(test_sdk_version_step);
+
+ if (velopack_required_fail) |fail_step| {
+ test_integration_step.dependOn(fail_step);
+ check_integration_step.dependOn(fail_step);
+ return;
+ }
+
+ const dvui_testing_dep = b.dependency("dvui", .{
+ .target = target,
+ .optimize = optimize,
+ .backend = .testing,
+ .accesskit = accesskit,
+ });
+ const dvui_test_proxy_bridge = sdk.addProxyBridgeModule(b, target, optimize, dvui_testing_dep, dvui_testing_dep.module("dvui_testing"));
+
+ // Build a module rooted at `src/fizzy.zig` carrying all the same
+ // imports the production exe carries. Because fizzy.zig's transitive
+ // imports (App.zig, Editor.zig, …) reference `dvui`, `assets`, etc. by
+ // name, those names must be wired here.
+ // We point dvui at the *testing* backend so calling drawing
+ // functions doesn't try to open a real OS window.
+ const fizzy_test_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/fizzy.zig"),
+ });
+ fizzy_test_module.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
+ fizzy_test_module.addImport("backend", dvui_testing_dep.module("testing"));
+ fizzy_test_module.addImport("assets", assets_module);
+ fizzy_test_module.addOptions("build_opts", build_opts);
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ fizzy_test_module.addImport("icons", dep.module("icons"));
+ }
+
+ // Shared `core` module for the test build (dvui testing backend variant).
+ const core_module_test = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ });
+ core_module_test.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ core_module_test.addImport("icons", dep.module("icons"));
+ }
+ fizzy_test_module.addImport("core", core_module_test);
+ const markdown_build = @import("markdown.zig");
+ _ = markdown_build.addModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module);
+
+ const sdk_module_test = sdk.wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), dvui_test_proxy_bridge, core_module_test, fizzy_test_module);
+ _ = workbench_plugin.addStaticModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .backend = dvui_testing_dep.module("testing"),
+ }, workbench_opts, fizzy_test_module);
+ _ = text_plugin.addStaticModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ }, fizzy_test_module);
+ _ = example_plugin.addStaticModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ }, fizzy_test_module);
+
+ if (target.result.os.tag == .macos) {
+ if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| {
+ fizzy_test_module.addImport("objc", dep.module("objc"));
+ }
+ } else if (target.result.os.tag == .windows) {
+ if (b.lazyDependency("zigwin32", .{})) |dep| {
+ fizzy_test_module.addImport("win32", dep.module("win32"));
+ }
+ }
+
+ const integration_module = b.addModule("fizzy-integration-tests", .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tests/integration.zig"),
+ });
+ integration_module.addImport("fizzy", fizzy_test_module);
+ integration_module.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
+
+ const integration_tests = b.addTest(.{
+ .name = "fizzy-integration-tests",
+ .root_module = integration_module,
+ .filters = test_filters,
+ });
+
+ if (target.result.os.tag == .windows) {
+ integration_tests.root_module.linkSystemLibrary("comctl32", .{});
+ }
+ // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers from
+ // --libc (vcruntime_typeinfo.h vs libc++ type_info, etc.), so libc++ must be
+ // off for the msvc ABI regardless of host (cross or native Windows).
+ integration_tests.root_module.link_libcpp = !target_is_windows_msvc;
+ if (velopack_enabled) {
+ try velopack.linkVelopack(b, vz, integration_tests, .{ .target = target, .optimize = optimize });
+ }
+
+ test_integration_step.dependOn(&b.addRunArtifact(integration_tests).step);
+ check_integration_step.dependOn(&integration_tests.step);
+
+ if (win_libc.needs_setup) {
+ exe.step.dependOn(&msvcup_before_compile.step);
+ if (!velopack_enabled and velopack_supported_for_target) {
+ exe_for_package.step.dependOn(&msvcup_before_compile.step);
+ }
+ integration_tests.step.dependOn(&msvcup_before_compile.step);
+ unit_tests.step.dependOn(&msvcup_before_compile.step);
+ }
+
+ if (target.result.os.tag == .windows and target.result.abi == .msvc) {
+ var roots: [4]*std.Build.Step.Compile = undefined;
+ var n: usize = 0;
+ roots[n] = exe;
+ n += 1;
+ roots[n] = unit_tests;
+ n += 1;
+ roots[n] = integration_tests;
+ n += 1;
+ if (!velopack_enabled and velopack_supported_for_target) {
+ roots[n] = exe_for_package;
+ n += 1;
+ }
+
+ // Always apply the translate-c shim + SIZE_MAX define for windows-msvc, regardless of
+ // whether we're using a downloaded SDK or the host's system MSVC. translate-c uses aro
+ // (not MSVC cl.exe), and aro rejects literals like `0xffffffffffffffffui64` from MSVC's
+ // . The shim shadows stdint.h via `-I` (search order beats `-isystem`); the
+ // defineCMacro adds belt-and-suspenders by predefining SIZE_MAX before any include so
+ // MSVC's stdint.h `#ifndef SIZE_MAX` skips its own definition entirely.
+ msvc.applyMsvcTranslateCShim(b, roots[0..n]) catch |e| {
+ std.debug.panic("MSVC translate-c shim wiring failed: {s}", .{@errorName(e)});
+ };
+
+ if (effective_win_libc) |ini| {
+ if (cross_win_msvc) b.libc_file = null;
+ const libc_lp: std.Build.LazyPath = .{ .cwd_relative = ini };
+ velopack.applyWindowsMsvcLibcRecursive(b, roots[0..n], libc_lp);
+
+ const ini_exists = blk: {
+ b.build_root.handle.access(b.graph.io, ini, .{}) catch break :blk false;
+ break :blk true;
+ };
+ if (ini_exists) {
+ // Adds explicit MSVC/UCRT/SDK `-isystem` paths from the libc INI to each reachable
+ // translate-c step. Only relevant when cross-compiling with .velopack-msvc/; on a
+ // Windows host with system MSVC, Zig auto-discovers these paths itself.
+ msvc.applyMsvcIncludesToReachableTranslateC(b, roots[0..n], ini) catch |e| {
+ std.debug.panic("MSVC translate-c include fixup failed: {s}", .{@errorName(e)});
+ };
+ } else {
+ // The INI is written by `msvcup-setup` (a make-phase step), but the translate-c
+ // `-isystem` paths embed the SDK version subdir, which is only known after the SDK
+ // is installed — so they must be wired at configure time, before that step runs.
+ // A one-shot `zig build package -Dfetch-msvc` against a clean .velopack-msvc can't
+ // satisfy that ordering. Fail only the compiles that need it (not `msvcup-setup`,
+ // which has no such dependency), so running setup first still works.
+ const fail = &b.addFail(
+ \\*-windows-msvc has no .velopack-msvc/zig-libc INI yet, so translate-c can't be wired.
+ \\The SDK install must run as its own step before packaging (it can't be done in one
+ \\pass — the translate-c include paths depend on the installed SDK version):
+ \\ zig build msvcup-setup
+ \\ zig build package -Dtarget=x86_64-windows-msvc
+ ).step;
+ for (roots[0..n]) |rc| rc.step.dependOn(fail);
+ }
+ }
+ }
+}
diff --git a/build/common.zig b/build/common.zig
new file mode 100644
index 00000000..f0cad19c
--- /dev/null
+++ b/build/common.zig
@@ -0,0 +1,125 @@
+const std = @import("std");
+
+const plugin = @import("../plugin_sdk.zig");
+const update = @import("../update.zig");
+const GitDependency = update.GitDependency;
+
+/// Install `{id}.{ext}` flat under a `plugins/` directory (no `lib` prefix).
+pub fn attachBuiltinPluginInstall(
+ b: *std.Build,
+ parent: *std.Build.Step,
+ dylib: *std.Build.Step.Compile,
+ id: []const u8,
+ plugins_dir: std.Build.InstallDir,
+) void {
+ parent.dependOn(&plugin.installBuiltinPlugin(b, dylib, id, plugins_dir).step);
+}
+
+pub fn addUpdateStep(b: *std.Build) void {
+ const step = b.step("update", "update git dependencies");
+ step.makeFn = updateStep;
+}
+
+fn updateStep(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void {
+ const deps = &.{
+ GitDependency{
+ .url = "https://github.com/foxnne/zig-objc",
+ .branch = "main",
+ },
+ GitDependency{
+ .url = "https://github.com/kristoff-it/zigwin32",
+ .branch = "fix/zig16",
+ },
+ GitDependency{
+ .url = "https://github.com/foxnne/zig-lib-icons",
+ .branch = "dvui",
+ },
+ GitDependency{
+ .url = "https://github.com/foxnne/dvui-dev",
+ .branch = "main",
+ },
+ };
+ try update.update_dependency(step.owner.allocator, step.owner.graph.io, deps);
+}
+
+/// Installed artifacts go under `zig-out//…` so `packageall` and parallel targets never clobber each other.
+pub fn zigOutSubdirForTarget(b: *std.Build, rt: std.Build.ResolvedTarget) []const u8 {
+ const arch_name: []const u8 = switch (rt.result.cpu.arch) {
+ .x86_64 => "x86-64",
+ .aarch64 => "arm64",
+ else => @tagName(rt.result.cpu.arch),
+ };
+ const os_name: []const u8 = switch (rt.result.os.tag) {
+ .windows => "windows",
+ .linux => "linux",
+ .macos => "macos",
+ else => @tagName(rt.result.os.tag),
+ };
+ const base = b.fmt("{s}-{s}", .{ arch_name, os_name });
+ if (std.mem.indexOfScalar(u8, base, '_') == null)
+ return base;
+ const buf = b.allocator.alloc(u8, base.len) catch @panic("OOM");
+ @memcpy(buf, base);
+ for (buf) |*byte| {
+ if (byte.* == '_') byte.* = '-';
+ }
+ return buf;
+}
+
+/// SDL (via dvui → lazy `sdl3`) requires SDK layout when `-Dtarget=*-macos` is not "native".
+pub const MacosSdlPaths = struct {
+ include: std.Build.LazyPath,
+ framework: std.Build.LazyPath,
+ lib: std.Build.LazyPath,
+};
+
+fn resolveMacosSdkPath(b: *std.Build) ![]const u8 {
+ if (b.graph.environ_map.get("SDKROOT")) |sdk| {
+ const trimmed = std.mem.trim(u8, sdk, " \t\r\n");
+ if (trimmed.len > 0) {
+ return b.dupePath(trimmed);
+ }
+ }
+
+ const argv: []const []const u8 = &.{
+ "xcrun",
+ "--sdk",
+ "macosx",
+ "--show-sdk-path",
+ };
+ const run = try std.process.run(b.allocator, b.graph.io, .{
+ .argv = argv,
+ .stdout_limit = std.Io.Limit.limited(4096),
+ .stderr_limit = std.Io.Limit.limited(4096),
+ });
+ defer {
+ b.allocator.free(run.stdout);
+ b.allocator.free(run.stderr);
+ }
+ switch (run.term) {
+ .exited => |code| if (code != 0) {
+ std.log.err("SDL on macOS: explicit -Dtarget=*-macos needs an SDK path. xcrun exited with code {d}. Install Xcode Command Line Tools or set SDKROOT.", .{code});
+ return error.MacosSdkPath;
+ },
+ else => {
+ std.log.err("SDL on macOS: xcrun --show-sdk-path failed", .{});
+ return error.MacosSdkPath;
+ },
+ }
+ const path = std.mem.trimEnd(u8, run.stdout, " \t\r\n");
+ if (path.len == 0) return error.MacosSdkPath;
+ return b.dupePath(path);
+}
+
+pub fn macosSdlPathsForExplicitTarget(b: *std.Build, target: std.Build.ResolvedTarget) !?MacosSdlPaths {
+ if (target.result.os.tag != .macos) return null;
+ if (b.graph.host.result.os.tag != .macos) return null;
+ if (target.query.isNative()) return null;
+
+ const sdk = try resolveMacosSdkPath(b);
+ return MacosSdlPaths{
+ .include = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/include" }) },
+ .framework = .{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) },
+ .lib = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/lib" }) },
+ };
+}
diff --git a/build/exe.zig b/build/exe.zig
new file mode 100644
index 00000000..93d13031
--- /dev/null
+++ b/build/exe.zig
@@ -0,0 +1,248 @@
+const std = @import("std");
+const dvui = @import("dvui");
+// Vendored Velopack glue — see build/velopack.zig header (never `@import("velopack_zig")`).
+const velopack = @import("velopack.zig");
+const plugin = @import("../plugin_sdk.zig");
+const common = @import("common.zig");
+const plugins = @import("plugins.zig");
+const sdk = @import("sdk.zig");
+const markdown = @import("markdown.zig");
+
+const workbench_plugin = plugins.workbench;
+const text_plugin = plugins.text;
+const example_plugin = plugins.example;
+const MacosSdlPaths = common.MacosSdlPaths;
+
+/// Install stripped exe + built-in plugin dylibs for `vpk pack --packDir`.
+pub fn addVelopackPackDirInstall(
+ b: *std.Build,
+ exe: *std.Build.Step.Compile,
+ fizzy: FizzyExecutable,
+ pack_input_subdir: []const u8,
+ pack_plugins_subdir: []const u8,
+ after_step: *std.Build.Step,
+) *std.Build.Step {
+ const pack_exe_install_dir: std.Build.InstallDir = .{ .custom = pack_input_subdir };
+ const pack_plugins_install_dir: std.Build.InstallDir = .{ .custom = pack_plugins_subdir };
+
+ const install_pack_exe = b.addInstallArtifact(exe, .{
+ .dest_dir = .{ .override = pack_exe_install_dir },
+ });
+ install_pack_exe.step.dependOn(after_step);
+
+ var tail: *std.Build.Step = &install_pack_exe.step;
+
+ if (fizzy.workbench_dylib) |dylib| {
+ const install_workbench = plugin.installBuiltinPlugin(b, dylib, "workbench", pack_plugins_install_dir);
+ install_workbench.step.dependOn(tail);
+ tail = &install_workbench.step;
+ }
+ if (fizzy.text_dylib) |dylib| {
+ const install_text = plugin.installBuiltinPlugin(b, dylib, "text", pack_plugins_install_dir);
+ install_text.step.dependOn(tail);
+ tail = &install_text.step;
+ }
+
+ return tail;
+}
+
+pub const FizzyExecutable = struct {
+ exe: *std.Build.Step.Compile,
+ /// Native-only; `null` on wasm targets.
+ workbench_dylib: ?*std.Build.Step.Compile = null,
+ text_dylib: ?*std.Build.Step.Compile = null,
+};
+
+pub fn addFizzyExecutableForTarget(
+ b: *std.Build,
+ vz: velopack.Dep,
+ resolved_target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ accesskit: dvui.AccesskitOptions,
+ build_opts: *std.Build.Step.Options,
+ workbench_opts: *std.Build.Step.Options,
+ assets_module: *std.Build.Module,
+ macos_sdl_paths: ?MacosSdlPaths,
+ velopack_enabled: bool,
+) !FizzyExecutable {
+ const dvui_dep = if (macos_sdl_paths) |p|
+ b.dependency("dvui", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ .backend = .sdl3,
+ .accesskit = accesskit,
+ .system_include_path = p.include,
+ .system_framework_path = p.framework,
+ .library_path = p.lib,
+ })
+ else
+ b.dependency("dvui", .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit });
+
+ const dvui_proxy_dep = b.dependency("dvui", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ .backend = .proxy,
+ .accesskit = .off,
+ });
+ const dvui_proxy_mod = dvui_proxy_dep.module("dvui_proxy");
+ const proxy_bridge_host_mod = sdk.addProxyBridgeModule(b, resolved_target, optimize, dvui_dep, dvui_dep.module("dvui_sdl3"));
+ const proxy_bridge_plugin_mod = dvui_proxy_dep.module("proxy_bridge");
+
+ const exe = b.addExecutable(.{
+ .name = "fizzy",
+ .root_module = b.addModule("App", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ .root_source_file = .{ .cwd_relative = "src/App.zig" },
+ }),
+ });
+ exe.root_module.strip = false;
+
+ exe.root_module.addImport("assets", assets_module);
+ exe.root_module.addOptions("build_opts", build_opts);
+
+ if (optimize != .Debug) {
+ switch (resolved_target.result.os.tag) {
+ .windows => {
+ exe.subsystem = .Windows;
+ // MSVC's libcmt links `WinMainCRTStartup` (needs `WinMain`) for /SUBSYSTEM:WINDOWS.
+ // Fizzy exposes `main`, so force the C `main` entry which works for either subsystem.
+ if (resolved_target.result.abi == .msvc) {
+ exe.entry = .{ .symbol_name = "mainCRTStartup" };
+ }
+ },
+ else => exe.subsystem = .Posix,
+ }
+ }
+
+ exe.root_module.addImport("dvui", dvui_dep.module("dvui_sdl3"));
+ exe.root_module.addImport("backend", dvui_dep.module("sdl3"));
+
+ // Shared `core` module (gfx/math/fs/generated atlas/platform/paths/dvui hub +
+ // generic widgets). Imports only `dvui` and `icons`.
+ const core_module = b.createModule(.{
+ .target = resolved_target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ });
+ core_module.addImport("dvui", dvui_dep.module("dvui_sdl3"));
+ exe.root_module.addImport("core", core_module);
+
+ var icons_module: ?*std.Build.Module = null;
+ if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| {
+ exe.root_module.addImport("icons", dep.module("icons"));
+ core_module.addImport("icons", dep.module("icons"));
+ icons_module = dep.module("icons");
+ }
+
+ const core_proxy_module = b.createModule(.{
+ .target = resolved_target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ });
+ core_proxy_module.addImport("dvui", dvui_proxy_mod);
+ if (icons_module) |icons| core_proxy_module.addImport("icons", icons);
+
+ // In-tree markdown render engine (native-only; links cmark-gfm). The store renders plugin
+ // READMEs through this. Not wired on web (cmark needs libc) — see build/web.zig.
+ _ = markdown.addModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module);
+
+ const sdk_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), proxy_bridge_host_mod, core_module, exe.root_module);
+ const sdk_proxy_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_proxy_mod, proxy_bridge_plugin_mod, core_proxy_module, null);
+ _ = workbench_plugin.addStaticModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ .icons = icons_module,
+ .backend = dvui_dep.module("sdl3"),
+ }, workbench_opts, exe.root_module);
+ _ = text_plugin.addStaticModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ }, exe.root_module);
+ _ = example_plugin.addStaticModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ }, exe.root_module);
+
+ const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
+ break :blk workbench_plugin.addDylib(b, resolved_target, optimize, .{
+ .dvui = dvui_proxy_mod,
+ .core = core_proxy_module,
+ .sdk = sdk_proxy_module,
+ .proxy_bridge = proxy_bridge_plugin_mod,
+ .icons = icons_module,
+ .backend = null,
+ }, workbench_opts);
+ } else null;
+
+ const text_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
+ break :blk text_plugin.addDylib(b, resolved_target, optimize, .{
+ .dvui = dvui_proxy_mod,
+ .core = core_proxy_module,
+ .sdk = sdk_proxy_module,
+ .proxy_bridge = proxy_bridge_plugin_mod,
+ });
+ } else null;
+
+ const singleton_app_dep = b.dependency("dvui_singleton_app", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ });
+ exe.root_module.addImport("singleton_app", singleton_app_dep.module("singleton_app"));
+
+ if (resolved_target.result.os.tag == .macos) {
+ if (macos_sdl_paths) |p| {
+ // Non-"native" macOS targets (`-Dtarget=aarch64-macos` on Apple Silicon, etc.) need the
+ // same SDK layout for Obj-C sources as for SDL; zig-objc paths do not always reach .m
+ // compiles (e.g. Security.framework → ).
+ exe.root_module.addSystemIncludePath(p.include);
+ exe.root_module.addSystemFrameworkPath(p.framework);
+ exe.root_module.addLibraryPath(p.lib);
+ }
+ if (b.lazyDependency("zig_objc", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ })) |dep| {
+ exe.root_module.addImport("objc", dep.module("objc"));
+ }
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyVisualEffectView.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyMenuTarget.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyTrackpadGesture.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyWindowMonitor.m") });
+ } else if (resolved_target.result.os.tag == .windows) {
+ if (b.lazyDependency("zigwin32", .{})) |dep| {
+ exe.root_module.addImport("win32", dep.module("win32"));
+ }
+ exe.root_module.linkSystemLibrary("comctl32", .{});
+
+ // Embed assets/windows/fizzy.rc -> fizzy.ico into the exe so Explorer,
+ // Taskbar, Alt-Tab and the Velopack-generated Start Menu shortcut all
+ // show the right icon without any runtime work. fizzy.ico must be a
+ // multi-resolution ICO with 16/32/48/256 px frames (see the README in
+ // that directory).
+ exe.root_module.addWin32ResourceFile(.{
+ .file = b.path("assets/windows/fizzy.rc"),
+ });
+ }
+
+ // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers
+ // (vcruntime_typeinfo.h's ::type_info vs libc++'s own, redefined bad_cast,
+ // etc.). We always feed MSVC's own STL via --libc for *-windows-msvc — on a
+ // cross host and on a native Windows host using .velopack-msvc alike — so
+ // libc++ must be off for the msvc ABI regardless of host.
+ const exe_is_windows_msvc = resolved_target.result.os.tag == .windows and
+ resolved_target.result.abi == .msvc;
+ exe.root_module.link_libcpp = !exe_is_windows_msvc;
+ if (velopack_enabled) {
+ try velopack.linkVelopack(b, vz, exe, .{ .target = resolved_target, .optimize = optimize });
+ }
+
+ return .{
+ .exe = exe,
+ .workbench_dylib = workbench_dylib,
+ .text_dylib = text_dylib,
+ };
+}
diff --git a/build/markdown.zig b/build/markdown.zig
new file mode 100644
index 00000000..3bca962b
--- /dev/null
+++ b/build/markdown.zig
@@ -0,0 +1,34 @@
+//! Build wiring for the in-tree markdown render library (`src/markdown`).
+//!
+//! Native-only: the engine links the `cmark-gfm` C library, which needs libc and so cannot
+//! build for the `wasm32-freestanding` web target. Callers wire this into the native exe and
+//! the (native) integration-test module; the web build never imports `markdown`.
+const std = @import("std");
+
+/// Create the `markdown` module (rooted at `src/markdown/markdown.zig`), link the cmark-gfm C
+/// library + extensions, set include paths, and import it into `consumer` as `"markdown"`.
+pub fn addModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ dvui_module: *std.Build.Module,
+ consumer: *std.Build.Module,
+) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/markdown/markdown.zig"),
+ .link_libc = true,
+ });
+ mod.addImport("dvui", dvui_module);
+
+ const cmark_gfm = b.dependency("cmark_gfm", .{ .target = target, .optimize = optimize });
+ mod.linkLibrary(cmark_gfm.artifact("cmark-gfm"));
+ mod.linkLibrary(cmark_gfm.artifact("cmark-gfm-extensions"));
+ mod.addIncludePath(cmark_gfm.path("src"));
+ mod.addIncludePath(cmark_gfm.path("extensions"));
+ mod.addIncludePath(b.path("src/markdown/md"));
+
+ consumer.addImport("markdown", mod);
+ return mod;
+}
diff --git a/build/msvc.zig b/build/msvc.zig
new file mode 100644
index 00000000..a4ec853c
--- /dev/null
+++ b/build/msvc.zig
@@ -0,0 +1,107 @@
+const std = @import("std");
+
+pub fn applyMsvcTranslateCShim(b: *std.Build, roots: []const *std.Build.Step.Compile) !void {
+ var seen = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator);
+ defer seen.deinit();
+ for (roots) |root_compile| {
+ const graph = root_compile.root_module.getGraph();
+ for (graph.modules) |mod| {
+ const root_src = mod.root_source_file orelse continue;
+ const gen = switch (root_src) {
+ .generated => |g| g,
+ else => continue,
+ };
+ const dep_step = gen.file.step;
+ if (dep_step.id != .translate_c) continue;
+ const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step);
+ const gop = try seen.getOrPut(tc);
+ if (gop.found_existing) continue;
+ const rt = tc.target.result;
+ if (rt.os.tag != .windows or rt.abi != .msvc) continue;
+ // `-I` searches before `-isystem`, so this shim wins over MSVC's .
+ tc.addIncludePath(b.path("src/backend/msvc_translatec_shim"));
+ // Pre-define SIZE_MAX so MSVC's stdint.h `#ifndef SIZE_MAX` block — which would
+ // otherwise install a `0xff…ui64` literal — skips itself. Belt-and-suspenders
+ // to the shim: covers the case where another header includes through
+ // a path that bypasses our shim.
+ tc.defineCMacro("SIZE_MAX", switch (rt.ptrBitWidth()) {
+ 32 => "4294967295U",
+ 64 => "18446744073709551615ULL",
+ else => "UINT_MAX",
+ });
+ }
+ }
+}
+
+/// Finds every `Step.TranslateC` reachable from each root compile's Zig module graph and adds
+/// MSVC / Windows SDK `-isystem` paths from the zig-libc INI. We walk `Module.getGraph()` (imports)
+/// rather than `Step.dependencies`: Zig wires `root_source_file` → `TranslateC` only in
+/// `createModuleDependencies`, which runs after `build()` returns, so a step BFS from `Compile`
+/// would miss DVUI's `dvui-c` / `sdl3-c` translate steps during Configure.
+pub fn applyMsvcIncludesToReachableTranslateC(
+ b: *std.Build,
+ roots: []const *std.Build.Step.Compile,
+ libc_ini_path: []const u8,
+) !void {
+ // `libc_ini_path` is absolute (resolved via `b.pathFromRoot`), so any Dir works as the base.
+ const data = try b.build_root.handle.readFileAlloc(b.graph.io, libc_ini_path, b.allocator, .unlimited);
+
+ var include_dir: ?[]const u8 = null;
+ var sys_include_dir: ?[]const u8 = null;
+ var line_it = std.mem.splitScalar(u8, data, '\n');
+ while (line_it.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \r\t");
+ if (std.mem.startsWith(u8, line, "include_dir=")) {
+ include_dir = std.mem.trim(u8, line["include_dir=".len..], " \r\t");
+ } else if (std.mem.startsWith(u8, line, "sys_include_dir=")) {
+ sys_include_dir = std.mem.trim(u8, line["sys_include_dir=".len..], " \r\t");
+ }
+ }
+ if (include_dir == null or sys_include_dir == null) return;
+
+ // `include_dir` points at `.../Windows Kits/10/Include//ucrt`. The Windows SDK's
+ // um/shared/winrt headers live as siblings of the `ucrt` directory.
+ const sdk_inc_root = std.fs.path.dirname(include_dir.?) orelse return;
+ const um_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "um" });
+ const shared_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "shared" });
+ const winrt_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "winrt" });
+
+ var seen_translate_c = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator);
+ defer seen_translate_c.deinit();
+
+ for (roots) |root_compile| {
+ const graph = root_compile.root_module.getGraph();
+ for (graph.modules) |mod| {
+ const root_src = mod.root_source_file orelse continue;
+ const gen = switch (root_src) {
+ .generated => |g| g,
+ else => continue,
+ };
+ const dep_step = gen.file.step;
+ if (dep_step.id != .translate_c) continue;
+
+ const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step);
+ const gop = try seen_translate_c.getOrPut(tc);
+ if (gop.found_existing) continue;
+
+ const rt = tc.target.result;
+ if (rt.os.tag == .windows and rt.abi == .msvc) {
+ // `translate-c` has no API to pass `--libc `, so `-lc` makes Zig
+ // auto-detect a system MSVC/SDK install — which fails on a Windows host
+ // that has no Visual Studio (we use the .velopack-msvc/ tree instead) with
+ // `WindowsSdkNotFound`. Drop `-lc` here: every MSVC/UCRT/SDK include dir is
+ // added explicitly below, so the headers still resolve, and the consuming
+ // exe links libc itself — the translated bindings don't need their own.
+ tc.link_libc = false;
+ // Shim + SIZE_MAX define are applied separately by `applyMsvcTranslateCShim`.
+ // Order matters: MSVC's own headers first (override Windows SDK declarations
+ // when both exist), then UCRT, then the Windows SDK trio.
+ tc.addSystemIncludePath(.{ .cwd_relative = sys_include_dir.? });
+ tc.addSystemIncludePath(.{ .cwd_relative = include_dir.? });
+ tc.addSystemIncludePath(.{ .cwd_relative = um_dir });
+ tc.addSystemIncludePath(.{ .cwd_relative = shared_dir });
+ tc.addSystemIncludePath(.{ .cwd_relative = winrt_dir });
+ }
+ }
+ }
+}
diff --git a/build/package.zig b/build/package.zig
new file mode 100644
index 00000000..9e698037
--- /dev/null
+++ b/build/package.zig
@@ -0,0 +1,264 @@
+const std = @import("std");
+// Vendored Velopack glue — see build/velopack.zig header (never `@import("velopack_zig")`).
+const velopack = @import("velopack.zig");
+const exe = @import("exe.zig");
+
+pub const Options = struct {
+ b: *std.Build,
+ vz: velopack.Dep,
+ 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 vz = opts.vz;
+ 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, vz, vpk_pkg_sh, target);
+
+ //vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step);
+ vpk_pkg_sh.step.dependOn(pack_stage_tail);
+
+ const build_package_install = b.addInstallDirectory(.{
+ .source_dir = vpk_pkg_out_dir,
+ .install_dir = zig_out_install_dir,
+ .install_subdir = "",
+ });
+
+ package_step.dependOn(&build_package_install.step);
+ },
+ else => {
+ package_step.dependOn(&b.addFail("Velopack packaging is only supported for Linux, macOS, and Windows targets").step);
+ },
+ }
+
+ const desktop_step = b.step("desktop", "Alias for `zig build package`");
+ desktop_step.dependOn(package_step);
+
+ const packageall_step = b.step("packageall", "Six zig build package runs; use -Dwindows-msvc-libc= or -Dfetch-msvc for Windows children from macOS/Linux");
+ if (no_emit) {
+ packageall_step.dependOn(&b.addFail("cannot run `packageall` with -Dno-emit").step);
+ } else {
+ const packageall_optimize_arg = b.fmt("-Doptimize={s}", .{@tagName(optimize)});
+
+ // Build order is deliberately fail-fast: Windows first (most likely to
+ // fail on a fresh CI runner because of MSVC SDK setup, libc.ini paths,
+ // and cross-compile ABI surprises), then Linux (mksquashfs / AppImage
+ // packaging quirks), then macOS last (native, lowest risk). When a
+ // release run is going to break, this ordering surfaces the failure
+ // 5-10 minutes sooner than the alphabetical order did.
+ const packageall_triples = [_][]const u8{
+ "x86_64-windows-msvc",
+ "aarch64-windows-msvc",
+ "x86_64-linux-gnu",
+ "aarch64-linux-gnu",
+ "x86_64-macos",
+ "aarch64-macos",
+ };
+
+ var prev_step: ?*std.Build.Step = null;
+ for (packageall_triples) |triple| {
+ const zig_pkg_run = b.addSystemCommand(&.{
+ b.graph.zig_exe,
+ "build",
+ "package",
+ packageall_optimize_arg,
+ b.fmt("-Dtarget={s}", .{triple}),
+ });
+ if (std.mem.endsWith(u8, triple, "-windows-msvc")) {
+ if (windows_msvc_libc_opt) |libc_path| {
+ zig_pkg_run.addArg(b.fmt("-Dwindows-msvc-libc={s}", .{libc_path}));
+ }
+ if (fetch_msvc) zig_pkg_run.addArg("-Dfetch-msvc");
+ }
+ zig_pkg_run.setCwd(b.path("."));
+ if (prev_step) |p| {
+ zig_pkg_run.step.dependOn(p);
+ }
+ prev_step = &zig_pkg_run.step;
+ }
+ packageall_step.dependOn(prev_step.?);
+ }
+
+ return package_step;
+}
diff --git a/build/plugins.zig b/build/plugins.zig
new file mode 100644
index 00000000..77c19e8c
--- /dev/null
+++ b/build/plugins.zig
@@ -0,0 +1,9 @@
+//! Built-in plugin build integration — the static-embed + bundled-dylib module graph.
+//!
+//! Each built-in plugin keeps its fizzy-internal static-embed glue self-contained in
+//! `src/plugins//static/integration.zig`, separate from the canonical third-party files
+//! at the plugin-folder root (the shell's `@import("")` resolves to the root
+//! `.zig`). Fizzy root aggregates those integration files here.
+pub const workbench = @import("../src/plugins/workbench/static/integration.zig");
+pub const text = @import("../src/plugins/text/static/integration.zig");
+pub const example = @import("../src/plugins/example/static/integration.zig");
diff --git a/build/sdk.zig b/build/sdk.zig
new file mode 100644
index 00000000..82490947
--- /dev/null
+++ b/build/sdk.zig
@@ -0,0 +1,38 @@
+const std = @import("std");
+
+pub fn addProxyBridgeModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ dvui_dep: *std.Build.Dependency,
+ dvui_module: *std.Build.Module,
+) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = dvui_dep.path("src/backends/proxy_bridge.zig"),
+ });
+ mod.addImport("dvui", dvui_module);
+ return mod;
+}
+
+pub fn wireSdkModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ dvui_module: *std.Build.Module,
+ proxy_bridge_module: *std.Build.Module,
+ core_module: *std.Build.Module,
+ consumer: ?*std.Build.Module,
+) *std.Build.Module {
+ const sdk_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/sdk/sdk.zig"),
+ });
+ sdk_module.addImport("dvui", dvui_module);
+ sdk_module.addImport("proxy_bridge", proxy_bridge_module);
+ sdk_module.addImport("core", core_module);
+ if (consumer) |c| c.addImport("sdk", sdk_module);
+ return sdk_module;
+}
diff --git a/build/velopack.zig b/build/velopack.zig
new file mode 100644
index 00000000..25ec3e59
--- /dev/null
+++ b/build/velopack.zig
@@ -0,0 +1,252 @@
+//! Vendored Velopack build glue for the fizzy app build.
+//!
+//! Mirrors `~/dev/velopack-zig/build.zig`; keep in sync if that repo's layout changes.
+const std = @import("std");
+const builtin = @import("builtin");
+
+/// The resolved `velopack_zig` dependency (from `b.lazyDependency("velopack_zig", .{})`).
+/// `vz.builder` is velopack-zig's own builder — the equivalent of velopack-zig's internal
+/// `ownBuilder(b)` — used to reach its bundled deps + tool sources.
+pub const Dep = *std.Build.Dependency;
+
+var trim_velopack_tool: ?*std.Build.Step.Compile = null;
+var dotnet_restore_cache: std.AutoHashMapUnmanaged(*std.Build, *std.Build.Step.Run) = .{};
+
+fn cachedDotnetToolRestore(b: *std.Build) *std.Build.Step.Run {
+ const gop = dotnet_restore_cache.getOrPut(b.allocator, b) catch @panic("OOM");
+ if (!gop.found_existing) {
+ // `.config/dotnet-tools.json` lives at the fizzy repo root (== `b` here).
+ const r = b.addSystemCommand(&.{ "dotnet", "tool", "restore" });
+ r.setCwd(b.path("."));
+ gop.value_ptr.* = r;
+ }
+ return gop.value_ptr.*;
+}
+
+fn trimVelopackLibTool(vz: Dep) *std.Build.Step.Compile {
+ if (trim_velopack_tool) |t| return t;
+ const own = vz.builder;
+ const t = own.addExecutable(.{
+ .name = "trim-velopack-lib",
+ .root_module = own.createModule(.{
+ .root_source_file = vz.path("tools/trim_velopack_lib.zig"),
+ .target = own.graph.host,
+ .optimize = .Debug,
+ }),
+ });
+ trim_velopack_tool = t;
+ return t;
+}
+
+pub const LinkVelopackOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+};
+
+/// Add include path + the correct prebuilt static lib + Windows ws2_32/bcrypt +
+/// macOS @loader_path / Linux $ORIGIN rpath, and attach a cached `dotnet tool restore`.
+pub fn linkVelopack(
+ b: *std.Build,
+ vz: Dep,
+ compile: *std.Build.Step.Compile,
+ opts: LinkVelopackOptions,
+) !void {
+ const velopack_dep = vz.builder.dependency("velopack", .{});
+ const target = opts.target;
+
+ compile.root_module.addIncludePath(velopack_dep.path("include"));
+
+ const lib_name = switch (target.result.os.tag) {
+ .linux => switch (target.result.cpu.arch) {
+ .x86_64 => "velopack_libc_linux_x64_gnu.a",
+ .aarch64 => "velopack_libc_linux_arm64_gnu.a",
+ else => @panic("velopack: unsupported linux arch"),
+ },
+ .macos => switch (target.result.cpu.arch) {
+ .x86_64 => "velopack_libc_osx_x64_gnu.a",
+ .aarch64 => "velopack_libc_osx_arm64_gnu.a",
+ else => @panic("velopack: unsupported macos arch"),
+ },
+ .windows => switch (target.result.cpu.arch) {
+ .x86_64 => "velopack_libc_win_x64_msvc.lib",
+ .aarch64 => "velopack_libc_win_arm64_msvc.lib",
+ .x86 => "velopack_libc_win_x86_msvc.lib",
+ else => @panic("velopack: unsupported windows arch"),
+ },
+ else => @panic("velopack: unsupported OS"),
+ };
+ const lib_src = velopack_dep.path(b.fmt("lib-static/{s}", .{lib_name}));
+
+ if (target.result.os.tag == .windows and target.result.abi == .msvc) {
+ // Copy the read-only dep file into a writable WriteFiles output, then trim
+ // duplicate `compiler_builtins-*` members with `zig ar` (Velopack's Rust libs
+ // collide with Zig's compiler_rt at link time).
+ const wf = b.addWriteFiles();
+ const lib_copy = wf.addCopyFile(lib_src, lib_name);
+ const trim = b.addRunArtifact(trimVelopackLibTool(vz));
+ trim.addArg(b.graph.zig_exe);
+ trim.addFileArg(lib_copy);
+ trim.step.dependOn(&wf.step);
+ compile.root_module.addObjectFile(lib_copy);
+ compile.step.dependOn(&trim.step);
+ } else {
+ compile.root_module.addObjectFile(lib_src);
+ }
+
+ if (target.result.os.tag == .windows) {
+ compile.root_module.linkSystemLibrary("ws2_32", .{});
+ compile.root_module.linkSystemLibrary("bcrypt", .{});
+ }
+ switch (target.result.os.tag) {
+ .linux => {
+ compile.root_module.addRPathSpecial("$ORIGIN");
+ compile.root_module.linkSystemLibrary("gcc_s", .{});
+ },
+ .macos => compile.root_module.addRPathSpecial("@loader_path"),
+ else => {},
+ }
+
+ compile.step.dependOn(&cachedDotnetToolRestore(b).step);
+}
+
+pub const MksquashfsBuild = struct {
+ step: *std.Build.Step,
+ bin_dir: []const u8,
+};
+
+/// Build the bundled mksquashfs for Linux AppImage packaging. Returns null on
+/// non-Linux hosts and on the first build before the lazy `squashfs` dep is fetched.
+pub fn buildMksquashfs(b: *std.Build, vz: Dep) !?MksquashfsBuild {
+ const own = vz.builder;
+ if (own.graph.host.result.os.tag == .windows) return null;
+
+ const dep = own.lazyDependency("squashfs", .{
+ .target = own.graph.host,
+ .optimize = .ReleaseFast,
+ }) orelse return null;
+
+ const mksquashfs_art = dep.artifact("mksquashfs");
+ const install_mksquashfs = b.addInstallArtifact(mksquashfs_art, .{
+ .dest_dir = .{ .override = .{ .custom = "velopack-mksquashfs" } },
+ });
+
+ return MksquashfsBuild{
+ .step = &install_mksquashfs.step,
+ .bin_dir = b.getInstallPath(.{ .custom = "velopack-mksquashfs" }, ""),
+ };
+}
+
+/// Wire the bundled mksquashfs into a `vpk pack` Run for Linux targets (no-op otherwise).
+pub fn attachMksquashfsToVpkRun(
+ b: *std.Build,
+ vz: Dep,
+ run: *std.Build.Step.Run,
+ target: std.Build.ResolvedTarget,
+) !void {
+ if (target.result.os.tag != .linux) return;
+ const built = (try buildMksquashfs(b, vz)) orelse return;
+ run.addPathDir(built.bin_dir);
+ run.step.dependOn(built.step);
+}
+
+/// Install MSVC + Windows SDK into `/` and emit zig-libc-*.ini,
+/// via velopack-zig's bundled setup scripts.
+pub fn addMsvcupSetupStep(
+ b: *std.Build,
+ vz: Dep,
+ install_dir: ?[]const u8,
+) *std.Build.Step.Run {
+ const resolved_install_dir: []const u8 = if (install_dir) |p|
+ if (std.fs.path.isAbsolute(p)) b.dupePath(p) else b.pathFromRoot(p)
+ else
+ b.pathFromRoot(".velopack-msvc");
+
+ const env_path = vz.path("tools/msvcup.env").getPath3(b, null).toString(b.allocator) catch |e|
+ std.debug.panic("velopack: resolve msvcup.env: {}", .{e});
+ const gen_path = vz.path("tools/gen_zig_libc_msvc.zig").getPath3(b, null).toString(b.allocator) catch |e|
+ std.debug.panic("velopack: resolve gen_zig_libc_msvc.zig: {}", .{e});
+
+ const run: *std.Build.Step.Run = switch (builtin.os.tag) {
+ .windows => blk: {
+ const script_path = vz.path("tools/setup-msvc.ps1").getPath3(b, null).toString(b.allocator) catch |e|
+ std.debug.panic("velopack: resolve setup-msvc.ps1: {}", .{e});
+ break :blk b.addSystemCommand(&.{
+ "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
+ "-File", script_path, resolved_install_dir,
+ });
+ },
+ else => blk: {
+ const script_path = vz.path("tools/setup-msvc.sh").getPath3(b, null).toString(b.allocator) catch |e|
+ std.debug.panic("velopack: resolve setup-msvc.sh: {}", .{e});
+ break :blk b.addSystemCommand(&.{ "bash", script_path, resolved_install_dir });
+ },
+ };
+ run.setEnvironmentVariable("VELOPACK_ZIG_ENV_FILE", env_path);
+ run.setEnvironmentVariable("VELOPACK_ZIG_GEN_SCRIPT", gen_path);
+ run.setEnvironmentVariable("VELOPACK_ZIG_ZIG", b.graph.zig_exe);
+ return run;
+}
+
+pub const ResolveWindowsMsvcLibcOptions = struct {
+ explicit_path: ?[]const u8 = null,
+ install_dir_name: []const u8 = ".velopack-msvc",
+ fetch_if_missing: bool = false,
+};
+
+pub const ResolvedWindowsMsvcLibc = struct {
+ libc_path: ?[]const u8,
+ needs_setup: bool,
+};
+
+/// Locate the right zig-libc-*.ini for *-windows-msvc. Pure path logic — does NOT need
+/// the velopack_zig dependency, so it is safe to call before (or without) resolving Velopack.
+pub fn resolveWindowsMsvcLibc(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ opts: ResolveWindowsMsvcLibcOptions,
+) ResolvedWindowsMsvcLibc {
+ if (target.result.os.tag != .windows or target.result.abi != .msvc)
+ return .{ .libc_path = null, .needs_setup = false };
+
+ if (opts.explicit_path) |p| {
+ const abs = if (std.fs.path.isAbsolute(p)) b.dupePath(p) else b.pathFromRoot(p);
+ return .{ .libc_path = abs, .needs_setup = false };
+ }
+
+ const arch_suffix: []const u8 = switch (target.result.cpu.arch) {
+ .x86_64 => "x64",
+ .aarch64 => "arm64",
+ else => return .{ .libc_path = null, .needs_setup = false },
+ };
+ const rel = b.fmt("{s}/zig-libc-{s}.ini", .{ opts.install_dir_name, arch_suffix });
+
+ const exists = blk: {
+ b.build_root.handle.access(b.graph.io, rel, .{}) catch break :blk false;
+ break :blk true;
+ };
+
+ if (exists) return .{ .libc_path = b.pathFromRoot(rel), .needs_setup = false };
+ if (opts.fetch_if_missing) return .{ .libc_path = b.pathFromRoot(rel), .needs_setup = true };
+ return .{ .libc_path = null, .needs_setup = false };
+}
+
+/// Apply a zig-libc INI to every reachable *-windows-msvc compile. Pure — no velopack dep.
+pub fn applyWindowsMsvcLibcRecursive(
+ b: *std.Build,
+ roots: []const *std.Build.Step.Compile,
+ libc_lp: std.Build.LazyPath,
+) void {
+ var seen = std.AutoHashMap(*std.Build.Step.Compile, void).init(b.allocator);
+ defer seen.deinit();
+ for (roots) |root| {
+ const compiles = std.Build.Step.Compile.getCompileDependencies(root, true);
+ for (compiles) |c| {
+ const gop = seen.getOrPut(c) catch @panic("OOM");
+ if (gop.found_existing) continue;
+ const rt = c.root_module.resolved_target orelse continue;
+ if (rt.result.os.tag == .windows and rt.result.abi == .msvc) {
+ c.setLibCFile(libc_lp);
+ }
+ }
+ }
+}
diff --git a/build/web.zig b/build/web.zig
new file mode 100644
index 00000000..03fe0fd7
--- /dev/null
+++ b/build/web.zig
@@ -0,0 +1,175 @@
+const std = @import("std");
+const plugins = @import("plugins.zig");
+const sdk = @import("sdk.zig");
+
+const workbench_plugin = plugins.workbench;
+const text_plugin = plugins.text;
+const example_plugin = plugins.example;
+
+pub fn addSteps(
+ b: *std.Build,
+ optimize: std.builtin.OptimizeMode,
+ build_opts: *std.Build.Step.Options,
+ workbench_opts: *std.Build.Step.Options,
+ assets_module: *std.Build.Module,
+) void {
+ const web_target = b.resolveTargetQuery(.{
+ .cpu_arch = .wasm32,
+ .os_tag = .freestanding,
+ .cpu_features_add = std.Target.wasm.featureSet(&.{
+ .atomics,
+ .multivalue,
+ .bulk_memory,
+ }),
+ });
+
+ const dvui_web_dep = b.dependency("dvui", .{
+ .target = web_target,
+ .optimize = optimize,
+ .backend = .web,
+ .freetype = false,
+ });
+ const dvui_web_proxy_bridge = sdk.addProxyBridgeModule(b, web_target, optimize, dvui_web_dep, dvui_web_dep.module("dvui_web"));
+
+ const web_exe = b.addExecutable(.{
+ .name = "web",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/web_main.zig"),
+ .target = web_target,
+ .optimize = optimize,
+ .link_libc = false,
+ .single_threaded = true,
+ .strip = optimize == .ReleaseFast or optimize == .ReleaseSmall,
+ }),
+ });
+ web_exe.entry = .disabled;
+ web_exe.root_module.addImport("dvui", dvui_web_dep.module("dvui_web"));
+ web_exe.root_module.addImport("web-backend", dvui_web_dep.module("web"));
+
+ // Extra wasm exports beyond dvui's own (`dvui_init`/`dvui_update`/etc.). The wasm
+ // linker only emits symbols listed here, so `export fn` in Zig isn't enough on its
+ // own — without this line our trackpad pinch entry point would compile cleanly but
+ // be missing from `instance.exports`, and the JS bootstrap in `web/shell.html`
+ // would never be able to forward pinch deltas into the canvas widget.
+ web_exe.root_module.export_symbol_names = &[_][]const u8{
+ "FizzyWebTrackpadMagnification",
+ };
+
+ // `icons` (pure-Zig icon data) is referenced at file scope in
+ // `src/dvui.zig` and `src/editor/Infobar.zig`. Wired in so any future
+ // wasm-reachable code that pulls those files in compiles cleanly.
+ if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| {
+ web_exe.root_module.addImport("icons", dep.module("icons"));
+ }
+
+ // `assets` is generated at build time by assetpack (pure `@embedFile`s,
+ // target-independent). Same instance as native — no extra build cost.
+ web_exe.root_module.addImport("assets", assets_module);
+
+ // `build_opts` (app_version, app_repo_url, velopack_enabled) — shared
+ // with native. velopack_enabled is whatever was passed via `-Dvelopack`;
+ // wasm path is gated by `arch != .wasm32` in `auto_update.impl`.
+ web_exe.root_module.addOptions("build_opts", build_opts);
+
+ // Shared `core` module for the wasm build (dvui web backend variant).
+ const core_module_web = b.createModule(.{
+ .target = web_target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ .link_libc = false,
+ .single_threaded = true,
+ });
+ core_module_web.addImport("dvui", dvui_web_dep.module("dvui_web"));
+ if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| {
+ core_module_web.addImport("icons", dep.module("icons"));
+ }
+ web_exe.root_module.addImport("core", core_module_web);
+ const sdk_module_web = sdk.wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), dvui_web_proxy_bridge, core_module_web, web_exe.root_module);
+
+ // Three editor files have `const sdl3 = @import("backend").c;` at file
+ // scope. After refactoring all `sdl3.SDL_DialogFileFilter` references
+ // to `fizzy.backend.DialogFileFilter`, those decls became dead — Zig's
+ // lazy analysis skips file-scope consts that no reachable body uses.
+ // So no `backend` module is wired in for the web build.
+
+ _ = workbench_plugin.addStaticModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .backend = null,
+ }, workbench_opts, web_exe.root_module);
+ _ = text_plugin.addStaticModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ }, web_exe.root_module);
+ _ = example_plugin.addStaticModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ }, web_exe.root_module);
+
+ const web_install_dir: std.Build.InstallDir = .{ .custom = "web" };
+ const install_wasm = b.addInstallArtifact(web_exe, .{
+ .dest_dir = .{ .override = web_install_dir },
+ });
+
+ // Cache-buster: stamps a 64-char hash into the index.html / web.js placeholders so
+ // the browser picks up new wasm builds without manual hard-reloads. Re-implements
+ // upstream DVUI's `addWebExample` machinery so we don't have to invoke its step.
+ const cb = b.addExecutable(.{
+ .name = "cacheBuster",
+ .root_module = b.createModule(.{
+ .root_source_file = dvui_web_dep.path("src/cacheBuster.zig"),
+ .target = b.graph.host,
+ }),
+ });
+ const cb_run = b.addRunArtifact(cb);
+ cb_run.addFileArg(b.path("web/shell.html"));
+ cb_run.addFileArg(dvui_web_dep.path("src/backends/web.js"));
+ cb_run.addFileArg(web_exe.getEmittedBin());
+ const index_html_with_hash = cb_run.captureStdOut(.{});
+
+ const web_step = b.step("web", "Build the fizzy web (wasm) app into zig-out/web/");
+ web_step.dependOn(&install_wasm.step);
+ web_step.dependOn(&b.addInstallFileWithDir(
+ index_html_with_hash,
+ web_install_dir,
+ "index.html",
+ ).step);
+ web_step.dependOn(&b.addInstallFileWithDir(
+ dvui_web_dep.path("src/backends/web.js"),
+ web_install_dir,
+ "web.js",
+ ).step);
+ web_step.dependOn(&b.addInstallFileWithDir(
+ dvui_web_dep.path("src/fonts/NotoSansKR-Regular.ttf"),
+ web_install_dir,
+ "NotoSansKR-Regular.ttf",
+ ).step);
+
+ // Compile-only smoke check for the wasm target. Pairs with `check` (unit
+ // tests). Catches regressions where someone reaches a wasm-incompatible
+ // code path (thread spawn, std.posix surface, missing module import)
+ // from the wasm root. No install — just compile.
+ const check_web_step = b.step("check-web", "Compile fizzy web (wasm) without installing artifacts");
+ check_web_step.dependOn(&web_exe.step);
+
+ // Copy zig-out/web into web/app/ for local preview at the production
+ // `/app/` path: `cd web && python3 -m http.server` then open
+ // http://localhost:8000/app/. The landing page lives in fizzyedit/website.
+ const web_docs_step = b.step("web-docs", "Build web app and copy into web/app/ for local /app/ preview");
+ web_docs_step.dependOn(web_step);
+ const cp_web_to_docs = b.addSystemCommand(&.{ "sh", "-c" });
+ cp_web_to_docs.addArg("mkdir -p web/app && cp -R zig-out/web/. web/app/");
+ cp_web_to_docs.step.dependOn(web_step);
+ web_docs_step.dependOn(&cp_web_to_docs.step);
+
+ const serve_web_cmd = b.addSystemCommand(&.{ "sh", "scripts/serve-web.sh" });
+ serve_web_cmd.step.dependOn(web_step);
+ _ = b.step(
+ "serve-web",
+ "Serve zig-out/web at http://127.0.0.1:8765/ (builds web first; frees stale :8765)",
+ ).dependOn(&serve_web_cmd.step);
+}
diff --git a/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..8f298a2d
--- /dev/null
+++ b/docs/PLUGINS.md
@@ -0,0 +1,711 @@
+# 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`:
+
+```zig
+const lib = fizzy.plugin.create(b, .{
+ .name = "", // = your manifest.id; the installed file is .
+ .target = target,
+ .optimize = optimize,
+});
+lib.root_module.linkLibrary(…);
+lib.root_module.addIncludePath(…);
+fizzy.plugin.install(b, lib, .{});
+```
+
+**To develop/test a plugin, run `zig build install`.** It builds the plugin for the current OS
+and drops `.` straight into the fizzy plugins dir the editor scans —
+`~/Library/Application Support/fizzy/plugins/` (macOS), `~/.config/fizzy/plugins/` (Linux),
+`%APPDATA%/fizzy/plugins/` (Windows) — so it loads on the editor's next launch (no `--prefix`,
+no `cp`). It also leaves `zig-out/.` for packaging / the store build action. (The
+plugins-dir copy is skipped silently on a host with no resolvable config home, e.g. a bare CI
+runner, so a packaging `zig build` never fails on it.)
+
+### Import discipline
+
+Files inside `src/**` must **not** `@import("fizzy")` or reach into the shell. Allowed:
+
+- `@import("sdk")`, `@import("core")`, `@import("dvui")` — wired on the dylib module by
+ `fizzy.plugin.create`
+- `@import("State.zig")`, … — sibling files in your `src/` tree
+- Built-in only: `@import("../.zig")` for an optional local hub file
+
+This is what lets the same sources compile as a standalone dylib.
+
+### The `register(host)` entry
+
+`register` wires the plugin into the shell. A minimal plugin just registers itself; a
+real one adds contributions:
+
+```zig
+pub fn register(host: *sdk.Host) !void {
+ plugin.state = @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` — builds for this OS and installs `.` into the fizzy plugins dir;
+ relaunch the editor to load it.
+
+`fizzy.plugin.create` options:
+
+| Option | Default | When to override |
+|--------|---------|------------------|
+| `root_source_file` | `root.zig` | Dylib entry is not at project root or not named `root.zig` |
+| `name` | `"plugin"` | Dylib artifact name (output is still `plugin.dylib` when installed) |
+
+Pin the **fizzy** dependency to the same revision as the host you run against; ABI
+mismatch surfaces as a failed load at `fizzy_plugin_abi_fingerprint`, not a semver check.
+
+### How built-in plugins are wired (fizzy-internal)
+
+The in-tree plugins (pixi, workbench, code, example) ship inside the signed app and compile
+**two ways** — statically into the native/web/test binaries *and* (for desktop) as a bundled
+dylib. **Their folder is, file-for-file, the same canonical third-party shape** described
+above (`build.zig` via `fizzy.plugin.create`, `build.zig.zon`, `root.zig` → `src/plugin.zig`,
+`src/…`), and each builds standalone with `cd src/plugins/ && zig build`. There is no
+embed-stub `build.zig` and no `build_standalone.zig` anymore.
+
+All the fizzy-internal glue is separated out so it never mixes into the plugin contract:
+
+```
+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`).
+
+### Cadence: keep fingerprint bumps rare (so plugins rebuild rarely)
+
+A prebuilt plugin dylib is valid for exactly one `(zig version, dvui version, SDK contract)`
+tuple — the coupling is inherent, because a plugin links its own `dvui` and operates on the host's
+injected `dvui` globals (`dvui_context.zig`), so host and plugin must share the same `dvui` and the
+same compiler. You cannot make a native dylib survive a `dvui`/zig change; the goal is to make those
+changes **rare and deliberate** so plugins only rebuild on intentional SDK bumps, not every release:
+
+- **App version ≠ SDK version.** The app version (`VERSION` / `build.zig.zon`) ships often and is
+ *not* an input to the fingerprint. A Fizzy release that does not touch the boundary, the pinned
+ `dvui`, or the compiler keeps the **same fingerprint**, so already-installed plugins keep loading.
+- **`dvui` and zig are pinned** (the `dvui` dependency in `build.zig.zon`; `ZIG_VERSION` in CI) and
+ bumped deliberately/batched. Tracking `dvui`-dev tip would flip the fingerprint constantly.
+- The **store matches binaries on the fingerprint**. When it changes, that is the (announced) signal
+ for plugin authors to rebuild; until they do, the store shows their plugin as "needs a rebuild for
+ Fizzy SDK x.y" rather than offering an incompatible binary.
+
+> Possible later hardening (not done yet): freeze the small `dvui`/zig value surface that crosses the
+> boundary behind Fizzy-owned POD types, so incidental `dvui` refactors can't move the fingerprint at
+> all — only genuine SDK-contract changes would.
+
+### Plugin dylib layout
+
+User and built-in plugins install as a **flat** file:
+
+```
+{config}/plugins/{id}.dylib # macOS
+{config}/plugins/{id}.so # Linux
+{config}/plugins/{id}.dll # Windows
+{exe}/plugins/{id}.{ext} # bundled built-ins
+```
+
+The declared `manifest.id` must match the filename basename. There is no legacy `{id}/plugin.dylib` layout.
+
+### Config folders (lowercase)
+
+```
+{config}/plugins/
+{config}/palettes/
+{config}/themes/
+```
+
+### Plugin manifest (dylib + optional sidecar)
+
+Each plugin embeds metadata via C exports from `PluginManifest`. Optional sidecar for store indexing:
+
+```json
+{
+ "id": "markdown",
+ "name": "Markdown Editor",
+ "version": "1.2.0",
+ "min_sdk_version": "0.1.0",
+ "abi_fingerprint": "0x05f167e314742930",
+ "author": "…",
+ "description": "…",
+ "homepage": "…"
+}
+```
+
+Install for local development with:
+
+```sh
+zig build install
+# → installs markdown. into this OS's fizzy plugins dir, e.g.
+# ~/Library/Application Support/fizzy/plugins/markdown.dylib (macOS)
+```
+
+### Store registry schema (future)
+
+Hosted registry JSON (Phase 2 Extensions UI):
+
+```json
+{
+ "sdk_version": "0.1.0",
+ "plugins": [
+ {
+ "id": "markdown",
+ "name": "Markdown Editor",
+ "releases": [
+ {
+ "version": "1.2.0",
+ "min_sdk_version": "0.1.0",
+ "abi_fingerprint": "0x…",
+ "published": "2026-06-01",
+ "downloads": {
+ "macos-aarch64": "https://…/markdown-1.2.0-macos-aarch64.dylib"
+ }
+ }
+ ]
+ }
+ ]
+}
+```
+
+---
+
+## Plugin profiles (IDE-shaped contract)
+
+The shell is **IDE-shaped**: sidebar rail + explorer, menubar, center (`CenterProvider`), bottom panel, infobar. Plugins contribute via `Host.register*` — the shell never hardcodes feature panes.
+
+| Profile | Implements | Example |
+|---------|------------|---------|
+| **Editor** | Document vtable cluster + optional panes/commands | `pixi`, `code` |
+| **Shell** | Center provider + file tree, no documents | `workbench` |
+| **Utility** | Menus/commands/settings only, no document hooks | external markdown menu plugin |
+
+Use `Plugin.assertEditorVTable(vtable)` / `Plugin.assertUtilityVTable(vtable)` at compile time to catch profile mistakes.
+
+Built-in plugin id renames (pre-release): runtime id **`pixi`** (was `pixelart`); dylib `pixi.dylib`; settings key `plugins.pixi`; env `FIZZY_STATIC_PIXI`.
+
+| `src/editor/Editor.zig` | The shell: frame loop, `postInit` plugin registration, dylib loading |
diff --git a/docs/PLUGIN_ROUGH_EDGES.md b/docs/PLUGIN_ROUGH_EDGES.md
new file mode 100644
index 00000000..1431d722
--- /dev/null
+++ b/docs/PLUGIN_ROUGH_EDGES.md
@@ -0,0 +1,232 @@
+# Plugin Author Rough Edges
+
+A punch list of friction points a third-party author hits when building a *complex*
+editor plugin (a second real editor alongside pixelart). Ordered by pain, with file
+references and fix sketches. Cheap correctness fixes (#4, #6, #7) are being done first;
+the rest are tracked as backlog.
+
+Status legend: 🔴 not started · 🟡 in progress · 🟢 done
+
+---
+
+## 1. 🟢 The "stable contract" is pixel-art-shaped — *large* — DONE
+
+The intermediate `canvas_ext` (a relocated grab-bag that still *named* pixelart concepts) was
+replaced with two clean mechanisms, so the SDK names zero domain features:
+
+1. **Command registry** ([`regions.Command`](../src/sdk/regions.zig) + `Host.registerCommand` /
+ `runCommand` / `commandEnabled`). Invocable features register as namespaced commands the shell
+ triggers by id (`"pixelart.transform"`, `"pixelart.gridLayout"`, `"pixelart.packProject"`)
+ without knowing what they do. Folded into the ABI fingerprint.
+2. **Generic per-frame / lifecycle / save protocol** on `Plugin.VTable`, renamed from the
+ pixelart-flavored hooks: `prepareFrame`, `tickActiveDocument`, `drawOverlay`, `endFrame`,
+ `needsContinuousRepaint`, `persistProjectState`/`restoreProjectState`, and
+ `saveNeedsConfirmation`/`requestSaveConfirmation` (mode enum `SaveConfirmMode`).
+
+Pixelart's pack lifecycle (`tickPackJobs`/`runPackWorkers`) folded into its own `beginFrame`
+(the plugin self-drives background work); its pack-status check reads its own state instead of
+round-tripping through the host. Dead pack plumbing removed from `EditorAPI`/`Host`/`Editor`.
+`EditorAPI.requestCompositeWarmup` → `requestPrepareFrame` to match the new phase name.
+`Plugin.CanvasEditorExt` deleted. Verified: native build, `test`, `test-plugin-loader`, `check-web`
+all green; a grep of `src/sdk/` shows no residual domain vocabulary on the typed surface.
+
+Follow-up pass (hook honesty + docs): audited each renamed hook against its real call site —
+9/10 are genuinely generic across editor types; `prepareFrame` is borderline and is now
+documented as an opt-in `[requested]` pre-draw pass (only fires after `host.requestPrepareFrame`).
+Found & fixed a real generality bug: `tickKeybinds` was invoked only on `pixelartPlugin(editor)`,
+so a second plugin's per-frame keybinds would never fire — now broadcast to all plugins. Added a
+**required-vs-optional** map (the document cluster you must implement to be an editor) and a
+`[broadcast]`/`[active-doc]`/`[requested]` invocation tag + call-site/timing table to
+[`Plugin.zig`](../src/sdk/Plugin.zig) and [`PLUGINS.md`](PLUGINS.md). This also closes the
+original "no map of which of N hooks to implement" complaint.
+
+Active-doc owner dispatch + verbs-as-commands (done): a design review concluded the editing
+actions (`copy`/`paste`/`transform`/`acceptEdit`/`cancelEdit`/`deleteSelection`) are *not*
+universal — they're user-invoked and mean different things per editor — so they were **removed
+from `Plugin.VTable` and registered as `Command`s** (`"pixelart.copy"`, …). The shell's Edit
+menu / keybinds and *Grid Layout* dispatch to `"."` via
+`Editor.runActiveDocCommand`, so every editing action routes to whichever editor owns the focused
+tab; an owner that registered none is a clean no-op. The `EditorAPI` verb reach-backs are
+unchanged (they funnel through `editor.()`, now per-owner command dispatch).
+
+Folder lifecycle rename (done): the pixelart-flavored `persistProjectState`/`restoreProjectState`
+became the shell-event-named `onFolderClose` / `onFolderOpen` (the shell has a *folder* concept;
+"project" was pixelart's layer on top).
+
+**Still open (smaller follow-ups):**
+- **New File chooser** — with multiple `requestNewDocumentDialog` providers, present a typed "New > \" chooser (rough-edge #9 / existing `Plugin.zig` TODO). Single-provider dispatch via `Host.requestNewDocument` is done.
+
+**Resolved in SDK hardening pass:**
+- ~~**New File is single-owner**~~ — `Editor.requestNewFileDialog` dispatches via `Host.requestNewDocument`.
+- ~~**`initPlugin` not broadcast**~~ — `postInit` calls `initPlugin` on every registered plugin.
+- ~~**Menu enablement by owner**~~ — Edit menu gates on `commandEnabled` for active-doc owner commands.
+- ~~**No comptime editor profile check**~~ — `Plugin.assertEditorVTable` / `assertUtilityVTable` + templates.
+
+---
+
+### Original note
+
+[`Plugin.VTable`](../src/sdk/Plugin.zig) is ~60 optional hooks; a large fraction are
+pixel-art concepts presented as the neutral SDK: `transform`, `copy`, `paste`,
+`startPackProject`, `isPackingActive`, `tickPackJobs`, `runPackWorkers`,
+`persistProjectFolder`, `reloadProjectFolder`, `requestGridLayoutDialog`,
+`requestFlatRasterSaveWarning`, `shouldConfirmFlatRasterSave`,
+`warmupActiveDocumentComposites`, `resetDocumentPeekLayers`, `removeCanvasPane`,
+`radialMenu*`, `tickActiveDocumentPlayback`. [`EditorAPI`](../src/sdk/EditorAPI.zig) does
+the same (`transform`, `startPackProject`, `isPackingActive`, `requestCompositeWarmup`).
+
+Every hook is `?`-optional, so the compiler gives zero guidance — a missing hook surfaces
+at runtime as a feature silently doing nothing. There is no delineated "minimal editor
+plugin" subset.
+
+**Fix sketch:** split the vtable into a core *editor protocol* (the ~8 hooks every editor
+needs) and an optional *pixelart extension* surface; or at minimum document the required
+subset and add a comptime check that flags an editor plugin missing a core hook.
+
+## 2. 🔴 Document-load staging protocol is intricate and thread-unsafe-by-comment — *medium*
+
+Opening one file requires a correctly-ordered cluster of cooperating hooks whose contract
+lives only in field comments: `documentStackSize`/`documentStackAlign` → shell allocates a
+raw buffer → `loadDocument(path, out_doc)` constructs in place into shell-owned memory **on
+a worker thread** → `documentIdFromBuffer` → `registerOpenDocument` to move to a stable
+pointer → plus a separate `loadDocumentFromBytes` for web. Wrong size/align or touching
+dvui/globals from the worker thread is UB with no compile-time protection.
+
+**Fix sketch:** provide an SDK helper that owns the happy path (size/align from the doc
+type via comptime), and lift the threading rule out of a field comment into a documented
+contract / debug assertion.
+
+## 3. 🔴 ABI compatibility is all-or-nothing, opaque, pins to an exact commit — *large*
+
+The structural fingerprint ([`dylib.zig`](../src/sdk/dylib.zig)) rejects every third-party
+plugin on *any* dvui bump / boundary-struct tweak / new vtable hook, with a bare
+`error.AbiMismatch`. No version range, no skew tolerance, no tool telling the author what
+changed or which fizzy build their `.dylib` matches. A plugin is dead the instant the user
+updates fizzy.
+
+**Fix sketch:** keep the fingerprint as the hard gate but layer a human-readable
+(fizzy-version, dvui-version) tuple alongside it so diagnostics can say *why* and *what to
+rebuild against*; consider a documented "compatible host build" stamp.
+
+## 4. 🟢 Failure is invisible to the user — *cheap* — DONE
+
+Implemented: `Editor.loadUserPlugins` now records each failure into `editor.failed_user_plugins`
+(`{id, reason}`, owned strings, freed in `unloadPluginLibs`), logs at `.err` with an
+actionable reason (`pluginLoadFailureReason` maps each `LoadError` — e.g. AbiMismatch →
+"rebuild against this Fizzy build"), and a one-shot startup dialog
+(`dialogs/PluginLoadFailures.zig`) lists them so the author isn't left reading logs.
+
+---
+
+### Original note
+
+[`Editor.loadUserPlugins`](../src/editor/Editor.zig) logs `dvui.log.warn` and silently
+skips on every failure (open failed, ABI mismatch, register rejected, OOM). A user whose
+plugin doesn't load sees nothing in the UI. ABI mismatch — the most common case — surfaces
+only as a log line.
+
+**Fix sketch:** record `{plugin_id, path, error}` for each failed load on the Editor/Host,
+and surface it (settings panel section and/or a startup notice). At minimum keep a
+queryable list so the UI can show "N plugins failed to load."
+
+## 5. 🔴 No hot-reload / unload — brutal dev loop — *large*
+
+[`PluginLoader.loadAndRegister`](../src/editor/PluginLoader.zig) keeps the DynLib open for
+the app lifetime; `registerPlugin` only appends; `deinit` is never called mid-session. Plugin
+development means quit + relaunch (and reopen project/files) on every change.
+
+**Fix sketch:** an unregister path (drop registry entries owned by a plugin id, call
+`deinit`, close the lib) + a dev "reload plugin" affordance. Non-trivial because open
+documents may be owned by the plugin being unloaded.
+
+## 6. 🟢 `set_globals` slot overload is a latent footgun — *cheap* — DONE
+
+Implemented: the two post-`gpa` slots are renamed `arg_b`/`arg_c` across `sdk.dylib.SetGlobalsFn`,
+`PluginLoader.PreRegister`, and all `Editor.zig` call sites (matching the existing
+`syncLoadedPluginGlobals` vocabulary), each with a doc comment + inline comment stating the
+per-plugin convention (third-party: `arg_b` = `*Host`). No more field literally named `.state`
+carrying the host.
+
+---
+
+### Original note
+
+The C entry `set_globals(gpa, state, packer)` has three positional `*anyopaque` slots whose
+meaning differs per plugin. Third-party [`exportEntry`](../src/sdk/dylib.zig) reads them as
+`(gpa, host, state-ignored)`, so [`Editor.zig`](../src/editor/Editor.zig) smuggles `&host`
+through the field named `.state` and `.packer` is dead. Built-ins use the slots differently
+again. Works only by convention; it's a raw pointer reinterpret.
+
+**Fix sketch:** rename `PreRegister`/`SetGlobalsFn`/`installRuntime`/`exportEntry` params to
+a single clear contract — `gpa`, `host`, `plugin_state` — and update all call sites. Naming
+only; no behavior change.
+
+## 7. 🟢 Plugin identity vs folder name conflated; no dedup — *cheap* — DONE
+
+Implemented: `Host.registerPlugin` now rejects a duplicate declared `id` with
+`error.DuplicatePluginId` (built-ins register first, so they always win). The dylib loader
+turns that into a failed load surfaced via #4, and the declared `id` — not the folder name —
+is the source of truth for routing.
+
+---
+
+### Original note
+
+[`Editor.loadUserPlugins`](../src/editor/Editor.zig) derives `plugin_id` from the directory
+name and keys its collision guard on `pluginById(entry.name)`, but plugins register under
+their own declared `plugin.id`, and [`registerPlugin`](../src/sdk/Host.zig) does no dedup. A
+plugin in folder `foo` declaring `id = "pixelart"` passes the folder guard then
+double-registers `"pixelart"`; routing (`pluginById`/`pluginForExtension`) becomes
+ambiguous.
+
+**Fix sketch:** make `registerPlugin` reject a duplicate id (return an error the loader
+treats as a failed load — feeds #4), and treat the declared id as the source of truth.
+
+## 8. 🔴 Service discovery is stringly-typed and unversioned — *medium*
+
+[`Host.getService(name) -> ?*anyopaque`](../src/sdk/Host.zig) then
+`@ptrCast(@alignCast(...))`. The author must know the magic string and the exact cast type,
+with nothing binding the two, and the service struct's layout is not in the ABI fingerprint —
+so a shape change silently corrupts. Only workbench's service is documented.
+
+**Fix sketch:** a typed service helper (`getService(T)` keyed on `T.service_name`) and fold
+registered service struct layouts into the fingerprint, or attach a per-service version.
+
+## 9. 🔴 Smaller items — *cheap-ish, batched*
+
+- **`core.gpa` global** — docs say "sync `core.gpa = sdk.allocator()` if you use core
+ helpers," but `core` is a first-class import a complex plugin will use; forgetting is UB
+ with no reminder. Consider asserting/initializing it at load.
+- **"New File" is single-owner** — existing TODO in [`Plugin.zig`](../src/sdk/Plugin.zig):
+ `requestNewDocumentDialog` dispatches to "a plugin that provides one"; a second editor
+ collides. Needs a typed "New > \" chooser.
+- **Install ergonomics / no manifest** — `zig build install --prefix /plugins//` is hand-assembled; no `fizzy install-plugin`, no manifest declaring
+ name/version/author/min-fizzy-version. Identity comes from the folder the user drops it in.
+- **dvui globals across the boundary** — context is re-injected each frame
+ ([`syncLoadedPluginDvuiContexts`](../src/editor/Editor.zig)); a plugin caching
+ `currentWindow()`, a font, or an ft2 handle across frames is in undocumented territory.
+
+## 10. 🟢 Built-in plugins didn't look like third-party plugins — *medium* — DONE
+
+A built-in's folder used to carry files a third-party plugin never has (an embed-stub
+`build.zig` + a separate `build_standalone.zig`, `module.zig`, `dylib.zig`, `Globals.zig`)
+and its `build/integration.zig` ran from two roots via dual-path (`repo_paths`/`pkg_paths`)
+machinery — so "what files does a plugin need?" had two different answers.
+
+Now every plugin folder — the built-ins (pixi/workbench/code), the new in-repo `example`
+template, and external plugins like markdown — is the **same canonical third-party shape**
+(`build.zig` via `fizzy.plugin.create`, `build.zig.zon`, `root.zig` → `src/plugin.zig`,
+`src/…`) and builds standalone with `cd src/plugins/ && zig build`. The only
+fizzy-internal extras are a root `.zig` (the conventional package module + import hub,
+forced to the root by Zig's module-import boundary) and a self-contained `static/` subfolder
+(`static/integration.zig`) holding the static-embed + bundled-dylib build graph; the embed stub,
+`build_standalone.zig`, `module.zig`, `src/hub.zig`, `dylib.zig`, `Globals.zig`, and the
+dual-root path machinery are all gone. Vendored C deps use the reusable `fizzy.plugin.addCModule`
+helper. The [`example`](../src/plugins/example/) plugin is the always-compiling copy-me
+template. See [PLUGINS.md](PLUGINS.md) §2.
+
+**Caveat (monorepo only):** building a built-in that vendors C deps shared with fizzy's own
+build graph (pixi's `build/deps.zig`) standalone from *inside* the repo would put one file in
+two build modules, so pixi's `build.zig` inlines its vendored-dep wiring. A genuine
+third-party plugin in its own repo has no such overlap.
diff --git a/plugin_sdk.zig b/plugin_sdk.zig
new file mode 100644
index 00000000..c6ceb218
--- /dev/null
+++ b/plugin_sdk.zig
@@ -0,0 +1,314 @@
+//! Build helpers for third-party Fizzy plugin dylibs.
+//!
+//! Required in your project (see `docs/PLUGINS.md` §2):
+//! - `root.zig` — copy from `fizzy/src/plugins/root.zig` (one `sdk.dylib.exportEntry` call)
+//! - `src/plugin.zig` — `register(host)` + `Plugin` vtable + `manifest`; read `sdk.allocator()` / `sdk.host()`
+//! - `build.zig` / `build.zig.zon` — declare `fizzy`, call `fizzy.plugin.create` + `.install` below
+const std = @import("std");
+
+/// Shared with the runtime loader so install + load locations never drift (see its doc comment).
+const core_paths = @import("src/core/paths.zig");
+
+/// C-ABI entry symbols every plugin dylib must export.
+pub const dylib_exports = [_][]const u8{
+ "fizzy_plugin_abi_fingerprint",
+ "fizzy_plugin_sdk_version",
+ "fizzy_plugin_min_sdk_version",
+ "fizzy_plugin_version",
+ "fizzy_plugin_id",
+ "fizzy_plugin_register",
+ "fizzy_plugin_set_dvui_context",
+ "fizzy_plugin_set_render_bridge",
+ "fizzy_plugin_set_globals",
+};
+
+pub const Modules = struct {
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+ dvui: *std.Build.Module,
+ proxy_bridge: *std.Build.Module,
+};
+
+pub const ModulesOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+};
+
+pub const ModuleOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ root_source_file: std.Build.LazyPath,
+ link_libc: bool = true,
+};
+
+pub const CreateOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ /// Dylib artifact name and installed filename stem (e.g. `"markdown"` → `markdown.dylib`).
+ name: []const u8,
+ link_libc: bool = true,
+ root_source_file: ?std.Build.LazyPath = null,
+};
+
+fn fizzyDep(b: *std.Build, opts: ModulesOptions) *std.Build.Dependency {
+ return b.dependency("fizzy", .{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .plugin_sdk = true,
+ });
+}
+
+fn modulesFromDep(fizzy_dep: *std.Build.Dependency) Modules {
+ return .{
+ .core = fizzy_dep.module("core"),
+ .sdk = fizzy_dep.module("sdk"),
+ .dvui = fizzy_dep.module("dvui"),
+ .proxy_bridge = fizzy_dep.module("proxy_bridge"),
+ };
+}
+
+pub fn modules(b: *std.Build, opts: ModulesOptions) Modules {
+ return modulesFromDep(fizzyDep(b, opts));
+}
+
+pub fn addImports(mod: *std.Build.Module, plugin_modules: Modules) void {
+ mod.addImport("core", plugin_modules.core);
+ mod.addImport("sdk", plugin_modules.sdk);
+ mod.addImport("dvui", plugin_modules.dvui);
+ mod.addImport("proxy_bridge", plugin_modules.proxy_bridge);
+}
+
+fn module(
+ b: *std.Build,
+ plugin_modules: Modules,
+ opts: ModuleOptions,
+) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = opts.root_source_file,
+ .link_libc = opts.link_libc,
+ });
+ addImports(mod, plugin_modules);
+ return mod;
+}
+
+pub fn createModule(b: *std.Build, opts: ModuleOptions) *std.Build.Module {
+ return module(b, modules(b, .{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ }), opts);
+}
+
+pub const InstallOptions = struct {
+ /// Install under `/{name}.{ext}`. Defaults to `lib` compile artifact name.
+ name: ?[]const u8 = null,
+};
+
+/// Wire `zig build install` for a plugin: emit `zig-out/{name}.{ext}` (for packaging / store CI)
+/// **and** drop `{name}.{ext}` into this OS's fizzy plugins dir, so the editor loads it on next
+/// launch. `{name}` must equal the plugin's `manifest.id`. This is the canonical plugin-dev
+/// command — `zig build install` is all an author needs.
+///
+/// const lib = fizzy.plugin.create(b, .{ .name = "markdown", .target = target, .optimize = optimize });
+/// fizzy.plugin.install(b, lib, .{});
+pub fn install(b: *std.Build, lib: *std.Build.Step.Compile, opts: InstallOptions) void {
+ const name = opts.name orelse lib.name;
+ const dest = b.fmt("{s}.{s}", .{ name, pluginExt(lib.rootModuleTarget().os.tag) });
+
+ // zig-out/{name}.{ext} — packaging / store CI grabs this.
+ const install_step = b.addInstallArtifact(lib, .{
+ .dest_dir = .{ .override = .prefix },
+ .dest_sub_path = dest,
+ });
+ b.getInstallStep().dependOn(&install_step.step);
+
+ // {config}/fizzy/plugins/{name}.{ext} — so the running editor picks it up (dev convenience).
+ const dev = b.allocator.create(DevInstall) catch @panic("OOM");
+ dev.* = .{
+ .step = std.Build.Step.init(.{
+ .id = .custom,
+ .name = b.fmt("install plugin '{s}' into the fizzy plugins dir", .{name}),
+ .owner = b,
+ .makeFn = DevInstall.make,
+ }),
+ .lib = lib,
+ .file_name = dest,
+ };
+ dev.step.dependOn(&lib.step);
+ b.getInstallStep().dependOn(&dev.step);
+}
+
+/// Platform extension for a dynamic plugin library, for the given target OS.
+fn pluginExt(os_tag: std.Target.Os.Tag) []const u8 {
+ return switch (os_tag) {
+ .windows => "dll",
+ .macos => "dylib",
+ else => "so",
+ };
+}
+
+/// Resolve `{local_config}/fizzy/plugins` on the build host — exactly where the app scans for
+/// user plugins. Must mirror `known-folders` `.local_configuration` (what the runtime loader
+/// uses, see `src/core/paths.zig`) + `fizzy/plugins`:
+/// macOS `~/Library/Application Support/fizzy/plugins`
+/// Linux `$XDG_CONFIG_HOME/fizzy/plugins` (or `~/.config/fizzy/plugins`)
+/// Windows `%LOCALAPPDATA%/fizzy/plugins` (FOLDERID_LocalAppData — *not* Roaming/`%APPDATA%`)
+fn fizzyPluginsDir(b: *std.Build) ![]const u8 {
+ const env = &b.graph.environ_map;
+ const config_root = (try core_paths.localConfigRoot(
+ b.graph.host.result.os.tag,
+ b.allocator,
+ env.get("HOME"),
+ env.get("XDG_CONFIG_HOME"),
+ env.get("LOCALAPPDATA"),
+ )) orelse return error.NoConfigHome;
+ return std.fs.path.join(b.allocator, &.{ config_root, "fizzy", "plugins" });
+}
+
+/// Custom step: copy the built dylib into the host's fizzy plugins dir as `{id}.{ext}`.
+const DevInstall = struct {
+ step: std.Build.Step,
+ lib: *std.Build.Step.Compile,
+ file_name: []const u8,
+
+ fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void {
+ const self: *DevInstall = @fieldParentPtr("step", step);
+ const b = step.owner;
+ const io = b.graph.io;
+
+ // Skip gracefully if the host has no resolvable config home (e.g. a bare CI runner) so a
+ // plain `zig build` for packaging never fails on the dev convenience.
+ const dir = fizzyPluginsDir(b) catch |err| {
+ std.log.warn("fizzy: skipping plugin dev install (no config home: {s})", .{@errorName(err)});
+ return;
+ };
+ // Create `{config}/fizzy` then `{config}/fizzy/plugins` (the config root already exists);
+ // "already exists" is fine.
+ const fizzy_dir = std.fs.path.dirname(dir).?;
+ std.Io.Dir.createDirAbsolute(io, fizzy_dir, .default_dir) catch {};
+ std.Io.Dir.createDirAbsolute(io, dir, .default_dir) catch {};
+
+ // `getPath2` is relative to the build root (the runner's cwd); the dest is absolute.
+ const src = self.lib.getEmittedBin().getPath2(b, step);
+ const dest = try std.fs.path.join(b.allocator, &.{ dir, self.file_name });
+ const data = try std.Io.Dir.cwd().readFileAlloc(io, src, b.allocator, .limited(512 * 1024 * 1024));
+ try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = dest, .data = data });
+ std.log.info("fizzy: installed plugin → {s}", .{dest});
+ }
+};
+
+/// A C source file + its compile flags, for `addCModule`.
+pub const CSourceFile = struct {
+ file: std.Build.LazyPath,
+ flags: []const []const u8 = &.{},
+};
+
+pub const CModuleOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ /// Zig bindings root (e.g. `zstbi.zig`).
+ root_source_file: std.Build.LazyPath,
+ /// C translation units compiled into the module.
+ c_sources: []const CSourceFile = &.{},
+ /// `-I` include dirs for the C sources.
+ include_paths: []const std.Build.LazyPath = &.{},
+ link_libc: bool = true,
+ single_threaded: bool = false,
+};
+
+/// Build a Zig module backed by vendored C sources (an image/codec/archive lib, etc.) and
+/// return it for `mod.addImport(...)`. The C compiles into whatever artifact imports the
+/// returned module. All inputs are caller-supplied `LazyPath`s, so this works unchanged whether
+/// invoked from the fizzy build root (static embed / bundled dylib) or a standalone plugin
+/// build — there is no shared, location-bound build file to collide between the two graphs.
+pub fn addCModule(b: *std.Build, opts: CModuleOptions) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = opts.root_source_file,
+ .link_libc = opts.link_libc,
+ .single_threaded = opts.single_threaded,
+ });
+ for (opts.include_paths) |path| mod.addIncludePath(path);
+ for (opts.c_sources) |c| mod.addCSourceFile(.{ .file = c.file, .flags = c.flags });
+ return mod;
+}
+
+pub fn create(b: *std.Build, opts: CreateOptions) *std.Build.Step.Compile {
+ const root_source = opts.root_source_file orelse b.path("root.zig");
+ const mod = module(b, modules(b, .{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ }), .{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = root_source,
+ .link_libc = opts.link_libc,
+ });
+
+ const lib = b.addLibrary(.{
+ .name = opts.name,
+ .linkage = .dynamic,
+ .root_module = mod,
+ });
+ lib.linker_allow_shlib_undefined = true;
+ lib.root_module.export_symbol_names = &dylib_exports;
+ return lib;
+}
+
+pub fn exportModules(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+) !void {
+ const dvui_dep = b.dependency("dvui", .{
+ .target = target,
+ .optimize = optimize,
+ .backend = .proxy,
+ .accesskit = .off,
+ });
+ const dvui_proxy_mod = dvui_dep.module("dvui_proxy");
+ const proxy_bridge_mod = dvui_dep.module("proxy_bridge");
+
+ const core_mod = b.addModule("core", .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ .link_libc = true,
+ });
+ core_mod.addImport("dvui", dvui_proxy_mod);
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ core_mod.addImport("icons", dep.module("icons"));
+ }
+
+ const sdk_mod = b.addModule("sdk", .{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/sdk/sdk.zig"),
+ });
+ sdk_mod.addImport("dvui", dvui_proxy_mod);
+ sdk_mod.addImport("proxy_bridge", proxy_bridge_mod);
+ sdk_mod.addImport("core", core_mod);
+
+ b.modules.put(b.graph.arena, b.dupe("dvui"), dvui_proxy_mod) catch @panic("OOM");
+ b.modules.put(b.graph.arena, b.dupe("proxy_bridge"), proxy_bridge_mod) catch @panic("OOM");
+}
+
+/// Install a built-in plugin dylib as `{name}.{ext}` under `plugins/`.
+pub fn installBuiltinPlugin(
+ b: *std.Build,
+ lib: *std.Build.Step.Compile,
+ name: []const u8,
+ plugins_install_dir: std.Build.InstallDir,
+) *std.Build.Step.InstallArtifact {
+ const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) {
+ .windows => "dll",
+ .macos => "dylib",
+ else => "so",
+ };
+ return b.addInstallArtifact(lib, .{
+ .dest_dir = .{ .override = plugins_install_dir },
+ .dest_sub_path = b.fmt("{s}.{s}", .{ name, ext }),
+ });
+}
diff --git a/readme.md b/readme.md
index 895d6c31..4ddd6f8d 100644
--- a/readme.md
+++ b/readme.md
@@ -6,7 +6,7 @@

#
-**Fizzy** is a cross-platform open-source pixel art editor and animation editor written in [Zig](https://github.com/ziglang/zig).
+**Fizzy** is a cross-platform open-source modular general editor written in [Zig](https://github.com/ziglang/zig).
### Try it in your browser [here](https://fizzyed.it/app/)
@@ -17,21 +17,6 @@
[](https://ko-fi.com/R5R4LL2PJ)
-## Currently supported features
-- [x] Typical pixel art operations. (draw, erase, dropper, bucket, selection, transformation, etc)
-- [x] Tabs and splits, drag and drop to reorder and reconfigure
-- [x] File explorer with search and drag and drop.
-- [ ] Create animations and preview easily, edit directly on the preview.
-- [ ] View previous and next frames of the animation.
-- [ ] Set sprite origins for drawing sprites easily in game frameworks.
-- [ ] Import and slice existing .png spritesheets.
-- [x] Intuitive and customizeable user interface.
-- [x] Sprite packing
-- [ ] Theming
-- [ ] Automatic packing and export on file save
-- [x] Also a zig library offering modules for handling assets
-- [ ] Export animations as .gifs
-
## User Interface
- The user interface is driven by [DVUI](https://github.com/david-vanderson/dvui).
- The general layout takes many ideas from VSCode or IDE's, as well as general project setup using folders.
diff --git a/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/Animation.zig b/src/Animation.zig
deleted file mode 100644
index f044c0ae..00000000
--- a/src/Animation.zig
+++ /dev/null
@@ -1,145 +0,0 @@
-const std = @import("std");
-const Animation = @This();
-
-name: []const u8,
-frames: []Frame,
-
-pub const Frame = struct {
- sprite_index: usize,
- ms: u32,
-};
-
-pub const AnimationV2 = struct {
- name: []const u8,
- frames: []usize,
- fps: f32,
-};
-
-pub const AnimationV1 = struct {
- name: []const u8,
- start: usize,
- length: usize,
- fps: f32,
-};
-
-pub fn init(allocator: std.mem.Allocator, name: []const u8, frames: []usize) !Animation {
- return .{
- .name = try allocator.dupe(u8, name),
- .frames = try allocator.dupe(Frame, frames),
- };
-}
-
-pub fn eql(a: Animation, b: Animation) bool {
- var e: bool = true;
- if (a.frames.len != b.frames.len) {
- return false;
- }
-
- for (a.frames, b.frames) |frame_a, frame_b| {
- if (frame_a.sprite_index != frame_b.sprite_index) {
- e = false;
- break;
- } else if (frame_a.ms != frame_b.ms) {
- e = false;
- break;
- }
- }
-
- return e;
-}
-
-pub fn eqlFrames(a: Animation, frames: []Frame) bool {
- var e: bool = true;
-
- if (a.frames.len != frames.len) {
- return false;
- }
-
- for (a.frames, frames) |frame_a, frame_b| {
- if (frame_a.sprite_index != frame_b.sprite_index) {
- e = false;
- break;
- } else if (frame_a.ms != frame_b.ms) {
- e = false;
- break;
- }
- }
-
- return e;
-}
-
-pub fn deinit(self: *Animation, allocator: std.mem.Allocator) void {
- allocator.free(self.name);
- allocator.free(self.frames);
-}
-
-// ----------------------------------------------------------------
-// Tests
-// ----------------------------------------------------------------
-
-const expect = std.testing.expect;
-const expectEqual = std.testing.expectEqual;
-
-test "eql: identical frame lists compare equal" {
- var f_a = [_]Frame{
- .{ .sprite_index = 0, .ms = 100 },
- .{ .sprite_index = 1, .ms = 100 },
- .{ .sprite_index = 2, .ms = 100 },
- };
- var f_b = [_]Frame{
- .{ .sprite_index = 0, .ms = 100 },
- .{ .sprite_index = 1, .ms = 100 },
- .{ .sprite_index = 2, .ms = 100 },
- };
- const a: Animation = .{ .name = "idle", .frames = &f_a };
- const b: Animation = .{ .name = "idle", .frames = &f_b };
- try expect(eql(a, b));
-}
-
-test "eql: differing sprite_index makes animations unequal" {
- var f_a = [_]Frame{.{ .sprite_index = 0, .ms = 100 }};
- var f_b = [_]Frame{.{ .sprite_index = 1, .ms = 100 }};
- const a: Animation = .{ .name = "idle", .frames = &f_a };
- const b: Animation = .{ .name = "idle", .frames = &f_b };
- try expect(!eql(a, b));
-}
-
-test "eql: differing ms makes animations unequal" {
- var f_a = [_]Frame{.{ .sprite_index = 0, .ms = 100 }};
- var f_b = [_]Frame{.{ .sprite_index = 0, .ms = 250 }};
- const a: Animation = .{ .name = "idle", .frames = &f_a };
- const b: Animation = .{ .name = "idle", .frames = &f_b };
- try expect(!eql(a, b));
-}
-
-test "eql: differing frame counts make animations unequal" {
- var f_a = [_]Frame{
- .{ .sprite_index = 0, .ms = 100 },
- .{ .sprite_index = 1, .ms = 100 },
- };
- var f_b = [_]Frame{
- .{ .sprite_index = 0, .ms = 100 },
- };
- const a: Animation = .{ .name = "idle", .frames = &f_a };
- const b: Animation = .{ .name = "idle", .frames = &f_b };
- try expect(!eql(a, b));
-}
-
-test "eqlFrames: matches when contents match exactly" {
- var f = [_]Frame{
- .{ .sprite_index = 5, .ms = 50 },
- .{ .sprite_index = 6, .ms = 50 },
- };
- var probe = [_]Frame{
- .{ .sprite_index = 5, .ms = 50 },
- .{ .sprite_index = 6, .ms = 50 },
- };
- const a: Animation = .{ .name = "x", .frames = &f };
- try expect(eqlFrames(a, &probe));
-}
-
-test "eqlFrames: empty frames compare equal" {
- var empty: [0]Frame = .{};
- const a: Animation = .{ .name = "empty", .frames = &empty };
- try expect(eqlFrames(a, &empty));
-}
diff --git a/src/App.zig b/src/App.zig
index 65e64e30..b043b924 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -8,15 +8,15 @@ 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 text = @import("text");
+const auto_update = @import("backend/auto_update.zig");
+const update_notify = @import("backend/update_notify.zig");
+const singleton = @import("backend/singleton.zig");
+const paths = fizzy.paths;
const App = @This();
const Editor = fizzy.Editor;
-const Packer = fizzy.Packer;
-//const Assets = fizzy.Assets;
// App fields
allocator: std.mem.Allocator = undefined,
@@ -59,7 +59,14 @@ const start_options_base: dvui.App.StartOptions = .{
fn startOptions() dvui.App.StartOptions {
var opts = start_options_base;
+ // Create the dvui window with the *same* allocator the host hands to plugins
+ // (`fizzy.app.allocator`). Without this, dvui defaults the window to the runtime's
+ // `main_init.gpa`, a different allocator instance — so `dvui.currentWindow().gpa`
+ // and `host.allocator` would be distinct, and a plugin that allocated with one and
+ // freed with the other would corrupt the heap. Unifying them makes every allocator a
+ // plugin can reach the same instance. (No-op on wasm, which uses the page allocator.)
if (comptime builtin.target.cpu.arch != .wasm32) {
+ opts.gpa = appAllocator();
const main_init = dvui.App.main_init orelse return opts;
if (paths.configFolderZ(&pref_path_buf, main_init.io, fizzy.processEnviron(), ".")) |pref_path| {
pref_path_len = pref_path.len;
@@ -130,6 +137,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,11 +173,13 @@ pub fn AppInit(win: *dvui.Window) !void {
fizzy.editor = try allocator.create(Editor);
fizzy.editor.* = Editor.init(fizzy.app) catch unreachable;
- // `Packer` works on web now that `zstbi.c` compiles for wasm32-freestanding
- // (`STBI_NO_STDLIB` + the `fizzy_stbi_libc.c` shims). The web pack flow
- // packs the currently-open files instead of walking a project directory.
- fizzy.packer = try allocator.create(Packer);
- fizzy.packer.* = Packer.init(allocator) catch unreachable;
+ // Workbench shell-owned state: wire before plugin `register`.
+ workbench.runtime.setWorkbench(&fizzy.editor.workbench);
+
+ // Second-stage init that needs the editor at its final heap address (e.g. registering the
+ // workbench-api service whose `ctx` is this pointer). This loads the built-in plugins,
+ // including pixi as a generic dylib that owns its own state + atlas packer.
+ fizzy.editor.postInit() catch unreachable;
// Hand the window to the listener thread and queue our own argv so the
// first frame opens any files / project folder supplied on the command line.
@@ -212,6 +226,8 @@ pub fn AppInit(win: *dvui.Window) !void {
pub fn AppDeinit() void {
// Persist the current windowed frame while the window still exists. No-op off macOS.
fizzy.backend.saveWindowGeometry(fizzy.app.window);
+ // `editor.deinit` runs each plugin's `deinit` first (pixi's persists its `.fizproject` and
+ // frees its own state + packer while `editor.host`/folder are still live).
fizzy.editor.deinit() catch unreachable;
// Tear down the singleton listener after the editor so any callback
// currently in flight finishes before we free state it touches.
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/Atlas.zig b/src/Atlas.zig
deleted file mode 100644
index ff1a5346..00000000
--- a/src/Atlas.zig
+++ /dev/null
@@ -1,108 +0,0 @@
-const std = @import("std");
-const fs = @import("tools/fs.zig");
-const fizzy = @import("fizzy.zig");
-
-const Atlas = @This();
-
-pub const Sprite = struct {
- origin: [2]f32 = .{ 0.0, 0.0 },
- source: [4]u32,
-};
-
-const Animation = @import("Animation.zig");
-
-sprites: []Sprite,
-animations: []Animation,
-
-const AtlasV2 = struct {
- sprites: []Sprite,
- animations: []Animation.AnimationV2,
-};
-
-const AtlasV1 = struct {
- sprites: []Sprite,
- animations: []Animation.AnimationV1,
-};
-
-pub fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, file: []const u8) !Atlas {
- const read = try fs.read(allocator, io, file);
- defer allocator.free(read);
-
- return loadFromBytes(allocator, read);
-}
-
-pub fn loadFromBytes(allocator: std.mem.Allocator, bytes: []const u8) !Atlas {
- const options = std.json.ParseOptions{ .duplicate_field_behavior = .use_first, .ignore_unknown_fields = true };
-
- if (std.json.parseFromSlice(Atlas, allocator, bytes, options) catch null) |parsed| {
- const animations = try allocator.dupe(Animation, parsed.value.animations);
-
- for (animations) |*animation| {
- animation.name = try allocator.dupe(u8, animation.name);
- }
-
- return .{
- .sprites = try allocator.dupe(Sprite, parsed.value.sprites),
- .animations = animations,
- };
- } else if (std.json.parseFromSlice(AtlasV2, allocator, bytes, options) catch null) |parsed| {
- const animations = try allocator.alloc(Animation, parsed.value.animations.len);
- for (animations, parsed.value.animations) |*animation, old_animation| {
- animation.name = try allocator.dupe(u8, old_animation.name);
- animation.frames = try allocator.alloc(Animation.Frame, old_animation.frames.len);
- for (animation.frames, old_animation.frames) |*frame, old_frame| {
- frame.sprite_index = old_frame;
- const fps = if (old_animation.fps > 0) old_animation.fps else 1.0;
- frame.ms = @intFromFloat(1000.0 / fps);
- }
- }
-
- return .{
- .sprites = try allocator.dupe(Sprite, parsed.value.sprites),
- .animations = animations,
- };
- } else if (std.json.parseFromSlice(AtlasV1, allocator, bytes, options) catch null) |parsed| {
- const animations = try allocator.alloc(Animation, parsed.value.animations.len);
- for (animations, parsed.value.animations) |*animation, old_animation| {
- animation.name = try allocator.dupe(u8, old_animation.name);
- animation.frames = try allocator.alloc(Animation.Frame, old_animation.length);
- for (animation.frames, old_animation.start..old_animation.start + old_animation.length) |*frame, frame_index| {
- frame.sprite_index = frame_index;
- const fps = if (old_animation.fps > 0) old_animation.fps else 1.0;
- frame.ms = @intFromFloat(1000.0 / fps);
- }
- }
-
- return .{
- .sprites = try allocator.dupe(Sprite, parsed.value.sprites),
- .animations = animations,
- };
- }
-
- return error.CannotLoadAtlas;
-}
-
-pub fn spriteName(atlas: *Atlas, allocator: std.mem.Allocator, index: usize) ![]const u8 {
- for (atlas.animations) |animation| {
- for (animation.frames, 0..) |frame, frame_index| {
- if (frame.sprite_index != index) continue;
-
- if (animation.frames.len > 1) {
- return std.fmt.allocPrint(allocator, "{s}_{d}", .{ animation.name, frame_index });
- } else {
- return std.fmt.allocPrint(allocator, "{s}", .{animation.name});
- }
- }
- }
-
- return std.fmt.allocPrint(allocator, "Sprite_{d}", .{index});
-}
-
-pub fn deinit(atlas: *Atlas, allocator: std.mem.Allocator) void {
- for (atlas.animations) |*animation| {
- allocator.free(animation.name);
- }
-
- allocator.free(atlas.sprites);
- allocator.free(atlas.animations);
-}
diff --git a/src/File.zig b/src/File.zig
deleted file mode 100644
index bb4cd017..00000000
--- a/src/File.zig
+++ /dev/null
@@ -1,105 +0,0 @@
-const std = @import("std");
-const fizzy = @import("fizzy.zig");
-
-const File = @This();
-
-/// Version of fizzy that created this file
-version: std.SemanticVersion,
-
-// Grid data
-columns: u32,
-rows: u32,
-column_width: u32,
-row_height: u32,
-
-// Layer data
-layers: []fizzy.Layer,
-// Origins of sprites
-sprites: []fizzy.Sprite,
-// Lists of sprite indexes and timings
-animations: []fizzy.Animation,
-
-pub fn deinit(self: *File, allocator: std.mem.Allocator) void {
- for (self.layers) |*layer| {
- allocator.free(layer.name);
- }
- for (self.animations) |*animation| {
- allocator.free(animation.frames);
- allocator.free(animation.name);
- }
- allocator.free(self.layers);
- allocator.free(self.sprites);
- allocator.free(self.animations);
-}
-
-/// Older file format, describes animations by frame indices with no duration information
-pub const FileV3 = struct {
- version: std.SemanticVersion,
- columns: u32,
- rows: u32,
- column_width: u32,
- row_height: u32,
- layers: []fizzy.Layer,
- sprites: []fizzy.Sprite,
- animations: []fizzy.Animation.AnimationV2,
-
- pub fn deinit(self: *File, allocator: std.mem.Allocator) void {
- for (self.layers) |*layer| {
- allocator.free(layer.name);
- }
- for (self.animations) |*animation| {
- allocator.free(animation.name);
- }
- allocator.free(self.layers);
- allocator.free(self.sprites);
- allocator.free(self.animations);
- }
-};
-
-/// Older file format, describes files by width and height and tile size
-pub const FileV2 = struct {
- version: std.SemanticVersion,
- width: u32,
- height: u32,
- tile_width: u32,
- tile_height: u32,
- layers: []fizzy.Layer,
- sprites: []fizzy.Sprite,
- animations: []fizzy.Animation.AnimationV2,
-
- pub fn deinit(self: *File, allocator: std.mem.Allocator) void {
- for (self.layers) |*layer| {
- allocator.free(layer.name);
- }
- for (self.animations) |*animation| {
- allocator.free(animation.name);
- }
- allocator.free(self.layers);
- allocator.free(self.sprites);
- allocator.free(self.animations);
- }
-};
-
-/// Original file format, has a different animation format
-pub const FileV1 = struct {
- version: std.SemanticVersion,
- width: u32,
- height: u32,
- tile_width: u32,
- tile_height: u32,
- layers: []fizzy.Layer,
- sprites: []fizzy.Sprite,
- animations: []fizzy.Animation.AnimationV1,
-
- pub fn deinit(self: *File, allocator: std.mem.Allocator) void {
- for (self.layers) |*layer| {
- allocator.free(layer.name);
- }
- for (self.animations) |*animation| {
- allocator.free(animation.name);
- }
- allocator.free(self.layers);
- allocator.free(self.sprites);
- allocator.free(self.animations);
- }
-};
diff --git a/src/Layer.zig b/src/Layer.zig
deleted file mode 100644
index 5c753a02..00000000
--- a/src/Layer.zig
+++ /dev/null
@@ -1,11 +0,0 @@
-const std = @import("std");
-
-const Layer = @This();
-
-name: []const u8,
-visible: bool = true,
-collapse: bool = false,
-
-pub fn deinit(layer: *Layer, allocator: std.mem.Allocator) void {
- allocator.free(layer.name);
-}
diff --git a/src/Sprite.zig b/src/Sprite.zig
deleted file mode 100644
index ec3c3e90..00000000
--- a/src/Sprite.zig
+++ /dev/null
@@ -1 +0,0 @@
-origin: [2]f32 = .{ 0.0, 0.0 },
diff --git a/src/algorithms/algorithms.zig b/src/algorithms/algorithms.zig
deleted file mode 100644
index b663c080..00000000
--- a/src/algorithms/algorithms.zig
+++ /dev/null
@@ -1,2 +0,0 @@
-pub const brezenham = @import("brezenham.zig");
-pub const reduce = @import("reduce.zig");
diff --git a/src/algorithms/brezenham.zig b/src/algorithms/brezenham.zig
deleted file mode 100644
index f61ab318..00000000
--- a/src/algorithms/brezenham.zig
+++ /dev/null
@@ -1,43 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const dvui = @import("dvui");
-
-pub fn process(start: dvui.Point, end: dvui.Point) ![]dvui.Point {
- // Bresenham's line algorithm for integer grid points
- var output = std.array_list.Managed(dvui.Point).init(fizzy.editor.arena.allocator());
-
- // Round input points to nearest integer grid
- const x0: i32 = @intFromFloat(@floor(start.x));
- const y0: i32 = @intFromFloat(@floor(start.y));
- const x1: i32 = @intFromFloat(@floor(end.x));
- const y1: i32 = @intFromFloat(@floor(end.y));
-
- const dx: i32 = @intCast(@abs(x1 - x0));
- const dy: i32 = @intCast(@abs(y1 - y0));
-
- var x: i32 = x0;
- var y: i32 = y0;
-
- const sx: i32 = if (x0 < x1) 1 else -1;
- const sy: i32 = if (y0 < y1) 1 else -1;
-
- var err: i32 = dx - dy;
-
- while (true) {
- try output.append(.{ .x = @floatFromInt(x), .y = @floatFromInt(y) });
-
- if (x == x1 and y == y1) break;
-
- const e2 = 2 * err;
- if (e2 > -dy) {
- err -= dy;
- x += sx;
- }
- if (e2 < dx) {
- err += dx;
- y += sy;
- }
- }
-
- return output.items;
-}
diff --git a/src/algorithms/reduce.zig b/src/algorithms/reduce.zig
deleted file mode 100644
index 69546d97..00000000
--- a/src/algorithms/reduce.zig
+++ /dev/null
@@ -1,452 +0,0 @@
-//! Pure transparency-aware bounding-rect tightening for sprite packing.
-//!
-//! The atlas packer (`tools/Packer.zig`) walks each grid cell of every visible layer and asks
-//! "what is the smallest sub-rect of this cell that contains all opaque pixels?" before handing
-//! the bitmap to the rect packer. That avoids reserving texture space for fully-transparent
-//! borders. The same call also reports the offset by which the sprite *origin* must shift so
-//! that the in-game anchor point (feet, hand, muzzle, …) still lines up after the bitmap is
-//! tightened.
-//!
-//! This module is std-only: no dvui, no fizzy globals, no allocator. `Internal.Layer.reduce` is
-//! a thin wrapper around `reduce` here, and `Packer.append` consumes both `reduce` and
-//! `originAfterReduce`.
-//!
-//! Behavior pinned by tests in this file:
-//! * Empty input — fully transparent rect, zero-area rect, or rect outside the layer — returns
-//! `null` (caller should drop the sprite or substitute a placeholder).
-//! * Non-empty input returns a rect whose four edges each touch at least one opaque pixel.
-//! * Returned rect is contained inside the requested src rect (clamped to the layer).
-//! * `originAfterReduce` is exact: `origin' = origin - (reduced_xy - src_xy)` so the world-space
-//! anchor lands on the same pixel before/after the reduce.
-
-const std = @import("std");
-const builtin = @import("builtin");
-
-/// Integer-pixel rect. Distinct from dvui.Rect (which is f32) so this module stays std-only.
-pub const Rect = struct {
- x: u32,
- y: u32,
- w: u32,
- h: u32,
-};
-
-/// Treat any pixel with `pixels[i][3] != 0` as opaque. Matches the production rule used
-/// throughout the editor (drawing tools clear alpha-zero pixels rather than touching alpha).
-inline fn isOpaque(p: [4]u8) bool {
- return p[3] != 0;
-}
-
-/// True if any pixel in the contiguous `pixels` is opaque (alpha byte != 0).
-///
-/// Vectorized: each RGBA pixel bitcasts to one `u32`, so it ORs the alpha bytes of
-/// `vec_len` pixels per step and bails on the first opaque chunk
-pub fn anyOpaque(pixels: []const [4]u8) bool {
- var i: usize = 0;
- if (std.simd.suggestVectorLength(u32)) |vec_len| {
- const V = @Vector(vec_len, u32);
- // Alpha is byte 3 of each pixel: the high byte of the u32 on little-endian
- // targets, the low byte on big-endian.
- const alpha_mask: V = @splat(switch (builtin.cpu.arch.endian()) {
- .little => 0xFF00_0000,
- .big => 0x0000_00FF,
- });
- while (i + vec_len <= pixels.len) : (i += vec_len) {
- const chunk: V = @bitCast(pixels[i..][0..vec_len].*);
- if (@reduce(.Or, chunk & alpha_mask) != 0) return true;
- }
- }
- while (i < pixels.len) : (i += 1) if (isOpaque(pixels[i])) return true;
- return false;
-}
-
-/// Tighten `src` to the smallest sub-rect of `pixels` (laid out row-major, `layer_width` wide and
-/// `layer_height` tall) that still contains every opaque pixel inside `src`. Returns `null` when:
-///
-/// * `src` has zero area, or its origin is outside the layer (caller passed nonsense), or
-/// * every pixel covered by the (clamped) src rect is fully transparent.
-///
-/// The returned rect is always non-empty (`w > 0 and h > 0`), fully contained within both
-/// `src` (after clamp) and the layer extents, and has at least one opaque pixel touching each of
-/// its four edges.
-pub fn reduce(
- pixels: []const [4]u8,
- layer_width: u32,
- layer_height: u32,
- src: Rect,
-) ?Rect {
- if (src.w == 0 or src.h == 0) return null;
- if (src.x >= layer_width or src.y >= layer_height) return null;
- if (@as(usize, layer_width) * @as(usize, layer_height) != pixels.len) return null;
-
- const x_end = @min(src.x + src.w, layer_width);
- const y_end = @min(src.y + src.h, layer_height);
-
- var top: u32 = src.y;
- var bottom: u32 = y_end - 1;
- var left: u32 = src.x;
- var right: u32 = x_end - 1;
-
- // Find the topmost row with any opaque pixel inside the src column range.
- top: while (top <= bottom) : (top += 1) {
- const row_start: usize = @as(usize, left) + @as(usize, top) * layer_width;
- const row = pixels[row_start .. row_start + (right - left + 1)];
- if (anyOpaque(row)) break :top;
- }
- if (top > bottom) return null;
-
- // Find the bottommost row with any opaque pixel.
- bottom: while (bottom >= top) : (bottom -= 1) {
- const row_start: usize = @as(usize, left) + @as(usize, bottom) * layer_width;
- const row = pixels[row_start .. row_start + (right - left + 1)];
- if (anyOpaque(row)) break :bottom;
- if (bottom == 0) break;
- }
-
- // Tighten left edge by scanning columns within the [top..bottom] band.
- left: while (left < right) : (left += 1) {
- var y = top;
- while (y <= bottom) : (y += 1) {
- const idx = @as(usize, left) + @as(usize, y) * layer_width;
- if (isOpaque(pixels[idx])) break :left;
- }
- }
-
- // Tighten right edge symmetrically.
- right: while (right > left) : (right -= 1) {
- var y = top;
- while (y <= bottom) : (y += 1) {
- const idx = @as(usize, right) + @as(usize, y) * layer_width;
- if (isOpaque(pixels[idx])) break :right;
- }
- }
-
- return .{
- .x = left,
- .y = top,
- .w = right - left + 1,
- .h = bottom - top + 1,
- };
-}
-
-/// New sprite origin after a reduce step. The packer ships sprites with their bitmap tightened
-/// to the rect returned by `reduce`, so the sprite origin (used at runtime as the pivot when the
-/// sprite is placed in the world) must shift by the same `(dx, dy)` to keep the anchor on the
-/// same pixel. With `cell_x`, `cell_y` the top-left of the cell the sprite was sliced from, and
-/// `reduced_x`, `reduced_y` the top-left of the reduced rect, the new origin is:
-///
-/// origin' = origin - (reduced - cell)
-///
-/// Origins are stored in *cell-local* pixel coordinates (e.g. `(8, 16)` means "pivot 8 px right
-/// and 16 px down inside the cell"), so subtracting the reduce offset gives the pivot's location
-/// inside the *reduced* bitmap.
-///
-/// Invariant: `reduced_x >= cell_x` and `reduced_y >= cell_y` (caller guaranteed by `reduce`).
-pub fn originAfterReduce(
- origin: [2]f32,
- cell_x: u32,
- cell_y: u32,
- reduced_x: u32,
- reduced_y: u32,
-) [2]f32 {
- std.debug.assert(reduced_x >= cell_x);
- std.debug.assert(reduced_y >= cell_y);
- const dx: f32 = @floatFromInt(reduced_x - cell_x);
- const dy: f32 = @floatFromInt(reduced_y - cell_y);
- return .{ origin[0] - dx, origin[1] - dy };
-}
-
-// ----------------------------------------------------------------
-// Tests
-// ----------------------------------------------------------------
-
-const expectEqual = std.testing.expectEqual;
-const expect = std.testing.expect;
-
-const transparent: [4]u8 = .{ 0, 0, 0, 0 };
-const opaque_red: [4]u8 = .{ 255, 0, 0, 255 };
-
-/// Build a `width × height` pixel buffer pre-filled with transparent pixels.
-fn blankPixels(comptime width: u32, comptime height: u32) [width * height][4]u8 {
- var out: [width * height][4]u8 = undefined;
- @memset(&out, transparent);
- return out;
-}
-
-test "reduce returns null for fully transparent src" {
- var px = blankPixels(8, 8);
- try expectEqual(@as(?Rect, null), reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 }));
-}
-
-test "reduce returns null for zero-area src" {
- var px = blankPixels(8, 8);
- px[0] = opaque_red;
- try expectEqual(@as(?Rect, null), reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 0, .h = 4 }));
- try expectEqual(@as(?Rect, null), reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 4, .h = 0 }));
-}
-
-test "reduce returns null for src origin outside the layer" {
- var px = blankPixels(4, 4);
- try expectEqual(@as(?Rect, null), reduce(&px, 4, 4, .{ .x = 4, .y = 0, .w = 1, .h = 1 }));
- try expectEqual(@as(?Rect, null), reduce(&px, 4, 4, .{ .x = 0, .y = 9, .w = 1, .h = 1 }));
-}
-
-test "reduce returns null on layer/pixels length mismatch (defensive)" {
- var px = blankPixels(4, 4);
- px[0] = opaque_red;
- try expectEqual(@as(?Rect, null), reduce(&px, 5, 4, .{ .x = 0, .y = 0, .w = 1, .h = 1 }));
-}
-
-test "reduce: single opaque pixel collapses src to 1x1" {
- var px = blankPixels(8, 8);
- px[3 * 8 + 5] = opaque_red;
- const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 }) orelse return error.Unexpected;
- try expectEqual(@as(u32, 5), r.x);
- try expectEqual(@as(u32, 3), r.y);
- try expectEqual(@as(u32, 1), r.w);
- try expectEqual(@as(u32, 1), r.h);
-}
-
-test "reduce: opaque pixel at (0,0) — corners returned exactly" {
- var px = blankPixels(8, 8);
- px[0] = opaque_red;
- const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 }) orelse return error.Unexpected;
- try expectEqual(@as(u32, 0), r.x);
- try expectEqual(@as(u32, 0), r.y);
- try expectEqual(@as(u32, 1), r.w);
- try expectEqual(@as(u32, 1), r.h);
-}
-
-test "reduce: opaque pixel at bottom-right corner" {
- var px = blankPixels(8, 8);
- px[7 * 8 + 7] = opaque_red;
- const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 }) orelse return error.Unexpected;
- try expectEqual(@as(u32, 7), r.x);
- try expectEqual(@as(u32, 7), r.y);
- try expectEqual(@as(u32, 1), r.w);
- try expectEqual(@as(u32, 1), r.h);
-}
-
-test "reduce: tightens around an opaque rectangle inside the cell" {
- // Paint a 3x2 rectangle at (2,4) inside an 8x8 layer.
- var px = blankPixels(8, 8);
- var y: u32 = 4;
- while (y < 6) : (y += 1) {
- var x: u32 = 2;
- while (x < 5) : (x += 1) {
- px[y * 8 + x] = opaque_red;
- }
- }
- const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 8, .h = 8 }) orelse return error.Unexpected;
- try expectEqual(@as(u32, 2), r.x);
- try expectEqual(@as(u32, 4), r.y);
- try expectEqual(@as(u32, 3), r.w);
- try expectEqual(@as(u32, 2), r.h);
-}
-
-test "reduce: src rect smaller than layer is honoured (does not see pixels outside)" {
- var px = blankPixels(8, 8);
- // Opaque pixel outside the src rect — must not affect the reduce.
- px[7 * 8 + 7] = opaque_red;
- // Opaque pixel inside the src rect.
- px[1 * 8 + 1] = opaque_red;
- const r = reduce(&px, 8, 8, .{ .x = 0, .y = 0, .w = 4, .h = 4 }) orelse return error.Unexpected;
- try expectEqual(@as(u32, 1), r.x);
- try expectEqual(@as(u32, 1), r.y);
- try expectEqual(@as(u32, 1), r.w);
- try expectEqual(@as(u32, 1), r.h);
-}
-
-test "reduce: returned rect is fully contained in the (clamped) src rect" {
- var px = blankPixels(16, 16);
- // Stripe across the full layer.
- var x: u32 = 0;
- while (x < 16) : (x += 1) px[5 * 16 + x] = opaque_red;
- // Pick a src rect off-center; reduce should clamp to the stripe within it.
- const src = Rect{ .x = 4, .y = 3, .w = 6, .h = 5 };
- const r = reduce(&px, 16, 16, src) orelse return error.Unexpected;
- try expect(r.x >= src.x);
- try expect(r.y >= src.y);
- try expect(r.x + r.w <= src.x + src.w);
- try expect(r.y + r.h <= src.y + src.h);
- try expectEqual(@as(u32, 5), r.y);
- try expectEqual(@as(u32, 1), r.h);
- try expectEqual(@as(u32, src.x), r.x);
- try expectEqual(@as(u32, src.w), r.w);
-}
-
-test "reduce: src that overshoots the layer is clamped, not rejected" {
- var px = blankPixels(8, 8);
- px[7 * 8 + 7] = opaque_red;
- const r = reduce(&px, 8, 8, .{ .x = 6, .y = 6, .w = 32, .h = 32 }) orelse return error.Unexpected;
- try expectEqual(@as(u32, 7), r.x);
- try expectEqual(@as(u32, 7), r.y);
- try expectEqual(@as(u32, 1), r.w);
- try expectEqual(@as(u32, 1), r.h);
-}
-
-test "reduce: separate opaque islands inside the src rect are spanned by one bbox" {
- var px = blankPixels(10, 10);
- px[2 * 10 + 1] = opaque_red;
- px[7 * 10 + 8] = opaque_red;
- const r = reduce(&px, 10, 10, .{ .x = 0, .y = 0, .w = 10, .h = 10 }) orelse return error.Unexpected;
- try expectEqual(@as(u32, 1), r.x);
- try expectEqual(@as(u32, 2), r.y);
- try expectEqual(@as(u32, 8), r.w);
- try expectEqual(@as(u32, 6), r.h);
-}
-
-test "reduce: alpha=0 with non-zero RGB is treated as transparent" {
- var px = blankPixels(4, 4);
- // Many pipelines write color into transparent slots. The reducer must look at alpha only.
- px[0] = .{ 255, 255, 255, 0 };
- px[5] = opaque_red;
- const r = reduce(&px, 4, 4, .{ .x = 0, .y = 0, .w = 4, .h = 4 }) orelse return error.Unexpected;
- try expectEqual(@as(u32, 1), r.x);
- try expectEqual(@as(u32, 1), r.y);
- try expectEqual(@as(u32, 1), r.w);
- try expectEqual(@as(u32, 1), r.h);
-}
-
-test "reduce: returned rect's edges each touch an opaque pixel" {
- var px = blankPixels(12, 12);
- // L-shape: column at x=2 from y=2..6, row at y=6 from x=2..8.
- var y: u32 = 2;
- while (y <= 6) : (y += 1) px[y * 12 + 2] = opaque_red;
- var x: u32 = 2;
- while (x <= 8) : (x += 1) px[6 * 12 + x] = opaque_red;
- const r = reduce(&px, 12, 12, .{ .x = 0, .y = 0, .w = 12, .h = 12 }) orelse return error.Unexpected;
- try expectEqual(@as(u32, 2), r.x);
- try expectEqual(@as(u32, 2), r.y);
- try expectEqual(@as(u32, 7), r.w);
- try expectEqual(@as(u32, 5), r.h);
-
- // Top edge: row r.y has at least one opaque pixel within [r.x, r.x+r.w).
- var has_top = false;
- var has_bot = false;
- var has_left = false;
- var has_right = false;
- {
- var i: u32 = 0;
- while (i < r.w) : (i += 1) {
- if (isOpaque(px[r.y * 12 + (r.x + i)])) has_top = true;
- if (isOpaque(px[(r.y + r.h - 1) * 12 + (r.x + i)])) has_bot = true;
- }
- }
- {
- var i: u32 = 0;
- while (i < r.h) : (i += 1) {
- if (isOpaque(px[(r.y + i) * 12 + r.x])) has_left = true;
- if (isOpaque(px[(r.y + i) * 12 + (r.x + r.w - 1)])) has_right = true;
- }
- }
- try expect(has_top);
- try expect(has_bot);
- try expect(has_left);
- try expect(has_right);
-}
-
-test "anyOpaque: empty slice is not opaque" {
- const px: []const [4]u8 = &.{};
- try expect(!anyOpaque(px));
-}
-
-test "anyOpaque: all transparent is false" {
- var px = blankPixels(16, 16);
- try expect(!anyOpaque(&px));
-}
-
-test "anyOpaque: alpha=0 with non-zero RGB still counts as transparent" {
- var px = blankPixels(8, 8);
- @memset(&px, .{ 255, 255, 255, 0 });
- try expect(!anyOpaque(&px));
-}
-
-test "anyOpaque: a single opaque pixel anywhere is detected" {
- // Cover positions inside the first vector chunk, on a likely chunk boundary,
- // and in the ragged tail past the last full vector.
- for ([_]usize{ 0, 1, 7, 8, 15, 16, 31, 63, 64, 100, 254, 255 }) |idx| {
- var px = blankPixels(16, 16); // 256 pixels
- px[idx] = opaque_red;
- try expect(anyOpaque(&px));
- }
-}
-
-test "anyOpaque: detects opaque pixel in a non-vector-multiple length (tail)" {
- // 13 is prime — guarantees a scalar-tail remainder for any vector width.
- var px = blankPixels(13, 1);
- try expect(!anyOpaque(&px));
- px[12] = opaque_red;
- try expect(anyOpaque(&px));
-}
-
-test "anyOpaque: only the alpha byte matters (RGB ignored)" {
- var px = blankPixels(8, 8);
- // Opaque red has alpha 255 -> opaque.
- px[5] = opaque_red;
- try expect(anyOpaque(&px));
- // Clearing alpha (but leaving RGB) makes the whole slice transparent again.
- px[5] = .{ 255, 0, 0, 0 };
- try expect(!anyOpaque(&px));
-}
-
-test "originAfterReduce: zero offset leaves the origin untouched" {
- const o = originAfterReduce(.{ 4.0, 7.5 }, 0, 0, 0, 0);
- try expectEqual(@as(f32, 4.0), o[0]);
- try expectEqual(@as(f32, 7.5), o[1]);
-}
-
-test "originAfterReduce: shifts by the reduce delta within the cell" {
- // Cell (32, 32) reduced rect at (35, 38) → offsets (3, 6). Origin (12, 24) becomes (9, 18).
- const o = originAfterReduce(.{ 12.0, 24.0 }, 32, 32, 35, 38);
- try expectEqual(@as(f32, 9.0), o[0]);
- try expectEqual(@as(f32, 18.0), o[1]);
-}
-
-test "originAfterReduce: anchor lands on the same world pixel after tighten" {
- // Sprite is sliced from cell (16,16); origin in cell-local space is (10, 12).
- const cell_x: u32 = 16;
- const cell_y: u32 = 16;
- const origin_local: [2]f32 = .{ 10.0, 12.0 };
-
- // Reducer tightens the bitmap to start at (19, 17) inside the layer.
- const reduced_x: u32 = 19;
- const reduced_y: u32 = 17;
-
- const new_origin = originAfterReduce(origin_local, cell_x, cell_y, reduced_x, reduced_y);
-
- // Convert both origins back to layer-space and check the world pixel is identical.
- const orig_world_x: f32 = origin_local[0] + @as(f32, @floatFromInt(cell_x));
- const orig_world_y: f32 = origin_local[1] + @as(f32, @floatFromInt(cell_y));
- const new_world_x: f32 = new_origin[0] + @as(f32, @floatFromInt(reduced_x));
- const new_world_y: f32 = new_origin[1] + @as(f32, @floatFromInt(reduced_y));
- try expectEqual(orig_world_x, new_world_x);
- try expectEqual(orig_world_y, new_world_y);
-}
-
-test "originAfterReduce + reduce: round-trip on a real bitmap" {
- // Paint a 2x2 opaque block at (5, 6) inside an 8x8 cell at (0, 0) within a 16x16 layer.
- // Cell origin (0, 0); sprite origin in cell-local space at (4, 5) (just below the block).
- var px = blankPixels(16, 16);
- var y: u32 = 6;
- while (y < 8) : (y += 1) {
- var x: u32 = 5;
- while (x < 7) : (x += 1) {
- px[y * 16 + x] = opaque_red;
- }
- }
-
- const cell = Rect{ .x = 0, .y = 0, .w = 8, .h = 8 };
- const r = reduce(&px, 16, 16, cell) orelse return error.Unexpected;
- try expectEqual(@as(u32, 5), r.x);
- try expectEqual(@as(u32, 6), r.y);
-
- const new_origin = originAfterReduce(.{ 4.0, 5.0 }, cell.x, cell.y, r.x, r.y);
- // Origin in reduced-bitmap-local space.
- try expectEqual(@as(f32, -1.0), new_origin[0]);
- try expectEqual(@as(f32, -1.0), new_origin[1]);
- // World pixel preserved.
- try expectEqual(
- @as(f32, 4.0) + @as(f32, @floatFromInt(cell.x)),
- new_origin[0] + @as(f32, @floatFromInt(r.x)),
- );
-}
diff --git a/src/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/backend/plugin_store/compat.zig b/src/backend/plugin_store/compat.zig
new file mode 100644
index 00000000..e1e846f0
--- /dev/null
+++ b/src/backend/plugin_store/compat.zig
@@ -0,0 +1,104 @@
+//! Compatibility matching between the running host and a registry plugin's releases.
+//!
+//! A prebuilt plugin dylib is valid only for one `(abi_fingerprint, os-arch)` pair (see
+//! `docs/PLUGINS.md` § Compatibility), so selection is exact on the fingerprint + arch — not
+//! a semver negotiation. Pure logic; fully unit-tested.
+const std = @import("std");
+const builtin = @import("builtin");
+const registry = @import("registry.zig");
+
+/// The host's `os-arch` key, matching the registry `downloads` object keys
+/// (e.g. "macos-aarch64"). Comptime-known.
+pub fn hostKey() []const u8 {
+ const os = switch (builtin.os.tag) {
+ .macos => "macos",
+ .linux => "linux",
+ .windows => "windows",
+ else => "unknown",
+ };
+ const arch = switch (builtin.cpu.arch) {
+ .aarch64 => "aarch64",
+ .x86_64 => "x86_64",
+ else => "unknown",
+ };
+ return os ++ "-" ++ arch;
+}
+
+/// Parse a "0x…" (or bare hex/decimal) fingerprint string into a u64, or null if malformed.
+pub fn parseFingerprint(s: []const u8) ?u64 {
+ const trimmed = std.mem.trim(u8, s, " \t\r\n");
+ return std.fmt.parseInt(u64, trimmed, 0) catch null;
+}
+
+/// The newest release of `entry` that is loadable on this host: its `abi_fingerprint` equals
+/// `host_fp` **and** it ships a binary for `host_key`. Returns null when none qualifies (the
+/// store shows "needs a rebuild for this Fizzy SDK").
+pub fn selectRelease(
+ entry: registry.PluginEntry,
+ host_fp: u64,
+ host_key: []const u8,
+) ?registry.Release {
+ var best: ?registry.Release = null;
+ var best_ver: std.SemanticVersion = undefined;
+ for (entry.releases) |candidate| {
+ const fp = parseFingerprint(candidate.abi_fingerprint) orelse continue;
+ if (fp != host_fp) continue;
+ if (candidate.downloadFor(host_key) == null) continue;
+ const ver = std.SemanticVersion.parse(candidate.version) catch continue;
+ if (best == null or ver.order(best_ver) == .gt) {
+ best = candidate;
+ best_ver = ver;
+ }
+ }
+ return best;
+}
+
+const testing = std.testing;
+
+/// Build a release whose `downloads` map (allocated with the testing allocator) has an entry
+/// per `keys`. Returned by value; the map's backing memory stays alive until `freeRel`.
+fn rel(version: []const u8, fp: []const u8, keys: []const []const u8) registry.Release {
+ var map: std.json.ArrayHashMap(registry.Download) = .{};
+ for (keys) |k| {
+ map.map.put(testing.allocator, k, .{ .url = "u", .sha256 = "s" }) catch {};
+ }
+ return .{ .version = version, .abi_fingerprint = fp, .downloads = map };
+}
+
+fn freeRel(r: registry.Release) void {
+ var m = r.downloads;
+ m.map.deinit(testing.allocator);
+}
+
+test "selectRelease picks newest matching fingerprint + arch" {
+ const releases = [_]registry.Release{
+ rel("1.0.0", "0x10", &.{"macos-aarch64"}),
+ rel("1.2.0", "0x10", &.{"macos-aarch64"}),
+ rel("1.3.0", "0x99", &.{"macos-aarch64"}), // wrong fingerprint
+ rel("1.1.0", "0x10", &.{"linux-x86_64"}), // wrong arch
+ };
+ defer for (releases) |r| freeRel(r);
+
+ const entry = registry.PluginEntry{ .id = "x", .releases = &releases };
+ const picked = selectRelease(entry, 0x10, "macos-aarch64") orelse return error.NoMatch;
+ try testing.expectEqualStrings("1.2.0", picked.version);
+}
+
+test "selectRelease returns null when fingerprint never matches" {
+ const releases = [_]registry.Release{rel("2.0.0", "0xdead", &.{"macos-aarch64"})};
+ defer for (releases) |r| freeRel(r);
+ const entry = registry.PluginEntry{ .id = "x", .releases = &releases };
+ try testing.expect(selectRelease(entry, 0x10, "macos-aarch64") == null);
+}
+
+test "selectRelease returns null when arch is missing" {
+ const releases = [_]registry.Release{rel("2.0.0", "0x10", &.{"windows-x86_64"})};
+ defer for (releases) |r| freeRel(r);
+ const entry = registry.PluginEntry{ .id = "x", .releases = &releases };
+ try testing.expect(selectRelease(entry, 0x10, "macos-aarch64") == null);
+}
+
+test "parseFingerprint handles 0x and whitespace" {
+ try testing.expectEqual(@as(?u64, 0x146eaf7c2f9605a), parseFingerprint(" 0x0146eaf7c2f9605a\n"));
+ try testing.expect(parseFingerprint("nothex") == null);
+}
diff --git a/src/backend/plugin_store/download.zig b/src/backend/plugin_store/download.zig
new file mode 100644
index 00000000..4a6c3108
--- /dev/null
+++ b/src/backend/plugin_store/download.zig
@@ -0,0 +1,84 @@
+//! Download + SHA-256-verified install of a plugin binary into the plugins dir.
+//!
+//! The downloaded bytes are verified against the manifest's `sha256` before being written, and
+//! the host's ABI fingerprint + id are re-checked at load time (`PluginLoader`). Hashing logic
+//! is unit-tested; the network + filesystem half is exercised by the Chunk 5/7 E2E.
+const std = @import("std");
+
+const Sha256 = std.crypto.hash.sha2.Sha256;
+
+pub const Error = error{ HttpStatus, Sha256Mismatch };
+
+/// Lowercase-hex SHA-256 of `data`.
+pub fn sha256Hex(data: []const u8) [Sha256.digest_length * 2]u8 {
+ var digest: [Sha256.digest_length]u8 = undefined;
+ Sha256.hash(data, &digest, .{});
+ var hex: [Sha256.digest_length * 2]u8 = undefined;
+ const charset = "0123456789abcdef";
+ for (digest, 0..) |b, i| {
+ hex[i * 2] = charset[b >> 4];
+ hex[i * 2 + 1] = charset[b & 0xf];
+ }
+ return hex;
+}
+
+/// True if `data`'s SHA-256 equals `expected_hex` (case-insensitive, surrounding whitespace
+/// ignored). A malformed expectation (wrong length) is treated as a mismatch.
+pub fn matchesSha256(data: []const u8, expected_hex: []const u8) bool {
+ const exp = std.mem.trim(u8, expected_hex, " \t\r\n");
+ if (exp.len != Sha256.digest_length * 2) return false;
+ const actual = sha256Hex(data);
+ for (actual, 0..) |c, i| {
+ if (std.ascii.toLower(exp[i]) != c) return false;
+ }
+ return true;
+}
+
+/// HTTPS GET `url` into memory, verify its SHA-256, then atomically install at `dest_path`
+/// (absolute; e.g. `{config}/plugins/{id}.{ext}`) via a temp file + rename. Rejects and
+/// installs nothing on a non-200 status or a hash mismatch.
+pub fn download(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ url: []const u8,
+ expected_sha256: []const u8,
+ dest_path: []const u8,
+) !void {
+ var client: std.http.Client = .{ .allocator = allocator, .io = io };
+ defer client.deinit();
+
+ var body: std.Io.Writer.Allocating = .init(allocator);
+ defer body.deinit();
+
+ const result = try client.fetch(.{
+ .location = .{ .url = url },
+ .response_writer = &body.writer,
+ });
+ if (result.status != .ok) return error.HttpStatus;
+
+ const data = body.written();
+ if (!matchesSha256(data, expected_sha256)) return error.Sha256Mismatch;
+
+ // Write to a sibling temp file, then rename into place so a crash mid-write never leaves a
+ // half-written dylib at the load path.
+ const tmp_path = try std.fmt.allocPrint(allocator, "{s}.part", .{dest_path});
+ defer allocator.free(tmp_path);
+ try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = tmp_path, .data = data });
+ try std.Io.Dir.renameAbsolute(tmp_path, dest_path, io);
+}
+
+const testing = std.testing;
+
+test "sha256Hex matches a known vector" {
+ // SHA-256("abc")
+ const want = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
+ try testing.expectEqualStrings(want, &sha256Hex("abc"));
+}
+
+test "matchesSha256 accepts correct digest case-insensitively, rejects others" {
+ const want = "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD";
+ try testing.expect(matchesSha256("abc", want));
+ try testing.expect(matchesSha256("abc", " ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad \n"));
+ try testing.expect(!matchesSha256("abcd", want)); // wrong data
+ try testing.expect(!matchesSha256("abc", "deadbeef")); // wrong length
+}
diff --git a/src/backend/plugin_store/registry.zig b/src/backend/plugin_store/registry.zig
new file mode 100644
index 00000000..875ead16
--- /dev/null
+++ b/src/backend/plugin_store/registry.zig
@@ -0,0 +1,131 @@
+//! The plugin-store registry: the typed shape of the hosted `index.json` plus a fetch +
+//! parse path. The index is aggregated from each author's manifest (see PLUGINS_PLAN.md
+//! § B) and served read-only over HTTPS; this module never writes it.
+//!
+//! Pure of dvui/globals — callers pass `allocator` and a `std.Io`. The parse half is
+//! unit-tested; the network half (`fetchIndex`) is exercised by the Chunk 5/7 E2E.
+const std = @import("std");
+
+/// One downloadable binary for a specific `os-arch` (e.g. "macos-aarch64"). `sha256` is the
+/// lowercase hex digest the client verifies after download (see `download.zig`).
+pub const Download = struct {
+ url: []const u8 = "",
+ sha256: []const u8 = "",
+};
+
+/// One published build of a plugin. A plugin version yields one `Release` per Fizzy SDK
+/// build it was compiled against (distinct `abi_fingerprint`); the client picks the entry
+/// whose fingerprint + arch match the running host (see `compat.selectRelease`).
+pub const Release = struct {
+ version: []const u8 = "",
+ min_sdk_version: []const u8 = "",
+ /// "0x…" hex string; the hard compatibility key (matches `sdk.dylib.abi_fingerprint`).
+ abi_fingerprint: []const u8 = "",
+ fizzy_sdk_version: []const u8 = "",
+ published: []const u8 = "",
+ /// `os-arch` → binary. Dynamic JSON object, so parsed via `std.json.ArrayHashMap`.
+ downloads: std.json.ArrayHashMap(Download) = .{},
+
+ /// The binary for `os_arch` (e.g. `compat.hostKey()`), or null when this release has none.
+ pub fn downloadFor(self: Release, os_arch: []const u8) ?Download {
+ return self.downloads.map.get(os_arch);
+ }
+};
+
+pub const PluginEntry = struct {
+ id: []const u8 = "",
+ name: []const u8 = "",
+ description: []const u8 = "",
+ author: []const u8 = "",
+ homepage: []const u8 = "",
+ tags: []const []const u8 = &.{},
+ releases: []const Release = &.{},
+};
+
+pub const Index = struct {
+ schema: u32 = 0,
+ generated: []const u8 = "",
+ plugins: []const PluginEntry = &.{},
+
+ pub fn pluginById(self: Index, id: []const u8) ?PluginEntry {
+ for (self.plugins) |p| {
+ if (std.mem.eql(u8, p.id, id)) return p;
+ }
+ return null;
+ }
+};
+
+/// Parse an `index.json` document. Caller owns the returned `Parsed` and must `deinit` it;
+/// every slice in the `Index` points into its arena.
+pub fn parseIndex(allocator: std.mem.Allocator, bytes: []const u8) !std.json.Parsed(Index) {
+ return std.json.parseFromSlice(Index, allocator, bytes, .{
+ .ignore_unknown_fields = true,
+ .allocate = .alloc_always,
+ });
+}
+
+/// HTTPS GET + parse the registry index. The client auto-rescans system root certs for TLS.
+pub fn fetchIndex(allocator: std.mem.Allocator, io: std.Io, url: []const u8) !std.json.Parsed(Index) {
+ var client: std.http.Client = .{ .allocator = allocator, .io = io };
+ defer client.deinit();
+
+ var body: std.Io.Writer.Allocating = .init(allocator);
+ defer body.deinit();
+
+ const result = try client.fetch(.{
+ .location = .{ .url = url },
+ .response_writer = &body.writer,
+ });
+ if (result.status != .ok) return error.HttpStatus;
+
+ return parseIndex(allocator, body.written());
+}
+
+test "parseIndex reads plugins, releases, and dynamic downloads" {
+ const json =
+ \\{
+ \\ "schema": 1,
+ \\ "generated": "2026-06-25T00:00:00Z",
+ \\ "plugins": [{
+ \\ "id": "markdown", "name": "Markdown Editor",
+ \\ "description": "Edit markdown", "author": "someone",
+ \\ "tags": ["editor"],
+ \\ "releases": [{
+ \\ "version": "1.2.0", "min_sdk_version": "0.5.0",
+ \\ "abi_fingerprint": "0x0146eaf7c2f9605a", "fizzy_sdk_version": "0.5.0",
+ \\ "published": "2026-06-25",
+ \\ "downloads": {
+ \\ "macos-aarch64": { "url": "https://x/m.dylib", "sha256": "ab" },
+ \\ "linux-x86_64": { "url": "https://x/m.so", "sha256": "cd" }
+ \\ }
+ \\ }]
+ \\ }]
+ \\}
+ ;
+ var parsed = try parseIndex(std.testing.allocator, json);
+ defer parsed.deinit();
+
+ const idx = parsed.value;
+ try std.testing.expectEqual(@as(u32, 1), idx.schema);
+ const entry = idx.pluginById("markdown") orelse return error.MissingPlugin;
+ try std.testing.expectEqualStrings("Markdown Editor", entry.name);
+ try std.testing.expectEqual(@as(usize, 1), entry.releases.len);
+
+ const rel = entry.releases[0];
+ try std.testing.expectEqualStrings("0x0146eaf7c2f9605a", rel.abi_fingerprint);
+ const mac = rel.downloadFor("macos-aarch64") orelse return error.MissingDownload;
+ try std.testing.expectEqualStrings("https://x/m.dylib", mac.url);
+ try std.testing.expect(rel.downloadFor("windows-x86_64") == null);
+}
+
+test "parseIndex tolerates unknown fields and missing optionals" {
+ const json =
+ \\{ "schema": 2, "extra_top": true,
+ \\ "plugins": [{ "id": "bare", "surprise": 1 }] }
+ ;
+ var parsed = try parseIndex(std.testing.allocator, json);
+ defer parsed.deinit();
+ const entry = parsed.value.pluginById("bare") orelse return error.MissingPlugin;
+ try std.testing.expectEqual(@as(usize, 0), entry.releases.len);
+ try std.testing.expectEqualStrings("", entry.name);
+}
diff --git a/src/backend/plugin_store/store.zig b/src/backend/plugin_store/store.zig
new file mode 100644
index 00000000..6e349b44
--- /dev/null
+++ b/src/backend/plugin_store/store.zig
@@ -0,0 +1,99 @@
+//! Plugin-store backend: registry fetch + compatibility matching + verified download, plus a
+//! small threaded `Catalog` that owns the latest parsed index. Pure of dvui/globals — the
+//! caller supplies `allocator` + `std.Io` (the app passes `dvui.io`). The store UI (Chunk 5)
+//! drives `Catalog` and tracks per-plugin install state on top of this.
+const std = @import("std");
+
+pub const registry = @import("registry.zig");
+pub const compat = @import("compat.zig");
+pub const download = @import("download.zig");
+
+pub const Index = registry.Index;
+pub const PluginEntry = registry.PluginEntry;
+pub const Release = registry.Release;
+
+/// Lifecycle of the registry index fetch (not a per-plugin install state — that lives in the UI).
+pub const Status = enum(u8) { idle, fetching, ready, failed };
+
+const Parsed = std.json.Parsed(registry.Index);
+
+/// Owns the latest parsed `index.json`, refreshed off the UI thread by a real `std.Thread`
+/// worker. Shared state (`parsed`) is guarded by a `std.Io.Mutex` — the codebase's pattern for
+/// coordinating a `std.Thread` worker with the GUI thread (see pixi's `SaveQueue`): lock with
+/// `dvui.io`, and `join` the worker on `deinit`. The owner must outlive any in-flight refresh (in
+/// the app it is `Editor`-owned, app-lifetime).
+///
+/// Read access goes through `acquire`/`release`: hold the lock across any read of the returned
+/// `Index` so the worker can't free the arena underneath a reader.
+pub const Catalog = struct {
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ url: []const u8,
+ status_value: std.atomic.Value(u8) = .init(@intFromEnum(Status.idle)),
+ mutex: std.Io.Mutex = .init,
+ parsed: ?Parsed = null,
+ /// Handle to the most recent worker; joined on the next `refresh`/`deinit` so finished
+ /// threads are reclaimed and shutdown waits for any in-flight fetch.
+ worker_thread: ?std.Thread = null,
+
+ pub fn init(allocator: std.mem.Allocator, io: std.Io, url: []const u8) Catalog {
+ return .{ .allocator = allocator, .io = io, .url = url };
+ }
+
+ pub fn deinit(self: *Catalog) void {
+ if (self.worker_thread) |t| {
+ t.join();
+ self.worker_thread = null;
+ }
+ if (self.parsed) |*p| p.deinit();
+ self.parsed = null;
+ }
+
+ pub fn status(self: *Catalog) Status {
+ return @enumFromInt(self.status_value.load(.acquire));
+ }
+
+ /// Kick off a background refresh. No-op while one is already in flight.
+ pub fn refresh(self: *Catalog) void {
+ if (self.status() == .fetching) return;
+ if (self.worker_thread) |t| { // reclaim a previous, already-finished worker
+ t.join();
+ self.worker_thread = null;
+ }
+ self.status_value.store(@intFromEnum(Status.fetching), .release);
+ self.worker_thread = std.Thread.spawn(.{}, worker, .{self}) catch {
+ self.status_value.store(@intFromEnum(Status.failed), .release);
+ return;
+ };
+ }
+
+ fn worker(self: *Catalog) void {
+ const fresh = registry.fetchIndex(self.allocator, self.io, self.url) catch {
+ self.status_value.store(@intFromEnum(Status.failed), .release);
+ return;
+ };
+ self.mutex.lockUncancelable(self.io);
+ if (self.parsed) |*p| p.deinit(); // free the previous index; no leak
+ self.parsed = fresh;
+ self.mutex.unlock(self.io);
+ self.status_value.store(@intFromEnum(Status.ready), .release);
+ }
+
+ /// Lock the catalog and return the parsed index (or null if none yet). The slices stay valid
+ /// until the matching `release` — hold the lock across any read of them. Pair with `release`.
+ pub fn acquire(self: *Catalog) ?registry.Index {
+ self.mutex.lockUncancelable(self.io);
+ return if (self.parsed) |p| p.value else null;
+ }
+
+ pub fn release(self: *Catalog) void {
+ self.mutex.unlock(self.io);
+ }
+};
+
+test {
+ // Pull the building blocks' tests into the unit-test target.
+ _ = registry;
+ _ = compat;
+ _ = download;
+}
diff --git a/src/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..728e03f5
--- /dev/null
+++ b/src/core/core.zig
@@ -0,0 +1,41 @@
+//! 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");
+
+/// 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/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/core/paths.zig b/src/core/paths.zig
new file mode 100644
index 00000000..1f09cabc
--- /dev/null
+++ b/src/core/paths.zig
@@ -0,0 +1,79 @@
+const std = @import("std");
+const builtin = @import("builtin");
+
+/// The OS "local configuration" root — fizzy's own canonical mapping (formerly `known-folders`
+/// `.local_configuration`). **Single source of truth, shared by the runtime loader (`configRoot`
+/// below) and the build-time plugin installer (`plugin_sdk.zig`'s `fizzyPluginsDir`)** so a
+/// plugin's install location and the editor's load location can never drift apart. Pure: the
+/// caller supplies the env values it read with its own env API.
+/// macOS `{home}/Library/Application Support`
+/// Linux `{xdg_config_home}` or `{home}/.config`
+/// Windows `{local_app_data}` (FOLDERID_LocalAppData — *not* Roaming/`%APPDATA%`)
+pub fn localConfigRoot(
+ os: std.Target.Os.Tag,
+ allocator: std.mem.Allocator,
+ home: ?[]const u8,
+ xdg_config_home: ?[]const u8,
+ local_app_data: ?[]const u8,
+) !?[]const u8 {
+ return switch (os) {
+ .windows => local_app_data,
+ .macos => if (home) |h|
+ try std.fs.path.join(allocator, &.{ h, "Library", "Application Support" })
+ else
+ null,
+ else => xdg_config_home orelse (if (home) |h|
+ try std.fs.path.join(allocator, &.{ h, ".config" })
+ else
+ null),
+ };
+}
+
+pub fn configRoot(
+ io: std.Io,
+ arena: std.mem.Allocator,
+ environ: std.process.Environ,
+ fallback: []const u8,
+) ![]const u8 {
+ _ = io;
+ if (comptime builtin.target.cpu.arch == .wasm32) return fallback;
+ const get = struct {
+ fn f(env: std.process.Environ, a: std.mem.Allocator, name: []const u8) ?[]const u8 {
+ return env.getAlloc(a, name) catch null;
+ }
+ }.f;
+ const root = (localConfigRoot(
+ builtin.target.os.tag,
+ arena,
+ get(environ, arena, "HOME"),
+ get(environ, arena, "XDG_CONFIG_HOME"),
+ get(environ, arena, "LOCALAPPDATA"),
+ ) catch fallback) orelse fallback;
+ return root;
+}
+
+pub fn configFolder(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ arena: std.mem.Allocator,
+ environ: std.process.Environ,
+ fallback: []const u8,
+) ![]const u8 {
+ const config_root = try configRoot(io, arena, environ, fallback);
+ return std.fs.path.join(allocator, &.{ config_root, "fizzy" }) catch fallback;
+}
+
+pub fn configFolderZ(
+ buf: []u8,
+ io: std.Io,
+ environ: std.process.Environ,
+ fallback: []const u8,
+) ?[:0]const u8 {
+ var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
+ defer arena.deinit();
+ const folder = configFolder(arena.allocator(), io, arena.allocator(), environ, fallback) catch return null;
+ if (folder.len + 1 > buf.len) return null;
+ @memcpy(buf[0..folder.len], folder);
+ buf[folder.len] = 0;
+ return buf[0..folder.len :0];
+}
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/deps/msf_gif/fizzy_msf_gif_wasm.c b/src/deps/msf_gif/fizzy_msf_gif_wasm.c
deleted file mode 100644
index 18ecca45..00000000
--- a/src/deps/msf_gif/fizzy_msf_gif_wasm.c
+++ /dev/null
@@ -1,55 +0,0 @@
-// msf_gif encoder for wasm32-freestanding: no ; route heap through DVUI's
-// exported allocator (same as zstbi / zip shims). SSE2 paths are disabled — not available on wasm.
-
-#include
-
-void *memcpy(void *restrict dest, const void *restrict src, size_t n) {
- unsigned char *d = (unsigned char *)dest;
- const unsigned char *s = (const unsigned char *)src;
- for (size_t i = 0; i < n; ++i) d[i] = s[i];
- return dest;
-}
-
-void *memset(void *s, int c, size_t n) {
- unsigned char *p = (unsigned char *)s;
- const unsigned char byte = (unsigned char)c;
- for (size_t i = 0; i < n; ++i) p[i] = byte;
- return s;
-}
-
-extern void *dvui_c_alloc(size_t size);
-extern void dvui_c_free(void *ptr);
-
-static void *fizzy_msf_gif_malloc(size_t newSize) {
- return dvui_c_alloc(newSize);
-}
-
-static void *fizzy_msf_gif_realloc(void *oldMemory, size_t oldSize, size_t newSize) {
- if (newSize == 0) {
- dvui_c_free(oldMemory);
- return NULL;
- }
- void *ptr = dvui_c_alloc(newSize);
- if (ptr == NULL) return NULL;
- if (oldMemory != NULL && oldSize > 0) {
- const size_t copy = oldSize < newSize ? oldSize : newSize;
- unsigned char *dst = (unsigned char *)ptr;
- const unsigned char *src = (const unsigned char *)oldMemory;
- for (size_t i = 0; i < copy; ++i) dst[i] = src[i];
- dvui_c_free(oldMemory);
- }
- return ptr;
-}
-
-static void fizzy_msf_gif_free(void *oldMemory) {
- dvui_c_free(oldMemory);
-}
-
-#define MSF_GIF_MALLOC(contextPointer, newSize) fizzy_msf_gif_malloc(newSize)
-#define MSF_GIF_REALLOC(contextPointer, oldMemory, oldSize, newSize) fizzy_msf_gif_realloc(oldMemory, oldSize, newSize)
-#define MSF_GIF_FREE(contextPointer, oldMemory, oldSize) fizzy_msf_gif_free(oldMemory)
-
-#define MSF_GIF_IMPL
-#define MSF_USE_ALPHA
-#define MSF_GIF_NO_SSE2
-#include "msf_gif.h"
diff --git a/src/deps/msf_gif/msf_gif.c b/src/deps/msf_gif/msf_gif.c
deleted file mode 100644
index 27b8c41a..00000000
--- a/src/deps/msf_gif/msf_gif.c
+++ /dev/null
@@ -1,3 +0,0 @@
-#define MSF_GIF_IMPL
-#define MSF_USE_ALPHA
-#include "msf_gif.h"
\ No newline at end of file
diff --git a/src/deps/msf_gif/msf_gif.h b/src/deps/msf_gif/msf_gif.h
deleted file mode 100644
index 395f1c84..00000000
--- a/src/deps/msf_gif/msf_gif.h
+++ /dev/null
@@ -1,735 +0,0 @@
-/*
-HOW TO USE:
-
- In exactly one translation unit (.c or .cpp file), #define MSF_GIF_IMPL before including the header, like so:
-
- #define MSF_GIF_IMPL
- #include "msf_gif.h"
-
- Everywhere else, just include the header like normal.
-
-
-USAGE EXAMPLE:
-
- int width = 480, height = 320, centisecondsPerFrame = 5, quality = 16;
- MsfGifState gifState = {};
- // msf_gif_bgra_flag = true; //optionally, set this flag if your pixels are in BGRA format instead of RGBA
- // msf_gif_alpha_threshold = 128; //optionally, enable transparency (see function documentation below for details)
- msf_gif_begin(&gifState, width, height);
- msf_gif_frame(&gifState, ..., centisecondsPerFrame, quality, width * 4); //frame 1
- msf_gif_frame(&gifState, ..., centisecondsPerFrame, quality, width * 4); //frame 2
- msf_gif_frame(&gifState, ..., centisecondsPerFrame, quality, width * 4); //frame 3, etc...
- MsfGifResult result = msf_gif_end(&gifState);
- if (result.data) {
- FILE * fp = fopen("MyGif.gif", "wb");
- fwrite(result.data, result.dataSize, 1, fp);
- fclose(fp);
- }
- msf_gif_free(result);
-
-Detailed function documentation can be found in the header section below.
-
-
-ERROR HANDLING:
-
- If memory allocation fails, the functions will signal the error via their return values.
- If one function call fails, the library will free all of its allocations,
- and all subsequent calls will safely no-op and return 0 until the next call to `msf_gif_begin()`.
- Therefore, it's safe to check only the return value of `msf_gif_end()`.
-
-
-REPLACING MALLOC:
-
- This library uses malloc+realloc+free internally for memory allocation.
- To facilitate integration with custom memory allocators, these calls go through macros, which can be redefined.
- The expected function signature equivalents of the macros are as follows:
-
- void * MSF_GIF_MALLOC(void * context, size_t newSize)
- void * MSF_GIF_REALLOC(void * context, void * oldMemory, size_t oldSize, size_t newSize)
- void MSF_GIF_FREE(void * context, void * oldMemory, size_t oldSize)
-
- If your allocator needs a context pointer, you can set the `customAllocatorContext` field of the MsfGifState struct
- before calling msf_gif_begin(), and it will be passed to all subsequent allocator macro calls.
-
- The maximum number of bytes the library will allocate to encode a single gif is bounded by the following formula:
- `(2 * 1024 * 1024) + (128 * 1024) + (width * height * 8) + ((2048 + width * height * 1.5) * 2 * frameCount)`
- The peak heap memory usage in bytes, if using a general-purpose heap allocator, is bounded by the following formula:
- `(2 * 1024 * 1024) + (128 * 1024) + (width * height * 11) + 2048 + (16 * frameCount) + (2 * sizeOfResultingGif)
-
-
-See end of file for license information.
-*/
-
-//version 2.4
-
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-/// HEADER ///
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-#ifndef MSF_GIF_H
-#define MSF_GIF_H
-
-#include
-#include
-
-typedef struct {
- void * data;
- size_t dataSize;
-
- size_t allocSize; //internal use
- void * contextPointer; //internal use
-} MsfGifResult;
-
-typedef struct { //internal use
- uint32_t * pixels;
- int depth, count, rbits, gbits, bbits;
-} MsfCookedFrame;
-
-typedef struct MsfGifBuffer { //internal use
- struct MsfGifBuffer * next;
- size_t size;
- uint8_t data[1];
-} MsfGifBuffer;
-
-typedef size_t (* MsfGifFileWriteFunc) (const void * buffer, size_t size, size_t count, void * stream);
-typedef struct { //internal use
- MsfGifFileWriteFunc fileWriteFunc;
- void * fileWriteData;
- MsfCookedFrame previousFrame;
- MsfCookedFrame currentFrame;
- int16_t * lzwMem;
- uint8_t * tlbMem;
- uint8_t * usedMem;
- MsfGifBuffer * listHead;
- MsfGifBuffer * listTail;
- int width, height;
- void * customAllocatorContext;
- int framesSubmitted; //needed for transparency to work correctly (because we reach into the previous frame)
-} MsfGifState;
-
-#ifdef __cplusplus
-extern "C" {
-#endif //__cplusplus
-
-/**
- * @param width Image width in pixels.
- * @param height Image height in pixels.
- * @return Non-zero on success, 0 on error.
- */
-int msf_gif_begin(MsfGifState * handle, int width, int height);
-
-/**
- * @param pixelData Pointer to raw framebuffer data. Rows must be contiguous in memory, in RGBA8 format
- * (or BGRA8 if you have set `msf_gif_bgra_flag = true`).
- * Note: This function does NOT free `pixelData`. You must free it yourself afterwards.
- * @param centiSecondsPerFrame How many hundredths of a second this frame should be displayed for.
- * Note: This being specified in centiseconds is a limitation of the GIF format.
- * @param quality This parameter limits the maximum color accuracy for quantization.
- * Actual color accuracy varies dynamically based on how many colors are used in the frame.
- * `quality` is clamped between 1 and 16. The recommended default is 16.
- * Lowering this value can result in smaller gifs and slightly faster exports,
- * but the resulting gifs may look noticeably worse with a more extreme dither pattern.
- * @param pitchInBytes The number of bytes from the beginning of one row of pixels to the beginning of the next.
- * If zero, the rows will be assumed to be contiguous (equivalent to `width * 4`).
- * If negative, the rows will be reversed, thus flipping the image vertically.
- * Regardless, `pixelData` should always point to the *first* row in memory, not the last.
- * @return Non-zero on success, 0 on error.
- */
-int msf_gif_frame(MsfGifState * handle, uint8_t * pixelData, int centiSecondsPerFrame, int quality, int pitchInBytes);
-
-/**
- * @return A block of memory containing the gif file data, or NULL on error.
- * You are responsible for freeing this via `msf_gif_free()`.
- */
-MsfGifResult msf_gif_end(MsfGifState * handle);
-
-/**
- * @param result The MsfGifResult struct, verbatim as it was returned from `msf_gif_end()`.
- */
-void msf_gif_free(MsfGifResult result);
-
-//The gif format only supports 1-bit transparency, meaning a pixel will either be fully transparent or fully opaque.
-//Pixels with an alpha value less than the alpha threshold will be treated as transparent.
-//To enable exporting transparent gifs, set it to a value between 1 and 255 (inclusive) before calling msf_gif_frame().
-//Setting it to 0 causes the alpha channel to be ignored. Its initial value is 0.
-extern int msf_gif_alpha_threshold;
-
-//Set `msf_gif_bgra_flag = true` before calling `msf_gif_frame()` if your pixels are in BGRA byte order instead of RBGA.
-extern int msf_gif_bgra_flag;
-
-
-
-//TO-FILE FUNCTIONS
-//These functions are equivalent to the ones above, but they write results to a file incrementally,
-//instead of building a buffer in memory. This can result in lower memory usage when saving large gifs,
-//because memory usage is bounded by only the size of a single frame, and is not dependent on the number of frames.
-//There is currently no reason to use these unless you are on a memory-constrained platform.
-//If in doubt about which API to use, for now you should use the normal (non-file) functions above.
-//The signature of MsfGifFileWriteFunc matches fwrite for convenience, so that you can use the C file API like so:
-// FILE * fp = fopen("MyGif.gif", "wb");
-// msf_gif_begin_to_file(&handle, width, height, (MsfGifFileWriteFunc) fwrite, (void *) fp);
-// msf_gif_frame_to_file(...)
-// msf_gif_end_to_file(&handle);
-// fclose(fp);
-//If you use a custom file write function, you must take care to return the same values that fwrite() would return.
-//Note that all three functions will potentially write to the file.
-int msf_gif_begin_to_file(MsfGifState * handle, int width, int height, MsfGifFileWriteFunc func, void * filePointer);
-int msf_gif_frame_to_file(MsfGifState * handle, uint8_t * pixelData, int centiSecondsPerFrame, int quality, int pitchInBytes);
-int msf_gif_end_to_file(MsfGifState * handle); //returns 0 on error and non-zero on success
-
-#ifdef __cplusplus
-}
-#endif //__cplusplus
-
-#endif //MSF_GIF_H
-
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-/// IMPLEMENTATION ///
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-#ifdef MSF_GIF_IMPL
-#ifndef MSF_GIF_ALREADY_IMPLEMENTED_IN_THIS_TRANSLATION_UNIT
-#define MSF_GIF_ALREADY_IMPLEMENTED_IN_THIS_TRANSLATION_UNIT
-
-//ensure the library user has either defined all of malloc/realloc/free, or none
-#if defined(MSF_GIF_MALLOC) && defined(MSF_GIF_REALLOC) && defined(MSF_GIF_FREE) //ok
-#elif !defined(MSF_GIF_MALLOC) && !defined(MSF_GIF_REALLOC) && !defined(MSF_GIF_FREE) //ok
-#else
-#error "You must either define all of MSF_GIF_MALLOC, MSF_GIF_REALLOC, and MSF_GIF_FREE, or define none of them"
-#endif
-
-//provide default allocator definitions that redirect to the standard global allocator
-#if !defined(MSF_GIF_MALLOC)
-#include //malloc, etc.
-#define MSF_GIF_MALLOC(contextPointer, newSize) malloc(newSize)
-#define MSF_GIF_REALLOC(contextPointer, oldMemory, oldSize, newSize) realloc(oldMemory, newSize)
-#define MSF_GIF_FREE(contextPointer, oldMemory, oldSize) free(oldMemory)
-#endif
-
-//instrumentation for capturing profiling traces (useless for the library user, but useful for the library author)
-#ifdef MSF_GIF_ENABLE_TRACING
-#define MsfTimeFunc TimeFunc
-#define MsfTimeLoop TimeLoop
-#define msf_init_profiling_thread init_profiling_thread
-#else
-#define MsfTimeFunc
-#define MsfTimeLoop(name)
-#define msf_init_profiling_thread()
-#endif //MSF_GIF_ENABLE_TRACING
-
-#include //memcpy
-
-//TODO: use compiler-specific notation to force-inline functions currently marked inline
-#if defined(__GNUC__) //gcc, clang
-static inline int msf_bit_log(int i) { return 32 - __builtin_clz(i); }
-#elif defined(_MSC_VER) //msvc
-#include
-static inline int msf_bit_log(int i) { unsigned long idx; _BitScanReverse(&idx, i); return idx + 1; }
-#else //fallback implementation for other compilers
-//from https://stackoverflow.com/a/31718095/3064745 - thanks!
-static inline int msf_bit_log(int i) {
- static const int MultiplyDeBruijnBitPosition[32] = {
- 0, 9, 1, 10, 13, 21, 2, 29, 11, 14, 16, 18, 22, 25, 3, 30,
- 8, 12, 20, 28, 15, 17, 24, 7, 19, 27, 23, 6, 26, 5, 4, 31,
- };
- i |= i >> 1;
- i |= i >> 2;
- i |= i >> 4;
- i |= i >> 8;
- i |= i >> 16;
- return MultiplyDeBruijnBitPosition[(uint32_t)(i * 0x07C4ACDDU) >> 27] + 1;
-}
-#endif
-static inline int msf_imin(int a, int b) { return a < b? a : b; }
-static inline int msf_imax(int a, int b) { return b < a? a : b; }
-
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-/// Frame Cooking ///
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-#if (defined (__SSE2__) || defined (_M_X64) || _M_IX86_FP == 2) && !defined(MSF_GIF_NO_SSE2)
-#include
-#endif
-
-int msf_gif_alpha_threshold = 0;
-int msf_gif_bgra_flag = 0;
-
-static void msf_cook_frame(MsfCookedFrame * frame, uint8_t * raw, uint8_t * used,
- int width, int height, int pitch, int depth)
-{ MsfTimeFunc
- //bit depth for each channel
- const static int rdepthsArray[17] = { 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5 };
- const static int gdepthsArray[17] = { 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6 };
- const static int bdepthsArray[17] = { 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5 };
- //this extra level of indirection looks unnecessary but we need to explicitly decay the arrays to pointers
- //in order to be able to swap them because of C's annoying not-quite-pointers, not-quite-value-types stack arrays.
- const int * rdepths = msf_gif_bgra_flag? bdepthsArray : rdepthsArray;
- const int * gdepths = gdepthsArray;
- const int * bdepths = msf_gif_bgra_flag? rdepthsArray : bdepthsArray;
-
- const static int ditherKernel[16] = {
- 0 << 12, 8 << 12, 2 << 12, 10 << 12,
- 12 << 12, 4 << 12, 14 << 12, 6 << 12,
- 3 << 12, 11 << 12, 1 << 12, 9 << 12,
- 15 << 12, 7 << 12, 13 << 12, 5 << 12,
- };
-
- uint32_t * cooked = frame->pixels;
- int count = 0;
- MsfTimeLoop("do") do {
- int rbits = rdepths[depth], gbits = gdepths[depth], bbits = bdepths[depth];
- int paletteSize = (1 << (rbits + gbits + bbits)) + 1;
- memset(used, 0, paletteSize * sizeof(uint8_t));
-
- //TODO: document what this math does and why it's correct
- int rdiff = (1 << (8 - rbits)) - 1;
- int gdiff = (1 << (8 - gbits)) - 1;
- int bdiff = (1 << (8 - bbits)) - 1;
- short rmul = (short) ((255.0f - rdiff) / 255.0f * 257);
- short gmul = (short) ((255.0f - gdiff) / 255.0f * 257);
- short bmul = (short) ((255.0f - bdiff) / 255.0f * 257);
-
- int gmask = ((1 << gbits) - 1) << rbits;
- int bmask = ((1 << bbits) - 1) << rbits << gbits;
-
- MsfTimeLoop("cook") for (int y = 0; y < height; ++y) {
- int x = 0;
-
- #if (defined (__SSE2__) || defined (_M_X64) || _M_IX86_FP == 2) && !defined(MSF_GIF_NO_SSE2)
- __m128i k = _mm_loadu_si128((__m128i *) &ditherKernel[(y & 3) * 4]);
- __m128i k2 = _mm_or_si128(_mm_srli_epi32(k, rbits), _mm_slli_epi32(_mm_srli_epi32(k, bbits), 16));
- for (; x < width - 3; x += 4) {
- uint8_t * pixels = &raw[y * pitch + x * 4];
- __m128i p = _mm_loadu_si128((__m128i *) pixels);
-
- __m128i rb = _mm_and_si128(p, _mm_set1_epi32(0x00FF00FF));
- __m128i rb1 = _mm_mullo_epi16(rb, _mm_set_epi16(bmul, rmul, bmul, rmul, bmul, rmul, bmul, rmul));
- __m128i rb2 = _mm_adds_epu16(rb1, k2);
- __m128i r3 = _mm_srli_epi32(_mm_and_si128(rb2, _mm_set1_epi32(0x0000FFFF)), 16 - rbits);
- __m128i b3 = _mm_and_si128(_mm_srli_epi32(rb2, 32 - rbits - gbits - bbits), _mm_set1_epi32(bmask));
-
- __m128i g = _mm_and_si128(_mm_srli_epi32(p, 8), _mm_set1_epi32(0x000000FF));
- __m128i g1 = _mm_mullo_epi16(g, _mm_set1_epi32(gmul));
- __m128i g2 = _mm_adds_epu16(g1, _mm_srli_epi32(k, gbits));
- __m128i g3 = _mm_and_si128(_mm_srli_epi32(g2, 16 - rbits - gbits), _mm_set1_epi32(gmask));
-
- __m128i out = _mm_or_si128(_mm_or_si128(r3, g3), b3);
-
- //mask in transparency based on threshold
- //NOTE: we can theoretically do a sub instead of srli by doing an unsigned compare via bias
- // to maybe save a TINY amount of throughput? but lol who cares maybe I'll do it later -m
- __m128i invAlphaMask = _mm_cmplt_epi32(_mm_srli_epi32(p, 24), _mm_set1_epi32(msf_gif_alpha_threshold));
- out = _mm_or_si128(_mm_and_si128(invAlphaMask, _mm_set1_epi32(paletteSize - 1)), _mm_andnot_si128(invAlphaMask, out));
-
- //TODO: does storing this as a __m128i then reading it back as a uint32_t violate strict aliasing?
- uint32_t * c = &cooked[y * width + x];
- _mm_storeu_si128((__m128i *) c, out);
- }
- #endif
-
- //scalar cleanup loop
- for (; x < width; ++x) {
- uint8_t * p = &raw[y * pitch + x * 4];
-
- //transparent pixel if alpha is low
- if (p[3] < msf_gif_alpha_threshold) {
- cooked[y * width + x] = paletteSize - 1;
- continue;
- }
-
- int dx = x & 3, dy = y & 3;
- int k = ditherKernel[dy * 4 + dx];
- cooked[y * width + x] =
- (msf_imin(65535, p[2] * bmul + (k >> bbits)) >> (16 - rbits - gbits - bbits) & bmask) |
- (msf_imin(65535, p[1] * gmul + (k >> gbits)) >> (16 - rbits - gbits ) & gmask) |
- msf_imin(65535, p[0] * rmul + (k >> rbits)) >> (16 - rbits );
- }
- }
-
- count = 0;
- MsfTimeLoop("mark") for (int i = 0; i < width * height; ++i) {
- used[cooked[i]] = 1;
- }
-
- //count used colors, transparent is ignored
- MsfTimeLoop("count") for (int j = 0; j < paletteSize - 1; ++j) {
- count += used[j];
- }
- } while (count >= 256 && --depth);
-
- MsfCookedFrame ret = { cooked, depth, count, rdepths[depth], gdepths[depth], bdepths[depth] };
- *frame = ret;
-}
-
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-/// Frame Compression ///
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-static inline void msf_put_code(uint8_t * * writeHead, uint32_t * blockBits, int len, uint32_t code) {
- //insert new code into block buffer
- int idx = *blockBits / 8;
- int bit = *blockBits % 8;
- (*writeHead)[idx + 0] |= code << bit ;
- (*writeHead)[idx + 1] |= code >> ( 8 - bit);
- (*writeHead)[idx + 2] |= code >> (16 - bit);
- *blockBits += len;
-
- //prep the next block buffer if the current one is full
- if (*blockBits >= 256 * 8) {
- *blockBits -= 255 * 8;
- (*writeHead) += 256;
- (*writeHead)[2] = (*writeHead)[1];
- (*writeHead)[1] = (*writeHead)[0];
- (*writeHead)[0] = 255;
- memset((*writeHead) + 4, 0, 256);
- }
-}
-
-typedef struct {
- int16_t * data;
- int len;
- int stride;
-} MsfStridedList;
-
-static inline void msf_lzw_reset(MsfStridedList * lzw, int tableSize, int stride) { MsfTimeFunc
- memset(lzw->data, 0xFF, 4096 * stride * sizeof(int16_t));
- lzw->len = tableSize + 2;
- lzw->stride = stride;
-}
-
-//PERF TODO: is it possible to use the same array for both `used` and `tlb`?
-static MsfGifBuffer * msf_compress_frame(void * allocContext, int width, int height, int centiSeconds,
- MsfCookedFrame frame, MsfGifState * handle, uint8_t * used, uint8_t * tlb, int16_t * lzwMem)
-{ MsfTimeFunc
- //NOTE: We reserve enough memory for the theoretical worst case upfront because it's a reasonable amount,
- // and prevents us from ever having to check size or realloc during compression.
- //NOTE: headers + color table + 12 bits per pixel (since 12 bits is the maximum code size)
- // + space for at least one full Image Data block (needed for small images since we zero a whole block at a time)
- int maxBufSize = offsetof(MsfGifBuffer, data) + 32 + 256 * 3 + width * height * 3 / 2 + (256 + 4);
- MsfGifBuffer * buffer = (MsfGifBuffer *) MSF_GIF_MALLOC(allocContext, maxBufSize);
- if (!buffer) { return NULL; }
- uint8_t * writeHead = buffer->data;
- MsfStridedList lzw = { lzwMem };
-
- //allocate tlb
- int totalBits = frame.rbits + frame.gbits + frame.bbits;
- int tlbSize = (1 << totalBits) + 1;
-
- //generate palette
- typedef struct { uint8_t r, g, b; } Color3;
- Color3 table[256] = { {0} };
- int tableIdx = 1; //we start counting at 1 because 0 is the transparent color
- //transparent is always last in the table
- tlb[tlbSize-1] = 0;
- MsfTimeLoop("table") for (int i = 0; i < tlbSize-1; ++i) {
- if (used[i]) {
- tlb[i] = tableIdx;
- int rmask = (1 << frame.rbits) - 1;
- int gmask = (1 << frame.gbits) - 1;
- //isolate components
- int r = i & rmask;
- int g = i >> frame.rbits & gmask;
- int b = i >> (frame.rbits + frame.gbits);
- //shift into highest bits
- r <<= 8 - frame.rbits;
- g <<= 8 - frame.gbits;
- b <<= 8 - frame.bbits;
- table[tableIdx].r = r | r >> frame.rbits | r >> (frame.rbits * 2) | r >> (frame.rbits * 3);
- table[tableIdx].g = g | g >> frame.gbits | g >> (frame.gbits * 2) | g >> (frame.gbits * 3);
- table[tableIdx].b = b | b >> frame.bbits | b >> (frame.bbits * 2) | b >> (frame.bbits * 3);
- if (msf_gif_bgra_flag) {
- uint8_t temp = table[tableIdx].r;
- table[tableIdx].r = table[tableIdx].b;
- table[tableIdx].b = temp;
- }
- ++tableIdx;
- }
- }
- int hasTransparentPixels = used[tlbSize-1];
-
- //SPEC: "Because of some algorithmic constraints however, black & white images which have one color bit
- // must be indicated as having a code size of 2."
- int tableBits = msf_imax(2, msf_bit_log(tableIdx - 1));
- int tableSize = 1 << tableBits;
- //NOTE: we don't just compare `depth` field here because it will be wrong for the first frame and we will segfault
- MsfCookedFrame previous = handle->previousFrame;
- int hasSamePal = frame.rbits == previous.rbits && frame.gbits == previous.gbits && frame.bbits == previous.bbits;
- int framesCompatible = hasSamePal && !hasTransparentPixels;
-
- //write the Graphics `Control Extension` and `Image Descriptor` blocks
- //NOTE: because __attribute__((__packed__)) is annoyingly compiler-specific, we do this unreadable weirdness
- char headerBytes[19] = "\x21\xF9\x04\x05\0\0\0\0" "\x2C\0\0\0\0\0\0\0\0\x80";
- //NOTE: we need to check the frame number because if we reach into the buffer prior to the first frame,
- // we'll just clobber the file header instead, which is a bug
- if (hasTransparentPixels && handle->framesSubmitted > 0) {
- handle->listTail->data[3] = 0x09; //set the previous frame's disposal to background, so transparency is possible
- }
- memcpy(&headerBytes[4], ¢iSeconds, 2);
- memcpy(&headerBytes[13], &width, 2);
- memcpy(&headerBytes[15], &height, 2);
- headerBytes[17] |= tableBits - 1;
- memcpy(writeHead, headerBytes, 18);
- writeHead += 18;
-
- //write Local Color Table
- memcpy(writeHead, table, tableSize * sizeof(Color3));
- writeHead += tableSize * sizeof(Color3);
- *writeHead++ = tableBits;
-
- //prep first Image Data block (analogous to what we do at the end of msf_put_code())
- memset(writeHead, 0, 256 + 4); //we write up to 4 bytes ahead of the end of the block - see msf_put_code()
- writeHead[0] = 255;
- uint32_t blockBits = 8; //relative to block.head
-
- //SPEC: "Encoders should output a Clear code as the first code of each image data stream."
- msf_lzw_reset(&lzw, tableSize, tableIdx);
- msf_put_code(&writeHead, &blockBits, msf_bit_log(lzw.len - 1), tableSize);
-
- int lastCode = framesCompatible && frame.pixels[0] == previous.pixels[0]? 0 : tlb[frame.pixels[0]];
- MsfTimeLoop("compress") for (int i = 1; i < width * height; ++i) {
- //PERF: branching vs. branchless version of this line is observed to have no discernable impact on speed
- int color = framesCompatible && frame.pixels[i] == previous.pixels[i]? 0 : tlb[frame.pixels[i]];
- int code = (&lzw.data[lastCode * lzw.stride])[color];
- if (code < 0) {
- //write to code stream
- int codeBits = msf_bit_log(lzw.len - 1);
- msf_put_code(&writeHead, &blockBits, codeBits, lastCode);
-
- if (lzw.len > 4095) {
- //reset buffer code table
- msf_put_code(&writeHead, &blockBits, codeBits, tableSize);
- msf_lzw_reset(&lzw, tableSize, tableIdx);
- } else {
- (&lzw.data[lastCode * lzw.stride])[color] = lzw.len;
- ++lzw.len;
- }
-
- lastCode = color;
- } else {
- lastCode = code;
- }
- }
-
- //write code for leftover index buffer contents, then the end code
- msf_put_code(&writeHead, &blockBits, msf_imin(12, msf_bit_log(lzw.len - 1)), lastCode);
- msf_put_code(&writeHead, &blockBits, msf_imin(12, msf_bit_log(lzw.len)), tableSize + 1);
-
- //flush remaining data
- if (blockBits > 8) {
- int bytes = (blockBits + 7) / 8; //round up
- writeHead[0] = bytes - 1;
- writeHead += bytes;
- }
- *writeHead++ = 0; //terminating block
-
- //fill in buffer header and shrink buffer to fit data
- buffer->next = NULL;
- buffer->size = writeHead - buffer->data;
- MsfGifBuffer * moved =
- (MsfGifBuffer *) MSF_GIF_REALLOC(allocContext, buffer, maxBufSize, offsetof(MsfGifBuffer, data) + buffer->size);
- if (!moved) { MSF_GIF_FREE(allocContext, buffer, maxBufSize); return NULL; }
- return moved;
-}
-
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-/// To-memory API ///
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-static const int lzwAllocSize = 4096 * 256 * sizeof(int16_t);
-static const int tlbAllocSize = ((1 << 16) + 1) * sizeof(uint8_t);
-static const int usedAllocSize = ((1 << 16) + 1) * sizeof(uint8_t);
-
-//NOTE: by C standard library conventions, freeing NULL should be a no-op,
-// but just in case the user's custom free doesn't follow that rule, we do null checks on our end as well.
-static void msf_free_gif_state(MsfGifState * handle) {
- if (handle->previousFrame.pixels) MSF_GIF_FREE(handle->customAllocatorContext, handle->previousFrame.pixels,
- handle->width * handle->height * sizeof(uint32_t));
- if (handle->currentFrame.pixels) MSF_GIF_FREE(handle->customAllocatorContext, handle->currentFrame.pixels,
- handle->width * handle->height * sizeof(uint32_t));
- if (handle->lzwMem) MSF_GIF_FREE(handle->customAllocatorContext, handle->lzwMem, lzwAllocSize);
- if (handle->tlbMem) MSF_GIF_FREE(handle->customAllocatorContext, handle->tlbMem, tlbAllocSize);
- if (handle->usedMem) MSF_GIF_FREE(handle->customAllocatorContext, handle->usedMem, usedAllocSize);
- for (MsfGifBuffer * node = handle->listHead; node;) {
- MsfGifBuffer * next = node->next; //NOTE: we have to copy the `next` pointer BEFORE freeing the node holding it
- MSF_GIF_FREE(handle->customAllocatorContext, node, offsetof(MsfGifBuffer, data) + node->size);
- node = next;
- }
- handle->listHead = NULL; //this implicitly marks the handle as invalid until the next msf_gif_begin() call
-}
-
-int msf_gif_begin(MsfGifState * handle, int width, int height) { MsfTimeFunc
- //To help avoid potential overflow errors, let's just not even try to support images larger than 1GB in size.
- //And let's also reject images with width or height more or less than what the gif format itself supports.
- const int MAX_PIXELS = 268435456; //2^30 / 4 = 1GB / bytesPerPixel
- if (width < 1 || height < 1 || width > 65535 || height > 65535 || width >= MAX_PIXELS / height) {
- handle->listHead = NULL; //this implicitly marks the handle as invalid until the next msf_gif_begin() call
- return 0;
- }
-
- //NOTE: we cannot stomp the entire struct to zero because we must preserve `customAllocatorContext`.
- MsfCookedFrame empty = {0}; //god I hate MSVC...
- handle->previousFrame = empty;
- handle->currentFrame = empty;
- handle->width = width;
- handle->height = height;
- handle->framesSubmitted = 0;
-
- //NOTE: Default stack sizes for some platforms are very small. Emscripten in particular uses a 64k stack by default.
- // So anything that large or larger must be allocated on the heap, even if its maximum size is compile-time known.
- // The `lzw`, `tlb`, and `used` arrays are 2MB, 64KB, and 64KB respectively, so we allocate them here.
- // We could make them arrays at global scope, but that would create problems if the library is used from multiple threads.
- handle->lzwMem = (int16_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, lzwAllocSize);
- handle->tlbMem = (uint8_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, tlbAllocSize);
- handle->usedMem = (uint8_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, usedAllocSize);
- handle->previousFrame.pixels =
- (uint32_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, handle->width * handle->height * sizeof(uint32_t));
- handle->currentFrame.pixels =
- (uint32_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, handle->width * handle->height * sizeof(uint32_t));
-
- //setup header buffer header (lol)
- handle->listHead = (MsfGifBuffer *) MSF_GIF_MALLOC(handle->customAllocatorContext, offsetof(MsfGifBuffer, data) + 32);
- if (!handle->listHead || !handle->lzwMem || !handle->previousFrame.pixels || !handle->currentFrame.pixels) {
- msf_free_gif_state(handle);
- return 0;
- }
- handle->listTail = handle->listHead;
- handle->listHead->next = NULL;
- handle->listHead->size = 32;
-
- //NOTE: because __attribute__((__packed__)) is annoyingly compiler-specific, we do this unreadable weirdness
- char headerBytes[33] = "GIF89a\0\0\0\0\x70\0\0" "\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0\0";
- memcpy(&headerBytes[6], &width, 2);
- memcpy(&headerBytes[8], &height, 2);
- memcpy(handle->listHead->data, headerBytes, 32);
- return 1;
-}
-
-int msf_gif_frame(MsfGifState * handle, uint8_t * pixelData, int centiSecondsPerFrame, int quality, int pitchInBytes)
-{ MsfTimeFunc
- if (!handle->listHead) { return 0; }
- //TODO: sanity-check `pitchInBytes`
-
- quality = msf_imax(1, msf_imin(16, quality));
- if (pitchInBytes == 0) pitchInBytes = handle->width * 4;
- if (pitchInBytes < 0) pixelData -= pitchInBytes * (handle->height - 1);
-
- msf_cook_frame(&handle->currentFrame, pixelData, handle->usedMem, handle->width, handle->height, pitchInBytes,
- msf_imin(quality, handle->previousFrame.depth + 160 / msf_imax(1, handle->previousFrame.count)));
-
- MsfGifBuffer * buffer = msf_compress_frame(handle->customAllocatorContext, handle->width, handle->height,
- centiSecondsPerFrame, handle->currentFrame, handle, handle->usedMem, handle->tlbMem, handle->lzwMem);
- if (!buffer) { msf_free_gif_state(handle); return 0; }
- handle->listTail->next = buffer;
- handle->listTail = buffer;
-
- //swap current and previous frames
- MsfCookedFrame tmp = handle->previousFrame;
- handle->previousFrame = handle->currentFrame;
- handle->currentFrame = tmp;
-
- handle->framesSubmitted += 1;
- return 1;
-}
-
-MsfGifResult msf_gif_end(MsfGifState * handle) { MsfTimeFunc
- if (!handle->listHead) { MsfGifResult empty = {0}; return empty; }
-
- //first pass: determine total size
- size_t total = 1; //1 byte for trailing marker
- for (MsfGifBuffer * node = handle->listHead; node; node = node->next) { total += node->size; }
-
- //second pass: write data
- uint8_t * buffer = (uint8_t *) MSF_GIF_MALLOC(handle->customAllocatorContext, total);
- if (buffer) {
- uint8_t * writeHead = buffer;
- for (MsfGifBuffer * node = handle->listHead; node; node = node->next) {
- memcpy(writeHead, node->data, node->size);
- writeHead += node->size;
- }
- *writeHead++ = 0x3B;
- }
-
- //third pass: free buffers
- msf_free_gif_state(handle);
-
- MsfGifResult ret = { buffer, total, total, handle->customAllocatorContext };
- return ret;
-}
-
-void msf_gif_free(MsfGifResult result) { MsfTimeFunc
- if (result.data) { MSF_GIF_FREE(result.contextPointer, result.data, result.allocSize); }
-}
-
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-/// To-file API ///
-////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-int msf_gif_begin_to_file(MsfGifState * handle, int width, int height, MsfGifFileWriteFunc func, void * filePointer) {
- handle->fileWriteFunc = func;
- handle->fileWriteData = filePointer;
- return msf_gif_begin(handle, width, height);
-}
-
-int msf_gif_frame_to_file(MsfGifState * handle, uint8_t * pixelData, int centiSecondsPerFrame, int quality, int pitchInBytes) {
- if (!msf_gif_frame(handle, pixelData, centiSecondsPerFrame, quality, pitchInBytes)) { return 0; }
-
- //NOTE: this is a somewhat hacky implementation which is not perfectly efficient, but it's good enough for now
- MsfGifBuffer * head = handle->listHead;
- if (!handle->fileWriteFunc(head->data, head->size, 1, handle->fileWriteData)) { msf_free_gif_state(handle); return 0; }
- handle->listHead = head->next;
- MSF_GIF_FREE(handle->customAllocatorContext, head, offsetof(MsfGifBuffer, data) + head->size);
- return 1;
-}
-
-int msf_gif_end_to_file(MsfGifState * handle) {
- //NOTE: this is a somewhat hacky implementation which is not perfectly efficient, but it's good enough for now
- MsfGifResult result = msf_gif_end(handle);
- int ret = (int) handle->fileWriteFunc(result.data, result.dataSize, 1, handle->fileWriteData);
- msf_gif_free(result);
- return ret;
-}
-
-#endif //MSF_GIF_ALREADY_IMPLEMENTED_IN_THIS_TRANSLATION_UNIT
-#endif //MSF_GIF_IMPL
-
-/*
-------------------------------------------------------------------------------
-This software is available under 2 licenses -- choose whichever you prefer.
-------------------------------------------------------------------------------
-ALTERNATIVE A - MIT License
-Copyright (c) 2025 Miles Fogle
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-------------------------------------------------------------------------------
-ALTERNATIVE B - Public Domain (www.unlicense.org)
-This is free and unencumbered software released into the public domain.
-Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
-software, either in source code form or as a compiled binary, for any purpose,
-commercial or non-commercial, and by any means.
-In jurisdictions that recognize copyright laws, the author or authors of this
-software dedicate any and all copyright interest in the software to the public
-domain. We make this dedication for the benefit of the public at large and to
-the detriment of our heirs and successors. We intend this dedication to be an
-overt act of relinquishment in perpetuity of all present and future rights to
-this software under copyright law.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
-ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-------------------------------------------------------------------------------
-*/
diff --git a/src/deps/msf_gif/msf_gif.zig b/src/deps/msf_gif/msf_gif.zig
deleted file mode 100644
index 999a1b6a..00000000
--- a/src/deps/msf_gif/msf_gif.zig
+++ /dev/null
@@ -1,87 +0,0 @@
-const std = @import("std");
-
-pub extern var msf_gif_alpha_threshold: u32;
-
-pub const MSFGifResult = extern struct {
- data: ?[*]u8,
- dataSize: usize,
- allocSize: usize,
- contextPointer: ?*anyopaque,
-};
-
-pub const MSFGifCookedFrame = extern struct {
- pixels: ?[*]u32,
- depth: c_int,
- count: c_int,
- rbits: c_int,
- gbits: c_int,
- bbits: c_int,
-};
-
-pub const MSFGifBuffer = extern struct {
- next: ?*MSFGifBuffer,
- size: usize,
- data: [1]u8, // flexible array member in C
-};
-
-pub const MSFGifState = extern struct {
- fileWriteFunc: ?*const fn (?*const anyopaque, usize, usize, ?*anyopaque) callconv(.c) usize,
- fileWriteData: ?*anyopaque,
- previousFrame: MSFGifCookedFrame,
- currentFrame: MSFGifCookedFrame,
- lzwMem: ?*anyopaque,
- tlbMem: ?*anyopaque,
- usedMem: ?*anyopaque,
- listHead: ?*MSFGifBuffer,
- listTail: ?*MSFGifBuffer,
- width: c_int,
- height: c_int,
- customAllocatorContext: ?*anyopaque,
- framesSubmitted: c_int,
-};
-
-pub extern fn msf_gif_begin(
- handle: *MSFGifState,
- width: c_int,
- height: c_int,
-) c_int;
-
-pub extern fn msf_gif_frame(
- handle: *MSFGifState,
- pixel_data: [*]u8,
- centi_seconds_per_frame: c_int,
- quality: c_int,
- pitch_in_bytes: c_int,
-) c_int;
-
-pub extern fn msf_gif_end(
- handle: *MSFGifState,
-) MSFGifResult;
-
-pub extern fn msf_gif_free(
- result: MSFGifResult,
-) void;
-
-// Helper Zig wrappers
-
-pub fn begin(handle: *MSFGifState, width: u32, height: u32) c_int {
- return msf_gif_begin(handle, @intCast(width), @intCast(height));
-}
-
-pub fn frame(
- handle: *MSFGifState,
- pixel_data: [*]u8,
- centi_seconds_per_frame: i32,
- //quality: i32, // 16 is recommended, can be lowered for faster exports but may look worse
- //pitch_in_bytes: i32, // 0 means contiguous rows, negative means reversed rows
-) c_int {
- return msf_gif_frame(handle, pixel_data, @intCast(centi_seconds_per_frame), 16, 0);
-}
-
-pub fn end(handle: *MSFGifState) MSFGifResult {
- return msf_gif_end(handle);
-}
-
-pub fn free(result: MSFGifResult) void {
- msf_gif_free(result);
-}
diff --git a/src/deps/msf_gif/wasm_shim/string.h b/src/deps/msf_gif/wasm_shim/string.h
deleted file mode 100644
index b3a6e162..00000000
--- a/src/deps/msf_gif/wasm_shim/string.h
+++ /dev/null
@@ -1,7 +0,0 @@
-// Minimal for msf_gif on wasm32-freestanding.
-#pragma once
-
-#include
-
-void *memcpy(void *restrict dest, const void *restrict src, size_t n);
-void *memset(void *s, int c, size_t n);
diff --git a/src/deps/stbi/fizzy_stbi_libc.c b/src/deps/stbi/fizzy_stbi_libc.c
deleted file mode 100644
index 3b365dfb..00000000
--- a/src/deps/stbi/fizzy_stbi_libc.c
+++ /dev/null
@@ -1,39 +0,0 @@
-// Heap + sort shims so `zstbi.c` (stb_rect_pack + stb_image_resize2) can compile
-// on wasm32-freestanding, where `` is not available. Routes C
-// allocations to DVUI's exported allocator (same source the zip + stb_image
-// shims use). The qsort shim is a simple insertion sort — stb_rect_pack uses it
-// once per atlas to sort rects by height, and even for thousand-rect atlases
-// O(n²) is negligible compared to the actual pack work.
-
-#include
-
-extern void *dvui_c_alloc(size_t size);
-extern void dvui_c_free(void *ptr);
-
-void *fizzy_stbi_malloc(size_t size) {
- return dvui_c_alloc(size);
-}
-
-void fizzy_stbi_free(void *ptr) {
- dvui_c_free(ptr);
-}
-
-typedef int (*fizzy_stbi_cmp)(const void *, const void *);
-
-static void fizzy_stbi_swap_bytes(unsigned char *a, unsigned char *b, size_t n) {
- while (n--) {
- unsigned char t = *a;
- *a++ = *b;
- *b++ = t;
- }
-}
-
-void fizzy_stbi_qsort(void *base, size_t nmemb, size_t size, fizzy_stbi_cmp cmp) {
- if (nmemb < 2 || size == 0) return;
- unsigned char *arr = (unsigned char *)base;
- for (size_t i = 1; i < nmemb; ++i) {
- for (size_t j = i; j > 0 && cmp(arr + (j - 1) * size, arr + j * size) > 0; --j) {
- fizzy_stbi_swap_bytes(arr + (j - 1) * size, arr + j * size, size);
- }
- }
-}
diff --git a/src/deps/stbi/stb_image_resize2.h b/src/deps/stbi/stb_image_resize2.h
deleted file mode 100644
index 6146ab7e..00000000
--- a/src/deps/stbi/stb_image_resize2.h
+++ /dev/null
@@ -1,10651 +0,0 @@
-/* stb_image_resize2 - v2.17 - public domain image resizing
-
- by Jeff Roberts (v2) and Jorge L Rodriguez
- http://github.com/nothings/stb
-
- Can be threaded with the extended API. SSE2, AVX, Neon and WASM SIMD support. Only
- scaling and translation is supported, no rotations or shears.
-
- COMPILING & LINKING
- In one C/C++ file that #includes this file, do this:
- #define STB_IMAGE_RESIZE_IMPLEMENTATION
- before the #include. That will create the implementation in that file.
-
- EASY API CALLS:
- Easy API downsamples w/Mitchell filter, upsamples w/cubic interpolation, clamps to edge.
-
- stbir_resize_uint8_srgb( input_pixels, input_w, input_h, input_stride_in_bytes,
- output_pixels, output_w, output_h, output_stride_in_bytes,
- pixel_layout_enum )
-
- stbir_resize_uint8_linear( input_pixels, input_w, input_h, input_stride_in_bytes,
- output_pixels, output_w, output_h, output_stride_in_bytes,
- pixel_layout_enum )
-
- stbir_resize_float_linear( input_pixels, input_w, input_h, input_stride_in_bytes,
- output_pixels, output_w, output_h, output_stride_in_bytes,
- pixel_layout_enum )
-
- If you pass NULL or zero for the output_pixels, we will allocate the output buffer
- for you and return it from the function (free with free() or STBIR_FREE).
- As a special case, XX_stride_in_bytes of 0 means packed continuously in memory.
-
- API LEVELS
- There are three levels of API - easy-to-use, medium-complexity and extended-complexity.
-
- See the "header file" section of the source for API documentation.
-
- ADDITIONAL DOCUMENTATION
-
- MEMORY ALLOCATION
- By default, we use malloc and free for memory allocation. To override the
- memory allocation, before the implementation #include, add a:
-
- #define STBIR_MALLOC(size,user_data) ...
- #define STBIR_FREE(ptr,user_data) ...
-
- Each resize makes exactly one call to malloc/free (unless you use the
- extended API where you can do one allocation for many resizes). Under
- address sanitizer, we do separate allocations to find overread/writes.
-
- PERFORMANCE
- This library was written with an emphasis on performance. When testing
- stb_image_resize with RGBA, the fastest mode is STBIR_4CHANNEL with
- STBIR_TYPE_UINT8 pixels and CLAMPed edges (which is what many other resize
- libs do by default). Also, make sure SIMD is turned on of course (default
- for 64-bit targets). Avoid WRAP edge mode if you want the fastest speed.
-
- This library also comes with profiling built-in. If you define STBIR_PROFILE,
- you can use the advanced API and get low-level profiling information by
- calling stbir_resize_extended_profile_info() or stbir_resize_split_profile_info()
- after a resize.
-
- SIMD
- Most of the routines have optimized SSE2, AVX, NEON and WASM versions.
-
- On Microsoft compilers, we automatically turn on SIMD for 64-bit x64 and
- ARM; for 32-bit x86 and ARM, you select SIMD mode by defining STBIR_SSE2 or
- STBIR_NEON. For AVX and AVX2, we auto-select it by detecting the /arch:AVX
- or /arch:AVX2 switches. You can also always manually turn SSE2, AVX or AVX2
- support on by defining STBIR_SSE2, STBIR_AVX or STBIR_AVX2.
-
- On Linux, SSE2 and Neon is on by default for 64-bit x64 or ARM64. For 32-bit,
- we select x86 SIMD mode by whether you have -msse2, -mavx or -mavx2 enabled
- on the command line. For 32-bit ARM, you must pass -mfpu=neon-vfpv4 for both
- clang and GCC, but GCC also requires an additional -mfp16-format=ieee to
- automatically enable NEON.
-
- On x86 platforms, you can also define STBIR_FP16C to turn on FP16C instructions
- for converting back and forth to half-floats. This is autoselected when we
- are using AVX2. Clang and GCC also require the -mf16c switch. ARM always uses
- the built-in half float hardware NEON instructions.
-
- You can also tell us to use multiply-add instructions with STBIR_USE_FMA.
- Because x86 doesn't always have fma, we turn it off by default to maintain
- determinism across all platforms. If you don't care about non-FMA determinism
- and are willing to restrict yourself to more recent x86 CPUs (around the AVX
- timeframe), then fma will give you around a 15% speedup.
-
- You can force off SIMD in all cases by defining STBIR_NO_SIMD. You can turn
- off AVX or AVX2 specifically with STBIR_NO_AVX or STBIR_NO_AVX2. AVX is 10%
- to 40% faster, and AVX2 is generally another 12%.
-
- ALPHA CHANNEL
- Most of the resizing functions provide the ability to control how the alpha
- channel of an image is processed.
-
- When alpha represents transparency, it is important that when combining
- colors with filtering, the pixels should not be treated equally; they
- should use a weighted average based on their alpha values. For example,
- if a pixel is 1% opaque bright green and another pixel is 99% opaque
- black and you average them, the average will be 50% opaque, but the
- unweighted average and will be a middling green color, while the weighted
- average will be nearly black. This means the unweighted version introduced
- green energy that didn't exist in the source image.
-
- (If you want to know why this makes sense, you can work out the math for
- the following: consider what happens if you alpha composite a source image
- over a fixed color and then average the output, vs. if you average the
- source image pixels and then composite that over the same fixed color.
- Only the weighted average produces the same result as the ground truth
- composite-then-average result.)
-
- Therefore, it is in general best to "alpha weight" the pixels when applying
- filters to them. This essentially means multiplying the colors by the alpha
- values before combining them, and then dividing by the alpha value at the
- end.
-
- The computer graphics industry introduced a technique called "premultiplied
- alpha" or "associated alpha" in which image colors are stored in image files
- already multiplied by their alpha. This saves some math when compositing,
- and also avoids the need to divide by the alpha at the end (which is quite
- inefficient). However, while premultiplied alpha is common in the movie CGI
- industry, it is not commonplace in other industries like videogames, and most
- consumer file formats are generally expected to contain not-premultiplied
- colors. For example, Photoshop saves PNG files "unpremultiplied", and web
- browsers like Chrome and Firefox expect PNG images to be unpremultiplied.
-
- Note that there are three possibilities that might describe your image
- and resize expectation:
-
- 1. images are not premultiplied, alpha weighting is desired
- 2. images are not premultiplied, alpha weighting is not desired
- 3. images are premultiplied
-
- Both case #2 and case #3 require the exact same math: no alpha weighting
- should be applied or removed. Only case 1 requires extra math operations;
- the other two cases can be handled identically.
-
- stb_image_resize expects case #1 by default, applying alpha weighting to
- images, expecting the input images to be unpremultiplied. This is what the
- COLOR+ALPHA buffer types tell the resizer to do.
-
- When you use the pixel layouts STBIR_RGBA, STBIR_BGRA, STBIR_ARGB,
- STBIR_ABGR, STBIR_RX, or STBIR_XR you are telling us that the pixels are
- non-premultiplied. In these cases, the resizer will alpha weight the colors
- (effectively creating the premultiplied image), do the filtering, and then
- convert back to non-premult on exit.
-
- When you use the pixel layouts STBIR_RGBA_PM, STBIR_RGBA_PM, STBIR_RGBA_PM,
- STBIR_RGBA_PM, STBIR_RX_PM or STBIR_XR_PM, you are telling that the pixels
- ARE premultiplied. In this case, the resizer doesn't have to do the
- premultipling - it can filter directly on the input. This about twice as
- fast as the non-premultiplied case, so it's the right option if your data is
- already setup correctly.
-
- When you use the pixel layout STBIR_4CHANNEL or STBIR_2CHANNEL, you are
- telling us that there is no channel that represents transparency; it may be
- RGB and some unrelated fourth channel that has been stored in the alpha
- channel, but it is actually not alpha. No special processing will be
- performed.
-
- The difference between the generic 4 or 2 channel layouts, and the
- specialized _PM versions is with the _PM versions you are telling us that
- the data *is* alpha, just don't premultiply it. That's important when
- using SRGB pixel formats, we need to know where the alpha is, because
- it is converted linearly (rather than with the SRGB converters).
-
- Because alpha weighting produces the same effect as premultiplying, you
- even have the option with non-premultiplied inputs to let the resizer
- produce a premultiplied output. Because the intially computed alpha-weighted
- output image is effectively premultiplied, this is actually more performant
- than the normal path which un-premultiplies the output image as a final step.
-
- Finally, when converting both in and out of non-premulitplied space (for
- example, when using STBIR_RGBA), we go to somewhat heroic measures to
- ensure that areas with zero alpha value pixels get something reasonable
- in the RGB values. If you don't care about the RGB values of zero alpha
- pixels, you can call the stbir_set_non_pm_alpha_speed_over_quality()
- function - this runs a premultiplied resize about 25% faster. That said,
- when you really care about speed, using premultiplied pixels for both in
- and out (STBIR_RGBA_PM, etc) much faster than both of these premultiplied
- options.
-
- PIXEL LAYOUT CONVERSION
- The resizer can convert from some pixel layouts to others. When using the
- stbir_set_pixel_layouts(), you can, for example, specify STBIR_RGBA
- on input, and STBIR_ARGB on output, and it will re-organize the channels
- during the resize. Currently, you can only convert between two pixel
- layouts with the same number of channels.
-
- DETERMINISM
- We commit to being deterministic (from x64 to ARM to scalar to SIMD, etc).
- This requires compiling with fast-math off (using at least /fp:precise).
- Also, you must turn off fp-contracting (which turns mult+adds into fmas)!
- We attempt to do this with pragmas, but with Clang, you usually want to add
- -ffp-contract=off to the command line as well.
-
- For 32-bit x86, you must use SSE and SSE2 codegen for determinism. That is,
- if the scalar x87 unit gets used at all, we immediately lose determinism.
- On Microsoft Visual Studio 2008 and earlier, from what we can tell there is
- no way to be deterministic in 32-bit x86 (some x87 always leaks in, even
- with fp:strict). On 32-bit x86 GCC, determinism requires both -msse2 and
- -fpmath=sse.
-
- Note that we will not be deterministic with float data containing NaNs -
- the NaNs will propagate differently on different SIMD and platforms.
-
- If you turn on STBIR_USE_FMA, then we will be deterministic with other
- fma targets, but we will differ from non-fma targets (this is unavoidable,
- because a fma isn't simply an add with a mult - it also introduces a
- rounding difference compared to non-fma instruction sequences.
-
- FLOAT PIXEL FORMAT RANGE
- Any range of values can be used for the non-alpha float data that you pass
- in (0 to 1, -1 to 1, whatever). However, if you are inputting float values
- but *outputting* bytes or shorts, you must use a range of 0 to 1 so that we
- scale back properly. The alpha channel must also be 0 to 1 for any format
- that does premultiplication prior to resizing.
-
- Note also that with float output, using filters with negative lobes, the
- output filtered values might go slightly out of range. You can define
- STBIR_FLOAT_LOW_CLAMP and/or STBIR_FLOAT_HIGH_CLAMP to specify the range
- to clamp to on output, if that's important.
-
- MAX/MIN SCALE FACTORS
- The input pixel resolutions are in integers, and we do the internal pointer
- resolution in size_t sized integers. However, the scale ratio from input
- resolution to output resolution is calculated in float form. This means
- the effective possible scale ratio is limited to 24 bits (or 16 million
- to 1). As you get close to the size of the float resolution (again, 16
- million pixels wide or high), you might start seeing float inaccuracy
- issues in general in the pipeline. If you have to do extreme resizes,
- you can usually do this is multiple stages (using float intermediate
- buffers).
-
- FLIPPED IMAGES
- Stride is just the delta from one scanline to the next. This means you can
- use a negative stride to handle inverted images (point to the final
- scanline and use a negative stride). You can invert the input or output,
- using negative strides.
-
- DEFAULT FILTERS
- For functions which don't provide explicit control over what filters to
- use, you can change the compile-time defaults with:
-
- #define STBIR_DEFAULT_FILTER_UPSAMPLE STBIR_FILTER_something
- #define STBIR_DEFAULT_FILTER_DOWNSAMPLE STBIR_FILTER_something
-
- See stbir_filter in the header-file section for the list of filters.
-
- NEW FILTERS
- A number of 1D filter kernels are supplied. For a list of supported
- filters, see the stbir_filter enum. You can install your own filters by
- using the stbir_set_filter_callbacks function.
-
- PROGRESS
- For interactive use with slow resize operations, you can use the
- scanline callbacks in the extended API. It would have to be a *very* large
- image resample to need progress though - we're very fast.
-
- CEIL and FLOOR
- In scalar mode, the only functions we use from math.h are ceilf and floorf,
- but if you have your own versions, you can define the STBIR_CEILF(v) and
- STBIR_FLOORF(v) macros and we'll use them instead. In SIMD, we just use
- our own versions.
-
- ASSERT
- Define STBIR_ASSERT(boolval) to override assert() and not use assert.h
-
- PORTING FROM VERSION 1
- The API has changed. You can continue to use the old version of stb_image_resize.h,
- which is available in the "deprecated/" directory.
-
- If you're using the old simple-to-use API, porting is straightforward.
- (For more advanced APIs, read the documentation.)
-
- stbir_resize_uint8():
- - call `stbir_resize_uint8_linear`, cast channel count to `stbir_pixel_layout`
-
- stbir_resize_float():
- - call `stbir_resize_float_linear`, cast channel count to `stbir_pixel_layout`
-
- stbir_resize_uint8_srgb():
- - function name is unchanged
- - cast channel count to `stbir_pixel_layout`
- - above is sufficient unless your image has alpha and it's not RGBA/BGRA
- - in that case, follow the below instructions for stbir_resize_uint8_srgb_edgemode
-
- stbir_resize_uint8_srgb_edgemode()
- - switch to the "medium complexity" API
- - stbir_resize(), very similar API but a few more parameters:
- - pixel_layout: cast channel count to `stbir_pixel_layout`
- - data_type: STBIR_TYPE_UINT8_SRGB
- - edge: unchanged (STBIR_EDGE_WRAP, etc.)
- - filter: STBIR_FILTER_DEFAULT
- - which channel is alpha is specified in stbir_pixel_layout, see enum for details
-
- FUTURE TODOS
- * For polyphase integral filters, we just memcpy the coeffs to dupe
- them, but we should indirect and use the same coeff memory.
- * Add pixel layout conversions for sensible different channel counts
- (maybe, 1->3/4, 3->4, 4->1, 3->1).
- * For SIMD encode and decode scanline routines, do any pre-aligning
- for bad input/output buffer alignments and pitch?
- * For very wide scanlines, we should we do vertical strips to stay within
- L2 cache. Maybe do chunks of 1K pixels at a time. There would be
- some pixel reconversion, but probably dwarfed by things falling out
- of cache. Probably also something possible with alternating between
- scattering and gathering at high resize scales?
- * Should we have a multiple MIPs at the same time function (could keep
- more memory in cache during multiple resizes)?
- * Rewrite the coefficient generator to do many at once.
- * AVX-512 vertical kernels - worried about downclocking here.
- * Convert the reincludes to macros when we know they aren't changing.
- * Experiment with pivoting the horizontal and always using the
- vertical filters (which are faster, but perhaps not enough to overcome
- the pivot cost and the extra memory touches). Need to buffer the whole
- image so have to balance memory use.
- * Most of our code is internally function pointers, should we compile
- all the SIMD stuff always and dynamically dispatch?
-
- CONTRIBUTORS
- Jeff Roberts: 2.0 implementation, optimizations, SIMD
- Martins Mozeiko: NEON simd, WASM simd, clang and GCC whisperer
- Fabian Giesen: half float and srgb converters
- Sean Barrett: API design, optimizations
- Jorge L Rodriguez: Original 1.0 implementation
- Aras Pranckevicius: bugfixes
- Nathan Reed: warning fixes for 1.0
-
- REVISIONS
- 2.17 (2025-10-25) silly format bug in easy-to-use APIs.
- 2.16 (2025-10-21) fixed the easy-to-use APIs to allow inverted bitmaps (negative
- strides), fix vertical filter kernel callback, fix threaded
- gather buffer priming (and assert).
- (thanks adipose, TainZerL, and Harrison Green)
- 2.15 (2025-07-17) fixed an assert in debug mode when using floats with input
- callbacks, work around GCC warning when adding to null ptr
- (thanks Johannes Spohr and Pyry Kovanen).
- 2.14 (2025-05-09) fixed a bug using downsampling gather horizontal first, and
- scatter with vertical first.
- 2.13 (2025-02-27) fixed a bug when using input callbacks, turned off simd for
- tiny-c, fixed some variables that should have been static,
- fixes a bug when calculating temp memory with resizes that
- exceed 2GB of temp memory (very large resizes).
- 2.12 (2024-10-18) fix incorrect use of user_data with STBIR_FREE
- 2.11 (2024-09-08) fix harmless asan warnings in 2-channel and 3-channel mode
- with AVX-2, fix some weird scaling edge conditions with
- point sample mode.
- 2.10 (2024-07-27) fix the defines GCC and mingw for loop unroll control,
- fix MSVC 32-bit arm half float routines.
- 2.09 (2024-06-19) fix the defines for 32-bit ARM GCC builds (was selecting
- hardware half floats).
- 2.08 (2024-06-10) fix for RGB->BGR three channel flips and add SIMD (thanks
- to Ryan Salsbury), fix for sub-rect resizes, use the
- pragmas to control unrolling when they are available.
- 2.07 (2024-05-24) fix for slow final split during threaded conversions of very
- wide scanlines when downsampling (caused by extra input
- converting), fix for wide scanline resamples with many
- splits (int overflow), fix GCC warning.
- 2.06 (2024-02-10) fix for identical width/height 3x or more down-scaling
- undersampling a single row on rare resize ratios (about 1%).
- 2.05 (2024-02-07) fix for 2 pixel to 1 pixel resizes with wrap (thanks Aras),
- fix for output callback (thanks Julien Koenen).
- 2.04 (2023-11-17) fix for rare AVX bug, shadowed symbol (thanks Nikola Smiljanic).
- 2.03 (2023-11-01) ASAN and TSAN warnings fixed, minor tweaks.
- 2.00 (2023-10-10) mostly new source: new api, optimizations, simd, vertical-first, etc
- 2x-5x faster without simd, 4x-12x faster with simd,
- in some cases, 20x to 40x faster esp resizing large to very small.
- 0.96 (2019-03-04) fixed warnings
- 0.95 (2017-07-23) fixed warnings
- 0.94 (2017-03-18) fixed warnings
- 0.93 (2017-03-03) fixed bug with certain combinations of heights
- 0.92 (2017-01-02) fix integer overflow on large (>2GB) images
- 0.91 (2016-04-02) fix warnings; fix handling of subpixel regions
- 0.90 (2014-09-17) first released version
-
- LICENSE
- See end of file for license information.
-*/
-
-#if !defined(STB_IMAGE_RESIZE_DO_HORIZONTALS) && !defined(STB_IMAGE_RESIZE_DO_VERTICALS) && !defined(STB_IMAGE_RESIZE_DO_CODERS) // for internal re-includes
-
-#ifndef STBIR_INCLUDE_STB_IMAGE_RESIZE2_H
-#define STBIR_INCLUDE_STB_IMAGE_RESIZE2_H
-
-#include
-#ifdef _MSC_VER
-typedef unsigned char stbir_uint8;
-typedef unsigned short stbir_uint16;
-typedef unsigned int stbir_uint32;
-typedef unsigned __int64 stbir_uint64;
-#else
-#include
-typedef uint8_t stbir_uint8;
-typedef uint16_t stbir_uint16;
-typedef uint32_t stbir_uint32;
-typedef uint64_t stbir_uint64;
-#endif
-
-#ifndef STBIRDEF
-#ifdef STB_IMAGE_RESIZE_STATIC
-#define STBIRDEF static
-#else
-#ifdef __cplusplus
-#define STBIRDEF extern "C"
-#else
-#define STBIRDEF extern
-#endif
-#endif
-#endif
-
-//////////////////////////////////////////////////////////////////////////////
-//// start "header file" ///////////////////////////////////////////////////
-//
-// Easy-to-use API:
-//
-// * stride is the offset between successive rows of image data
-// in memory, in bytes. specify 0 for packed continuously in memory
-// * colorspace is linear or sRGB as specified by function name
-// * Uses the default filters
-// * Uses edge mode clamped
-// * returned result is 1 for success or 0 in case of an error.
-
-
-// stbir_pixel_layout specifies:
-// number of channels
-// order of channels
-// whether color is premultiplied by alpha
-// for back compatibility, you can cast the old channel count to an stbir_pixel_layout
-typedef enum
-{
- STBIR_1CHANNEL = 1,
- STBIR_2CHANNEL = 2,
- STBIR_RGB = 3, // 3-chan, with order specified (for channel flipping)
- STBIR_BGR = 0, // 3-chan, with order specified (for channel flipping)
- STBIR_4CHANNEL = 5,
-
- STBIR_RGBA = 4, // alpha formats, where alpha is NOT premultiplied into color channels
- STBIR_BGRA = 6,
- STBIR_ARGB = 7,
- STBIR_ABGR = 8,
- STBIR_RA = 9,
- STBIR_AR = 10,
-
- STBIR_RGBA_PM = 11, // alpha formats, where alpha is premultiplied into color channels
- STBIR_BGRA_PM = 12,
- STBIR_ARGB_PM = 13,
- STBIR_ABGR_PM = 14,
- STBIR_RA_PM = 15,
- STBIR_AR_PM = 16,
-
- STBIR_RGBA_NO_AW = 11, // alpha formats, where NO alpha weighting is applied at all!
- STBIR_BGRA_NO_AW = 12, // these are just synonyms for the _PM flags (which also do
- STBIR_ARGB_NO_AW = 13, // no alpha weighting). These names just make it more clear
- STBIR_ABGR_NO_AW = 14, // for some folks).
- STBIR_RA_NO_AW = 15,
- STBIR_AR_NO_AW = 16,
-
-} stbir_pixel_layout;
-
-//===============================================================
-// Simple-complexity API
-//
-// If output_pixels is NULL (0), then we will allocate the buffer and return it to you.
-//--------------------------------
-
-STBIRDEF unsigned char * stbir_resize_uint8_srgb( const unsigned char *input_pixels , int input_w , int input_h, int input_stride_in_bytes,
- unsigned char *output_pixels, int output_w, int output_h, int output_stride_in_bytes,
- stbir_pixel_layout pixel_type );
-
-STBIRDEF unsigned char * stbir_resize_uint8_linear( const unsigned char *input_pixels , int input_w , int input_h, int input_stride_in_bytes,
- unsigned char *output_pixels, int output_w, int output_h, int output_stride_in_bytes,
- stbir_pixel_layout pixel_type );
-
-STBIRDEF float * stbir_resize_float_linear( const float *input_pixels , int input_w , int input_h, int input_stride_in_bytes,
- float *output_pixels, int output_w, int output_h, int output_stride_in_bytes,
- stbir_pixel_layout pixel_type );
-//===============================================================
-
-//===============================================================
-// Medium-complexity API
-//
-// This extends the easy-to-use API as follows:
-//
-// * Can specify the datatype - U8, U8_SRGB, U16, FLOAT, HALF_FLOAT
-// * Edge wrap can selected explicitly
-// * Filter can be selected explicitly
-//--------------------------------
-
-typedef enum
-{
- STBIR_EDGE_CLAMP = 0,
- STBIR_EDGE_REFLECT = 1,
- STBIR_EDGE_WRAP = 2, // this edge mode is slower and uses more memory
- STBIR_EDGE_ZERO = 3,
-} stbir_edge;
-
-typedef enum
-{
- STBIR_FILTER_DEFAULT = 0, // use same filter type that easy-to-use API chooses
- STBIR_FILTER_BOX = 1, // A trapezoid w/1-pixel wide ramps, same result as box for integer scale ratios
- STBIR_FILTER_TRIANGLE = 2, // On upsampling, produces same results as bilinear texture filtering
- STBIR_FILTER_CUBICBSPLINE = 3, // The cubic b-spline (aka Mitchell-Netrevalli with B=1,C=0), gaussian-esque
- STBIR_FILTER_CATMULLROM = 4, // An interpolating cubic spline
- STBIR_FILTER_MITCHELL = 5, // Mitchell-Netrevalli filter with B=1/3, C=1/3
- STBIR_FILTER_POINT_SAMPLE = 6, // Simple point sampling
- STBIR_FILTER_OTHER = 7, // User callback specified
-} stbir_filter;
-
-typedef enum
-{
- STBIR_TYPE_UINT8 = 0,
- STBIR_TYPE_UINT8_SRGB = 1,
- STBIR_TYPE_UINT8_SRGB_ALPHA = 2, // alpha channel, when present, should also be SRGB (this is very unusual)
- STBIR_TYPE_UINT16 = 3,
- STBIR_TYPE_FLOAT = 4,
- STBIR_TYPE_HALF_FLOAT = 5
-} stbir_datatype;
-
-// medium api
-STBIRDEF void * stbir_resize( const void *input_pixels , int input_w , int input_h, int input_stride_in_bytes,
- void *output_pixels, int output_w, int output_h, int output_stride_in_bytes,
- stbir_pixel_layout pixel_layout, stbir_datatype data_type,
- stbir_edge edge, stbir_filter filter );
-//===============================================================
-
-
-
-//===============================================================
-// Extended-complexity API
-//
-// This API exposes all resize functionality.
-//
-// * Separate filter types for each axis
-// * Separate edge modes for each axis
-// * Separate input and output data types
-// * Can specify regions with subpixel correctness
-// * Can specify alpha flags
-// * Can specify a memory callback
-// * Can specify a callback data type for pixel input and output
-// * Can be threaded for a single resize
-// * Can be used to resize many frames without recalculating the sampler info
-//
-// Use this API as follows:
-// 1) Call the stbir_resize_init function on a local STBIR_RESIZE structure
-// 2) Call any of the stbir_set functions
-// 3) Optionally call stbir_build_samplers() if you are going to resample multiple times
-// with the same input and output dimensions (like resizing video frames)
-// 4) Resample by calling stbir_resize_extended().
-// 5) Call stbir_free_samplers() if you called stbir_build_samplers()
-//--------------------------------
-
-
-// Types:
-
-// INPUT CALLBACK: this callback is used for input scanlines
-typedef void const * stbir_input_callback( void * optional_output, void const * input_ptr, int num_pixels, int x, int y, void * context );
-
-// OUTPUT CALLBACK: this callback is used for output scanlines
-typedef void stbir_output_callback( void const * output_ptr, int num_pixels, int y, void * context );
-
-// callbacks for user installed filters
-typedef float stbir__kernel_callback( float x, float scale, void * user_data ); // centered at zero
-typedef float stbir__support_callback( float scale, void * user_data );
-
-// internal structure with precomputed scaling
-typedef struct stbir__info stbir__info;
-
-typedef struct STBIR_RESIZE // use the stbir_resize_init and stbir_override functions to set these values for future compatibility
-{
- void * user_data;
- void const * input_pixels;
- int input_w, input_h;
- double input_s0, input_t0, input_s1, input_t1;
- stbir_input_callback * input_cb;
- void * output_pixels;
- int output_w, output_h;
- int output_subx, output_suby, output_subw, output_subh;
- stbir_output_callback * output_cb;
- int input_stride_in_bytes;
- int output_stride_in_bytes;
- int splits;
- int fast_alpha;
- int needs_rebuild;
- int called_alloc;
- stbir_pixel_layout input_pixel_layout_public;
- stbir_pixel_layout output_pixel_layout_public;
- stbir_datatype input_data_type;
- stbir_datatype output_data_type;
- stbir_filter horizontal_filter, vertical_filter;
- stbir_edge horizontal_edge, vertical_edge;
- stbir__kernel_callback * horizontal_filter_kernel; stbir__support_callback * horizontal_filter_support;
- stbir__kernel_callback * vertical_filter_kernel; stbir__support_callback * vertical_filter_support;
- stbir__info * samplers;
-} STBIR_RESIZE;
-
-// extended complexity api
-
-
-// First off, you must ALWAYS call stbir_resize_init on your resize structure before any of the other calls!
-STBIRDEF void stbir_resize_init( STBIR_RESIZE * resize,
- const void *input_pixels, int input_w, int input_h, int input_stride_in_bytes, // stride can be zero
- void *output_pixels, int output_w, int output_h, int output_stride_in_bytes, // stride can be zero
- stbir_pixel_layout pixel_layout, stbir_datatype data_type );
-
-//===============================================================
-// You can update these parameters any time after resize_init and there is no cost
-//--------------------------------
-
-STBIRDEF void stbir_set_datatypes( STBIR_RESIZE * resize, stbir_datatype input_type, stbir_datatype output_type );
-STBIRDEF void stbir_set_pixel_callbacks( STBIR_RESIZE * resize, stbir_input_callback * input_cb, stbir_output_callback * output_cb ); // no callbacks by default
-STBIRDEF void stbir_set_user_data( STBIR_RESIZE * resize, void * user_data ); // pass back STBIR_RESIZE* by default
-STBIRDEF void stbir_set_buffer_ptrs( STBIR_RESIZE * resize, const void * input_pixels, int input_stride_in_bytes, void * output_pixels, int output_stride_in_bytes );
-
-//===============================================================
-
-
-//===============================================================
-// If you call any of these functions, you will trigger a sampler rebuild!
-//--------------------------------
-
-STBIRDEF int stbir_set_pixel_layouts( STBIR_RESIZE * resize, stbir_pixel_layout input_pixel_layout, stbir_pixel_layout output_pixel_layout ); // sets new buffer layouts
-STBIRDEF int stbir_set_edgemodes( STBIR_RESIZE * resize, stbir_edge horizontal_edge, stbir_edge vertical_edge ); // CLAMP by default
-
-STBIRDEF int stbir_set_filters( STBIR_RESIZE * resize, stbir_filter horizontal_filter, stbir_filter vertical_filter ); // STBIR_DEFAULT_FILTER_UPSAMPLE/DOWNSAMPLE by default
-STBIRDEF int stbir_set_filter_callbacks( STBIR_RESIZE * resize, stbir__kernel_callback * horizontal_filter, stbir__support_callback * horizontal_support, stbir__kernel_callback * vertical_filter, stbir__support_callback * vertical_support );
-
-STBIRDEF int stbir_set_pixel_subrect( STBIR_RESIZE * resize, int subx, int suby, int subw, int subh ); // sets both sub-regions (full regions by default)
-STBIRDEF int stbir_set_input_subrect( STBIR_RESIZE * resize, double s0, double t0, double s1, double t1 ); // sets input sub-region (full region by default)
-STBIRDEF int stbir_set_output_pixel_subrect( STBIR_RESIZE * resize, int subx, int suby, int subw, int subh ); // sets output sub-region (full region by default)
-
-// when inputting AND outputting non-premultiplied alpha pixels, we use a slower but higher quality technique
-// that fills the zero alpha pixel's RGB values with something plausible. If you don't care about areas of
-// zero alpha, you can call this function to get about a 25% speed improvement for STBIR_RGBA to STBIR_RGBA
-// types of resizes.
-STBIRDEF int stbir_set_non_pm_alpha_speed_over_quality( STBIR_RESIZE * resize, int non_pma_alpha_speed_over_quality );
-//===============================================================
-
-
-//===============================================================
-// You can call build_samplers to prebuild all the internal data we need to resample.
-// Then, if you call resize_extended many times with the same resize, you only pay the
-// cost once.
-// If you do call build_samplers, you MUST call free_samplers eventually.
-//--------------------------------
-
-// This builds the samplers and does one allocation
-STBIRDEF int stbir_build_samplers( STBIR_RESIZE * resize );
-
-// You MUST call this, if you call stbir_build_samplers or stbir_build_samplers_with_splits
-STBIRDEF void stbir_free_samplers( STBIR_RESIZE * resize );
-//===============================================================
-
-
-// And this is the main function to perform the resize synchronously on one thread.
-STBIRDEF int stbir_resize_extended( STBIR_RESIZE * resize );
-
-
-//===============================================================
-// Use these functions for multithreading.
-// 1) You call stbir_build_samplers_with_splits first on the main thread
-// 2) Then stbir_resize_with_split on each thread
-// 3) stbir_free_samplers when done on the main thread
-//--------------------------------
-
-// This will build samplers for threading.
-// You can pass in the number of threads you'd like to use (try_splits).
-// It returns the number of splits (threads) that you can call it with.
-/// It might be less if the image resize can't be split up that many ways.
-
-STBIRDEF int stbir_build_samplers_with_splits( STBIR_RESIZE * resize, int try_splits );
-
-// This function does a split of the resizing (you call this fuction for each
-// split, on multiple threads). A split is a piece of the output resize pixel space.
-
-// Note that you MUST call stbir_build_samplers_with_splits before stbir_resize_extended_split!
-
-// Usually, you will always call stbir_resize_split with split_start as the thread_index
-// and "1" for the split_count.
-// But, if you have a weird situation where you MIGHT want 8 threads, but sometimes
-// only 4 threads, you can use 0,2,4,6 for the split_start's and use "2" for the
-// split_count each time to turn in into a 4 thread resize. (This is unusual).
-
-STBIRDEF int stbir_resize_extended_split( STBIR_RESIZE * resize, int split_start, int split_count );
-//===============================================================
-
-
-//===============================================================
-// Pixel Callbacks info:
-//--------------------------------
-
-// The input callback is super flexible - it calls you with the input address
-// (based on the stride and base pointer), it gives you an optional_output
-// pointer that you can fill, or you can just return your own pointer into
-// your own data.
-//
-// You can also do conversion from non-supported data types if necessary - in
-// this case, you ignore the input_ptr and just use the x and y parameters to
-// calculate your own input_ptr based on the size of each non-supported pixel.
-// (Something like the third example below.)
-//
-// You can also install just an input or just an output callback by setting the
-// callback that you don't want to zero.
-//
-// First example, progress: (getting a callback that you can monitor the progress):
-// void const * my_callback( void * optional_output, void const * input_ptr, int num_pixels, int x, int y, void * context )
-// {
-// percentage_done = y / input_height;
-// return input_ptr; // use buffer from call
-// }
-//
-// Next example, copying: (copy from some other buffer or stream):
-// void const * my_callback( void * optional_output, void const * input_ptr, int num_pixels, int x, int y, void * context )
-// {
-// CopyOrStreamData( optional_output, other_data_src, num_pixels * pixel_width_in_bytes );
-// return optional_output; // return the optional buffer that we filled
-// }
-//
-// Third example, input another buffer without copying: (zero-copy from other buffer):
-// void const * my_callback( void * optional_output, void const * input_ptr, int num_pixels, int x, int y, void * context )
-// {
-// void * pixels = ( (char*) other_image_base ) + ( y * other_image_stride ) + ( x * other_pixel_width_in_bytes );
-// return pixels; // return pointer to your data without copying
-// }
-//
-//
-// The output callback is considerably simpler - it just calls you so that you can dump
-// out each scanline. You could even directly copy out to disk if you have a simple format
-// like TGA or BMP. You can also convert to other output types here if you want.
-//
-// Simple example:
-// void const * my_output( void * output_ptr, int num_pixels, int y, void * context )
-// {
-// percentage_done = y / output_height;
-// fwrite( output_ptr, pixel_width_in_bytes, num_pixels, output_file );
-// }
-//===============================================================
-
-
-
-
-//===============================================================
-// optional built-in profiling API
-//--------------------------------
-
-#ifdef STBIR_PROFILE
-
-typedef struct STBIR_PROFILE_INFO
-{
- stbir_uint64 total_clocks;
-
- // how many clocks spent (of total_clocks) in the various resize routines, along with a string description
- // there are "resize_count" number of zones
- stbir_uint64 clocks[ 8 ];
- char const ** descriptions;
-
- // count of clocks and descriptions
- stbir_uint32 count;
-} STBIR_PROFILE_INFO;
-
-// use after calling stbir_resize_extended (or stbir_build_samplers or stbir_build_samplers_with_splits)
-STBIRDEF void stbir_resize_build_profile_info( STBIR_PROFILE_INFO * out_info, STBIR_RESIZE const * resize );
-
-// use after calling stbir_resize_extended
-STBIRDEF void stbir_resize_extended_profile_info( STBIR_PROFILE_INFO * out_info, STBIR_RESIZE const * resize );
-
-// use after calling stbir_resize_extended_split
-STBIRDEF void stbir_resize_split_profile_info( STBIR_PROFILE_INFO * out_info, STBIR_RESIZE const * resize, int split_start, int split_num );
-
-//===============================================================
-
-#endif
-
-
-//// end header file /////////////////////////////////////////////////////
-#endif // STBIR_INCLUDE_STB_IMAGE_RESIZE2_H
-
-#if defined(STB_IMAGE_RESIZE_IMPLEMENTATION) || defined(STB_IMAGE_RESIZE2_IMPLEMENTATION)
-
-#ifndef STBIR_ASSERT
-#include
-#define STBIR_ASSERT(x) assert(x)
-#endif
-
-#ifndef STBIR_MALLOC
-#include
-#define STBIR_MALLOC(size,user_data) ((void)(user_data), malloc(size))
-#define STBIR_FREE(ptr,user_data) ((void)(user_data), free(ptr))
-// (we used the comma operator to evaluate user_data, to avoid "unused parameter" warnings)
-#endif
-
-#ifdef _MSC_VER
-
-#define stbir__inline __forceinline
-
-#else
-
-#define stbir__inline __inline__
-
-// Clang address sanitizer
-#if defined(__has_feature)
- #if __has_feature(address_sanitizer) || __has_feature(memory_sanitizer)
- #ifndef STBIR__SEPARATE_ALLOCATIONS
- #define STBIR__SEPARATE_ALLOCATIONS
- #endif
- #endif
-#endif
-
-#endif
-
-// GCC and MSVC
-#if defined(__SANITIZE_ADDRESS__)
- #ifndef STBIR__SEPARATE_ALLOCATIONS
- #define STBIR__SEPARATE_ALLOCATIONS
- #endif
-#endif
-
-// Always turn off automatic FMA use - use STBIR_USE_FMA if you want.
-// Otherwise, this is a determinism disaster.
-#ifndef STBIR_DONT_CHANGE_FP_CONTRACT // override in case you don't want this behavior
-#if defined(_MSC_VER) && !defined(__clang__)
-#if _MSC_VER > 1200
-#pragma fp_contract(off)
-#endif
-#elif defined(__GNUC__) && !defined(__clang__)
-#pragma GCC optimize("fp-contract=off")
-#else
-#pragma STDC FP_CONTRACT OFF
-#endif
-#endif
-
-#ifdef _MSC_VER
-#define STBIR__UNUSED(v) (void)(v)
-#else
-#define STBIR__UNUSED(v) (void)sizeof(v)
-#endif
-
-#define STBIR__ARRAY_SIZE(a) (sizeof((a))/sizeof((a)[0]))
-
-
-#ifndef STBIR_DEFAULT_FILTER_UPSAMPLE
-#define STBIR_DEFAULT_FILTER_UPSAMPLE STBIR_FILTER_CATMULLROM
-#endif
-
-#ifndef STBIR_DEFAULT_FILTER_DOWNSAMPLE
-#define STBIR_DEFAULT_FILTER_DOWNSAMPLE STBIR_FILTER_MITCHELL
-#endif
-
-
-#ifndef STBIR__HEADER_FILENAME
-#define STBIR__HEADER_FILENAME "stb_image_resize2.h"
-#endif
-
-// the internal pixel layout enums are in a different order, so we can easily do range comparisons of types
-// the public pixel layout is ordered in a way that if you cast num_channels (1-4) to the enum, you get something sensible
-typedef enum
-{
- STBIRI_1CHANNEL = 0,
- STBIRI_2CHANNEL = 1,
- STBIRI_RGB = 2,
- STBIRI_BGR = 3,
- STBIRI_4CHANNEL = 4,
-
- STBIRI_RGBA = 5,
- STBIRI_BGRA = 6,
- STBIRI_ARGB = 7,
- STBIRI_ABGR = 8,
- STBIRI_RA = 9,
- STBIRI_AR = 10,
-
- STBIRI_RGBA_PM = 11,
- STBIRI_BGRA_PM = 12,
- STBIRI_ARGB_PM = 13,
- STBIRI_ABGR_PM = 14,
- STBIRI_RA_PM = 15,
- STBIRI_AR_PM = 16,
-} stbir_internal_pixel_layout;
-
-// define the public pixel layouts to not compile inside the implementation (to avoid accidental use)
-#define STBIR_BGR bad_dont_use_in_implementation
-#define STBIR_1CHANNEL STBIR_BGR
-#define STBIR_2CHANNEL STBIR_BGR
-#define STBIR_RGB STBIR_BGR
-#define STBIR_RGBA STBIR_BGR
-#define STBIR_4CHANNEL STBIR_BGR
-#define STBIR_BGRA STBIR_BGR
-#define STBIR_ARGB STBIR_BGR
-#define STBIR_ABGR STBIR_BGR
-#define STBIR_RA STBIR_BGR
-#define STBIR_AR STBIR_BGR
-#define STBIR_RGBA_PM STBIR_BGR
-#define STBIR_BGRA_PM STBIR_BGR
-#define STBIR_ARGB_PM STBIR_BGR
-#define STBIR_ABGR_PM STBIR_BGR
-#define STBIR_RA_PM STBIR_BGR
-#define STBIR_AR_PM STBIR_BGR
-
-// must match stbir_datatype
-static unsigned char stbir__type_size[] = {
- 1,1,1,2,4,2 // STBIR_TYPE_UINT8,STBIR_TYPE_UINT8_SRGB,STBIR_TYPE_UINT8_SRGB_ALPHA,STBIR_TYPE_UINT16,STBIR_TYPE_FLOAT,STBIR_TYPE_HALF_FLOAT
-};
-
-// When gathering, the contributors are which source pixels contribute.
-// When scattering, the contributors are which destination pixels are contributed to.
-typedef struct
-{
- int n0; // First contributing pixel
- int n1; // Last contributing pixel
-} stbir__contributors;
-
-typedef struct
-{
- int lowest; // First sample index for whole filter
- int highest; // Last sample index for whole filter
- int widest; // widest single set of samples for an output
-} stbir__filter_extent_info;
-
-typedef struct
-{
- int n0; // First pixel of decode buffer to write to
- int n1; // Last pixel of decode that will be written to
- int pixel_offset_for_input; // Pixel offset into input_scanline
-} stbir__span;
-
-typedef struct stbir__scale_info
-{
- int input_full_size;
- int output_sub_size;
- float scale;
- float inv_scale;
- float pixel_shift; // starting shift in output pixel space (in pixels)
- int scale_is_rational;
- stbir_uint32 scale_numerator, scale_denominator;
-} stbir__scale_info;
-
-typedef struct
-{
- stbir__contributors * contributors;
- float* coefficients;
- stbir__contributors * gather_prescatter_contributors;
- float * gather_prescatter_coefficients;
- stbir__scale_info scale_info;
- float support;
- stbir_filter filter_enum;
- stbir__kernel_callback * filter_kernel;
- stbir__support_callback * filter_support;
- stbir_edge edge;
- int coefficient_width;
- int filter_pixel_width;
- int filter_pixel_margin;
- int num_contributors;
- int contributors_size;
- int coefficients_size;
- stbir__filter_extent_info extent_info;
- int is_gather; // 0 = scatter, 1 = gather with scale >= 1, 2 = gather with scale < 1
- int gather_prescatter_num_contributors;
- int gather_prescatter_coefficient_width;
- int gather_prescatter_contributors_size;
- int gather_prescatter_coefficients_size;
-} stbir__sampler;
-
-typedef struct
-{
- stbir__contributors conservative;
- int edge_sizes[2]; // this can be less than filter_pixel_margin, if the filter and scaling falls off
- stbir__span spans[2]; // can be two spans, if doing input subrect with clamp mode WRAP
-} stbir__extents;
-
-typedef struct
-{
-#ifdef STBIR_PROFILE
- union
- {
- struct { stbir_uint64 total, looping, vertical, horizontal, decode, encode, alpha, unalpha; } named;
- stbir_uint64 array[8];
- } profile;
- stbir_uint64 * current_zone_excluded_ptr;
-#endif
- float* decode_buffer;
-
- int ring_buffer_first_scanline;
- int ring_buffer_last_scanline;
- int ring_buffer_begin_index; // first_scanline is at this index in the ring buffer
- int start_output_y, end_output_y;
- int start_input_y, end_input_y; // used in scatter only
-
- #ifdef STBIR__SEPARATE_ALLOCATIONS
- float** ring_buffers; // one pointer for each ring buffer
- #else
- float* ring_buffer; // one big buffer that we index into
- #endif
-
- float* vertical_buffer;
-
- char no_cache_straddle[64];
-} stbir__per_split_info;
-
-typedef float * stbir__decode_pixels_func( float * decode, int width_times_channels, void const * input );
-typedef void stbir__alpha_weight_func( float * decode_buffer, int width_times_channels );
-typedef void stbir__horizontal_gather_channels_func( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer,
- stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width );
-typedef void stbir__alpha_unweight_func(float * encode_buffer, int width_times_channels );
-typedef void stbir__encode_pixels_func( void * output, int width_times_channels, float const * encode );
-
-struct stbir__info
-{
-#ifdef STBIR_PROFILE
- union
- {
- struct { stbir_uint64 total, build, alloc, horizontal, vertical, cleanup, pivot; } named;
- stbir_uint64 array[7];
- } profile;
- stbir_uint64 * current_zone_excluded_ptr;
-#endif
- stbir__sampler horizontal;
- stbir__sampler vertical;
-
- void const * input_data;
- void * output_data;
-
- int input_stride_bytes;
- int output_stride_bytes;
- int ring_buffer_length_bytes; // The length of an individual entry in the ring buffer. The total number of ring buffers is stbir__get_filter_pixel_width(filter)
- int ring_buffer_num_entries; // Total number of entries in the ring buffer.
-
- stbir_datatype input_type;
- stbir_datatype output_type;
-
- stbir_input_callback * in_pixels_cb;
- void * user_data;
- stbir_output_callback * out_pixels_cb;
-
- stbir__extents scanline_extents;
-
- void * alloced_mem;
- stbir__per_split_info * split_info; // by default 1, but there will be N of these allocated based on the thread init you did
-
- stbir__decode_pixels_func * decode_pixels;
- stbir__alpha_weight_func * alpha_weight;
- stbir__horizontal_gather_channels_func * horizontal_gather_channels;
- stbir__alpha_unweight_func * alpha_unweight;
- stbir__encode_pixels_func * encode_pixels;
-
- int alloc_ring_buffer_num_entries; // Number of entries in the ring buffer that will be allocated
- int splits; // count of splits
-
- stbir_internal_pixel_layout input_pixel_layout_internal;
- stbir_internal_pixel_layout output_pixel_layout_internal;
-
- int input_color_and_type;
- int offset_x, offset_y; // offset within output_data
- int vertical_first;
- int channels;
- int effective_channels; // same as channels, except on RGBA/ARGB (7), or XA/AX (3)
- size_t alloced_total;
-};
-
-
-#define stbir__max_uint8_as_float 255.0f
-#define stbir__max_uint16_as_float 65535.0f
-#define stbir__max_uint8_as_float_inverted 3.9215689e-03f // (1.0f/255.0f)
-#define stbir__max_uint16_as_float_inverted 1.5259022e-05f // (1.0f/65535.0f)
-#define stbir__small_float ((float)1 / (1 << 20) / (1 << 20) / (1 << 20) / (1 << 20) / (1 << 20) / (1 << 20))
-
-// min/max friendly
-#define STBIR_CLAMP(x, xmin, xmax) for(;;) { \
- if ( (x) < (xmin) ) (x) = (xmin); \
- if ( (x) > (xmax) ) (x) = (xmax); \
- break; \
-}
-
-static stbir__inline int stbir__min(int a, int b)
-{
- return a < b ? a : b;
-}
-
-static stbir__inline int stbir__max(int a, int b)
-{
- return a > b ? a : b;
-}
-
-static float stbir__srgb_uchar_to_linear_float[256] = {
- 0.000000f, 0.000304f, 0.000607f, 0.000911f, 0.001214f, 0.001518f, 0.001821f, 0.002125f, 0.002428f, 0.002732f, 0.003035f,
- 0.003347f, 0.003677f, 0.004025f, 0.004391f, 0.004777f, 0.005182f, 0.005605f, 0.006049f, 0.006512f, 0.006995f, 0.007499f,
- 0.008023f, 0.008568f, 0.009134f, 0.009721f, 0.010330f, 0.010960f, 0.011612f, 0.012286f, 0.012983f, 0.013702f, 0.014444f,
- 0.015209f, 0.015996f, 0.016807f, 0.017642f, 0.018500f, 0.019382f, 0.020289f, 0.021219f, 0.022174f, 0.023153f, 0.024158f,
- 0.025187f, 0.026241f, 0.027321f, 0.028426f, 0.029557f, 0.030713f, 0.031896f, 0.033105f, 0.034340f, 0.035601f, 0.036889f,
- 0.038204f, 0.039546f, 0.040915f, 0.042311f, 0.043735f, 0.045186f, 0.046665f, 0.048172f, 0.049707f, 0.051269f, 0.052861f,
- 0.054480f, 0.056128f, 0.057805f, 0.059511f, 0.061246f, 0.063010f, 0.064803f, 0.066626f, 0.068478f, 0.070360f, 0.072272f,
- 0.074214f, 0.076185f, 0.078187f, 0.080220f, 0.082283f, 0.084376f, 0.086500f, 0.088656f, 0.090842f, 0.093059f, 0.095307f,
- 0.097587f, 0.099899f, 0.102242f, 0.104616f, 0.107023f, 0.109462f, 0.111932f, 0.114435f, 0.116971f, 0.119538f, 0.122139f,
- 0.124772f, 0.127438f, 0.130136f, 0.132868f, 0.135633f, 0.138432f, 0.141263f, 0.144128f, 0.147027f, 0.149960f, 0.152926f,
- 0.155926f, 0.158961f, 0.162029f, 0.165132f, 0.168269f, 0.171441f, 0.174647f, 0.177888f, 0.181164f, 0.184475f, 0.187821f,
- 0.191202f, 0.194618f, 0.198069f, 0.201556f, 0.205079f, 0.208637f, 0.212231f, 0.215861f, 0.219526f, 0.223228f, 0.226966f,
- 0.230740f, 0.234551f, 0.238398f, 0.242281f, 0.246201f, 0.250158f, 0.254152f, 0.258183f, 0.262251f, 0.266356f, 0.270498f,
- 0.274677f, 0.278894f, 0.283149f, 0.287441f, 0.291771f, 0.296138f, 0.300544f, 0.304987f, 0.309469f, 0.313989f, 0.318547f,
- 0.323143f, 0.327778f, 0.332452f, 0.337164f, 0.341914f, 0.346704f, 0.351533f, 0.356400f, 0.361307f, 0.366253f, 0.371238f,
- 0.376262f, 0.381326f, 0.386430f, 0.391573f, 0.396755f, 0.401978f, 0.407240f, 0.412543f, 0.417885f, 0.423268f, 0.428691f,
- 0.434154f, 0.439657f, 0.445201f, 0.450786f, 0.456411f, 0.462077f, 0.467784f, 0.473532f, 0.479320f, 0.485150f, 0.491021f,
- 0.496933f, 0.502887f, 0.508881f, 0.514918f, 0.520996f, 0.527115f, 0.533276f, 0.539480f, 0.545725f, 0.552011f, 0.558340f,
- 0.564712f, 0.571125f, 0.577581f, 0.584078f, 0.590619f, 0.597202f, 0.603827f, 0.610496f, 0.617207f, 0.623960f, 0.630757f,
- 0.637597f, 0.644480f, 0.651406f, 0.658375f, 0.665387f, 0.672443f, 0.679543f, 0.686685f, 0.693872f, 0.701102f, 0.708376f,
- 0.715694f, 0.723055f, 0.730461f, 0.737911f, 0.745404f, 0.752942f, 0.760525f, 0.768151f, 0.775822f, 0.783538f, 0.791298f,
- 0.799103f, 0.806952f, 0.814847f, 0.822786f, 0.830770f, 0.838799f, 0.846873f, 0.854993f, 0.863157f, 0.871367f, 0.879622f,
- 0.887923f, 0.896269f, 0.904661f, 0.913099f, 0.921582f, 0.930111f, 0.938686f, 0.947307f, 0.955974f, 0.964686f, 0.973445f,
- 0.982251f, 0.991102f, 1.0f
-};
-
-typedef union
-{
- unsigned int u;
- float f;
-} stbir__FP32;
-
-// From https://gist.github.com/rygorous/2203834
-
-static const stbir_uint32 fp32_to_srgb8_tab4[104] = {
- 0x0073000d, 0x007a000d, 0x0080000d, 0x0087000d, 0x008d000d, 0x0094000d, 0x009a000d, 0x00a1000d,
- 0x00a7001a, 0x00b4001a, 0x00c1001a, 0x00ce001a, 0x00da001a, 0x00e7001a, 0x00f4001a, 0x0101001a,
- 0x010e0033, 0x01280033, 0x01410033, 0x015b0033, 0x01750033, 0x018f0033, 0x01a80033, 0x01c20033,
- 0x01dc0067, 0x020f0067, 0x02430067, 0x02760067, 0x02aa0067, 0x02dd0067, 0x03110067, 0x03440067,
- 0x037800ce, 0x03df00ce, 0x044600ce, 0x04ad00ce, 0x051400ce, 0x057b00c5, 0x05dd00bc, 0x063b00b5,
- 0x06970158, 0x07420142, 0x07e30130, 0x087b0120, 0x090b0112, 0x09940106, 0x0a1700fc, 0x0a9500f2,
- 0x0b0f01cb, 0x0bf401ae, 0x0ccb0195, 0x0d950180, 0x0e56016e, 0x0f0d015e, 0x0fbc0150, 0x10630143,
- 0x11070264, 0x1238023e, 0x1357021d, 0x14660201, 0x156601e9, 0x165a01d3, 0x174401c0, 0x182401af,
- 0x18fe0331, 0x1a9602fe, 0x1c1502d2, 0x1d7e02ad, 0x1ed4028d, 0x201a0270, 0x21520256, 0x227d0240,
- 0x239f0443, 0x25c003fe, 0x27bf03c4, 0x29a10392, 0x2b6a0367, 0x2d1d0341, 0x2ebe031f, 0x304d0300,
- 0x31d105b0, 0x34a80555, 0x37520507, 0x39d504c5, 0x3c37048b, 0x3e7c0458, 0x40a8042a, 0x42bd0401,
- 0x44c20798, 0x488e071e, 0x4c1c06b6, 0x4f76065d, 0x52a50610, 0x55ac05cc, 0x5892058f, 0x5b590559,
- 0x5e0c0a23, 0x631c0980, 0x67db08f6, 0x6c55087f, 0x70940818, 0x74a007bd, 0x787d076c, 0x7c330723,
-};
-
-static stbir__inline stbir_uint8 stbir__linear_to_srgb_uchar(float in)
-{
- static const stbir__FP32 almostone = { 0x3f7fffff }; // 1-eps
- static const stbir__FP32 minval = { (127-13) << 23 };
- stbir_uint32 tab,bias,scale,t;
- stbir__FP32 f;
-
- // Clamp to [2^(-13), 1-eps]; these two values map to 0 and 1, respectively.
- // The tests are carefully written so that NaNs map to 0, same as in the reference
- // implementation.
- if (!(in > minval.f)) // written this way to catch NaNs
- return 0;
- if (in > almostone.f)
- return 255;
-
- // Do the table lookup and unpack bias, scale
- f.f = in;
- tab = fp32_to_srgb8_tab4[(f.u - minval.u) >> 20];
- bias = (tab >> 16) << 9;
- scale = tab & 0xffff;
-
- // Grab next-highest mantissa bits and perform linear interpolation
- t = (f.u >> 12) & 0xff;
- return (unsigned char) ((bias + scale*t) >> 16);
-}
-
-#ifndef STBIR_FORCE_GATHER_FILTER_SCANLINES_AMOUNT
-#define STBIR_FORCE_GATHER_FILTER_SCANLINES_AMOUNT 32 // when downsampling and <= 32 scanlines of buffering, use gather. gather used down to 1/8th scaling for 25% win.
-#endif
-
-#ifndef STBIR_FORCE_MINIMUM_SCANLINES_FOR_SPLITS
-#define STBIR_FORCE_MINIMUM_SCANLINES_FOR_SPLITS 4 // when threading, what is the minimum number of scanlines for a split?
-#endif
-
-#define STBIR_INPUT_CALLBACK_PADDING 3
-
-#ifdef _M_IX86_FP
-#if ( _M_IX86_FP >= 1 )
-#ifndef STBIR_SSE
-#define STBIR_SSE
-#endif
-#endif
-#endif
-
-#ifdef __TINYC__
- // tiny c has no intrinsics yet - this can become a version check if they add them
- #define STBIR_NO_SIMD
-#endif
-
-#if defined(_x86_64) || defined( __x86_64__ ) || defined( _M_X64 ) || defined(__x86_64) || defined(_M_AMD64) || defined(__SSE2__) || defined(STBIR_SSE) || defined(STBIR_SSE2)
- #ifndef STBIR_SSE2
- #define STBIR_SSE2
- #endif
- #if defined(__AVX__) || defined(STBIR_AVX2)
- #ifndef STBIR_AVX
- #ifndef STBIR_NO_AVX
- #define STBIR_AVX
- #endif
- #endif
- #endif
- #if defined(__AVX2__) || defined(STBIR_AVX2)
- #ifndef STBIR_NO_AVX2
- #ifndef STBIR_AVX2
- #define STBIR_AVX2
- #endif
- #if defined( _MSC_VER ) && !defined(__clang__)
- #ifndef STBIR_FP16C // FP16C instructions are on all AVX2 cpus, so we can autoselect it here on microsoft - clang needs -m16c
- #define STBIR_FP16C
- #endif
- #endif
- #endif
- #endif
- #ifdef __F16C__
- #ifndef STBIR_FP16C // turn on FP16C instructions if the define is set (for clang and gcc)
- #define STBIR_FP16C
- #endif
- #endif
-#endif
-
-#if defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ ) || ((__ARM_NEON_FP & 4) != 0) || defined(__ARM_NEON__)
-#ifndef STBIR_NEON
-#define STBIR_NEON
-#endif
-#endif
-
-#if defined(_M_ARM) || defined(__arm__)
-#ifdef STBIR_USE_FMA
-#undef STBIR_USE_FMA // no FMA for 32-bit arm on MSVC
-#endif
-#endif
-
-#if defined(__wasm__) && defined(__wasm_simd128__)
-#ifndef STBIR_WASM
-#define STBIR_WASM
-#endif
-#endif
-
-// restrict pointers for the output pointers, other loop and unroll control
-#if defined( _MSC_VER ) && !defined(__clang__)
- #define STBIR_STREAMOUT_PTR( star ) star __restrict
- #define STBIR_NO_UNROLL( ptr ) __assume(ptr) // this oddly keeps msvc from unrolling a loop
- #if _MSC_VER >= 1900
- #define STBIR_NO_UNROLL_LOOP_START __pragma(loop( no_vector ))
- #else
- #define STBIR_NO_UNROLL_LOOP_START
- #endif
-#elif defined( __clang__ )
- #define STBIR_STREAMOUT_PTR( star ) star __restrict__
- #define STBIR_NO_UNROLL( ptr ) __asm__ (""::"r"(ptr))
- #if ( __clang_major__ >= 4 ) || ( ( __clang_major__ >= 3 ) && ( __clang_minor__ >= 5 ) )
- #define STBIR_NO_UNROLL_LOOP_START _Pragma("clang loop unroll(disable)") _Pragma("clang loop vectorize(disable)")
- #else
- #define STBIR_NO_UNROLL_LOOP_START
- #endif
-#elif defined( __GNUC__ )
- #define STBIR_STREAMOUT_PTR( star ) star __restrict__
- #define STBIR_NO_UNROLL( ptr ) __asm__ (""::"r"(ptr))
- #if __GNUC__ >= 14
- #define STBIR_NO_UNROLL_LOOP_START _Pragma("GCC unroll 0") _Pragma("GCC novector")
- #else
- #define STBIR_NO_UNROLL_LOOP_START
- #endif
- #define STBIR_NO_UNROLL_LOOP_START_INF_FOR
-#else
- #define STBIR_STREAMOUT_PTR( star ) star
- #define STBIR_NO_UNROLL( ptr )
- #define STBIR_NO_UNROLL_LOOP_START
-#endif
-
-#ifndef STBIR_NO_UNROLL_LOOP_START_INF_FOR
-#define STBIR_NO_UNROLL_LOOP_START_INF_FOR STBIR_NO_UNROLL_LOOP_START
-#endif
-
-#ifdef STBIR_NO_SIMD // force simd off for whatever reason
-
-// force simd off overrides everything else, so clear it all
-
-#ifdef STBIR_SSE2
-#undef STBIR_SSE2
-#endif
-
-#ifdef STBIR_AVX
-#undef STBIR_AVX
-#endif
-
-#ifdef STBIR_NEON
-#undef STBIR_NEON
-#endif
-
-#ifdef STBIR_AVX2
-#undef STBIR_AVX2
-#endif
-
-#ifdef STBIR_FP16C
-#undef STBIR_FP16C
-#endif
-
-#ifdef STBIR_WASM
-#undef STBIR_WASM
-#endif
-
-#ifdef STBIR_SIMD
-#undef STBIR_SIMD
-#endif
-
-#else // STBIR_SIMD
-
-#ifdef STBIR_SSE2
- #include
-
- #define stbir__simdf __m128
- #define stbir__simdi __m128i
-
- #define stbir_simdi_castf( reg ) _mm_castps_si128(reg)
- #define stbir_simdf_casti( reg ) _mm_castsi128_ps(reg)
-
- #define stbir__simdf_load( reg, ptr ) (reg) = _mm_loadu_ps( (float const*)(ptr) )
- #define stbir__simdi_load( reg, ptr ) (reg) = _mm_loadu_si128 ( (stbir__simdi const*)(ptr) )
- #define stbir__simdf_load1( out, ptr ) (out) = _mm_load_ss( (float const*)(ptr) ) // top values can be random (not denormal or nan for perf)
- #define stbir__simdi_load1( out, ptr ) (out) = _mm_castps_si128( _mm_load_ss( (float const*)(ptr) ))
- #define stbir__simdf_load1z( out, ptr ) (out) = _mm_load_ss( (float const*)(ptr) ) // top values must be zero
- #define stbir__simdf_frep4( fvar ) _mm_set_ps1( fvar )
- #define stbir__simdf_load1frep4( out, fvar ) (out) = _mm_set_ps1( fvar )
- #define stbir__simdf_load2( out, ptr ) (out) = _mm_castsi128_ps( _mm_loadl_epi64( (__m128i*)(ptr)) ) // top values can be random (not denormal or nan for perf)
- #define stbir__simdf_load2z( out, ptr ) (out) = _mm_castsi128_ps( _mm_loadl_epi64( (__m128i*)(ptr)) ) // top values must be zero
- #define stbir__simdf_load2hmerge( out, reg, ptr ) (out) = _mm_castpd_ps(_mm_loadh_pd( _mm_castps_pd(reg), (double*)(ptr) ))
-
- #define stbir__simdf_zeroP() _mm_setzero_ps()
- #define stbir__simdf_zero( reg ) (reg) = _mm_setzero_ps()
-
- #define stbir__simdf_store( ptr, reg ) _mm_storeu_ps( (float*)(ptr), reg )
- #define stbir__simdf_store1( ptr, reg ) _mm_store_ss( (float*)(ptr), reg )
- #define stbir__simdf_store2( ptr, reg ) _mm_storel_epi64( (__m128i*)(ptr), _mm_castps_si128(reg) )
- #define stbir__simdf_store2h( ptr, reg ) _mm_storeh_pd( (double*)(ptr), _mm_castps_pd(reg) )
-
- #define stbir__simdi_store( ptr, reg ) _mm_storeu_si128( (__m128i*)(ptr), reg )
- #define stbir__simdi_store1( ptr, reg ) _mm_store_ss( (float*)(ptr), _mm_castsi128_ps(reg) )
- #define stbir__simdi_store2( ptr, reg ) _mm_storel_epi64( (__m128i*)(ptr), (reg) )
-
- #define stbir__prefetch( ptr ) _mm_prefetch((char*)(ptr), _MM_HINT_T0 )
-
- #define stbir__simdi_expand_u8_to_u32(out0,out1,out2,out3,ireg) \
- { \
- stbir__simdi zero = _mm_setzero_si128(); \
- out2 = _mm_unpacklo_epi8( ireg, zero ); \
- out3 = _mm_unpackhi_epi8( ireg, zero ); \
- out0 = _mm_unpacklo_epi16( out2, zero ); \
- out1 = _mm_unpackhi_epi16( out2, zero ); \
- out2 = _mm_unpacklo_epi16( out3, zero ); \
- out3 = _mm_unpackhi_epi16( out3, zero ); \
- }
-
-#define stbir__simdi_expand_u8_to_1u32(out,ireg) \
- { \
- stbir__simdi zero = _mm_setzero_si128(); \
- out = _mm_unpacklo_epi8( ireg, zero ); \
- out = _mm_unpacklo_epi16( out, zero ); \
- }
-
- #define stbir__simdi_expand_u16_to_u32(out0,out1,ireg) \
- { \
- stbir__simdi zero = _mm_setzero_si128(); \
- out0 = _mm_unpacklo_epi16( ireg, zero ); \
- out1 = _mm_unpackhi_epi16( ireg, zero ); \
- }
-
- #define stbir__simdf_convert_float_to_i32( i, f ) (i) = _mm_cvttps_epi32(f)
- #define stbir__simdf_convert_float_to_int( f ) _mm_cvtt_ss2si(f)
- #define stbir__simdf_convert_float_to_uint8( f ) ((unsigned char)_mm_cvtsi128_si32(_mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(f,STBIR__CONSTF(STBIR_max_uint8_as_float)),_mm_setzero_ps()))))
- #define stbir__simdf_convert_float_to_short( f ) ((unsigned short)_mm_cvtsi128_si32(_mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(f,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps()))))
-
- #define stbir__simdi_to_int( i ) _mm_cvtsi128_si32(i)
- #define stbir__simdi_convert_i32_to_float(out, ireg) (out) = _mm_cvtepi32_ps( ireg )
- #define stbir__simdf_add( out, reg0, reg1 ) (out) = _mm_add_ps( reg0, reg1 )
- #define stbir__simdf_mult( out, reg0, reg1 ) (out) = _mm_mul_ps( reg0, reg1 )
- #define stbir__simdf_mult_mem( out, reg, ptr ) (out) = _mm_mul_ps( reg, _mm_loadu_ps( (float const*)(ptr) ) )
- #define stbir__simdf_mult1_mem( out, reg, ptr ) (out) = _mm_mul_ss( reg, _mm_load_ss( (float const*)(ptr) ) )
- #define stbir__simdf_add_mem( out, reg, ptr ) (out) = _mm_add_ps( reg, _mm_loadu_ps( (float const*)(ptr) ) )
- #define stbir__simdf_add1_mem( out, reg, ptr ) (out) = _mm_add_ss( reg, _mm_load_ss( (float const*)(ptr) ) )
-
- #ifdef STBIR_USE_FMA // not on by default to maintain bit identical simd to non-simd
- #include
- #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = _mm_fmadd_ps( mul1, mul2, add )
- #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = _mm_fmadd_ss( mul1, mul2, add )
- #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = _mm_fmadd_ps( mul, _mm_loadu_ps( (float const*)(ptr) ), add )
- #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = _mm_fmadd_ss( mul, _mm_load_ss( (float const*)(ptr) ), add )
- #else
- #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = _mm_add_ps( add, _mm_mul_ps( mul1, mul2 ) )
- #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = _mm_add_ss( add, _mm_mul_ss( mul1, mul2 ) )
- #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = _mm_add_ps( add, _mm_mul_ps( mul, _mm_loadu_ps( (float const*)(ptr) ) ) )
- #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = _mm_add_ss( add, _mm_mul_ss( mul, _mm_load_ss( (float const*)(ptr) ) ) )
- #endif
-
- #define stbir__simdf_add1( out, reg0, reg1 ) (out) = _mm_add_ss( reg0, reg1 )
- #define stbir__simdf_mult1( out, reg0, reg1 ) (out) = _mm_mul_ss( reg0, reg1 )
-
- #define stbir__simdf_and( out, reg0, reg1 ) (out) = _mm_and_ps( reg0, reg1 )
- #define stbir__simdf_or( out, reg0, reg1 ) (out) = _mm_or_ps( reg0, reg1 )
-
- #define stbir__simdf_min( out, reg0, reg1 ) (out) = _mm_min_ps( reg0, reg1 )
- #define stbir__simdf_max( out, reg0, reg1 ) (out) = _mm_max_ps( reg0, reg1 )
- #define stbir__simdf_min1( out, reg0, reg1 ) (out) = _mm_min_ss( reg0, reg1 )
- #define stbir__simdf_max1( out, reg0, reg1 ) (out) = _mm_max_ss( reg0, reg1 )
-
- #define stbir__simdf_0123ABCDto3ABx( out, reg0, reg1 ) (out)=_mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( _mm_shuffle_ps( reg1,reg0, (0<<0) + (1<<2) + (2<<4) + (3<<6) )), (3<<0) + (0<<2) + (1<<4) + (2<<6) ) )
- #define stbir__simdf_0123ABCDto23Ax( out, reg0, reg1 ) (out)=_mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( _mm_shuffle_ps( reg1,reg0, (0<<0) + (1<<2) + (2<<4) + (3<<6) )), (2<<0) + (3<<2) + (0<<4) + (1<<6) ) )
-
- static const stbir__simdf STBIR_zeroones = { 0.0f,1.0f,0.0f,1.0f };
- static const stbir__simdf STBIR_onezeros = { 1.0f,0.0f,1.0f,0.0f };
- #define stbir__simdf_aaa1( out, alp, ones ) (out)=_mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( _mm_movehl_ps( ones, alp ) ), (1<<0) + (1<<2) + (1<<4) + (2<<6) ) )
- #define stbir__simdf_1aaa( out, alp, ones ) (out)=_mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( _mm_movelh_ps( ones, alp ) ), (0<<0) + (2<<2) + (2<<4) + (2<<6) ) )
- #define stbir__simdf_a1a1( out, alp, ones) (out) = _mm_or_ps( _mm_castsi128_ps( _mm_srli_epi64( _mm_castps_si128(alp), 32 ) ), STBIR_zeroones )
- #define stbir__simdf_1a1a( out, alp, ones) (out) = _mm_or_ps( _mm_castsi128_ps( _mm_slli_epi64( _mm_castps_si128(alp), 32 ) ), STBIR_onezeros )
-
- #define stbir__simdf_swiz( reg, one, two, three, four ) _mm_castsi128_ps( _mm_shuffle_epi32( _mm_castps_si128( reg ), (one<<0) + (two<<2) + (three<<4) + (four<<6) ) )
-
- #define stbir__simdi_and( out, reg0, reg1 ) (out) = _mm_and_si128( reg0, reg1 )
- #define stbir__simdi_or( out, reg0, reg1 ) (out) = _mm_or_si128( reg0, reg1 )
- #define stbir__simdi_16madd( out, reg0, reg1 ) (out) = _mm_madd_epi16( reg0, reg1 )
-
- #define stbir__simdf_pack_to_8bytes(out,aa,bb) \
- { \
- stbir__simdf af,bf; \
- stbir__simdi a,b; \
- af = _mm_min_ps( aa, STBIR_max_uint8_as_float ); \
- bf = _mm_min_ps( bb, STBIR_max_uint8_as_float ); \
- af = _mm_max_ps( af, _mm_setzero_ps() ); \
- bf = _mm_max_ps( bf, _mm_setzero_ps() ); \
- a = _mm_cvttps_epi32( af ); \
- b = _mm_cvttps_epi32( bf ); \
- a = _mm_packs_epi32( a, b ); \
- out = _mm_packus_epi16( a, a ); \
- }
-
- #define stbir__simdf_load4_transposed( o0, o1, o2, o3, ptr ) \
- stbir__simdf_load( o0, (ptr) ); \
- stbir__simdf_load( o1, (ptr)+4 ); \
- stbir__simdf_load( o2, (ptr)+8 ); \
- stbir__simdf_load( o3, (ptr)+12 ); \
- { \
- __m128 tmp0, tmp1, tmp2, tmp3; \
- tmp0 = _mm_unpacklo_ps(o0, o1); \
- tmp2 = _mm_unpacklo_ps(o2, o3); \
- tmp1 = _mm_unpackhi_ps(o0, o1); \
- tmp3 = _mm_unpackhi_ps(o2, o3); \
- o0 = _mm_movelh_ps(tmp0, tmp2); \
- o1 = _mm_movehl_ps(tmp2, tmp0); \
- o2 = _mm_movelh_ps(tmp1, tmp3); \
- o3 = _mm_movehl_ps(tmp3, tmp1); \
- }
-
- #define stbir__interleave_pack_and_store_16_u8( ptr, r0, r1, r2, r3 ) \
- r0 = _mm_packs_epi32( r0, r1 ); \
- r2 = _mm_packs_epi32( r2, r3 ); \
- r1 = _mm_unpacklo_epi16( r0, r2 ); \
- r3 = _mm_unpackhi_epi16( r0, r2 ); \
- r0 = _mm_unpacklo_epi16( r1, r3 ); \
- r2 = _mm_unpackhi_epi16( r1, r3 ); \
- r0 = _mm_packus_epi16( r0, r2 ); \
- stbir__simdi_store( ptr, r0 ); \
-
- #define stbir__simdi_32shr( out, reg, imm ) out = _mm_srli_epi32( reg, imm )
-
- #if defined(_MSC_VER) && !defined(__clang__)
- // msvc inits with 8 bytes
- #define STBIR__CONST_32_TO_8( v ) (char)(unsigned char)((v)&255),(char)(unsigned char)(((v)>>8)&255),(char)(unsigned char)(((v)>>16)&255),(char)(unsigned char)(((v)>>24)&255)
- #define STBIR__CONST_4_32i( v ) STBIR__CONST_32_TO_8( v ), STBIR__CONST_32_TO_8( v ), STBIR__CONST_32_TO_8( v ), STBIR__CONST_32_TO_8( v )
- #define STBIR__CONST_4d_32i( v0, v1, v2, v3 ) STBIR__CONST_32_TO_8( v0 ), STBIR__CONST_32_TO_8( v1 ), STBIR__CONST_32_TO_8( v2 ), STBIR__CONST_32_TO_8( v3 )
- #else
- // everything else inits with long long's
- #define STBIR__CONST_4_32i( v ) (long long)((((stbir_uint64)(stbir_uint32)(v))<<32)|((stbir_uint64)(stbir_uint32)(v))),(long long)((((stbir_uint64)(stbir_uint32)(v))<<32)|((stbir_uint64)(stbir_uint32)(v)))
- #define STBIR__CONST_4d_32i( v0, v1, v2, v3 ) (long long)((((stbir_uint64)(stbir_uint32)(v1))<<32)|((stbir_uint64)(stbir_uint32)(v0))),(long long)((((stbir_uint64)(stbir_uint32)(v3))<<32)|((stbir_uint64)(stbir_uint32)(v2)))
- #endif
-
- #define STBIR__SIMDF_CONST(var, x) stbir__simdf var = { x, x, x, x }
- #define STBIR__SIMDI_CONST(var, x) stbir__simdi var = { STBIR__CONST_4_32i(x) }
- #define STBIR__CONSTF(var) (var)
- #define STBIR__CONSTI(var) (var)
-
- #if defined(STBIR_AVX) || defined(__SSE4_1__)
- #include
- #define stbir__simdf_pack_to_8words(out,reg0,reg1) out = _mm_packus_epi32(_mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(reg0,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps())), _mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(reg1,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps())))
- #else
- static STBIR__SIMDI_CONST(stbir__s32_32768, 32768);
- static STBIR__SIMDI_CONST(stbir__s16_32768, ((32768<<16)|32768));
-
- #define stbir__simdf_pack_to_8words(out,reg0,reg1) \
- { \
- stbir__simdi tmp0,tmp1; \
- tmp0 = _mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(reg0,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps())); \
- tmp1 = _mm_cvttps_epi32(_mm_max_ps(_mm_min_ps(reg1,STBIR__CONSTF(STBIR_max_uint16_as_float)),_mm_setzero_ps())); \
- tmp0 = _mm_sub_epi32( tmp0, stbir__s32_32768 ); \
- tmp1 = _mm_sub_epi32( tmp1, stbir__s32_32768 ); \
- out = _mm_packs_epi32( tmp0, tmp1 ); \
- out = _mm_sub_epi16( out, stbir__s16_32768 ); \
- }
-
- #endif
-
- #define STBIR_SIMD
-
- // if we detect AVX, set the simd8 defines
- #ifdef STBIR_AVX
- #include
- #define STBIR_SIMD8
- #define stbir__simdf8 __m256
- #define stbir__simdi8 __m256i
- #define stbir__simdf8_load( out, ptr ) (out) = _mm256_loadu_ps( (float const *)(ptr) )
- #define stbir__simdi8_load( out, ptr ) (out) = _mm256_loadu_si256( (__m256i const *)(ptr) )
- #define stbir__simdf8_mult( out, a, b ) (out) = _mm256_mul_ps( (a), (b) )
- #define stbir__simdf8_store( ptr, out ) _mm256_storeu_ps( (float*)(ptr), out )
- #define stbir__simdi8_store( ptr, reg ) _mm256_storeu_si256( (__m256i*)(ptr), reg )
- #define stbir__simdf8_frep8( fval ) _mm256_set1_ps( fval )
-
- #define stbir__simdf8_min( out, reg0, reg1 ) (out) = _mm256_min_ps( reg0, reg1 )
- #define stbir__simdf8_max( out, reg0, reg1 ) (out) = _mm256_max_ps( reg0, reg1 )
-
- #define stbir__simdf8_add4halves( out, bot4, top8 ) (out) = _mm_add_ps( bot4, _mm256_extractf128_ps( top8, 1 ) )
- #define stbir__simdf8_mult_mem( out, reg, ptr ) (out) = _mm256_mul_ps( reg, _mm256_loadu_ps( (float const*)(ptr) ) )
- #define stbir__simdf8_add_mem( out, reg, ptr ) (out) = _mm256_add_ps( reg, _mm256_loadu_ps( (float const*)(ptr) ) )
- #define stbir__simdf8_add( out, a, b ) (out) = _mm256_add_ps( a, b )
- #define stbir__simdf8_load1b( out, ptr ) (out) = _mm256_broadcast_ss( ptr )
- #define stbir__simdf_load1rep4( out, ptr ) (out) = _mm_broadcast_ss( ptr ) // avx load instruction
-
- #define stbir__simdi8_convert_i32_to_float(out, ireg) (out) = _mm256_cvtepi32_ps( ireg )
- #define stbir__simdf8_convert_float_to_i32( i, f ) (i) = _mm256_cvttps_epi32(f)
-
- #define stbir__simdf8_bot4s( out, a, b ) (out) = _mm256_permute2f128_ps(a,b, (0<<0)+(2<<4) )
- #define stbir__simdf8_top4s( out, a, b ) (out) = _mm256_permute2f128_ps(a,b, (1<<0)+(3<<4) )
-
- #define stbir__simdf8_gettop4( reg ) _mm256_extractf128_ps(reg,1)
-
- #ifdef STBIR_AVX2
-
- #define stbir__simdi8_expand_u8_to_u32(out0,out1,ireg) \
- { \
- stbir__simdi8 a, zero =_mm256_setzero_si256();\
- a = _mm256_permute4x64_epi64( _mm256_unpacklo_epi8( _mm256_permute4x64_epi64(_mm256_castsi128_si256(ireg),(0<<0)+(2<<2)+(1<<4)+(3<<6)), zero ),(0<<0)+(2<<2)+(1<<4)+(3<<6)); \
- out0 = _mm256_unpacklo_epi16( a, zero ); \
- out1 = _mm256_unpackhi_epi16( a, zero ); \
- }
-
- #define stbir__simdf8_pack_to_16bytes(out,aa,bb) \
- { \
- stbir__simdi8 t; \
- stbir__simdf8 af,bf; \
- stbir__simdi8 a,b; \
- af = _mm256_min_ps( aa, STBIR_max_uint8_as_floatX ); \
- bf = _mm256_min_ps( bb, STBIR_max_uint8_as_floatX ); \
- af = _mm256_max_ps( af, _mm256_setzero_ps() ); \
- bf = _mm256_max_ps( bf, _mm256_setzero_ps() ); \
- a = _mm256_cvttps_epi32( af ); \
- b = _mm256_cvttps_epi32( bf ); \
- t = _mm256_permute4x64_epi64( _mm256_packs_epi32( a, b ), (0<<0)+(2<<2)+(1<<4)+(3<<6) ); \
- out = _mm256_castsi256_si128( _mm256_permute4x64_epi64( _mm256_packus_epi16( t, t ), (0<<0)+(2<<2)+(1<<4)+(3<<6) ) ); \
- }
-
- #define stbir__simdi8_expand_u16_to_u32(out,ireg) out = _mm256_unpacklo_epi16( _mm256_permute4x64_epi64(_mm256_castsi128_si256(ireg),(0<<0)+(2<<2)+(1<<4)+(3<<6)), _mm256_setzero_si256() );
-
- #define stbir__simdf8_pack_to_16words(out,aa,bb) \
- { \
- stbir__simdf8 af,bf; \
- stbir__simdi8 a,b; \
- af = _mm256_min_ps( aa, STBIR_max_uint16_as_floatX ); \
- bf = _mm256_min_ps( bb, STBIR_max_uint16_as_floatX ); \
- af = _mm256_max_ps( af, _mm256_setzero_ps() ); \
- bf = _mm256_max_ps( bf, _mm256_setzero_ps() ); \
- a = _mm256_cvttps_epi32( af ); \
- b = _mm256_cvttps_epi32( bf ); \
- (out) = _mm256_permute4x64_epi64( _mm256_packus_epi32(a, b), (0<<0)+(2<<2)+(1<<4)+(3<<6) ); \
- }
-
- #else
-
- #define stbir__simdi8_expand_u8_to_u32(out0,out1,ireg) \
- { \
- stbir__simdi a,zero = _mm_setzero_si128(); \
- a = _mm_unpacklo_epi8( ireg, zero ); \
- out0 = _mm256_setr_m128i( _mm_unpacklo_epi16( a, zero ), _mm_unpackhi_epi16( a, zero ) ); \
- a = _mm_unpackhi_epi8( ireg, zero ); \
- out1 = _mm256_setr_m128i( _mm_unpacklo_epi16( a, zero ), _mm_unpackhi_epi16( a, zero ) ); \
- }
-
- #define stbir__simdf8_pack_to_16bytes(out,aa,bb) \
- { \
- stbir__simdi t; \
- stbir__simdf8 af,bf; \
- stbir__simdi8 a,b; \
- af = _mm256_min_ps( aa, STBIR_max_uint8_as_floatX ); \
- bf = _mm256_min_ps( bb, STBIR_max_uint8_as_floatX ); \
- af = _mm256_max_ps( af, _mm256_setzero_ps() ); \
- bf = _mm256_max_ps( bf, _mm256_setzero_ps() ); \
- a = _mm256_cvttps_epi32( af ); \
- b = _mm256_cvttps_epi32( bf ); \
- out = _mm_packs_epi32( _mm256_castsi256_si128(a), _mm256_extractf128_si256( a, 1 ) ); \
- out = _mm_packus_epi16( out, out ); \
- t = _mm_packs_epi32( _mm256_castsi256_si128(b), _mm256_extractf128_si256( b, 1 ) ); \
- t = _mm_packus_epi16( t, t ); \
- out = _mm_castps_si128( _mm_shuffle_ps( _mm_castsi128_ps(out), _mm_castsi128_ps(t), (0<<0)+(1<<2)+(0<<4)+(1<<6) ) ); \
- }
-
- #define stbir__simdi8_expand_u16_to_u32(out,ireg) \
- { \
- stbir__simdi a,b,zero = _mm_setzero_si128(); \
- a = _mm_unpacklo_epi16( ireg, zero ); \
- b = _mm_unpackhi_epi16( ireg, zero ); \
- out = _mm256_insertf128_si256( _mm256_castsi128_si256( a ), b, 1 ); \
- }
-
- #define stbir__simdf8_pack_to_16words(out,aa,bb) \
- { \
- stbir__simdi t0,t1; \
- stbir__simdf8 af,bf; \
- stbir__simdi8 a,b; \
- af = _mm256_min_ps( aa, STBIR_max_uint16_as_floatX ); \
- bf = _mm256_min_ps( bb, STBIR_max_uint16_as_floatX ); \
- af = _mm256_max_ps( af, _mm256_setzero_ps() ); \
- bf = _mm256_max_ps( bf, _mm256_setzero_ps() ); \
- a = _mm256_cvttps_epi32( af ); \
- b = _mm256_cvttps_epi32( bf ); \
- t0 = _mm_packus_epi32( _mm256_castsi256_si128(a), _mm256_extractf128_si256( a, 1 ) ); \
- t1 = _mm_packus_epi32( _mm256_castsi256_si128(b), _mm256_extractf128_si256( b, 1 ) ); \
- out = _mm256_setr_m128i( t0, t1 ); \
- }
-
- #endif
-
- static __m256i stbir_00001111 = { STBIR__CONST_4d_32i( 0, 0, 0, 0 ), STBIR__CONST_4d_32i( 1, 1, 1, 1 ) };
- #define stbir__simdf8_0123to00001111( out, in ) (out) = _mm256_permutevar_ps ( in, stbir_00001111 )
-
- static __m256i stbir_22223333 = { STBIR__CONST_4d_32i( 2, 2, 2, 2 ), STBIR__CONST_4d_32i( 3, 3, 3, 3 ) };
- #define stbir__simdf8_0123to22223333( out, in ) (out) = _mm256_permutevar_ps ( in, stbir_22223333 )
-
- #define stbir__simdf8_0123to2222( out, in ) (out) = stbir__simdf_swiz(_mm256_castps256_ps128(in), 2,2,2,2 )
-
- #define stbir__simdf8_load4b( out, ptr ) (out) = _mm256_broadcast_ps( (__m128 const *)(ptr) )
-
- static __m256i stbir_00112233 = { STBIR__CONST_4d_32i( 0, 0, 1, 1 ), STBIR__CONST_4d_32i( 2, 2, 3, 3 ) };
- #define stbir__simdf8_0123to00112233( out, in ) (out) = _mm256_permutevar_ps ( in, stbir_00112233 )
- #define stbir__simdf8_add4( out, a8, b ) (out) = _mm256_add_ps( a8, _mm256_castps128_ps256( b ) )
-
- static __m256i stbir_load6 = { STBIR__CONST_4_32i( 0x80000000 ), STBIR__CONST_4d_32i( 0x80000000, 0x80000000, 0, 0 ) };
- #define stbir__simdf8_load6z( out, ptr ) (out) = _mm256_maskload_ps( ptr, stbir_load6 )
-
- #define stbir__simdf8_0123to00000000( out, in ) (out) = _mm256_shuffle_ps ( in, in, (0<<0)+(0<<2)+(0<<4)+(0<<6) )
- #define stbir__simdf8_0123to11111111( out, in ) (out) = _mm256_shuffle_ps ( in, in, (1<<0)+(1<<2)+(1<<4)+(1<<6) )
- #define stbir__simdf8_0123to22222222( out, in ) (out) = _mm256_shuffle_ps ( in, in, (2<<0)+(2<<2)+(2<<4)+(2<<6) )
- #define stbir__simdf8_0123to33333333( out, in ) (out) = _mm256_shuffle_ps ( in, in, (3<<0)+(3<<2)+(3<<4)+(3<<6) )
- #define stbir__simdf8_0123to21032103( out, in ) (out) = _mm256_shuffle_ps ( in, in, (2<<0)+(1<<2)+(0<<4)+(3<<6) )
- #define stbir__simdf8_0123to32103210( out, in ) (out) = _mm256_shuffle_ps ( in, in, (3<<0)+(2<<2)+(1<<4)+(0<<6) )
- #define stbir__simdf8_0123to12301230( out, in ) (out) = _mm256_shuffle_ps ( in, in, (1<<0)+(2<<2)+(3<<4)+(0<<6) )
- #define stbir__simdf8_0123to10321032( out, in ) (out) = _mm256_shuffle_ps ( in, in, (1<<0)+(0<<2)+(3<<4)+(2<<6) )
- #define stbir__simdf8_0123to30123012( out, in ) (out) = _mm256_shuffle_ps ( in, in, (3<<0)+(0<<2)+(1<<4)+(2<<6) )
-
- #define stbir__simdf8_0123to11331133( out, in ) (out) = _mm256_shuffle_ps ( in, in, (1<<0)+(1<<2)+(3<<4)+(3<<6) )
- #define stbir__simdf8_0123to00220022( out, in ) (out) = _mm256_shuffle_ps ( in, in, (0<<0)+(0<<2)+(2<<4)+(2<<6) )
-
- #define stbir__simdf8_aaa1( out, alp, ones ) (out) = _mm256_blend_ps( alp, ones, (1<<0)+(1<<1)+(1<<2)+(0<<3)+(1<<4)+(1<<5)+(1<<6)+(0<<7)); (out)=_mm256_shuffle_ps( out,out, (3<<0) + (3<<2) + (3<<4) + (0<<6) )
- #define stbir__simdf8_1aaa( out, alp, ones ) (out) = _mm256_blend_ps( alp, ones, (0<<0)+(1<<1)+(1<<2)+(1<<3)+(0<<4)+(1<<5)+(1<<6)+(1<<7)); (out)=_mm256_shuffle_ps( out,out, (1<<0) + (0<<2) + (0<<4) + (0<<6) )
- #define stbir__simdf8_a1a1( out, alp, ones) (out) = _mm256_blend_ps( alp, ones, (1<<0)+(0<<1)+(1<<2)+(0<<3)+(1<<4)+(0<<5)+(1<<6)+(0<<7)); (out)=_mm256_shuffle_ps( out,out, (1<<0) + (0<<2) + (3<<4) + (2<<6) )
- #define stbir__simdf8_1a1a( out, alp, ones) (out) = _mm256_blend_ps( alp, ones, (0<<0)+(1<<1)+(0<<2)+(1<<3)+(0<<4)+(1<<5)+(0<<6)+(1<<7)); (out)=_mm256_shuffle_ps( out,out, (1<<0) + (0<<2) + (3<<4) + (2<<6) )
-
- #define stbir__simdf8_zero( reg ) (reg) = _mm256_setzero_ps()
-
- #ifdef STBIR_USE_FMA // not on by default to maintain bit identical simd to non-simd
- #define stbir__simdf8_madd( out, add, mul1, mul2 ) (out) = _mm256_fmadd_ps( mul1, mul2, add )
- #define stbir__simdf8_madd_mem( out, add, mul, ptr ) (out) = _mm256_fmadd_ps( mul, _mm256_loadu_ps( (float const*)(ptr) ), add )
- #define stbir__simdf8_madd_mem4( out, add, mul, ptr )(out) = _mm256_fmadd_ps( _mm256_setr_m128( mul, _mm_setzero_ps() ), _mm256_setr_m128( _mm_loadu_ps( (float const*)(ptr) ), _mm_setzero_ps() ), add )
- #else
- #define stbir__simdf8_madd( out, add, mul1, mul2 ) (out) = _mm256_add_ps( add, _mm256_mul_ps( mul1, mul2 ) )
- #define stbir__simdf8_madd_mem( out, add, mul, ptr ) (out) = _mm256_add_ps( add, _mm256_mul_ps( mul, _mm256_loadu_ps( (float const*)(ptr) ) ) )
- #define stbir__simdf8_madd_mem4( out, add, mul, ptr ) (out) = _mm256_add_ps( add, _mm256_setr_m128( _mm_mul_ps( mul, _mm_loadu_ps( (float const*)(ptr) ) ), _mm_setzero_ps() ) )
- #endif
- #define stbir__if_simdf8_cast_to_simdf4( val ) _mm256_castps256_ps128( val )
-
- #endif
-
- #ifdef STBIR_FLOORF
- #undef STBIR_FLOORF
- #endif
- #define STBIR_FLOORF stbir_simd_floorf
- static stbir__inline float stbir_simd_floorf(float x) // martins floorf
- {
- #if defined(STBIR_AVX) || defined(__SSE4_1__) || defined(STBIR_SSE41)
- __m128 t = _mm_set_ss(x);
- return _mm_cvtss_f32( _mm_floor_ss(t, t) );
- #else
- __m128 f = _mm_set_ss(x);
- __m128 t = _mm_cvtepi32_ps(_mm_cvttps_epi32(f));
- __m128 r = _mm_add_ss(t, _mm_and_ps(_mm_cmplt_ss(f, t), _mm_set_ss(-1.0f)));
- return _mm_cvtss_f32(r);
- #endif
- }
-
- #ifdef STBIR_CEILF
- #undef STBIR_CEILF
- #endif
- #define STBIR_CEILF stbir_simd_ceilf
- static stbir__inline float stbir_simd_ceilf(float x) // martins ceilf
- {
- #if defined(STBIR_AVX) || defined(__SSE4_1__) || defined(STBIR_SSE41)
- __m128 t = _mm_set_ss(x);
- return _mm_cvtss_f32( _mm_ceil_ss(t, t) );
- #else
- __m128 f = _mm_set_ss(x);
- __m128 t = _mm_cvtepi32_ps(_mm_cvttps_epi32(f));
- __m128 r = _mm_add_ss(t, _mm_and_ps(_mm_cmplt_ss(t, f), _mm_set_ss(1.0f)));
- return _mm_cvtss_f32(r);
- #endif
- }
-
-#elif defined(STBIR_NEON)
-
- #include
-
- #define stbir__simdf float32x4_t
- #define stbir__simdi uint32x4_t
-
- #define stbir_simdi_castf( reg ) vreinterpretq_u32_f32(reg)
- #define stbir_simdf_casti( reg ) vreinterpretq_f32_u32(reg)
-
- #define stbir__simdf_load( reg, ptr ) (reg) = vld1q_f32( (float const*)(ptr) )
- #define stbir__simdi_load( reg, ptr ) (reg) = vld1q_u32( (uint32_t const*)(ptr) )
- #define stbir__simdf_load1( out, ptr ) (out) = vld1q_dup_f32( (float const*)(ptr) ) // top values can be random (not denormal or nan for perf)
- #define stbir__simdi_load1( out, ptr ) (out) = vld1q_dup_u32( (uint32_t const*)(ptr) )
- #define stbir__simdf_load1z( out, ptr ) (out) = vld1q_lane_f32( (float const*)(ptr), vdupq_n_f32(0), 0 ) // top values must be zero
- #define stbir__simdf_frep4( fvar ) vdupq_n_f32( fvar )
- #define stbir__simdf_load1frep4( out, fvar ) (out) = vdupq_n_f32( fvar )
- #define stbir__simdf_load2( out, ptr ) (out) = vcombine_f32( vld1_f32( (float const*)(ptr) ), vcreate_f32(0) ) // top values can be random (not denormal or nan for perf)
- #define stbir__simdf_load2z( out, ptr ) (out) = vcombine_f32( vld1_f32( (float const*)(ptr) ), vcreate_f32(0) ) // top values must be zero
- #define stbir__simdf_load2hmerge( out, reg, ptr ) (out) = vcombine_f32( vget_low_f32(reg), vld1_f32( (float const*)(ptr) ) )
-
- #define stbir__simdf_zeroP() vdupq_n_f32(0)
- #define stbir__simdf_zero( reg ) (reg) = vdupq_n_f32(0)
-
- #define stbir__simdf_store( ptr, reg ) vst1q_f32( (float*)(ptr), reg )
- #define stbir__simdf_store1( ptr, reg ) vst1q_lane_f32( (float*)(ptr), reg, 0)
- #define stbir__simdf_store2( ptr, reg ) vst1_f32( (float*)(ptr), vget_low_f32(reg) )
- #define stbir__simdf_store2h( ptr, reg ) vst1_f32( (float*)(ptr), vget_high_f32(reg) )
-
- #define stbir__simdi_store( ptr, reg ) vst1q_u32( (uint32_t*)(ptr), reg )
- #define stbir__simdi_store1( ptr, reg ) vst1q_lane_u32( (uint32_t*)(ptr), reg, 0 )
- #define stbir__simdi_store2( ptr, reg ) vst1_u32( (uint32_t*)(ptr), vget_low_u32(reg) )
-
- #define stbir__prefetch( ptr )
-
- #define stbir__simdi_expand_u8_to_u32(out0,out1,out2,out3,ireg) \
- { \
- uint16x8_t l = vmovl_u8( vget_low_u8 ( vreinterpretq_u8_u32(ireg) ) ); \
- uint16x8_t h = vmovl_u8( vget_high_u8( vreinterpretq_u8_u32(ireg) ) ); \
- out0 = vmovl_u16( vget_low_u16 ( l ) ); \
- out1 = vmovl_u16( vget_high_u16( l ) ); \
- out2 = vmovl_u16( vget_low_u16 ( h ) ); \
- out3 = vmovl_u16( vget_high_u16( h ) ); \
- }
-
- #define stbir__simdi_expand_u8_to_1u32(out,ireg) \
- { \
- uint16x8_t tmp = vmovl_u8( vget_low_u8( vreinterpretq_u8_u32(ireg) ) ); \
- out = vmovl_u16( vget_low_u16( tmp ) ); \
- }
-
- #define stbir__simdi_expand_u16_to_u32(out0,out1,ireg) \
- { \
- uint16x8_t tmp = vreinterpretq_u16_u32(ireg); \
- out0 = vmovl_u16( vget_low_u16 ( tmp ) ); \
- out1 = vmovl_u16( vget_high_u16( tmp ) ); \
- }
-
- #define stbir__simdf_convert_float_to_i32( i, f ) (i) = vreinterpretq_u32_s32( vcvtq_s32_f32(f) )
- #define stbir__simdf_convert_float_to_int( f ) vgetq_lane_s32(vcvtq_s32_f32(f), 0)
- #define stbir__simdi_to_int( i ) (int)vgetq_lane_u32(i, 0)
- #define stbir__simdf_convert_float_to_uint8( f ) ((unsigned char)vgetq_lane_s32(vcvtq_s32_f32(vmaxq_f32(vminq_f32(f,STBIR__CONSTF(STBIR_max_uint8_as_float)),vdupq_n_f32(0))), 0))
- #define stbir__simdf_convert_float_to_short( f ) ((unsigned short)vgetq_lane_s32(vcvtq_s32_f32(vmaxq_f32(vminq_f32(f,STBIR__CONSTF(STBIR_max_uint16_as_float)),vdupq_n_f32(0))), 0))
- #define stbir__simdi_convert_i32_to_float(out, ireg) (out) = vcvtq_f32_s32( vreinterpretq_s32_u32(ireg) )
- #define stbir__simdf_add( out, reg0, reg1 ) (out) = vaddq_f32( reg0, reg1 )
- #define stbir__simdf_mult( out, reg0, reg1 ) (out) = vmulq_f32( reg0, reg1 )
- #define stbir__simdf_mult_mem( out, reg, ptr ) (out) = vmulq_f32( reg, vld1q_f32( (float const*)(ptr) ) )
- #define stbir__simdf_mult1_mem( out, reg, ptr ) (out) = vmulq_f32( reg, vld1q_dup_f32( (float const*)(ptr) ) )
- #define stbir__simdf_add_mem( out, reg, ptr ) (out) = vaddq_f32( reg, vld1q_f32( (float const*)(ptr) ) )
- #define stbir__simdf_add1_mem( out, reg, ptr ) (out) = vaddq_f32( reg, vld1q_dup_f32( (float const*)(ptr) ) )
-
- #ifdef STBIR_USE_FMA // not on by default to maintain bit identical simd to non-simd (and also x64 no madd to arm madd)
- #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = vfmaq_f32( add, mul1, mul2 )
- #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = vfmaq_f32( add, mul1, mul2 )
- #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = vfmaq_f32( add, mul, vld1q_f32( (float const*)(ptr) ) )
- #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = vfmaq_f32( add, mul, vld1q_dup_f32( (float const*)(ptr) ) )
- #else
- #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = vaddq_f32( add, vmulq_f32( mul1, mul2 ) )
- #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = vaddq_f32( add, vmulq_f32( mul1, mul2 ) )
- #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = vaddq_f32( add, vmulq_f32( mul, vld1q_f32( (float const*)(ptr) ) ) )
- #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = vaddq_f32( add, vmulq_f32( mul, vld1q_dup_f32( (float const*)(ptr) ) ) )
- #endif
-
- #define stbir__simdf_add1( out, reg0, reg1 ) (out) = vaddq_f32( reg0, reg1 )
- #define stbir__simdf_mult1( out, reg0, reg1 ) (out) = vmulq_f32( reg0, reg1 )
-
- #define stbir__simdf_and( out, reg0, reg1 ) (out) = vreinterpretq_f32_u32( vandq_u32( vreinterpretq_u32_f32(reg0), vreinterpretq_u32_f32(reg1) ) )
- #define stbir__simdf_or( out, reg0, reg1 ) (out) = vreinterpretq_f32_u32( vorrq_u32( vreinterpretq_u32_f32(reg0), vreinterpretq_u32_f32(reg1) ) )
-
- #define stbir__simdf_min( out, reg0, reg1 ) (out) = vminq_f32( reg0, reg1 )
- #define stbir__simdf_max( out, reg0, reg1 ) (out) = vmaxq_f32( reg0, reg1 )
- #define stbir__simdf_min1( out, reg0, reg1 ) (out) = vminq_f32( reg0, reg1 )
- #define stbir__simdf_max1( out, reg0, reg1 ) (out) = vmaxq_f32( reg0, reg1 )
-
- #define stbir__simdf_0123ABCDto3ABx( out, reg0, reg1 ) (out) = vextq_f32( reg0, reg1, 3 )
- #define stbir__simdf_0123ABCDto23Ax( out, reg0, reg1 ) (out) = vextq_f32( reg0, reg1, 2 )
-
- #define stbir__simdf_a1a1( out, alp, ones ) (out) = vzipq_f32(vuzpq_f32(alp, alp).val[1], ones).val[0]
- #define stbir__simdf_1a1a( out, alp, ones ) (out) = vzipq_f32(ones, vuzpq_f32(alp, alp).val[0]).val[0]
-
- #if defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ )
-
- #define stbir__simdf_aaa1( out, alp, ones ) (out) = vcopyq_laneq_f32(vdupq_n_f32(vgetq_lane_f32(alp, 3)), 3, ones, 3)
- #define stbir__simdf_1aaa( out, alp, ones ) (out) = vcopyq_laneq_f32(vdupq_n_f32(vgetq_lane_f32(alp, 0)), 0, ones, 0)
-
- #if defined( _MSC_VER ) && !defined(__clang__)
- #define stbir_make16(a,b,c,d) vcombine_u8( \
- vcreate_u8( (4*a+0) | ((4*a+1)<<8) | ((4*a+2)<<16) | ((4*a+3)<<24) | \
- ((stbir_uint64)(4*b+0)<<32) | ((stbir_uint64)(4*b+1)<<40) | ((stbir_uint64)(4*b+2)<<48) | ((stbir_uint64)(4*b+3)<<56)), \
- vcreate_u8( (4*c+0) | ((4*c+1)<<8) | ((4*c+2)<<16) | ((4*c+3)<<24) | \
- ((stbir_uint64)(4*d+0)<<32) | ((stbir_uint64)(4*d+1)<<40) | ((stbir_uint64)(4*d+2)<<48) | ((stbir_uint64)(4*d+3)<<56) ) )
-
- static stbir__inline uint8x16x2_t stbir_make16x2(float32x4_t rega,float32x4_t regb)
- {
- uint8x16x2_t r = { vreinterpretq_u8_f32(rega), vreinterpretq_u8_f32(regb) };
- return r;
- }
- #else
- #define stbir_make16(a,b,c,d) (uint8x16_t){4*a+0,4*a+1,4*a+2,4*a+3,4*b+0,4*b+1,4*b+2,4*b+3,4*c+0,4*c+1,4*c+2,4*c+3,4*d+0,4*d+1,4*d+2,4*d+3}
- #define stbir_make16x2(a,b) (uint8x16x2_t){{vreinterpretq_u8_f32(a),vreinterpretq_u8_f32(b)}}
- #endif
-
- #define stbir__simdf_swiz( reg, one, two, three, four ) vreinterpretq_f32_u8( vqtbl1q_u8( vreinterpretq_u8_f32(reg), stbir_make16(one, two, three, four) ) )
- #define stbir__simdf_swiz2( rega, regb, one, two, three, four ) vreinterpretq_f32_u8( vqtbl2q_u8( stbir_make16x2(rega,regb), stbir_make16(one, two, three, four) ) )
-
- #define stbir__simdi_16madd( out, reg0, reg1 ) \
- { \
- int16x8_t r0 = vreinterpretq_s16_u32(reg0); \
- int16x8_t r1 = vreinterpretq_s16_u32(reg1); \
- int32x4_t tmp0 = vmull_s16( vget_low_s16(r0), vget_low_s16(r1) ); \
- int32x4_t tmp1 = vmull_s16( vget_high_s16(r0), vget_high_s16(r1) ); \
- (out) = vreinterpretq_u32_s32( vpaddq_s32(tmp0, tmp1) ); \
- }
-
- #else
-
- #define stbir__simdf_aaa1( out, alp, ones ) (out) = vsetq_lane_f32(1.0f, vdupq_n_f32(vgetq_lane_f32(alp, 3)), 3)
- #define stbir__simdf_1aaa( out, alp, ones ) (out) = vsetq_lane_f32(1.0f, vdupq_n_f32(vgetq_lane_f32(alp, 0)), 0)
-
- #if defined( _MSC_VER ) && !defined(__clang__)
- static stbir__inline uint8x8x2_t stbir_make8x2(float32x4_t reg)
- {
- uint8x8x2_t r = { { vget_low_u8(vreinterpretq_u8_f32(reg)), vget_high_u8(vreinterpretq_u8_f32(reg)) } };
- return r;
- }
- #define stbir_make8(a,b) vcreate_u8( \
- (4*a+0) | ((4*a+1)<<8) | ((4*a+2)<<16) | ((4*a+3)<<24) | \
- ((stbir_uint64)(4*b+0)<<32) | ((stbir_uint64)(4*b+1)<<40) | ((stbir_uint64)(4*b+2)<<48) | ((stbir_uint64)(4*b+3)<<56) )
- #else
- #define stbir_make8x2(reg) (uint8x8x2_t){ { vget_low_u8(vreinterpretq_u8_f32(reg)), vget_high_u8(vreinterpretq_u8_f32(reg)) } }
- #define stbir_make8(a,b) (uint8x8_t){4*a+0,4*a+1,4*a+2,4*a+3,4*b+0,4*b+1,4*b+2,4*b+3}
- #endif
-
- #define stbir__simdf_swiz( reg, one, two, three, four ) vreinterpretq_f32_u8( vcombine_u8( \
- vtbl2_u8( stbir_make8x2( reg ), stbir_make8( one, two ) ), \
- vtbl2_u8( stbir_make8x2( reg ), stbir_make8( three, four ) ) ) )
-
- #define stbir__simdi_16madd( out, reg0, reg1 ) \
- { \
- int16x8_t r0 = vreinterpretq_s16_u32(reg0); \
- int16x8_t r1 = vreinterpretq_s16_u32(reg1); \
- int32x4_t tmp0 = vmull_s16( vget_low_s16(r0), vget_low_s16(r1) ); \
- int32x4_t tmp1 = vmull_s16( vget_high_s16(r0), vget_high_s16(r1) ); \
- int32x2_t out0 = vpadd_s32( vget_low_s32(tmp0), vget_high_s32(tmp0) ); \
- int32x2_t out1 = vpadd_s32( vget_low_s32(tmp1), vget_high_s32(tmp1) ); \
- (out) = vreinterpretq_u32_s32( vcombine_s32(out0, out1) ); \
- }
-
- #endif
-
- #define stbir__simdi_and( out, reg0, reg1 ) (out) = vandq_u32( reg0, reg1 )
- #define stbir__simdi_or( out, reg0, reg1 ) (out) = vorrq_u32( reg0, reg1 )
-
- #define stbir__simdf_pack_to_8bytes(out,aa,bb) \
- { \
- float32x4_t af = vmaxq_f32( vminq_f32(aa,STBIR__CONSTF(STBIR_max_uint8_as_float) ), vdupq_n_f32(0) ); \
- float32x4_t bf = vmaxq_f32( vminq_f32(bb,STBIR__CONSTF(STBIR_max_uint8_as_float) ), vdupq_n_f32(0) ); \
- int16x4_t ai = vqmovn_s32( vcvtq_s32_f32( af ) ); \
- int16x4_t bi = vqmovn_s32( vcvtq_s32_f32( bf ) ); \
- uint8x8_t out8 = vqmovun_s16( vcombine_s16(ai, bi) ); \
- out = vreinterpretq_u32_u8( vcombine_u8(out8, out8) ); \
- }
-
- #define stbir__simdf_pack_to_8words(out,aa,bb) \
- { \
- float32x4_t af = vmaxq_f32( vminq_f32(aa,STBIR__CONSTF(STBIR_max_uint16_as_float) ), vdupq_n_f32(0) ); \
- float32x4_t bf = vmaxq_f32( vminq_f32(bb,STBIR__CONSTF(STBIR_max_uint16_as_float) ), vdupq_n_f32(0) ); \
- int32x4_t ai = vcvtq_s32_f32( af ); \
- int32x4_t bi = vcvtq_s32_f32( bf ); \
- out = vreinterpretq_u32_u16( vcombine_u16(vqmovun_s32(ai), vqmovun_s32(bi)) ); \
- }
-
- #define stbir__interleave_pack_and_store_16_u8( ptr, r0, r1, r2, r3 ) \
- { \
- int16x4x2_t tmp0 = vzip_s16( vqmovn_s32(vreinterpretq_s32_u32(r0)), vqmovn_s32(vreinterpretq_s32_u32(r2)) ); \
- int16x4x2_t tmp1 = vzip_s16( vqmovn_s32(vreinterpretq_s32_u32(r1)), vqmovn_s32(vreinterpretq_s32_u32(r3)) ); \
- uint8x8x2_t out = \
- { { \
- vqmovun_s16( vcombine_s16(tmp0.val[0], tmp0.val[1]) ), \
- vqmovun_s16( vcombine_s16(tmp1.val[0], tmp1.val[1]) ), \
- } }; \
- vst2_u8(ptr, out); \
- }
-
- #define stbir__simdf_load4_transposed( o0, o1, o2, o3, ptr ) \
- { \
- float32x4x4_t tmp = vld4q_f32(ptr); \
- o0 = tmp.val[0]; \
- o1 = tmp.val[1]; \
- o2 = tmp.val[2]; \
- o3 = tmp.val[3]; \
- }
-
- #define stbir__simdi_32shr( out, reg, imm ) out = vshrq_n_u32( reg, imm )
-
- #if defined( _MSC_VER ) && !defined(__clang__)
- #define STBIR__SIMDF_CONST(var, x) __declspec(align(8)) float var[] = { x, x, x, x }
- #define STBIR__SIMDI_CONST(var, x) __declspec(align(8)) uint32_t var[] = { x, x, x, x }
- #define STBIR__CONSTF(var) (*(const float32x4_t*)var)
- #define STBIR__CONSTI(var) (*(const uint32x4_t*)var)
- #else
- #define STBIR__SIMDF_CONST(var, x) stbir__simdf var = { x, x, x, x }
- #define STBIR__SIMDI_CONST(var, x) stbir__simdi var = { x, x, x, x }
- #define STBIR__CONSTF(var) (var)
- #define STBIR__CONSTI(var) (var)
- #endif
-
- #ifdef STBIR_FLOORF
- #undef STBIR_FLOORF
- #endif
- #define STBIR_FLOORF stbir_simd_floorf
- static stbir__inline float stbir_simd_floorf(float x)
- {
- #if defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ )
- return vget_lane_f32( vrndm_f32( vdup_n_f32(x) ), 0);
- #else
- float32x2_t f = vdup_n_f32(x);
- float32x2_t t = vcvt_f32_s32(vcvt_s32_f32(f));
- uint32x2_t a = vclt_f32(f, t);
- uint32x2_t b = vreinterpret_u32_f32(vdup_n_f32(-1.0f));
- float32x2_t r = vadd_f32(t, vreinterpret_f32_u32(vand_u32(a, b)));
- return vget_lane_f32(r, 0);
- #endif
- }
-
- #ifdef STBIR_CEILF
- #undef STBIR_CEILF
- #endif
- #define STBIR_CEILF stbir_simd_ceilf
- static stbir__inline float stbir_simd_ceilf(float x)
- {
- #if defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ )
- return vget_lane_f32( vrndp_f32( vdup_n_f32(x) ), 0);
- #else
- float32x2_t f = vdup_n_f32(x);
- float32x2_t t = vcvt_f32_s32(vcvt_s32_f32(f));
- uint32x2_t a = vclt_f32(t, f);
- uint32x2_t b = vreinterpret_u32_f32(vdup_n_f32(1.0f));
- float32x2_t r = vadd_f32(t, vreinterpret_f32_u32(vand_u32(a, b)));
- return vget_lane_f32(r, 0);
- #endif
- }
-
- #define STBIR_SIMD
-
-#elif defined(STBIR_WASM)
-
- #include
-
- #define stbir__simdf v128_t
- #define stbir__simdi v128_t
-
- #define stbir_simdi_castf( reg ) (reg)
- #define stbir_simdf_casti( reg ) (reg)
-
- #define stbir__simdf_load( reg, ptr ) (reg) = wasm_v128_load( (void const*)(ptr) )
- #define stbir__simdi_load( reg, ptr ) (reg) = wasm_v128_load( (void const*)(ptr) )
- #define stbir__simdf_load1( out, ptr ) (out) = wasm_v128_load32_splat( (void const*)(ptr) ) // top values can be random (not denormal or nan for perf)
- #define stbir__simdi_load1( out, ptr ) (out) = wasm_v128_load32_splat( (void const*)(ptr) )
- #define stbir__simdf_load1z( out, ptr ) (out) = wasm_v128_load32_zero( (void const*)(ptr) ) // top values must be zero
- #define stbir__simdf_frep4( fvar ) wasm_f32x4_splat( fvar )
- #define stbir__simdf_load1frep4( out, fvar ) (out) = wasm_f32x4_splat( fvar )
- #define stbir__simdf_load2( out, ptr ) (out) = wasm_v128_load64_splat( (void const*)(ptr) ) // top values can be random (not denormal or nan for perf)
- #define stbir__simdf_load2z( out, ptr ) (out) = wasm_v128_load64_zero( (void const*)(ptr) ) // top values must be zero
- #define stbir__simdf_load2hmerge( out, reg, ptr ) (out) = wasm_v128_load64_lane( (void const*)(ptr), reg, 1 )
-
- #define stbir__simdf_zeroP() wasm_f32x4_const_splat(0)
- #define stbir__simdf_zero( reg ) (reg) = wasm_f32x4_const_splat(0)
-
- #define stbir__simdf_store( ptr, reg ) wasm_v128_store( (void*)(ptr), reg )
- #define stbir__simdf_store1( ptr, reg ) wasm_v128_store32_lane( (void*)(ptr), reg, 0 )
- #define stbir__simdf_store2( ptr, reg ) wasm_v128_store64_lane( (void*)(ptr), reg, 0 )
- #define stbir__simdf_store2h( ptr, reg ) wasm_v128_store64_lane( (void*)(ptr), reg, 1 )
-
- #define stbir__simdi_store( ptr, reg ) wasm_v128_store( (void*)(ptr), reg )
- #define stbir__simdi_store1( ptr, reg ) wasm_v128_store32_lane( (void*)(ptr), reg, 0 )
- #define stbir__simdi_store2( ptr, reg ) wasm_v128_store64_lane( (void*)(ptr), reg, 0 )
-
- #define stbir__prefetch( ptr )
-
- #define stbir__simdi_expand_u8_to_u32(out0,out1,out2,out3,ireg) \
- { \
- v128_t l = wasm_u16x8_extend_low_u8x16 ( ireg ); \
- v128_t h = wasm_u16x8_extend_high_u8x16( ireg ); \
- out0 = wasm_u32x4_extend_low_u16x8 ( l ); \
- out1 = wasm_u32x4_extend_high_u16x8( l ); \
- out2 = wasm_u32x4_extend_low_u16x8 ( h ); \
- out3 = wasm_u32x4_extend_high_u16x8( h ); \
- }
-
- #define stbir__simdi_expand_u8_to_1u32(out,ireg) \
- { \
- v128_t tmp = wasm_u16x8_extend_low_u8x16(ireg); \
- out = wasm_u32x4_extend_low_u16x8(tmp); \
- }
-
- #define stbir__simdi_expand_u16_to_u32(out0,out1,ireg) \
- { \
- out0 = wasm_u32x4_extend_low_u16x8 ( ireg ); \
- out1 = wasm_u32x4_extend_high_u16x8( ireg ); \
- }
-
- #define stbir__simdf_convert_float_to_i32( i, f ) (i) = wasm_i32x4_trunc_sat_f32x4(f)
- #define stbir__simdf_convert_float_to_int( f ) wasm_i32x4_extract_lane(wasm_i32x4_trunc_sat_f32x4(f), 0)
- #define stbir__simdi_to_int( i ) wasm_i32x4_extract_lane(i, 0)
- #define stbir__simdf_convert_float_to_uint8( f ) ((unsigned char)wasm_i32x4_extract_lane(wasm_i32x4_trunc_sat_f32x4(wasm_f32x4_max(wasm_f32x4_min(f,STBIR_max_uint8_as_float),wasm_f32x4_const_splat(0))), 0))
- #define stbir__simdf_convert_float_to_short( f ) ((unsigned short)wasm_i32x4_extract_lane(wasm_i32x4_trunc_sat_f32x4(wasm_f32x4_max(wasm_f32x4_min(f,STBIR_max_uint16_as_float),wasm_f32x4_const_splat(0))), 0))
- #define stbir__simdi_convert_i32_to_float(out, ireg) (out) = wasm_f32x4_convert_i32x4(ireg)
- #define stbir__simdf_add( out, reg0, reg1 ) (out) = wasm_f32x4_add( reg0, reg1 )
- #define stbir__simdf_mult( out, reg0, reg1 ) (out) = wasm_f32x4_mul( reg0, reg1 )
- #define stbir__simdf_mult_mem( out, reg, ptr ) (out) = wasm_f32x4_mul( reg, wasm_v128_load( (void const*)(ptr) ) )
- #define stbir__simdf_mult1_mem( out, reg, ptr ) (out) = wasm_f32x4_mul( reg, wasm_v128_load32_splat( (void const*)(ptr) ) )
- #define stbir__simdf_add_mem( out, reg, ptr ) (out) = wasm_f32x4_add( reg, wasm_v128_load( (void const*)(ptr) ) )
- #define stbir__simdf_add1_mem( out, reg, ptr ) (out) = wasm_f32x4_add( reg, wasm_v128_load32_splat( (void const*)(ptr) ) )
-
- #define stbir__simdf_madd( out, add, mul1, mul2 ) (out) = wasm_f32x4_add( add, wasm_f32x4_mul( mul1, mul2 ) )
- #define stbir__simdf_madd1( out, add, mul1, mul2 ) (out) = wasm_f32x4_add( add, wasm_f32x4_mul( mul1, mul2 ) )
- #define stbir__simdf_madd_mem( out, add, mul, ptr ) (out) = wasm_f32x4_add( add, wasm_f32x4_mul( mul, wasm_v128_load( (void const*)(ptr) ) ) )
- #define stbir__simdf_madd1_mem( out, add, mul, ptr ) (out) = wasm_f32x4_add( add, wasm_f32x4_mul( mul, wasm_v128_load32_splat( (void const*)(ptr) ) ) )
-
- #define stbir__simdf_add1( out, reg0, reg1 ) (out) = wasm_f32x4_add( reg0, reg1 )
- #define stbir__simdf_mult1( out, reg0, reg1 ) (out) = wasm_f32x4_mul( reg0, reg1 )
-
- #define stbir__simdf_and( out, reg0, reg1 ) (out) = wasm_v128_and( reg0, reg1 )
- #define stbir__simdf_or( out, reg0, reg1 ) (out) = wasm_v128_or( reg0, reg1 )
-
- #define stbir__simdf_min( out, reg0, reg1 ) (out) = wasm_f32x4_min( reg0, reg1 )
- #define stbir__simdf_max( out, reg0, reg1 ) (out) = wasm_f32x4_max( reg0, reg1 )
- #define stbir__simdf_min1( out, reg0, reg1 ) (out) = wasm_f32x4_min( reg0, reg1 )
- #define stbir__simdf_max1( out, reg0, reg1 ) (out) = wasm_f32x4_max( reg0, reg1 )
-
- #define stbir__simdf_0123ABCDto3ABx( out, reg0, reg1 ) (out) = wasm_i32x4_shuffle( reg0, reg1, 3, 4, 5, -1 )
- #define stbir__simdf_0123ABCDto23Ax( out, reg0, reg1 ) (out) = wasm_i32x4_shuffle( reg0, reg1, 2, 3, 4, -1 )
-
- #define stbir__simdf_aaa1(out,alp,ones) (out) = wasm_i32x4_shuffle(alp, ones, 3, 3, 3, 4)
- #define stbir__simdf_1aaa(out,alp,ones) (out) = wasm_i32x4_shuffle(alp, ones, 4, 0, 0, 0)
- #define stbir__simdf_a1a1(out,alp,ones) (out) = wasm_i32x4_shuffle(alp, ones, 1, 4, 3, 4)
- #define stbir__simdf_1a1a(out,alp,ones) (out) = wasm_i32x4_shuffle(alp, ones, 4, 0, 4, 2)
-
- #define stbir__simdf_swiz( reg, one, two, three, four ) wasm_i32x4_shuffle(reg, reg, one, two, three, four)
-
- #define stbir__simdi_and( out, reg0, reg1 ) (out) = wasm_v128_and( reg0, reg1 )
- #define stbir__simdi_or( out, reg0, reg1 ) (out) = wasm_v128_or( reg0, reg1 )
- #define stbir__simdi_16madd( out, reg0, reg1 ) (out) = wasm_i32x4_dot_i16x8( reg0, reg1 )
-
- #define stbir__simdf_pack_to_8bytes(out,aa,bb) \
- { \
- v128_t af = wasm_f32x4_max( wasm_f32x4_min(aa, STBIR_max_uint8_as_float), wasm_f32x4_const_splat(0) ); \
- v128_t bf = wasm_f32x4_max( wasm_f32x4_min(bb, STBIR_max_uint8_as_float), wasm_f32x4_const_splat(0) ); \
- v128_t ai = wasm_i32x4_trunc_sat_f32x4( af ); \
- v128_t bi = wasm_i32x4_trunc_sat_f32x4( bf ); \
- v128_t out16 = wasm_i16x8_narrow_i32x4( ai, bi ); \
- out = wasm_u8x16_narrow_i16x8( out16, out16 ); \
- }
-
- #define stbir__simdf_pack_to_8words(out,aa,bb) \
- { \
- v128_t af = wasm_f32x4_max( wasm_f32x4_min(aa, STBIR_max_uint16_as_float), wasm_f32x4_const_splat(0)); \
- v128_t bf = wasm_f32x4_max( wasm_f32x4_min(bb, STBIR_max_uint16_as_float), wasm_f32x4_const_splat(0)); \
- v128_t ai = wasm_i32x4_trunc_sat_f32x4( af ); \
- v128_t bi = wasm_i32x4_trunc_sat_f32x4( bf ); \
- out = wasm_u16x8_narrow_i32x4( ai, bi ); \
- }
-
- #define stbir__interleave_pack_and_store_16_u8( ptr, r0, r1, r2, r3 ) \
- { \
- v128_t tmp0 = wasm_i16x8_narrow_i32x4(r0, r1); \
- v128_t tmp1 = wasm_i16x8_narrow_i32x4(r2, r3); \
- v128_t tmp = wasm_u8x16_narrow_i16x8(tmp0, tmp1); \
- tmp = wasm_i8x16_shuffle(tmp, tmp, 0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15); \
- wasm_v128_store( (void*)(ptr), tmp); \
- }
-
- #define stbir__simdf_load4_transposed( o0, o1, o2, o3, ptr ) \
- { \
- v128_t t0 = wasm_v128_load( ptr ); \
- v128_t t1 = wasm_v128_load( ptr+4 ); \
- v128_t t2 = wasm_v128_load( ptr+8 ); \
- v128_t t3 = wasm_v128_load( ptr+12 ); \
- v128_t s0 = wasm_i32x4_shuffle(t0, t1, 0, 4, 2, 6); \
- v128_t s1 = wasm_i32x4_shuffle(t0, t1, 1, 5, 3, 7); \
- v128_t s2 = wasm_i32x4_shuffle(t2, t3, 0, 4, 2, 6); \
- v128_t s3 = wasm_i32x4_shuffle(t2, t3, 1, 5, 3, 7); \
- o0 = wasm_i32x4_shuffle(s0, s2, 0, 1, 4, 5); \
- o1 = wasm_i32x4_shuffle(s1, s3, 0, 1, 4, 5); \
- o2 = wasm_i32x4_shuffle(s0, s2, 2, 3, 6, 7); \
- o3 = wasm_i32x4_shuffle(s1, s3, 2, 3, 6, 7); \
- }
-
- #define stbir__simdi_32shr( out, reg, imm ) out = wasm_u32x4_shr( reg, imm )
-
- typedef float stbir__f32x4 __attribute__((__vector_size__(16), __aligned__(16)));
- #define STBIR__SIMDF_CONST(var, x) stbir__simdf var = (v128_t)(stbir__f32x4){ x, x, x, x }
- #define STBIR__SIMDI_CONST(var, x) stbir__simdi var = { x, x, x, x }
- #define STBIR__CONSTF(var) (var)
- #define STBIR__CONSTI(var) (var)
-
- #ifdef STBIR_FLOORF
- #undef STBIR_FLOORF
- #endif
- #define STBIR_FLOORF stbir_simd_floorf
- static stbir__inline float stbir_simd_floorf(float x)
- {
- return wasm_f32x4_extract_lane( wasm_f32x4_floor( wasm_f32x4_splat(x) ), 0);
- }
-
- #ifdef STBIR_CEILF
- #undef STBIR_CEILF
- #endif
- #define STBIR_CEILF stbir_simd_ceilf
- static stbir__inline float stbir_simd_ceilf(float x)
- {
- return wasm_f32x4_extract_lane( wasm_f32x4_ceil( wasm_f32x4_splat(x) ), 0);
- }
-
- #define STBIR_SIMD
-
-#endif // SSE2/NEON/WASM
-
-#endif // NO SIMD
-
-#ifdef STBIR_SIMD8
- #define stbir__simdfX stbir__simdf8
- #define stbir__simdiX stbir__simdi8
- #define stbir__simdfX_load stbir__simdf8_load
- #define stbir__simdiX_load stbir__simdi8_load
- #define stbir__simdfX_mult stbir__simdf8_mult
- #define stbir__simdfX_add_mem stbir__simdf8_add_mem
- #define stbir__simdfX_madd_mem stbir__simdf8_madd_mem
- #define stbir__simdfX_store stbir__simdf8_store
- #define stbir__simdiX_store stbir__simdi8_store
- #define stbir__simdf_frepX stbir__simdf8_frep8
- #define stbir__simdfX_madd stbir__simdf8_madd
- #define stbir__simdfX_min stbir__simdf8_min
- #define stbir__simdfX_max stbir__simdf8_max
- #define stbir__simdfX_aaa1 stbir__simdf8_aaa1
- #define stbir__simdfX_1aaa stbir__simdf8_1aaa
- #define stbir__simdfX_a1a1 stbir__simdf8_a1a1
- #define stbir__simdfX_1a1a stbir__simdf8_1a1a
- #define stbir__simdfX_convert_float_to_i32 stbir__simdf8_convert_float_to_i32
- #define stbir__simdfX_pack_to_words stbir__simdf8_pack_to_16words
- #define stbir__simdfX_zero stbir__simdf8_zero
- #define STBIR_onesX STBIR_ones8
- #define STBIR_max_uint8_as_floatX STBIR_max_uint8_as_float8
- #define STBIR_max_uint16_as_floatX STBIR_max_uint16_as_float8
- #define STBIR_simd_point5X STBIR_simd_point58
- #define stbir__simdfX_float_count 8
- #define stbir__simdfX_0123to1230 stbir__simdf8_0123to12301230
- #define stbir__simdfX_0123to2103 stbir__simdf8_0123to21032103
- static const stbir__simdf8 STBIR_max_uint16_as_float_inverted8 = { stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted,stbir__max_uint16_as_float_inverted };
- static const stbir__simdf8 STBIR_max_uint8_as_float_inverted8 = { stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted,stbir__max_uint8_as_float_inverted };
- static const stbir__simdf8 STBIR_ones8 = { 1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0 };
- static const stbir__simdf8 STBIR_simd_point58 = { 0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 };
- static const stbir__simdf8 STBIR_max_uint8_as_float8 = { stbir__max_uint8_as_float,stbir__max_uint8_as_float,stbir__max_uint8_as_float,stbir__max_uint8_as_float, stbir__max_uint8_as_float,stbir__max_uint8_as_float,stbir__max_uint8_as_float,stbir__max_uint8_as_float };
- static const stbir__simdf8 STBIR_max_uint16_as_float8 = { stbir__max_uint16_as_float,stbir__max_uint16_as_float,stbir__max_uint16_as_float,stbir__max_uint16_as_float, stbir__max_uint16_as_float,stbir__max_uint16_as_float,stbir__max_uint16_as_float,stbir__max_uint16_as_float };
-#else
- #define stbir__simdfX stbir__simdf
- #define stbir__simdiX stbir__simdi
- #define stbir__simdfX_load stbir__simdf_load
- #define stbir__simdiX_load stbir__simdi_load
- #define stbir__simdfX_mult stbir__simdf_mult
- #define stbir__simdfX_add_mem stbir__simdf_add_mem
- #define stbir__simdfX_madd_mem stbir__simdf_madd_mem
- #define stbir__simdfX_store stbir__simdf_store
- #define stbir__simdiX_store stbir__simdi_store
- #define stbir__simdf_frepX stbir__simdf_frep4
- #define stbir__simdfX_madd stbir__simdf_madd
- #define stbir__simdfX_min stbir__simdf_min
- #define stbir__simdfX_max stbir__simdf_max
- #define stbir__simdfX_aaa1 stbir__simdf_aaa1
- #define stbir__simdfX_1aaa stbir__simdf_1aaa
- #define stbir__simdfX_a1a1 stbir__simdf_a1a1
- #define stbir__simdfX_1a1a stbir__simdf_1a1a
- #define stbir__simdfX_convert_float_to_i32 stbir__simdf_convert_float_to_i32
- #define stbir__simdfX_pack_to_words stbir__simdf_pack_to_8words
- #define stbir__simdfX_zero stbir__simdf_zero
- #define STBIR_onesX STBIR__CONSTF(STBIR_ones)
- #define STBIR_simd_point5X STBIR__CONSTF(STBIR_simd_point5)
- #define STBIR_max_uint8_as_floatX STBIR__CONSTF(STBIR_max_uint8_as_float)
- #define STBIR_max_uint16_as_floatX STBIR__CONSTF(STBIR_max_uint16_as_float)
- #define stbir__simdfX_float_count 4
- #define stbir__if_simdf8_cast_to_simdf4( val ) ( val )
- #define stbir__simdfX_0123to1230 stbir__simdf_0123to1230
- #define stbir__simdfX_0123to2103 stbir__simdf_0123to2103
-#endif
-
-
-#if defined(STBIR_NEON) && !defined(_M_ARM) && !defined(__arm__)
-
- #if defined( _MSC_VER ) && !defined(__clang__)
- typedef __int16 stbir__FP16;
- #else
- typedef float16_t stbir__FP16;
- #endif
-
-#else // no NEON, or 32-bit ARM for MSVC
-
- typedef union stbir__FP16
- {
- unsigned short u;
- } stbir__FP16;
-
-#endif
-
-#if (!defined(STBIR_NEON) && !defined(STBIR_FP16C)) || (defined(STBIR_NEON) && defined(_M_ARM)) || (defined(STBIR_NEON) && defined(__arm__))
-
- // Fabian's half float routines, see: https://gist.github.com/rygorous/2156668
-
- static stbir__inline float stbir__half_to_float( stbir__FP16 h )
- {
- static const stbir__FP32 magic = { (254 - 15) << 23 };
- static const stbir__FP32 was_infnan = { (127 + 16) << 23 };
- stbir__FP32 o;
-
- o.u = (h.u & 0x7fff) << 13; // exponent/mantissa bits
- o.f *= magic.f; // exponent adjust
- if (o.f >= was_infnan.f) // make sure Inf/NaN survive
- o.u |= 255 << 23;
- o.u |= (h.u & 0x8000) << 16; // sign bit
- return o.f;
- }
-
- static stbir__inline stbir__FP16 stbir__float_to_half(float val)
- {
- stbir__FP32 f32infty = { 255 << 23 };
- stbir__FP32 f16max = { (127 + 16) << 23 };
- stbir__FP32 denorm_magic = { ((127 - 15) + (23 - 10) + 1) << 23 };
- unsigned int sign_mask = 0x80000000u;
- stbir__FP16 o = { 0 };
- stbir__FP32 f;
- unsigned int sign;
-
- f.f = val;
- sign = f.u & sign_mask;
- f.u ^= sign;
-
- if (f.u >= f16max.u) // result is Inf or NaN (all exponent bits set)
- o.u = (f.u > f32infty.u) ? 0x7e00 : 0x7c00; // NaN->qNaN and Inf->Inf
- else // (De)normalized number or zero
- {
- if (f.u < (113 << 23)) // resulting FP16 is subnormal or zero
- {
- // use a magic value to align our 10 mantissa bits at the bottom of
- // the float. as long as FP addition is round-to-nearest-even this
- // just works.
- f.f += denorm_magic.f;
- // and one integer subtract of the bias later, we have our final float!
- o.u = (unsigned short) ( f.u - denorm_magic.u );
- }
- else
- {
- unsigned int mant_odd = (f.u >> 13) & 1; // resulting mantissa is odd
- // update exponent, rounding bias part 1
- f.u = f.u + ((15u - 127) << 23) + 0xfff;
- // rounding bias part 2
- f.u += mant_odd;
- // take the bits!
- o.u = (unsigned short) ( f.u >> 13 );
- }
- }
-
- o.u |= sign >> 16;
- return o;
- }
-
-#endif
-
-
-#if defined(STBIR_FP16C)
-
- #include
-
- static stbir__inline void stbir__half_to_float_SIMD(float * output, stbir__FP16 const * input)
- {
- _mm256_storeu_ps( (float*)output, _mm256_cvtph_ps( _mm_loadu_si128( (__m128i const* )input ) ) );
- }
-
- static stbir__inline void stbir__float_to_half_SIMD(stbir__FP16 * output, float const * input)
- {
- _mm_storeu_si128( (__m128i*)output, _mm256_cvtps_ph( _mm256_loadu_ps( input ), 0 ) );
- }
-
- static stbir__inline float stbir__half_to_float( stbir__FP16 h )
- {
- return _mm_cvtss_f32( _mm_cvtph_ps( _mm_cvtsi32_si128( (int)h.u ) ) );
- }
-
- static stbir__inline stbir__FP16 stbir__float_to_half( float f )
- {
- stbir__FP16 h;
- h.u = (unsigned short) _mm_cvtsi128_si32( _mm_cvtps_ph( _mm_set_ss( f ), 0 ) );
- return h;
- }
-
-#elif defined(STBIR_SSE2)
-
- // Fabian's half float routines, see: https://gist.github.com/rygorous/2156668
- stbir__inline static void stbir__half_to_float_SIMD(float * output, void const * input)
- {
- static const STBIR__SIMDI_CONST(mask_nosign, 0x7fff);
- static const STBIR__SIMDI_CONST(smallest_normal, 0x0400);
- static const STBIR__SIMDI_CONST(infinity, 0x7c00);
- static const STBIR__SIMDI_CONST(expadjust_normal, (127 - 15) << 23);
- static const STBIR__SIMDI_CONST(magic_denorm, 113 << 23);
-
- __m128i i = _mm_loadu_si128 ( (__m128i const*)(input) );
- __m128i h = _mm_unpacklo_epi16 ( i, _mm_setzero_si128() );
- __m128i mnosign = STBIR__CONSTI(mask_nosign);
- __m128i eadjust = STBIR__CONSTI(expadjust_normal);
- __m128i smallest = STBIR__CONSTI(smallest_normal);
- __m128i infty = STBIR__CONSTI(infinity);
- __m128i expmant = _mm_and_si128(mnosign, h);
- __m128i justsign = _mm_xor_si128(h, expmant);
- __m128i b_notinfnan = _mm_cmpgt_epi32(infty, expmant);
- __m128i b_isdenorm = _mm_cmpgt_epi32(smallest, expmant);
- __m128i shifted = _mm_slli_epi32(expmant, 13);
- __m128i adj_infnan = _mm_andnot_si128(b_notinfnan, eadjust);
- __m128i adjusted = _mm_add_epi32(eadjust, shifted);
- __m128i den1 = _mm_add_epi32(shifted, STBIR__CONSTI(magic_denorm));
- __m128i adjusted2 = _mm_add_epi32(adjusted, adj_infnan);
- __m128 den2 = _mm_sub_ps(_mm_castsi128_ps(den1), *(const __m128 *)&magic_denorm);
- __m128 adjusted3 = _mm_and_ps(den2, _mm_castsi128_ps(b_isdenorm));
- __m128 adjusted4 = _mm_andnot_ps(_mm_castsi128_ps(b_isdenorm), _mm_castsi128_ps(adjusted2));
- __m128 adjusted5 = _mm_or_ps(adjusted3, adjusted4);
- __m128i sign = _mm_slli_epi32(justsign, 16);
- __m128 final = _mm_or_ps(adjusted5, _mm_castsi128_ps(sign));
- stbir__simdf_store( output + 0, final );
-
- h = _mm_unpackhi_epi16 ( i, _mm_setzero_si128() );
- expmant = _mm_and_si128(mnosign, h);
- justsign = _mm_xor_si128(h, expmant);
- b_notinfnan = _mm_cmpgt_epi32(infty, expmant);
- b_isdenorm = _mm_cmpgt_epi32(smallest, expmant);
- shifted = _mm_slli_epi32(expmant, 13);
- adj_infnan = _mm_andnot_si128(b_notinfnan, eadjust);
- adjusted = _mm_add_epi32(eadjust, shifted);
- den1 = _mm_add_epi32(shifted, STBIR__CONSTI(magic_denorm));
- adjusted2 = _mm_add_epi32(adjusted, adj_infnan);
- den2 = _mm_sub_ps(_mm_castsi128_ps(den1), *(const __m128 *)&magic_denorm);
- adjusted3 = _mm_and_ps(den2, _mm_castsi128_ps(b_isdenorm));
- adjusted4 = _mm_andnot_ps(_mm_castsi128_ps(b_isdenorm), _mm_castsi128_ps(adjusted2));
- adjusted5 = _mm_or_ps(adjusted3, adjusted4);
- sign = _mm_slli_epi32(justsign, 16);
- final = _mm_or_ps(adjusted5, _mm_castsi128_ps(sign));
- stbir__simdf_store( output + 4, final );
-
- // ~38 SSE2 ops for 8 values
- }
-
- // Fabian's round-to-nearest-even float to half
- // ~48 SSE2 ops for 8 output
- stbir__inline static void stbir__float_to_half_SIMD(void * output, float const * input)
- {
- static const STBIR__SIMDI_CONST(mask_sign, 0x80000000u);
- static const STBIR__SIMDI_CONST(c_f16max, (127 + 16) << 23); // all FP32 values >=this round to +inf
- static const STBIR__SIMDI_CONST(c_nanbit, 0x200);
- static const STBIR__SIMDI_CONST(c_infty_as_fp16, 0x7c00);
- static const STBIR__SIMDI_CONST(c_min_normal, (127 - 14) << 23); // smallest FP32 that yields a normalized FP16
- static const STBIR__SIMDI_CONST(c_subnorm_magic, ((127 - 15) + (23 - 10) + 1) << 23);
- static const STBIR__SIMDI_CONST(c_normal_bias, 0xfff - ((127 - 15) << 23)); // adjust exponent and add mantissa rounding
-
- __m128 f = _mm_loadu_ps(input);
- __m128 msign = _mm_castsi128_ps(STBIR__CONSTI(mask_sign));
- __m128 justsign = _mm_and_ps(msign, f);
- __m128 absf = _mm_xor_ps(f, justsign);
- __m128i absf_int = _mm_castps_si128(absf); // the cast is "free" (extra bypass latency, but no thruput hit)
- __m128i f16max = STBIR__CONSTI(c_f16max);
- __m128 b_isnan = _mm_cmpunord_ps(absf, absf); // is this a NaN?
- __m128i b_isregular = _mm_cmpgt_epi32(f16max, absf_int); // (sub)normalized or special?
- __m128i nanbit = _mm_and_si128(_mm_castps_si128(b_isnan), STBIR__CONSTI(c_nanbit));
- __m128i inf_or_nan = _mm_or_si128(nanbit, STBIR__CONSTI(c_infty_as_fp16)); // output for specials
-
- __m128i min_normal = STBIR__CONSTI(c_min_normal);
- __m128i b_issub = _mm_cmpgt_epi32(min_normal, absf_int);
-
- // "result is subnormal" path
- __m128 subnorm1 = _mm_add_ps(absf, _mm_castsi128_ps(STBIR__CONSTI(c_subnorm_magic))); // magic value to round output mantissa
- __m128i subnorm2 = _mm_sub_epi32(_mm_castps_si128(subnorm1), STBIR__CONSTI(c_subnorm_magic)); // subtract out bias
-
- // "result is normal" path
- __m128i mantoddbit = _mm_slli_epi32(absf_int, 31 - 13); // shift bit 13 (mantissa LSB) to sign
- __m128i mantodd = _mm_srai_epi32(mantoddbit, 31); // -1 if FP16 mantissa odd, else 0
-
- __m128i round1 = _mm_add_epi32(absf_int, STBIR__CONSTI(c_normal_bias));
- __m128i round2 = _mm_sub_epi32(round1, mantodd); // if mantissa LSB odd, bias towards rounding up (RTNE)
- __m128i normal = _mm_srli_epi32(round2, 13); // rounded result
-
- // combine the two non-specials
- __m128i nonspecial = _mm_or_si128(_mm_and_si128(subnorm2, b_issub), _mm_andnot_si128(b_issub, normal));
-
- // merge in specials as well
- __m128i joined = _mm_or_si128(_mm_and_si128(nonspecial, b_isregular), _mm_andnot_si128(b_isregular, inf_or_nan));
-
- __m128i sign_shift = _mm_srai_epi32(_mm_castps_si128(justsign), 16);
- __m128i final2, final= _mm_or_si128(joined, sign_shift);
-
- f = _mm_loadu_ps(input+4);
- justsign = _mm_and_ps(msign, f);
- absf = _mm_xor_ps(f, justsign);
- absf_int = _mm_castps_si128(absf); // the cast is "free" (extra bypass latency, but no thruput hit)
- b_isnan = _mm_cmpunord_ps(absf, absf); // is this a NaN?
- b_isregular = _mm_cmpgt_epi32(f16max, absf_int); // (sub)normalized or special?
- nanbit = _mm_and_si128(_mm_castps_si128(b_isnan), c_nanbit);
- inf_or_nan = _mm_or_si128(nanbit, STBIR__CONSTI(c_infty_as_fp16)); // output for specials
-
- b_issub = _mm_cmpgt_epi32(min_normal, absf_int);
-
- // "result is subnormal" path
- subnorm1 = _mm_add_ps(absf, _mm_castsi128_ps(STBIR__CONSTI(c_subnorm_magic))); // magic value to round output mantissa
- subnorm2 = _mm_sub_epi32(_mm_castps_si128(subnorm1), STBIR__CONSTI(c_subnorm_magic)); // subtract out bias
-
- // "result is normal" path
- mantoddbit = _mm_slli_epi32(absf_int, 31 - 13); // shift bit 13 (mantissa LSB) to sign
- mantodd = _mm_srai_epi32(mantoddbit, 31); // -1 if FP16 mantissa odd, else 0
-
- round1 = _mm_add_epi32(absf_int, STBIR__CONSTI(c_normal_bias));
- round2 = _mm_sub_epi32(round1, mantodd); // if mantissa LSB odd, bias towards rounding up (RTNE)
- normal = _mm_srli_epi32(round2, 13); // rounded result
-
- // combine the two non-specials
- nonspecial = _mm_or_si128(_mm_and_si128(subnorm2, b_issub), _mm_andnot_si128(b_issub, normal));
-
- // merge in specials as well
- joined = _mm_or_si128(_mm_and_si128(nonspecial, b_isregular), _mm_andnot_si128(b_isregular, inf_or_nan));
-
- sign_shift = _mm_srai_epi32(_mm_castps_si128(justsign), 16);
- final2 = _mm_or_si128(joined, sign_shift);
- final = _mm_packs_epi32(final, final2);
- stbir__simdi_store( output,final );
- }
-
-#elif defined(STBIR_NEON) && defined(_MSC_VER) && defined(_M_ARM64) && !defined(__clang__) // 64-bit ARM on MSVC (not clang)
-
- static stbir__inline void stbir__half_to_float_SIMD(float * output, stbir__FP16 const * input)
- {
- float16x4_t in0 = vld1_f16(input + 0);
- float16x4_t in1 = vld1_f16(input + 4);
- vst1q_f32(output + 0, vcvt_f32_f16(in0));
- vst1q_f32(output + 4, vcvt_f32_f16(in1));
- }
-
- static stbir__inline void stbir__float_to_half_SIMD(stbir__FP16 * output, float const * input)
- {
- float16x4_t out0 = vcvt_f16_f32(vld1q_f32(input + 0));
- float16x4_t out1 = vcvt_f16_f32(vld1q_f32(input + 4));
- vst1_f16(output+0, out0);
- vst1_f16(output+4, out1);
- }
-
- static stbir__inline float stbir__half_to_float( stbir__FP16 h )
- {
- return vgetq_lane_f32(vcvt_f32_f16(vld1_dup_f16(&h)), 0);
- }
-
- static stbir__inline stbir__FP16 stbir__float_to_half( float f )
- {
- return vget_lane_f16(vcvt_f16_f32(vdupq_n_f32(f)), 0).n16_u16[0];
- }
-
-#elif defined(STBIR_NEON) && ( defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ ) ) // 64-bit ARM
-
- static stbir__inline void stbir__half_to_float_SIMD(float * output, stbir__FP16 const * input)
- {
- float16x8_t in = vld1q_f16(input);
- vst1q_f32(output + 0, vcvt_f32_f16(vget_low_f16(in)));
- vst1q_f32(output + 4, vcvt_f32_f16(vget_high_f16(in)));
- }
-
- static stbir__inline void stbir__float_to_half_SIMD(stbir__FP16 * output, float const * input)
- {
- float16x4_t out0 = vcvt_f16_f32(vld1q_f32(input + 0));
- float16x4_t out1 = vcvt_f16_f32(vld1q_f32(input + 4));
- vst1q_f16(output, vcombine_f16(out0, out1));
- }
-
- static stbir__inline float stbir__half_to_float( stbir__FP16 h )
- {
- return vgetq_lane_f32(vcvt_f32_f16(vdup_n_f16(h)), 0);
- }
-
- static stbir__inline stbir__FP16 stbir__float_to_half( float f )
- {
- return vget_lane_f16(vcvt_f16_f32(vdupq_n_f32(f)), 0);
- }
-
-#elif defined(STBIR_WASM) || (defined(STBIR_NEON) && (defined(_MSC_VER) || defined(_M_ARM) || defined(__arm__))) // WASM or 32-bit ARM on MSVC/clang
-
- static stbir__inline void stbir__half_to_float_SIMD(float * output, stbir__FP16 const * input)
- {
- for (int i=0; i<8; i++)
- {
- output[i] = stbir__half_to_float(input[i]);
- }
- }
- static stbir__inline void stbir__float_to_half_SIMD(stbir__FP16 * output, float const * input)
- {
- for (int i=0; i<8; i++)
- {
- output[i] = stbir__float_to_half(input[i]);
- }
- }
-
-#endif
-
-
-#ifdef STBIR_SIMD
-
-#define stbir__simdf_0123to3333( out, reg ) (out) = stbir__simdf_swiz( reg, 3,3,3,3 )
-#define stbir__simdf_0123to2222( out, reg ) (out) = stbir__simdf_swiz( reg, 2,2,2,2 )
-#define stbir__simdf_0123to1111( out, reg ) (out) = stbir__simdf_swiz( reg, 1,1,1,1 )
-#define stbir__simdf_0123to0000( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,0,0 )
-#define stbir__simdf_0123to0003( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,0,3 )
-#define stbir__simdf_0123to0001( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,0,1 )
-#define stbir__simdf_0123to1122( out, reg ) (out) = stbir__simdf_swiz( reg, 1,1,2,2 )
-#define stbir__simdf_0123to2333( out, reg ) (out) = stbir__simdf_swiz( reg, 2,3,3,3 )
-#define stbir__simdf_0123to0023( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,2,3 )
-#define stbir__simdf_0123to1230( out, reg ) (out) = stbir__simdf_swiz( reg, 1,2,3,0 )
-#define stbir__simdf_0123to2103( out, reg ) (out) = stbir__simdf_swiz( reg, 2,1,0,3 )
-#define stbir__simdf_0123to3210( out, reg ) (out) = stbir__simdf_swiz( reg, 3,2,1,0 )
-#define stbir__simdf_0123to2301( out, reg ) (out) = stbir__simdf_swiz( reg, 2,3,0,1 )
-#define stbir__simdf_0123to3012( out, reg ) (out) = stbir__simdf_swiz( reg, 3,0,1,2 )
-#define stbir__simdf_0123to0011( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,1,1 )
-#define stbir__simdf_0123to1100( out, reg ) (out) = stbir__simdf_swiz( reg, 1,1,0,0 )
-#define stbir__simdf_0123to2233( out, reg ) (out) = stbir__simdf_swiz( reg, 2,2,3,3 )
-#define stbir__simdf_0123to1133( out, reg ) (out) = stbir__simdf_swiz( reg, 1,1,3,3 )
-#define stbir__simdf_0123to0022( out, reg ) (out) = stbir__simdf_swiz( reg, 0,0,2,2 )
-#define stbir__simdf_0123to1032( out, reg ) (out) = stbir__simdf_swiz( reg, 1,0,3,2 )
-
-typedef union stbir__simdi_u32
-{
- stbir_uint32 m128i_u32[4];
- int m128i_i32[4];
- stbir__simdi m128i_i128;
-} stbir__simdi_u32;
-
-static const int STBIR_mask[9] = { 0,0,0,-1,-1,-1,0,0,0 };
-
-static const STBIR__SIMDF_CONST(STBIR_max_uint8_as_float, stbir__max_uint8_as_float);
-static const STBIR__SIMDF_CONST(STBIR_max_uint16_as_float, stbir__max_uint16_as_float);
-static const STBIR__SIMDF_CONST(STBIR_max_uint8_as_float_inverted, stbir__max_uint8_as_float_inverted);
-static const STBIR__SIMDF_CONST(STBIR_max_uint16_as_float_inverted, stbir__max_uint16_as_float_inverted);
-
-static const STBIR__SIMDF_CONST(STBIR_simd_point5, 0.5f);
-static const STBIR__SIMDF_CONST(STBIR_ones, 1.0f);
-static const STBIR__SIMDI_CONST(STBIR_almost_zero, (127 - 13) << 23);
-static const STBIR__SIMDI_CONST(STBIR_almost_one, 0x3f7fffff);
-static const STBIR__SIMDI_CONST(STBIR_mastissa_mask, 0xff);
-static const STBIR__SIMDI_CONST(STBIR_topscale, 0x02000000);
-
-// Basically, in simd mode, we unroll the proper amount, and we don't want
-// the non-simd remnant loops to be unroll because they only run a few times
-// Adding this switch saves about 5K on clang which is Captain Unroll the 3rd.
-#define STBIR_SIMD_STREAMOUT_PTR( star ) STBIR_STREAMOUT_PTR( star )
-#define STBIR_SIMD_NO_UNROLL(ptr) STBIR_NO_UNROLL(ptr)
-#define STBIR_SIMD_NO_UNROLL_LOOP_START STBIR_NO_UNROLL_LOOP_START
-#define STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR STBIR_NO_UNROLL_LOOP_START_INF_FOR
-
-#ifdef STBIR_MEMCPY
-#undef STBIR_MEMCPY
-#endif
-#define STBIR_MEMCPY stbir_simd_memcpy
-
-// override normal use of memcpy with much simpler copy (faster and smaller with our sized copies)
-static void stbir_simd_memcpy( void * dest, void const * src, size_t bytes )
-{
- char STBIR_SIMD_STREAMOUT_PTR (*) d = (char*) dest;
- char STBIR_SIMD_STREAMOUT_PTR( * ) d_end = ((char*) dest) + bytes;
- ptrdiff_t ofs_to_src = (char*)src - (char*)dest;
-
- // check overlaps
- STBIR_ASSERT( ( ( d >= ( (char*)src) + bytes ) ) || ( ( d + bytes ) <= (char*)src ) );
-
- if ( bytes < (16*stbir__simdfX_float_count) )
- {
- if ( bytes < 16 )
- {
- if ( bytes )
- {
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do
- {
- STBIR_SIMD_NO_UNROLL(d);
- d[ 0 ] = d[ ofs_to_src ];
- ++d;
- } while ( d < d_end );
- }
- }
- else
- {
- stbir__simdf x;
- // do one unaligned to get us aligned for the stream out below
- stbir__simdf_load( x, ( d + ofs_to_src ) );
- stbir__simdf_store( d, x );
- d = (char*)( ( ( (size_t)d ) + 16 ) & ~15 );
-
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- STBIR_SIMD_NO_UNROLL(d);
-
- if ( d > ( d_end - 16 ) )
- {
- if ( d == d_end )
- return;
- d = d_end - 16;
- }
-
- stbir__simdf_load( x, ( d + ofs_to_src ) );
- stbir__simdf_store( d, x );
- d += 16;
- }
- }
- }
- else
- {
- stbir__simdfX x0,x1,x2,x3;
-
- // do one unaligned to get us aligned for the stream out below
- stbir__simdfX_load( x0, ( d + ofs_to_src ) + 0*stbir__simdfX_float_count );
- stbir__simdfX_load( x1, ( d + ofs_to_src ) + 4*stbir__simdfX_float_count );
- stbir__simdfX_load( x2, ( d + ofs_to_src ) + 8*stbir__simdfX_float_count );
- stbir__simdfX_load( x3, ( d + ofs_to_src ) + 12*stbir__simdfX_float_count );
- stbir__simdfX_store( d + 0*stbir__simdfX_float_count, x0 );
- stbir__simdfX_store( d + 4*stbir__simdfX_float_count, x1 );
- stbir__simdfX_store( d + 8*stbir__simdfX_float_count, x2 );
- stbir__simdfX_store( d + 12*stbir__simdfX_float_count, x3 );
- d = (char*)( ( ( (size_t)d ) + (16*stbir__simdfX_float_count) ) & ~((16*stbir__simdfX_float_count)-1) );
-
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- STBIR_SIMD_NO_UNROLL(d);
-
- if ( d > ( d_end - (16*stbir__simdfX_float_count) ) )
- {
- if ( d == d_end )
- return;
- d = d_end - (16*stbir__simdfX_float_count);
- }
-
- stbir__simdfX_load( x0, ( d + ofs_to_src ) + 0*stbir__simdfX_float_count );
- stbir__simdfX_load( x1, ( d + ofs_to_src ) + 4*stbir__simdfX_float_count );
- stbir__simdfX_load( x2, ( d + ofs_to_src ) + 8*stbir__simdfX_float_count );
- stbir__simdfX_load( x3, ( d + ofs_to_src ) + 12*stbir__simdfX_float_count );
- stbir__simdfX_store( d + 0*stbir__simdfX_float_count, x0 );
- stbir__simdfX_store( d + 4*stbir__simdfX_float_count, x1 );
- stbir__simdfX_store( d + 8*stbir__simdfX_float_count, x2 );
- stbir__simdfX_store( d + 12*stbir__simdfX_float_count, x3 );
- d += (16*stbir__simdfX_float_count);
- }
- }
-}
-
-// memcpy that is specically intentionally overlapping (src is smaller then dest, so can be
-// a normal forward copy, bytes is divisible by 4 and bytes is greater than or equal to
-// the diff between dest and src)
-static void stbir_overlapping_memcpy( void * dest, void const * src, size_t bytes )
-{
- char STBIR_SIMD_STREAMOUT_PTR (*) sd = (char*) src;
- char STBIR_SIMD_STREAMOUT_PTR( * ) s_end = ((char*) src) + bytes;
- ptrdiff_t ofs_to_dest = (char*)dest - (char*)src;
-
- if ( ofs_to_dest >= 16 ) // is the overlap more than 16 away?
- {
- char STBIR_SIMD_STREAMOUT_PTR( * ) s_end16 = ((char*) src) + (bytes&~15);
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do
- {
- stbir__simdf x;
- STBIR_SIMD_NO_UNROLL(sd);
- stbir__simdf_load( x, sd );
- stbir__simdf_store( ( sd + ofs_to_dest ), x );
- sd += 16;
- } while ( sd < s_end16 );
-
- if ( sd == s_end )
- return;
- }
-
- do
- {
- STBIR_SIMD_NO_UNROLL(sd);
- *(int*)( sd + ofs_to_dest ) = *(int*) sd;
- sd += 4;
- } while ( sd < s_end );
-}
-
-#else // no SSE2
-
-// when in scalar mode, we let unrolling happen, so this macro just does the __restrict
-#define STBIR_SIMD_STREAMOUT_PTR( star ) STBIR_STREAMOUT_PTR( star )
-#define STBIR_SIMD_NO_UNROLL(ptr)
-#define STBIR_SIMD_NO_UNROLL_LOOP_START
-#define STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
-
-#endif // SSE2
-
-
-#ifdef STBIR_PROFILE
-
-#ifndef STBIR_PROFILE_FUNC
-
-#if defined(_x86_64) || defined( __x86_64__ ) || defined( _M_X64 ) || defined(__x86_64) || defined(__SSE2__) || defined(STBIR_SSE) || defined( _M_IX86_FP ) || defined(__i386) || defined( __i386__ ) || defined( _M_IX86 ) || defined( _X86_ )
-
-#ifdef _MSC_VER
-
- STBIRDEF stbir_uint64 __rdtsc();
- #define STBIR_PROFILE_FUNC() __rdtsc()
-
-#else // non msvc
-
- static stbir__inline stbir_uint64 STBIR_PROFILE_FUNC()
- {
- stbir_uint32 lo, hi;
- asm volatile ("rdtsc" : "=a" (lo), "=d" (hi) );
- return ( ( (stbir_uint64) hi ) << 32 ) | ( (stbir_uint64) lo );
- }
-
-#endif // msvc
-
-#elif defined( _M_ARM64 ) || defined( __aarch64__ ) || defined( __arm64__ ) || defined(__ARM_NEON__)
-
-#if defined( _MSC_VER ) && !defined(__clang__)
-
- #define STBIR_PROFILE_FUNC() _ReadStatusReg(ARM64_CNTVCT)
-
-#else
-
- static stbir__inline stbir_uint64 STBIR_PROFILE_FUNC()
- {
- stbir_uint64 tsc;
- asm volatile("mrs %0, cntvct_el0" : "=r" (tsc));
- return tsc;
- }
-
-#endif
-
-#else // x64, arm
-
-#error Unknown platform for profiling.
-
-#endif // x64, arm
-
-#endif // STBIR_PROFILE_FUNC
-
-#define STBIR_ONLY_PROFILE_GET_SPLIT_INFO ,stbir__per_split_info * split_info
-#define STBIR_ONLY_PROFILE_SET_SPLIT_INFO ,split_info
-
-#define STBIR_ONLY_PROFILE_BUILD_GET_INFO ,stbir__info * profile_info
-#define STBIR_ONLY_PROFILE_BUILD_SET_INFO ,profile_info
-
-// super light-weight micro profiler
-#define STBIR_PROFILE_START_ll( info, wh ) { stbir_uint64 wh##thiszonetime = STBIR_PROFILE_FUNC(); stbir_uint64 * wh##save_parent_excluded_ptr = info->current_zone_excluded_ptr; stbir_uint64 wh##current_zone_excluded = 0; info->current_zone_excluded_ptr = &wh##current_zone_excluded;
-#define STBIR_PROFILE_END_ll( info, wh ) wh##thiszonetime = STBIR_PROFILE_FUNC() - wh##thiszonetime; info->profile.named.wh += wh##thiszonetime - wh##current_zone_excluded; *wh##save_parent_excluded_ptr += wh##thiszonetime; info->current_zone_excluded_ptr = wh##save_parent_excluded_ptr; }
-#define STBIR_PROFILE_FIRST_START_ll( info, wh ) { int i; info->current_zone_excluded_ptr = &info->profile.named.total; for(i=0;iprofile.array);i++) info->profile.array[i]=0; } STBIR_PROFILE_START_ll( info, wh );
-#define STBIR_PROFILE_CLEAR_EXTRAS_ll( info, num ) { int extra; for(extra=1;extra<(num);extra++) { int i; for(i=0;iprofile.array);i++) (info)[extra].profile.array[i]=0; } }
-
-// for thread data
-#define STBIR_PROFILE_START( wh ) STBIR_PROFILE_START_ll( split_info, wh )
-#define STBIR_PROFILE_END( wh ) STBIR_PROFILE_END_ll( split_info, wh )
-#define STBIR_PROFILE_FIRST_START( wh ) STBIR_PROFILE_FIRST_START_ll( split_info, wh )
-#define STBIR_PROFILE_CLEAR_EXTRAS() STBIR_PROFILE_CLEAR_EXTRAS_ll( split_info, split_count )
-
-// for build data
-#define STBIR_PROFILE_BUILD_START( wh ) STBIR_PROFILE_START_ll( profile_info, wh )
-#define STBIR_PROFILE_BUILD_END( wh ) STBIR_PROFILE_END_ll( profile_info, wh )
-#define STBIR_PROFILE_BUILD_FIRST_START( wh ) STBIR_PROFILE_FIRST_START_ll( profile_info, wh )
-#define STBIR_PROFILE_BUILD_CLEAR( info ) { int i; for(i=0;iprofile.array);i++) info->profile.array[i]=0; }
-
-#else // no profile
-
-#define STBIR_ONLY_PROFILE_GET_SPLIT_INFO
-#define STBIR_ONLY_PROFILE_SET_SPLIT_INFO
-
-#define STBIR_ONLY_PROFILE_BUILD_GET_INFO
-#define STBIR_ONLY_PROFILE_BUILD_SET_INFO
-
-#define STBIR_PROFILE_START( wh )
-#define STBIR_PROFILE_END( wh )
-#define STBIR_PROFILE_FIRST_START( wh )
-#define STBIR_PROFILE_CLEAR_EXTRAS( )
-
-#define STBIR_PROFILE_BUILD_START( wh )
-#define STBIR_PROFILE_BUILD_END( wh )
-#define STBIR_PROFILE_BUILD_FIRST_START( wh )
-#define STBIR_PROFILE_BUILD_CLEAR( info )
-
-#endif // stbir_profile
-
-#ifndef STBIR_CEILF
-#include
-#if _MSC_VER <= 1200 // support VC6 for Sean
-#define STBIR_CEILF(x) ((float)ceil((float)(x)))
-#define STBIR_FLOORF(x) ((float)floor((float)(x)))
-#else
-#define STBIR_CEILF(x) ceilf(x)
-#define STBIR_FLOORF(x) floorf(x)
-#endif
-#endif
-
-#ifndef STBIR_MEMCPY
-// For memcpy
-#include
-#define STBIR_MEMCPY( dest, src, len ) memcpy( dest, src, len )
-#endif
-
-#ifndef STBIR_SIMD
-
-// memcpy that is specifically intentionally overlapping (src is smaller then dest, so can be
-// a normal forward copy, bytes is divisible by 4 and bytes is greater than or equal to
-// the diff between dest and src)
-static void stbir_overlapping_memcpy( void * dest, void const * src, size_t bytes )
-{
- char STBIR_SIMD_STREAMOUT_PTR (*) sd = (char*) src;
- char STBIR_SIMD_STREAMOUT_PTR( * ) s_end = ((char*) src) + bytes;
- ptrdiff_t ofs_to_dest = (char*)dest - (char*)src;
-
- if ( ofs_to_dest >= 8 ) // is the overlap more than 8 away?
- {
- char STBIR_SIMD_STREAMOUT_PTR( * ) s_end8 = ((char*) src) + (bytes&~7);
- STBIR_NO_UNROLL_LOOP_START
- do
- {
- STBIR_NO_UNROLL(sd);
- *(stbir_uint64*)( sd + ofs_to_dest ) = *(stbir_uint64*) sd;
- sd += 8;
- } while ( sd < s_end8 );
-
- if ( sd == s_end )
- return;
- }
-
- STBIR_NO_UNROLL_LOOP_START
- do
- {
- STBIR_NO_UNROLL(sd);
- *(int*)( sd + ofs_to_dest ) = *(int*) sd;
- sd += 4;
- } while ( sd < s_end );
-}
-
-#endif
-
-static float stbir__filter_trapezoid(float x, float scale, void * user_data)
-{
- float halfscale = scale / 2;
- float t = 0.5f + halfscale;
- STBIR_ASSERT(scale <= 1);
- STBIR__UNUSED(user_data);
-
- if ( x < 0.0f ) x = -x;
-
- if (x >= t)
- return 0.0f;
- else
- {
- float r = 0.5f - halfscale;
- if (x <= r)
- return 1.0f;
- else
- return (t - x) / scale;
- }
-}
-
-static float stbir__support_trapezoid(float scale, void * user_data)
-{
- STBIR__UNUSED(user_data);
- return 0.5f + scale / 2.0f;
-}
-
-static float stbir__filter_triangle(float x, float s, void * user_data)
-{
- STBIR__UNUSED(s);
- STBIR__UNUSED(user_data);
-
- if ( x < 0.0f ) x = -x;
-
- if (x <= 1.0f)
- return 1.0f - x;
- else
- return 0.0f;
-}
-
-static float stbir__filter_point(float x, float s, void * user_data)
-{
- STBIR__UNUSED(x);
- STBIR__UNUSED(s);
- STBIR__UNUSED(user_data);
-
- return 1.0f;
-}
-
-static float stbir__filter_cubic(float x, float s, void * user_data)
-{
- STBIR__UNUSED(s);
- STBIR__UNUSED(user_data);
-
- if ( x < 0.0f ) x = -x;
-
- if (x < 1.0f)
- return (4.0f + x*x*(3.0f*x - 6.0f))/6.0f;
- else if (x < 2.0f)
- return (8.0f + x*(-12.0f + x*(6.0f - x)))/6.0f;
-
- return (0.0f);
-}
-
-static float stbir__filter_catmullrom(float x, float s, void * user_data)
-{
- STBIR__UNUSED(s);
- STBIR__UNUSED(user_data);
-
- if ( x < 0.0f ) x = -x;
-
- if (x < 1.0f)
- return 1.0f - x*x*(2.5f - 1.5f*x);
- else if (x < 2.0f)
- return 2.0f - x*(4.0f + x*(0.5f*x - 2.5f));
-
- return (0.0f);
-}
-
-static float stbir__filter_mitchell(float x, float s, void * user_data)
-{
- STBIR__UNUSED(s);
- STBIR__UNUSED(user_data);
-
- if ( x < 0.0f ) x = -x;
-
- if (x < 1.0f)
- return (16.0f + x*x*(21.0f * x - 36.0f))/18.0f;
- else if (x < 2.0f)
- return (32.0f + x*(-60.0f + x*(36.0f - 7.0f*x)))/18.0f;
-
- return (0.0f);
-}
-
-static float stbir__support_zeropoint5(float s, void * user_data)
-{
- STBIR__UNUSED(s);
- STBIR__UNUSED(user_data);
- return 0.5f;
-}
-
-static float stbir__support_one(float s, void * user_data)
-{
- STBIR__UNUSED(s);
- STBIR__UNUSED(user_data);
- return 1;
-}
-
-static float stbir__support_two(float s, void * user_data)
-{
- STBIR__UNUSED(s);
- STBIR__UNUSED(user_data);
- return 2;
-}
-
-// This is the maximum number of input samples that can affect an output sample
-// with the given filter from the output pixel's perspective
-static int stbir__get_filter_pixel_width(stbir__support_callback * support, float scale, void * user_data)
-{
- STBIR_ASSERT(support != 0);
-
- if ( scale >= ( 1.0f-stbir__small_float ) ) // upscale
- return (int)STBIR_CEILF(support(1.0f/scale,user_data) * 2.0f);
- else
- return (int)STBIR_CEILF(support(scale,user_data) * 2.0f / scale);
-}
-
-// this is how many coefficents per run of the filter (which is different
-// from the filter_pixel_width depending on if we are scattering or gathering)
-static int stbir__get_coefficient_width(stbir__sampler * samp, int is_gather, void * user_data)
-{
- float scale = samp->scale_info.scale;
- stbir__support_callback * support = samp->filter_support;
-
- switch( is_gather )
- {
- case 1:
- return (int)STBIR_CEILF(support(1.0f / scale, user_data) * 2.0f);
- case 2:
- return (int)STBIR_CEILF(support(scale, user_data) * 2.0f / scale);
- case 0:
- return (int)STBIR_CEILF(support(scale, user_data) * 2.0f);
- default:
- STBIR_ASSERT( (is_gather >= 0 ) && (is_gather <= 2 ) );
- return 0;
- }
-}
-
-static int stbir__get_contributors(stbir__sampler * samp, int is_gather)
-{
- if (is_gather)
- return samp->scale_info.output_sub_size;
- else
- return (samp->scale_info.input_full_size + samp->filter_pixel_margin * 2);
-}
-
-static int stbir__edge_zero_full( int n, int max )
-{
- STBIR__UNUSED(n);
- STBIR__UNUSED(max);
- return 0; // NOTREACHED
-}
-
-static int stbir__edge_clamp_full( int n, int max )
-{
- if (n < 0)
- return 0;
-
- if (n >= max)
- return max - 1;
-
- return n; // NOTREACHED
-}
-
-static int stbir__edge_reflect_full( int n, int max )
-{
- if (n < 0)
- {
- if (n > -max)
- return -n;
- else
- return max - 1;
- }
-
- if (n >= max)
- {
- int max2 = max * 2;
- if (n >= max2)
- return 0;
- else
- return max2 - n - 1;
- }
-
- return n; // NOTREACHED
-}
-
-static int stbir__edge_wrap_full( int n, int max )
-{
- if (n >= 0)
- return (n % max);
- else
- {
- int m = (-n) % max;
-
- if (m != 0)
- m = max - m;
-
- return (m);
- }
-}
-
-typedef int stbir__edge_wrap_func( int n, int max );
-static stbir__edge_wrap_func * stbir__edge_wrap_slow[] =
-{
- stbir__edge_clamp_full, // STBIR_EDGE_CLAMP
- stbir__edge_reflect_full, // STBIR_EDGE_REFLECT
- stbir__edge_wrap_full, // STBIR_EDGE_WRAP
- stbir__edge_zero_full, // STBIR_EDGE_ZERO
-};
-
-stbir__inline static int stbir__edge_wrap(stbir_edge edge, int n, int max)
-{
- // avoid per-pixel switch
- if (n >= 0 && n < max)
- return n;
- return stbir__edge_wrap_slow[edge]( n, max );
-}
-
-#define STBIR__MERGE_RUNS_PIXEL_THRESHOLD 16
-
-// get information on the extents of a sampler
-static void stbir__get_extents( stbir__sampler * samp, stbir__extents * scanline_extents )
-{
- int j, stop;
- int left_margin, right_margin;
- int min_n = 0x7fffffff, max_n = -0x7fffffff;
- int min_left = 0x7fffffff, max_left = -0x7fffffff;
- int min_right = 0x7fffffff, max_right = -0x7fffffff;
- stbir_edge edge = samp->edge;
- stbir__contributors* contributors = samp->contributors;
- int output_sub_size = samp->scale_info.output_sub_size;
- int input_full_size = samp->scale_info.input_full_size;
- int filter_pixel_margin = samp->filter_pixel_margin;
-
- STBIR_ASSERT( samp->is_gather );
-
- stop = output_sub_size;
- for (j = 0; j < stop; j++ )
- {
- STBIR_ASSERT( contributors[j].n1 >= contributors[j].n0 );
- if ( contributors[j].n0 < min_n )
- {
- min_n = contributors[j].n0;
- stop = j + filter_pixel_margin; // if we find a new min, only scan another filter width
- if ( stop > output_sub_size ) stop = output_sub_size;
- }
- }
-
- stop = 0;
- for (j = output_sub_size - 1; j >= stop; j-- )
- {
- STBIR_ASSERT( contributors[j].n1 >= contributors[j].n0 );
- if ( contributors[j].n1 > max_n )
- {
- max_n = contributors[j].n1;
- stop = j - filter_pixel_margin; // if we find a new max, only scan another filter width
- if (stop<0) stop = 0;
- }
- }
-
- STBIR_ASSERT( scanline_extents->conservative.n0 <= min_n );
- STBIR_ASSERT( scanline_extents->conservative.n1 >= max_n );
-
- // now calculate how much into the margins we really read
- left_margin = 0;
- if ( min_n < 0 )
- {
- left_margin = -min_n;
- min_n = 0;
- }
-
- right_margin = 0;
- if ( max_n >= input_full_size )
- {
- right_margin = max_n - input_full_size + 1;
- max_n = input_full_size - 1;
- }
-
- // index 1 is margin pixel extents (how many pixels we hang over the edge)
- scanline_extents->edge_sizes[0] = left_margin;
- scanline_extents->edge_sizes[1] = right_margin;
-
- // index 2 is pixels read from the input
- scanline_extents->spans[0].n0 = min_n;
- scanline_extents->spans[0].n1 = max_n;
- scanline_extents->spans[0].pixel_offset_for_input = min_n;
-
- // default to no other input range
- scanline_extents->spans[1].n0 = 0;
- scanline_extents->spans[1].n1 = -1;
- scanline_extents->spans[1].pixel_offset_for_input = 0;
-
- // don't have to do edge calc for zero clamp
- if ( edge == STBIR_EDGE_ZERO )
- return;
-
- // convert margin pixels to the pixels within the input (min and max)
- for( j = -left_margin ; j < 0 ; j++ )
- {
- int p = stbir__edge_wrap( edge, j, input_full_size );
- if ( p < min_left )
- min_left = p;
- if ( p > max_left )
- max_left = p;
- }
-
- for( j = input_full_size ; j < (input_full_size + right_margin) ; j++ )
- {
- int p = stbir__edge_wrap( edge, j, input_full_size );
- if ( p < min_right )
- min_right = p;
- if ( p > max_right )
- max_right = p;
- }
-
- // merge the left margin pixel region if it connects within 4 pixels of main pixel region
- if ( min_left != 0x7fffffff )
- {
- if ( ( ( min_left <= min_n ) && ( ( max_left + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= min_n ) ) ||
- ( ( min_n <= min_left ) && ( ( max_n + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= max_left ) ) )
- {
- scanline_extents->spans[0].n0 = min_n = stbir__min( min_n, min_left );
- scanline_extents->spans[0].n1 = max_n = stbir__max( max_n, max_left );
- scanline_extents->spans[0].pixel_offset_for_input = min_n;
- left_margin = 0;
- }
- }
-
- // merge the right margin pixel region if it connects within 4 pixels of main pixel region
- if ( min_right != 0x7fffffff )
- {
- if ( ( ( min_right <= min_n ) && ( ( max_right + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= min_n ) ) ||
- ( ( min_n <= min_right ) && ( ( max_n + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= max_right ) ) )
- {
- scanline_extents->spans[0].n0 = min_n = stbir__min( min_n, min_right );
- scanline_extents->spans[0].n1 = max_n = stbir__max( max_n, max_right );
- scanline_extents->spans[0].pixel_offset_for_input = min_n;
- right_margin = 0;
- }
- }
-
- STBIR_ASSERT( scanline_extents->conservative.n0 <= min_n );
- STBIR_ASSERT( scanline_extents->conservative.n1 >= max_n );
-
- // you get two ranges when you have the WRAP edge mode and you are doing just the a piece of the resize
- // so you need to get a second run of pixels from the opposite side of the scanline (which you
- // wouldn't need except for WRAP)
-
-
- // if we can't merge the min_left range, add it as a second range
- if ( ( left_margin ) && ( min_left != 0x7fffffff ) )
- {
- stbir__span * newspan = scanline_extents->spans + 1;
- STBIR_ASSERT( right_margin == 0 );
- if ( min_left < scanline_extents->spans[0].n0 )
- {
- scanline_extents->spans[1].pixel_offset_for_input = scanline_extents->spans[0].n0;
- scanline_extents->spans[1].n0 = scanline_extents->spans[0].n0;
- scanline_extents->spans[1].n1 = scanline_extents->spans[0].n1;
- --newspan;
- }
- newspan->pixel_offset_for_input = min_left;
- newspan->n0 = -left_margin;
- newspan->n1 = ( max_left - min_left ) - left_margin;
- scanline_extents->edge_sizes[0] = 0; // don't need to copy the left margin, since we are directly decoding into the margin
- }
- // if we can't merge the min_left range, add it as a second range
- else
- if ( ( right_margin ) && ( min_right != 0x7fffffff ) )
- {
- stbir__span * newspan = scanline_extents->spans + 1;
- if ( min_right < scanline_extents->spans[0].n0 )
- {
- scanline_extents->spans[1].pixel_offset_for_input = scanline_extents->spans[0].n0;
- scanline_extents->spans[1].n0 = scanline_extents->spans[0].n0;
- scanline_extents->spans[1].n1 = scanline_extents->spans[0].n1;
- --newspan;
- }
- newspan->pixel_offset_for_input = min_right;
- newspan->n0 = scanline_extents->spans[1].n1 + 1;
- newspan->n1 = scanline_extents->spans[1].n1 + 1 + ( max_right - min_right );
- scanline_extents->edge_sizes[1] = 0; // don't need to copy the right margin, since we are directly decoding into the margin
- }
-
- // sort the spans into write output order
- if ( ( scanline_extents->spans[1].n1 > scanline_extents->spans[1].n0 ) && ( scanline_extents->spans[0].n0 > scanline_extents->spans[1].n0 ) )
- {
- stbir__span tspan = scanline_extents->spans[0];
- scanline_extents->spans[0] = scanline_extents->spans[1];
- scanline_extents->spans[1] = tspan;
- }
-}
-
-static void stbir__calculate_in_pixel_range( int * first_pixel, int * last_pixel, float out_pixel_center, float out_filter_radius, float inv_scale, float out_shift, int input_size, stbir_edge edge )
-{
- int first, last;
- float out_pixel_influence_lowerbound = out_pixel_center - out_filter_radius;
- float out_pixel_influence_upperbound = out_pixel_center + out_filter_radius;
-
- float in_pixel_influence_lowerbound = (out_pixel_influence_lowerbound + out_shift) * inv_scale;
- float in_pixel_influence_upperbound = (out_pixel_influence_upperbound + out_shift) * inv_scale;
-
- first = (int)(STBIR_FLOORF(in_pixel_influence_lowerbound + 0.5f));
- last = (int)(STBIR_FLOORF(in_pixel_influence_upperbound - 0.5f));
- if ( last < first ) last = first; // point sample mode can span a value *right* at 0.5, and cause these to cross
-
- if ( edge == STBIR_EDGE_WRAP )
- {
- if ( first < -input_size )
- first = -input_size;
- if ( last >= (input_size*2))
- last = (input_size*2) - 1;
- }
-
- *first_pixel = first;
- *last_pixel = last;
-}
-
-static void stbir__calculate_coefficients_for_gather_upsample( float out_filter_radius, stbir__kernel_callback * kernel, stbir__scale_info * scale_info, int num_contributors, stbir__contributors* contributors, float* coefficient_group, int coefficient_width, stbir_edge edge, void * user_data )
-{
- int n, end;
- float inv_scale = scale_info->inv_scale;
- float out_shift = scale_info->pixel_shift;
- int input_size = scale_info->input_full_size;
- int numerator = scale_info->scale_numerator;
- int polyphase = ( ( scale_info->scale_is_rational ) && ( numerator < num_contributors ) );
-
- // Looping through out pixels
- end = num_contributors; if ( polyphase ) end = numerator;
- for (n = 0; n < end; n++)
- {
- int i;
- int last_non_zero;
- float out_pixel_center = (float)n + 0.5f;
- float in_center_of_out = (out_pixel_center + out_shift) * inv_scale;
-
- int in_first_pixel, in_last_pixel;
-
- stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, out_pixel_center, out_filter_radius, inv_scale, out_shift, input_size, edge );
-
- // make sure we never generate a range larger than our precalculated coeff width
- // this only happens in point sample mode, but it's a good safe thing to do anyway
- if ( ( in_last_pixel - in_first_pixel + 1 ) > coefficient_width )
- in_last_pixel = in_first_pixel + coefficient_width - 1;
-
- last_non_zero = -1;
- for (i = 0; i <= in_last_pixel - in_first_pixel; i++)
- {
- float in_pixel_center = (float)(i + in_first_pixel) + 0.5f;
- float coeff = kernel(in_center_of_out - in_pixel_center, inv_scale, user_data);
-
- // kill denormals
- if ( ( ( coeff < stbir__small_float ) && ( coeff > -stbir__small_float ) ) )
- {
- if ( i == 0 ) // if we're at the front, just eat zero contributors
- {
- STBIR_ASSERT ( ( in_last_pixel - in_first_pixel ) != 0 ); // there should be at least one contrib
- ++in_first_pixel;
- i--;
- continue;
- }
- coeff = 0; // make sure is fully zero (should keep denormals away)
- }
- else
- last_non_zero = i;
-
- coefficient_group[i] = coeff;
- }
-
- in_last_pixel = last_non_zero+in_first_pixel; // kills trailing zeros
- contributors->n0 = in_first_pixel;
- contributors->n1 = in_last_pixel;
-
- STBIR_ASSERT(contributors->n1 >= contributors->n0);
-
- ++contributors;
- coefficient_group += coefficient_width;
- }
-}
-
-static void stbir__insert_coeff( stbir__contributors * contribs, float * coeffs, int new_pixel, float new_coeff, int max_width )
-{
- if ( new_pixel <= contribs->n1 ) // before the end
- {
- if ( new_pixel < contribs->n0 ) // before the front?
- {
- if ( ( contribs->n1 - new_pixel + 1 ) <= max_width )
- {
- int j, o = contribs->n0 - new_pixel;
- for ( j = contribs->n1 - contribs->n0 ; j <= 0 ; j-- )
- coeffs[ j + o ] = coeffs[ j ];
- for ( j = 1 ; j < o ; j-- )
- coeffs[ j ] = coeffs[ 0 ];
- coeffs[ 0 ] = new_coeff;
- contribs->n0 = new_pixel;
- }
- }
- else
- {
- coeffs[ new_pixel - contribs->n0 ] += new_coeff;
- }
- }
- else
- {
- if ( ( new_pixel - contribs->n0 + 1 ) <= max_width )
- {
- int j, e = new_pixel - contribs->n0;
- for( j = ( contribs->n1 - contribs->n0 ) + 1 ; j < e ; j++ ) // clear in-betweens coeffs if there are any
- coeffs[j] = 0;
-
- coeffs[ e ] = new_coeff;
- contribs->n1 = new_pixel;
- }
- }
-}
-
-static void stbir__calculate_out_pixel_range( int * first_pixel, int * last_pixel, float in_pixel_center, float in_pixels_radius, float scale, float out_shift, int out_size )
-{
- float in_pixel_influence_lowerbound = in_pixel_center - in_pixels_radius;
- float in_pixel_influence_upperbound = in_pixel_center + in_pixels_radius;
- float out_pixel_influence_lowerbound = in_pixel_influence_lowerbound * scale - out_shift;
- float out_pixel_influence_upperbound = in_pixel_influence_upperbound * scale - out_shift;
- int out_first_pixel = (int)(STBIR_FLOORF(out_pixel_influence_lowerbound + 0.5f));
- int out_last_pixel = (int)(STBIR_FLOORF(out_pixel_influence_upperbound - 0.5f));
-
- if ( out_first_pixel < 0 )
- out_first_pixel = 0;
- if ( out_last_pixel >= out_size )
- out_last_pixel = out_size - 1;
- *first_pixel = out_first_pixel;
- *last_pixel = out_last_pixel;
-}
-
-static void stbir__calculate_coefficients_for_gather_downsample( int start, int end, float in_pixels_radius, stbir__kernel_callback * kernel, stbir__scale_info * scale_info, int coefficient_width, int num_contributors, stbir__contributors * contributors, float * coefficient_group, void * user_data )
-{
- int in_pixel;
- int i;
- int first_out_inited = -1;
- float scale = scale_info->scale;
- float out_shift = scale_info->pixel_shift;
- int out_size = scale_info->output_sub_size;
- int numerator = scale_info->scale_numerator;
- int polyphase = ( ( scale_info->scale_is_rational ) && ( numerator < out_size ) );
-
- STBIR__UNUSED(num_contributors);
-
- // Loop through the input pixels
- for (in_pixel = start; in_pixel < end; in_pixel++)
- {
- float in_pixel_center = (float)in_pixel + 0.5f;
- float out_center_of_in = in_pixel_center * scale - out_shift;
- int out_first_pixel, out_last_pixel;
-
- stbir__calculate_out_pixel_range( &out_first_pixel, &out_last_pixel, in_pixel_center, in_pixels_radius, scale, out_shift, out_size );
-
- if ( out_first_pixel > out_last_pixel )
- continue;
-
- // clamp or exit if we are using polyphase filtering, and the limit is up
- if ( polyphase )
- {
- // when polyphase, you only have to do coeffs up to the numerator count
- if ( out_first_pixel == numerator )
- break;
-
- // don't do any extra work, clamp last pixel at numerator too
- if ( out_last_pixel >= numerator )
- out_last_pixel = numerator - 1;
- }
-
- for (i = 0; i <= out_last_pixel - out_first_pixel; i++)
- {
- float out_pixel_center = (float)(i + out_first_pixel) + 0.5f;
- float x = out_pixel_center - out_center_of_in;
- float coeff = kernel(x, scale, user_data) * scale;
-
- // kill the coeff if it's too small (avoid denormals)
- if ( ( ( coeff < stbir__small_float ) && ( coeff > -stbir__small_float ) ) )
- coeff = 0.0f;
-
- {
- int out = i + out_first_pixel;
- float * coeffs = coefficient_group + out * coefficient_width;
- stbir__contributors * contribs = contributors + out;
-
- // is this the first time this output pixel has been seen? Init it.
- if ( out > first_out_inited )
- {
- STBIR_ASSERT( out == ( first_out_inited + 1 ) ); // ensure we have only advanced one at time
- first_out_inited = out;
- contribs->n0 = in_pixel;
- contribs->n1 = in_pixel;
- coeffs[0] = coeff;
- }
- else
- {
- // insert on end (always in order)
- if ( coeffs[0] == 0.0f ) // if the first coefficent is zero, then zap it for this coeffs
- {
- STBIR_ASSERT( ( in_pixel - contribs->n0 ) == 1 ); // ensure that when we zap, we're at the 2nd pos
- contribs->n0 = in_pixel;
- }
- contribs->n1 = in_pixel;
- STBIR_ASSERT( ( in_pixel - contribs->n0 ) < coefficient_width );
- coeffs[in_pixel - contribs->n0] = coeff;
- }
- }
- }
- }
-}
-
-#ifdef STBIR_RENORMALIZE_IN_FLOAT
-#define STBIR_RENORM_TYPE float
-#else
-#define STBIR_RENORM_TYPE double
-#endif
-
-static void stbir__cleanup_gathered_coefficients( stbir_edge edge, stbir__filter_extent_info* filter_info, stbir__scale_info * scale_info, int num_contributors, stbir__contributors* contributors, float * coefficient_group, int coefficient_width )
-{
- int input_size = scale_info->input_full_size;
- int input_last_n1 = input_size - 1;
- int n, end;
- int lowest = 0x7fffffff;
- int highest = -0x7fffffff;
- int widest = -1;
- int numerator = scale_info->scale_numerator;
- int denominator = scale_info->scale_denominator;
- int polyphase = ( ( scale_info->scale_is_rational ) && ( numerator < num_contributors ) );
- float * coeffs;
- stbir__contributors * contribs;
-
- // weight all the coeffs for each sample
- coeffs = coefficient_group;
- contribs = contributors;
- end = num_contributors; if ( polyphase ) end = numerator;
- for (n = 0; n < end; n++)
- {
- int i;
- STBIR_RENORM_TYPE filter_scale, total_filter = 0;
- int e;
-
- // add all contribs
- e = contribs->n1 - contribs->n0;
- for( i = 0 ; i <= e ; i++ )
- {
- total_filter += (STBIR_RENORM_TYPE) coeffs[i];
- STBIR_ASSERT( ( coeffs[i] >= -2.0f ) && ( coeffs[i] <= 2.0f ) ); // check for wonky weights
- }
-
- // rescale
- if ( ( total_filter < stbir__small_float ) && ( total_filter > -stbir__small_float ) )
- {
- // all coeffs are extremely small, just zero it
- contribs->n1 = contribs->n0;
- coeffs[0] = 0.0f;
- }
- else
- {
- // if the total isn't 1.0, rescale everything
- if ( ( total_filter < (1.0f-stbir__small_float) ) || ( total_filter > (1.0f+stbir__small_float) ) )
- {
- filter_scale = ((STBIR_RENORM_TYPE)1.0) / total_filter;
-
- // scale them all
- for (i = 0; i <= e; i++)
- coeffs[i] = (float) ( coeffs[i] * filter_scale );
- }
- }
- ++contribs;
- coeffs += coefficient_width;
- }
-
- // if we have a rational for the scale, we can exploit the polyphaseness to not calculate
- // most of the coefficients, so we copy them here
- if ( polyphase )
- {
- stbir__contributors * prev_contribs = contributors;
- stbir__contributors * cur_contribs = contributors + numerator;
-
- for( n = numerator ; n < num_contributors ; n++ )
- {
- cur_contribs->n0 = prev_contribs->n0 + denominator;
- cur_contribs->n1 = prev_contribs->n1 + denominator;
- ++cur_contribs;
- ++prev_contribs;
- }
- stbir_overlapping_memcpy( coefficient_group + numerator * coefficient_width, coefficient_group, ( num_contributors - numerator ) * coefficient_width * sizeof( coeffs[ 0 ] ) );
- }
-
- coeffs = coefficient_group;
- contribs = contributors;
-
- for (n = 0; n < num_contributors; n++)
- {
- int i;
-
- // in zero edge mode, just remove out of bounds contribs completely (since their weights are accounted for now)
- if ( edge == STBIR_EDGE_ZERO )
- {
- // shrink the right side if necessary
- if ( contribs->n1 > input_last_n1 )
- contribs->n1 = input_last_n1;
-
- // shrink the left side
- if ( contribs->n0 < 0 )
- {
- int j, left, skips = 0;
-
- skips = -contribs->n0;
- contribs->n0 = 0;
-
- // now move down the weights
- left = contribs->n1 - contribs->n0 + 1;
- if ( left > 0 )
- {
- for( j = 0 ; j < left ; j++ )
- coeffs[ j ] = coeffs[ j + skips ];
- }
- }
- }
- else if ( ( edge == STBIR_EDGE_CLAMP ) || ( edge == STBIR_EDGE_REFLECT ) )
- {
- // for clamp and reflect, calculate the true inbounds position (based on edge type) and just add that to the existing weight
-
- // right hand side first
- if ( contribs->n1 > input_last_n1 )
- {
- int start = contribs->n0;
- int endi = contribs->n1;
- contribs->n1 = input_last_n1;
- for( i = input_size; i <= endi; i++ )
- stbir__insert_coeff( contribs, coeffs, stbir__edge_wrap_slow[edge]( i, input_size ), coeffs[i-start], coefficient_width );
- }
-
- // now check left hand edge
- if ( contribs->n0 < 0 )
- {
- int save_n0;
- float save_n0_coeff;
- float * c = coeffs - ( contribs->n0 + 1 );
-
- // reinsert the coeffs with it reflected or clamped (insert accumulates, if the coeffs exist)
- for( i = -1 ; i > contribs->n0 ; i-- )
- stbir__insert_coeff( contribs, coeffs, stbir__edge_wrap_slow[edge]( i, input_size ), *c--, coefficient_width );
- save_n0 = contribs->n0;
- save_n0_coeff = c[0]; // save it, since we didn't do the final one (i==n0), because there might be too many coeffs to hold (before we resize)!
-
- // now slide all the coeffs down (since we have accumulated them in the positive contribs) and reset the first contrib
- contribs->n0 = 0;
- for(i = 0 ; i <= contribs->n1 ; i++ )
- coeffs[i] = coeffs[i-save_n0];
-
- // now that we have shrunk down the contribs, we insert the first one safely
- stbir__insert_coeff( contribs, coeffs, stbir__edge_wrap_slow[edge]( save_n0, input_size ), save_n0_coeff, coefficient_width );
- }
- }
-
- if ( contribs->n0 <= contribs->n1 )
- {
- int diff = contribs->n1 - contribs->n0 + 1;
- while ( diff && ( coeffs[ diff-1 ] == 0.0f ) )
- --diff;
-
- contribs->n1 = contribs->n0 + diff - 1;
-
- if ( contribs->n0 <= contribs->n1 )
- {
- if ( contribs->n0 < lowest )
- lowest = contribs->n0;
- if ( contribs->n1 > highest )
- highest = contribs->n1;
- if ( diff > widest )
- widest = diff;
- }
-
- // re-zero out unused coefficients (if any)
- for( i = diff ; i < coefficient_width ; i++ )
- coeffs[i] = 0.0f;
- }
-
- ++contribs;
- coeffs += coefficient_width;
- }
- filter_info->lowest = lowest;
- filter_info->highest = highest;
- filter_info->widest = widest;
-}
-
-#undef STBIR_RENORM_TYPE
-
-static int stbir__pack_coefficients( int num_contributors, stbir__contributors* contributors, float * coefficents, int coefficient_width, int widest, int row0, int row1 )
-{
- #define STBIR_MOVE_1( dest, src ) { STBIR_NO_UNROLL(dest); ((stbir_uint32*)(dest))[0] = ((stbir_uint32*)(src))[0]; }
- #define STBIR_MOVE_2( dest, src ) { STBIR_NO_UNROLL(dest); ((stbir_uint64*)(dest))[0] = ((stbir_uint64*)(src))[0]; }
- #ifdef STBIR_SIMD
- #define STBIR_MOVE_4( dest, src ) { stbir__simdf t; STBIR_NO_UNROLL(dest); stbir__simdf_load( t, src ); stbir__simdf_store( dest, t ); }
- #else
- #define STBIR_MOVE_4( dest, src ) { STBIR_NO_UNROLL(dest); ((stbir_uint64*)(dest))[0] = ((stbir_uint64*)(src))[0]; ((stbir_uint64*)(dest))[1] = ((stbir_uint64*)(src))[1]; }
- #endif
-
- int row_end = row1 + 1;
- STBIR__UNUSED( row0 ); // only used in an assert
-
- if ( coefficient_width != widest )
- {
- float * pc = coefficents;
- float * coeffs = coefficents;
- float * pc_end = coefficents + num_contributors * widest;
- switch( widest )
- {
- case 1:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_1( pc, coeffs );
- ++pc;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 2:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_2( pc, coeffs );
- pc += 2;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 3:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_2( pc, coeffs );
- STBIR_MOVE_1( pc+2, coeffs+2 );
- pc += 3;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 4:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_4( pc, coeffs );
- pc += 4;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 5:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_4( pc, coeffs );
- STBIR_MOVE_1( pc+4, coeffs+4 );
- pc += 5;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 6:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_4( pc, coeffs );
- STBIR_MOVE_2( pc+4, coeffs+4 );
- pc += 6;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 7:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_4( pc, coeffs );
- STBIR_MOVE_2( pc+4, coeffs+4 );
- STBIR_MOVE_1( pc+6, coeffs+6 );
- pc += 7;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 8:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_4( pc, coeffs );
- STBIR_MOVE_4( pc+4, coeffs+4 );
- pc += 8;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 9:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_4( pc, coeffs );
- STBIR_MOVE_4( pc+4, coeffs+4 );
- STBIR_MOVE_1( pc+8, coeffs+8 );
- pc += 9;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 10:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_4( pc, coeffs );
- STBIR_MOVE_4( pc+4, coeffs+4 );
- STBIR_MOVE_2( pc+8, coeffs+8 );
- pc += 10;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 11:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_4( pc, coeffs );
- STBIR_MOVE_4( pc+4, coeffs+4 );
- STBIR_MOVE_2( pc+8, coeffs+8 );
- STBIR_MOVE_1( pc+10, coeffs+10 );
- pc += 11;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- case 12:
- STBIR_NO_UNROLL_LOOP_START
- do {
- STBIR_MOVE_4( pc, coeffs );
- STBIR_MOVE_4( pc+4, coeffs+4 );
- STBIR_MOVE_4( pc+8, coeffs+8 );
- pc += 12;
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- default:
- STBIR_NO_UNROLL_LOOP_START
- do {
- float * copy_end = pc + widest - 4;
- float * c = coeffs;
- do {
- STBIR_NO_UNROLL( pc );
- STBIR_MOVE_4( pc, c );
- pc += 4;
- c += 4;
- } while ( pc <= copy_end );
- copy_end += 4;
- STBIR_NO_UNROLL_LOOP_START
- while ( pc < copy_end )
- {
- STBIR_MOVE_1( pc, c );
- ++pc; ++c;
- }
- coeffs += coefficient_width;
- } while ( pc < pc_end );
- break;
- }
- }
-
- // some horizontal routines read one float off the end (which is then masked off), so put in a sentinal so we don't read an snan or denormal
- coefficents[ widest * num_contributors ] = 8888.0f;
-
- // the minimum we might read for unrolled filters widths is 12. So, we need to
- // make sure we never read outside the decode buffer, by possibly moving
- // the sample area back into the scanline, and putting zeros weights first.
- // we start on the right edge and check until we're well past the possible
- // clip area (2*widest).
- {
- stbir__contributors * contribs = contributors + num_contributors - 1;
- float * coeffs = coefficents + widest * ( num_contributors - 1 );
-
- // go until no chance of clipping (this is usually less than 8 lops)
- while ( ( contribs >= contributors ) && ( ( contribs->n0 + widest*2 ) >= row_end ) )
- {
- // might we clip??
- if ( ( contribs->n0 + widest ) > row_end )
- {
- int stop_range = widest;
-
- // if range is larger than 12, it will be handled by generic loops that can terminate on the exact length
- // of this contrib n1, instead of a fixed widest amount - so calculate this
- if ( widest > 12 )
- {
- int mod;
-
- // how far will be read in the n_coeff loop (which depends on the widest count mod4);
- mod = widest & 3;
- stop_range = ( ( ( contribs->n1 - contribs->n0 + 1 ) - mod + 3 ) & ~3 ) + mod;
-
- // the n_coeff loops do a minimum amount of coeffs, so factor that in!
- if ( stop_range < ( 8 + mod ) ) stop_range = 8 + mod;
- }
-
- // now see if we still clip with the refined range
- if ( ( contribs->n0 + stop_range ) > row_end )
- {
- int new_n0 = row_end - stop_range;
- int num = contribs->n1 - contribs->n0 + 1;
- int backup = contribs->n0 - new_n0;
- float * from_co = coeffs + num - 1;
- float * to_co = from_co + backup;
-
- STBIR_ASSERT( ( new_n0 >= row0 ) && ( new_n0 < contribs->n0 ) );
-
- // move the coeffs over
- while( num )
- {
- *to_co-- = *from_co--;
- --num;
- }
- // zero new positions
- while ( to_co >= coeffs )
- *to_co-- = 0;
- // set new start point
- contribs->n0 = new_n0;
- if ( widest > 12 )
- {
- int mod;
-
- // how far will be read in the n_coeff loop (which depends on the widest count mod4);
- mod = widest & 3;
- stop_range = ( ( ( contribs->n1 - contribs->n0 + 1 ) - mod + 3 ) & ~3 ) + mod;
-
- // the n_coeff loops do a minimum amount of coeffs, so factor that in!
- if ( stop_range < ( 8 + mod ) ) stop_range = 8 + mod;
- }
- }
- }
- --contribs;
- coeffs -= widest;
- }
- }
-
- return widest;
- #undef STBIR_MOVE_1
- #undef STBIR_MOVE_2
- #undef STBIR_MOVE_4
-}
-
-static void stbir__calculate_filters( stbir__sampler * samp, stbir__sampler * other_axis_for_pivot, void * user_data STBIR_ONLY_PROFILE_BUILD_GET_INFO )
-{
- int n;
- float scale = samp->scale_info.scale;
- stbir__kernel_callback * kernel = samp->filter_kernel;
- stbir__support_callback * support = samp->filter_support;
- float inv_scale = samp->scale_info.inv_scale;
- int input_full_size = samp->scale_info.input_full_size;
- int gather_num_contributors = samp->num_contributors;
- stbir__contributors* gather_contributors = samp->contributors;
- float * gather_coeffs = samp->coefficients;
- int gather_coefficient_width = samp->coefficient_width;
-
- switch ( samp->is_gather )
- {
- case 1: // gather upsample
- {
- float out_pixels_radius = support(inv_scale,user_data) * scale;
-
- stbir__calculate_coefficients_for_gather_upsample( out_pixels_radius, kernel, &samp->scale_info, gather_num_contributors, gather_contributors, gather_coeffs, gather_coefficient_width, samp->edge, user_data );
-
- STBIR_PROFILE_BUILD_START( cleanup );
- stbir__cleanup_gathered_coefficients( samp->edge, &samp->extent_info, &samp->scale_info, gather_num_contributors, gather_contributors, gather_coeffs, gather_coefficient_width );
- STBIR_PROFILE_BUILD_END( cleanup );
- }
- break;
-
- case 0: // scatter downsample (only on vertical)
- case 2: // gather downsample
- {
- float in_pixels_radius = support(scale,user_data) * inv_scale;
- int filter_pixel_margin = samp->filter_pixel_margin;
- int input_end = input_full_size + filter_pixel_margin;
-
- // if this is a scatter, we do a downsample gather to get the coeffs, and then pivot after
- if ( !samp->is_gather )
- {
- // check if we are using the same gather downsample on the horizontal as this vertical,
- // if so, then we don't have to generate them, we can just pivot from the horizontal.
- if ( other_axis_for_pivot )
- {
- gather_contributors = other_axis_for_pivot->contributors;
- gather_coeffs = other_axis_for_pivot->coefficients;
- gather_coefficient_width = other_axis_for_pivot->coefficient_width;
- gather_num_contributors = other_axis_for_pivot->num_contributors;
- samp->extent_info.lowest = other_axis_for_pivot->extent_info.lowest;
- samp->extent_info.highest = other_axis_for_pivot->extent_info.highest;
- samp->extent_info.widest = other_axis_for_pivot->extent_info.widest;
- goto jump_right_to_pivot;
- }
-
- gather_contributors = samp->gather_prescatter_contributors;
- gather_coeffs = samp->gather_prescatter_coefficients;
- gather_coefficient_width = samp->gather_prescatter_coefficient_width;
- gather_num_contributors = samp->gather_prescatter_num_contributors;
- }
-
- stbir__calculate_coefficients_for_gather_downsample( -filter_pixel_margin, input_end, in_pixels_radius, kernel, &samp->scale_info, gather_coefficient_width, gather_num_contributors, gather_contributors, gather_coeffs, user_data );
-
- STBIR_PROFILE_BUILD_START( cleanup );
- stbir__cleanup_gathered_coefficients( samp->edge, &samp->extent_info, &samp->scale_info, gather_num_contributors, gather_contributors, gather_coeffs, gather_coefficient_width );
- STBIR_PROFILE_BUILD_END( cleanup );
-
- if ( !samp->is_gather )
- {
- // if this is a scatter (vertical only), then we need to pivot the coeffs
- stbir__contributors * scatter_contributors;
- int highest_set;
-
- jump_right_to_pivot:
-
- STBIR_PROFILE_BUILD_START( pivot );
-
- highest_set = (-filter_pixel_margin) - 1;
- for (n = 0; n < gather_num_contributors; n++)
- {
- int k;
- int gn0 = gather_contributors->n0, gn1 = gather_contributors->n1;
- int scatter_coefficient_width = samp->coefficient_width;
- float * scatter_coeffs = samp->coefficients + ( gn0 + filter_pixel_margin ) * scatter_coefficient_width;
- float * g_coeffs = gather_coeffs;
- scatter_contributors = samp->contributors + ( gn0 + filter_pixel_margin );
-
- for (k = gn0 ; k <= gn1 ; k++ )
- {
- float gc = *g_coeffs++;
-
- // skip zero and denormals - must skip zeros to avoid adding coeffs beyond scatter_coefficient_width
- // (which happens when pivoting from horizontal, which might have dummy zeros)
- if ( ( ( gc >= stbir__small_float ) || ( gc <= -stbir__small_float ) ) )
- {
- if ( ( k > highest_set ) || ( scatter_contributors->n0 > scatter_contributors->n1 ) )
- {
- {
- // if we are skipping over several contributors, we need to clear the skipped ones
- stbir__contributors * clear_contributors = samp->contributors + ( highest_set + filter_pixel_margin + 1);
- while ( clear_contributors < scatter_contributors )
- {
- clear_contributors->n0 = 0;
- clear_contributors->n1 = -1;
- ++clear_contributors;
- }
- }
- scatter_contributors->n0 = n;
- scatter_contributors->n1 = n;
- scatter_coeffs[0] = gc;
- highest_set = k;
- }
- else
- {
- stbir__insert_coeff( scatter_contributors, scatter_coeffs, n, gc, scatter_coefficient_width );
- }
- STBIR_ASSERT( ( scatter_contributors->n1 - scatter_contributors->n0 + 1 ) <= scatter_coefficient_width );
- }
- ++scatter_contributors;
- scatter_coeffs += scatter_coefficient_width;
- }
-
- ++gather_contributors;
- gather_coeffs += gather_coefficient_width;
- }
-
- // now clear any unset contribs
- {
- stbir__contributors * clear_contributors = samp->contributors + ( highest_set + filter_pixel_margin + 1);
- stbir__contributors * end_contributors = samp->contributors + samp->num_contributors;
- while ( clear_contributors < end_contributors )
- {
- clear_contributors->n0 = 0;
- clear_contributors->n1 = -1;
- ++clear_contributors;
- }
- }
-
- STBIR_PROFILE_BUILD_END( pivot );
- }
- }
- break;
- }
-}
-
-
-//========================================================================================================
-// scanline decoders and encoders
-
-#define stbir__coder_min_num 1
-#define STB_IMAGE_RESIZE_DO_CODERS
-#include STBIR__HEADER_FILENAME
-
-#define stbir__decode_suffix BGRA
-#define stbir__decode_swizzle
-#define stbir__decode_order0 2
-#define stbir__decode_order1 1
-#define stbir__decode_order2 0
-#define stbir__decode_order3 3
-#define stbir__encode_order0 2
-#define stbir__encode_order1 1
-#define stbir__encode_order2 0
-#define stbir__encode_order3 3
-#define stbir__coder_min_num 4
-#define STB_IMAGE_RESIZE_DO_CODERS
-#include STBIR__HEADER_FILENAME
-
-#define stbir__decode_suffix ARGB
-#define stbir__decode_swizzle
-#define stbir__decode_order0 1
-#define stbir__decode_order1 2
-#define stbir__decode_order2 3
-#define stbir__decode_order3 0
-#define stbir__encode_order0 3
-#define stbir__encode_order1 0
-#define stbir__encode_order2 1
-#define stbir__encode_order3 2
-#define stbir__coder_min_num 4
-#define STB_IMAGE_RESIZE_DO_CODERS
-#include STBIR__HEADER_FILENAME
-
-#define stbir__decode_suffix ABGR
-#define stbir__decode_swizzle
-#define stbir__decode_order0 3
-#define stbir__decode_order1 2
-#define stbir__decode_order2 1
-#define stbir__decode_order3 0
-#define stbir__encode_order0 3
-#define stbir__encode_order1 2
-#define stbir__encode_order2 1
-#define stbir__encode_order3 0
-#define stbir__coder_min_num 4
-#define STB_IMAGE_RESIZE_DO_CODERS
-#include STBIR__HEADER_FILENAME
-
-#define stbir__decode_suffix AR
-#define stbir__decode_swizzle
-#define stbir__decode_order0 1
-#define stbir__decode_order1 0
-#define stbir__decode_order2 3
-#define stbir__decode_order3 2
-#define stbir__encode_order0 1
-#define stbir__encode_order1 0
-#define stbir__encode_order2 3
-#define stbir__encode_order3 2
-#define stbir__coder_min_num 2
-#define STB_IMAGE_RESIZE_DO_CODERS
-#include STBIR__HEADER_FILENAME
-
-
-// fancy alpha means we expand to keep both premultipied and non-premultiplied color channels
-static void stbir__fancy_alpha_weight_4ch( float * out_buffer, int width_times_channels )
-{
- float STBIR_STREAMOUT_PTR(*) out = out_buffer;
- float const * end_decode = out_buffer + ( width_times_channels / 4 ) * 7; // decode buffer aligned to end of out_buffer
- float STBIR_STREAMOUT_PTR(*) decode = (float*)end_decode - width_times_channels;
-
- // fancy alpha is stored internally as R G B A Rpm Gpm Bpm
-
- #ifdef STBIR_SIMD
-
- #ifdef STBIR_SIMD8
- decode += 16;
- STBIR_NO_UNROLL_LOOP_START
- while ( decode <= end_decode )
- {
- stbir__simdf8 d0,d1,a0,a1,p0,p1;
- STBIR_NO_UNROLL(decode);
- stbir__simdf8_load( d0, decode-16 );
- stbir__simdf8_load( d1, decode-16+8 );
- stbir__simdf8_0123to33333333( a0, d0 );
- stbir__simdf8_0123to33333333( a1, d1 );
- stbir__simdf8_mult( p0, a0, d0 );
- stbir__simdf8_mult( p1, a1, d1 );
- stbir__simdf8_bot4s( a0, d0, p0 );
- stbir__simdf8_bot4s( a1, d1, p1 );
- stbir__simdf8_top4s( d0, d0, p0 );
- stbir__simdf8_top4s( d1, d1, p1 );
- stbir__simdf8_store ( out, a0 );
- stbir__simdf8_store ( out+7, d0 );
- stbir__simdf8_store ( out+14, a1 );
- stbir__simdf8_store ( out+21, d1 );
- decode += 16;
- out += 28;
- }
- decode -= 16;
- #else
- decode += 8;
- STBIR_NO_UNROLL_LOOP_START
- while ( decode <= end_decode )
- {
- stbir__simdf d0,a0,d1,a1,p0,p1;
- STBIR_NO_UNROLL(decode);
- stbir__simdf_load( d0, decode-8 );
- stbir__simdf_load( d1, decode-8+4 );
- stbir__simdf_0123to3333( a0, d0 );
- stbir__simdf_0123to3333( a1, d1 );
- stbir__simdf_mult( p0, a0, d0 );
- stbir__simdf_mult( p1, a1, d1 );
- stbir__simdf_store ( out, d0 );
- stbir__simdf_store ( out+4, p0 );
- stbir__simdf_store ( out+7, d1 );
- stbir__simdf_store ( out+7+4, p1 );
- decode += 8;
- out += 14;
- }
- decode -= 8;
- #endif
-
- // might be one last odd pixel
- #ifdef STBIR_SIMD8
- STBIR_NO_UNROLL_LOOP_START
- while ( decode < end_decode )
- #else
- if ( decode < end_decode )
- #endif
- {
- stbir__simdf d,a,p;
- STBIR_NO_UNROLL(decode);
- stbir__simdf_load( d, decode );
- stbir__simdf_0123to3333( a, d );
- stbir__simdf_mult( p, a, d );
- stbir__simdf_store ( out, d );
- stbir__simdf_store ( out+4, p );
- decode += 4;
- out += 7;
- }
-
- #else
-
- while( decode < end_decode )
- {
- float r = decode[0], g = decode[1], b = decode[2], alpha = decode[3];
- out[0] = r;
- out[1] = g;
- out[2] = b;
- out[3] = alpha;
- out[4] = r * alpha;
- out[5] = g * alpha;
- out[6] = b * alpha;
- out += 7;
- decode += 4;
- }
-
- #endif
-}
-
-static void stbir__fancy_alpha_weight_2ch( float * out_buffer, int width_times_channels )
-{
- float STBIR_STREAMOUT_PTR(*) out = out_buffer;
- float const * end_decode = out_buffer + ( width_times_channels / 2 ) * 3;
- float STBIR_STREAMOUT_PTR(*) decode = (float*)end_decode - width_times_channels;
-
- // for fancy alpha, turns into: [X A Xpm][X A Xpm],etc
-
- #ifdef STBIR_SIMD
-
- decode += 8;
- if ( decode <= end_decode )
- {
- STBIR_NO_UNROLL_LOOP_START
- do {
- #ifdef STBIR_SIMD8
- stbir__simdf8 d0,a0,p0;
- STBIR_NO_UNROLL(decode);
- stbir__simdf8_load( d0, decode-8 );
- stbir__simdf8_0123to11331133( p0, d0 );
- stbir__simdf8_0123to00220022( a0, d0 );
- stbir__simdf8_mult( p0, p0, a0 );
-
- stbir__simdf_store2( out, stbir__if_simdf8_cast_to_simdf4( d0 ) );
- stbir__simdf_store( out+2, stbir__if_simdf8_cast_to_simdf4( p0 ) );
- stbir__simdf_store2h( out+3, stbir__if_simdf8_cast_to_simdf4( d0 ) );
-
- stbir__simdf_store2( out+6, stbir__simdf8_gettop4( d0 ) );
- stbir__simdf_store( out+8, stbir__simdf8_gettop4( p0 ) );
- stbir__simdf_store2h( out+9, stbir__simdf8_gettop4( d0 ) );
- #else
- stbir__simdf d0,a0,d1,a1,p0,p1;
- STBIR_NO_UNROLL(decode);
- stbir__simdf_load( d0, decode-8 );
- stbir__simdf_load( d1, decode-8+4 );
- stbir__simdf_0123to1133( p0, d0 );
- stbir__simdf_0123to1133( p1, d1 );
- stbir__simdf_0123to0022( a0, d0 );
- stbir__simdf_0123to0022( a1, d1 );
- stbir__simdf_mult( p0, p0, a0 );
- stbir__simdf_mult( p1, p1, a1 );
-
- stbir__simdf_store2( out, d0 );
- stbir__simdf_store( out+2, p0 );
- stbir__simdf_store2h( out+3, d0 );
-
- stbir__simdf_store2( out+6, d1 );
- stbir__simdf_store( out+8, p1 );
- stbir__simdf_store2h( out+9, d1 );
- #endif
- decode += 8;
- out += 12;
- } while ( decode <= end_decode );
- }
- decode -= 8;
- #endif
-
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( decode < end_decode )
- {
- float x = decode[0], y = decode[1];
- STBIR_SIMD_NO_UNROLL(decode);
- out[0] = x;
- out[1] = y;
- out[2] = x * y;
- out += 3;
- decode += 2;
- }
-}
-
-static void stbir__fancy_alpha_unweight_4ch( float * encode_buffer, int width_times_channels )
-{
- float STBIR_SIMD_STREAMOUT_PTR(*) encode = encode_buffer;
- float STBIR_SIMD_STREAMOUT_PTR(*) input = encode_buffer;
- float const * end_output = encode_buffer + width_times_channels;
-
- // fancy RGBA is stored internally as R G B A Rpm Gpm Bpm
-
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float alpha = input[3];
-#ifdef STBIR_SIMD
- stbir__simdf i,ia;
- STBIR_SIMD_NO_UNROLL(encode);
- if ( alpha < stbir__small_float )
- {
- stbir__simdf_load( i, input );
- stbir__simdf_store( encode, i );
- }
- else
- {
- stbir__simdf_load1frep4( ia, 1.0f / alpha );
- stbir__simdf_load( i, input+4 );
- stbir__simdf_mult( i, i, ia );
- stbir__simdf_store( encode, i );
- encode[3] = alpha;
- }
-#else
- if ( alpha < stbir__small_float )
- {
- encode[0] = input[0];
- encode[1] = input[1];
- encode[2] = input[2];
- }
- else
- {
- float ialpha = 1.0f / alpha;
- encode[0] = input[4] * ialpha;
- encode[1] = input[5] * ialpha;
- encode[2] = input[6] * ialpha;
- }
- encode[3] = alpha;
-#endif
-
- input += 7;
- encode += 4;
- } while ( encode < end_output );
-}
-
-// format: [X A Xpm][X A Xpm] etc
-static void stbir__fancy_alpha_unweight_2ch( float * encode_buffer, int width_times_channels )
-{
- float STBIR_SIMD_STREAMOUT_PTR(*) encode = encode_buffer;
- float STBIR_SIMD_STREAMOUT_PTR(*) input = encode_buffer;
- float const * end_output = encode_buffer + width_times_channels;
-
- do {
- float alpha = input[1];
- encode[0] = input[0];
- if ( alpha >= stbir__small_float )
- encode[0] = input[2] / alpha;
- encode[1] = alpha;
-
- input += 3;
- encode += 2;
- } while ( encode < end_output );
-}
-
-static void stbir__simple_alpha_weight_4ch( float * decode_buffer, int width_times_channels )
-{
- float STBIR_STREAMOUT_PTR(*) decode = decode_buffer;
- float const * end_decode = decode_buffer + width_times_channels;
-
- #ifdef STBIR_SIMD
- {
- decode += 2 * stbir__simdfX_float_count;
- STBIR_NO_UNROLL_LOOP_START
- while ( decode <= end_decode )
- {
- stbir__simdfX d0,a0,d1,a1;
- STBIR_NO_UNROLL(decode);
- stbir__simdfX_load( d0, decode-2*stbir__simdfX_float_count );
- stbir__simdfX_load( d1, decode-2*stbir__simdfX_float_count+stbir__simdfX_float_count );
- stbir__simdfX_aaa1( a0, d0, STBIR_onesX );
- stbir__simdfX_aaa1( a1, d1, STBIR_onesX );
- stbir__simdfX_mult( d0, d0, a0 );
- stbir__simdfX_mult( d1, d1, a1 );
- stbir__simdfX_store ( decode-2*stbir__simdfX_float_count, d0 );
- stbir__simdfX_store ( decode-2*stbir__simdfX_float_count+stbir__simdfX_float_count, d1 );
- decode += 2 * stbir__simdfX_float_count;
- }
- decode -= 2 * stbir__simdfX_float_count;
-
- // few last pixels remnants
- #ifdef STBIR_SIMD8
- STBIR_NO_UNROLL_LOOP_START
- while ( decode < end_decode )
- #else
- if ( decode < end_decode )
- #endif
- {
- stbir__simdf d,a;
- stbir__simdf_load( d, decode );
- stbir__simdf_aaa1( a, d, STBIR__CONSTF(STBIR_ones) );
- stbir__simdf_mult( d, d, a );
- stbir__simdf_store ( decode, d );
- decode += 4;
- }
- }
-
- #else
-
- while( decode < end_decode )
- {
- float alpha = decode[3];
- decode[0] *= alpha;
- decode[1] *= alpha;
- decode[2] *= alpha;
- decode += 4;
- }
-
- #endif
-}
-
-static void stbir__simple_alpha_weight_2ch( float * decode_buffer, int width_times_channels )
-{
- float STBIR_STREAMOUT_PTR(*) decode = decode_buffer;
- float const * end_decode = decode_buffer + width_times_channels;
-
- #ifdef STBIR_SIMD
- decode += 2 * stbir__simdfX_float_count;
- STBIR_NO_UNROLL_LOOP_START
- while ( decode <= end_decode )
- {
- stbir__simdfX d0,a0,d1,a1;
- STBIR_NO_UNROLL(decode);
- stbir__simdfX_load( d0, decode-2*stbir__simdfX_float_count );
- stbir__simdfX_load( d1, decode-2*stbir__simdfX_float_count+stbir__simdfX_float_count );
- stbir__simdfX_a1a1( a0, d0, STBIR_onesX );
- stbir__simdfX_a1a1( a1, d1, STBIR_onesX );
- stbir__simdfX_mult( d0, d0, a0 );
- stbir__simdfX_mult( d1, d1, a1 );
- stbir__simdfX_store ( decode-2*stbir__simdfX_float_count, d0 );
- stbir__simdfX_store ( decode-2*stbir__simdfX_float_count+stbir__simdfX_float_count, d1 );
- decode += 2 * stbir__simdfX_float_count;
- }
- decode -= 2 * stbir__simdfX_float_count;
- #endif
-
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( decode < end_decode )
- {
- float alpha = decode[1];
- STBIR_SIMD_NO_UNROLL(decode);
- decode[0] *= alpha;
- decode += 2;
- }
-}
-
-static void stbir__simple_alpha_unweight_4ch( float * encode_buffer, int width_times_channels )
-{
- float STBIR_SIMD_STREAMOUT_PTR(*) encode = encode_buffer;
- float const * end_output = encode_buffer + width_times_channels;
-
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float alpha = encode[3];
-
-#ifdef STBIR_SIMD
- stbir__simdf i,ia;
- STBIR_SIMD_NO_UNROLL(encode);
- if ( alpha >= stbir__small_float )
- {
- stbir__simdf_load1frep4( ia, 1.0f / alpha );
- stbir__simdf_load( i, encode );
- stbir__simdf_mult( i, i, ia );
- stbir__simdf_store( encode, i );
- encode[3] = alpha;
- }
-#else
- if ( alpha >= stbir__small_float )
- {
- float ialpha = 1.0f / alpha;
- encode[0] *= ialpha;
- encode[1] *= ialpha;
- encode[2] *= ialpha;
- }
-#endif
- encode += 4;
- } while ( encode < end_output );
-}
-
-static void stbir__simple_alpha_unweight_2ch( float * encode_buffer, int width_times_channels )
-{
- float STBIR_SIMD_STREAMOUT_PTR(*) encode = encode_buffer;
- float const * end_output = encode_buffer + width_times_channels;
-
- do {
- float alpha = encode[1];
- if ( alpha >= stbir__small_float )
- encode[0] /= alpha;
- encode += 2;
- } while ( encode < end_output );
-}
-
-
-// only used in RGB->BGR or BGR->RGB
-static void stbir__simple_flip_3ch( float * decode_buffer, int width_times_channels )
-{
- float STBIR_STREAMOUT_PTR(*) decode = decode_buffer;
- float const * end_decode = decode_buffer + width_times_channels;
-
-#ifdef STBIR_SIMD
- #ifdef stbir__simdf_swiz2 // do we have two argument swizzles?
- end_decode -= 12;
- STBIR_NO_UNROLL_LOOP_START
- while( decode <= end_decode )
- {
- // on arm64 8 instructions, no overlapping stores
- stbir__simdf a,b,c,na,nb;
- STBIR_SIMD_NO_UNROLL(decode);
- stbir__simdf_load( a, decode );
- stbir__simdf_load( b, decode+4 );
- stbir__simdf_load( c, decode+8 );
-
- na = stbir__simdf_swiz2( a, b, 2, 1, 0, 5 );
- b = stbir__simdf_swiz2( a, b, 4, 3, 6, 7 );
- nb = stbir__simdf_swiz2( b, c, 0, 1, 4, 3 );
- c = stbir__simdf_swiz2( b, c, 2, 7, 6, 5 );
-
- stbir__simdf_store( decode, na );
- stbir__simdf_store( decode+4, nb );
- stbir__simdf_store( decode+8, c );
- decode += 12;
- }
- end_decode += 12;
- #else
- end_decode -= 24;
- STBIR_NO_UNROLL_LOOP_START
- while( decode <= end_decode )
- {
- // 26 instructions on x64
- stbir__simdf a,b,c,d,e,f,g;
- float i21, i23;
- STBIR_SIMD_NO_UNROLL(decode);
- stbir__simdf_load( a, decode );
- stbir__simdf_load( b, decode+3 );
- stbir__simdf_load( c, decode+6 );
- stbir__simdf_load( d, decode+9 );
- stbir__simdf_load( e, decode+12 );
- stbir__simdf_load( f, decode+15 );
- stbir__simdf_load( g, decode+18 );
-
- a = stbir__simdf_swiz( a, 2, 1, 0, 3 );
- b = stbir__simdf_swiz( b, 2, 1, 0, 3 );
- c = stbir__simdf_swiz( c, 2, 1, 0, 3 );
- d = stbir__simdf_swiz( d, 2, 1, 0, 3 );
- e = stbir__simdf_swiz( e, 2, 1, 0, 3 );
- f = stbir__simdf_swiz( f, 2, 1, 0, 3 );
- g = stbir__simdf_swiz( g, 2, 1, 0, 3 );
-
- // stores overlap, need to be in order,
- stbir__simdf_store( decode, a );
- i21 = decode[21];
- stbir__simdf_store( decode+3, b );
- i23 = decode[23];
- stbir__simdf_store( decode+6, c );
- stbir__simdf_store( decode+9, d );
- stbir__simdf_store( decode+12, e );
- stbir__simdf_store( decode+15, f );
- stbir__simdf_store( decode+18, g );
- decode[21] = i23;
- decode[23] = i21;
- decode += 24;
- }
- end_decode += 24;
- #endif
-#else
- end_decode -= 12;
- STBIR_NO_UNROLL_LOOP_START
- while( decode <= end_decode )
- {
- // 16 instructions
- float t0,t1,t2,t3;
- STBIR_NO_UNROLL(decode);
- t0 = decode[0]; t1 = decode[3]; t2 = decode[6]; t3 = decode[9];
- decode[0] = decode[2]; decode[3] = decode[5]; decode[6] = decode[8]; decode[9] = decode[11];
- decode[2] = t0; decode[5] = t1; decode[8] = t2; decode[11] = t3;
- decode += 12;
- }
- end_decode += 12;
-#endif
-
- STBIR_NO_UNROLL_LOOP_START
- while( decode < end_decode )
- {
- float t = decode[0];
- STBIR_NO_UNROLL(decode);
- decode[0] = decode[2];
- decode[2] = t;
- decode += 3;
- }
-}
-
-
-
-static void stbir__decode_scanline(stbir__info const * stbir_info, int n, float * output_buffer STBIR_ONLY_PROFILE_GET_SPLIT_INFO )
-{
- int channels = stbir_info->channels;
- int effective_channels = stbir_info->effective_channels;
- int input_sample_in_bytes = stbir__type_size[stbir_info->input_type] * channels;
- stbir_edge edge_horizontal = stbir_info->horizontal.edge;
- stbir_edge edge_vertical = stbir_info->vertical.edge;
- int row = stbir__edge_wrap(edge_vertical, n, stbir_info->vertical.scale_info.input_full_size);
- const void* input_plane_data = ( (char *) stbir_info->input_data ) + (size_t)row * (size_t) stbir_info->input_stride_bytes;
- stbir__span const * spans = stbir_info->scanline_extents.spans;
- float * full_decode_buffer = output_buffer - stbir_info->scanline_extents.conservative.n0 * effective_channels;
- float * last_decoded = 0;
-
- // if we are on edge_zero, and we get in here with an out of bounds n, then the calculate filters has failed
- STBIR_ASSERT( !(edge_vertical == STBIR_EDGE_ZERO && (n < 0 || n >= stbir_info->vertical.scale_info.input_full_size)) );
-
- do
- {
- float * decode_buffer;
- void const * input_data;
- float * end_decode;
- int width_times_channels;
- int width;
-
- if ( spans->n1 < spans->n0 )
- break;
-
- width = spans->n1 + 1 - spans->n0;
- decode_buffer = full_decode_buffer + spans->n0 * effective_channels;
- end_decode = full_decode_buffer + ( spans->n1 + 1 ) * effective_channels;
- width_times_channels = width * channels;
-
- // read directly out of input plane by default
- input_data = ( (char*)input_plane_data ) + spans->pixel_offset_for_input * input_sample_in_bytes;
-
- // if we have an input callback, call it to get the input data
- if ( stbir_info->in_pixels_cb )
- {
- // call the callback with a temp buffer (that they can choose to use or not). the temp is just right aligned memory in the decode_buffer itself
- input_data = stbir_info->in_pixels_cb( ( (char*) end_decode ) - ( width * input_sample_in_bytes ) + ( ( stbir_info->input_type != STBIR_TYPE_FLOAT ) ? ( sizeof(float)*STBIR_INPUT_CALLBACK_PADDING ) : 0 ), input_plane_data, width, spans->pixel_offset_for_input, row, stbir_info->user_data );
- }
-
- STBIR_PROFILE_START( decode );
- // convert the pixels info the float decode_buffer, (we index from end_decode, so that when channelsdecode_pixels( (float*)end_decode - width_times_channels, width_times_channels, input_data );
- STBIR_PROFILE_END( decode );
-
- if (stbir_info->alpha_weight)
- {
- STBIR_PROFILE_START( alpha );
- stbir_info->alpha_weight( decode_buffer, width_times_channels );
- STBIR_PROFILE_END( alpha );
- }
-
- ++spans;
- } while ( spans <= ( &stbir_info->scanline_extents.spans[1] ) );
-
- // handle the edge_wrap filter (all other types are handled back out at the calculate_filter stage)
- // basically the idea here is that if we have the whole scanline in memory, we don't redecode the
- // wrapped edge pixels, and instead just memcpy them from the scanline into the edge positions
- if ( ( edge_horizontal == STBIR_EDGE_WRAP ) && ( stbir_info->scanline_extents.edge_sizes[0] | stbir_info->scanline_extents.edge_sizes[1] ) )
- {
- // this code only runs if we're in edge_wrap, and we're doing the entire scanline
- int e, start_x[2];
- int input_full_size = stbir_info->horizontal.scale_info.input_full_size;
-
- start_x[0] = -stbir_info->scanline_extents.edge_sizes[0]; // left edge start x
- start_x[1] = input_full_size; // right edge
-
- for( e = 0; e < 2 ; e++ )
- {
- // do each margin
- int margin = stbir_info->scanline_extents.edge_sizes[e];
- if ( margin )
- {
- int x = start_x[e];
- float * marg = full_decode_buffer + x * effective_channels;
- float const * src = full_decode_buffer + stbir__edge_wrap(edge_horizontal, x, input_full_size) * effective_channels;
- STBIR_MEMCPY( marg, src, margin * effective_channels * sizeof(float) );
- if ( e == 1 ) last_decoded = marg + margin * effective_channels;
- }
- }
- }
-
- // some of the horizontal gathers read one float off the edge (which is masked out), but we force a zero here to make sure no NaNs leak in
- // (we can't pre-zero it, because the input callback can use that area as padding)
- last_decoded[0] = 0.0f;
-
- // we clear this extra float, because the final output pixel filter kernel might have used one less coeff than the max filter width
- // when this happens, we do read that pixel from the input, so it too could be Nan, so just zero an extra one.
- // this fits because each scanline is padded by three floats (STBIR_INPUT_CALLBACK_PADDING)
- last_decoded[1] = 0.0f;
-}
-
-
-//=================
-// Do 1 channel horizontal routines
-
-#ifdef STBIR_SIMD
-
-#define stbir__1_coeff_only() \
- stbir__simdf tot,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1( c, hc ); \
- stbir__simdf_mult1_mem( tot, c, decode );
-
-#define stbir__2_coeff_only() \
- stbir__simdf tot,c,d; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load2z( c, hc ); \
- stbir__simdf_load2( d, decode ); \
- stbir__simdf_mult( tot, c, d ); \
- stbir__simdf_0123to1230( c, tot ); \
- stbir__simdf_add1( tot, tot, c );
-
-#define stbir__3_coeff_only() \
- stbir__simdf tot,c,t; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( c, hc ); \
- stbir__simdf_mult_mem( tot, c, decode ); \
- stbir__simdf_0123to1230( c, tot ); \
- stbir__simdf_0123to2301( t, tot ); \
- stbir__simdf_add1( tot, tot, c ); \
- stbir__simdf_add1( tot, tot, t );
-
-#define stbir__store_output_tiny() \
- stbir__simdf_store1( output, tot ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 1;
-
-#define stbir__4_coeff_start() \
- stbir__simdf tot,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( c, hc ); \
- stbir__simdf_mult_mem( tot, c, decode ); \
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( c, hc + (ofs) ); \
- stbir__simdf_madd_mem( tot, tot, c, decode+(ofs) );
-
-#define stbir__1_coeff_remnant( ofs ) \
- { stbir__simdf d; \
- stbir__simdf_load1z( c, hc + (ofs) ); \
- stbir__simdf_load1( d, decode + (ofs) ); \
- stbir__simdf_madd( tot, tot, d, c ); }
-
-#define stbir__2_coeff_remnant( ofs ) \
- { stbir__simdf d; \
- stbir__simdf_load2z( c, hc+(ofs) ); \
- stbir__simdf_load2( d, decode+(ofs) ); \
- stbir__simdf_madd( tot, tot, d, c ); }
-
-#define stbir__3_coeff_setup() \
- stbir__simdf mask; \
- stbir__simdf_load( mask, STBIR_mask + 3 );
-
-#define stbir__3_coeff_remnant( ofs ) \
- stbir__simdf_load( c, hc+(ofs) ); \
- stbir__simdf_and( c, c, mask ); \
- stbir__simdf_madd_mem( tot, tot, c, decode+(ofs) );
-
-#define stbir__store_output() \
- stbir__simdf_0123to2301( c, tot ); \
- stbir__simdf_add( tot, tot, c ); \
- stbir__simdf_0123to1230( c, tot ); \
- stbir__simdf_add1( tot, tot, c ); \
- stbir__simdf_store1( output, tot ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 1;
-
-#else
-
-#define stbir__1_coeff_only() \
- float tot; \
- tot = decode[0]*hc[0];
-
-#define stbir__2_coeff_only() \
- float tot; \
- tot = decode[0] * hc[0]; \
- tot += decode[1] * hc[1];
-
-#define stbir__3_coeff_only() \
- float tot; \
- tot = decode[0] * hc[0]; \
- tot += decode[1] * hc[1]; \
- tot += decode[2] * hc[2];
-
-#define stbir__store_output_tiny() \
- output[0] = tot; \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 1;
-
-#define stbir__4_coeff_start() \
- float tot0,tot1,tot2,tot3; \
- tot0 = decode[0] * hc[0]; \
- tot1 = decode[1] * hc[1]; \
- tot2 = decode[2] * hc[2]; \
- tot3 = decode[3] * hc[3];
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- tot0 += decode[0+(ofs)] * hc[0+(ofs)]; \
- tot1 += decode[1+(ofs)] * hc[1+(ofs)]; \
- tot2 += decode[2+(ofs)] * hc[2+(ofs)]; \
- tot3 += decode[3+(ofs)] * hc[3+(ofs)];
-
-#define stbir__1_coeff_remnant( ofs ) \
- tot0 += decode[0+(ofs)] * hc[0+(ofs)];
-
-#define stbir__2_coeff_remnant( ofs ) \
- tot0 += decode[0+(ofs)] * hc[0+(ofs)]; \
- tot1 += decode[1+(ofs)] * hc[1+(ofs)]; \
-
-#define stbir__3_coeff_remnant( ofs ) \
- tot0 += decode[0+(ofs)] * hc[0+(ofs)]; \
- tot1 += decode[1+(ofs)] * hc[1+(ofs)]; \
- tot2 += decode[2+(ofs)] * hc[2+(ofs)];
-
-#define stbir__store_output() \
- output[0] = (tot0+tot2)+(tot1+tot3); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 1;
-
-#endif
-
-#define STBIR__horizontal_channels 1
-#define STB_IMAGE_RESIZE_DO_HORIZONTALS
-#include STBIR__HEADER_FILENAME
-
-
-//=================
-// Do 2 channel horizontal routines
-
-#ifdef STBIR_SIMD
-
-#define stbir__1_coeff_only() \
- stbir__simdf tot,c,d; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1z( c, hc ); \
- stbir__simdf_0123to0011( c, c ); \
- stbir__simdf_load2( d, decode ); \
- stbir__simdf_mult( tot, d, c );
-
-#define stbir__2_coeff_only() \
- stbir__simdf tot,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load2( c, hc ); \
- stbir__simdf_0123to0011( c, c ); \
- stbir__simdf_mult_mem( tot, c, decode );
-
-#define stbir__3_coeff_only() \
- stbir__simdf tot,c,cs,d; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc ); \
- stbir__simdf_0123to0011( c, cs ); \
- stbir__simdf_mult_mem( tot, c, decode ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_load2z( d, decode+4 ); \
- stbir__simdf_madd( tot, tot, d, c );
-
-#define stbir__store_output_tiny() \
- stbir__simdf_0123to2301( c, tot ); \
- stbir__simdf_add( tot, tot, c ); \
- stbir__simdf_store2( output, tot ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 2;
-
-#ifdef STBIR_SIMD8
-
-#define stbir__4_coeff_start() \
- stbir__simdf8 tot0,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc ); \
- stbir__simdf8_0123to00112233( c, cs ); \
- stbir__simdf8_mult_mem( tot0, c, decode );
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc + (ofs) ); \
- stbir__simdf8_0123to00112233( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*2 );
-
-#define stbir__1_coeff_remnant( ofs ) \
- { stbir__simdf t,d; \
- stbir__simdf_load1z( t, hc + (ofs) ); \
- stbir__simdf_load2( d, decode + (ofs) * 2 ); \
- stbir__simdf_0123to0011( t, t ); \
- stbir__simdf_mult( t, t, d ); \
- stbir__simdf8_add4( tot0, tot0, t ); }
-
-#define stbir__2_coeff_remnant( ofs ) \
- { stbir__simdf t; \
- stbir__simdf_load2( t, hc + (ofs) ); \
- stbir__simdf_0123to0011( t, t ); \
- stbir__simdf_mult_mem( t, t, decode+(ofs)*2 ); \
- stbir__simdf8_add4( tot0, tot0, t ); }
-
-#define stbir__3_coeff_remnant( ofs ) \
- { stbir__simdf8 d; \
- stbir__simdf8_load4b( cs, hc + (ofs) ); \
- stbir__simdf8_0123to00112233( c, cs ); \
- stbir__simdf8_load6z( d, decode+(ofs)*2 ); \
- stbir__simdf8_madd( tot0, tot0, c, d ); }
-
-#define stbir__store_output() \
- { stbir__simdf t,d; \
- stbir__simdf8_add4halves( t, stbir__if_simdf8_cast_to_simdf4(tot0), tot0 ); \
- stbir__simdf_0123to2301( d, t ); \
- stbir__simdf_add( t, t, d ); \
- stbir__simdf_store2( output, t ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 2; }
-
-#else
-
-#define stbir__4_coeff_start() \
- stbir__simdf tot0,tot1,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc ); \
- stbir__simdf_0123to0011( c, cs ); \
- stbir__simdf_mult_mem( tot0, c, decode ); \
- stbir__simdf_0123to2233( c, cs ); \
- stbir__simdf_mult_mem( tot1, c, decode+4 );
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc + (ofs) ); \
- stbir__simdf_0123to0011( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*2 ); \
- stbir__simdf_0123to2233( c, cs ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*2+4 );
-
-#define stbir__1_coeff_remnant( ofs ) \
- { stbir__simdf d; \
- stbir__simdf_load1z( cs, hc + (ofs) ); \
- stbir__simdf_0123to0011( c, cs ); \
- stbir__simdf_load2( d, decode + (ofs) * 2 ); \
- stbir__simdf_madd( tot0, tot0, d, c ); }
-
-#define stbir__2_coeff_remnant( ofs ) \
- stbir__simdf_load2( cs, hc + (ofs) ); \
- stbir__simdf_0123to0011( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*2 );
-
-#define stbir__3_coeff_remnant( ofs ) \
- { stbir__simdf d; \
- stbir__simdf_load( cs, hc + (ofs) ); \
- stbir__simdf_0123to0011( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*2 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_load2z( d, decode + (ofs) * 2 + 4 ); \
- stbir__simdf_madd( tot1, tot1, d, c ); }
-
-#define stbir__store_output() \
- stbir__simdf_add( tot0, tot0, tot1 ); \
- stbir__simdf_0123to2301( c, tot0 ); \
- stbir__simdf_add( tot0, tot0, c ); \
- stbir__simdf_store2( output, tot0 ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 2;
-
-#endif
-
-#else
-
-#define stbir__1_coeff_only() \
- float tota,totb,c; \
- c = hc[0]; \
- tota = decode[0]*c; \
- totb = decode[1]*c;
-
-#define stbir__2_coeff_only() \
- float tota,totb,c; \
- c = hc[0]; \
- tota = decode[0]*c; \
- totb = decode[1]*c; \
- c = hc[1]; \
- tota += decode[2]*c; \
- totb += decode[3]*c;
-
-// this weird order of add matches the simd
-#define stbir__3_coeff_only() \
- float tota,totb,c; \
- c = hc[0]; \
- tota = decode[0]*c; \
- totb = decode[1]*c; \
- c = hc[2]; \
- tota += decode[4]*c; \
- totb += decode[5]*c; \
- c = hc[1]; \
- tota += decode[2]*c; \
- totb += decode[3]*c;
-
-#define stbir__store_output_tiny() \
- output[0] = tota; \
- output[1] = totb; \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 2;
-
-#define stbir__4_coeff_start() \
- float tota0,tota1,tota2,tota3,totb0,totb1,totb2,totb3,c; \
- c = hc[0]; \
- tota0 = decode[0]*c; \
- totb0 = decode[1]*c; \
- c = hc[1]; \
- tota1 = decode[2]*c; \
- totb1 = decode[3]*c; \
- c = hc[2]; \
- tota2 = decode[4]*c; \
- totb2 = decode[5]*c; \
- c = hc[3]; \
- tota3 = decode[6]*c; \
- totb3 = decode[7]*c;
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- c = hc[0+(ofs)]; \
- tota0 += decode[0+(ofs)*2]*c; \
- totb0 += decode[1+(ofs)*2]*c; \
- c = hc[1+(ofs)]; \
- tota1 += decode[2+(ofs)*2]*c; \
- totb1 += decode[3+(ofs)*2]*c; \
- c = hc[2+(ofs)]; \
- tota2 += decode[4+(ofs)*2]*c; \
- totb2 += decode[5+(ofs)*2]*c; \
- c = hc[3+(ofs)]; \
- tota3 += decode[6+(ofs)*2]*c; \
- totb3 += decode[7+(ofs)*2]*c;
-
-#define stbir__1_coeff_remnant( ofs ) \
- c = hc[0+(ofs)]; \
- tota0 += decode[0+(ofs)*2] * c; \
- totb0 += decode[1+(ofs)*2] * c;
-
-#define stbir__2_coeff_remnant( ofs ) \
- c = hc[0+(ofs)]; \
- tota0 += decode[0+(ofs)*2] * c; \
- totb0 += decode[1+(ofs)*2] * c; \
- c = hc[1+(ofs)]; \
- tota1 += decode[2+(ofs)*2] * c; \
- totb1 += decode[3+(ofs)*2] * c;
-
-#define stbir__3_coeff_remnant( ofs ) \
- c = hc[0+(ofs)]; \
- tota0 += decode[0+(ofs)*2] * c; \
- totb0 += decode[1+(ofs)*2] * c; \
- c = hc[1+(ofs)]; \
- tota1 += decode[2+(ofs)*2] * c; \
- totb1 += decode[3+(ofs)*2] * c; \
- c = hc[2+(ofs)]; \
- tota2 += decode[4+(ofs)*2] * c; \
- totb2 += decode[5+(ofs)*2] * c;
-
-#define stbir__store_output() \
- output[0] = (tota0+tota2)+(tota1+tota3); \
- output[1] = (totb0+totb2)+(totb1+totb3); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 2;
-
-#endif
-
-#define STBIR__horizontal_channels 2
-#define STB_IMAGE_RESIZE_DO_HORIZONTALS
-#include STBIR__HEADER_FILENAME
-
-
-//=================
-// Do 3 channel horizontal routines
-
-#ifdef STBIR_SIMD
-
-#define stbir__1_coeff_only() \
- stbir__simdf tot,c,d; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1z( c, hc ); \
- stbir__simdf_0123to0001( c, c ); \
- stbir__simdf_load( d, decode ); \
- stbir__simdf_mult( tot, d, c );
-
-#define stbir__2_coeff_only() \
- stbir__simdf tot,c,cs,d; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load2( cs, hc ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_load( d, decode ); \
- stbir__simdf_mult( tot, d, c ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_load( d, decode+3 ); \
- stbir__simdf_madd( tot, tot, d, c );
-
-#define stbir__3_coeff_only() \
- stbir__simdf tot,c,d,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_load( d, decode ); \
- stbir__simdf_mult( tot, d, c ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_load( d, decode+3 ); \
- stbir__simdf_madd( tot, tot, d, c ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_load( d, decode+6 ); \
- stbir__simdf_madd( tot, tot, d, c );
-
-#define stbir__store_output_tiny() \
- stbir__simdf_store2( output, tot ); \
- stbir__simdf_0123to2301( tot, tot ); \
- stbir__simdf_store1( output+2, tot ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 3;
-
-#ifdef STBIR_SIMD8
-
-// we're loading from the XXXYYY decode by -1 to get the XXXYYY into different halves of the AVX reg fyi
-#define stbir__4_coeff_start() \
- stbir__simdf8 tot0,tot1,c,cs; stbir__simdf t; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc ); \
- stbir__simdf8_0123to00001111( c, cs ); \
- stbir__simdf8_mult_mem( tot0, c, decode - 1 ); \
- stbir__simdf8_0123to22223333( c, cs ); \
- stbir__simdf8_mult_mem( tot1, c, decode+6 - 1 );
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc + (ofs) ); \
- stbir__simdf8_0123to00001111( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*3 - 1 ); \
- stbir__simdf8_0123to22223333( c, cs ); \
- stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*3 + 6 - 1 );
-
-#define stbir__1_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1rep4( t, hc + (ofs) ); \
- stbir__simdf8_madd_mem4( tot0, tot0, t, decode+(ofs)*3 - 1 );
-
-#define stbir__2_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc + (ofs) - 2 ); \
- stbir__simdf8_0123to22223333( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*3 - 1 );
-
- #define stbir__3_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc + (ofs) ); \
- stbir__simdf8_0123to00001111( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*3 - 1 ); \
- stbir__simdf8_0123to2222( t, cs ); \
- stbir__simdf8_madd_mem4( tot1, tot1, t, decode+(ofs)*3 + 6 - 1 );
-
-#define stbir__store_output() \
- stbir__simdf8_add( tot0, tot0, tot1 ); \
- stbir__simdf_0123to1230( t, stbir__if_simdf8_cast_to_simdf4( tot0 ) ); \
- stbir__simdf8_add4halves( t, t, tot0 ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 3; \
- if ( output < output_end ) \
- { \
- stbir__simdf_store( output-3, t ); \
- continue; \
- } \
- { stbir__simdf tt; stbir__simdf_0123to2301( tt, t ); \
- stbir__simdf_store2( output-3, t ); \
- stbir__simdf_store1( output+2-3, tt ); } \
- break;
-
-
-#else
-
-#define stbir__4_coeff_start() \
- stbir__simdf tot0,tot1,tot2,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc ); \
- stbir__simdf_0123to0001( c, cs ); \
- stbir__simdf_mult_mem( tot0, c, decode ); \
- stbir__simdf_0123to1122( c, cs ); \
- stbir__simdf_mult_mem( tot1, c, decode+4 ); \
- stbir__simdf_0123to2333( c, cs ); \
- stbir__simdf_mult_mem( tot2, c, decode+8 );
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc + (ofs) ); \
- stbir__simdf_0123to0001( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*3 ); \
- stbir__simdf_0123to1122( c, cs ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*3+4 ); \
- stbir__simdf_0123to2333( c, cs ); \
- stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*3+8 );
-
-#define stbir__1_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1z( c, hc + (ofs) ); \
- stbir__simdf_0123to0001( c, c ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*3 );
-
-#define stbir__2_coeff_remnant( ofs ) \
- { stbir__simdf d; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load2z( cs, hc + (ofs) ); \
- stbir__simdf_0123to0001( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*3 ); \
- stbir__simdf_0123to1122( c, cs ); \
- stbir__simdf_load2z( d, decode+(ofs)*3+4 ); \
- stbir__simdf_madd( tot1, tot1, c, d ); }
-
-#define stbir__3_coeff_remnant( ofs ) \
- { stbir__simdf d; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc + (ofs) ); \
- stbir__simdf_0123to0001( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*3 ); \
- stbir__simdf_0123to1122( c, cs ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*3+4 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_load1z( d, decode+(ofs)*3+8 ); \
- stbir__simdf_madd( tot2, tot2, c, d ); }
-
-#define stbir__store_output() \
- stbir__simdf_0123ABCDto3ABx( c, tot0, tot1 ); \
- stbir__simdf_0123ABCDto23Ax( cs, tot1, tot2 ); \
- stbir__simdf_0123to1230( tot2, tot2 ); \
- stbir__simdf_add( tot0, tot0, cs ); \
- stbir__simdf_add( c, c, tot2 ); \
- stbir__simdf_add( tot0, tot0, c ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 3; \
- if ( output < output_end ) \
- { \
- stbir__simdf_store( output-3, tot0 ); \
- continue; \
- } \
- stbir__simdf_0123to2301( tot1, tot0 ); \
- stbir__simdf_store2( output-3, tot0 ); \
- stbir__simdf_store1( output+2-3, tot1 ); \
- break;
-
-#endif
-
-#else
-
-#define stbir__1_coeff_only() \
- float tot0, tot1, tot2, c; \
- c = hc[0]; \
- tot0 = decode[0]*c; \
- tot1 = decode[1]*c; \
- tot2 = decode[2]*c;
-
-#define stbir__2_coeff_only() \
- float tot0, tot1, tot2, c; \
- c = hc[0]; \
- tot0 = decode[0]*c; \
- tot1 = decode[1]*c; \
- tot2 = decode[2]*c; \
- c = hc[1]; \
- tot0 += decode[3]*c; \
- tot1 += decode[4]*c; \
- tot2 += decode[5]*c;
-
-#define stbir__3_coeff_only() \
- float tot0, tot1, tot2, c; \
- c = hc[0]; \
- tot0 = decode[0]*c; \
- tot1 = decode[1]*c; \
- tot2 = decode[2]*c; \
- c = hc[1]; \
- tot0 += decode[3]*c; \
- tot1 += decode[4]*c; \
- tot2 += decode[5]*c; \
- c = hc[2]; \
- tot0 += decode[6]*c; \
- tot1 += decode[7]*c; \
- tot2 += decode[8]*c;
-
-#define stbir__store_output_tiny() \
- output[0] = tot0; \
- output[1] = tot1; \
- output[2] = tot2; \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 3;
-
-#define stbir__4_coeff_start() \
- float tota0,tota1,tota2,totb0,totb1,totb2,totc0,totc1,totc2,totd0,totd1,totd2,c; \
- c = hc[0]; \
- tota0 = decode[0]*c; \
- tota1 = decode[1]*c; \
- tota2 = decode[2]*c; \
- c = hc[1]; \
- totb0 = decode[3]*c; \
- totb1 = decode[4]*c; \
- totb2 = decode[5]*c; \
- c = hc[2]; \
- totc0 = decode[6]*c; \
- totc1 = decode[7]*c; \
- totc2 = decode[8]*c; \
- c = hc[3]; \
- totd0 = decode[9]*c; \
- totd1 = decode[10]*c; \
- totd2 = decode[11]*c;
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- c = hc[0+(ofs)]; \
- tota0 += decode[0+(ofs)*3]*c; \
- tota1 += decode[1+(ofs)*3]*c; \
- tota2 += decode[2+(ofs)*3]*c; \
- c = hc[1+(ofs)]; \
- totb0 += decode[3+(ofs)*3]*c; \
- totb1 += decode[4+(ofs)*3]*c; \
- totb2 += decode[5+(ofs)*3]*c; \
- c = hc[2+(ofs)]; \
- totc0 += decode[6+(ofs)*3]*c; \
- totc1 += decode[7+(ofs)*3]*c; \
- totc2 += decode[8+(ofs)*3]*c; \
- c = hc[3+(ofs)]; \
- totd0 += decode[9+(ofs)*3]*c; \
- totd1 += decode[10+(ofs)*3]*c; \
- totd2 += decode[11+(ofs)*3]*c;
-
-#define stbir__1_coeff_remnant( ofs ) \
- c = hc[0+(ofs)]; \
- tota0 += decode[0+(ofs)*3]*c; \
- tota1 += decode[1+(ofs)*3]*c; \
- tota2 += decode[2+(ofs)*3]*c;
-
-#define stbir__2_coeff_remnant( ofs ) \
- c = hc[0+(ofs)]; \
- tota0 += decode[0+(ofs)*3]*c; \
- tota1 += decode[1+(ofs)*3]*c; \
- tota2 += decode[2+(ofs)*3]*c; \
- c = hc[1+(ofs)]; \
- totb0 += decode[3+(ofs)*3]*c; \
- totb1 += decode[4+(ofs)*3]*c; \
- totb2 += decode[5+(ofs)*3]*c; \
-
-#define stbir__3_coeff_remnant( ofs ) \
- c = hc[0+(ofs)]; \
- tota0 += decode[0+(ofs)*3]*c; \
- tota1 += decode[1+(ofs)*3]*c; \
- tota2 += decode[2+(ofs)*3]*c; \
- c = hc[1+(ofs)]; \
- totb0 += decode[3+(ofs)*3]*c; \
- totb1 += decode[4+(ofs)*3]*c; \
- totb2 += decode[5+(ofs)*3]*c; \
- c = hc[2+(ofs)]; \
- totc0 += decode[6+(ofs)*3]*c; \
- totc1 += decode[7+(ofs)*3]*c; \
- totc2 += decode[8+(ofs)*3]*c;
-
-#define stbir__store_output() \
- output[0] = (tota0+totc0)+(totb0+totd0); \
- output[1] = (tota1+totc1)+(totb1+totd1); \
- output[2] = (tota2+totc2)+(totb2+totd2); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 3;
-
-#endif
-
-#define STBIR__horizontal_channels 3
-#define STB_IMAGE_RESIZE_DO_HORIZONTALS
-#include STBIR__HEADER_FILENAME
-
-//=================
-// Do 4 channel horizontal routines
-
-#ifdef STBIR_SIMD
-
-#define stbir__1_coeff_only() \
- stbir__simdf tot,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1( c, hc ); \
- stbir__simdf_0123to0000( c, c ); \
- stbir__simdf_mult_mem( tot, c, decode );
-
-#define stbir__2_coeff_only() \
- stbir__simdf tot,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load2( cs, hc ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_mult_mem( tot, c, decode ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot, tot, c, decode+4 );
-
-#define stbir__3_coeff_only() \
- stbir__simdf tot,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_mult_mem( tot, c, decode ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot, tot, c, decode+4 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_madd_mem( tot, tot, c, decode+8 );
-
-#define stbir__store_output_tiny() \
- stbir__simdf_store( output, tot ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 4;
-
-#ifdef STBIR_SIMD8
-
-#define stbir__4_coeff_start() \
- stbir__simdf8 tot0,c,cs; stbir__simdf t; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc ); \
- stbir__simdf8_0123to00001111( c, cs ); \
- stbir__simdf8_mult_mem( tot0, c, decode ); \
- stbir__simdf8_0123to22223333( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+8 );
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc + (ofs) ); \
- stbir__simdf8_0123to00001111( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \
- stbir__simdf8_0123to22223333( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*4+8 );
-
-#define stbir__1_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1rep4( t, hc + (ofs) ); \
- stbir__simdf8_madd_mem4( tot0, tot0, t, decode+(ofs)*4 );
-
-#define stbir__2_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc + (ofs) - 2 ); \
- stbir__simdf8_0123to22223333( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*4 );
-
- #define stbir__3_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc + (ofs) ); \
- stbir__simdf8_0123to00001111( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \
- stbir__simdf8_0123to2222( t, cs ); \
- stbir__simdf8_madd_mem4( tot0, tot0, t, decode+(ofs)*4+8 );
-
-#define stbir__store_output() \
- stbir__simdf8_add4halves( t, stbir__if_simdf8_cast_to_simdf4(tot0), tot0 ); \
- stbir__simdf_store( output, t ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 4;
-
-#else
-
-#define stbir__4_coeff_start() \
- stbir__simdf tot0,tot1,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_mult_mem( tot0, c, decode ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_mult_mem( tot1, c, decode+4 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+8 ); \
- stbir__simdf_0123to3333( c, cs ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+12 );
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc + (ofs) ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*4+4 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4+8 ); \
- stbir__simdf_0123to3333( c, cs ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*4+12 );
-
-#define stbir__1_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1( c, hc + (ofs) ); \
- stbir__simdf_0123to0000( c, c ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4 );
-
-#define stbir__2_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load2( cs, hc + (ofs) ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*4+4 );
-
-#define stbir__3_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc + (ofs) ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4 ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*4+4 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*4+8 );
-
-#define stbir__store_output() \
- stbir__simdf_add( tot0, tot0, tot1 ); \
- stbir__simdf_store( output, tot0 ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 4;
-
-#endif
-
-#else
-
-#define stbir__1_coeff_only() \
- float p0,p1,p2,p3,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0]; \
- p0 = decode[0] * c; \
- p1 = decode[1] * c; \
- p2 = decode[2] * c; \
- p3 = decode[3] * c;
-
-#define stbir__2_coeff_only() \
- float p0,p1,p2,p3,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0]; \
- p0 = decode[0] * c; \
- p1 = decode[1] * c; \
- p2 = decode[2] * c; \
- p3 = decode[3] * c; \
- c = hc[1]; \
- p0 += decode[4] * c; \
- p1 += decode[5] * c; \
- p2 += decode[6] * c; \
- p3 += decode[7] * c;
-
-#define stbir__3_coeff_only() \
- float p0,p1,p2,p3,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0]; \
- p0 = decode[0] * c; \
- p1 = decode[1] * c; \
- p2 = decode[2] * c; \
- p3 = decode[3] * c; \
- c = hc[1]; \
- p0 += decode[4] * c; \
- p1 += decode[5] * c; \
- p2 += decode[6] * c; \
- p3 += decode[7] * c; \
- c = hc[2]; \
- p0 += decode[8] * c; \
- p1 += decode[9] * c; \
- p2 += decode[10] * c; \
- p3 += decode[11] * c;
-
-#define stbir__store_output_tiny() \
- output[0] = p0; \
- output[1] = p1; \
- output[2] = p2; \
- output[3] = p3; \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 4;
-
-#define stbir__4_coeff_start() \
- float x0,x1,x2,x3,y0,y1,y2,y3,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0]; \
- x0 = decode[0] * c; \
- x1 = decode[1] * c; \
- x2 = decode[2] * c; \
- x3 = decode[3] * c; \
- c = hc[1]; \
- y0 = decode[4] * c; \
- y1 = decode[5] * c; \
- y2 = decode[6] * c; \
- y3 = decode[7] * c; \
- c = hc[2]; \
- x0 += decode[8] * c; \
- x1 += decode[9] * c; \
- x2 += decode[10] * c; \
- x3 += decode[11] * c; \
- c = hc[3]; \
- y0 += decode[12] * c; \
- y1 += decode[13] * c; \
- y2 += decode[14] * c; \
- y3 += decode[15] * c;
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0+(ofs)]; \
- x0 += decode[0+(ofs)*4] * c; \
- x1 += decode[1+(ofs)*4] * c; \
- x2 += decode[2+(ofs)*4] * c; \
- x3 += decode[3+(ofs)*4] * c; \
- c = hc[1+(ofs)]; \
- y0 += decode[4+(ofs)*4] * c; \
- y1 += decode[5+(ofs)*4] * c; \
- y2 += decode[6+(ofs)*4] * c; \
- y3 += decode[7+(ofs)*4] * c; \
- c = hc[2+(ofs)]; \
- x0 += decode[8+(ofs)*4] * c; \
- x1 += decode[9+(ofs)*4] * c; \
- x2 += decode[10+(ofs)*4] * c; \
- x3 += decode[11+(ofs)*4] * c; \
- c = hc[3+(ofs)]; \
- y0 += decode[12+(ofs)*4] * c; \
- y1 += decode[13+(ofs)*4] * c; \
- y2 += decode[14+(ofs)*4] * c; \
- y3 += decode[15+(ofs)*4] * c;
-
-#define stbir__1_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0+(ofs)]; \
- x0 += decode[0+(ofs)*4] * c; \
- x1 += decode[1+(ofs)*4] * c; \
- x2 += decode[2+(ofs)*4] * c; \
- x3 += decode[3+(ofs)*4] * c;
-
-#define stbir__2_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0+(ofs)]; \
- x0 += decode[0+(ofs)*4] * c; \
- x1 += decode[1+(ofs)*4] * c; \
- x2 += decode[2+(ofs)*4] * c; \
- x3 += decode[3+(ofs)*4] * c; \
- c = hc[1+(ofs)]; \
- y0 += decode[4+(ofs)*4] * c; \
- y1 += decode[5+(ofs)*4] * c; \
- y2 += decode[6+(ofs)*4] * c; \
- y3 += decode[7+(ofs)*4] * c;
-
-#define stbir__3_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0+(ofs)]; \
- x0 += decode[0+(ofs)*4] * c; \
- x1 += decode[1+(ofs)*4] * c; \
- x2 += decode[2+(ofs)*4] * c; \
- x3 += decode[3+(ofs)*4] * c; \
- c = hc[1+(ofs)]; \
- y0 += decode[4+(ofs)*4] * c; \
- y1 += decode[5+(ofs)*4] * c; \
- y2 += decode[6+(ofs)*4] * c; \
- y3 += decode[7+(ofs)*4] * c; \
- c = hc[2+(ofs)]; \
- x0 += decode[8+(ofs)*4] * c; \
- x1 += decode[9+(ofs)*4] * c; \
- x2 += decode[10+(ofs)*4] * c; \
- x3 += decode[11+(ofs)*4] * c;
-
-#define stbir__store_output() \
- output[0] = x0 + y0; \
- output[1] = x1 + y1; \
- output[2] = x2 + y2; \
- output[3] = x3 + y3; \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 4;
-
-#endif
-
-#define STBIR__horizontal_channels 4
-#define STB_IMAGE_RESIZE_DO_HORIZONTALS
-#include STBIR__HEADER_FILENAME
-
-
-
-//=================
-// Do 7 channel horizontal routines
-
-#ifdef STBIR_SIMD
-
-#define stbir__1_coeff_only() \
- stbir__simdf tot0,tot1,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1( c, hc ); \
- stbir__simdf_0123to0000( c, c ); \
- stbir__simdf_mult_mem( tot0, c, decode ); \
- stbir__simdf_mult_mem( tot1, c, decode+3 );
-
-#define stbir__2_coeff_only() \
- stbir__simdf tot0,tot1,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load2( cs, hc ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_mult_mem( tot0, c, decode ); \
- stbir__simdf_mult_mem( tot1, c, decode+3 ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+7 ); \
- stbir__simdf_madd_mem( tot1, tot1, c,decode+10 );
-
-#define stbir__3_coeff_only() \
- stbir__simdf tot0,tot1,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_mult_mem( tot0, c, decode ); \
- stbir__simdf_mult_mem( tot1, c, decode+3 ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+7 ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+10 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+14 ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+17 );
-
-#define stbir__store_output_tiny() \
- stbir__simdf_store( output+3, tot1 ); \
- stbir__simdf_store( output, tot0 ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 7;
-
-#ifdef STBIR_SIMD8
-
-#define stbir__4_coeff_start() \
- stbir__simdf8 tot0,tot1,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc ); \
- stbir__simdf8_0123to00000000( c, cs ); \
- stbir__simdf8_mult_mem( tot0, c, decode ); \
- stbir__simdf8_0123to11111111( c, cs ); \
- stbir__simdf8_mult_mem( tot1, c, decode+7 ); \
- stbir__simdf8_0123to22222222( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+14 ); \
- stbir__simdf8_0123to33333333( c, cs ); \
- stbir__simdf8_madd_mem( tot1, tot1, c, decode+21 );
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc + (ofs) ); \
- stbir__simdf8_0123to00000000( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \
- stbir__simdf8_0123to11111111( c, cs ); \
- stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*7+7 ); \
- stbir__simdf8_0123to22222222( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7+14 ); \
- stbir__simdf8_0123to33333333( c, cs ); \
- stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*7+21 );
-
-#define stbir__1_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load1b( c, hc + (ofs) ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7 );
-
-#define stbir__2_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load1b( c, hc + (ofs) ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \
- stbir__simdf8_load1b( c, hc + (ofs)+1 ); \
- stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*7+7 );
-
-#define stbir__3_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf8_load4b( cs, hc + (ofs) ); \
- stbir__simdf8_0123to00000000( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \
- stbir__simdf8_0123to11111111( c, cs ); \
- stbir__simdf8_madd_mem( tot1, tot1, c, decode+(ofs)*7+7 ); \
- stbir__simdf8_0123to22222222( c, cs ); \
- stbir__simdf8_madd_mem( tot0, tot0, c, decode+(ofs)*7+14 );
-
-#define stbir__store_output() \
- stbir__simdf8_add( tot0, tot0, tot1 ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 7; \
- if ( output < output_end ) \
- { \
- stbir__simdf8_store( output-7, tot0 ); \
- continue; \
- } \
- stbir__simdf_store( output-7+3, stbir__simdf_swiz(stbir__simdf8_gettop4(tot0),0,0,1,2) ); \
- stbir__simdf_store( output-7, stbir__if_simdf8_cast_to_simdf4(tot0) ); \
- break;
-
-#else
-
-#define stbir__4_coeff_start() \
- stbir__simdf tot0,tot1,tot2,tot3,c,cs; \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_mult_mem( tot0, c, decode ); \
- stbir__simdf_mult_mem( tot1, c, decode+3 ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_mult_mem( tot2, c, decode+7 ); \
- stbir__simdf_mult_mem( tot3, c, decode+10 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+14 ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+17 ); \
- stbir__simdf_0123to3333( c, cs ); \
- stbir__simdf_madd_mem( tot2, tot2, c, decode+21 ); \
- stbir__simdf_madd_mem( tot3, tot3, c, decode+24 );
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc + (ofs) ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+3 ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*7+7 ); \
- stbir__simdf_madd_mem( tot3, tot3, c, decode+(ofs)*7+10 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7+14 ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+17 ); \
- stbir__simdf_0123to3333( c, cs ); \
- stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*7+21 ); \
- stbir__simdf_madd_mem( tot3, tot3, c, decode+(ofs)*7+24 );
-
-#define stbir__1_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load1( c, hc + (ofs) ); \
- stbir__simdf_0123to0000( c, c ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+3 ); \
-
-#define stbir__2_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load2( cs, hc + (ofs) ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+3 ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*7+7 ); \
- stbir__simdf_madd_mem( tot3, tot3, c, decode+(ofs)*7+10 );
-
-#define stbir__3_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- stbir__simdf_load( cs, hc + (ofs) ); \
- stbir__simdf_0123to0000( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7 ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+3 ); \
- stbir__simdf_0123to1111( c, cs ); \
- stbir__simdf_madd_mem( tot2, tot2, c, decode+(ofs)*7+7 ); \
- stbir__simdf_madd_mem( tot3, tot3, c, decode+(ofs)*7+10 ); \
- stbir__simdf_0123to2222( c, cs ); \
- stbir__simdf_madd_mem( tot0, tot0, c, decode+(ofs)*7+14 ); \
- stbir__simdf_madd_mem( tot1, tot1, c, decode+(ofs)*7+17 );
-
-#define stbir__store_output() \
- stbir__simdf_add( tot0, tot0, tot2 ); \
- stbir__simdf_add( tot1, tot1, tot3 ); \
- stbir__simdf_store( output+3, tot1 ); \
- stbir__simdf_store( output, tot0 ); \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 7;
-
-#endif
-
-#else
-
-#define stbir__1_coeff_only() \
- float tot0, tot1, tot2, tot3, tot4, tot5, tot6, c; \
- c = hc[0]; \
- tot0 = decode[0]*c; \
- tot1 = decode[1]*c; \
- tot2 = decode[2]*c; \
- tot3 = decode[3]*c; \
- tot4 = decode[4]*c; \
- tot5 = decode[5]*c; \
- tot6 = decode[6]*c;
-
-#define stbir__2_coeff_only() \
- float tot0, tot1, tot2, tot3, tot4, tot5, tot6, c; \
- c = hc[0]; \
- tot0 = decode[0]*c; \
- tot1 = decode[1]*c; \
- tot2 = decode[2]*c; \
- tot3 = decode[3]*c; \
- tot4 = decode[4]*c; \
- tot5 = decode[5]*c; \
- tot6 = decode[6]*c; \
- c = hc[1]; \
- tot0 += decode[7]*c; \
- tot1 += decode[8]*c; \
- tot2 += decode[9]*c; \
- tot3 += decode[10]*c; \
- tot4 += decode[11]*c; \
- tot5 += decode[12]*c; \
- tot6 += decode[13]*c; \
-
-#define stbir__3_coeff_only() \
- float tot0, tot1, tot2, tot3, tot4, tot5, tot6, c; \
- c = hc[0]; \
- tot0 = decode[0]*c; \
- tot1 = decode[1]*c; \
- tot2 = decode[2]*c; \
- tot3 = decode[3]*c; \
- tot4 = decode[4]*c; \
- tot5 = decode[5]*c; \
- tot6 = decode[6]*c; \
- c = hc[1]; \
- tot0 += decode[7]*c; \
- tot1 += decode[8]*c; \
- tot2 += decode[9]*c; \
- tot3 += decode[10]*c; \
- tot4 += decode[11]*c; \
- tot5 += decode[12]*c; \
- tot6 += decode[13]*c; \
- c = hc[2]; \
- tot0 += decode[14]*c; \
- tot1 += decode[15]*c; \
- tot2 += decode[16]*c; \
- tot3 += decode[17]*c; \
- tot4 += decode[18]*c; \
- tot5 += decode[19]*c; \
- tot6 += decode[20]*c; \
-
-#define stbir__store_output_tiny() \
- output[0] = tot0; \
- output[1] = tot1; \
- output[2] = tot2; \
- output[3] = tot3; \
- output[4] = tot4; \
- output[5] = tot5; \
- output[6] = tot6; \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 7;
-
-#define stbir__4_coeff_start() \
- float x0,x1,x2,x3,x4,x5,x6,y0,y1,y2,y3,y4,y5,y6,c; \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0]; \
- x0 = decode[0] * c; \
- x1 = decode[1] * c; \
- x2 = decode[2] * c; \
- x3 = decode[3] * c; \
- x4 = decode[4] * c; \
- x5 = decode[5] * c; \
- x6 = decode[6] * c; \
- c = hc[1]; \
- y0 = decode[7] * c; \
- y1 = decode[8] * c; \
- y2 = decode[9] * c; \
- y3 = decode[10] * c; \
- y4 = decode[11] * c; \
- y5 = decode[12] * c; \
- y6 = decode[13] * c; \
- c = hc[2]; \
- x0 += decode[14] * c; \
- x1 += decode[15] * c; \
- x2 += decode[16] * c; \
- x3 += decode[17] * c; \
- x4 += decode[18] * c; \
- x5 += decode[19] * c; \
- x6 += decode[20] * c; \
- c = hc[3]; \
- y0 += decode[21] * c; \
- y1 += decode[22] * c; \
- y2 += decode[23] * c; \
- y3 += decode[24] * c; \
- y4 += decode[25] * c; \
- y5 += decode[26] * c; \
- y6 += decode[27] * c;
-
-#define stbir__4_coeff_continue_from_4( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0+(ofs)]; \
- x0 += decode[0+(ofs)*7] * c; \
- x1 += decode[1+(ofs)*7] * c; \
- x2 += decode[2+(ofs)*7] * c; \
- x3 += decode[3+(ofs)*7] * c; \
- x4 += decode[4+(ofs)*7] * c; \
- x5 += decode[5+(ofs)*7] * c; \
- x6 += decode[6+(ofs)*7] * c; \
- c = hc[1+(ofs)]; \
- y0 += decode[7+(ofs)*7] * c; \
- y1 += decode[8+(ofs)*7] * c; \
- y2 += decode[9+(ofs)*7] * c; \
- y3 += decode[10+(ofs)*7] * c; \
- y4 += decode[11+(ofs)*7] * c; \
- y5 += decode[12+(ofs)*7] * c; \
- y6 += decode[13+(ofs)*7] * c; \
- c = hc[2+(ofs)]; \
- x0 += decode[14+(ofs)*7] * c; \
- x1 += decode[15+(ofs)*7] * c; \
- x2 += decode[16+(ofs)*7] * c; \
- x3 += decode[17+(ofs)*7] * c; \
- x4 += decode[18+(ofs)*7] * c; \
- x5 += decode[19+(ofs)*7] * c; \
- x6 += decode[20+(ofs)*7] * c; \
- c = hc[3+(ofs)]; \
- y0 += decode[21+(ofs)*7] * c; \
- y1 += decode[22+(ofs)*7] * c; \
- y2 += decode[23+(ofs)*7] * c; \
- y3 += decode[24+(ofs)*7] * c; \
- y4 += decode[25+(ofs)*7] * c; \
- y5 += decode[26+(ofs)*7] * c; \
- y6 += decode[27+(ofs)*7] * c;
-
-#define stbir__1_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0+(ofs)]; \
- x0 += decode[0+(ofs)*7] * c; \
- x1 += decode[1+(ofs)*7] * c; \
- x2 += decode[2+(ofs)*7] * c; \
- x3 += decode[3+(ofs)*7] * c; \
- x4 += decode[4+(ofs)*7] * c; \
- x5 += decode[5+(ofs)*7] * c; \
- x6 += decode[6+(ofs)*7] * c; \
-
-#define stbir__2_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0+(ofs)]; \
- x0 += decode[0+(ofs)*7] * c; \
- x1 += decode[1+(ofs)*7] * c; \
- x2 += decode[2+(ofs)*7] * c; \
- x3 += decode[3+(ofs)*7] * c; \
- x4 += decode[4+(ofs)*7] * c; \
- x5 += decode[5+(ofs)*7] * c; \
- x6 += decode[6+(ofs)*7] * c; \
- c = hc[1+(ofs)]; \
- y0 += decode[7+(ofs)*7] * c; \
- y1 += decode[8+(ofs)*7] * c; \
- y2 += decode[9+(ofs)*7] * c; \
- y3 += decode[10+(ofs)*7] * c; \
- y4 += decode[11+(ofs)*7] * c; \
- y5 += decode[12+(ofs)*7] * c; \
- y6 += decode[13+(ofs)*7] * c; \
-
-#define stbir__3_coeff_remnant( ofs ) \
- STBIR_SIMD_NO_UNROLL(decode); \
- c = hc[0+(ofs)]; \
- x0 += decode[0+(ofs)*7] * c; \
- x1 += decode[1+(ofs)*7] * c; \
- x2 += decode[2+(ofs)*7] * c; \
- x3 += decode[3+(ofs)*7] * c; \
- x4 += decode[4+(ofs)*7] * c; \
- x5 += decode[5+(ofs)*7] * c; \
- x6 += decode[6+(ofs)*7] * c; \
- c = hc[1+(ofs)]; \
- y0 += decode[7+(ofs)*7] * c; \
- y1 += decode[8+(ofs)*7] * c; \
- y2 += decode[9+(ofs)*7] * c; \
- y3 += decode[10+(ofs)*7] * c; \
- y4 += decode[11+(ofs)*7] * c; \
- y5 += decode[12+(ofs)*7] * c; \
- y6 += decode[13+(ofs)*7] * c; \
- c = hc[2+(ofs)]; \
- x0 += decode[14+(ofs)*7] * c; \
- x1 += decode[15+(ofs)*7] * c; \
- x2 += decode[16+(ofs)*7] * c; \
- x3 += decode[17+(ofs)*7] * c; \
- x4 += decode[18+(ofs)*7] * c; \
- x5 += decode[19+(ofs)*7] * c; \
- x6 += decode[20+(ofs)*7] * c; \
-
-#define stbir__store_output() \
- output[0] = x0 + y0; \
- output[1] = x1 + y1; \
- output[2] = x2 + y2; \
- output[3] = x3 + y3; \
- output[4] = x4 + y4; \
- output[5] = x5 + y5; \
- output[6] = x6 + y6; \
- horizontal_coefficients += coefficient_width; \
- ++horizontal_contributors; \
- output += 7;
-
-#endif
-
-#define STBIR__horizontal_channels 7
-#define STB_IMAGE_RESIZE_DO_HORIZONTALS
-#include STBIR__HEADER_FILENAME
-
-
-// include all of the vertical resamplers (both scatter and gather versions)
-
-#define STBIR__vertical_channels 1
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 1
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 2
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 2
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 3
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 3
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 4
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 4
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 5
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 5
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 6
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 6
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 7
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 7
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 8
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#include STBIR__HEADER_FILENAME
-
-#define STBIR__vertical_channels 8
-#define STB_IMAGE_RESIZE_DO_VERTICALS
-#define STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#include STBIR__HEADER_FILENAME
-
-typedef void STBIR_VERTICAL_GATHERFUNC( float * output, float const * coeffs, float const ** inputs, float const * input0_end );
-
-static STBIR_VERTICAL_GATHERFUNC * stbir__vertical_gathers[ 8 ] =
-{
- stbir__vertical_gather_with_1_coeffs,stbir__vertical_gather_with_2_coeffs,stbir__vertical_gather_with_3_coeffs,stbir__vertical_gather_with_4_coeffs,stbir__vertical_gather_with_5_coeffs,stbir__vertical_gather_with_6_coeffs,stbir__vertical_gather_with_7_coeffs,stbir__vertical_gather_with_8_coeffs
-};
-
-static STBIR_VERTICAL_GATHERFUNC * stbir__vertical_gathers_continues[ 8 ] =
-{
- stbir__vertical_gather_with_1_coeffs_cont,stbir__vertical_gather_with_2_coeffs_cont,stbir__vertical_gather_with_3_coeffs_cont,stbir__vertical_gather_with_4_coeffs_cont,stbir__vertical_gather_with_5_coeffs_cont,stbir__vertical_gather_with_6_coeffs_cont,stbir__vertical_gather_with_7_coeffs_cont,stbir__vertical_gather_with_8_coeffs_cont
-};
-
-typedef void STBIR_VERTICAL_SCATTERFUNC( float ** outputs, float const * coeffs, float const * input, float const * input_end );
-
-static STBIR_VERTICAL_SCATTERFUNC * stbir__vertical_scatter_sets[ 8 ] =
-{
- stbir__vertical_scatter_with_1_coeffs,stbir__vertical_scatter_with_2_coeffs,stbir__vertical_scatter_with_3_coeffs,stbir__vertical_scatter_with_4_coeffs,stbir__vertical_scatter_with_5_coeffs,stbir__vertical_scatter_with_6_coeffs,stbir__vertical_scatter_with_7_coeffs,stbir__vertical_scatter_with_8_coeffs
-};
-
-static STBIR_VERTICAL_SCATTERFUNC * stbir__vertical_scatter_blends[ 8 ] =
-{
- stbir__vertical_scatter_with_1_coeffs_cont,stbir__vertical_scatter_with_2_coeffs_cont,stbir__vertical_scatter_with_3_coeffs_cont,stbir__vertical_scatter_with_4_coeffs_cont,stbir__vertical_scatter_with_5_coeffs_cont,stbir__vertical_scatter_with_6_coeffs_cont,stbir__vertical_scatter_with_7_coeffs_cont,stbir__vertical_scatter_with_8_coeffs_cont
-};
-
-
-static void stbir__encode_scanline( stbir__info const * stbir_info, void *output_buffer_data, float * encode_buffer, int row STBIR_ONLY_PROFILE_GET_SPLIT_INFO )
-{
- int num_pixels = stbir_info->horizontal.scale_info.output_sub_size;
- int channels = stbir_info->channels;
- int width_times_channels = num_pixels * channels;
- void * output_buffer;
-
- // un-alpha weight if we need to
- if ( stbir_info->alpha_unweight )
- {
- STBIR_PROFILE_START( unalpha );
- stbir_info->alpha_unweight( encode_buffer, width_times_channels );
- STBIR_PROFILE_END( unalpha );
- }
-
- // write directly into output by default
- output_buffer = output_buffer_data;
-
- // if we have an output callback, we first convert the decode buffer in place (and then hand that to the callback)
- if ( stbir_info->out_pixels_cb )
- output_buffer = encode_buffer;
-
- STBIR_PROFILE_START( encode );
- // convert into the output buffer
- stbir_info->encode_pixels( output_buffer, width_times_channels, encode_buffer );
- STBIR_PROFILE_END( encode );
-
- // if we have an output callback, call it to send the data
- if ( stbir_info->out_pixels_cb )
- stbir_info->out_pixels_cb( output_buffer, num_pixels, row, stbir_info->user_data );
-}
-
-
-// Get the ring buffer pointer for an index
-static float* stbir__get_ring_buffer_entry(stbir__info const * stbir_info, stbir__per_split_info const * split_info, int index )
-{
- STBIR_ASSERT( index < stbir_info->ring_buffer_num_entries );
-
- #ifdef STBIR__SEPARATE_ALLOCATIONS
- return split_info->ring_buffers[ index ];
- #else
- return (float*) ( ( (char*) split_info->ring_buffer ) + ( index * stbir_info->ring_buffer_length_bytes ) );
- #endif
-}
-
-// Get the specified scan line from the ring buffer
-static float* stbir__get_ring_buffer_scanline(stbir__info const * stbir_info, stbir__per_split_info const * split_info, int get_scanline)
-{
- int ring_buffer_index = (split_info->ring_buffer_begin_index + (get_scanline - split_info->ring_buffer_first_scanline)) % stbir_info->ring_buffer_num_entries;
- return stbir__get_ring_buffer_entry( stbir_info, split_info, ring_buffer_index );
-}
-
-static void stbir__resample_horizontal_gather(stbir__info const * stbir_info, float* output_buffer, float const * input_buffer STBIR_ONLY_PROFILE_GET_SPLIT_INFO )
-{
- float const * decode_buffer = input_buffer - ( stbir_info->scanline_extents.conservative.n0 * stbir_info->effective_channels );
-
- STBIR_PROFILE_START( horizontal );
- if ( ( stbir_info->horizontal.filter_enum == STBIR_FILTER_POINT_SAMPLE ) && ( stbir_info->horizontal.scale_info.scale == 1.0f ) )
- STBIR_MEMCPY( output_buffer, input_buffer, stbir_info->horizontal.scale_info.output_sub_size * sizeof( float ) * stbir_info->effective_channels );
- else
- stbir_info->horizontal_gather_channels( output_buffer, stbir_info->horizontal.scale_info.output_sub_size, decode_buffer, stbir_info->horizontal.contributors, stbir_info->horizontal.coefficients, stbir_info->horizontal.coefficient_width );
- STBIR_PROFILE_END( horizontal );
-}
-
-static void stbir__resample_vertical_gather(stbir__info const * stbir_info, stbir__per_split_info* split_info, int n, int contrib_n0, int contrib_n1, float const * vertical_coefficients )
-{
- float* encode_buffer = split_info->vertical_buffer;
- float* decode_buffer = split_info->decode_buffer;
- int vertical_first = stbir_info->vertical_first;
- int width = (vertical_first) ? ( stbir_info->scanline_extents.conservative.n1-stbir_info->scanline_extents.conservative.n0+1 ) : stbir_info->horizontal.scale_info.output_sub_size;
- int width_times_channels = stbir_info->effective_channels * width;
-
- STBIR_ASSERT( stbir_info->vertical.is_gather );
-
- // loop over the contributing scanlines and scale into the buffer
- STBIR_PROFILE_START( vertical );
- {
- int k = 0, total = contrib_n1 - contrib_n0 + 1;
- STBIR_ASSERT( total > 0 );
- do {
- float const * inputs[8];
- int i, cnt = total; if ( cnt > 8 ) cnt = 8;
- for( i = 0 ; i < cnt ; i++ )
- inputs[ i ] = stbir__get_ring_buffer_scanline(stbir_info, split_info, k+i+contrib_n0 );
-
- // call the N scanlines at a time function (up to 8 scanlines of blending at once)
- ((k==0)?stbir__vertical_gathers:stbir__vertical_gathers_continues)[cnt-1]( (vertical_first) ? decode_buffer : encode_buffer, vertical_coefficients + k, inputs, inputs[0] + width_times_channels );
- k += cnt;
- total -= cnt;
- } while ( total );
- }
- STBIR_PROFILE_END( vertical );
-
- if ( vertical_first )
- {
- // Now resample the gathered vertical data in the horizontal axis into the encode buffer
- decode_buffer[ width_times_channels ] = 0.0f; // clear two over for horizontals with a remnant of 3
- decode_buffer[ width_times_channels+1 ] = 0.0f;
- stbir__resample_horizontal_gather(stbir_info, encode_buffer, decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
- }
-
- stbir__encode_scanline( stbir_info, ( (char *) stbir_info->output_data ) + ((size_t)n * (size_t)stbir_info->output_stride_bytes),
- encode_buffer, n STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
-}
-
-static void stbir__decode_and_resample_for_vertical_gather_loop(stbir__info const * stbir_info, stbir__per_split_info* split_info, int n)
-{
- int ring_buffer_index;
- float* ring_buffer;
-
- // Decode the nth scanline from the source image into the decode buffer.
- stbir__decode_scanline( stbir_info, n, split_info->decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
-
- // update new end scanline
- split_info->ring_buffer_last_scanline = n;
-
- // get ring buffer
- ring_buffer_index = (split_info->ring_buffer_begin_index + (split_info->ring_buffer_last_scanline - split_info->ring_buffer_first_scanline)) % stbir_info->ring_buffer_num_entries;
- ring_buffer = stbir__get_ring_buffer_entry(stbir_info, split_info, ring_buffer_index);
-
- // Now resample it into the ring buffer.
- stbir__resample_horizontal_gather( stbir_info, ring_buffer, split_info->decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
-
- // Now it's sitting in the ring buffer ready to be used as source for the vertical sampling.
-}
-
-static void stbir__vertical_gather_loop( stbir__info const * stbir_info, stbir__per_split_info* split_info, int split_count )
-{
- int y, start_output_y, end_output_y;
- stbir__contributors* vertical_contributors = stbir_info->vertical.contributors;
- float const * vertical_coefficients = stbir_info->vertical.coefficients;
-
- STBIR_ASSERT( stbir_info->vertical.is_gather );
-
- start_output_y = split_info->start_output_y;
- end_output_y = split_info[split_count-1].end_output_y;
-
- vertical_contributors += start_output_y;
- vertical_coefficients += start_output_y * stbir_info->vertical.coefficient_width;
-
- // initialize the ring buffer for gathering
- split_info->ring_buffer_begin_index = 0;
- split_info->ring_buffer_first_scanline = vertical_contributors->n0;
- split_info->ring_buffer_last_scanline = split_info->ring_buffer_first_scanline - 1; // means "empty"
-
- for (y = start_output_y; y < end_output_y; y++)
- {
- int in_first_scanline, in_last_scanline;
-
- in_first_scanline = vertical_contributors->n0;
- in_last_scanline = vertical_contributors->n1;
-
- // make sure the indexing hasn't broken
- STBIR_ASSERT( in_first_scanline >= split_info->ring_buffer_first_scanline );
-
- // Load in new scanlines
- while (in_last_scanline > split_info->ring_buffer_last_scanline)
- {
- STBIR_ASSERT( ( split_info->ring_buffer_last_scanline - split_info->ring_buffer_first_scanline + 1 ) <= stbir_info->ring_buffer_num_entries );
-
- // make sure there was room in the ring buffer when we add new scanlines
- if ( ( split_info->ring_buffer_last_scanline - split_info->ring_buffer_first_scanline + 1 ) == stbir_info->ring_buffer_num_entries )
- {
- split_info->ring_buffer_first_scanline++;
- split_info->ring_buffer_begin_index++;
- }
-
- if ( stbir_info->vertical_first )
- {
- float * ring_buffer = stbir__get_ring_buffer_scanline( stbir_info, split_info, ++split_info->ring_buffer_last_scanline );
- // Decode the nth scanline from the source image into the decode buffer.
- stbir__decode_scanline( stbir_info, split_info->ring_buffer_last_scanline, ring_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
- }
- else
- {
- stbir__decode_and_resample_for_vertical_gather_loop(stbir_info, split_info, split_info->ring_buffer_last_scanline + 1);
- }
- }
-
- // Now all buffers should be ready to write a row of vertical sampling, so do it.
- stbir__resample_vertical_gather(stbir_info, split_info, y, in_first_scanline, in_last_scanline, vertical_coefficients );
-
- ++vertical_contributors;
- vertical_coefficients += stbir_info->vertical.coefficient_width;
- }
-}
-
-#define STBIR__FLOAT_EMPTY_MARKER 3.0e+38F
-#define STBIR__FLOAT_BUFFER_IS_EMPTY(ptr) ((ptr)[0]==STBIR__FLOAT_EMPTY_MARKER)
-
-static void stbir__encode_first_scanline_from_scatter(stbir__info const * stbir_info, stbir__per_split_info* split_info)
-{
- // evict a scanline out into the output buffer
- float* ring_buffer_entry = stbir__get_ring_buffer_entry(stbir_info, split_info, split_info->ring_buffer_begin_index );
-
- // dump the scanline out
- stbir__encode_scanline( stbir_info, ( (char *)stbir_info->output_data ) + ( (size_t)split_info->ring_buffer_first_scanline * (size_t)stbir_info->output_stride_bytes ), ring_buffer_entry, split_info->ring_buffer_first_scanline STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
-
- // mark it as empty
- ring_buffer_entry[ 0 ] = STBIR__FLOAT_EMPTY_MARKER;
-
- // advance the first scanline
- split_info->ring_buffer_first_scanline++;
- if ( ++split_info->ring_buffer_begin_index == stbir_info->ring_buffer_num_entries )
- split_info->ring_buffer_begin_index = 0;
-}
-
-static void stbir__horizontal_resample_and_encode_first_scanline_from_scatter(stbir__info const * stbir_info, stbir__per_split_info* split_info)
-{
- // evict a scanline out into the output buffer
-
- float* ring_buffer_entry = stbir__get_ring_buffer_entry(stbir_info, split_info, split_info->ring_buffer_begin_index );
-
- // Now resample it into the buffer.
- stbir__resample_horizontal_gather( stbir_info, split_info->vertical_buffer, ring_buffer_entry STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
-
- // dump the scanline out
- stbir__encode_scanline( stbir_info, ( (char *)stbir_info->output_data ) + ( (size_t)split_info->ring_buffer_first_scanline * (size_t)stbir_info->output_stride_bytes ), split_info->vertical_buffer, split_info->ring_buffer_first_scanline STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
-
- // mark it as empty
- ring_buffer_entry[ 0 ] = STBIR__FLOAT_EMPTY_MARKER;
-
- // advance the first scanline
- split_info->ring_buffer_first_scanline++;
- if ( ++split_info->ring_buffer_begin_index == stbir_info->ring_buffer_num_entries )
- split_info->ring_buffer_begin_index = 0;
-}
-
-static void stbir__resample_vertical_scatter(stbir__info const * stbir_info, stbir__per_split_info* split_info, int n0, int n1, float const * vertical_coefficients, float const * vertical_buffer, float const * vertical_buffer_end )
-{
- STBIR_ASSERT( !stbir_info->vertical.is_gather );
-
- STBIR_PROFILE_START( vertical );
- {
- int k = 0, total = n1 - n0 + 1;
- STBIR_ASSERT( total > 0 );
- do {
- float * outputs[8];
- int i, n = total; if ( n > 8 ) n = 8;
- for( i = 0 ; i < n ; i++ )
- {
- outputs[ i ] = stbir__get_ring_buffer_scanline(stbir_info, split_info, k+i+n0 );
- if ( ( i ) && ( STBIR__FLOAT_BUFFER_IS_EMPTY( outputs[i] ) != STBIR__FLOAT_BUFFER_IS_EMPTY( outputs[0] ) ) ) // make sure runs are of the same type
- {
- n = i;
- break;
- }
- }
- // call the scatter to N scanlines at a time function (up to 8 scanlines of scattering at once)
- ((STBIR__FLOAT_BUFFER_IS_EMPTY( outputs[0] ))?stbir__vertical_scatter_sets:stbir__vertical_scatter_blends)[n-1]( outputs, vertical_coefficients + k, vertical_buffer, vertical_buffer_end );
- k += n;
- total -= n;
- } while ( total );
- }
-
- STBIR_PROFILE_END( vertical );
-}
-
-typedef void stbir__handle_scanline_for_scatter_func(stbir__info const * stbir_info, stbir__per_split_info* split_info);
-
-static void stbir__vertical_scatter_loop( stbir__info const * stbir_info, stbir__per_split_info* split_info, int split_count )
-{
- int y, start_output_y, end_output_y, start_input_y, end_input_y;
- stbir__contributors* vertical_contributors = stbir_info->vertical.contributors;
- float const * vertical_coefficients = stbir_info->vertical.coefficients;
- stbir__handle_scanline_for_scatter_func * handle_scanline_for_scatter;
- void * scanline_scatter_buffer;
- void * scanline_scatter_buffer_end;
- int on_first_input_y, last_input_y;
- int width = (stbir_info->vertical_first) ? ( stbir_info->scanline_extents.conservative.n1-stbir_info->scanline_extents.conservative.n0+1 ) : stbir_info->horizontal.scale_info.output_sub_size;
- int width_times_channels = stbir_info->effective_channels * width;
-
- STBIR_ASSERT( !stbir_info->vertical.is_gather );
-
- start_output_y = split_info->start_output_y;
- end_output_y = split_info[split_count-1].end_output_y; // may do multiple split counts
-
- start_input_y = split_info->start_input_y;
- end_input_y = split_info[split_count-1].end_input_y;
-
- // adjust for starting offset start_input_y
- y = start_input_y + stbir_info->vertical.filter_pixel_margin;
- vertical_contributors += y ;
- vertical_coefficients += stbir_info->vertical.coefficient_width * y;
-
- if ( stbir_info->vertical_first )
- {
- handle_scanline_for_scatter = stbir__horizontal_resample_and_encode_first_scanline_from_scatter;
- scanline_scatter_buffer = split_info->decode_buffer;
- scanline_scatter_buffer_end = ( (char*) scanline_scatter_buffer ) + sizeof( float ) * stbir_info->effective_channels * (stbir_info->scanline_extents.conservative.n1-stbir_info->scanline_extents.conservative.n0+1);
- }
- else
- {
- handle_scanline_for_scatter = stbir__encode_first_scanline_from_scatter;
- scanline_scatter_buffer = split_info->vertical_buffer;
- scanline_scatter_buffer_end = ( (char*) scanline_scatter_buffer ) + sizeof( float ) * stbir_info->effective_channels * stbir_info->horizontal.scale_info.output_sub_size;
- }
-
- // initialize the ring buffer for scattering
- split_info->ring_buffer_first_scanline = start_output_y;
- split_info->ring_buffer_last_scanline = -1;
- split_info->ring_buffer_begin_index = -1;
-
- // mark all the buffers as empty to start
- for( y = 0 ; y < stbir_info->ring_buffer_num_entries ; y++ )
- {
- float * decode_buffer = stbir__get_ring_buffer_entry( stbir_info, split_info, y );
- decode_buffer[ width_times_channels ] = 0.0f; // clear two over for horizontals with a remnant of 3
- decode_buffer[ width_times_channels+1 ] = 0.0f;
- decode_buffer[0] = STBIR__FLOAT_EMPTY_MARKER; // only used on scatter
- }
-
- // do the loop in input space
- on_first_input_y = 1; last_input_y = start_input_y;
- for (y = start_input_y ; y < end_input_y; y++)
- {
- int out_first_scanline, out_last_scanline;
-
- out_first_scanline = vertical_contributors->n0;
- out_last_scanline = vertical_contributors->n1;
-
- STBIR_ASSERT(out_last_scanline - out_first_scanline + 1 <= stbir_info->ring_buffer_num_entries);
-
- if ( ( out_last_scanline >= out_first_scanline ) && ( ( ( out_first_scanline >= start_output_y ) && ( out_first_scanline < end_output_y ) ) || ( ( out_last_scanline >= start_output_y ) && ( out_last_scanline < end_output_y ) ) ) )
- {
- float const * vc = vertical_coefficients;
-
- // keep track of the range actually seen for the next resize
- last_input_y = y;
- if ( ( on_first_input_y ) && ( y > start_input_y ) )
- split_info->start_input_y = y;
- on_first_input_y = 0;
-
- // clip the region
- if ( out_first_scanline < start_output_y )
- {
- vc += start_output_y - out_first_scanline;
- out_first_scanline = start_output_y;
- }
-
- if ( out_last_scanline >= end_output_y )
- out_last_scanline = end_output_y - 1;
-
- // if very first scanline, init the index
- if (split_info->ring_buffer_begin_index < 0)
- split_info->ring_buffer_begin_index = out_first_scanline - start_output_y;
-
- STBIR_ASSERT( split_info->ring_buffer_begin_index <= out_first_scanline );
-
- // Decode the nth scanline from the source image into the decode buffer.
- stbir__decode_scanline( stbir_info, y, split_info->decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
-
- // When horizontal first, we resample horizontally into the vertical buffer before we scatter it out
- if ( !stbir_info->vertical_first )
- stbir__resample_horizontal_gather( stbir_info, split_info->vertical_buffer, split_info->decode_buffer STBIR_ONLY_PROFILE_SET_SPLIT_INFO );
-
- // Now it's sitting in the buffer ready to be distributed into the ring buffers.
-
- // evict from the ringbuffer, if we need are full
- if ( ( ( split_info->ring_buffer_last_scanline - split_info->ring_buffer_first_scanline + 1 ) == stbir_info->ring_buffer_num_entries ) &&
- ( out_last_scanline > split_info->ring_buffer_last_scanline ) )
- handle_scanline_for_scatter( stbir_info, split_info );
-
- // Now the horizontal buffer is ready to write to all ring buffer rows, so do it.
- stbir__resample_vertical_scatter(stbir_info, split_info, out_first_scanline, out_last_scanline, vc, (float*)scanline_scatter_buffer, (float*)scanline_scatter_buffer_end );
-
- // update the end of the buffer
- if ( out_last_scanline > split_info->ring_buffer_last_scanline )
- split_info->ring_buffer_last_scanline = out_last_scanline;
- }
- ++vertical_contributors;
- vertical_coefficients += stbir_info->vertical.coefficient_width;
- }
-
- // now evict the scanlines that are left over in the ring buffer
- while ( split_info->ring_buffer_first_scanline < end_output_y )
- handle_scanline_for_scatter(stbir_info, split_info);
-
- // update the end_input_y if we do multiple resizes with the same data
- ++last_input_y;
- for( y = 0 ; y < split_count; y++ )
- if ( split_info[y].end_input_y > last_input_y )
- split_info[y].end_input_y = last_input_y;
-}
-
-
-static stbir__kernel_callback * stbir__builtin_kernels[] = { 0, stbir__filter_trapezoid, stbir__filter_triangle, stbir__filter_cubic, stbir__filter_catmullrom, stbir__filter_mitchell, stbir__filter_point };
-static stbir__support_callback * stbir__builtin_supports[] = { 0, stbir__support_trapezoid, stbir__support_one, stbir__support_two, stbir__support_two, stbir__support_two, stbir__support_zeropoint5 };
-
-static void stbir__set_sampler(stbir__sampler * samp, stbir_filter filter, stbir__kernel_callback * kernel, stbir__support_callback * support, stbir_edge edge, stbir__scale_info * scale_info, int always_gather, void * user_data )
-{
- // set filter
- if (filter == 0)
- {
- filter = STBIR_DEFAULT_FILTER_DOWNSAMPLE; // default to downsample
- if (scale_info->scale >= ( 1.0f - stbir__small_float ) )
- {
- if ( (scale_info->scale <= ( 1.0f + stbir__small_float ) ) && ( STBIR_CEILF(scale_info->pixel_shift) == scale_info->pixel_shift ) )
- filter = STBIR_FILTER_POINT_SAMPLE;
- else
- filter = STBIR_DEFAULT_FILTER_UPSAMPLE;
- }
- }
- samp->filter_enum = filter;
-
- STBIR_ASSERT(samp->filter_enum != 0);
- STBIR_ASSERT((unsigned)samp->filter_enum < STBIR_FILTER_OTHER);
- samp->filter_kernel = stbir__builtin_kernels[ filter ];
- samp->filter_support = stbir__builtin_supports[ filter ];
-
- if ( kernel && support )
- {
- samp->filter_kernel = kernel;
- samp->filter_support = support;
- samp->filter_enum = STBIR_FILTER_OTHER;
- }
-
- samp->edge = edge;
- samp->filter_pixel_width = stbir__get_filter_pixel_width (samp->filter_support, scale_info->scale, user_data );
- // Gather is always better, but in extreme downsamples, you have to most or all of the data in memory
- // For horizontal, we always have all the pixels, so we always use gather here (always_gather==1).
- // For vertical, we use gather if scaling up (which means we will have samp->filter_pixel_width
- // scanlines in memory at once).
- samp->is_gather = 0;
- if ( scale_info->scale >= ( 1.0f - stbir__small_float ) )
- samp->is_gather = 1;
- else if ( ( always_gather ) || ( samp->filter_pixel_width <= STBIR_FORCE_GATHER_FILTER_SCANLINES_AMOUNT ) )
- samp->is_gather = 2;
-
- // pre calculate stuff based on the above
- samp->coefficient_width = stbir__get_coefficient_width(samp, samp->is_gather, user_data);
-
- // filter_pixel_width is the conservative size in pixels of input that affect an output pixel.
- // In rare cases (only with 2 pix to 1 pix with the default filters), it's possible that the
- // filter will extend before or after the scanline beyond just one extra entire copy of the
- // scanline (we would hit the edge twice). We don't let you do that, so we clamp the total
- // width to 3x the total of input pixel (once for the scanline, once for the left side
- // overhang, and once for the right side). We only do this for edge mode, since the other
- // modes can just re-edge clamp back in again.
- if ( edge == STBIR_EDGE_WRAP )
- if ( samp->filter_pixel_width > ( scale_info->input_full_size * 3 ) )
- samp->filter_pixel_width = scale_info->input_full_size * 3;
-
- // This is how much to expand buffers to account for filters seeking outside
- // the image boundaries.
- samp->filter_pixel_margin = samp->filter_pixel_width / 2;
-
- // filter_pixel_margin is the amount that this filter can overhang on just one side of either
- // end of the scanline (left or the right). Since we only allow you to overhang 1 scanline's
- // worth of pixels, we clamp this one side of overhang to the input scanline size. Again,
- // this clamping only happens in rare cases with the default filters (2 pix to 1 pix).
- if ( edge == STBIR_EDGE_WRAP )
- if ( samp->filter_pixel_margin > scale_info->input_full_size )
- samp->filter_pixel_margin = scale_info->input_full_size;
-
- samp->num_contributors = stbir__get_contributors(samp, samp->is_gather);
-
- samp->contributors_size = samp->num_contributors * sizeof(stbir__contributors);
- samp->coefficients_size = samp->num_contributors * samp->coefficient_width * sizeof(float) + sizeof(float)*STBIR_INPUT_CALLBACK_PADDING; // extra sizeof(float) is padding
-
- samp->gather_prescatter_contributors = 0;
- samp->gather_prescatter_coefficients = 0;
- if ( samp->is_gather == 0 )
- {
- samp->gather_prescatter_coefficient_width = samp->filter_pixel_width;
- samp->gather_prescatter_num_contributors = stbir__get_contributors(samp, 2);
- samp->gather_prescatter_contributors_size = samp->gather_prescatter_num_contributors * sizeof(stbir__contributors);
- samp->gather_prescatter_coefficients_size = samp->gather_prescatter_num_contributors * samp->gather_prescatter_coefficient_width * sizeof(float);
- }
-}
-
-static void stbir__get_conservative_extents( stbir__sampler * samp, stbir__contributors * range, void * user_data )
-{
- float scale = samp->scale_info.scale;
- float out_shift = samp->scale_info.pixel_shift;
- stbir__support_callback * support = samp->filter_support;
- int input_full_size = samp->scale_info.input_full_size;
- stbir_edge edge = samp->edge;
- float inv_scale = samp->scale_info.inv_scale;
-
- STBIR_ASSERT( samp->is_gather != 0 );
-
- if ( samp->is_gather == 1 )
- {
- int in_first_pixel, in_last_pixel;
- float out_filter_radius = support(inv_scale, user_data) * scale;
-
- stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, 0.5, out_filter_radius, inv_scale, out_shift, input_full_size, edge );
- range->n0 = in_first_pixel;
- stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, ( (float)(samp->scale_info.output_sub_size-1) ) + 0.5f, out_filter_radius, inv_scale, out_shift, input_full_size, edge );
- range->n1 = in_last_pixel;
- }
- else if ( samp->is_gather == 2 ) // downsample gather, refine
- {
- float in_pixels_radius = support(scale, user_data) * inv_scale;
- int filter_pixel_margin = samp->filter_pixel_margin;
- int output_sub_size = samp->scale_info.output_sub_size;
- int input_end;
- int n;
- int in_first_pixel, in_last_pixel;
-
- // get a conservative area of the input range
- stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, 0, 0, inv_scale, out_shift, input_full_size, edge );
- range->n0 = in_first_pixel;
- stbir__calculate_in_pixel_range( &in_first_pixel, &in_last_pixel, (float)output_sub_size, 0, inv_scale, out_shift, input_full_size, edge );
- range->n1 = in_last_pixel;
-
- // now go through the margin to the start of area to find bottom
- n = range->n0 + 1;
- input_end = -filter_pixel_margin;
- while( n >= input_end )
- {
- int out_first_pixel, out_last_pixel;
- stbir__calculate_out_pixel_range( &out_first_pixel, &out_last_pixel, ((float)n)+0.5f, in_pixels_radius, scale, out_shift, output_sub_size );
- if ( out_first_pixel > out_last_pixel )
- break;
-
- if ( ( out_first_pixel < output_sub_size ) || ( out_last_pixel >= 0 ) )
- range->n0 = n;
- --n;
- }
-
- // now go through the end of the area through the margin to find top
- n = range->n1 - 1;
- input_end = n + 1 + filter_pixel_margin;
- while( n <= input_end )
- {
- int out_first_pixel, out_last_pixel;
- stbir__calculate_out_pixel_range( &out_first_pixel, &out_last_pixel, ((float)n)+0.5f, in_pixels_radius, scale, out_shift, output_sub_size );
- if ( out_first_pixel > out_last_pixel )
- break;
- if ( ( out_first_pixel < output_sub_size ) || ( out_last_pixel >= 0 ) )
- range->n1 = n;
- ++n;
- }
- }
-
- if ( samp->edge == STBIR_EDGE_WRAP )
- {
- // if we are wrapping, and we are very close to the image size (so the edges might merge), just use the scanline up to the edge
- if ( ( range->n0 > 0 ) && ( range->n1 >= input_full_size ) )
- {
- int marg = range->n1 - input_full_size + 1;
- if ( ( marg + STBIR__MERGE_RUNS_PIXEL_THRESHOLD ) >= range->n0 )
- range->n0 = 0;
- }
- if ( ( range->n0 < 0 ) && ( range->n1 < (input_full_size-1) ) )
- {
- int marg = -range->n0;
- if ( ( input_full_size - marg - STBIR__MERGE_RUNS_PIXEL_THRESHOLD - 1 ) <= range->n1 )
- range->n1 = input_full_size - 1;
- }
- }
- else
- {
- // for non-edge-wrap modes, we never read over the edge, so clamp
- if ( range->n0 < 0 )
- range->n0 = 0;
- if ( range->n1 >= input_full_size )
- range->n1 = input_full_size - 1;
- }
-}
-
-static void stbir__get_split_info( stbir__per_split_info* split_info, int splits, int output_height, int vertical_pixel_margin, int input_full_height, int is_gather, stbir__contributors * contribs )
-{
- int i, cur;
- int left = output_height;
-
- cur = 0;
- for( i = 0 ; i < splits ; i++ )
- {
- int each;
-
- split_info[i].start_output_y = cur;
- each = left / ( splits - i );
- split_info[i].end_output_y = cur + each;
-
- // ok, when we are gathering, we need to make sure we are starting on a y offset that doesn't have
- // a "special" set of coefficients. Basically, with exactly the right filter at exactly the right
- // resize at exactly the right phase, some of the coefficents can be zero. When they are zero, we
- // don't process them at all. But this leads to a tricky thing with the thread splits, where we
- // might have a set of two coeffs like this for example: (4,4) and (3,6). The 4,4 means there was
- // just one single coeff because things worked out perfectly (normally, they all have 4 coeffs
- // like the range 3,6. The problem is that if we start right on the (4,4) on a brand new thread,
- // then when we get to (3,6), we don't have the "3" sample in memory (because we didn't load
- // it on the initial (4,4) range because it didn't have a 3 (we only add new samples that are
- // larger than our existing samples - it's just how the eviction works). So, our solution here
- // is pretty simple, if we start right on a range that has samples that start earlier, then we
- // simply bump up our previous thread split range to include it, and then start this threads
- // range with the smaller sample. It just moves one scanline from one thread split to another,
- // so that we end with the unusual one, instead of start with it. To do this, we check 2-4
- // sample at each thread split start and then occassionally move them.
-
- if ( ( is_gather ) && ( i ) )
- {
- stbir__contributors * small_contribs;
- int j, smallest, stop, start_n0;
- stbir__contributors * split_contribs = contribs + cur;
-
- // scan for a max of 3x the filter width or until the next thread split
- stop = vertical_pixel_margin * 3;
- if ( each < stop )
- stop = each;
-
- // loops a few times before early out
- smallest = 0;
- small_contribs = split_contribs;
- start_n0 = small_contribs->n0;
- for( j = 1 ; j <= stop ; j++ )
- {
- ++split_contribs;
- if ( split_contribs->n0 > start_n0 )
- break;
- if ( split_contribs->n0 < small_contribs->n0 )
- {
- small_contribs = split_contribs;
- smallest = j;
- }
- }
-
- split_info[i-1].end_output_y += smallest;
- split_info[i].start_output_y += smallest;
- }
-
- cur += each;
- left -= each;
-
- // scatter range (updated to minimum as you run it)
- split_info[i].start_input_y = -vertical_pixel_margin;
- split_info[i].end_input_y = input_full_height + vertical_pixel_margin;
- }
-}
-
-static void stbir__free_internal_mem( stbir__info *info )
-{
- #define STBIR__FREE_AND_CLEAR( ptr ) { if ( ptr ) { void * p = (ptr); (ptr) = 0; STBIR_FREE( p, info->user_data); } }
-
- if ( info )
- {
- #ifndef STBIR__SEPARATE_ALLOCATIONS
- STBIR__FREE_AND_CLEAR( info->alloced_mem );
- #else
- int i,j;
-
- if ( ( info->vertical.gather_prescatter_contributors ) && ( (void*)info->vertical.gather_prescatter_contributors != (void*)info->split_info[0].decode_buffer ) )
- {
- STBIR__FREE_AND_CLEAR( info->vertical.gather_prescatter_coefficients );
- STBIR__FREE_AND_CLEAR( info->vertical.gather_prescatter_contributors );
- }
- for( i = 0 ; i < info->splits ; i++ )
- {
- for( j = 0 ; j < info->alloc_ring_buffer_num_entries ; j++ )
- {
- #ifdef STBIR_SIMD8
- if ( info->effective_channels == 3 )
- --info->split_info[i].ring_buffers[j]; // avx in 3 channel mode needs one float at the start of the buffer
- #endif
- STBIR__FREE_AND_CLEAR( info->split_info[i].ring_buffers[j] );
- }
-
- #ifdef STBIR_SIMD8
- if ( info->effective_channels == 3 )
- --info->split_info[i].decode_buffer; // avx in 3 channel mode needs one float at the start of the buffer
- #endif
- STBIR__FREE_AND_CLEAR( info->split_info[i].decode_buffer );
- STBIR__FREE_AND_CLEAR( info->split_info[i].ring_buffers );
- STBIR__FREE_AND_CLEAR( info->split_info[i].vertical_buffer );
- }
- STBIR__FREE_AND_CLEAR( info->split_info );
- if ( info->vertical.coefficients != info->horizontal.coefficients )
- {
- STBIR__FREE_AND_CLEAR( info->vertical.coefficients );
- STBIR__FREE_AND_CLEAR( info->vertical.contributors );
- }
- STBIR__FREE_AND_CLEAR( info->horizontal.coefficients );
- STBIR__FREE_AND_CLEAR( info->horizontal.contributors );
- STBIR__FREE_AND_CLEAR( info->alloced_mem );
- STBIR_FREE( info, info->user_data );
- #endif
- }
-
- #undef STBIR__FREE_AND_CLEAR
-}
-
-static int stbir__get_max_split( int splits, int height )
-{
- int i;
- int max = 0;
-
- for( i = 0 ; i < splits ; i++ )
- {
- int each = height / ( splits - i );
- if ( each > max )
- max = each;
- height -= each;
- }
- return max;
-}
-
-static stbir__horizontal_gather_channels_func ** stbir__horizontal_gather_n_coeffs_funcs[8] =
-{
- 0, stbir__horizontal_gather_1_channels_with_n_coeffs_funcs, stbir__horizontal_gather_2_channels_with_n_coeffs_funcs, stbir__horizontal_gather_3_channels_with_n_coeffs_funcs, stbir__horizontal_gather_4_channels_with_n_coeffs_funcs, 0,0, stbir__horizontal_gather_7_channels_with_n_coeffs_funcs
-};
-
-static stbir__horizontal_gather_channels_func ** stbir__horizontal_gather_channels_funcs[8] =
-{
- 0, stbir__horizontal_gather_1_channels_funcs, stbir__horizontal_gather_2_channels_funcs, stbir__horizontal_gather_3_channels_funcs, stbir__horizontal_gather_4_channels_funcs, 0,0, stbir__horizontal_gather_7_channels_funcs
-};
-
-// there are six resize classifications: 0 == vertical scatter, 1 == vertical gather < 1x scale, 2 == vertical gather 1x-2x scale, 4 == vertical gather < 3x scale, 4 == vertical gather > 3x scale, 5 == <=4 pixel height, 6 == <=4 pixel wide column
-#define STBIR_RESIZE_CLASSIFICATIONS 8
-
-static float stbir__compute_weights[5][STBIR_RESIZE_CLASSIFICATIONS][4]= // 5 = 0=1chan, 1=2chan, 2=3chan, 3=4chan, 4=7chan
-{
- {
- { 1.00000f, 1.00000f, 0.31250f, 1.00000f },
- { 0.56250f, 0.59375f, 0.00000f, 0.96875f },
- { 1.00000f, 0.06250f, 0.00000f, 1.00000f },
- { 0.00000f, 0.09375f, 1.00000f, 1.00000f },
- { 1.00000f, 1.00000f, 1.00000f, 1.00000f },
- { 0.03125f, 0.12500f, 1.00000f, 1.00000f },
- { 0.06250f, 0.12500f, 0.00000f, 1.00000f },
- { 0.00000f, 1.00000f, 0.00000f, 0.03125f },
- }, {
- { 0.00000f, 0.84375f, 0.00000f, 0.03125f },
- { 0.09375f, 0.93750f, 0.00000f, 0.78125f },
- { 0.87500f, 0.21875f, 0.00000f, 0.96875f },
- { 0.09375f, 0.09375f, 1.00000f, 1.00000f },
- { 1.00000f, 1.00000f, 1.00000f, 1.00000f },
- { 0.03125f, 0.12500f, 1.00000f, 1.00000f },
- { 0.06250f, 0.12500f, 0.00000f, 1.00000f },
- { 0.00000f, 1.00000f, 0.00000f, 0.53125f },
- }, {
- { 0.00000f, 0.53125f, 0.00000f, 0.03125f },
- { 0.06250f, 0.96875f, 0.00000f, 0.53125f },
- { 0.87500f, 0.18750f, 0.00000f, 0.93750f },
- { 0.00000f, 0.09375f, 1.00000f, 1.00000f },
- { 1.00000f, 1.00000f, 1.00000f, 1.00000f },
- { 0.03125f, 0.12500f, 1.00000f, 1.00000f },
- { 0.06250f, 0.12500f, 0.00000f, 1.00000f },
- { 0.00000f, 1.00000f, 0.00000f, 0.56250f },
- }, {
- { 0.00000f, 0.50000f, 0.00000f, 0.71875f },
- { 0.06250f, 0.84375f, 0.00000f, 0.87500f },
- { 1.00000f, 0.50000f, 0.50000f, 0.96875f },
- { 1.00000f, 0.09375f, 0.31250f, 0.50000f },
- { 1.00000f, 1.00000f, 1.00000f, 1.00000f },
- { 1.00000f, 0.03125f, 0.03125f, 0.53125f },
- { 0.18750f, 0.12500f, 0.00000f, 1.00000f },
- { 0.00000f, 1.00000f, 0.03125f, 0.18750f },
- }, {
- { 0.00000f, 0.59375f, 0.00000f, 0.96875f },
- { 0.06250f, 0.81250f, 0.06250f, 0.59375f },
- { 0.75000f, 0.43750f, 0.12500f, 0.96875f },
- { 0.87500f, 0.06250f, 0.18750f, 0.43750f },
- { 1.00000f, 1.00000f, 1.00000f, 1.00000f },
- { 0.15625f, 0.12500f, 1.00000f, 1.00000f },
- { 0.06250f, 0.12500f, 0.00000f, 1.00000f },
- { 0.00000f, 1.00000f, 0.03125f, 0.34375f },
- }
-};
-
-// structure that allow us to query and override info for training the costs
-typedef struct STBIR__V_FIRST_INFO
-{
- double v_cost, h_cost;
- int control_v_first; // 0 = no control, 1 = force hori, 2 = force vert
- int v_first;
- int v_resize_classification;
- int is_gather;
-} STBIR__V_FIRST_INFO;
-
-#ifdef STBIR__V_FIRST_INFO_BUFFER
-static STBIR__V_FIRST_INFO STBIR__V_FIRST_INFO_BUFFER = {0};
-#define STBIR__V_FIRST_INFO_POINTER &STBIR__V_FIRST_INFO_BUFFER
-#else
-#define STBIR__V_FIRST_INFO_POINTER 0
-#endif
-
-// Figure out whether to scale along the horizontal or vertical first.
-// This only *super* important when you are scaling by a massively
-// different amount in the vertical vs the horizontal (for example, if
-// you are scaling by 2x in the width, and 0.5x in the height, then you
-// want to do the vertical scale first, because it's around 3x faster
-// in that order.
-//
-// In more normal circumstances, this makes a 20-40% differences, so
-// it's good to get right, but not critical. The normal way that you
-// decide which direction goes first is just figuring out which
-// direction does more multiplies. But with modern CPUs with their
-// fancy caches and SIMD and high IPC abilities, so there's just a lot
-// more that goes into it.
-//
-// My handwavy sort of solution is to have an app that does a whole
-// bunch of timing for both vertical and horizontal first modes,
-// and then another app that can read lots of these timing files
-// and try to search for the best weights to use. Dotimings.c
-// is the app that does a bunch of timings, and vf_train.c is the
-// app that solves for the best weights (and shows how well it
-// does currently).
-
-static int stbir__should_do_vertical_first( float weights_table[STBIR_RESIZE_CLASSIFICATIONS][4], int horizontal_filter_pixel_width, float horizontal_scale, int horizontal_output_size, int vertical_filter_pixel_width, float vertical_scale, int vertical_output_size, int is_gather, STBIR__V_FIRST_INFO * info )
-{
- double v_cost, h_cost;
- float * weights;
- int vertical_first;
- int v_classification;
-
- // categorize the resize into buckets
- if ( ( vertical_output_size <= 4 ) || ( horizontal_output_size <= 4 ) )
- v_classification = ( vertical_output_size < horizontal_output_size ) ? 6 : 7;
- else if ( vertical_scale <= 1.0f )
- v_classification = ( is_gather ) ? 1 : 0;
- else if ( vertical_scale <= 2.0f)
- v_classification = 2;
- else if ( vertical_scale <= 3.0f)
- v_classification = 3;
- else if ( vertical_scale <= 4.0f)
- v_classification = 5;
- else
- v_classification = 6;
-
- // use the right weights
- weights = weights_table[ v_classification ];
-
- // this is the costs when you don't take into account modern CPUs with high ipc and simd and caches - wish we had a better estimate
- h_cost = (float)horizontal_filter_pixel_width * weights[0] + horizontal_scale * (float)vertical_filter_pixel_width * weights[1];
- v_cost = (float)vertical_filter_pixel_width * weights[2] + vertical_scale * (float)horizontal_filter_pixel_width * weights[3];
-
- // use computation estimate to decide vertical first or not
- vertical_first = ( v_cost <= h_cost ) ? 1 : 0;
-
- // save these, if requested
- if ( info )
- {
- info->h_cost = h_cost;
- info->v_cost = v_cost;
- info->v_resize_classification = v_classification;
- info->v_first = vertical_first;
- info->is_gather = is_gather;
- }
-
- // and this allows us to override everything for testing (see dotiming.c)
- if ( ( info ) && ( info->control_v_first ) )
- vertical_first = ( info->control_v_first == 2 ) ? 1 : 0;
-
- return vertical_first;
-}
-
-// layout lookups - must match stbir_internal_pixel_layout
-static unsigned char stbir__pixel_channels[] = {
- 1,2,3,3,4, // 1ch, 2ch, rgb, bgr, 4ch
- 4,4,4,4,2,2, // RGBA,BGRA,ARGB,ABGR,RA,AR
- 4,4,4,4,2,2, // RGBA_PM,BGRA_PM,ARGB_PM,ABGR_PM,RA_PM,AR_PM
-};
-
-// the internal pixel layout enums are in a different order, so we can easily do range comparisons of types
-// the public pixel layout is ordered in a way that if you cast num_channels (1-4) to the enum, you get something sensible
-static stbir_internal_pixel_layout stbir__pixel_layout_convert_public_to_internal[] = {
- STBIRI_BGR, STBIRI_1CHANNEL, STBIRI_2CHANNEL, STBIRI_RGB, STBIRI_RGBA,
- STBIRI_4CHANNEL, STBIRI_BGRA, STBIRI_ARGB, STBIRI_ABGR, STBIRI_RA, STBIRI_AR,
- STBIRI_RGBA_PM, STBIRI_BGRA_PM, STBIRI_ARGB_PM, STBIRI_ABGR_PM, STBIRI_RA_PM, STBIRI_AR_PM,
-};
-
-static stbir__info * stbir__alloc_internal_mem_and_build_samplers( stbir__sampler * horizontal, stbir__sampler * vertical, stbir__contributors * conservative, stbir_pixel_layout input_pixel_layout_public, stbir_pixel_layout output_pixel_layout_public, int splits, int new_x, int new_y, int fast_alpha, void * user_data STBIR_ONLY_PROFILE_BUILD_GET_INFO )
-{
- static char stbir_channel_count_index[8]={ 9,0,1,2, 3,9,9,4 };
-
- stbir__info * info = 0;
- void * alloced = 0;
- size_t alloced_total = 0;
- int vertical_first;
- size_t decode_buffer_size, ring_buffer_length_bytes, ring_buffer_size, vertical_buffer_size;
- int alloc_ring_buffer_num_entries;
-
- int alpha_weighting_type = 0; // 0=none, 1=simple, 2=fancy
- int conservative_split_output_size = stbir__get_max_split( splits, vertical->scale_info.output_sub_size );
- stbir_internal_pixel_layout input_pixel_layout = stbir__pixel_layout_convert_public_to_internal[ input_pixel_layout_public ];
- stbir_internal_pixel_layout output_pixel_layout = stbir__pixel_layout_convert_public_to_internal[ output_pixel_layout_public ];
- int channels = stbir__pixel_channels[ input_pixel_layout ];
- int effective_channels = channels;
-
- // first figure out what type of alpha weighting to use (if any)
- if ( ( horizontal->filter_enum != STBIR_FILTER_POINT_SAMPLE ) || ( vertical->filter_enum != STBIR_FILTER_POINT_SAMPLE ) ) // no alpha weighting on point sampling
- {
- if ( ( input_pixel_layout >= STBIRI_RGBA ) && ( input_pixel_layout <= STBIRI_AR ) && ( output_pixel_layout >= STBIRI_RGBA ) && ( output_pixel_layout <= STBIRI_AR ) )
- {
- if ( fast_alpha )
- {
- alpha_weighting_type = 4;
- }
- else
- {
- static int fancy_alpha_effective_cnts[6] = { 7, 7, 7, 7, 3, 3 };
- alpha_weighting_type = 2;
- effective_channels = fancy_alpha_effective_cnts[ input_pixel_layout - STBIRI_RGBA ];
- }
- }
- else if ( ( input_pixel_layout >= STBIRI_RGBA_PM ) && ( input_pixel_layout <= STBIRI_AR_PM ) && ( output_pixel_layout >= STBIRI_RGBA ) && ( output_pixel_layout <= STBIRI_AR ) )
- {
- // input premult, output non-premult
- alpha_weighting_type = 3;
- }
- else if ( ( input_pixel_layout >= STBIRI_RGBA ) && ( input_pixel_layout <= STBIRI_AR ) && ( output_pixel_layout >= STBIRI_RGBA_PM ) && ( output_pixel_layout <= STBIRI_AR_PM ) )
- {
- // input non-premult, output premult
- alpha_weighting_type = 1;
- }
- }
-
- // channel in and out count must match currently
- if ( channels != stbir__pixel_channels[ output_pixel_layout ] )
- return 0;
-
- // get vertical first
- vertical_first = stbir__should_do_vertical_first( stbir__compute_weights[ (int)stbir_channel_count_index[ effective_channels ] ], horizontal->filter_pixel_width, horizontal->scale_info.scale, horizontal->scale_info.output_sub_size, vertical->filter_pixel_width, vertical->scale_info.scale, vertical->scale_info.output_sub_size, vertical->is_gather, STBIR__V_FIRST_INFO_POINTER );
-
- // sometimes read one float off in some of the unrolled loops (with a weight of zero coeff, so it doesn't have an effect)
- // we use a few extra floats instead of just 1, so that input callback buffer can overlap with the decode buffer without
- // the conversion routines overwriting the callback input data.
- decode_buffer_size = ( conservative->n1 - conservative->n0 + 1 ) * effective_channels * sizeof(float) + sizeof(float)*STBIR_INPUT_CALLBACK_PADDING; // extra floats for input callback stagger
-
-#if defined( STBIR__SEPARATE_ALLOCATIONS ) && defined(STBIR_SIMD8)
- if ( effective_channels == 3 )
- decode_buffer_size += sizeof(float); // avx in 3 channel mode needs one float at the start of the buffer (only with separate allocations)
-#endif
-
- ring_buffer_length_bytes = (size_t)horizontal->scale_info.output_sub_size * (size_t)effective_channels * sizeof(float) + sizeof(float)*STBIR_INPUT_CALLBACK_PADDING; // extra floats for padding
-
- // if we do vertical first, the ring buffer holds a whole decoded line
- if ( vertical_first )
- ring_buffer_length_bytes = ( decode_buffer_size + 15 ) & ~15;
-
- if ( ( ring_buffer_length_bytes & 4095 ) == 0 ) ring_buffer_length_bytes += 64*3; // avoid 4k alias
-
- // One extra entry because floating point precision problems sometimes cause an extra to be necessary.
- alloc_ring_buffer_num_entries = vertical->filter_pixel_width + 1;
-
- // we never need more ring buffer entries than the scanlines we're outputting when in scatter mode
- if ( ( !vertical->is_gather ) && ( alloc_ring_buffer_num_entries > conservative_split_output_size ) )
- alloc_ring_buffer_num_entries = conservative_split_output_size;
-
- ring_buffer_size = (size_t)alloc_ring_buffer_num_entries * (size_t)ring_buffer_length_bytes;
-
- // The vertical buffer is used differently, depending on whether we are scattering
- // the vertical scanlines, or gathering them.
- // If scattering, it's used at the temp buffer to accumulate each output.
- // If gathering, it's just the output buffer.
- vertical_buffer_size = (size_t)horizontal->scale_info.output_sub_size * (size_t)effective_channels * sizeof(float) + sizeof(float); // extra float for padding
-
- // we make two passes through this loop, 1st to add everything up, 2nd to allocate and init
- for(;;)
- {
- int i;
- void * advance_mem = alloced;
- int copy_horizontal = 0;
- stbir__sampler * possibly_use_horizontal_for_pivot = 0;
-
-#ifdef STBIR__SEPARATE_ALLOCATIONS
- #define STBIR__NEXT_PTR( ptr, size, ntype ) if ( alloced ) { void * p = STBIR_MALLOC( size, user_data); if ( p == 0 ) { stbir__free_internal_mem( info ); return 0; } (ptr) = (ntype*)p; }
-#else
- #define STBIR__NEXT_PTR( ptr, size, ntype ) advance_mem = (void*) ( ( ((size_t)advance_mem) + 15 ) & ~15 ); if ( alloced ) ptr = (ntype*)advance_mem; advance_mem = (char*)(((size_t)advance_mem) + (size));
-#endif
-
- STBIR__NEXT_PTR( info, sizeof( stbir__info ), stbir__info );
-
- STBIR__NEXT_PTR( info->split_info, sizeof( stbir__per_split_info ) * splits, stbir__per_split_info );
-
- if ( info )
- {
- static stbir__alpha_weight_func * fancy_alpha_weights[6] = { stbir__fancy_alpha_weight_4ch, stbir__fancy_alpha_weight_4ch, stbir__fancy_alpha_weight_4ch, stbir__fancy_alpha_weight_4ch, stbir__fancy_alpha_weight_2ch, stbir__fancy_alpha_weight_2ch };
- static stbir__alpha_unweight_func * fancy_alpha_unweights[6] = { stbir__fancy_alpha_unweight_4ch, stbir__fancy_alpha_unweight_4ch, stbir__fancy_alpha_unweight_4ch, stbir__fancy_alpha_unweight_4ch, stbir__fancy_alpha_unweight_2ch, stbir__fancy_alpha_unweight_2ch };
- static stbir__alpha_weight_func * simple_alpha_weights[6] = { stbir__simple_alpha_weight_4ch, stbir__simple_alpha_weight_4ch, stbir__simple_alpha_weight_4ch, stbir__simple_alpha_weight_4ch, stbir__simple_alpha_weight_2ch, stbir__simple_alpha_weight_2ch };
- static stbir__alpha_unweight_func * simple_alpha_unweights[6] = { stbir__simple_alpha_unweight_4ch, stbir__simple_alpha_unweight_4ch, stbir__simple_alpha_unweight_4ch, stbir__simple_alpha_unweight_4ch, stbir__simple_alpha_unweight_2ch, stbir__simple_alpha_unweight_2ch };
-
- // initialize info fields
- info->alloced_mem = alloced;
- info->alloced_total = alloced_total;
-
- info->channels = channels;
- info->effective_channels = effective_channels;
-
- info->offset_x = new_x;
- info->offset_y = new_y;
- info->alloc_ring_buffer_num_entries = (int)alloc_ring_buffer_num_entries;
- info->ring_buffer_num_entries = 0;
- info->ring_buffer_length_bytes = (int)ring_buffer_length_bytes;
- info->splits = splits;
- info->vertical_first = vertical_first;
-
- info->input_pixel_layout_internal = input_pixel_layout;
- info->output_pixel_layout_internal = output_pixel_layout;
-
- // setup alpha weight functions
- info->alpha_weight = 0;
- info->alpha_unweight = 0;
-
- // handle alpha weighting functions and overrides
- if ( alpha_weighting_type == 2 )
- {
- // high quality alpha multiplying on the way in, dividing on the way out
- info->alpha_weight = fancy_alpha_weights[ input_pixel_layout - STBIRI_RGBA ];
- info->alpha_unweight = fancy_alpha_unweights[ output_pixel_layout - STBIRI_RGBA ];
- }
- else if ( alpha_weighting_type == 4 )
- {
- // fast alpha multiplying on the way in, dividing on the way out
- info->alpha_weight = simple_alpha_weights[ input_pixel_layout - STBIRI_RGBA ];
- info->alpha_unweight = simple_alpha_unweights[ output_pixel_layout - STBIRI_RGBA ];
- }
- else if ( alpha_weighting_type == 1 )
- {
- // fast alpha on the way in, leave in premultiplied form on way out
- info->alpha_weight = simple_alpha_weights[ input_pixel_layout - STBIRI_RGBA ];
- }
- else if ( alpha_weighting_type == 3 )
- {
- // incoming is premultiplied, fast alpha dividing on the way out - non-premultiplied output
- info->alpha_unweight = simple_alpha_unweights[ output_pixel_layout - STBIRI_RGBA ];
- }
-
- // handle 3-chan color flipping, using the alpha weight path
- if ( ( ( input_pixel_layout == STBIRI_RGB ) && ( output_pixel_layout == STBIRI_BGR ) ) ||
- ( ( input_pixel_layout == STBIRI_BGR ) && ( output_pixel_layout == STBIRI_RGB ) ) )
- {
- // do the flipping on the smaller of the two ends
- if ( horizontal->scale_info.scale < 1.0f )
- info->alpha_unweight = stbir__simple_flip_3ch;
- else
- info->alpha_weight = stbir__simple_flip_3ch;
- }
-
- }
-
- // get all the per-split buffers
- for( i = 0 ; i < splits ; i++ )
- {
- STBIR__NEXT_PTR( info->split_info[i].decode_buffer, decode_buffer_size, float );
-
-#ifdef STBIR__SEPARATE_ALLOCATIONS
-
- #ifdef STBIR_SIMD8
- if ( ( info ) && ( effective_channels == 3 ) )
- ++info->split_info[i].decode_buffer; // avx in 3 channel mode needs one float at the start of the buffer
- #endif
-
- STBIR__NEXT_PTR( info->split_info[i].ring_buffers, alloc_ring_buffer_num_entries * sizeof(float*), float* );
- {
- int j;
- for( j = 0 ; j < alloc_ring_buffer_num_entries ; j++ )
- {
- STBIR__NEXT_PTR( info->split_info[i].ring_buffers[j], ring_buffer_length_bytes, float );
- #ifdef STBIR_SIMD8
- if ( ( info ) && ( effective_channels == 3 ) )
- ++info->split_info[i].ring_buffers[j]; // avx in 3 channel mode needs one float at the start of the buffer
- #endif
- }
- }
-#else
- STBIR__NEXT_PTR( info->split_info[i].ring_buffer, ring_buffer_size, float );
-#endif
- STBIR__NEXT_PTR( info->split_info[i].vertical_buffer, vertical_buffer_size, float );
- }
-
- // alloc memory for to-be-pivoted coeffs (if necessary)
- if ( vertical->is_gather == 0 )
- {
- size_t both;
- size_t temp_mem_amt;
-
- // when in vertical scatter mode, we first build the coefficients in gather mode, and then pivot after,
- // that means we need two buffers, so we try to use the decode buffer and ring buffer for this. if that
- // is too small, we just allocate extra memory to use as this temp.
-
- both = (size_t)vertical->gather_prescatter_contributors_size + (size_t)vertical->gather_prescatter_coefficients_size;
-
-#ifdef STBIR__SEPARATE_ALLOCATIONS
- temp_mem_amt = decode_buffer_size;
-
- #ifdef STBIR_SIMD8
- if ( effective_channels == 3 )
- --temp_mem_amt; // avx in 3 channel mode needs one float at the start of the buffer
- #endif
-#else
- temp_mem_amt = (size_t)( decode_buffer_size + ring_buffer_size + vertical_buffer_size ) * (size_t)splits;
-#endif
- if ( temp_mem_amt >= both )
- {
- if ( info )
- {
- vertical->gather_prescatter_contributors = (stbir__contributors*)info->split_info[0].decode_buffer;
- vertical->gather_prescatter_coefficients = (float*) ( ( (char*)info->split_info[0].decode_buffer ) + vertical->gather_prescatter_contributors_size );
- }
- }
- else
- {
- // ring+decode memory is too small, so allocate temp memory
- STBIR__NEXT_PTR( vertical->gather_prescatter_contributors, vertical->gather_prescatter_contributors_size, stbir__contributors );
- STBIR__NEXT_PTR( vertical->gather_prescatter_coefficients, vertical->gather_prescatter_coefficients_size, float );
- }
- }
-
- STBIR__NEXT_PTR( horizontal->contributors, horizontal->contributors_size, stbir__contributors );
- STBIR__NEXT_PTR( horizontal->coefficients, horizontal->coefficients_size, float );
-
- // are the two filters identical?? (happens a lot with mipmap generation)
- if ( ( horizontal->filter_kernel == vertical->filter_kernel ) && ( horizontal->filter_support == vertical->filter_support ) && ( horizontal->edge == vertical->edge ) && ( horizontal->scale_info.output_sub_size == vertical->scale_info.output_sub_size ) )
- {
- float diff_scale = horizontal->scale_info.scale - vertical->scale_info.scale;
- float diff_shift = horizontal->scale_info.pixel_shift - vertical->scale_info.pixel_shift;
- if ( diff_scale < 0.0f ) diff_scale = -diff_scale;
- if ( diff_shift < 0.0f ) diff_shift = -diff_shift;
- if ( ( diff_scale <= stbir__small_float ) && ( diff_shift <= stbir__small_float ) )
- {
- if ( horizontal->is_gather == vertical->is_gather )
- {
- copy_horizontal = 1;
- goto no_vert_alloc;
- }
- // everything matches, but vertical is scatter, horizontal is gather, use horizontal coeffs for vertical pivot coeffs
- possibly_use_horizontal_for_pivot = horizontal;
- }
- }
-
- STBIR__NEXT_PTR( vertical->contributors, vertical->contributors_size, stbir__contributors );
- STBIR__NEXT_PTR( vertical->coefficients, vertical->coefficients_size, float );
-
- no_vert_alloc:
-
- if ( info )
- {
- STBIR_PROFILE_BUILD_START( horizontal );
-
- stbir__calculate_filters( horizontal, 0, user_data STBIR_ONLY_PROFILE_BUILD_SET_INFO );
-
- // setup the horizontal gather functions
- // start with defaulting to the n_coeffs functions (specialized on channels and remnant leftover)
- info->horizontal_gather_channels = stbir__horizontal_gather_n_coeffs_funcs[ effective_channels ][ horizontal->extent_info.widest & 3 ];
- // but if the number of coeffs <= 12, use another set of special cases. <=12 coeffs is any enlarging resize, or shrinking resize down to about 1/3 size
- if ( horizontal->extent_info.widest <= 12 )
- info->horizontal_gather_channels = stbir__horizontal_gather_channels_funcs[ effective_channels ][ horizontal->extent_info.widest - 1 ];
-
- info->scanline_extents.conservative.n0 = conservative->n0;
- info->scanline_extents.conservative.n1 = conservative->n1;
-
- // get exact extents
- stbir__get_extents( horizontal, &info->scanline_extents );
-
- // pack the horizontal coeffs
- horizontal->coefficient_width = stbir__pack_coefficients(horizontal->num_contributors, horizontal->contributors, horizontal->coefficients, horizontal->coefficient_width, horizontal->extent_info.widest, info->scanline_extents.conservative.n0, info->scanline_extents.conservative.n1 );
-
- STBIR_MEMCPY( &info->horizontal, horizontal, sizeof( stbir__sampler ) );
-
- STBIR_PROFILE_BUILD_END( horizontal );
-
- if ( copy_horizontal )
- {
- STBIR_MEMCPY( &info->vertical, horizontal, sizeof( stbir__sampler ) );
- }
- else
- {
- STBIR_PROFILE_BUILD_START( vertical );
-
- stbir__calculate_filters( vertical, possibly_use_horizontal_for_pivot, user_data STBIR_ONLY_PROFILE_BUILD_SET_INFO );
- STBIR_MEMCPY( &info->vertical, vertical, sizeof( stbir__sampler ) );
-
- STBIR_PROFILE_BUILD_END( vertical );
- }
-
- // setup the vertical split ranges
- stbir__get_split_info( info->split_info, info->splits, info->vertical.scale_info.output_sub_size, info->vertical.filter_pixel_margin, info->vertical.scale_info.input_full_size, info->vertical.is_gather, info->vertical.contributors );
-
- // now we know precisely how many entries we need
- info->ring_buffer_num_entries = info->vertical.extent_info.widest;
-
- // we never need more ring buffer entries than the scanlines we're outputting
- if ( ( !info->vertical.is_gather ) && ( info->ring_buffer_num_entries > conservative_split_output_size ) )
- info->ring_buffer_num_entries = conservative_split_output_size;
- STBIR_ASSERT( info->ring_buffer_num_entries <= info->alloc_ring_buffer_num_entries );
- }
- #undef STBIR__NEXT_PTR
-
-
- // is this the first time through loop?
- if ( info == 0 )
- {
- alloced_total = ( 15 + (size_t)advance_mem );
- alloced = STBIR_MALLOC( alloced_total, user_data );
- if ( alloced == 0 )
- return 0;
- }
- else
- return info; // success
- }
-}
-
-static int stbir__perform_resize( stbir__info const * info, int split_start, int split_count )
-{
- stbir__per_split_info * split_info = info->split_info + split_start;
-
- STBIR_PROFILE_CLEAR_EXTRAS();
-
- STBIR_PROFILE_FIRST_START( looping );
- if (info->vertical.is_gather)
- stbir__vertical_gather_loop( info, split_info, split_count );
- else
- stbir__vertical_scatter_loop( info, split_info, split_count );
- STBIR_PROFILE_END( looping );
-
- return 1;
-}
-
-static void stbir__update_info_from_resize( stbir__info * info, STBIR_RESIZE * resize )
-{
- static stbir__decode_pixels_func * decode_simple[STBIR_TYPE_HALF_FLOAT-STBIR_TYPE_UINT8_SRGB+1]=
- {
- /* 1ch-4ch */ stbir__decode_uint8_srgb, stbir__decode_uint8_srgb, 0, stbir__decode_float_linear, stbir__decode_half_float_linear,
- };
-
- static stbir__decode_pixels_func * decode_alphas[STBIRI_AR-STBIRI_RGBA+1][STBIR_TYPE_HALF_FLOAT-STBIR_TYPE_UINT8_SRGB+1]=
- {
- { /* RGBA */ stbir__decode_uint8_srgb4_linearalpha, stbir__decode_uint8_srgb, 0, stbir__decode_float_linear, stbir__decode_half_float_linear },
- { /* BGRA */ stbir__decode_uint8_srgb4_linearalpha_BGRA, stbir__decode_uint8_srgb_BGRA, 0, stbir__decode_float_linear_BGRA, stbir__decode_half_float_linear_BGRA },
- { /* ARGB */ stbir__decode_uint8_srgb4_linearalpha_ARGB, stbir__decode_uint8_srgb_ARGB, 0, stbir__decode_float_linear_ARGB, stbir__decode_half_float_linear_ARGB },
- { /* ABGR */ stbir__decode_uint8_srgb4_linearalpha_ABGR, stbir__decode_uint8_srgb_ABGR, 0, stbir__decode_float_linear_ABGR, stbir__decode_half_float_linear_ABGR },
- { /* RA */ stbir__decode_uint8_srgb2_linearalpha, stbir__decode_uint8_srgb, 0, stbir__decode_float_linear, stbir__decode_half_float_linear },
- { /* AR */ stbir__decode_uint8_srgb2_linearalpha_AR, stbir__decode_uint8_srgb_AR, 0, stbir__decode_float_linear_AR, stbir__decode_half_float_linear_AR },
- };
-
- static stbir__decode_pixels_func * decode_simple_scaled_or_not[2][2]=
- {
- { stbir__decode_uint8_linear_scaled, stbir__decode_uint8_linear }, { stbir__decode_uint16_linear_scaled, stbir__decode_uint16_linear },
- };
-
- static stbir__decode_pixels_func * decode_alphas_scaled_or_not[STBIRI_AR-STBIRI_RGBA+1][2][2]=
- {
- { /* RGBA */ { stbir__decode_uint8_linear_scaled, stbir__decode_uint8_linear }, { stbir__decode_uint16_linear_scaled, stbir__decode_uint16_linear } },
- { /* BGRA */ { stbir__decode_uint8_linear_scaled_BGRA, stbir__decode_uint8_linear_BGRA }, { stbir__decode_uint16_linear_scaled_BGRA, stbir__decode_uint16_linear_BGRA } },
- { /* ARGB */ { stbir__decode_uint8_linear_scaled_ARGB, stbir__decode_uint8_linear_ARGB }, { stbir__decode_uint16_linear_scaled_ARGB, stbir__decode_uint16_linear_ARGB } },
- { /* ABGR */ { stbir__decode_uint8_linear_scaled_ABGR, stbir__decode_uint8_linear_ABGR }, { stbir__decode_uint16_linear_scaled_ABGR, stbir__decode_uint16_linear_ABGR } },
- { /* RA */ { stbir__decode_uint8_linear_scaled, stbir__decode_uint8_linear }, { stbir__decode_uint16_linear_scaled, stbir__decode_uint16_linear } },
- { /* AR */ { stbir__decode_uint8_linear_scaled_AR, stbir__decode_uint8_linear_AR }, { stbir__decode_uint16_linear_scaled_AR, stbir__decode_uint16_linear_AR } }
- };
-
- static stbir__encode_pixels_func * encode_simple[STBIR_TYPE_HALF_FLOAT-STBIR_TYPE_UINT8_SRGB+1]=
- {
- /* 1ch-4ch */ stbir__encode_uint8_srgb, stbir__encode_uint8_srgb, 0, stbir__encode_float_linear, stbir__encode_half_float_linear,
- };
-
- static stbir__encode_pixels_func * encode_alphas[STBIRI_AR-STBIRI_RGBA+1][STBIR_TYPE_HALF_FLOAT-STBIR_TYPE_UINT8_SRGB+1]=
- {
- { /* RGBA */ stbir__encode_uint8_srgb4_linearalpha, stbir__encode_uint8_srgb, 0, stbir__encode_float_linear, stbir__encode_half_float_linear },
- { /* BGRA */ stbir__encode_uint8_srgb4_linearalpha_BGRA, stbir__encode_uint8_srgb_BGRA, 0, stbir__encode_float_linear_BGRA, stbir__encode_half_float_linear_BGRA },
- { /* ARGB */ stbir__encode_uint8_srgb4_linearalpha_ARGB, stbir__encode_uint8_srgb_ARGB, 0, stbir__encode_float_linear_ARGB, stbir__encode_half_float_linear_ARGB },
- { /* ABGR */ stbir__encode_uint8_srgb4_linearalpha_ABGR, stbir__encode_uint8_srgb_ABGR, 0, stbir__encode_float_linear_ABGR, stbir__encode_half_float_linear_ABGR },
- { /* RA */ stbir__encode_uint8_srgb2_linearalpha, stbir__encode_uint8_srgb, 0, stbir__encode_float_linear, stbir__encode_half_float_linear },
- { /* AR */ stbir__encode_uint8_srgb2_linearalpha_AR, stbir__encode_uint8_srgb_AR, 0, stbir__encode_float_linear_AR, stbir__encode_half_float_linear_AR }
- };
-
- static stbir__encode_pixels_func * encode_simple_scaled_or_not[2][2]=
- {
- { stbir__encode_uint8_linear_scaled, stbir__encode_uint8_linear }, { stbir__encode_uint16_linear_scaled, stbir__encode_uint16_linear },
- };
-
- static stbir__encode_pixels_func * encode_alphas_scaled_or_not[STBIRI_AR-STBIRI_RGBA+1][2][2]=
- {
- { /* RGBA */ { stbir__encode_uint8_linear_scaled, stbir__encode_uint8_linear }, { stbir__encode_uint16_linear_scaled, stbir__encode_uint16_linear } },
- { /* BGRA */ { stbir__encode_uint8_linear_scaled_BGRA, stbir__encode_uint8_linear_BGRA }, { stbir__encode_uint16_linear_scaled_BGRA, stbir__encode_uint16_linear_BGRA } },
- { /* ARGB */ { stbir__encode_uint8_linear_scaled_ARGB, stbir__encode_uint8_linear_ARGB }, { stbir__encode_uint16_linear_scaled_ARGB, stbir__encode_uint16_linear_ARGB } },
- { /* ABGR */ { stbir__encode_uint8_linear_scaled_ABGR, stbir__encode_uint8_linear_ABGR }, { stbir__encode_uint16_linear_scaled_ABGR, stbir__encode_uint16_linear_ABGR } },
- { /* RA */ { stbir__encode_uint8_linear_scaled, stbir__encode_uint8_linear }, { stbir__encode_uint16_linear_scaled, stbir__encode_uint16_linear } },
- { /* AR */ { stbir__encode_uint8_linear_scaled_AR, stbir__encode_uint8_linear_AR }, { stbir__encode_uint16_linear_scaled_AR, stbir__encode_uint16_linear_AR } }
- };
-
- stbir__decode_pixels_func * decode_pixels = 0;
- stbir__encode_pixels_func * encode_pixels = 0;
- stbir_datatype input_type, output_type;
-
- input_type = resize->input_data_type;
- output_type = resize->output_data_type;
- info->input_data = resize->input_pixels;
- info->input_stride_bytes = resize->input_stride_in_bytes;
- info->output_stride_bytes = resize->output_stride_in_bytes;
-
- // if we're completely point sampling, then we can turn off SRGB
- if ( ( info->horizontal.filter_enum == STBIR_FILTER_POINT_SAMPLE ) && ( info->vertical.filter_enum == STBIR_FILTER_POINT_SAMPLE ) )
- {
- if ( ( ( input_type == STBIR_TYPE_UINT8_SRGB ) || ( input_type == STBIR_TYPE_UINT8_SRGB_ALPHA ) ) &&
- ( ( output_type == STBIR_TYPE_UINT8_SRGB ) || ( output_type == STBIR_TYPE_UINT8_SRGB_ALPHA ) ) )
- {
- input_type = STBIR_TYPE_UINT8;
- output_type = STBIR_TYPE_UINT8;
- }
- }
-
- // recalc the output and input strides
- if ( info->input_stride_bytes == 0 )
- info->input_stride_bytes = info->channels * info->horizontal.scale_info.input_full_size * stbir__type_size[input_type];
-
- if ( info->output_stride_bytes == 0 )
- info->output_stride_bytes = info->channels * info->horizontal.scale_info.output_sub_size * stbir__type_size[output_type];
-
- // calc offset
- info->output_data = ( (char*) resize->output_pixels ) + ( (size_t) info->offset_y * (size_t) resize->output_stride_in_bytes ) + ( info->offset_x * info->channels * stbir__type_size[output_type] );
-
- info->in_pixels_cb = resize->input_cb;
- info->user_data = resize->user_data;
- info->out_pixels_cb = resize->output_cb;
-
- // setup the input format converters
- if ( ( input_type == STBIR_TYPE_UINT8 ) || ( input_type == STBIR_TYPE_UINT16 ) )
- {
- int non_scaled = 0;
-
- // check if we can run unscaled - 0-255.0/0-65535.0 instead of 0-1.0 (which is a tiny bit faster when doing linear 8->8 or 16->16)
- if ( ( !info->alpha_weight ) && ( !info->alpha_unweight ) ) // don't short circuit when alpha weighting (get everything to 0-1.0 as usual)
- if ( ( ( input_type == STBIR_TYPE_UINT8 ) && ( output_type == STBIR_TYPE_UINT8 ) ) || ( ( input_type == STBIR_TYPE_UINT16 ) && ( output_type == STBIR_TYPE_UINT16 ) ) )
- non_scaled = 1;
-
- if ( info->input_pixel_layout_internal <= STBIRI_4CHANNEL )
- decode_pixels = decode_simple_scaled_or_not[ input_type == STBIR_TYPE_UINT16 ][ non_scaled ];
- else
- decode_pixels = decode_alphas_scaled_or_not[ ( info->input_pixel_layout_internal - STBIRI_RGBA ) % ( STBIRI_AR-STBIRI_RGBA+1 ) ][ input_type == STBIR_TYPE_UINT16 ][ non_scaled ];
- }
- else
- {
- if ( info->input_pixel_layout_internal <= STBIRI_4CHANNEL )
- decode_pixels = decode_simple[ input_type - STBIR_TYPE_UINT8_SRGB ];
- else
- decode_pixels = decode_alphas[ ( info->input_pixel_layout_internal - STBIRI_RGBA ) % ( STBIRI_AR-STBIRI_RGBA+1 ) ][ input_type - STBIR_TYPE_UINT8_SRGB ];
- }
-
- // setup the output format converters
- if ( ( output_type == STBIR_TYPE_UINT8 ) || ( output_type == STBIR_TYPE_UINT16 ) )
- {
- int non_scaled = 0;
-
- // check if we can run unscaled - 0-255.0/0-65535.0 instead of 0-1.0 (which is a tiny bit faster when doing linear 8->8 or 16->16)
- if ( ( !info->alpha_weight ) && ( !info->alpha_unweight ) ) // don't short circuit when alpha weighting (get everything to 0-1.0 as usual)
- if ( ( ( input_type == STBIR_TYPE_UINT8 ) && ( output_type == STBIR_TYPE_UINT8 ) ) || ( ( input_type == STBIR_TYPE_UINT16 ) && ( output_type == STBIR_TYPE_UINT16 ) ) )
- non_scaled = 1;
-
- if ( info->output_pixel_layout_internal <= STBIRI_4CHANNEL )
- encode_pixels = encode_simple_scaled_or_not[ output_type == STBIR_TYPE_UINT16 ][ non_scaled ];
- else
- encode_pixels = encode_alphas_scaled_or_not[ ( info->output_pixel_layout_internal - STBIRI_RGBA ) % ( STBIRI_AR-STBIRI_RGBA+1 ) ][ output_type == STBIR_TYPE_UINT16 ][ non_scaled ];
- }
- else
- {
- if ( info->output_pixel_layout_internal <= STBIRI_4CHANNEL )
- encode_pixels = encode_simple[ output_type - STBIR_TYPE_UINT8_SRGB ];
- else
- encode_pixels = encode_alphas[ ( info->output_pixel_layout_internal - STBIRI_RGBA ) % ( STBIRI_AR-STBIRI_RGBA+1 ) ][ output_type - STBIR_TYPE_UINT8_SRGB ];
- }
-
- info->input_type = input_type;
- info->output_type = output_type;
- info->decode_pixels = decode_pixels;
- info->encode_pixels = encode_pixels;
-}
-
-static void stbir__clip( int * outx, int * outsubw, int outw, double * u0, double * u1 )
-{
- double per, adj;
- int over;
-
- // do left/top edge
- if ( *outx < 0 )
- {
- per = ( (double)*outx ) / ( (double)*outsubw ); // is negative
- adj = per * ( *u1 - *u0 );
- *u0 -= adj; // increases u0
- *outx = 0;
- }
-
- // do right/bot edge
- over = outw - ( *outx + *outsubw );
- if ( over < 0 )
- {
- per = ( (double)over ) / ( (double)*outsubw ); // is negative
- adj = per * ( *u1 - *u0 );
- *u1 += adj; // decrease u1
- *outsubw = outw - *outx;
- }
-}
-
-// converts a double to a rational that has less than one float bit of error (returns 0 if unable to do so)
-static int stbir__double_to_rational(double f, stbir_uint32 limit, stbir_uint32 *numer, stbir_uint32 *denom, int limit_denom ) // limit_denom (1) or limit numer (0)
-{
- double err;
- stbir_uint64 top, bot;
- stbir_uint64 numer_last = 0;
- stbir_uint64 denom_last = 1;
- stbir_uint64 numer_estimate = 1;
- stbir_uint64 denom_estimate = 0;
-
- // scale to past float error range
- top = (stbir_uint64)( f * (double)(1 << 25) );
- bot = 1 << 25;
-
- // keep refining, but usually stops in a few loops - usually 5 for bad cases
- for(;;)
- {
- stbir_uint64 est, temp;
-
- // hit limit, break out and do best full range estimate
- if ( ( ( limit_denom ) ? denom_estimate : numer_estimate ) >= limit )
- break;
-
- // is the current error less than 1 bit of a float? if so, we're done
- if ( denom_estimate )
- {
- err = ( (double)numer_estimate / (double)denom_estimate ) - f;
- if ( err < 0.0 ) err = -err;
- if ( err < ( 1.0 / (double)(1<<24) ) )
- {
- // yup, found it
- *numer = (stbir_uint32) numer_estimate;
- *denom = (stbir_uint32) denom_estimate;
- return 1;
- }
- }
-
- // no more refinement bits left? break out and do full range estimate
- if ( bot == 0 )
- break;
-
- // gcd the estimate bits
- est = top / bot;
- temp = top % bot;
- top = bot;
- bot = temp;
-
- // move remainders
- temp = est * denom_estimate + denom_last;
- denom_last = denom_estimate;
- denom_estimate = temp;
-
- // move remainders
- temp = est * numer_estimate + numer_last;
- numer_last = numer_estimate;
- numer_estimate = temp;
- }
-
- // we didn't fine anything good enough for float, use a full range estimate
- if ( limit_denom )
- {
- numer_estimate= (stbir_uint64)( f * (double)limit + 0.5 );
- denom_estimate = limit;
- }
- else
- {
- numer_estimate = limit;
- denom_estimate = (stbir_uint64)( ( (double)limit / f ) + 0.5 );
- }
-
- *numer = (stbir_uint32) numer_estimate;
- *denom = (stbir_uint32) denom_estimate;
-
- err = ( denom_estimate ) ? ( ( (double)(stbir_uint32)numer_estimate / (double)(stbir_uint32)denom_estimate ) - f ) : 1.0;
- if ( err < 0.0 ) err = -err;
- return ( err < ( 1.0 / (double)(1<<24) ) ) ? 1 : 0;
-}
-
-static int stbir__calculate_region_transform( stbir__scale_info * scale_info, int output_full_range, int * output_offset, int output_sub_range, int input_full_range, double input_s0, double input_s1 )
-{
- double output_range, input_range, output_s, input_s, ratio, scale;
-
- input_s = input_s1 - input_s0;
-
- // null area
- if ( ( output_full_range == 0 ) || ( input_full_range == 0 ) ||
- ( output_sub_range == 0 ) || ( input_s <= stbir__small_float ) )
- return 0;
-
- // are either of the ranges completely out of bounds?
- if ( ( *output_offset >= output_full_range ) || ( ( *output_offset + output_sub_range ) <= 0 ) || ( input_s0 >= (1.0f-stbir__small_float) ) || ( input_s1 <= stbir__small_float ) )
- return 0;
-
- output_range = (double)output_full_range;
- input_range = (double)input_full_range;
-
- output_s = ( (double)output_sub_range) / output_range;
-
- // figure out the scaling to use
- ratio = output_s / input_s;
-
- // save scale before clipping
- scale = ( output_range / input_range ) * ratio;
- scale_info->scale = (float)scale;
- scale_info->inv_scale = (float)( 1.0 / scale );
-
- // clip output area to left/right output edges (and adjust input area)
- stbir__clip( output_offset, &output_sub_range, output_full_range, &input_s0, &input_s1 );
-
- // recalc input area
- input_s = input_s1 - input_s0;
-
- // after clipping do we have zero input area?
- if ( input_s <= stbir__small_float )
- return 0;
-
- // calculate and store the starting source offsets in output pixel space
- scale_info->pixel_shift = (float) ( input_s0 * ratio * output_range );
-
- scale_info->scale_is_rational = stbir__double_to_rational( scale, ( scale <= 1.0 ) ? output_full_range : input_full_range, &scale_info->scale_numerator, &scale_info->scale_denominator, ( scale >= 1.0 ) );
-
- scale_info->input_full_size = input_full_range;
- scale_info->output_sub_size = output_sub_range;
-
- return 1;
-}
-
-
-static void stbir__init_and_set_layout( STBIR_RESIZE * resize, stbir_pixel_layout pixel_layout, stbir_datatype data_type )
-{
- resize->input_cb = 0;
- resize->output_cb = 0;
- resize->user_data = resize;
- resize->samplers = 0;
- resize->called_alloc = 0;
- resize->horizontal_filter = STBIR_FILTER_DEFAULT;
- resize->horizontal_filter_kernel = 0; resize->horizontal_filter_support = 0;
- resize->vertical_filter = STBIR_FILTER_DEFAULT;
- resize->vertical_filter_kernel = 0; resize->vertical_filter_support = 0;
- resize->horizontal_edge = STBIR_EDGE_CLAMP;
- resize->vertical_edge = STBIR_EDGE_CLAMP;
- resize->input_s0 = 0; resize->input_t0 = 0; resize->input_s1 = 1; resize->input_t1 = 1;
- resize->output_subx = 0; resize->output_suby = 0; resize->output_subw = resize->output_w; resize->output_subh = resize->output_h;
- resize->input_data_type = data_type;
- resize->output_data_type = data_type;
- resize->input_pixel_layout_public = pixel_layout;
- resize->output_pixel_layout_public = pixel_layout;
- resize->needs_rebuild = 1;
-}
-
-STBIRDEF void stbir_resize_init( STBIR_RESIZE * resize,
- const void *input_pixels, int input_w, int input_h, int input_stride_in_bytes, // stride can be zero
- void *output_pixels, int output_w, int output_h, int output_stride_in_bytes, // stride can be zero
- stbir_pixel_layout pixel_layout, stbir_datatype data_type )
-{
- resize->input_pixels = input_pixels;
- resize->input_w = input_w;
- resize->input_h = input_h;
- resize->input_stride_in_bytes = input_stride_in_bytes;
- resize->output_pixels = output_pixels;
- resize->output_w = output_w;
- resize->output_h = output_h;
- resize->output_stride_in_bytes = output_stride_in_bytes;
- resize->fast_alpha = 0;
-
- stbir__init_and_set_layout( resize, pixel_layout, data_type );
-}
-
-// You can update parameters any time after resize_init
-STBIRDEF void stbir_set_datatypes( STBIR_RESIZE * resize, stbir_datatype input_type, stbir_datatype output_type ) // by default, datatype from resize_init
-{
- resize->input_data_type = input_type;
- resize->output_data_type = output_type;
- if ( ( resize->samplers ) && ( !resize->needs_rebuild ) )
- stbir__update_info_from_resize( resize->samplers, resize );
-}
-
-STBIRDEF void stbir_set_pixel_callbacks( STBIR_RESIZE * resize, stbir_input_callback * input_cb, stbir_output_callback * output_cb ) // no callbacks by default
-{
- resize->input_cb = input_cb;
- resize->output_cb = output_cb;
-
- if ( ( resize->samplers ) && ( !resize->needs_rebuild ) )
- {
- resize->samplers->in_pixels_cb = input_cb;
- resize->samplers->out_pixels_cb = output_cb;
- }
-}
-
-STBIRDEF void stbir_set_user_data( STBIR_RESIZE * resize, void * user_data ) // pass back STBIR_RESIZE* by default
-{
- resize->user_data = user_data;
- if ( ( resize->samplers ) && ( !resize->needs_rebuild ) )
- resize->samplers->user_data = user_data;
-}
-
-STBIRDEF void stbir_set_buffer_ptrs( STBIR_RESIZE * resize, const void * input_pixels, int input_stride_in_bytes, void * output_pixels, int output_stride_in_bytes )
-{
- resize->input_pixels = input_pixels;
- resize->input_stride_in_bytes = input_stride_in_bytes;
- resize->output_pixels = output_pixels;
- resize->output_stride_in_bytes = output_stride_in_bytes;
- if ( ( resize->samplers ) && ( !resize->needs_rebuild ) )
- stbir__update_info_from_resize( resize->samplers, resize );
-}
-
-
-STBIRDEF int stbir_set_edgemodes( STBIR_RESIZE * resize, stbir_edge horizontal_edge, stbir_edge vertical_edge ) // CLAMP by default
-{
- resize->horizontal_edge = horizontal_edge;
- resize->vertical_edge = vertical_edge;
- resize->needs_rebuild = 1;
- return 1;
-}
-
-STBIRDEF int stbir_set_filters( STBIR_RESIZE * resize, stbir_filter horizontal_filter, stbir_filter vertical_filter ) // STBIR_DEFAULT_FILTER_UPSAMPLE/DOWNSAMPLE by default
-{
- resize->horizontal_filter = horizontal_filter;
- resize->vertical_filter = vertical_filter;
- resize->needs_rebuild = 1;
- return 1;
-}
-
-STBIRDEF int stbir_set_filter_callbacks( STBIR_RESIZE * resize, stbir__kernel_callback * horizontal_filter, stbir__support_callback * horizontal_support, stbir__kernel_callback * vertical_filter, stbir__support_callback * vertical_support )
-{
- resize->horizontal_filter_kernel = horizontal_filter; resize->horizontal_filter_support = horizontal_support;
- resize->vertical_filter_kernel = vertical_filter; resize->vertical_filter_support = vertical_support;
- resize->needs_rebuild = 1;
- return 1;
-}
-
-STBIRDEF int stbir_set_pixel_layouts( STBIR_RESIZE * resize, stbir_pixel_layout input_pixel_layout, stbir_pixel_layout output_pixel_layout ) // sets new pixel layouts
-{
- resize->input_pixel_layout_public = input_pixel_layout;
- resize->output_pixel_layout_public = output_pixel_layout;
- resize->needs_rebuild = 1;
- return 1;
-}
-
-
-STBIRDEF int stbir_set_non_pm_alpha_speed_over_quality( STBIR_RESIZE * resize, int non_pma_alpha_speed_over_quality ) // sets alpha speed
-{
- resize->fast_alpha = non_pma_alpha_speed_over_quality;
- resize->needs_rebuild = 1;
- return 1;
-}
-
-STBIRDEF int stbir_set_input_subrect( STBIR_RESIZE * resize, double s0, double t0, double s1, double t1 ) // sets input region (full region by default)
-{
- resize->input_s0 = s0;
- resize->input_t0 = t0;
- resize->input_s1 = s1;
- resize->input_t1 = t1;
- resize->needs_rebuild = 1;
-
- // are we inbounds?
- if ( ( s1 < stbir__small_float ) || ( (s1-s0) < stbir__small_float ) ||
- ( t1 < stbir__small_float ) || ( (t1-t0) < stbir__small_float ) ||
- ( s0 > (1.0f-stbir__small_float) ) ||
- ( t0 > (1.0f-stbir__small_float) ) )
- return 0;
-
- return 1;
-}
-
-STBIRDEF int stbir_set_output_pixel_subrect( STBIR_RESIZE * resize, int subx, int suby, int subw, int subh ) // sets input region (full region by default)
-{
- resize->output_subx = subx;
- resize->output_suby = suby;
- resize->output_subw = subw;
- resize->output_subh = subh;
- resize->needs_rebuild = 1;
-
- // are we inbounds?
- if ( ( subx >= resize->output_w ) || ( ( subx + subw ) <= 0 ) || ( suby >= resize->output_h ) || ( ( suby + subh ) <= 0 ) || ( subw == 0 ) || ( subh == 0 ) )
- return 0;
-
- return 1;
-}
-
-STBIRDEF int stbir_set_pixel_subrect( STBIR_RESIZE * resize, int subx, int suby, int subw, int subh ) // sets both regions (full regions by default)
-{
- double s0, t0, s1, t1;
-
- s0 = ( (double)subx ) / ( (double)resize->output_w );
- t0 = ( (double)suby ) / ( (double)resize->output_h );
- s1 = ( (double)(subx+subw) ) / ( (double)resize->output_w );
- t1 = ( (double)(suby+subh) ) / ( (double)resize->output_h );
-
- resize->input_s0 = s0;
- resize->input_t0 = t0;
- resize->input_s1 = s1;
- resize->input_t1 = t1;
- resize->output_subx = subx;
- resize->output_suby = suby;
- resize->output_subw = subw;
- resize->output_subh = subh;
- resize->needs_rebuild = 1;
-
- // are we inbounds?
- if ( ( subx >= resize->output_w ) || ( ( subx + subw ) <= 0 ) || ( suby >= resize->output_h ) || ( ( suby + subh ) <= 0 ) || ( subw == 0 ) || ( subh == 0 ) )
- return 0;
-
- return 1;
-}
-
-static int stbir__perform_build( STBIR_RESIZE * resize, int splits )
-{
- stbir__contributors conservative = { 0, 0 };
- stbir__sampler horizontal, vertical;
- int new_output_subx, new_output_suby;
- stbir__info * out_info;
- #ifdef STBIR_PROFILE
- stbir__info profile_infod; // used to contain building profile info before everything is allocated
- stbir__info * profile_info = &profile_infod;
- #endif
-
- // have we already built the samplers?
- if ( resize->samplers )
- return 0;
-
- #define STBIR_RETURN_ERROR_AND_ASSERT( exp ) STBIR_ASSERT( !(exp) ); if (exp) return 0;
- STBIR_RETURN_ERROR_AND_ASSERT( (unsigned)resize->horizontal_filter >= STBIR_FILTER_OTHER)
- STBIR_RETURN_ERROR_AND_ASSERT( (unsigned)resize->vertical_filter >= STBIR_FILTER_OTHER)
- #undef STBIR_RETURN_ERROR_AND_ASSERT
-
- if ( splits <= 0 )
- return 0;
-
- STBIR_PROFILE_BUILD_FIRST_START( build );
-
- new_output_subx = resize->output_subx;
- new_output_suby = resize->output_suby;
-
- // do horizontal clip and scale calcs
- if ( !stbir__calculate_region_transform( &horizontal.scale_info, resize->output_w, &new_output_subx, resize->output_subw, resize->input_w, resize->input_s0, resize->input_s1 ) )
- return 0;
-
- // do vertical clip and scale calcs
- if ( !stbir__calculate_region_transform( &vertical.scale_info, resize->output_h, &new_output_suby, resize->output_subh, resize->input_h, resize->input_t0, resize->input_t1 ) )
- return 0;
-
- // if nothing to do, just return
- if ( ( horizontal.scale_info.output_sub_size == 0 ) || ( vertical.scale_info.output_sub_size == 0 ) )
- return 0;
-
- stbir__set_sampler(&horizontal, resize->horizontal_filter, resize->horizontal_filter_kernel, resize->horizontal_filter_support, resize->horizontal_edge, &horizontal.scale_info, 1, resize->user_data );
- stbir__get_conservative_extents( &horizontal, &conservative, resize->user_data );
- stbir__set_sampler(&vertical, resize->vertical_filter, resize->vertical_filter_kernel, resize->vertical_filter_support, resize->vertical_edge, &vertical.scale_info, 0, resize->user_data );
-
- if ( ( vertical.scale_info.output_sub_size / splits ) < STBIR_FORCE_MINIMUM_SCANLINES_FOR_SPLITS ) // each split should be a minimum of 4 scanlines (handwavey choice)
- {
- splits = vertical.scale_info.output_sub_size / STBIR_FORCE_MINIMUM_SCANLINES_FOR_SPLITS;
- if ( splits == 0 ) splits = 1;
- }
-
- STBIR_PROFILE_BUILD_START( alloc );
- out_info = stbir__alloc_internal_mem_and_build_samplers( &horizontal, &vertical, &conservative, resize->input_pixel_layout_public, resize->output_pixel_layout_public, splits, new_output_subx, new_output_suby, resize->fast_alpha, resize->user_data STBIR_ONLY_PROFILE_BUILD_SET_INFO );
- STBIR_PROFILE_BUILD_END( alloc );
- STBIR_PROFILE_BUILD_END( build );
-
- if ( out_info )
- {
- resize->splits = splits;
- resize->samplers = out_info;
- resize->needs_rebuild = 0;
- #ifdef STBIR_PROFILE
- STBIR_MEMCPY( &out_info->profile, &profile_infod.profile, sizeof( out_info->profile ) );
- #endif
-
- // update anything that can be changed without recalcing samplers
- stbir__update_info_from_resize( out_info, resize );
-
- return splits;
- }
-
- return 0;
-}
-
-void stbir_free_samplers( STBIR_RESIZE * resize )
-{
- if ( resize->samplers )
- {
- stbir__free_internal_mem( resize->samplers );
- resize->samplers = 0;
- resize->called_alloc = 0;
- }
-}
-
-STBIRDEF int stbir_build_samplers_with_splits( STBIR_RESIZE * resize, int splits )
-{
- if ( ( resize->samplers == 0 ) || ( resize->needs_rebuild ) )
- {
- if ( resize->samplers )
- stbir_free_samplers( resize );
-
- resize->called_alloc = 1;
- return stbir__perform_build( resize, splits );
- }
-
- STBIR_PROFILE_BUILD_CLEAR( resize->samplers );
-
- return 1;
-}
-
-STBIRDEF int stbir_build_samplers( STBIR_RESIZE * resize )
-{
- return stbir_build_samplers_with_splits( resize, 1 );
-}
-
-STBIRDEF int stbir_resize_extended( STBIR_RESIZE * resize )
-{
- int result;
-
- if ( ( resize->samplers == 0 ) || ( resize->needs_rebuild ) )
- {
- int alloc_state = resize->called_alloc; // remember allocated state
-
- if ( resize->samplers )
- {
- stbir__free_internal_mem( resize->samplers );
- resize->samplers = 0;
- }
-
- if ( !stbir_build_samplers( resize ) )
- return 0;
-
- resize->called_alloc = alloc_state;
-
- // if build_samplers succeeded (above), but there are no samplers set, then
- // the area to stretch into was zero pixels, so don't do anything and return
- // success
- if ( resize->samplers == 0 )
- return 1;
- }
- else
- {
- // didn't build anything - clear it
- STBIR_PROFILE_BUILD_CLEAR( resize->samplers );
- }
-
- // do resize
- result = stbir__perform_resize( resize->samplers, 0, resize->splits );
-
- // if we alloced, then free
- if ( !resize->called_alloc )
- {
- stbir_free_samplers( resize );
- resize->samplers = 0;
- }
-
- return result;
-}
-
-STBIRDEF int stbir_resize_extended_split( STBIR_RESIZE * resize, int split_start, int split_count )
-{
- STBIR_ASSERT( resize->samplers );
-
- // if we're just doing the whole thing, call full
- if ( ( split_start == -1 ) || ( ( split_start == 0 ) && ( split_count == resize->splits ) ) )
- return stbir_resize_extended( resize );
-
- // you **must** build samplers first when using split resize
- if ( ( resize->samplers == 0 ) || ( resize->needs_rebuild ) )
- return 0;
-
- if ( ( split_start >= resize->splits ) || ( split_start < 0 ) || ( ( split_start + split_count ) > resize->splits ) || ( split_count <= 0 ) )
- return 0;
-
- // do resize
- return stbir__perform_resize( resize->samplers, split_start, split_count );
-}
-
-
-static void * stbir_quick_resize_helper( const void *input_pixels , int input_w , int input_h, int input_stride_in_bytes,
- void *output_pixels, int output_w, int output_h, int output_stride_in_bytes,
- stbir_pixel_layout pixel_layout, stbir_datatype data_type, stbir_edge edge, stbir_filter filter )
-{
- STBIR_RESIZE resize;
- int scanline_output_in_bytes;
- int positive_output_stride_in_bytes;
- void * start_ptr;
- void * free_ptr;
-
- scanline_output_in_bytes = output_w * stbir__type_size[ data_type ] * stbir__pixel_channels[ stbir__pixel_layout_convert_public_to_internal[ pixel_layout ] ];
- if ( scanline_output_in_bytes == 0 )
- return 0;
-
- // if zero stride, use scanline output
- if ( output_stride_in_bytes == 0 )
- output_stride_in_bytes = scanline_output_in_bytes;
-
- // abs value for inverted images (negative pitches)
- positive_output_stride_in_bytes = output_stride_in_bytes;
- if ( positive_output_stride_in_bytes < 0 )
- positive_output_stride_in_bytes = -positive_output_stride_in_bytes;
-
- // is the requested stride smaller than the scanline output? if so, just fail
- if ( positive_output_stride_in_bytes < scanline_output_in_bytes )
- return 0;
-
- start_ptr = output_pixels;
- free_ptr = 0; // no free pointer, since they passed buffer to use
-
- // did they pass a zero for the dest? if so, allocate the buffer
- if ( output_pixels == 0 )
- {
- size_t size;
- char * ptr;
-
- size = (size_t)positive_output_stride_in_bytes * (size_t)output_h;
- if ( size == 0 )
- return 0;
-
- ptr = (char*) STBIR_MALLOC( size, 0 );
- if ( ptr == 0 )
- return 0;
-
- free_ptr = ptr;
-
- // point at the last scanline, if they requested a flipped image
- if ( output_stride_in_bytes < 0 )
- start_ptr = ptr + ( (size_t)positive_output_stride_in_bytes * (size_t)( output_h - 1 ) );
- else
- start_ptr = ptr;
- }
-
- // ok, now do the resize
- stbir_resize_init( &resize,
- input_pixels, input_w, input_h, input_stride_in_bytes,
- start_ptr, output_w, output_h, output_stride_in_bytes,
- pixel_layout, data_type );
-
- resize.horizontal_edge = edge;
- resize.vertical_edge = edge;
- resize.horizontal_filter = filter;
- resize.vertical_filter = filter;
-
- if ( !stbir_resize_extended( &resize ) )
- {
- if ( free_ptr )
- STBIR_FREE( free_ptr, 0 );
- return 0;
- }
-
- return (free_ptr) ? free_ptr : start_ptr;
-}
-
-
-
-STBIRDEF unsigned char * stbir_resize_uint8_linear( const unsigned char *input_pixels , int input_w , int input_h, int input_stride_in_bytes,
- unsigned char *output_pixels, int output_w, int output_h, int output_stride_in_bytes,
- stbir_pixel_layout pixel_layout )
-{
- return (unsigned char *) stbir_quick_resize_helper( input_pixels , input_w , input_h, input_stride_in_bytes,
- output_pixels, output_w, output_h, output_stride_in_bytes,
- pixel_layout, STBIR_TYPE_UINT8, STBIR_EDGE_CLAMP, STBIR_FILTER_DEFAULT );
-}
-
-STBIRDEF unsigned char * stbir_resize_uint8_srgb( const unsigned char *input_pixels , int input_w , int input_h, int input_stride_in_bytes,
- unsigned char *output_pixels, int output_w, int output_h, int output_stride_in_bytes,
- stbir_pixel_layout pixel_layout )
-{
- return (unsigned char *) stbir_quick_resize_helper( input_pixels , input_w , input_h, input_stride_in_bytes,
- output_pixels, output_w, output_h, output_stride_in_bytes,
- pixel_layout, STBIR_TYPE_UINT8_SRGB, STBIR_EDGE_CLAMP, STBIR_FILTER_DEFAULT );
-}
-
-
-STBIRDEF float * stbir_resize_float_linear( const float *input_pixels , int input_w , int input_h, int input_stride_in_bytes,
- float *output_pixels, int output_w, int output_h, int output_stride_in_bytes,
- stbir_pixel_layout pixel_layout )
-{
- return (float *) stbir_quick_resize_helper( input_pixels , input_w , input_h, input_stride_in_bytes,
- output_pixels, output_w, output_h, output_stride_in_bytes,
- pixel_layout, STBIR_TYPE_FLOAT, STBIR_EDGE_CLAMP, STBIR_FILTER_DEFAULT );
-}
-
-
-STBIRDEF void * stbir_resize( const void *input_pixels , int input_w , int input_h, int input_stride_in_bytes,
- void *output_pixels, int output_w, int output_h, int output_stride_in_bytes,
- stbir_pixel_layout pixel_layout, stbir_datatype data_type,
- stbir_edge edge, stbir_filter filter )
-{
- return (void *) stbir_quick_resize_helper( input_pixels , input_w , input_h, input_stride_in_bytes,
- output_pixels, output_w, output_h, output_stride_in_bytes,
- pixel_layout, data_type, edge, filter );
-}
-
-#ifdef STBIR_PROFILE
-
-STBIRDEF void stbir_resize_build_profile_info( STBIR_PROFILE_INFO * info, STBIR_RESIZE const * resize )
-{
- static char const * bdescriptions[6] = { "Building", "Allocating", "Horizontal sampler", "Vertical sampler", "Coefficient cleanup", "Coefficient piovot" } ;
- stbir__info* samp = resize->samplers;
- int i;
-
- typedef int testa[ (STBIR__ARRAY_SIZE( bdescriptions ) == (STBIR__ARRAY_SIZE( samp->profile.array )-1) )?1:-1];
- typedef int testb[ (sizeof( samp->profile.array ) == (sizeof(samp->profile.named)) )?1:-1];
- typedef int testc[ (sizeof( info->clocks ) >= (sizeof(samp->profile.named)) )?1:-1];
-
- for( i = 0 ; i < STBIR__ARRAY_SIZE( bdescriptions ) ; i++)
- info->clocks[i] = samp->profile.array[i+1];
-
- info->total_clocks = samp->profile.named.total;
- info->descriptions = bdescriptions;
- info->count = STBIR__ARRAY_SIZE( bdescriptions );
-}
-
-STBIRDEF void stbir_resize_split_profile_info( STBIR_PROFILE_INFO * info, STBIR_RESIZE const * resize, int split_start, int split_count )
-{
- static char const * descriptions[7] = { "Looping", "Vertical sampling", "Horizontal sampling", "Scanline input", "Scanline output", "Alpha weighting", "Alpha unweighting" };
- stbir__per_split_info * split_info;
- int s, i;
-
- typedef int testa[ (STBIR__ARRAY_SIZE( descriptions ) == (STBIR__ARRAY_SIZE( split_info->profile.array )-1) )?1:-1];
- typedef int testb[ (sizeof( split_info->profile.array ) == (sizeof(split_info->profile.named)) )?1:-1];
- typedef int testc[ (sizeof( info->clocks ) >= (sizeof(split_info->profile.named)) )?1:-1];
-
- if ( split_start == -1 )
- {
- split_start = 0;
- split_count = resize->samplers->splits;
- }
-
- if ( ( split_start >= resize->splits ) || ( split_start < 0 ) || ( ( split_start + split_count ) > resize->splits ) || ( split_count <= 0 ) )
- {
- info->total_clocks = 0;
- info->descriptions = 0;
- info->count = 0;
- return;
- }
-
- split_info = resize->samplers->split_info + split_start;
-
- // sum up the profile from all the splits
- for( i = 0 ; i < STBIR__ARRAY_SIZE( descriptions ) ; i++ )
- {
- stbir_uint64 sum = 0;
- for( s = 0 ; s < split_count ; s++ )
- sum += split_info[s].profile.array[i+1];
- info->clocks[i] = sum;
- }
-
- info->total_clocks = split_info->profile.named.total;
- info->descriptions = descriptions;
- info->count = STBIR__ARRAY_SIZE( descriptions );
-}
-
-STBIRDEF void stbir_resize_extended_profile_info( STBIR_PROFILE_INFO * info, STBIR_RESIZE const * resize )
-{
- stbir_resize_split_profile_info( info, resize, -1, 0 );
-}
-
-#endif // STBIR_PROFILE
-
-#undef STBIR_BGR
-#undef STBIR_1CHANNEL
-#undef STBIR_2CHANNEL
-#undef STBIR_RGB
-#undef STBIR_RGBA
-#undef STBIR_4CHANNEL
-#undef STBIR_BGRA
-#undef STBIR_ARGB
-#undef STBIR_ABGR
-#undef STBIR_RA
-#undef STBIR_AR
-#undef STBIR_RGBA_PM
-#undef STBIR_BGRA_PM
-#undef STBIR_ARGB_PM
-#undef STBIR_ABGR_PM
-#undef STBIR_RA_PM
-#undef STBIR_AR_PM
-
-#endif // STB_IMAGE_RESIZE_IMPLEMENTATION
-
-#else // STB_IMAGE_RESIZE_HORIZONTALS&STB_IMAGE_RESIZE_DO_VERTICALS
-
-// we reinclude the header file to define all the horizontal functions
-// specializing each function for the number of coeffs is 20-40% faster *OVERALL*
-
-// by including the header file again this way, we can still debug the functions
-
-#define STBIR_strs_join2( start, mid, end ) start##mid##end
-#define STBIR_strs_join1( start, mid, end ) STBIR_strs_join2( start, mid, end )
-
-#define STBIR_strs_join24( start, mid1, mid2, end ) start##mid1##mid2##end
-#define STBIR_strs_join14( start, mid1, mid2, end ) STBIR_strs_join24( start, mid1, mid2, end )
-
-#ifdef STB_IMAGE_RESIZE_DO_CODERS
-
-#ifdef stbir__decode_suffix
-#define STBIR__CODER_NAME( name ) STBIR_strs_join1( name, _, stbir__decode_suffix )
-#else
-#define STBIR__CODER_NAME( name ) name
-#endif
-
-#ifdef stbir__decode_swizzle
-#define stbir__decode_simdf8_flip(reg) STBIR_strs_join1( STBIR_strs_join1( STBIR_strs_join1( STBIR_strs_join1( stbir__simdf8_0123to,stbir__decode_order0,stbir__decode_order1),stbir__decode_order2,stbir__decode_order3),stbir__decode_order0,stbir__decode_order1),stbir__decode_order2,stbir__decode_order3)(reg, reg)
-#define stbir__decode_simdf4_flip(reg) STBIR_strs_join1( STBIR_strs_join1( stbir__simdf_0123to,stbir__decode_order0,stbir__decode_order1),stbir__decode_order2,stbir__decode_order3)(reg, reg)
-#define stbir__encode_simdf8_unflip(reg) STBIR_strs_join1( STBIR_strs_join1( STBIR_strs_join1( STBIR_strs_join1( stbir__simdf8_0123to,stbir__encode_order0,stbir__encode_order1),stbir__encode_order2,stbir__encode_order3),stbir__encode_order0,stbir__encode_order1),stbir__encode_order2,stbir__encode_order3)(reg, reg)
-#define stbir__encode_simdf4_unflip(reg) STBIR_strs_join1( STBIR_strs_join1( stbir__simdf_0123to,stbir__encode_order0,stbir__encode_order1),stbir__encode_order2,stbir__encode_order3)(reg, reg)
-#else
-#define stbir__decode_order0 0
-#define stbir__decode_order1 1
-#define stbir__decode_order2 2
-#define stbir__decode_order3 3
-#define stbir__encode_order0 0
-#define stbir__encode_order1 1
-#define stbir__encode_order2 2
-#define stbir__encode_order3 3
-#define stbir__decode_simdf8_flip(reg)
-#define stbir__decode_simdf4_flip(reg)
-#define stbir__encode_simdf8_unflip(reg)
-#define stbir__encode_simdf4_unflip(reg)
-#endif
-
-#ifdef STBIR_SIMD8
-#define stbir__encode_simdfX_unflip stbir__encode_simdf8_unflip
-#else
-#define stbir__encode_simdfX_unflip stbir__encode_simdf4_unflip
-#endif
-
-static float * STBIR__CODER_NAME( stbir__decode_uint8_linear_scaled )( float * decodep, int width_times_channels, void const * inputp )
-{
- float STBIR_STREAMOUT_PTR( * ) decode = decodep;
- float * decode_end = (float*) decode + width_times_channels;
- unsigned char const * input = (unsigned char const*)inputp;
-
- #ifdef STBIR_SIMD
- unsigned char const * end_input_m16 = input + width_times_channels - 16;
- if ( width_times_channels >= 16 )
- {
- decode_end -= 16;
- STBIR_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- #ifdef STBIR_SIMD8
- stbir__simdi i; stbir__simdi8 o0,o1;
- stbir__simdf8 of0, of1;
- STBIR_NO_UNROLL(decode);
- stbir__simdi_load( i, input );
- stbir__simdi8_expand_u8_to_u32( o0, o1, i );
- stbir__simdi8_convert_i32_to_float( of0, o0 );
- stbir__simdi8_convert_i32_to_float( of1, o1 );
- stbir__simdf8_mult( of0, of0, STBIR_max_uint8_as_float_inverted8);
- stbir__simdf8_mult( of1, of1, STBIR_max_uint8_as_float_inverted8);
- stbir__decode_simdf8_flip( of0 );
- stbir__decode_simdf8_flip( of1 );
- stbir__simdf8_store( decode + 0, of0 );
- stbir__simdf8_store( decode + 8, of1 );
- #else
- stbir__simdi i, o0, o1, o2, o3;
- stbir__simdf of0, of1, of2, of3;
- STBIR_NO_UNROLL(decode);
- stbir__simdi_load( i, input );
- stbir__simdi_expand_u8_to_u32( o0,o1,o2,o3,i);
- stbir__simdi_convert_i32_to_float( of0, o0 );
- stbir__simdi_convert_i32_to_float( of1, o1 );
- stbir__simdi_convert_i32_to_float( of2, o2 );
- stbir__simdi_convert_i32_to_float( of3, o3 );
- stbir__simdf_mult( of0, of0, STBIR__CONSTF(STBIR_max_uint8_as_float_inverted) );
- stbir__simdf_mult( of1, of1, STBIR__CONSTF(STBIR_max_uint8_as_float_inverted) );
- stbir__simdf_mult( of2, of2, STBIR__CONSTF(STBIR_max_uint8_as_float_inverted) );
- stbir__simdf_mult( of3, of3, STBIR__CONSTF(STBIR_max_uint8_as_float_inverted) );
- stbir__decode_simdf4_flip( of0 );
- stbir__decode_simdf4_flip( of1 );
- stbir__decode_simdf4_flip( of2 );
- stbir__decode_simdf4_flip( of3 );
- stbir__simdf_store( decode + 0, of0 );
- stbir__simdf_store( decode + 4, of1 );
- stbir__simdf_store( decode + 8, of2 );
- stbir__simdf_store( decode + 12, of3 );
- #endif
- decode += 16;
- input += 16;
- if ( decode <= decode_end )
- continue;
- if ( decode == ( decode_end + 16 ) )
- break;
- decode = decode_end; // backup and do last couple
- input = end_input_m16;
- }
- return decode_end + 16;
- }
- #endif
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- decode += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( decode <= decode_end )
- {
- STBIR_SIMD_NO_UNROLL(decode);
- decode[0-4] = ((float)(input[stbir__decode_order0])) * stbir__max_uint8_as_float_inverted;
- decode[1-4] = ((float)(input[stbir__decode_order1])) * stbir__max_uint8_as_float_inverted;
- decode[2-4] = ((float)(input[stbir__decode_order2])) * stbir__max_uint8_as_float_inverted;
- decode[3-4] = ((float)(input[stbir__decode_order3])) * stbir__max_uint8_as_float_inverted;
- decode += 4;
- input += 4;
- }
- decode -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( decode < decode_end )
- {
- STBIR_NO_UNROLL(decode);
- decode[0] = ((float)(input[stbir__decode_order0])) * stbir__max_uint8_as_float_inverted;
- #if stbir__coder_min_num >= 2
- decode[1] = ((float)(input[stbir__decode_order1])) * stbir__max_uint8_as_float_inverted;
- #endif
- #if stbir__coder_min_num >= 3
- decode[2] = ((float)(input[stbir__decode_order2])) * stbir__max_uint8_as_float_inverted;
- #endif
- decode += stbir__coder_min_num;
- input += stbir__coder_min_num;
- }
- #endif
-
- return decode_end;
-}
-
-static void STBIR__CODER_NAME( stbir__encode_uint8_linear_scaled )( void * outputp, int width_times_channels, float const * encode )
-{
- unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char *) outputp;
- unsigned char * end_output = ( (unsigned char *) output ) + width_times_channels;
-
- #ifdef STBIR_SIMD
- if ( width_times_channels >= stbir__simdfX_float_count*2 )
- {
- float const * end_encode_m8 = encode + width_times_channels - stbir__simdfX_float_count*2;
- end_output -= stbir__simdfX_float_count*2;
- STBIR_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- stbir__simdfX e0, e1;
- stbir__simdi i;
- STBIR_SIMD_NO_UNROLL(encode);
- stbir__simdfX_madd_mem( e0, STBIR_simd_point5X, STBIR_max_uint8_as_floatX, encode );
- stbir__simdfX_madd_mem( e1, STBIR_simd_point5X, STBIR_max_uint8_as_floatX, encode+stbir__simdfX_float_count );
- stbir__encode_simdfX_unflip( e0 );
- stbir__encode_simdfX_unflip( e1 );
- #ifdef STBIR_SIMD8
- stbir__simdf8_pack_to_16bytes( i, e0, e1 );
- stbir__simdi_store( output, i );
- #else
- stbir__simdf_pack_to_8bytes( i, e0, e1 );
- stbir__simdi_store2( output, i );
- #endif
- encode += stbir__simdfX_float_count*2;
- output += stbir__simdfX_float_count*2;
- if ( output <= end_output )
- continue;
- if ( output == ( end_output + stbir__simdfX_float_count*2 ) )
- break;
- output = end_output; // backup and do last couple
- encode = end_encode_m8;
- }
- return;
- }
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_NO_UNROLL_LOOP_START
- while( output <= end_output )
- {
- stbir__simdf e0;
- stbir__simdi i0;
- STBIR_NO_UNROLL(encode);
- stbir__simdf_load( e0, encode );
- stbir__simdf_madd( e0, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint8_as_float), e0 );
- stbir__encode_simdf4_unflip( e0 );
- stbir__simdf_pack_to_8bytes( i0, e0, e0 ); // only use first 4
- *(int*)(output-4) = stbir__simdi_to_int( i0 );
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( output < end_output )
- {
- stbir__simdf e0;
- STBIR_NO_UNROLL(encode);
- stbir__simdf_madd1_mem( e0, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint8_as_float), encode+stbir__encode_order0 ); output[0] = stbir__simdf_convert_float_to_uint8( e0 );
- #if stbir__coder_min_num >= 2
- stbir__simdf_madd1_mem( e0, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint8_as_float), encode+stbir__encode_order1 ); output[1] = stbir__simdf_convert_float_to_uint8( e0 );
- #endif
- #if stbir__coder_min_num >= 3
- stbir__simdf_madd1_mem( e0, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint8_as_float), encode+stbir__encode_order2 ); output[2] = stbir__simdf_convert_float_to_uint8( e0 );
- #endif
- output += stbir__coder_min_num;
- encode += stbir__coder_min_num;
- }
- #endif
-
- #else
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- while( output <= end_output )
- {
- float f;
- f = encode[stbir__encode_order0] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[0-4] = (unsigned char)f;
- f = encode[stbir__encode_order1] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[1-4] = (unsigned char)f;
- f = encode[stbir__encode_order2] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[2-4] = (unsigned char)f;
- f = encode[stbir__encode_order3] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[3-4] = (unsigned char)f;
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( output < end_output )
- {
- float f;
- STBIR_NO_UNROLL(encode);
- f = encode[stbir__encode_order0] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[0] = (unsigned char)f;
- #if stbir__coder_min_num >= 2
- f = encode[stbir__encode_order1] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[1] = (unsigned char)f;
- #endif
- #if stbir__coder_min_num >= 3
- f = encode[stbir__encode_order2] * stbir__max_uint8_as_float + 0.5f; STBIR_CLAMP(f, 0, 255); output[2] = (unsigned char)f;
- #endif
- output += stbir__coder_min_num;
- encode += stbir__coder_min_num;
- }
- #endif
- #endif
-}
-
-static float * STBIR__CODER_NAME(stbir__decode_uint8_linear)( float * decodep, int width_times_channels, void const * inputp )
-{
- float STBIR_STREAMOUT_PTR( * ) decode = decodep;
- float * decode_end = (float*) decode + width_times_channels;
- unsigned char const * input = (unsigned char const*)inputp;
-
- #ifdef STBIR_SIMD
- unsigned char const * end_input_m16 = input + width_times_channels - 16;
- if ( width_times_channels >= 16 )
- {
- decode_end -= 16;
- STBIR_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- #ifdef STBIR_SIMD8
- stbir__simdi i; stbir__simdi8 o0,o1;
- stbir__simdf8 of0, of1;
- STBIR_NO_UNROLL(decode);
- stbir__simdi_load( i, input );
- stbir__simdi8_expand_u8_to_u32( o0, o1, i );
- stbir__simdi8_convert_i32_to_float( of0, o0 );
- stbir__simdi8_convert_i32_to_float( of1, o1 );
- stbir__decode_simdf8_flip( of0 );
- stbir__decode_simdf8_flip( of1 );
- stbir__simdf8_store( decode + 0, of0 );
- stbir__simdf8_store( decode + 8, of1 );
- #else
- stbir__simdi i, o0, o1, o2, o3;
- stbir__simdf of0, of1, of2, of3;
- STBIR_NO_UNROLL(decode);
- stbir__simdi_load( i, input );
- stbir__simdi_expand_u8_to_u32( o0,o1,o2,o3,i);
- stbir__simdi_convert_i32_to_float( of0, o0 );
- stbir__simdi_convert_i32_to_float( of1, o1 );
- stbir__simdi_convert_i32_to_float( of2, o2 );
- stbir__simdi_convert_i32_to_float( of3, o3 );
- stbir__decode_simdf4_flip( of0 );
- stbir__decode_simdf4_flip( of1 );
- stbir__decode_simdf4_flip( of2 );
- stbir__decode_simdf4_flip( of3 );
- stbir__simdf_store( decode + 0, of0 );
- stbir__simdf_store( decode + 4, of1 );
- stbir__simdf_store( decode + 8, of2 );
- stbir__simdf_store( decode + 12, of3 );
-#endif
- decode += 16;
- input += 16;
- if ( decode <= decode_end )
- continue;
- if ( decode == ( decode_end + 16 ) )
- break;
- decode = decode_end; // backup and do last couple
- input = end_input_m16;
- }
- return decode_end + 16;
- }
- #endif
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- decode += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( decode <= decode_end )
- {
- STBIR_SIMD_NO_UNROLL(decode);
- decode[0-4] = ((float)(input[stbir__decode_order0]));
- decode[1-4] = ((float)(input[stbir__decode_order1]));
- decode[2-4] = ((float)(input[stbir__decode_order2]));
- decode[3-4] = ((float)(input[stbir__decode_order3]));
- decode += 4;
- input += 4;
- }
- decode -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( decode < decode_end )
- {
- STBIR_NO_UNROLL(decode);
- decode[0] = ((float)(input[stbir__decode_order0]));
- #if stbir__coder_min_num >= 2
- decode[1] = ((float)(input[stbir__decode_order1]));
- #endif
- #if stbir__coder_min_num >= 3
- decode[2] = ((float)(input[stbir__decode_order2]));
- #endif
- decode += stbir__coder_min_num;
- input += stbir__coder_min_num;
- }
- #endif
- return decode_end;
-}
-
-static void STBIR__CODER_NAME( stbir__encode_uint8_linear )( void * outputp, int width_times_channels, float const * encode )
-{
- unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char *) outputp;
- unsigned char * end_output = ( (unsigned char *) output ) + width_times_channels;
-
- #ifdef STBIR_SIMD
- if ( width_times_channels >= stbir__simdfX_float_count*2 )
- {
- float const * end_encode_m8 = encode + width_times_channels - stbir__simdfX_float_count*2;
- end_output -= stbir__simdfX_float_count*2;
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- stbir__simdfX e0, e1;
- stbir__simdi i;
- STBIR_SIMD_NO_UNROLL(encode);
- stbir__simdfX_add_mem( e0, STBIR_simd_point5X, encode );
- stbir__simdfX_add_mem( e1, STBIR_simd_point5X, encode+stbir__simdfX_float_count );
- stbir__encode_simdfX_unflip( e0 );
- stbir__encode_simdfX_unflip( e1 );
- #ifdef STBIR_SIMD8
- stbir__simdf8_pack_to_16bytes( i, e0, e1 );
- stbir__simdi_store( output, i );
- #else
- stbir__simdf_pack_to_8bytes( i, e0, e1 );
- stbir__simdi_store2( output, i );
- #endif
- encode += stbir__simdfX_float_count*2;
- output += stbir__simdfX_float_count*2;
- if ( output <= end_output )
- continue;
- if ( output == ( end_output + stbir__simdfX_float_count*2 ) )
- break;
- output = end_output; // backup and do last couple
- encode = end_encode_m8;
- }
- return;
- }
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_NO_UNROLL_LOOP_START
- while( output <= end_output )
- {
- stbir__simdf e0;
- stbir__simdi i0;
- STBIR_NO_UNROLL(encode);
- stbir__simdf_load( e0, encode );
- stbir__simdf_add( e0, STBIR__CONSTF(STBIR_simd_point5), e0 );
- stbir__encode_simdf4_unflip( e0 );
- stbir__simdf_pack_to_8bytes( i0, e0, e0 ); // only use first 4
- *(int*)(output-4) = stbir__simdi_to_int( i0 );
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- #else
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- while( output <= end_output )
- {
- float f;
- f = encode[stbir__encode_order0] + 0.5f; STBIR_CLAMP(f, 0, 255); output[0-4] = (unsigned char)f;
- f = encode[stbir__encode_order1] + 0.5f; STBIR_CLAMP(f, 0, 255); output[1-4] = (unsigned char)f;
- f = encode[stbir__encode_order2] + 0.5f; STBIR_CLAMP(f, 0, 255); output[2-4] = (unsigned char)f;
- f = encode[stbir__encode_order3] + 0.5f; STBIR_CLAMP(f, 0, 255); output[3-4] = (unsigned char)f;
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( output < end_output )
- {
- float f;
- STBIR_NO_UNROLL(encode);
- f = encode[stbir__encode_order0] + 0.5f; STBIR_CLAMP(f, 0, 255); output[0] = (unsigned char)f;
- #if stbir__coder_min_num >= 2
- f = encode[stbir__encode_order1] + 0.5f; STBIR_CLAMP(f, 0, 255); output[1] = (unsigned char)f;
- #endif
- #if stbir__coder_min_num >= 3
- f = encode[stbir__encode_order2] + 0.5f; STBIR_CLAMP(f, 0, 255); output[2] = (unsigned char)f;
- #endif
- output += stbir__coder_min_num;
- encode += stbir__coder_min_num;
- }
- #endif
-}
-
-static float * STBIR__CODER_NAME(stbir__decode_uint8_srgb)( float * decodep, int width_times_channels, void const * inputp )
-{
- float STBIR_STREAMOUT_PTR( * ) decode = decodep;
- float * decode_end = (float*) decode + width_times_channels;
- unsigned char const * input = (unsigned char const *)inputp;
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- decode += 4;
- while( decode <= decode_end )
- {
- decode[0-4] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order0 ] ];
- decode[1-4] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order1 ] ];
- decode[2-4] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order2 ] ];
- decode[3-4] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order3 ] ];
- decode += 4;
- input += 4;
- }
- decode -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( decode < decode_end )
- {
- STBIR_NO_UNROLL(decode);
- decode[0] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order0 ] ];
- #if stbir__coder_min_num >= 2
- decode[1] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order1 ] ];
- #endif
- #if stbir__coder_min_num >= 3
- decode[2] = stbir__srgb_uchar_to_linear_float[ input[ stbir__decode_order2 ] ];
- #endif
- decode += stbir__coder_min_num;
- input += stbir__coder_min_num;
- }
- #endif
- return decode_end;
-}
-
-#define stbir__min_max_shift20( i, f ) \
- stbir__simdf_max( f, f, stbir_simdf_casti(STBIR__CONSTI( STBIR_almost_zero )) ); \
- stbir__simdf_min( f, f, stbir_simdf_casti(STBIR__CONSTI( STBIR_almost_one )) ); \
- stbir__simdi_32shr( i, stbir_simdi_castf( f ), 20 );
-
-#define stbir__scale_and_convert( i, f ) \
- stbir__simdf_madd( f, STBIR__CONSTF( STBIR_simd_point5 ), STBIR__CONSTF( STBIR_max_uint8_as_float ), f ); \
- stbir__simdf_max( f, f, stbir__simdf_zeroP() ); \
- stbir__simdf_min( f, f, STBIR__CONSTF( STBIR_max_uint8_as_float ) ); \
- stbir__simdf_convert_float_to_i32( i, f );
-
-#define stbir__linear_to_srgb_finish( i, f ) \
-{ \
- stbir__simdi temp; \
- stbir__simdi_32shr( temp, stbir_simdi_castf( f ), 12 ) ; \
- stbir__simdi_and( temp, temp, STBIR__CONSTI(STBIR_mastissa_mask) ); \
- stbir__simdi_or( temp, temp, STBIR__CONSTI(STBIR_topscale) ); \
- stbir__simdi_16madd( i, i, temp ); \
- stbir__simdi_32shr( i, i, 16 ); \
-}
-
-#define stbir__simdi_table_lookup2( v0,v1, table ) \
-{ \
- stbir__simdi_u32 temp0,temp1; \
- temp0.m128i_i128 = v0; \
- temp1.m128i_i128 = v1; \
- temp0.m128i_u32[0] = table[temp0.m128i_i32[0]]; temp0.m128i_u32[1] = table[temp0.m128i_i32[1]]; temp0.m128i_u32[2] = table[temp0.m128i_i32[2]]; temp0.m128i_u32[3] = table[temp0.m128i_i32[3]]; \
- temp1.m128i_u32[0] = table[temp1.m128i_i32[0]]; temp1.m128i_u32[1] = table[temp1.m128i_i32[1]]; temp1.m128i_u32[2] = table[temp1.m128i_i32[2]]; temp1.m128i_u32[3] = table[temp1.m128i_i32[3]]; \
- v0 = temp0.m128i_i128; \
- v1 = temp1.m128i_i128; \
-}
-
-#define stbir__simdi_table_lookup3( v0,v1,v2, table ) \
-{ \
- stbir__simdi_u32 temp0,temp1,temp2; \
- temp0.m128i_i128 = v0; \
- temp1.m128i_i128 = v1; \
- temp2.m128i_i128 = v2; \
- temp0.m128i_u32[0] = table[temp0.m128i_i32[0]]; temp0.m128i_u32[1] = table[temp0.m128i_i32[1]]; temp0.m128i_u32[2] = table[temp0.m128i_i32[2]]; temp0.m128i_u32[3] = table[temp0.m128i_i32[3]]; \
- temp1.m128i_u32[0] = table[temp1.m128i_i32[0]]; temp1.m128i_u32[1] = table[temp1.m128i_i32[1]]; temp1.m128i_u32[2] = table[temp1.m128i_i32[2]]; temp1.m128i_u32[3] = table[temp1.m128i_i32[3]]; \
- temp2.m128i_u32[0] = table[temp2.m128i_i32[0]]; temp2.m128i_u32[1] = table[temp2.m128i_i32[1]]; temp2.m128i_u32[2] = table[temp2.m128i_i32[2]]; temp2.m128i_u32[3] = table[temp2.m128i_i32[3]]; \
- v0 = temp0.m128i_i128; \
- v1 = temp1.m128i_i128; \
- v2 = temp2.m128i_i128; \
-}
-
-#define stbir__simdi_table_lookup4( v0,v1,v2,v3, table ) \
-{ \
- stbir__simdi_u32 temp0,temp1,temp2,temp3; \
- temp0.m128i_i128 = v0; \
- temp1.m128i_i128 = v1; \
- temp2.m128i_i128 = v2; \
- temp3.m128i_i128 = v3; \
- temp0.m128i_u32[0] = table[temp0.m128i_i32[0]]; temp0.m128i_u32[1] = table[temp0.m128i_i32[1]]; temp0.m128i_u32[2] = table[temp0.m128i_i32[2]]; temp0.m128i_u32[3] = table[temp0.m128i_i32[3]]; \
- temp1.m128i_u32[0] = table[temp1.m128i_i32[0]]; temp1.m128i_u32[1] = table[temp1.m128i_i32[1]]; temp1.m128i_u32[2] = table[temp1.m128i_i32[2]]; temp1.m128i_u32[3] = table[temp1.m128i_i32[3]]; \
- temp2.m128i_u32[0] = table[temp2.m128i_i32[0]]; temp2.m128i_u32[1] = table[temp2.m128i_i32[1]]; temp2.m128i_u32[2] = table[temp2.m128i_i32[2]]; temp2.m128i_u32[3] = table[temp2.m128i_i32[3]]; \
- temp3.m128i_u32[0] = table[temp3.m128i_i32[0]]; temp3.m128i_u32[1] = table[temp3.m128i_i32[1]]; temp3.m128i_u32[2] = table[temp3.m128i_i32[2]]; temp3.m128i_u32[3] = table[temp3.m128i_i32[3]]; \
- v0 = temp0.m128i_i128; \
- v1 = temp1.m128i_i128; \
- v2 = temp2.m128i_i128; \
- v3 = temp3.m128i_i128; \
-}
-
-static void STBIR__CODER_NAME( stbir__encode_uint8_srgb )( void * outputp, int width_times_channels, float const * encode )
-{
- unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char*) outputp;
- unsigned char * end_output = ( (unsigned char*) output ) + width_times_channels;
-
- #ifdef STBIR_SIMD
-
- if ( width_times_channels >= 16 )
- {
- float const * end_encode_m16 = encode + width_times_channels - 16;
- end_output -= 16;
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- stbir__simdf f0, f1, f2, f3;
- stbir__simdi i0, i1, i2, i3;
- STBIR_SIMD_NO_UNROLL(encode);
-
- stbir__simdf_load4_transposed( f0, f1, f2, f3, encode );
-
- stbir__min_max_shift20( i0, f0 );
- stbir__min_max_shift20( i1, f1 );
- stbir__min_max_shift20( i2, f2 );
- stbir__min_max_shift20( i3, f3 );
-
- stbir__simdi_table_lookup4( i0, i1, i2, i3, ( fp32_to_srgb8_tab4 - (127-13)*8 ) );
-
- stbir__linear_to_srgb_finish( i0, f0 );
- stbir__linear_to_srgb_finish( i1, f1 );
- stbir__linear_to_srgb_finish( i2, f2 );
- stbir__linear_to_srgb_finish( i3, f3 );
-
- stbir__interleave_pack_and_store_16_u8( output, STBIR_strs_join1(i, ,stbir__encode_order0), STBIR_strs_join1(i, ,stbir__encode_order1), STBIR_strs_join1(i, ,stbir__encode_order2), STBIR_strs_join1(i, ,stbir__encode_order3) );
-
- encode += 16;
- output += 16;
- if ( output <= end_output )
- continue;
- if ( output == ( end_output + 16 ) )
- break;
- output = end_output; // backup and do last couple
- encode = end_encode_m16;
- }
- return;
- }
- #endif
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while ( output <= end_output )
- {
- STBIR_SIMD_NO_UNROLL(encode);
-
- output[0-4] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order0] );
- output[1-4] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order1] );
- output[2-4] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order2] );
- output[3-4] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order3] );
-
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( output < end_output )
- {
- STBIR_NO_UNROLL(encode);
- output[0] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order0] );
- #if stbir__coder_min_num >= 2
- output[1] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order1] );
- #endif
- #if stbir__coder_min_num >= 3
- output[2] = stbir__linear_to_srgb_uchar( encode[stbir__encode_order2] );
- #endif
- output += stbir__coder_min_num;
- encode += stbir__coder_min_num;
- }
- #endif
-}
-
-#if ( stbir__coder_min_num == 4 ) || ( ( stbir__coder_min_num == 1 ) && ( !defined(stbir__decode_swizzle) ) )
-
-static float * STBIR__CODER_NAME(stbir__decode_uint8_srgb4_linearalpha)( float * decodep, int width_times_channels, void const * inputp )
-{
- float STBIR_STREAMOUT_PTR( * ) decode = decodep;
- float * decode_end = (float*) decode + width_times_channels;
- unsigned char const * input = (unsigned char const *)inputp;
-
- do {
- decode[0] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order0] ];
- decode[1] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order1] ];
- decode[2] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order2] ];
- decode[3] = ( (float) input[stbir__decode_order3] ) * stbir__max_uint8_as_float_inverted;
- input += 4;
- decode += 4;
- } while( decode < decode_end );
- return decode_end;
-}
-
-
-static void STBIR__CODER_NAME( stbir__encode_uint8_srgb4_linearalpha )( void * outputp, int width_times_channels, float const * encode )
-{
- unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char*) outputp;
- unsigned char * end_output = ( (unsigned char*) output ) + width_times_channels;
-
- #ifdef STBIR_SIMD
-
- if ( width_times_channels >= 16 )
- {
- float const * end_encode_m16 = encode + width_times_channels - 16;
- end_output -= 16;
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- stbir__simdf f0, f1, f2, f3;
- stbir__simdi i0, i1, i2, i3;
-
- STBIR_SIMD_NO_UNROLL(encode);
- stbir__simdf_load4_transposed( f0, f1, f2, f3, encode );
-
- stbir__min_max_shift20( i0, f0 );
- stbir__min_max_shift20( i1, f1 );
- stbir__min_max_shift20( i2, f2 );
- stbir__scale_and_convert( i3, f3 );
-
- stbir__simdi_table_lookup3( i0, i1, i2, ( fp32_to_srgb8_tab4 - (127-13)*8 ) );
-
- stbir__linear_to_srgb_finish( i0, f0 );
- stbir__linear_to_srgb_finish( i1, f1 );
- stbir__linear_to_srgb_finish( i2, f2 );
-
- stbir__interleave_pack_and_store_16_u8( output, STBIR_strs_join1(i, ,stbir__encode_order0), STBIR_strs_join1(i, ,stbir__encode_order1), STBIR_strs_join1(i, ,stbir__encode_order2), STBIR_strs_join1(i, ,stbir__encode_order3) );
-
- output += 16;
- encode += 16;
-
- if ( output <= end_output )
- continue;
- if ( output == ( end_output + 16 ) )
- break;
- output = end_output; // backup and do last couple
- encode = end_encode_m16;
- }
- return;
- }
- #endif
-
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float f;
- STBIR_SIMD_NO_UNROLL(encode);
-
- output[stbir__decode_order0] = stbir__linear_to_srgb_uchar( encode[0] );
- output[stbir__decode_order1] = stbir__linear_to_srgb_uchar( encode[1] );
- output[stbir__decode_order2] = stbir__linear_to_srgb_uchar( encode[2] );
-
- f = encode[3] * stbir__max_uint8_as_float + 0.5f;
- STBIR_CLAMP(f, 0, 255);
- output[stbir__decode_order3] = (unsigned char) f;
-
- output += 4;
- encode += 4;
- } while( output < end_output );
-}
-
-#endif
-
-#if ( stbir__coder_min_num == 2 ) || ( ( stbir__coder_min_num == 1 ) && ( !defined(stbir__decode_swizzle) ) )
-
-static float * STBIR__CODER_NAME(stbir__decode_uint8_srgb2_linearalpha)( float * decodep, int width_times_channels, void const * inputp )
-{
- float STBIR_STREAMOUT_PTR( * ) decode = decodep;
- float * decode_end = (float*) decode + width_times_channels;
- unsigned char const * input = (unsigned char const *)inputp;
-
- decode += 4;
- while( decode <= decode_end )
- {
- decode[0-4] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order0] ];
- decode[1-4] = ( (float) input[stbir__decode_order1] ) * stbir__max_uint8_as_float_inverted;
- decode[2-4] = stbir__srgb_uchar_to_linear_float[ input[stbir__decode_order0+2] ];
- decode[3-4] = ( (float) input[stbir__decode_order1+2] ) * stbir__max_uint8_as_float_inverted;
- input += 4;
- decode += 4;
- }
- decode -= 4;
- if( decode < decode_end )
- {
- decode[0] = stbir__srgb_uchar_to_linear_float[ stbir__decode_order0 ];
- decode[1] = ( (float) input[stbir__decode_order1] ) * stbir__max_uint8_as_float_inverted;
- }
- return decode_end;
-}
-
-static void STBIR__CODER_NAME( stbir__encode_uint8_srgb2_linearalpha )( void * outputp, int width_times_channels, float const * encode )
-{
- unsigned char STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned char*) outputp;
- unsigned char * end_output = ( (unsigned char*) output ) + width_times_channels;
-
- #ifdef STBIR_SIMD
-
- if ( width_times_channels >= 16 )
- {
- float const * end_encode_m16 = encode + width_times_channels - 16;
- end_output -= 16;
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- stbir__simdf f0, f1, f2, f3;
- stbir__simdi i0, i1, i2, i3;
-
- STBIR_SIMD_NO_UNROLL(encode);
- stbir__simdf_load4_transposed( f0, f1, f2, f3, encode );
-
- stbir__min_max_shift20( i0, f0 );
- stbir__scale_and_convert( i1, f1 );
- stbir__min_max_shift20( i2, f2 );
- stbir__scale_and_convert( i3, f3 );
-
- stbir__simdi_table_lookup2( i0, i2, ( fp32_to_srgb8_tab4 - (127-13)*8 ) );
-
- stbir__linear_to_srgb_finish( i0, f0 );
- stbir__linear_to_srgb_finish( i2, f2 );
-
- stbir__interleave_pack_and_store_16_u8( output, STBIR_strs_join1(i, ,stbir__encode_order0), STBIR_strs_join1(i, ,stbir__encode_order1), STBIR_strs_join1(i, ,stbir__encode_order2), STBIR_strs_join1(i, ,stbir__encode_order3) );
-
- output += 16;
- encode += 16;
- if ( output <= end_output )
- continue;
- if ( output == ( end_output + 16 ) )
- break;
- output = end_output; // backup and do last couple
- encode = end_encode_m16;
- }
- return;
- }
- #endif
-
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float f;
- STBIR_SIMD_NO_UNROLL(encode);
-
- output[stbir__decode_order0] = stbir__linear_to_srgb_uchar( encode[0] );
-
- f = encode[1] * stbir__max_uint8_as_float + 0.5f;
- STBIR_CLAMP(f, 0, 255);
- output[stbir__decode_order1] = (unsigned char) f;
-
- output += 2;
- encode += 2;
- } while( output < end_output );
-}
-
-#endif
-
-static float * STBIR__CODER_NAME(stbir__decode_uint16_linear_scaled)( float * decodep, int width_times_channels, void const * inputp )
-{
- float STBIR_STREAMOUT_PTR( * ) decode = decodep;
- float * decode_end = (float*) decode + width_times_channels;
- unsigned short const * input = (unsigned short const *)inputp;
-
- #ifdef STBIR_SIMD
- unsigned short const * end_input_m8 = input + width_times_channels - 8;
- if ( width_times_channels >= 8 )
- {
- decode_end -= 8;
- STBIR_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- #ifdef STBIR_SIMD8
- stbir__simdi i; stbir__simdi8 o;
- stbir__simdf8 of;
- STBIR_NO_UNROLL(decode);
- stbir__simdi_load( i, input );
- stbir__simdi8_expand_u16_to_u32( o, i );
- stbir__simdi8_convert_i32_to_float( of, o );
- stbir__simdf8_mult( of, of, STBIR_max_uint16_as_float_inverted8);
- stbir__decode_simdf8_flip( of );
- stbir__simdf8_store( decode + 0, of );
- #else
- stbir__simdi i, o0, o1;
- stbir__simdf of0, of1;
- STBIR_NO_UNROLL(decode);
- stbir__simdi_load( i, input );
- stbir__simdi_expand_u16_to_u32( o0,o1,i );
- stbir__simdi_convert_i32_to_float( of0, o0 );
- stbir__simdi_convert_i32_to_float( of1, o1 );
- stbir__simdf_mult( of0, of0, STBIR__CONSTF(STBIR_max_uint16_as_float_inverted) );
- stbir__simdf_mult( of1, of1, STBIR__CONSTF(STBIR_max_uint16_as_float_inverted));
- stbir__decode_simdf4_flip( of0 );
- stbir__decode_simdf4_flip( of1 );
- stbir__simdf_store( decode + 0, of0 );
- stbir__simdf_store( decode + 4, of1 );
- #endif
- decode += 8;
- input += 8;
- if ( decode <= decode_end )
- continue;
- if ( decode == ( decode_end + 8 ) )
- break;
- decode = decode_end; // backup and do last couple
- input = end_input_m8;
- }
- return decode_end + 8;
- }
- #endif
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- decode += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( decode <= decode_end )
- {
- STBIR_SIMD_NO_UNROLL(decode);
- decode[0-4] = ((float)(input[stbir__decode_order0])) * stbir__max_uint16_as_float_inverted;
- decode[1-4] = ((float)(input[stbir__decode_order1])) * stbir__max_uint16_as_float_inverted;
- decode[2-4] = ((float)(input[stbir__decode_order2])) * stbir__max_uint16_as_float_inverted;
- decode[3-4] = ((float)(input[stbir__decode_order3])) * stbir__max_uint16_as_float_inverted;
- decode += 4;
- input += 4;
- }
- decode -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( decode < decode_end )
- {
- STBIR_NO_UNROLL(decode);
- decode[0] = ((float)(input[stbir__decode_order0])) * stbir__max_uint16_as_float_inverted;
- #if stbir__coder_min_num >= 2
- decode[1] = ((float)(input[stbir__decode_order1])) * stbir__max_uint16_as_float_inverted;
- #endif
- #if stbir__coder_min_num >= 3
- decode[2] = ((float)(input[stbir__decode_order2])) * stbir__max_uint16_as_float_inverted;
- #endif
- decode += stbir__coder_min_num;
- input += stbir__coder_min_num;
- }
- #endif
- return decode_end;
-}
-
-
-static void STBIR__CODER_NAME(stbir__encode_uint16_linear_scaled)( void * outputp, int width_times_channels, float const * encode )
-{
- unsigned short STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned short*) outputp;
- unsigned short * end_output = ( (unsigned short*) output ) + width_times_channels;
-
- #ifdef STBIR_SIMD
- {
- if ( width_times_channels >= stbir__simdfX_float_count*2 )
- {
- float const * end_encode_m8 = encode + width_times_channels - stbir__simdfX_float_count*2;
- end_output -= stbir__simdfX_float_count*2;
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- stbir__simdfX e0, e1;
- stbir__simdiX i;
- STBIR_SIMD_NO_UNROLL(encode);
- stbir__simdfX_madd_mem( e0, STBIR_simd_point5X, STBIR_max_uint16_as_floatX, encode );
- stbir__simdfX_madd_mem( e1, STBIR_simd_point5X, STBIR_max_uint16_as_floatX, encode+stbir__simdfX_float_count );
- stbir__encode_simdfX_unflip( e0 );
- stbir__encode_simdfX_unflip( e1 );
- stbir__simdfX_pack_to_words( i, e0, e1 );
- stbir__simdiX_store( output, i );
- encode += stbir__simdfX_float_count*2;
- output += stbir__simdfX_float_count*2;
- if ( output <= end_output )
- continue;
- if ( output == ( end_output + stbir__simdfX_float_count*2 ) )
- break;
- output = end_output; // backup and do last couple
- encode = end_encode_m8;
- }
- return;
- }
- }
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_NO_UNROLL_LOOP_START
- while( output <= end_output )
- {
- stbir__simdf e;
- stbir__simdi i;
- STBIR_NO_UNROLL(encode);
- stbir__simdf_load( e, encode );
- stbir__simdf_madd( e, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint16_as_float), e );
- stbir__encode_simdf4_unflip( e );
- stbir__simdf_pack_to_8words( i, e, e ); // only use first 4
- stbir__simdi_store2( output-4, i );
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( output < end_output )
- {
- stbir__simdf e;
- STBIR_NO_UNROLL(encode);
- stbir__simdf_madd1_mem( e, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint16_as_float), encode+stbir__encode_order0 ); output[0] = stbir__simdf_convert_float_to_short( e );
- #if stbir__coder_min_num >= 2
- stbir__simdf_madd1_mem( e, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint16_as_float), encode+stbir__encode_order1 ); output[1] = stbir__simdf_convert_float_to_short( e );
- #endif
- #if stbir__coder_min_num >= 3
- stbir__simdf_madd1_mem( e, STBIR__CONSTF(STBIR_simd_point5), STBIR__CONSTF(STBIR_max_uint16_as_float), encode+stbir__encode_order2 ); output[2] = stbir__simdf_convert_float_to_short( e );
- #endif
- output += stbir__coder_min_num;
- encode += stbir__coder_min_num;
- }
- #endif
-
- #else
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( output <= end_output )
- {
- float f;
- STBIR_SIMD_NO_UNROLL(encode);
- f = encode[stbir__encode_order0] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[0-4] = (unsigned short)f;
- f = encode[stbir__encode_order1] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[1-4] = (unsigned short)f;
- f = encode[stbir__encode_order2] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[2-4] = (unsigned short)f;
- f = encode[stbir__encode_order3] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[3-4] = (unsigned short)f;
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( output < end_output )
- {
- float f;
- STBIR_NO_UNROLL(encode);
- f = encode[stbir__encode_order0] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[0] = (unsigned short)f;
- #if stbir__coder_min_num >= 2
- f = encode[stbir__encode_order1] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[1] = (unsigned short)f;
- #endif
- #if stbir__coder_min_num >= 3
- f = encode[stbir__encode_order2] * stbir__max_uint16_as_float + 0.5f; STBIR_CLAMP(f, 0, 65535); output[2] = (unsigned short)f;
- #endif
- output += stbir__coder_min_num;
- encode += stbir__coder_min_num;
- }
- #endif
- #endif
-}
-
-static float * STBIR__CODER_NAME(stbir__decode_uint16_linear)( float * decodep, int width_times_channels, void const * inputp )
-{
- float STBIR_STREAMOUT_PTR( * ) decode = decodep;
- float * decode_end = (float*) decode + width_times_channels;
- unsigned short const * input = (unsigned short const *)inputp;
-
- #ifdef STBIR_SIMD
- unsigned short const * end_input_m8 = input + width_times_channels - 8;
- if ( width_times_channels >= 8 )
- {
- decode_end -= 8;
- STBIR_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- #ifdef STBIR_SIMD8
- stbir__simdi i; stbir__simdi8 o;
- stbir__simdf8 of;
- STBIR_NO_UNROLL(decode);
- stbir__simdi_load( i, input );
- stbir__simdi8_expand_u16_to_u32( o, i );
- stbir__simdi8_convert_i32_to_float( of, o );
- stbir__decode_simdf8_flip( of );
- stbir__simdf8_store( decode + 0, of );
- #else
- stbir__simdi i, o0, o1;
- stbir__simdf of0, of1;
- STBIR_NO_UNROLL(decode);
- stbir__simdi_load( i, input );
- stbir__simdi_expand_u16_to_u32( o0, o1, i );
- stbir__simdi_convert_i32_to_float( of0, o0 );
- stbir__simdi_convert_i32_to_float( of1, o1 );
- stbir__decode_simdf4_flip( of0 );
- stbir__decode_simdf4_flip( of1 );
- stbir__simdf_store( decode + 0, of0 );
- stbir__simdf_store( decode + 4, of1 );
- #endif
- decode += 8;
- input += 8;
- if ( decode <= decode_end )
- continue;
- if ( decode == ( decode_end + 8 ) )
- break;
- decode = decode_end; // backup and do last couple
- input = end_input_m8;
- }
- return decode_end + 8;
- }
- #endif
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- decode += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( decode <= decode_end )
- {
- STBIR_SIMD_NO_UNROLL(decode);
- decode[0-4] = ((float)(input[stbir__decode_order0]));
- decode[1-4] = ((float)(input[stbir__decode_order1]));
- decode[2-4] = ((float)(input[stbir__decode_order2]));
- decode[3-4] = ((float)(input[stbir__decode_order3]));
- decode += 4;
- input += 4;
- }
- decode -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( decode < decode_end )
- {
- STBIR_NO_UNROLL(decode);
- decode[0] = ((float)(input[stbir__decode_order0]));
- #if stbir__coder_min_num >= 2
- decode[1] = ((float)(input[stbir__decode_order1]));
- #endif
- #if stbir__coder_min_num >= 3
- decode[2] = ((float)(input[stbir__decode_order2]));
- #endif
- decode += stbir__coder_min_num;
- input += stbir__coder_min_num;
- }
- #endif
- return decode_end;
-}
-
-static void STBIR__CODER_NAME(stbir__encode_uint16_linear)( void * outputp, int width_times_channels, float const * encode )
-{
- unsigned short STBIR_SIMD_STREAMOUT_PTR( * ) output = (unsigned short*) outputp;
- unsigned short * end_output = ( (unsigned short*) output ) + width_times_channels;
-
- #ifdef STBIR_SIMD
- {
- if ( width_times_channels >= stbir__simdfX_float_count*2 )
- {
- float const * end_encode_m8 = encode + width_times_channels - stbir__simdfX_float_count*2;
- end_output -= stbir__simdfX_float_count*2;
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- stbir__simdfX e0, e1;
- stbir__simdiX i;
- STBIR_SIMD_NO_UNROLL(encode);
- stbir__simdfX_add_mem( e0, STBIR_simd_point5X, encode );
- stbir__simdfX_add_mem( e1, STBIR_simd_point5X, encode+stbir__simdfX_float_count );
- stbir__encode_simdfX_unflip( e0 );
- stbir__encode_simdfX_unflip( e1 );
- stbir__simdfX_pack_to_words( i, e0, e1 );
- stbir__simdiX_store( output, i );
- encode += stbir__simdfX_float_count*2;
- output += stbir__simdfX_float_count*2;
- if ( output <= end_output )
- continue;
- if ( output == ( end_output + stbir__simdfX_float_count*2 ) )
- break;
- output = end_output; // backup and do last couple
- encode = end_encode_m8;
- }
- return;
- }
- }
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_NO_UNROLL_LOOP_START
- while( output <= end_output )
- {
- stbir__simdf e;
- stbir__simdi i;
- STBIR_NO_UNROLL(encode);
- stbir__simdf_load( e, encode );
- stbir__simdf_add( e, STBIR__CONSTF(STBIR_simd_point5), e );
- stbir__encode_simdf4_unflip( e );
- stbir__simdf_pack_to_8words( i, e, e ); // only use first 4
- stbir__simdi_store2( output-4, i );
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- #else
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( output <= end_output )
- {
- float f;
- STBIR_SIMD_NO_UNROLL(encode);
- f = encode[stbir__encode_order0] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[0-4] = (unsigned short)f;
- f = encode[stbir__encode_order1] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[1-4] = (unsigned short)f;
- f = encode[stbir__encode_order2] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[2-4] = (unsigned short)f;
- f = encode[stbir__encode_order3] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[3-4] = (unsigned short)f;
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( output < end_output )
- {
- float f;
- STBIR_NO_UNROLL(encode);
- f = encode[stbir__encode_order0] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[0] = (unsigned short)f;
- #if stbir__coder_min_num >= 2
- f = encode[stbir__encode_order1] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[1] = (unsigned short)f;
- #endif
- #if stbir__coder_min_num >= 3
- f = encode[stbir__encode_order2] + 0.5f; STBIR_CLAMP(f, 0, 65535); output[2] = (unsigned short)f;
- #endif
- output += stbir__coder_min_num;
- encode += stbir__coder_min_num;
- }
- #endif
-}
-
-static float * STBIR__CODER_NAME(stbir__decode_half_float_linear)( float * decodep, int width_times_channels, void const * inputp )
-{
- float STBIR_STREAMOUT_PTR( * ) decode = decodep;
- float * decode_end = (float*) decode + width_times_channels;
- stbir__FP16 const * input = (stbir__FP16 const *)inputp;
-
- #ifdef STBIR_SIMD
- if ( width_times_channels >= 8 )
- {
- stbir__FP16 const * end_input_m8 = input + width_times_channels - 8;
- decode_end -= 8;
- STBIR_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- STBIR_NO_UNROLL(decode);
-
- stbir__half_to_float_SIMD( decode, input );
- #ifdef stbir__decode_swizzle
- #ifdef STBIR_SIMD8
- {
- stbir__simdf8 of;
- stbir__simdf8_load( of, decode );
- stbir__decode_simdf8_flip( of );
- stbir__simdf8_store( decode, of );
- }
- #else
- {
- stbir__simdf of0,of1;
- stbir__simdf_load( of0, decode );
- stbir__simdf_load( of1, decode+4 );
- stbir__decode_simdf4_flip( of0 );
- stbir__decode_simdf4_flip( of1 );
- stbir__simdf_store( decode, of0 );
- stbir__simdf_store( decode+4, of1 );
- }
- #endif
- #endif
- decode += 8;
- input += 8;
- if ( decode <= decode_end )
- continue;
- if ( decode == ( decode_end + 8 ) )
- break;
- decode = decode_end; // backup and do last couple
- input = end_input_m8;
- }
- return decode_end + 8;
- }
- #endif
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- decode += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( decode <= decode_end )
- {
- STBIR_SIMD_NO_UNROLL(decode);
- decode[0-4] = stbir__half_to_float(input[stbir__decode_order0]);
- decode[1-4] = stbir__half_to_float(input[stbir__decode_order1]);
- decode[2-4] = stbir__half_to_float(input[stbir__decode_order2]);
- decode[3-4] = stbir__half_to_float(input[stbir__decode_order3]);
- decode += 4;
- input += 4;
- }
- decode -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( decode < decode_end )
- {
- STBIR_NO_UNROLL(decode);
- decode[0] = stbir__half_to_float(input[stbir__decode_order0]);
- #if stbir__coder_min_num >= 2
- decode[1] = stbir__half_to_float(input[stbir__decode_order1]);
- #endif
- #if stbir__coder_min_num >= 3
- decode[2] = stbir__half_to_float(input[stbir__decode_order2]);
- #endif
- decode += stbir__coder_min_num;
- input += stbir__coder_min_num;
- }
- #endif
- return decode_end;
-}
-
-static void STBIR__CODER_NAME( stbir__encode_half_float_linear )( void * outputp, int width_times_channels, float const * encode )
-{
- stbir__FP16 STBIR_SIMD_STREAMOUT_PTR( * ) output = (stbir__FP16*) outputp;
- stbir__FP16 * end_output = ( (stbir__FP16*) output ) + width_times_channels;
-
- #ifdef STBIR_SIMD
- if ( width_times_channels >= 8 )
- {
- float const * end_encode_m8 = encode + width_times_channels - 8;
- end_output -= 8;
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- STBIR_SIMD_NO_UNROLL(encode);
- #ifdef stbir__decode_swizzle
- #ifdef STBIR_SIMD8
- {
- stbir__simdf8 of;
- stbir__simdf8_load( of, encode );
- stbir__encode_simdf8_unflip( of );
- stbir__float_to_half_SIMD( output, (float*)&of );
- }
- #else
- {
- stbir__simdf of[2];
- stbir__simdf_load( of[0], encode );
- stbir__simdf_load( of[1], encode+4 );
- stbir__encode_simdf4_unflip( of[0] );
- stbir__encode_simdf4_unflip( of[1] );
- stbir__float_to_half_SIMD( output, (float*)of );
- }
- #endif
- #else
- stbir__float_to_half_SIMD( output, encode );
- #endif
- encode += 8;
- output += 8;
- if ( output <= end_output )
- continue;
- if ( output == ( end_output + 8 ) )
- break;
- output = end_output; // backup and do last couple
- encode = end_encode_m8;
- }
- return;
- }
- #endif
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( output <= end_output )
- {
- STBIR_SIMD_NO_UNROLL(output);
- output[0-4] = stbir__float_to_half(encode[stbir__encode_order0]);
- output[1-4] = stbir__float_to_half(encode[stbir__encode_order1]);
- output[2-4] = stbir__float_to_half(encode[stbir__encode_order2]);
- output[3-4] = stbir__float_to_half(encode[stbir__encode_order3]);
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( output < end_output )
- {
- STBIR_NO_UNROLL(output);
- output[0] = stbir__float_to_half(encode[stbir__encode_order0]);
- #if stbir__coder_min_num >= 2
- output[1] = stbir__float_to_half(encode[stbir__encode_order1]);
- #endif
- #if stbir__coder_min_num >= 3
- output[2] = stbir__float_to_half(encode[stbir__encode_order2]);
- #endif
- output += stbir__coder_min_num;
- encode += stbir__coder_min_num;
- }
- #endif
-}
-
-static float * STBIR__CODER_NAME(stbir__decode_float_linear)( float * decodep, int width_times_channels, void const * inputp )
-{
- #ifdef stbir__decode_swizzle
- float STBIR_STREAMOUT_PTR( * ) decode = decodep;
- float * decode_end = (float*) decode + width_times_channels;
- float const * input = (float const *)inputp;
-
- #ifdef STBIR_SIMD
- if ( width_times_channels >= 16 )
- {
- float const * end_input_m16 = input + width_times_channels - 16;
- decode_end -= 16;
- STBIR_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- STBIR_NO_UNROLL(decode);
- #ifdef stbir__decode_swizzle
- #ifdef STBIR_SIMD8
- {
- stbir__simdf8 of0,of1;
- stbir__simdf8_load( of0, input );
- stbir__simdf8_load( of1, input+8 );
- stbir__decode_simdf8_flip( of0 );
- stbir__decode_simdf8_flip( of1 );
- stbir__simdf8_store( decode, of0 );
- stbir__simdf8_store( decode+8, of1 );
- }
- #else
- {
- stbir__simdf of0,of1,of2,of3;
- stbir__simdf_load( of0, input );
- stbir__simdf_load( of1, input+4 );
- stbir__simdf_load( of2, input+8 );
- stbir__simdf_load( of3, input+12 );
- stbir__decode_simdf4_flip( of0 );
- stbir__decode_simdf4_flip( of1 );
- stbir__decode_simdf4_flip( of2 );
- stbir__decode_simdf4_flip( of3 );
- stbir__simdf_store( decode, of0 );
- stbir__simdf_store( decode+4, of1 );
- stbir__simdf_store( decode+8, of2 );
- stbir__simdf_store( decode+12, of3 );
- }
- #endif
- #endif
- decode += 16;
- input += 16;
- if ( decode <= decode_end )
- continue;
- if ( decode == ( decode_end + 16 ) )
- break;
- decode = decode_end; // backup and do last couple
- input = end_input_m16;
- }
- return decode_end + 16;
- }
- #endif
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- decode += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( decode <= decode_end )
- {
- STBIR_SIMD_NO_UNROLL(decode);
- decode[0-4] = input[stbir__decode_order0];
- decode[1-4] = input[stbir__decode_order1];
- decode[2-4] = input[stbir__decode_order2];
- decode[3-4] = input[stbir__decode_order3];
- decode += 4;
- input += 4;
- }
- decode -= 4;
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( decode < decode_end )
- {
- STBIR_NO_UNROLL(decode);
- decode[0] = input[stbir__decode_order0];
- #if stbir__coder_min_num >= 2
- decode[1] = input[stbir__decode_order1];
- #endif
- #if stbir__coder_min_num >= 3
- decode[2] = input[stbir__decode_order2];
- #endif
- decode += stbir__coder_min_num;
- input += stbir__coder_min_num;
- }
- #endif
- return decode_end;
-
- #else
-
- if ( (void*)decodep != inputp )
- STBIR_MEMCPY( decodep, inputp, width_times_channels * sizeof( float ) );
-
- return decodep + width_times_channels;
-
- #endif
-}
-
-static void STBIR__CODER_NAME( stbir__encode_float_linear )( void * outputp, int width_times_channels, float const * encode )
-{
- #if !defined( STBIR_FLOAT_HIGH_CLAMP ) && !defined(STBIR_FLOAT_LO_CLAMP) && !defined(stbir__decode_swizzle)
-
- if ( (void*)outputp != (void*) encode )
- STBIR_MEMCPY( outputp, encode, width_times_channels * sizeof( float ) );
-
- #else
-
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = (float*) outputp;
- float * end_output = ( (float*) output ) + width_times_channels;
-
- #ifdef STBIR_FLOAT_HIGH_CLAMP
- #define stbir_scalar_hi_clamp( v ) if ( v > STBIR_FLOAT_HIGH_CLAMP ) v = STBIR_FLOAT_HIGH_CLAMP;
- #else
- #define stbir_scalar_hi_clamp( v )
- #endif
- #ifdef STBIR_FLOAT_LOW_CLAMP
- #define stbir_scalar_lo_clamp( v ) if ( v < STBIR_FLOAT_LOW_CLAMP ) v = STBIR_FLOAT_LOW_CLAMP;
- #else
- #define stbir_scalar_lo_clamp( v )
- #endif
-
- #ifdef STBIR_SIMD
-
- #ifdef STBIR_FLOAT_HIGH_CLAMP
- const stbir__simdfX high_clamp = stbir__simdf_frepX(STBIR_FLOAT_HIGH_CLAMP);
- #endif
- #ifdef STBIR_FLOAT_LOW_CLAMP
- const stbir__simdfX low_clamp = stbir__simdf_frepX(STBIR_FLOAT_LOW_CLAMP);
- #endif
-
- if ( width_times_channels >= ( stbir__simdfX_float_count * 2 ) )
- {
- float const * end_encode_m8 = encode + width_times_channels - ( stbir__simdfX_float_count * 2 );
- end_output -= ( stbir__simdfX_float_count * 2 );
- STBIR_SIMD_NO_UNROLL_LOOP_START_INF_FOR
- for(;;)
- {
- stbir__simdfX e0, e1;
- STBIR_SIMD_NO_UNROLL(encode);
- stbir__simdfX_load( e0, encode );
- stbir__simdfX_load( e1, encode+stbir__simdfX_float_count );
-#ifdef STBIR_FLOAT_HIGH_CLAMP
- stbir__simdfX_min( e0, e0, high_clamp );
- stbir__simdfX_min( e1, e1, high_clamp );
-#endif
-#ifdef STBIR_FLOAT_LOW_CLAMP
- stbir__simdfX_max( e0, e0, low_clamp );
- stbir__simdfX_max( e1, e1, low_clamp );
-#endif
- stbir__encode_simdfX_unflip( e0 );
- stbir__encode_simdfX_unflip( e1 );
- stbir__simdfX_store( output, e0 );
- stbir__simdfX_store( output+stbir__simdfX_float_count, e1 );
- encode += stbir__simdfX_float_count * 2;
- output += stbir__simdfX_float_count * 2;
- if ( output < end_output )
- continue;
- if ( output == ( end_output + ( stbir__simdfX_float_count * 2 ) ) )
- break;
- output = end_output; // backup and do last couple
- encode = end_encode_m8;
- }
- return;
- }
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_NO_UNROLL_LOOP_START
- while( output <= end_output )
- {
- stbir__simdf e0;
- STBIR_NO_UNROLL(encode);
- stbir__simdf_load( e0, encode );
-#ifdef STBIR_FLOAT_HIGH_CLAMP
- stbir__simdf_min( e0, e0, high_clamp );
-#endif
-#ifdef STBIR_FLOAT_LOW_CLAMP
- stbir__simdf_max( e0, e0, low_clamp );
-#endif
- stbir__encode_simdf4_unflip( e0 );
- stbir__simdf_store( output-4, e0 );
- output += 4;
- encode += 4;
- }
- output -= 4;
- #endif
-
- #else
-
- // try to do blocks of 4 when you can
- #if stbir__coder_min_num != 3 // doesn't divide cleanly by four
- output += 4;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while( output <= end_output )
- {
- float e;
- STBIR_SIMD_NO_UNROLL(encode);
- e = encode[ stbir__encode_order0 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[0-4] = e;
- e = encode[ stbir__encode_order1 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[1-4] = e;
- e = encode[ stbir__encode_order2 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[2-4] = e;
- e = encode[ stbir__encode_order3 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[3-4] = e;
- output += 4;
- encode += 4;
- }
- output -= 4;
-
- #endif
-
- #endif
-
- // do the remnants
- #if stbir__coder_min_num < 4
- STBIR_NO_UNROLL_LOOP_START
- while( output < end_output )
- {
- float e;
- STBIR_NO_UNROLL(encode);
- e = encode[ stbir__encode_order0 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[0] = e;
- #if stbir__coder_min_num >= 2
- e = encode[ stbir__encode_order1 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[1] = e;
- #endif
- #if stbir__coder_min_num >= 3
- e = encode[ stbir__encode_order2 ]; stbir_scalar_hi_clamp( e ); stbir_scalar_lo_clamp( e ); output[2] = e;
- #endif
- output += stbir__coder_min_num;
- encode += stbir__coder_min_num;
- }
- #endif
-
- #endif
-}
-
-#undef stbir__decode_suffix
-#undef stbir__decode_simdf8_flip
-#undef stbir__decode_simdf4_flip
-#undef stbir__decode_order0
-#undef stbir__decode_order1
-#undef stbir__decode_order2
-#undef stbir__decode_order3
-#undef stbir__encode_order0
-#undef stbir__encode_order1
-#undef stbir__encode_order2
-#undef stbir__encode_order3
-#undef stbir__encode_simdf8_unflip
-#undef stbir__encode_simdf4_unflip
-#undef stbir__encode_simdfX_unflip
-#undef STBIR__CODER_NAME
-#undef stbir__coder_min_num
-#undef stbir__decode_swizzle
-#undef stbir_scalar_hi_clamp
-#undef stbir_scalar_lo_clamp
-#undef STB_IMAGE_RESIZE_DO_CODERS
-
-#elif defined( STB_IMAGE_RESIZE_DO_VERTICALS)
-
-#ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#define STBIR_chans( start, end ) STBIR_strs_join14(start,STBIR__vertical_channels,end,_cont)
-#else
-#define STBIR_chans( start, end ) STBIR_strs_join1(start,STBIR__vertical_channels,end)
-#endif
-
-#if STBIR__vertical_channels >= 1
-#define stbIF0( code ) code
-#else
-#define stbIF0( code )
-#endif
-#if STBIR__vertical_channels >= 2
-#define stbIF1( code ) code
-#else
-#define stbIF1( code )
-#endif
-#if STBIR__vertical_channels >= 3
-#define stbIF2( code ) code
-#else
-#define stbIF2( code )
-#endif
-#if STBIR__vertical_channels >= 4
-#define stbIF3( code ) code
-#else
-#define stbIF3( code )
-#endif
-#if STBIR__vertical_channels >= 5
-#define stbIF4( code ) code
-#else
-#define stbIF4( code )
-#endif
-#if STBIR__vertical_channels >= 6
-#define stbIF5( code ) code
-#else
-#define stbIF5( code )
-#endif
-#if STBIR__vertical_channels >= 7
-#define stbIF6( code ) code
-#else
-#define stbIF6( code )
-#endif
-#if STBIR__vertical_channels >= 8
-#define stbIF7( code ) code
-#else
-#define stbIF7( code )
-#endif
-
-static void STBIR_chans( stbir__vertical_scatter_with_,_coeffs)( float ** outputs, float const * vertical_coefficients, float const * input, float const * input_end )
-{
- stbIF0( float STBIR_SIMD_STREAMOUT_PTR( * ) output0 = outputs[0]; float c0s = vertical_coefficients[0]; )
- stbIF1( float STBIR_SIMD_STREAMOUT_PTR( * ) output1 = outputs[1]; float c1s = vertical_coefficients[1]; )
- stbIF2( float STBIR_SIMD_STREAMOUT_PTR( * ) output2 = outputs[2]; float c2s = vertical_coefficients[2]; )
- stbIF3( float STBIR_SIMD_STREAMOUT_PTR( * ) output3 = outputs[3]; float c3s = vertical_coefficients[3]; )
- stbIF4( float STBIR_SIMD_STREAMOUT_PTR( * ) output4 = outputs[4]; float c4s = vertical_coefficients[4]; )
- stbIF5( float STBIR_SIMD_STREAMOUT_PTR( * ) output5 = outputs[5]; float c5s = vertical_coefficients[5]; )
- stbIF6( float STBIR_SIMD_STREAMOUT_PTR( * ) output6 = outputs[6]; float c6s = vertical_coefficients[6]; )
- stbIF7( float STBIR_SIMD_STREAMOUT_PTR( * ) output7 = outputs[7]; float c7s = vertical_coefficients[7]; )
-
- #ifdef STBIR_SIMD
- {
- stbIF0(stbir__simdfX c0 = stbir__simdf_frepX( c0s ); )
- stbIF1(stbir__simdfX c1 = stbir__simdf_frepX( c1s ); )
- stbIF2(stbir__simdfX c2 = stbir__simdf_frepX( c2s ); )
- stbIF3(stbir__simdfX c3 = stbir__simdf_frepX( c3s ); )
- stbIF4(stbir__simdfX c4 = stbir__simdf_frepX( c4s ); )
- stbIF5(stbir__simdfX c5 = stbir__simdf_frepX( c5s ); )
- stbIF6(stbir__simdfX c6 = stbir__simdf_frepX( c6s ); )
- stbIF7(stbir__simdfX c7 = stbir__simdf_frepX( c7s ); )
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while ( ( (char*)input_end - (char*) input ) >= (16*stbir__simdfX_float_count) )
- {
- stbir__simdfX o0, o1, o2, o3, r0, r1, r2, r3;
- STBIR_SIMD_NO_UNROLL(output0);
-
- stbir__simdfX_load( r0, input ); stbir__simdfX_load( r1, input+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input+(3*stbir__simdfX_float_count) );
-
- #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
- stbIF0( stbir__simdfX_load( o0, output0 ); stbir__simdfX_load( o1, output0+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output0+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output0+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c0 ); stbir__simdfX_madd( o1, o1, r1, c0 ); stbir__simdfX_madd( o2, o2, r2, c0 ); stbir__simdfX_madd( o3, o3, r3, c0 );
- stbir__simdfX_store( output0, o0 ); stbir__simdfX_store( output0+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output0+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output0+(3*stbir__simdfX_float_count), o3 ); )
- stbIF1( stbir__simdfX_load( o0, output1 ); stbir__simdfX_load( o1, output1+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output1+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output1+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c1 ); stbir__simdfX_madd( o1, o1, r1, c1 ); stbir__simdfX_madd( o2, o2, r2, c1 ); stbir__simdfX_madd( o3, o3, r3, c1 );
- stbir__simdfX_store( output1, o0 ); stbir__simdfX_store( output1+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output1+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output1+(3*stbir__simdfX_float_count), o3 ); )
- stbIF2( stbir__simdfX_load( o0, output2 ); stbir__simdfX_load( o1, output2+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output2+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output2+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c2 ); stbir__simdfX_madd( o1, o1, r1, c2 ); stbir__simdfX_madd( o2, o2, r2, c2 ); stbir__simdfX_madd( o3, o3, r3, c2 );
- stbir__simdfX_store( output2, o0 ); stbir__simdfX_store( output2+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output2+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output2+(3*stbir__simdfX_float_count), o3 ); )
- stbIF3( stbir__simdfX_load( o0, output3 ); stbir__simdfX_load( o1, output3+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output3+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output3+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c3 ); stbir__simdfX_madd( o1, o1, r1, c3 ); stbir__simdfX_madd( o2, o2, r2, c3 ); stbir__simdfX_madd( o3, o3, r3, c3 );
- stbir__simdfX_store( output3, o0 ); stbir__simdfX_store( output3+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output3+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output3+(3*stbir__simdfX_float_count), o3 ); )
- stbIF4( stbir__simdfX_load( o0, output4 ); stbir__simdfX_load( o1, output4+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output4+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output4+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c4 ); stbir__simdfX_madd( o1, o1, r1, c4 ); stbir__simdfX_madd( o2, o2, r2, c4 ); stbir__simdfX_madd( o3, o3, r3, c4 );
- stbir__simdfX_store( output4, o0 ); stbir__simdfX_store( output4+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output4+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output4+(3*stbir__simdfX_float_count), o3 ); )
- stbIF5( stbir__simdfX_load( o0, output5 ); stbir__simdfX_load( o1, output5+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output5+(2*stbir__simdfX_float_count)); stbir__simdfX_load( o3, output5+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c5 ); stbir__simdfX_madd( o1, o1, r1, c5 ); stbir__simdfX_madd( o2, o2, r2, c5 ); stbir__simdfX_madd( o3, o3, r3, c5 );
- stbir__simdfX_store( output5, o0 ); stbir__simdfX_store( output5+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output5+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output5+(3*stbir__simdfX_float_count), o3 ); )
- stbIF6( stbir__simdfX_load( o0, output6 ); stbir__simdfX_load( o1, output6+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output6+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output6+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c6 ); stbir__simdfX_madd( o1, o1, r1, c6 ); stbir__simdfX_madd( o2, o2, r2, c6 ); stbir__simdfX_madd( o3, o3, r3, c6 );
- stbir__simdfX_store( output6, o0 ); stbir__simdfX_store( output6+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output6+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output6+(3*stbir__simdfX_float_count), o3 ); )
- stbIF7( stbir__simdfX_load( o0, output7 ); stbir__simdfX_load( o1, output7+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output7+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output7+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c7 ); stbir__simdfX_madd( o1, o1, r1, c7 ); stbir__simdfX_madd( o2, o2, r2, c7 ); stbir__simdfX_madd( o3, o3, r3, c7 );
- stbir__simdfX_store( output7, o0 ); stbir__simdfX_store( output7+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output7+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output7+(3*stbir__simdfX_float_count), o3 ); )
- #else
- stbIF0( stbir__simdfX_mult( o0, r0, c0 ); stbir__simdfX_mult( o1, r1, c0 ); stbir__simdfX_mult( o2, r2, c0 ); stbir__simdfX_mult( o3, r3, c0 );
- stbir__simdfX_store( output0, o0 ); stbir__simdfX_store( output0+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output0+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output0+(3*stbir__simdfX_float_count), o3 ); )
- stbIF1( stbir__simdfX_mult( o0, r0, c1 ); stbir__simdfX_mult( o1, r1, c1 ); stbir__simdfX_mult( o2, r2, c1 ); stbir__simdfX_mult( o3, r3, c1 );
- stbir__simdfX_store( output1, o0 ); stbir__simdfX_store( output1+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output1+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output1+(3*stbir__simdfX_float_count), o3 ); )
- stbIF2( stbir__simdfX_mult( o0, r0, c2 ); stbir__simdfX_mult( o1, r1, c2 ); stbir__simdfX_mult( o2, r2, c2 ); stbir__simdfX_mult( o3, r3, c2 );
- stbir__simdfX_store( output2, o0 ); stbir__simdfX_store( output2+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output2+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output2+(3*stbir__simdfX_float_count), o3 ); )
- stbIF3( stbir__simdfX_mult( o0, r0, c3 ); stbir__simdfX_mult( o1, r1, c3 ); stbir__simdfX_mult( o2, r2, c3 ); stbir__simdfX_mult( o3, r3, c3 );
- stbir__simdfX_store( output3, o0 ); stbir__simdfX_store( output3+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output3+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output3+(3*stbir__simdfX_float_count), o3 ); )
- stbIF4( stbir__simdfX_mult( o0, r0, c4 ); stbir__simdfX_mult( o1, r1, c4 ); stbir__simdfX_mult( o2, r2, c4 ); stbir__simdfX_mult( o3, r3, c4 );
- stbir__simdfX_store( output4, o0 ); stbir__simdfX_store( output4+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output4+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output4+(3*stbir__simdfX_float_count), o3 ); )
- stbIF5( stbir__simdfX_mult( o0, r0, c5 ); stbir__simdfX_mult( o1, r1, c5 ); stbir__simdfX_mult( o2, r2, c5 ); stbir__simdfX_mult( o3, r3, c5 );
- stbir__simdfX_store( output5, o0 ); stbir__simdfX_store( output5+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output5+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output5+(3*stbir__simdfX_float_count), o3 ); )
- stbIF6( stbir__simdfX_mult( o0, r0, c6 ); stbir__simdfX_mult( o1, r1, c6 ); stbir__simdfX_mult( o2, r2, c6 ); stbir__simdfX_mult( o3, r3, c6 );
- stbir__simdfX_store( output6, o0 ); stbir__simdfX_store( output6+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output6+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output6+(3*stbir__simdfX_float_count), o3 ); )
- stbIF7( stbir__simdfX_mult( o0, r0, c7 ); stbir__simdfX_mult( o1, r1, c7 ); stbir__simdfX_mult( o2, r2, c7 ); stbir__simdfX_mult( o3, r3, c7 );
- stbir__simdfX_store( output7, o0 ); stbir__simdfX_store( output7+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output7+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output7+(3*stbir__simdfX_float_count), o3 ); )
- #endif
-
- input += (4*stbir__simdfX_float_count);
- stbIF0( output0 += (4*stbir__simdfX_float_count); ) stbIF1( output1 += (4*stbir__simdfX_float_count); ) stbIF2( output2 += (4*stbir__simdfX_float_count); ) stbIF3( output3 += (4*stbir__simdfX_float_count); ) stbIF4( output4 += (4*stbir__simdfX_float_count); ) stbIF5( output5 += (4*stbir__simdfX_float_count); ) stbIF6( output6 += (4*stbir__simdfX_float_count); ) stbIF7( output7 += (4*stbir__simdfX_float_count); )
- }
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while ( ( (char*)input_end - (char*) input ) >= 16 )
- {
- stbir__simdf o0, r0;
- STBIR_SIMD_NO_UNROLL(output0);
-
- stbir__simdf_load( r0, input );
-
- #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
- stbIF0( stbir__simdf_load( o0, output0 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c0 ) ); stbir__simdf_store( output0, o0 ); )
- stbIF1( stbir__simdf_load( o0, output1 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c1 ) ); stbir__simdf_store( output1, o0 ); )
- stbIF2( stbir__simdf_load( o0, output2 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c2 ) ); stbir__simdf_store( output2, o0 ); )
- stbIF3( stbir__simdf_load( o0, output3 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c3 ) ); stbir__simdf_store( output3, o0 ); )
- stbIF4( stbir__simdf_load( o0, output4 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c4 ) ); stbir__simdf_store( output4, o0 ); )
- stbIF5( stbir__simdf_load( o0, output5 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c5 ) ); stbir__simdf_store( output5, o0 ); )
- stbIF6( stbir__simdf_load( o0, output6 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c6 ) ); stbir__simdf_store( output6, o0 ); )
- stbIF7( stbir__simdf_load( o0, output7 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c7 ) ); stbir__simdf_store( output7, o0 ); )
- #else
- stbIF0( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c0 ) ); stbir__simdf_store( output0, o0 ); )
- stbIF1( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c1 ) ); stbir__simdf_store( output1, o0 ); )
- stbIF2( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c2 ) ); stbir__simdf_store( output2, o0 ); )
- stbIF3( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c3 ) ); stbir__simdf_store( output3, o0 ); )
- stbIF4( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c4 ) ); stbir__simdf_store( output4, o0 ); )
- stbIF5( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c5 ) ); stbir__simdf_store( output5, o0 ); )
- stbIF6( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c6 ) ); stbir__simdf_store( output6, o0 ); )
- stbIF7( stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c7 ) ); stbir__simdf_store( output7, o0 ); )
- #endif
-
- input += 4;
- stbIF0( output0 += 4; ) stbIF1( output1 += 4; ) stbIF2( output2 += 4; ) stbIF3( output3 += 4; ) stbIF4( output4 += 4; ) stbIF5( output5 += 4; ) stbIF6( output6 += 4; ) stbIF7( output7 += 4; )
- }
- }
- #else
- STBIR_NO_UNROLL_LOOP_START
- while ( ( (char*)input_end - (char*) input ) >= 16 )
- {
- float r0, r1, r2, r3;
- STBIR_NO_UNROLL(input);
-
- r0 = input[0], r1 = input[1], r2 = input[2], r3 = input[3];
-
- #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
- stbIF0( output0[0] += ( r0 * c0s ); output0[1] += ( r1 * c0s ); output0[2] += ( r2 * c0s ); output0[3] += ( r3 * c0s ); )
- stbIF1( output1[0] += ( r0 * c1s ); output1[1] += ( r1 * c1s ); output1[2] += ( r2 * c1s ); output1[3] += ( r3 * c1s ); )
- stbIF2( output2[0] += ( r0 * c2s ); output2[1] += ( r1 * c2s ); output2[2] += ( r2 * c2s ); output2[3] += ( r3 * c2s ); )
- stbIF3( output3[0] += ( r0 * c3s ); output3[1] += ( r1 * c3s ); output3[2] += ( r2 * c3s ); output3[3] += ( r3 * c3s ); )
- stbIF4( output4[0] += ( r0 * c4s ); output4[1] += ( r1 * c4s ); output4[2] += ( r2 * c4s ); output4[3] += ( r3 * c4s ); )
- stbIF5( output5[0] += ( r0 * c5s ); output5[1] += ( r1 * c5s ); output5[2] += ( r2 * c5s ); output5[3] += ( r3 * c5s ); )
- stbIF6( output6[0] += ( r0 * c6s ); output6[1] += ( r1 * c6s ); output6[2] += ( r2 * c6s ); output6[3] += ( r3 * c6s ); )
- stbIF7( output7[0] += ( r0 * c7s ); output7[1] += ( r1 * c7s ); output7[2] += ( r2 * c7s ); output7[3] += ( r3 * c7s ); )
- #else
- stbIF0( output0[0] = ( r0 * c0s ); output0[1] = ( r1 * c0s ); output0[2] = ( r2 * c0s ); output0[3] = ( r3 * c0s ); )
- stbIF1( output1[0] = ( r0 * c1s ); output1[1] = ( r1 * c1s ); output1[2] = ( r2 * c1s ); output1[3] = ( r3 * c1s ); )
- stbIF2( output2[0] = ( r0 * c2s ); output2[1] = ( r1 * c2s ); output2[2] = ( r2 * c2s ); output2[3] = ( r3 * c2s ); )
- stbIF3( output3[0] = ( r0 * c3s ); output3[1] = ( r1 * c3s ); output3[2] = ( r2 * c3s ); output3[3] = ( r3 * c3s ); )
- stbIF4( output4[0] = ( r0 * c4s ); output4[1] = ( r1 * c4s ); output4[2] = ( r2 * c4s ); output4[3] = ( r3 * c4s ); )
- stbIF5( output5[0] = ( r0 * c5s ); output5[1] = ( r1 * c5s ); output5[2] = ( r2 * c5s ); output5[3] = ( r3 * c5s ); )
- stbIF6( output6[0] = ( r0 * c6s ); output6[1] = ( r1 * c6s ); output6[2] = ( r2 * c6s ); output6[3] = ( r3 * c6s ); )
- stbIF7( output7[0] = ( r0 * c7s ); output7[1] = ( r1 * c7s ); output7[2] = ( r2 * c7s ); output7[3] = ( r3 * c7s ); )
- #endif
-
- input += 4;
- stbIF0( output0 += 4; ) stbIF1( output1 += 4; ) stbIF2( output2 += 4; ) stbIF3( output3 += 4; ) stbIF4( output4 += 4; ) stbIF5( output5 += 4; ) stbIF6( output6 += 4; ) stbIF7( output7 += 4; )
- }
- #endif
- STBIR_NO_UNROLL_LOOP_START
- while ( input < input_end )
- {
- float r = input[0];
- STBIR_NO_UNROLL(output0);
-
- #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
- stbIF0( output0[0] += ( r * c0s ); )
- stbIF1( output1[0] += ( r * c1s ); )
- stbIF2( output2[0] += ( r * c2s ); )
- stbIF3( output3[0] += ( r * c3s ); )
- stbIF4( output4[0] += ( r * c4s ); )
- stbIF5( output5[0] += ( r * c5s ); )
- stbIF6( output6[0] += ( r * c6s ); )
- stbIF7( output7[0] += ( r * c7s ); )
- #else
- stbIF0( output0[0] = ( r * c0s ); )
- stbIF1( output1[0] = ( r * c1s ); )
- stbIF2( output2[0] = ( r * c2s ); )
- stbIF3( output3[0] = ( r * c3s ); )
- stbIF4( output4[0] = ( r * c4s ); )
- stbIF5( output5[0] = ( r * c5s ); )
- stbIF6( output6[0] = ( r * c6s ); )
- stbIF7( output7[0] = ( r * c7s ); )
- #endif
-
- ++input;
- stbIF0( ++output0; ) stbIF1( ++output1; ) stbIF2( ++output2; ) stbIF3( ++output3; ) stbIF4( ++output4; ) stbIF5( ++output5; ) stbIF6( ++output6; ) stbIF7( ++output7; )
- }
-}
-
-static void STBIR_chans( stbir__vertical_gather_with_,_coeffs)( float * outputp, float const * vertical_coefficients, float const ** inputs, float const * input0_end )
-{
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = outputp;
-
- stbIF0( float const * input0 = inputs[0]; float c0s = vertical_coefficients[0]; )
- stbIF1( float const * input1 = inputs[1]; float c1s = vertical_coefficients[1]; )
- stbIF2( float const * input2 = inputs[2]; float c2s = vertical_coefficients[2]; )
- stbIF3( float const * input3 = inputs[3]; float c3s = vertical_coefficients[3]; )
- stbIF4( float const * input4 = inputs[4]; float c4s = vertical_coefficients[4]; )
- stbIF5( float const * input5 = inputs[5]; float c5s = vertical_coefficients[5]; )
- stbIF6( float const * input6 = inputs[6]; float c6s = vertical_coefficients[6]; )
- stbIF7( float const * input7 = inputs[7]; float c7s = vertical_coefficients[7]; )
-
-#if ( STBIR__vertical_channels == 1 ) && !defined(STB_IMAGE_RESIZE_VERTICAL_CONTINUE)
- // check single channel one weight
- if ( ( c0s >= (1.0f-0.000001f) ) && ( c0s <= (1.0f+0.000001f) ) )
- {
- STBIR_MEMCPY( output, input0, (char*)input0_end - (char*)input0 );
- return;
- }
-#endif
-
- #ifdef STBIR_SIMD
- {
- stbIF0(stbir__simdfX c0 = stbir__simdf_frepX( c0s ); )
- stbIF1(stbir__simdfX c1 = stbir__simdf_frepX( c1s ); )
- stbIF2(stbir__simdfX c2 = stbir__simdf_frepX( c2s ); )
- stbIF3(stbir__simdfX c3 = stbir__simdf_frepX( c3s ); )
- stbIF4(stbir__simdfX c4 = stbir__simdf_frepX( c4s ); )
- stbIF5(stbir__simdfX c5 = stbir__simdf_frepX( c5s ); )
- stbIF6(stbir__simdfX c6 = stbir__simdf_frepX( c6s ); )
- stbIF7(stbir__simdfX c7 = stbir__simdf_frepX( c7s ); )
-
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while ( ( (char*)input0_end - (char*) input0 ) >= (16*stbir__simdfX_float_count) )
- {
- stbir__simdfX o0, o1, o2, o3, r0, r1, r2, r3;
- STBIR_SIMD_NO_UNROLL(output);
-
- // prefetch four loop iterations ahead (doesn't affect much for small resizes, but helps with big ones)
- stbIF0( stbir__prefetch( input0 + (16*stbir__simdfX_float_count) ); )
- stbIF1( stbir__prefetch( input1 + (16*stbir__simdfX_float_count) ); )
- stbIF2( stbir__prefetch( input2 + (16*stbir__simdfX_float_count) ); )
- stbIF3( stbir__prefetch( input3 + (16*stbir__simdfX_float_count) ); )
- stbIF4( stbir__prefetch( input4 + (16*stbir__simdfX_float_count) ); )
- stbIF5( stbir__prefetch( input5 + (16*stbir__simdfX_float_count) ); )
- stbIF6( stbir__prefetch( input6 + (16*stbir__simdfX_float_count) ); )
- stbIF7( stbir__prefetch( input7 + (16*stbir__simdfX_float_count) ); )
-
- #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
- stbIF0( stbir__simdfX_load( o0, output ); stbir__simdfX_load( o1, output+stbir__simdfX_float_count ); stbir__simdfX_load( o2, output+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( o3, output+(3*stbir__simdfX_float_count) );
- stbir__simdfX_load( r0, input0 ); stbir__simdfX_load( r1, input0+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input0+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input0+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c0 ); stbir__simdfX_madd( o1, o1, r1, c0 ); stbir__simdfX_madd( o2, o2, r2, c0 ); stbir__simdfX_madd( o3, o3, r3, c0 ); )
- #else
- stbIF0( stbir__simdfX_load( r0, input0 ); stbir__simdfX_load( r1, input0+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input0+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input0+(3*stbir__simdfX_float_count) );
- stbir__simdfX_mult( o0, r0, c0 ); stbir__simdfX_mult( o1, r1, c0 ); stbir__simdfX_mult( o2, r2, c0 ); stbir__simdfX_mult( o3, r3, c0 ); )
- #endif
-
- stbIF1( stbir__simdfX_load( r0, input1 ); stbir__simdfX_load( r1, input1+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input1+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input1+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c1 ); stbir__simdfX_madd( o1, o1, r1, c1 ); stbir__simdfX_madd( o2, o2, r2, c1 ); stbir__simdfX_madd( o3, o3, r3, c1 ); )
- stbIF2( stbir__simdfX_load( r0, input2 ); stbir__simdfX_load( r1, input2+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input2+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input2+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c2 ); stbir__simdfX_madd( o1, o1, r1, c2 ); stbir__simdfX_madd( o2, o2, r2, c2 ); stbir__simdfX_madd( o3, o3, r3, c2 ); )
- stbIF3( stbir__simdfX_load( r0, input3 ); stbir__simdfX_load( r1, input3+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input3+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input3+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c3 ); stbir__simdfX_madd( o1, o1, r1, c3 ); stbir__simdfX_madd( o2, o2, r2, c3 ); stbir__simdfX_madd( o3, o3, r3, c3 ); )
- stbIF4( stbir__simdfX_load( r0, input4 ); stbir__simdfX_load( r1, input4+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input4+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input4+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c4 ); stbir__simdfX_madd( o1, o1, r1, c4 ); stbir__simdfX_madd( o2, o2, r2, c4 ); stbir__simdfX_madd( o3, o3, r3, c4 ); )
- stbIF5( stbir__simdfX_load( r0, input5 ); stbir__simdfX_load( r1, input5+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input5+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input5+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c5 ); stbir__simdfX_madd( o1, o1, r1, c5 ); stbir__simdfX_madd( o2, o2, r2, c5 ); stbir__simdfX_madd( o3, o3, r3, c5 ); )
- stbIF6( stbir__simdfX_load( r0, input6 ); stbir__simdfX_load( r1, input6+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input6+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input6+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c6 ); stbir__simdfX_madd( o1, o1, r1, c6 ); stbir__simdfX_madd( o2, o2, r2, c6 ); stbir__simdfX_madd( o3, o3, r3, c6 ); )
- stbIF7( stbir__simdfX_load( r0, input7 ); stbir__simdfX_load( r1, input7+stbir__simdfX_float_count ); stbir__simdfX_load( r2, input7+(2*stbir__simdfX_float_count) ); stbir__simdfX_load( r3, input7+(3*stbir__simdfX_float_count) );
- stbir__simdfX_madd( o0, o0, r0, c7 ); stbir__simdfX_madd( o1, o1, r1, c7 ); stbir__simdfX_madd( o2, o2, r2, c7 ); stbir__simdfX_madd( o3, o3, r3, c7 ); )
-
- stbir__simdfX_store( output, o0 ); stbir__simdfX_store( output+stbir__simdfX_float_count, o1 ); stbir__simdfX_store( output+(2*stbir__simdfX_float_count), o2 ); stbir__simdfX_store( output+(3*stbir__simdfX_float_count), o3 );
- output += (4*stbir__simdfX_float_count);
- stbIF0( input0 += (4*stbir__simdfX_float_count); ) stbIF1( input1 += (4*stbir__simdfX_float_count); ) stbIF2( input2 += (4*stbir__simdfX_float_count); ) stbIF3( input3 += (4*stbir__simdfX_float_count); ) stbIF4( input4 += (4*stbir__simdfX_float_count); ) stbIF5( input5 += (4*stbir__simdfX_float_count); ) stbIF6( input6 += (4*stbir__simdfX_float_count); ) stbIF7( input7 += (4*stbir__simdfX_float_count); )
- }
-
- STBIR_SIMD_NO_UNROLL_LOOP_START
- while ( ( (char*)input0_end - (char*) input0 ) >= 16 )
- {
- stbir__simdf o0, r0;
- STBIR_SIMD_NO_UNROLL(output);
-
- #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
- stbIF0( stbir__simdf_load( o0, output ); stbir__simdf_load( r0, input0 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c0 ) ); )
- #else
- stbIF0( stbir__simdf_load( r0, input0 ); stbir__simdf_mult( o0, r0, stbir__if_simdf8_cast_to_simdf4( c0 ) ); )
- #endif
- stbIF1( stbir__simdf_load( r0, input1 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c1 ) ); )
- stbIF2( stbir__simdf_load( r0, input2 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c2 ) ); )
- stbIF3( stbir__simdf_load( r0, input3 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c3 ) ); )
- stbIF4( stbir__simdf_load( r0, input4 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c4 ) ); )
- stbIF5( stbir__simdf_load( r0, input5 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c5 ) ); )
- stbIF6( stbir__simdf_load( r0, input6 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c6 ) ); )
- stbIF7( stbir__simdf_load( r0, input7 ); stbir__simdf_madd( o0, o0, r0, stbir__if_simdf8_cast_to_simdf4( c7 ) ); )
-
- stbir__simdf_store( output, o0 );
- output += 4;
- stbIF0( input0 += 4; ) stbIF1( input1 += 4; ) stbIF2( input2 += 4; ) stbIF3( input3 += 4; ) stbIF4( input4 += 4; ) stbIF5( input5 += 4; ) stbIF6( input6 += 4; ) stbIF7( input7 += 4; )
- }
- }
- #else
- STBIR_NO_UNROLL_LOOP_START
- while ( ( (char*)input0_end - (char*) input0 ) >= 16 )
- {
- float o0, o1, o2, o3;
- STBIR_NO_UNROLL(output);
- #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
- stbIF0( o0 = output[0] + input0[0] * c0s; o1 = output[1] + input0[1] * c0s; o2 = output[2] + input0[2] * c0s; o3 = output[3] + input0[3] * c0s; )
- #else
- stbIF0( o0 = input0[0] * c0s; o1 = input0[1] * c0s; o2 = input0[2] * c0s; o3 = input0[3] * c0s; )
- #endif
- stbIF1( o0 += input1[0] * c1s; o1 += input1[1] * c1s; o2 += input1[2] * c1s; o3 += input1[3] * c1s; )
- stbIF2( o0 += input2[0] * c2s; o1 += input2[1] * c2s; o2 += input2[2] * c2s; o3 += input2[3] * c2s; )
- stbIF3( o0 += input3[0] * c3s; o1 += input3[1] * c3s; o2 += input3[2] * c3s; o3 += input3[3] * c3s; )
- stbIF4( o0 += input4[0] * c4s; o1 += input4[1] * c4s; o2 += input4[2] * c4s; o3 += input4[3] * c4s; )
- stbIF5( o0 += input5[0] * c5s; o1 += input5[1] * c5s; o2 += input5[2] * c5s; o3 += input5[3] * c5s; )
- stbIF6( o0 += input6[0] * c6s; o1 += input6[1] * c6s; o2 += input6[2] * c6s; o3 += input6[3] * c6s; )
- stbIF7( o0 += input7[0] * c7s; o1 += input7[1] * c7s; o2 += input7[2] * c7s; o3 += input7[3] * c7s; )
- output[0] = o0; output[1] = o1; output[2] = o2; output[3] = o3;
- output += 4;
- stbIF0( input0 += 4; ) stbIF1( input1 += 4; ) stbIF2( input2 += 4; ) stbIF3( input3 += 4; ) stbIF4( input4 += 4; ) stbIF5( input5 += 4; ) stbIF6( input6 += 4; ) stbIF7( input7 += 4; )
- }
- #endif
- STBIR_NO_UNROLL_LOOP_START
- while ( input0 < input0_end )
- {
- float o0;
- STBIR_NO_UNROLL(output);
- #ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
- stbIF0( o0 = output[0] + input0[0] * c0s; )
- #else
- stbIF0( o0 = input0[0] * c0s; )
- #endif
- stbIF1( o0 += input1[0] * c1s; )
- stbIF2( o0 += input2[0] * c2s; )
- stbIF3( o0 += input3[0] * c3s; )
- stbIF4( o0 += input4[0] * c4s; )
- stbIF5( o0 += input5[0] * c5s; )
- stbIF6( o0 += input6[0] * c6s; )
- stbIF7( o0 += input7[0] * c7s; )
- output[0] = o0;
- ++output;
- stbIF0( ++input0; ) stbIF1( ++input1; ) stbIF2( ++input2; ) stbIF3( ++input3; ) stbIF4( ++input4; ) stbIF5( ++input5; ) stbIF6( ++input6; ) stbIF7( ++input7; )
- }
-}
-
-#undef stbIF0
-#undef stbIF1
-#undef stbIF2
-#undef stbIF3
-#undef stbIF4
-#undef stbIF5
-#undef stbIF6
-#undef stbIF7
-#undef STB_IMAGE_RESIZE_DO_VERTICALS
-#undef STBIR__vertical_channels
-#undef STB_IMAGE_RESIZE_DO_HORIZONTALS
-#undef STBIR_strs_join24
-#undef STBIR_strs_join14
-#undef STBIR_chans
-#ifdef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#undef STB_IMAGE_RESIZE_VERTICAL_CONTINUE
-#endif
-
-#else // !STB_IMAGE_RESIZE_DO_VERTICALS
-
-#define STBIR_chans( start, end ) STBIR_strs_join1(start,STBIR__horizontal_channels,end)
-
-#ifndef stbir__2_coeff_only
-#define stbir__2_coeff_only() \
- stbir__1_coeff_only(); \
- stbir__1_coeff_remnant(1);
-#endif
-
-#ifndef stbir__2_coeff_remnant
-#define stbir__2_coeff_remnant( ofs ) \
- stbir__1_coeff_remnant(ofs); \
- stbir__1_coeff_remnant((ofs)+1);
-#endif
-
-#ifndef stbir__3_coeff_only
-#define stbir__3_coeff_only() \
- stbir__2_coeff_only(); \
- stbir__1_coeff_remnant(2);
-#endif
-
-#ifndef stbir__3_coeff_remnant
-#define stbir__3_coeff_remnant( ofs ) \
- stbir__2_coeff_remnant(ofs); \
- stbir__1_coeff_remnant((ofs)+2);
-#endif
-
-#ifndef stbir__3_coeff_setup
-#define stbir__3_coeff_setup()
-#endif
-
-#ifndef stbir__4_coeff_start
-#define stbir__4_coeff_start() \
- stbir__2_coeff_only(); \
- stbir__2_coeff_remnant(2);
-#endif
-
-#ifndef stbir__4_coeff_continue_from_4
-#define stbir__4_coeff_continue_from_4( ofs ) \
- stbir__2_coeff_remnant(ofs); \
- stbir__2_coeff_remnant((ofs)+2);
-#endif
-
-#ifndef stbir__store_output_tiny
-#define stbir__store_output_tiny stbir__store_output
-#endif
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_1_coeff)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__1_coeff_only();
- stbir__store_output_tiny();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_2_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__2_coeff_only();
- stbir__store_output_tiny();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_3_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__3_coeff_only();
- stbir__store_output_tiny();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_4_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__4_coeff_start();
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_5_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__4_coeff_start();
- stbir__1_coeff_remnant(4);
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_6_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__4_coeff_start();
- stbir__2_coeff_remnant(4);
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_7_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- stbir__3_coeff_setup();
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
-
- stbir__4_coeff_start();
- stbir__3_coeff_remnant(4);
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_8_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__4_coeff_start();
- stbir__4_coeff_continue_from_4(4);
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_9_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__4_coeff_start();
- stbir__4_coeff_continue_from_4(4);
- stbir__1_coeff_remnant(8);
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_10_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__4_coeff_start();
- stbir__4_coeff_continue_from_4(4);
- stbir__2_coeff_remnant(8);
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_11_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- stbir__3_coeff_setup();
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__4_coeff_start();
- stbir__4_coeff_continue_from_4(4);
- stbir__3_coeff_remnant(8);
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_12_coeffs)( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- float const * hc = horizontal_coefficients;
- stbir__4_coeff_start();
- stbir__4_coeff_continue_from_4(4);
- stbir__4_coeff_continue_from_4(8);
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_n_coeffs_mod0 )( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- int n = ( ( horizontal_contributors->n1 - horizontal_contributors->n0 + 1 ) - 4 + 3 ) >> 2;
- float const * hc = horizontal_coefficients;
-
- stbir__4_coeff_start();
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- hc += 4;
- decode += STBIR__horizontal_channels * 4;
- stbir__4_coeff_continue_from_4( 0 );
- --n;
- } while ( n > 0 );
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_n_coeffs_mod1 )( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- int n = ( ( horizontal_contributors->n1 - horizontal_contributors->n0 + 1 ) - 5 + 3 ) >> 2;
- float const * hc = horizontal_coefficients;
-
- stbir__4_coeff_start();
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- hc += 4;
- decode += STBIR__horizontal_channels * 4;
- stbir__4_coeff_continue_from_4( 0 );
- --n;
- } while ( n > 0 );
- stbir__1_coeff_remnant( 4 );
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_n_coeffs_mod2 )( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- int n = ( ( horizontal_contributors->n1 - horizontal_contributors->n0 + 1 ) - 6 + 3 ) >> 2;
- float const * hc = horizontal_coefficients;
-
- stbir__4_coeff_start();
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- hc += 4;
- decode += STBIR__horizontal_channels * 4;
- stbir__4_coeff_continue_from_4( 0 );
- --n;
- } while ( n > 0 );
- stbir__2_coeff_remnant( 4 );
-
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static void STBIR_chans( stbir__horizontal_gather_,_channels_with_n_coeffs_mod3 )( float * output_buffer, unsigned int output_sub_size, float const * decode_buffer, stbir__contributors const * horizontal_contributors, float const * horizontal_coefficients, int coefficient_width )
-{
- float const * output_end = output_buffer + output_sub_size * STBIR__horizontal_channels;
- float STBIR_SIMD_STREAMOUT_PTR( * ) output = output_buffer;
- stbir__3_coeff_setup();
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- float const * decode = decode_buffer + horizontal_contributors->n0 * STBIR__horizontal_channels;
- int n = ( ( horizontal_contributors->n1 - horizontal_contributors->n0 + 1 ) - 7 + 3 ) >> 2;
- float const * hc = horizontal_coefficients;
-
- stbir__4_coeff_start();
- STBIR_SIMD_NO_UNROLL_LOOP_START
- do {
- hc += 4;
- decode += STBIR__horizontal_channels * 4;
- stbir__4_coeff_continue_from_4( 0 );
- --n;
- } while ( n > 0 );
- stbir__3_coeff_remnant( 4 );
-
- stbir__store_output();
- } while ( output < output_end );
-}
-
-static stbir__horizontal_gather_channels_func * STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_funcs)[4]=
-{
- STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_mod0),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_mod1),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_mod2),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_n_coeffs_mod3),
-};
-
-static stbir__horizontal_gather_channels_func * STBIR_chans(stbir__horizontal_gather_,_channels_funcs)[12]=
-{
- STBIR_chans(stbir__horizontal_gather_,_channels_with_1_coeff),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_2_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_3_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_4_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_5_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_6_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_7_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_8_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_9_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_10_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_11_coeffs),
- STBIR_chans(stbir__horizontal_gather_,_channels_with_12_coeffs),
-};
-
-#undef STBIR__horizontal_channels
-#undef STB_IMAGE_RESIZE_DO_HORIZONTALS
-#undef stbir__1_coeff_only
-#undef stbir__1_coeff_remnant
-#undef stbir__2_coeff_only
-#undef stbir__2_coeff_remnant
-#undef stbir__3_coeff_only
-#undef stbir__3_coeff_remnant
-#undef stbir__3_coeff_setup
-#undef stbir__4_coeff_start
-#undef stbir__4_coeff_continue_from_4
-#undef stbir__store_output
-#undef stbir__store_output_tiny
-#undef STBIR_chans
-
-#endif // HORIZONALS
-
-#undef STBIR_strs_join2
-#undef STBIR_strs_join1
-
-#endif // STB_IMAGE_RESIZE_DO_HORIZONTALS/VERTICALS/CODERS
-
-/*
-------------------------------------------------------------------------------
-This software is available under 2 licenses -- choose whichever you prefer.
-------------------------------------------------------------------------------
-ALTERNATIVE A - MIT License
-Copyright (c) 2017 Sean Barrett
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-------------------------------------------------------------------------------
-ALTERNATIVE B - Public Domain (www.unlicense.org)
-This is free and unencumbered software released into the public domain.
-Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
-software, either in source code form or as a compiled binary, for any purpose,
-commercial or non-commercial, and by any means.
-In jurisdictions that recognize copyright laws, the author or authors of this
-software dedicate any and all copyright interest in the software to the public
-domain. We make this dedication for the benefit of the public at large and to
-the detriment of our heirs and successors. We intend this dedication to be an
-overt act of relinquishment in perpetuity of all present and future rights to
-this software under copyright law.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
-ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-------------------------------------------------------------------------------
-*/
diff --git a/src/deps/stbi/stb_rect_pack.h b/src/deps/stbi/stb_rect_pack.h
deleted file mode 100644
index 5c848de0..00000000
--- a/src/deps/stbi/stb_rect_pack.h
+++ /dev/null
@@ -1,628 +0,0 @@
-// stb_rect_pack.h - v1.00 - public domain - rectangle packing
-// Sean Barrett 2014
-//
-// Useful for e.g. packing rectangular textures into an atlas.
-// Does not do rotation.
-//
-// Not necessarily the awesomest packing method, but better than
-// the totally naive one in stb_truetype (which is primarily what
-// this is meant to replace).
-//
-// Has only had a few tests run, may have issues.
-//
-// More docs to come.
-//
-// No memory allocations; uses qsort() and assert() from stdlib.
-// Can override those by defining STBRP_SORT and STBRP_ASSERT.
-//
-// This library currently uses the Skyline Bottom-Left algorithm.
-//
-// Please note: better rectangle packers are welcome! Please
-// implement them to the same API, but with a different init
-// function.
-//
-// Credits
-//
-// Library
-// Sean Barrett
-// Minor features
-// Martins Mozeiko
-// github:IntellectualKitty
-//
-// Bugfixes / warning fixes
-// Jeremy Jaussaud
-// Fabian Giesen
-//
-// Version history:
-//
-// 1.00 (2019-02-25) avoid small space waste; gracefully fail too-wide rectangles
-// 0.99 (2019-02-07) warning fixes
-// 0.11 (2017-03-03) return packing success/fail result
-// 0.10 (2016-10-25) remove cast-away-const to avoid warnings
-// 0.09 (2016-08-27) fix compiler warnings
-// 0.08 (2015-09-13) really fix bug with empty rects (w=0 or h=0)
-// 0.07 (2015-09-13) fix bug with empty rects (w=0 or h=0)
-// 0.06 (2015-04-15) added STBRP_SORT to allow replacing qsort
-// 0.05: added STBRP_ASSERT to allow replacing assert
-// 0.04: fixed minor bug in STBRP_LARGE_RECTS support
-// 0.01: initial release
-//
-// LICENSE
-//
-// See end of file for license information.
-
-//////////////////////////////////////////////////////////////////////////////
-//
-// INCLUDE SECTION
-//
-
-#ifndef STB_INCLUDE_STB_RECT_PACK_H
-#define STB_INCLUDE_STB_RECT_PACK_H
-
-#define STB_RECT_PACK_VERSION 1
-
-#ifdef STBRP_STATIC
-#define STBRP_DEF static
-#else
-#define STBRP_DEF extern
-#endif
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-typedef struct stbrp_context stbrp_context;
-typedef struct stbrp_node stbrp_node;
-typedef struct stbrp_rect stbrp_rect;
-
-#ifdef STBRP_LARGE_RECTS
-typedef int stbrp_coord;
-#else
-typedef unsigned short stbrp_coord;
-#endif
-
-STBRP_DEF int stbrp_pack_rects (stbrp_context *context, stbrp_rect *rects, int num_rects);
-// Assign packed locations to rectangles. The rectangles are of type
-// 'stbrp_rect' defined below, stored in the array 'rects', and there
-// are 'num_rects' many of them.
-//
-// Rectangles which are successfully packed have the 'was_packed' flag
-// set to a non-zero value and 'x' and 'y' store the minimum location
-// on each axis (i.e. bottom-left in cartesian coordinates, top-left
-// if you imagine y increasing downwards). Rectangles which do not fit
-// have the 'was_packed' flag set to 0.
-//
-// You should not try to access the 'rects' array from another thread
-// while this function is running, as the function temporarily reorders
-// the array while it executes.
-//
-// To pack into another rectangle, you need to call stbrp_init_target
-// again. To continue packing into the same rectangle, you can call
-// this function again. Calling this multiple times with multiple rect
-// arrays will probably produce worse packing results than calling it
-// a single time with the full rectangle array, but the option is
-// available.
-//
-// The function returns 1 if all of the rectangles were successfully
-// packed and 0 otherwise.
-
-struct stbrp_rect
-{
- // reserved for your use:
- int id;
-
- // input:
- stbrp_coord w, h;
-
- // output:
- stbrp_coord x, y;
- int was_packed; // non-zero if valid packing
-
-}; // 16 bytes, nominally
-
-
-STBRP_DEF void stbrp_init_target (stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes);
-// Initialize a rectangle packer to:
-// pack a rectangle that is 'width' by 'height' in dimensions
-// using temporary storage provided by the array 'nodes', which is 'num_nodes' long
-//
-// You must call this function every time you start packing into a new target.
-//
-// There is no "shutdown" function. The 'nodes' memory must stay valid for
-// the following stbrp_pack_rects() call (or calls), but can be freed after
-// the call (or calls) finish.
-//
-// Note: to guarantee best results, either:
-// 1. make sure 'num_nodes' >= 'width'
-// or 2. call stbrp_allow_out_of_mem() defined below with 'allow_out_of_mem = 1'
-//
-// If you don't do either of the above things, widths will be quantized to multiples
-// of small integers to guarantee the algorithm doesn't run out of temporary storage.
-//
-// If you do #2, then the non-quantized algorithm will be used, but the algorithm
-// may run out of temporary storage and be unable to pack some rectangles.
-
-STBRP_DEF void stbrp_setup_allow_out_of_mem (stbrp_context *context, int allow_out_of_mem);
-// Optionally call this function after init but before doing any packing to
-// change the handling of the out-of-temp-memory scenario, described above.
-// If you call init again, this will be reset to the default (false).
-
-
-STBRP_DEF void stbrp_setup_heuristic (stbrp_context *context, int heuristic);
-// Optionally select which packing heuristic the library should use. Different
-// heuristics will produce better/worse results for different data sets.
-// If you call init again, this will be reset to the default.
-
-enum
-{
- STBRP_HEURISTIC_Skyline_default=0,
- STBRP_HEURISTIC_Skyline_BL_sortHeight = STBRP_HEURISTIC_Skyline_default,
- STBRP_HEURISTIC_Skyline_BF_sortHeight
-};
-
-
-//////////////////////////////////////////////////////////////////////////////
-//
-// the details of the following structures don't matter to you, but they must
-// be visible so you can handle the memory allocations for them
-
-struct stbrp_node
-{
- stbrp_coord x,y;
- stbrp_node *next;
-};
-
-struct stbrp_context
-{
- int width;
- int height;
- int align;
- int init_mode;
- int heuristic;
- int num_nodes;
- stbrp_node *active_head;
- stbrp_node *free_head;
- stbrp_node extra[2]; // we allocate two extra nodes so optimal user-node-count is 'width' not 'width+2'
-};
-
-#ifdef __cplusplus
-}
-#endif
-
-#endif
-
-//////////////////////////////////////////////////////////////////////////////
-//
-// IMPLEMENTATION SECTION
-//
-
-#ifdef STB_RECT_PACK_IMPLEMENTATION
-#ifndef STBRP_SORT
-#include
-#define STBRP_SORT qsort
-#endif
-
-#ifndef STBRP_ASSERT
-#include
-#define STBRP_ASSERT assert
-#endif
-
-#ifdef _MSC_VER
-#define STBRP__NOTUSED(v) (void)(v)
-#else
-#define STBRP__NOTUSED(v) (void)sizeof(v)
-#endif
-
-enum
-{
- STBRP__INIT_skyline = 1
-};
-
-STBRP_DEF void stbrp_setup_heuristic(stbrp_context *context, int heuristic)
-{
- switch (context->init_mode) {
- case STBRP__INIT_skyline:
- STBRP_ASSERT(heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight || heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight);
- context->heuristic = heuristic;
- break;
- default:
- STBRP_ASSERT(0);
- }
-}
-
-STBRP_DEF void stbrp_setup_allow_out_of_mem(stbrp_context *context, int allow_out_of_mem)
-{
- if (allow_out_of_mem)
- // if it's ok to run out of memory, then don't bother aligning them;
- // this gives better packing, but may fail due to OOM (even though
- // the rectangles easily fit). @TODO a smarter approach would be to only
- // quantize once we've hit OOM, then we could get rid of this parameter.
- context->align = 1;
- else {
- // if it's not ok to run out of memory, then quantize the widths
- // so that num_nodes is always enough nodes.
- //
- // I.e. num_nodes * align >= width
- // align >= width / num_nodes
- // align = ceil(width/num_nodes)
-
- context->align = (context->width + context->num_nodes-1) / context->num_nodes;
- }
-}
-
-STBRP_DEF void stbrp_init_target(stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes)
-{
- int i;
-#ifndef STBRP_LARGE_RECTS
- STBRP_ASSERT(width <= 0xffff && height <= 0xffff);
-#endif
-
- for (i=0; i < num_nodes-1; ++i)
- nodes[i].next = &nodes[i+1];
- nodes[i].next = NULL;
- context->init_mode = STBRP__INIT_skyline;
- context->heuristic = STBRP_HEURISTIC_Skyline_default;
- context->free_head = &nodes[0];
- context->active_head = &context->extra[0];
- context->width = width;
- context->height = height;
- context->num_nodes = num_nodes;
- stbrp_setup_allow_out_of_mem(context, 0);
-
- // node 0 is the full width, node 1 is the sentinel (lets us not store width explicitly)
- context->extra[0].x = 0;
- context->extra[0].y = 0;
- context->extra[0].next = &context->extra[1];
- context->extra[1].x = (stbrp_coord) width;
-#ifdef STBRP_LARGE_RECTS
- context->extra[1].y = (1<<30);
-#else
- context->extra[1].y = 65535;
-#endif
- context->extra[1].next = NULL;
-}
-
-// find minimum y position if it starts at x1
-static int stbrp__skyline_find_min_y(stbrp_context *c, stbrp_node *first, int x0, int width, int *pwaste)
-{
- stbrp_node *node = first;
- int x1 = x0 + width;
- int min_y, visited_width, waste_area;
-
- STBRP__NOTUSED(c);
-
- STBRP_ASSERT(first->x <= x0);
-
- #if 0
- // skip in case we're past the node
- while (node->next->x <= x0)
- ++node;
- #else
- STBRP_ASSERT(node->next->x > x0); // we ended up handling this in the caller for efficiency
- #endif
-
- STBRP_ASSERT(node->x <= x0);
-
- min_y = 0;
- waste_area = 0;
- visited_width = 0;
- while (node->x < x1) {
- if (node->y > min_y) {
- // raise min_y higher.
- // we've accounted for all waste up to min_y,
- // but we'll now add more waste for everything we've visted
- waste_area += visited_width * (node->y - min_y);
- min_y = node->y;
- // the first time through, visited_width might be reduced
- if (node->x < x0)
- visited_width += node->next->x - x0;
- else
- visited_width += node->next->x - node->x;
- } else {
- // add waste area
- int under_width = node->next->x - node->x;
- if (under_width + visited_width > width)
- under_width = width - visited_width;
- waste_area += under_width * (min_y - node->y);
- visited_width += under_width;
- }
- node = node->next;
- }
-
- *pwaste = waste_area;
- return min_y;
-}
-
-typedef struct
-{
- int x,y;
- stbrp_node **prev_link;
-} stbrp__findresult;
-
-static stbrp__findresult stbrp__skyline_find_best_pos(stbrp_context *c, int width, int height)
-{
- int best_waste = (1<<30), best_x, best_y = (1 << 30);
- stbrp__findresult fr;
- stbrp_node **prev, *node, *tail, **best = NULL;
-
- // align to multiple of c->align
- width = (width + c->align - 1);
- width -= width % c->align;
- STBRP_ASSERT(width % c->align == 0);
-
- // if it can't possibly fit, bail immediately
- if (width > c->width || height > c->height) {
- fr.prev_link = NULL;
- fr.x = fr.y = 0;
- return fr;
- }
-
- node = c->active_head;
- prev = &c->active_head;
- while (node->x + width <= c->width) {
- int y,waste;
- y = stbrp__skyline_find_min_y(c, node, node->x, width, &waste);
- if (c->heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight) { // actually just want to test BL
- // bottom left
- if (y < best_y) {
- best_y = y;
- best = prev;
- }
- } else {
- // best-fit
- if (y + height <= c->height) {
- // can only use it if it first vertically
- if (y < best_y || (y == best_y && waste < best_waste)) {
- best_y = y;
- best_waste = waste;
- best = prev;
- }
- }
- }
- prev = &node->next;
- node = node->next;
- }
-
- best_x = (best == NULL) ? 0 : (*best)->x;
-
- // if doing best-fit (BF), we also have to try aligning right edge to each node position
- //
- // e.g, if fitting
- //
- // ____________________
- // |____________________|
- //
- // into
- //
- // | |
- // | ____________|
- // |____________|
- //
- // then right-aligned reduces waste, but bottom-left BL is always chooses left-aligned
- //
- // This makes BF take about 2x the time
-
- if (c->heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight) {
- tail = c->active_head;
- node = c->active_head;
- prev = &c->active_head;
- // find first node that's admissible
- while (tail->x < width)
- tail = tail->next;
- while (tail) {
- int xpos = tail->x - width;
- int y,waste;
- STBRP_ASSERT(xpos >= 0);
- // find the left position that matches this
- while (node->next->x <= xpos) {
- prev = &node->next;
- node = node->next;
- }
- STBRP_ASSERT(node->next->x > xpos && node->x <= xpos);
- y = stbrp__skyline_find_min_y(c, node, xpos, width, &waste);
- if (y + height <= c->height) {
- if (y <= best_y) {
- if (y < best_y || waste < best_waste || (waste==best_waste && xpos < best_x)) {
- best_x = xpos;
- STBRP_ASSERT(y <= best_y);
- best_y = y;
- best_waste = waste;
- best = prev;
- }
- }
- }
- tail = tail->next;
- }
- }
-
- fr.prev_link = best;
- fr.x = best_x;
- fr.y = best_y;
- return fr;
-}
-
-static stbrp__findresult stbrp__skyline_pack_rectangle(stbrp_context *context, int width, int height)
-{
- // find best position according to heuristic
- stbrp__findresult res = stbrp__skyline_find_best_pos(context, width, height);
- stbrp_node *node, *cur;
-
- // bail if:
- // 1. it failed
- // 2. the best node doesn't fit (we don't always check this)
- // 3. we're out of memory
- if (res.prev_link == NULL || res.y + height > context->height || context->free_head == NULL) {
- res.prev_link = NULL;
- return res;
- }
-
- // on success, create new node
- node = context->free_head;
- node->x = (stbrp_coord) res.x;
- node->y = (stbrp_coord) (res.y + height);
-
- context->free_head = node->next;
-
- // insert the new node into the right starting point, and
- // let 'cur' point to the remaining nodes needing to be
- // stiched back in
-
- cur = *res.prev_link;
- if (cur->x < res.x) {
- // preserve the existing one, so start testing with the next one
- stbrp_node *next = cur->next;
- cur->next = node;
- cur = next;
- } else {
- *res.prev_link = node;
- }
-
- // from here, traverse cur and free the nodes, until we get to one
- // that shouldn't be freed
- while (cur->next && cur->next->x <= res.x + width) {
- stbrp_node *next = cur->next;
- // move the current node to the free list
- cur->next = context->free_head;
- context->free_head = cur;
- cur = next;
- }
-
- // stitch the list back in
- node->next = cur;
-
- if (cur->x < res.x + width)
- cur->x = (stbrp_coord) (res.x + width);
-
-#ifdef _DEBUG
- cur = context->active_head;
- while (cur->x < context->width) {
- STBRP_ASSERT(cur->x < cur->next->x);
- cur = cur->next;
- }
- STBRP_ASSERT(cur->next == NULL);
-
- {
- int count=0;
- cur = context->active_head;
- while (cur) {
- cur = cur->next;
- ++count;
- }
- cur = context->free_head;
- while (cur) {
- cur = cur->next;
- ++count;
- }
- STBRP_ASSERT(count == context->num_nodes+2);
- }
-#endif
-
- return res;
-}
-
-static int rect_height_compare(const void *a, const void *b)
-{
- const stbrp_rect *p = (const stbrp_rect *) a;
- const stbrp_rect *q = (const stbrp_rect *) b;
- if (p->h > q->h)
- return -1;
- if (p->h < q->h)
- return 1;
- return (p->w > q->w) ? -1 : (p->w < q->w);
-}
-
-static int rect_original_order(const void *a, const void *b)
-{
- const stbrp_rect *p = (const stbrp_rect *) a;
- const stbrp_rect *q = (const stbrp_rect *) b;
- return (p->was_packed < q->was_packed) ? -1 : (p->was_packed > q->was_packed);
-}
-
-#ifdef STBRP_LARGE_RECTS
-#define STBRP__MAXVAL 0xffffffff
-#else
-#define STBRP__MAXVAL 0xffff
-#endif
-
-STBRP_DEF int stbrp_pack_rects(stbrp_context *context, stbrp_rect *rects, int num_rects)
-{
- int i, all_rects_packed = 1;
-
- // we use the 'was_packed' field internally to allow sorting/unsorting
- for (i=0; i < num_rects; ++i) {
- rects[i].was_packed = i;
- }
-
- // sort according to heuristic
- STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_height_compare);
-
- for (i=0; i < num_rects; ++i) {
- if (rects[i].w == 0 || rects[i].h == 0) {
- rects[i].x = rects[i].y = 0; // empty rect needs no space
- } else {
- stbrp__findresult fr = stbrp__skyline_pack_rectangle(context, rects[i].w, rects[i].h);
- if (fr.prev_link) {
- rects[i].x = (stbrp_coord) fr.x;
- rects[i].y = (stbrp_coord) fr.y;
- } else {
- rects[i].x = rects[i].y = STBRP__MAXVAL;
- }
- }
- }
-
- // unsort
- STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_original_order);
-
- // set was_packed flags and all_rects_packed status
- for (i=0; i < num_rects; ++i) {
- rects[i].was_packed = !(rects[i].x == STBRP__MAXVAL && rects[i].y == STBRP__MAXVAL);
- if (!rects[i].was_packed)
- all_rects_packed = 0;
- }
-
- // return the all_rects_packed status
- return all_rects_packed;
-}
-#endif
-
-/*
-------------------------------------------------------------------------------
-This software is available under 2 licenses -- choose whichever you prefer.
-------------------------------------------------------------------------------
-ALTERNATIVE A - MIT License
-Copyright (c) 2017 Sean Barrett
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-------------------------------------------------------------------------------
-ALTERNATIVE B - Public Domain (www.unlicense.org)
-This is free and unencumbered software released into the public domain.
-Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
-software, either in source code form or as a compiled binary, for any purpose,
-commercial or non-commercial, and by any means.
-In jurisdictions that recognize copyright laws, the author or authors of this
-software dedicate any and all copyright interest in the software to the public
-domain. We make this dedication for the benefit of the public at large and to
-the detriment of our heirs and successors. We intend this dedication to be an
-overt act of relinquishment in perpetuity of all present and future rights to
-this software under copyright law.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
-ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-------------------------------------------------------------------------------
-*/
diff --git a/src/deps/stbi/zstbi.c b/src/deps/stbi/zstbi.c
deleted file mode 100644
index af99e181..00000000
--- a/src/deps/stbi/zstbi.c
+++ /dev/null
@@ -1,34 +0,0 @@
-#ifdef STBI_NO_STDLIB
-// `wasm32-freestanding` has no libc. Route alloc + qsort through fizzy shims
-// (see `fizzy_stbi_libc.c`) and stub out asserts so neither stb header pulls
-// in `` / ``.
-#include
-extern void *fizzy_stbi_malloc(size_t size);
-extern void fizzy_stbi_free(void *ptr);
-extern void fizzy_stbi_qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));
-
-// stb_rect_pack: comparator-driven sort + asserts.
-#define STBRP_SORT(base, nmemb, size, compar) fizzy_stbi_qsort((base), (nmemb), (size), (compar))
-#define STBRP_ASSERT(x) ((void)0)
-
-// stb_image_resize2: malloc/free + asserts. `user_data` is passthrough.
-#define STBIR_MALLOC(size, user_data) ((void)(user_data), fizzy_stbi_malloc(size))
-#define STBIR_FREE(ptr, user_data) ((void)(user_data), fizzy_stbi_free(ptr))
-#define STBIR_ASSERT(x) ((void)0)
-// Skip `` — use Clang/LLVM builtins available on wasm32.
-#define STBIR_CEILF(x) __builtin_ceilf(x)
-#define STBIR_FLOORF(x) __builtin_floorf(x)
-// Skip `` — use the Clang builtin memcpy. Zig's wasm target provides
-// `memcpy` as an intrinsic for any compilation unit that emits a memcpy.
-#define STBIR_MEMCPY(dest, src, len) __builtin_memcpy((dest), (src), (len))
-#else
-#include
-#endif
-
-#define STB_RECT_PACK_IMPLEMENTATION
-#include "stb_rect_pack.h"
-
-#define STB_IMAGE_RESIZE_IMPLEMENTATION
-#define STBIR_DEFAULT_FILTER_UPSAMPLE STBIR_FILTER_POINT_SAMPLE
-#define STBIR_DEFAULT_FILTER_DOWNSAMPLE STBIR_FILTER_POINT_SAMPLE
-#include "stb_image_resize2.h"
\ No newline at end of file
diff --git a/src/deps/stbi/zstbi.zig b/src/deps/stbi/zstbi.zig
deleted file mode 100644
index d968b707..00000000
--- a/src/deps/stbi/zstbi.zig
+++ /dev/null
@@ -1,95 +0,0 @@
-pub const version = @import("std").SemanticVersion{ .major = 0, .minor = 9, .patch = 3 };
-const std = @import("std");
-const assert = std.debug.assert;
-
-pub const Rect = extern struct {
- id: u32,
- w: u16,
- h: u16,
- x: u16 = 0,
- y: u16 = 0,
- was_packed: i32 = 0,
-
- pub fn slice(self: Rect) [4]u32 {
- return .{
- @intCast(self.x),
- @intCast(self.y),
- @intCast(self.w),
- @intCast(self.h),
- };
- }
-};
-
-pub const Node = extern struct {
- x: u16,
- y: u16,
- next: [*c]Node,
-};
-
-pub const Context = extern struct {
- width: i32,
- height: i32,
- @"align": i32,
- init_mode: i32,
- heuristic: i32,
- num_nodes: i32,
- active_head: [*c]Node,
- free_head: [*c]Node,
- extra: [2]Node,
-};
-
-pub const Heuristic = enum(u32) {
- skyline_default,
- skyline_bl_sort_height,
- skyline_bf_sort_height,
-};
-
-pub fn initTarget(context: *Context, width: u32, height: u32, nodes: []Node) void {
- stbrp_init_target(context, width, height, nodes.ptr, nodes.len);
-}
-
-pub fn packRects(context: *Context, rects: []Rect) usize {
- return @as(usize, @intCast(stbrp_pack_rects(context, rects.ptr, rects.len)));
-}
-
-pub fn setupHeuristic(context: *Context, heuristic: Heuristic) void {
- stbrp_setup_heuristic(context, @as(u32, @intCast(@intFromEnum(heuristic))));
-}
-
-pub extern fn stbrp_init_target(context: [*c]Context, width: u32, height: u32, nodes: [*c]Node, num_nodes: usize) void;
-pub extern fn stbrp_pack_rects(context: [*c]Context, rects: [*c]Rect, num_rects: usize) usize;
-pub extern fn stbrp_setup_allow_out_of_mem(context: [*c]Context, allow_out_of_mem: u32) void;
-pub extern fn stbrp_setup_heuristic(context: [*c]Context, heuristic: u32) void;
-
-pub const stbir_pixel_layout = enum(i32) {
- STBIR_1CHANNEL = 1,
- STBIR_2CHANNEL = 2,
- STBIR_RGB = 3, // 3-chan, with order specified (for channel flipping)
- STBIR_BGR = 0, // 3-chan, with order specified (for channel flipping)
- STBIR_4CHANNEL = 5,
-
- STBIR_RGBA = 4, // alpha formats, where alpha is NOT premultiplied into color channels
- STBIR_BGRA = 6,
- STBIR_ARGB = 7,
- STBIR_ABGR = 8,
- STBIR_RA = 9,
- STBIR_AR = 10,
-
- STBIR_RGBA_PM = 11, // alpha formats, where alpha is premultiplied into color channels
- STBIR_BGRA_PM = 12,
- STBIR_ARGB_PM = 13,
- STBIR_ABGR_PM = 14,
- STBIR_RA_PM = 15,
- STBIR_AR_PM = 16,
-};
-
-pub fn resize(input_pixels: [][4]u8, input_w: u32, input_h: u32, output_pixels: [][4]u8, output_w: u32, output_h: u32) ?[]u8 {
- const input_slice = @as([*]u8, @ptrCast(input_pixels.ptr))[0..@intCast(input_w * input_h * 4)];
- const output_slice = @as([*]u8, @ptrCast(output_pixels.ptr))[0..@intCast(output_w * output_h * 4)];
- const output = stbir_resize_uint8_linear(input_slice.ptr, @intCast(input_w), @intCast(input_h), @intCast(input_w * 4), output_slice.ptr, @intCast(output_w), @intCast(output_h), @intCast(output_w * 4), .STBIR_RGBA);
- if (output == null) return null;
- return output_slice;
-}
-
-pub extern fn stbir_resize_uint8_srgb(input_pixels: [*c]u8, input_w: i32, input_h: i32, input_stride_in_bytes: i32, output_pixels: [*c]u8, output_w: i32, output_h: i32, output_stride_in_bytes: i32, pixel_type: stbir_pixel_layout) [*c]u8;
-pub extern fn stbir_resize_uint8_linear(input_pixels: [*c]u8, input_w: i32, input_h: i32, input_stride_in_bytes: i32, output_pixels: [*c]u8, output_w: i32, output_h: i32, output_stride_in_bytes: i32, pixel_type: stbir_pixel_layout) [*c]u8;
diff --git a/src/deps/zip/build.zig b/src/deps/zip/build.zig
deleted file mode 100644
index 98924c57..00000000
--- a/src/deps/zip/build.zig
+++ /dev/null
@@ -1,41 +0,0 @@
-const builtin = @import("builtin");
-const std = @import("std");
-
-pub fn build(_: *std.Build) !void {}
-
-pub const Package = struct {
- module: *std.Build.Module,
-};
-
-pub fn package(b: *std.Build, _: struct {}) Package {
- const module = b.createModule(.{
- .root_source_file = .{ .cwd_relative = thisDir() ++ "/zip.zig" },
- });
- return .{ .module = module };
-}
-
-const wasm_c_flags = [_][]const u8{
- "-fno-sanitize=undefined",
- "-DFIZZY_ZIP_WASM",
- "-DZIP_RAW_ENTRYNAME",
-};
-
-pub fn link(exe: *std.Build.Step.Compile) void {
- exe.root_module.link_libc = true;
- exe.root_module.addIncludePath(.{ .cwd_relative = thisDir() ++ "/src" });
- const c_flags = [_][]const u8{"-fno-sanitize=undefined"};
- exe.root_module.addCSourceFile(.{ .file = .{ .cwd_relative = thisDir() ++ "/src/zip.c" }, .flags = &c_flags });
-}
-
-/// In-memory zip read/write for wasm32-freestanding (no libc, no filesystem).
-/// Uses DVUI's `dvui_c_alloc` via `fizzy_zip_libc.c`.
-pub fn linkWasm(exe: *std.Build.Step.Compile) void {
- exe.root_module.addIncludePath(.{ .cwd_relative = thisDir() ++ "/src" });
- exe.root_module.addCSourceFile(.{ .file = .{ .cwd_relative = thisDir() ++ "/fizzy_zip_libc.c" }, .flags = &wasm_c_flags });
- exe.root_module.addCSourceFile(.{ .file = .{ .cwd_relative = thisDir() ++ "/fizzy_zip_strings.c" }, .flags = &wasm_c_flags });
- exe.root_module.addCSourceFile(.{ .file = .{ .cwd_relative = thisDir() ++ "/src/zip.c" }, .flags = &wasm_c_flags });
-}
-
-inline fn thisDir() []const u8 {
- return comptime std.fs.path.dirname(@src().file) orelse ".";
-}
diff --git a/src/deps/zip/fizzy_zip_libc.c b/src/deps/zip/fizzy_zip_libc.c
deleted file mode 100644
index d2cbe827..00000000
--- a/src/deps/zip/fizzy_zip_libc.c
+++ /dev/null
@@ -1,65 +0,0 @@
-// Heap shims for compiling zip.c / miniz on wasm32-freestanding.
-// Routes C allocations to DVUI's exported allocator (same as stb on web).
-
-#include
-#include
-
-extern void *memset(void *dest, int c, size_t n);
-extern void *memcpy(void *dest, const void *src, size_t n);
-
-extern void *dvui_c_alloc(size_t size);
-extern void dvui_c_free(void *ptr);
-extern void *dvui_c_realloc_sized(void *ptr, size_t oldsize, size_t newsize);
-
-void *fizzy_zip_malloc(size_t size) {
- return dvui_c_alloc(size);
-}
-
-void fizzy_zip_free(void *ptr) {
- dvui_c_free(ptr);
-}
-
-// `dvui_c_realloc_sized` uses `oldsize` as the memcpy length when copying from
-// the old buffer to the new one. Passing 0 would leave the new buffer's content
-// uninitialized — miniz's zip archive grows via realloc, so a 0 here would
-// corrupt the zip output (the bytes wouldn't even start with `PK\x03\x04`).
-//
-// DVUI's `dvui_c_alloc` stores the allocation's *total* byte count (user size
-// + the 8-byte prefix) 8 bytes before the user pointer. We recover the
-// user-visible size by reading that prefix and subtracting 8, then clamp the
-// copy to `min(oldsize, newsize)` so a shrinking realloc never overruns the
-// new buffer's user area.
-void *fizzy_zip_realloc(void *ptr, size_t size) {
- if (!ptr) {
- return dvui_c_alloc(size);
- }
- uint64_t buflen;
- memcpy(&buflen, (uint8_t *)ptr - 8, sizeof(uint64_t));
- size_t oldsize = (size_t)buflen - 8;
- size_t copy = oldsize < size ? oldsize : size;
- return dvui_c_realloc_sized(ptr, copy, size);
-}
-
-void *fizzy_zip_calloc(size_t num, size_t size) {
- const size_t total = num * size;
- void *ptr = dvui_c_alloc(total);
- if (ptr) {
- memset(ptr, 0, total);
- }
- return ptr;
-}
-
-extern size_t strlen(const char *s);
-extern void *memcpy(void *dest, const void *src, size_t n);
-
-char *fizzy_strdup(const char *s) {
- if (!s) {
- return NULL;
- }
- const size_t n = strlen(s) + 1;
- char *d = (char *)dvui_c_alloc(n);
- if (d) {
- memcpy(d, s, n);
- }
- return d;
-}
diff --git a/src/deps/zip/fizzy_zip_strings.c b/src/deps/zip/fizzy_zip_strings.c
deleted file mode 100644
index 12168b47..00000000
--- a/src/deps/zip/fizzy_zip_strings.c
+++ /dev/null
@@ -1,34 +0,0 @@
-// Minimal string routines for zip/miniz on wasm32-freestanding (no libc).
-
-#include
-
-extern void *memcpy(void *dest, const void *src, size_t n);
-
-int strcmp(const char *l, const char *r) {
- for (; *l == *r && *l; l++, r++) {}
- return *(const unsigned char *)l - *(const unsigned char *)r;
-}
-
-size_t strlen(const char *s) {
- const char *p = s;
- while (*p) {
- p++;
- }
- return (size_t)(p - s);
-}
-
-int memcmp(const void *l, const void *r, size_t n) {
- const unsigned char *a = l, *b = r;
- for (; n; n--, a++, b++) {
- if (*a != *b) {
- return *a - *b;
- }
- }
- return 0;
-}
-
-char *strcpy(char *dest, const char *src) {
- char *d = dest;
- while ((*d++ = *src++)) {}
- return dest;
-}
diff --git a/src/deps/zip/fizzy_zip_wasm.h b/src/deps/zip/fizzy_zip_wasm.h
deleted file mode 100644
index 759b60bc..00000000
--- a/src/deps/zip/fizzy_zip_wasm.h
+++ /dev/null
@@ -1,42 +0,0 @@
-// Included first when compiling zip.c with -DFIZZY_ZIP_WASM (web / freestanding).
-#pragma once
-
-#include
-#include
-
-extern void *memcpy(void *dest, const void *src, size_t n);
-extern void *memset(void *dest, int c, size_t n);
-extern void *memmove(void *dest, const void *src, size_t n);
-
-extern int strcmp(const char *l, const char *r);
-extern size_t strlen(const char *s);
-extern int memcmp(const void *l, const void *r, size_t n);
-extern char *strcpy(char *dest, const char *src);
-extern char *fizzy_strdup(const char *s);
-
-extern void *fizzy_zip_malloc(size_t size);
-extern void fizzy_zip_free(void *ptr);
-extern void *fizzy_zip_realloc(void *ptr, size_t size);
-extern void *fizzy_zip_calloc(size_t num, size_t size);
-
-#define malloc(SZ) fizzy_zip_malloc(SZ)
-#define free(PTR) fizzy_zip_free(PTR)
-#define calloc(N, SZ) fizzy_zip_calloc(N, SZ)
-#define realloc(PTR, SZ) fizzy_zip_realloc(PTR, SZ)
-
-#define MINIZ_NO_STDIO
-#define MINIZ_NO_TIME
-
-#define MZ_MALLOC(SZ) fizzy_zip_malloc(SZ)
-#define MZ_FREE(PTR) fizzy_zip_free(PTR)
-#define MZ_REALLOC(PTR, SZ) fizzy_zip_realloc(PTR, SZ)
-
-#ifndef assert
-#define assert(EXPR) \
- do { \
- if (!(EXPR)) \
- __builtin_trap(); \
- } while (0)
-#endif
-
-#include "miniz.h"
diff --git a/src/deps/zip/src/miniz.h b/src/deps/zip/src/miniz.h
deleted file mode 100644
index d6f15f89..00000000
--- a/src/deps/zip/src/miniz.h
+++ /dev/null
@@ -1,10145 +0,0 @@
-#define MINIZ_EXPORT
-/* miniz.c 2.2.0 - public domain deflate/inflate, zlib-subset, ZIP
- reading/writing/appending, PNG writing See "unlicense" statement at the end
- of this file. Rich Geldreich , last updated Oct. 13,
- 2013 Implements RFC 1950: http://www.ietf.org/rfc/rfc1950.txt and RFC 1951:
- http://www.ietf.org/rfc/rfc1951.txt
-
- Most API's defined in miniz.c are optional. For example, to disable the
- archive related functions just define MINIZ_NO_ARCHIVE_APIS, or to get rid of
- all stdio usage define MINIZ_NO_STDIO (see the list below for more macros).
-
- * Low-level Deflate/Inflate implementation notes:
-
- Compression: Use the "tdefl" API's. The compressor supports raw, static,
- and dynamic blocks, lazy or greedy parsing, match length filtering, RLE-only,
- and Huffman-only streams. It performs and compresses approximately as well as
- zlib.
-
- Decompression: Use the "tinfl" API's. The entire decompressor is
- implemented as a single function coroutine: see tinfl_decompress(). It
- supports decompression into a 32KB (or larger power of 2) wrapping buffer, or
- into a memory block large enough to hold the entire file.
-
- The low-level tdefl/tinfl API's do not make any use of dynamic memory
- allocation.
-
- * zlib-style API notes:
-
- miniz.c implements a fairly large subset of zlib. There's enough
- functionality present for it to be a drop-in zlib replacement in many apps:
- The z_stream struct, optional memory allocation callbacks
- deflateInit/deflateInit2/deflate/deflateReset/deflateEnd/deflateBound
- inflateInit/inflateInit2/inflate/inflateReset/inflateEnd
- compress, compress2, compressBound, uncompress
- CRC-32, Adler-32 - Using modern, minimal code size, CPU cache friendly
- routines. Supports raw deflate streams or standard zlib streams with adler-32
- checking.
-
- Limitations:
- The callback API's are not implemented yet. No support for gzip headers or
- zlib static dictionaries. I've tried to closely emulate zlib's various
- flavors of stream flushing and return status codes, but there are no
- guarantees that miniz.c pulls this off perfectly.
-
- * PNG writing: See the tdefl_write_image_to_png_file_in_memory() function,
- originally written by Alex Evans. Supports 1-4 bytes/pixel images.
-
- * ZIP archive API notes:
-
- The ZIP archive API's where designed with simplicity and efficiency in
- mind, with just enough abstraction to get the job done with minimal fuss.
- There are simple API's to retrieve file information, read files from existing
- archives, create new archives, append new files to existing archives, or
- clone archive data from one archive to another. It supports archives located
- in memory or the heap, on disk (using stdio.h), or you can specify custom
- file read/write callbacks.
-
- - Archive reading: Just call this function to read a single file from a
- disk archive:
-
- void *mz_zip_extract_archive_file_to_heap(const char *pZip_filename, const
- char *pArchive_name, size_t *pSize, mz_uint zip_flags);
-
- For more complex cases, use the "mz_zip_reader" functions. Upon opening an
- archive, the entire central directory is located and read as-is into memory,
- and subsequent file access only occurs when reading individual files.
-
- - Archives file scanning: The simple way is to use this function to scan a
- loaded archive for a specific file:
-
- int mz_zip_reader_locate_file(mz_zip_archive *pZip, const char *pName,
- const char *pComment, mz_uint flags);
-
- The locate operation can optionally check file comments too, which (as one
- example) can be used to identify multiple versions of the same file in an
- archive. This function uses a simple linear search through the central
- directory, so it's not very fast.
-
- Alternately, you can iterate through all the files in an archive (using
- mz_zip_reader_get_num_files()) and retrieve detailed info on each file by
- calling mz_zip_reader_file_stat().
-
- - Archive creation: Use the "mz_zip_writer" functions. The ZIP writer
- immediately writes compressed file data to disk and builds an exact image of
- the central directory in memory. The central directory image is written all
- at once at the end of the archive file when the archive is finalized.
-
- The archive writer can optionally align each file's local header and file
- data to any power of 2 alignment, which can be useful when the archive will
- be read from optical media. Also, the writer supports placing arbitrary data
- blobs at the very beginning of ZIP archives. Archives written using either
- feature are still readable by any ZIP tool.
-
- - Archive appending: The simple way to add a single file to an archive is
- to call this function:
-
- mz_bool mz_zip_add_mem_to_archive_file_in_place(const char *pZip_filename,
- const char *pArchive_name, const void *pBuf, size_t buf_size, const void
- *pComment, mz_uint16 comment_size, mz_uint level_and_flags);
-
- The archive will be created if it doesn't already exist, otherwise it'll be
- appended to. Note the appending is done in-place and is not an atomic
- operation, so if something goes wrong during the operation it's possible the
- archive could be left without a central directory (although the local file
- headers and file data will be fine, so the archive will be recoverable).
-
- For more complex archive modification scenarios:
- 1. The safest way is to use a mz_zip_reader to read the existing archive,
- cloning only those bits you want to preserve into a new archive using using
- the mz_zip_writer_add_from_zip_reader() function (which compiles the
- compressed file data as-is). When you're done, delete the old archive and
- rename the newly written archive, and you're done. This is safe but requires
- a bunch of temporary disk space or heap memory.
-
- 2. Or, you can convert an mz_zip_reader in-place to an mz_zip_writer using
- mz_zip_writer_init_from_reader(), append new files as needed, then finalize
- the archive which will write an updated central directory to the original
- archive. (This is basically what mz_zip_add_mem_to_archive_file_in_place()
- does.) There's a possibility that the archive's central directory could be
- lost with this method if anything goes wrong, though.
-
- - ZIP archive support limitations:
- No spanning support. Extraction functions can only handle unencrypted,
- stored or deflated files. Requires streams capable of seeking.
-
- * This is a header file library, like stb_image.c. To get only a header file,
- either cut and paste the below header, or create miniz.h, #define
- MINIZ_HEADER_FILE_ONLY, and then include miniz.c from it.
-
- * Important: For best perf. be sure to customize the below macros for your
- target platform: #define MINIZ_USE_UNALIGNED_LOADS_AND_STORES 1 #define
- MINIZ_LITTLE_ENDIAN 1 #define MINIZ_HAS_64BIT_REGISTERS 1
-
- * On platforms using glibc, Be sure to "#define _LARGEFILE64_SOURCE 1" before
- including miniz.c to ensure miniz uses the 64-bit variants: fopen64(),
- stat64(), etc. Otherwise you won't be able to process large files (i.e.
- 32-bit stat() fails for me on files > 0x7FFFFFFF bytes).
-*/
-#pragma once
-
-/* Defines to completely disable specific portions of miniz.c:
- If all macros here are defined the only functionality remaining will be
- CRC-32, adler-32, tinfl, and tdefl. */
-
-/* Define MINIZ_NO_STDIO to disable all usage and any functions which rely on
- * stdio for file I/O. */
-/*#define MINIZ_NO_STDIO */
-
-/* If MINIZ_NO_TIME is specified then the ZIP archive functions will not be able
- * to get the current time, or */
-/* get/set file times, and the C run-time funcs that get/set times won't be
- * called. */
-/* The current downside is the times written to your archives will be from 1979.
- */
-/*#define MINIZ_NO_TIME */
-
-/* Define MINIZ_NO_ARCHIVE_APIS to disable all ZIP archive API's. */
-/*#define MINIZ_NO_ARCHIVE_APIS */
-
-/* Define MINIZ_NO_ARCHIVE_WRITING_APIS to disable all writing related ZIP
- * archive API's. */
-/*#define MINIZ_NO_ARCHIVE_WRITING_APIS */
-
-/* Define MINIZ_NO_ZLIB_APIS to remove all ZLIB-style compression/decompression
- * API's. */
-/*#define MINIZ_NO_ZLIB_APIS */
-
-/* Define MINIZ_NO_ZLIB_COMPATIBLE_NAME to disable zlib names, to prevent
- * conflicts against stock zlib. */
-/*#define MINIZ_NO_ZLIB_COMPATIBLE_NAMES */
-
-/* Define MINIZ_NO_MALLOC to disable all calls to malloc, free, and realloc.
- Note if MINIZ_NO_MALLOC is defined then the user must always provide custom
- user alloc/free/realloc callbacks to the zlib and archive API's, and a few
- stand-alone helper API's which don't provide custom user functions (such as
- tdefl_compress_mem_to_heap() and tinfl_decompress_mem_to_heap()) won't work.
- */
-/*#define MINIZ_NO_MALLOC */
-
-#if defined(__TINYC__) && (defined(__linux) || defined(__linux__))
-/* TODO: Work around "error: include file 'sys\utime.h' when compiling with tcc
- * on Linux */
-#define MINIZ_NO_TIME
-#endif
-
-#include
-
-#if !defined(MINIZ_NO_TIME) && !defined(MINIZ_NO_ARCHIVE_APIS)
-#include
-#endif
-
-#if defined(_M_IX86) || defined(_M_X64) || defined(__i386__) || \
- defined(__i386) || defined(__i486__) || defined(__i486) || \
- defined(i386) || defined(__ia64__) || defined(__x86_64__)
-/* MINIZ_X86_OR_X64_CPU is only used to help set the below macros. */
-#define MINIZ_X86_OR_X64_CPU 1
-#else
-#define MINIZ_X86_OR_X64_CPU 0
-#endif
-
-#if (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) || MINIZ_X86_OR_X64_CPU
-/* Set MINIZ_LITTLE_ENDIAN to 1 if the processor is little endian. */
-#define MINIZ_LITTLE_ENDIAN 1
-#else
-#define MINIZ_LITTLE_ENDIAN 0
-#endif
-
-/* Set MINIZ_USE_UNALIGNED_LOADS_AND_STORES only if not set */
-#if !defined(MINIZ_USE_UNALIGNED_LOADS_AND_STORES)
-#if MINIZ_X86_OR_X64_CPU
-/* Set MINIZ_USE_UNALIGNED_LOADS_AND_STORES to 1 on CPU's that permit efficient
- * integer loads and stores from unaligned addresses. */
-#define MINIZ_USE_UNALIGNED_LOADS_AND_STORES 1
-#define MINIZ_UNALIGNED_USE_MEMCPY
-#else
-#define MINIZ_USE_UNALIGNED_LOADS_AND_STORES 0
-#endif
-#endif
-
-#if defined(_M_X64) || defined(_WIN64) || defined(__MINGW64__) || \
- defined(_LP64) || defined(__LP64__) || defined(__ia64__) || \
- defined(__x86_64__)
-/* Set MINIZ_HAS_64BIT_REGISTERS to 1 if operations on 64-bit integers are
- * reasonably fast (and don't involve compiler generated calls to helper
- * functions). */
-#define MINIZ_HAS_64BIT_REGISTERS 1
-#else
-#define MINIZ_HAS_64BIT_REGISTERS 0
-#endif
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/* ------------------- zlib-style API Definitions. */
-
-/* For more compatibility with zlib, miniz.c uses unsigned long for some
- * parameters/struct members. Beware: mz_ulong can be either 32 or 64-bits! */
-typedef unsigned long mz_ulong;
-
-/* mz_free() internally uses the MZ_FREE() macro (which by default calls free()
- * unless you've modified the MZ_MALLOC macro) to release a block allocated from
- * the heap. */
-MINIZ_EXPORT void mz_free(void *p);
-
-#define MZ_ADLER32_INIT (1)
-/* mz_adler32() returns the initial adler-32 value to use when called with
- * ptr==NULL. */
-MINIZ_EXPORT mz_ulong mz_adler32(mz_ulong adler, const unsigned char *ptr,
- size_t buf_len);
-
-#define MZ_CRC32_INIT (0)
-/* mz_crc32() returns the initial CRC-32 value to use when called with
- * ptr==NULL. */
-MINIZ_EXPORT mz_ulong mz_crc32(mz_ulong crc, const unsigned char *ptr,
- size_t buf_len);
-
-/* Compression strategies. */
-enum {
- MZ_DEFAULT_STRATEGY = 0,
- MZ_FILTERED = 1,
- MZ_HUFFMAN_ONLY = 2,
- MZ_RLE = 3,
- MZ_FIXED = 4
-};
-
-/* Method */
-#define MZ_DEFLATED 8
-
-/* Heap allocation callbacks.
-Note that mz_alloc_func parameter types purposely differ from zlib's: items/size
-is size_t, not unsigned long. */
-typedef void *(*mz_alloc_func)(void *opaque, size_t items, size_t size);
-typedef void (*mz_free_func)(void *opaque, void *address);
-typedef void *(*mz_realloc_func)(void *opaque, void *address, size_t items,
- size_t size);
-
-/* Compression levels: 0-9 are the standard zlib-style levels, 10 is best
- * possible compression (not zlib compatible, and may be very slow),
- * MZ_DEFAULT_COMPRESSION=MZ_DEFAULT_LEVEL. */
-enum {
- MZ_NO_COMPRESSION = 0,
- MZ_BEST_SPEED = 1,
- MZ_BEST_COMPRESSION = 9,
- MZ_UBER_COMPRESSION = 10,
- MZ_DEFAULT_LEVEL = 6,
- MZ_DEFAULT_COMPRESSION = -1
-};
-
-#define MZ_VERSION "10.2.0"
-#define MZ_VERNUM 0xA100
-#define MZ_VER_MAJOR 10
-#define MZ_VER_MINOR 2
-#define MZ_VER_REVISION 0
-#define MZ_VER_SUBREVISION 0
-
-#ifndef MINIZ_NO_ZLIB_APIS
-
-/* Flush values. For typical usage you only need MZ_NO_FLUSH and MZ_FINISH. The
- * other values are for advanced use (refer to the zlib docs). */
-enum {
- MZ_NO_FLUSH = 0,
- MZ_PARTIAL_FLUSH = 1,
- MZ_SYNC_FLUSH = 2,
- MZ_FULL_FLUSH = 3,
- MZ_FINISH = 4,
- MZ_BLOCK = 5
-};
-
-/* Return status codes. MZ_PARAM_ERROR is non-standard. */
-enum {
- MZ_OK = 0,
- MZ_STREAM_END = 1,
- MZ_NEED_DICT = 2,
- MZ_ERRNO = -1,
- MZ_STREAM_ERROR = -2,
- MZ_DATA_ERROR = -3,
- MZ_MEM_ERROR = -4,
- MZ_BUF_ERROR = -5,
- MZ_VERSION_ERROR = -6,
- MZ_PARAM_ERROR = -10000
-};
-
-/* Window bits */
-#define MZ_DEFAULT_WINDOW_BITS 15
-
-struct mz_internal_state;
-
-/* Compression/decompression stream struct. */
-typedef struct mz_stream_s {
- const unsigned char *next_in; /* pointer to next byte to read */
- unsigned int avail_in; /* number of bytes available at next_in */
- mz_ulong total_in; /* total number of bytes consumed so far */
-
- unsigned char *next_out; /* pointer to next byte to write */
- unsigned int avail_out; /* number of bytes that can be written to next_out */
- mz_ulong total_out; /* total number of bytes produced so far */
-
- char *msg; /* error msg (unused) */
- struct mz_internal_state
- *state; /* internal state, allocated by zalloc/zfree */
-
- mz_alloc_func
- zalloc; /* optional heap allocation function (defaults to malloc) */
- mz_free_func zfree; /* optional heap free function (defaults to free) */
- void *opaque; /* heap alloc function user pointer */
-
- int data_type; /* data_type (unused) */
- mz_ulong adler; /* adler32 of the source or uncompressed data */
- mz_ulong reserved; /* not used */
-} mz_stream;
-
-typedef mz_stream *mz_streamp;
-
-/* Returns the version string of miniz.c. */
-MINIZ_EXPORT const char *mz_version(void);
-
-/* mz_deflateInit() initializes a compressor with default options: */
-/* Parameters: */
-/* pStream must point to an initialized mz_stream struct. */
-/* level must be between [MZ_NO_COMPRESSION, MZ_BEST_COMPRESSION]. */
-/* level 1 enables a specially optimized compression function that's been
- * optimized purely for performance, not ratio. */
-/* (This special func. is currently only enabled when
- * MINIZ_USE_UNALIGNED_LOADS_AND_STORES and MINIZ_LITTLE_ENDIAN are defined.) */
-/* Return values: */
-/* MZ_OK on success. */
-/* MZ_STREAM_ERROR if the stream is bogus. */
-/* MZ_PARAM_ERROR if the input parameters are bogus. */
-/* MZ_MEM_ERROR on out of memory. */
-MINIZ_EXPORT int mz_deflateInit(mz_streamp pStream, int level);
-
-/* mz_deflateInit2() is like mz_deflate(), except with more control: */
-/* Additional parameters: */
-/* method must be MZ_DEFLATED */
-/* window_bits must be MZ_DEFAULT_WINDOW_BITS (to wrap the deflate stream with
- * zlib header/adler-32 footer) or -MZ_DEFAULT_WINDOW_BITS (raw deflate/no
- * header or footer) */
-/* mem_level must be between [1, 9] (it's checked but ignored by miniz.c) */
-MINIZ_EXPORT int mz_deflateInit2(mz_streamp pStream, int level, int method,
- int window_bits, int mem_level, int strategy);
-
-/* Quickly resets a compressor without having to reallocate anything. Same as
- * calling mz_deflateEnd() followed by mz_deflateInit()/mz_deflateInit2(). */
-MINIZ_EXPORT int mz_deflateReset(mz_streamp pStream);
-
-/* mz_deflate() compresses the input to output, consuming as much of the input
- * and producing as much output as possible. */
-/* Parameters: */
-/* pStream is the stream to read from and write to. You must initialize/update
- * the next_in, avail_in, next_out, and avail_out members. */
-/* flush may be MZ_NO_FLUSH, MZ_PARTIAL_FLUSH/MZ_SYNC_FLUSH, MZ_FULL_FLUSH, or
- * MZ_FINISH. */
-/* Return values: */
-/* MZ_OK on success (when flushing, or if more input is needed but not
- * available, and/or there's more output to be written but the output buffer is
- * full). */
-/* MZ_STREAM_END if all input has been consumed and all output bytes have been
- * written. Don't call mz_deflate() on the stream anymore. */
-/* MZ_STREAM_ERROR if the stream is bogus. */
-/* MZ_PARAM_ERROR if one of the parameters is invalid. */
-/* MZ_BUF_ERROR if no forward progress is possible because the input and/or
- * output buffers are empty. (Fill up the input buffer or free up some output
- * space and try again.) */
-MINIZ_EXPORT int mz_deflate(mz_streamp pStream, int flush);
-
-/* mz_deflateEnd() deinitializes a compressor: */
-/* Return values: */
-/* MZ_OK on success. */
-/* MZ_STREAM_ERROR if the stream is bogus. */
-MINIZ_EXPORT int mz_deflateEnd(mz_streamp pStream);
-
-/* mz_deflateBound() returns a (very) conservative upper bound on the amount of
- * data that could be generated by deflate(), assuming flush is set to only
- * MZ_NO_FLUSH or MZ_FINISH. */
-MINIZ_EXPORT mz_ulong mz_deflateBound(mz_streamp pStream, mz_ulong source_len);
-
-/* Single-call compression functions mz_compress() and mz_compress2(): */
-/* Returns MZ_OK on success, or one of the error codes from mz_deflate() on
- * failure. */
-MINIZ_EXPORT int mz_compress(unsigned char *pDest, mz_ulong *pDest_len,
- const unsigned char *pSource, mz_ulong source_len);
-MINIZ_EXPORT int mz_compress2(unsigned char *pDest, mz_ulong *pDest_len,
- const unsigned char *pSource, mz_ulong source_len,
- int level);
-
-/* mz_compressBound() returns a (very) conservative upper bound on the amount of
- * data that could be generated by calling mz_compress(). */
-MINIZ_EXPORT mz_ulong mz_compressBound(mz_ulong source_len);
-
-/* Initializes a decompressor. */
-MINIZ_EXPORT int mz_inflateInit(mz_streamp pStream);
-
-/* mz_inflateInit2() is like mz_inflateInit() with an additional option that
- * controls the window size and whether or not the stream has been wrapped with
- * a zlib header/footer: */
-/* window_bits must be MZ_DEFAULT_WINDOW_BITS (to parse zlib header/footer) or
- * -MZ_DEFAULT_WINDOW_BITS (raw deflate). */
-MINIZ_EXPORT int mz_inflateInit2(mz_streamp pStream, int window_bits);
-
-/* Quickly resets a compressor without having to reallocate anything. Same as
- * calling mz_inflateEnd() followed by mz_inflateInit()/mz_inflateInit2(). */
-MINIZ_EXPORT int mz_inflateReset(mz_streamp pStream);
-
-/* Decompresses the input stream to the output, consuming only as much of the
- * input as needed, and writing as much to the output as possible. */
-/* Parameters: */
-/* pStream is the stream to read from and write to. You must initialize/update
- * the next_in, avail_in, next_out, and avail_out members. */
-/* flush may be MZ_NO_FLUSH, MZ_SYNC_FLUSH, or MZ_FINISH. */
-/* On the first call, if flush is MZ_FINISH it's assumed the input and output
- * buffers are both sized large enough to decompress the entire stream in a
- * single call (this is slightly faster). */
-/* MZ_FINISH implies that there are no more source bytes available beside
- * what's already in the input buffer, and that the output buffer is large
- * enough to hold the rest of the decompressed data. */
-/* Return values: */
-/* MZ_OK on success. Either more input is needed but not available, and/or
- * there's more output to be written but the output buffer is full. */
-/* MZ_STREAM_END if all needed input has been consumed and all output bytes
- * have been written. For zlib streams, the adler-32 of the decompressed data
- * has also been verified. */
-/* MZ_STREAM_ERROR if the stream is bogus. */
-/* MZ_DATA_ERROR if the deflate stream is invalid. */
-/* MZ_PARAM_ERROR if one of the parameters is invalid. */
-/* MZ_BUF_ERROR if no forward progress is possible because the input buffer is
- * empty but the inflater needs more input to continue, or if the output buffer
- * is not large enough. Call mz_inflate() again */
-/* with more input data, or with more room in the output buffer (except when
- * using single call decompression, described above). */
-MINIZ_EXPORT int mz_inflate(mz_streamp pStream, int flush);
-
-/* Deinitializes a decompressor. */
-MINIZ_EXPORT int mz_inflateEnd(mz_streamp pStream);
-
-/* Single-call decompression. */
-/* Returns MZ_OK on success, or one of the error codes from mz_inflate() on
- * failure. */
-MINIZ_EXPORT int mz_uncompress(unsigned char *pDest, mz_ulong *pDest_len,
- const unsigned char *pSource,
- mz_ulong source_len);
-MINIZ_EXPORT int mz_uncompress2(unsigned char *pDest, mz_ulong *pDest_len,
- const unsigned char *pSource,
- mz_ulong *pSource_len);
-
-/* Returns a string description of the specified error code, or NULL if the
- * error code is invalid. */
-MINIZ_EXPORT const char *mz_error(int err);
-
-/* Redefine zlib-compatible names to miniz equivalents, so miniz.c can be used
- * as a drop-in replacement for the subset of zlib that miniz.c supports. */
-/* Define MINIZ_NO_ZLIB_COMPATIBLE_NAMES to disable zlib-compatibility if you
- * use zlib in the same project. */
-#ifndef MINIZ_NO_ZLIB_COMPATIBLE_NAMES
-typedef unsigned char Byte;
-typedef unsigned int uInt;
-typedef mz_ulong uLong;
-typedef Byte Bytef;
-typedef uInt uIntf;
-typedef char charf;
-typedef int intf;
-typedef void *voidpf;
-typedef uLong uLongf;
-typedef void *voidp;
-typedef void *const voidpc;
-#define Z_NULL 0
-#define Z_NO_FLUSH MZ_NO_FLUSH
-#define Z_PARTIAL_FLUSH MZ_PARTIAL_FLUSH
-#define Z_SYNC_FLUSH MZ_SYNC_FLUSH
-#define Z_FULL_FLUSH MZ_FULL_FLUSH
-#define Z_FINISH MZ_FINISH
-#define Z_BLOCK MZ_BLOCK
-#define Z_OK MZ_OK
-#define Z_STREAM_END MZ_STREAM_END
-#define Z_NEED_DICT MZ_NEED_DICT
-#define Z_ERRNO MZ_ERRNO
-#define Z_STREAM_ERROR MZ_STREAM_ERROR
-#define Z_DATA_ERROR MZ_DATA_ERROR
-#define Z_MEM_ERROR MZ_MEM_ERROR
-#define Z_BUF_ERROR MZ_BUF_ERROR
-#define Z_VERSION_ERROR MZ_VERSION_ERROR
-#define Z_PARAM_ERROR MZ_PARAM_ERROR
-#define Z_NO_COMPRESSION MZ_NO_COMPRESSION
-#define Z_BEST_SPEED MZ_BEST_SPEED
-#define Z_BEST_COMPRESSION MZ_BEST_COMPRESSION
-#define Z_DEFAULT_COMPRESSION MZ_DEFAULT_COMPRESSION
-#define Z_DEFAULT_STRATEGY MZ_DEFAULT_STRATEGY
-#define Z_FILTERED MZ_FILTERED
-#define Z_HUFFMAN_ONLY MZ_HUFFMAN_ONLY
-#define Z_RLE MZ_RLE
-#define Z_FIXED MZ_FIXED
-#define Z_DEFLATED MZ_DEFLATED
-#define Z_DEFAULT_WINDOW_BITS MZ_DEFAULT_WINDOW_BITS
-#define alloc_func mz_alloc_func
-#define free_func mz_free_func
-#define internal_state mz_internal_state
-#define z_stream mz_stream
-#define deflateInit mz_deflateInit
-#define deflateInit2 mz_deflateInit2
-#define deflateReset mz_deflateReset
-#define deflate mz_deflate
-#define deflateEnd mz_deflateEnd
-#define deflateBound mz_deflateBound
-#define compress mz_compress
-#define compress2 mz_compress2
-#define compressBound mz_compressBound
-#define inflateInit mz_inflateInit
-#define inflateInit2 mz_inflateInit2
-#define inflateReset mz_inflateReset
-#define inflate mz_inflate
-#define inflateEnd mz_inflateEnd
-#define uncompress mz_uncompress
-#define uncompress2 mz_uncompress2
-#define crc32 mz_crc32
-#define adler32 mz_adler32
-#define MAX_WBITS 15
-#define MAX_MEM_LEVEL 9
-#define zError mz_error
-#define ZLIB_VERSION MZ_VERSION
-#define ZLIB_VERNUM MZ_VERNUM
-#define ZLIB_VER_MAJOR MZ_VER_MAJOR
-#define ZLIB_VER_MINOR MZ_VER_MINOR
-#define ZLIB_VER_REVISION MZ_VER_REVISION
-#define ZLIB_VER_SUBREVISION MZ_VER_SUBREVISION
-#define zlibVersion mz_version
-#define zlib_version mz_version()
-#endif /* #ifndef MINIZ_NO_ZLIB_COMPATIBLE_NAMES */
-
-#endif /* MINIZ_NO_ZLIB_APIS */
-
-#ifdef __cplusplus
-}
-#endif
-
-#pragma once
-#ifndef FIZZY_ZIP_WASM
-#include
-#endif
-#include
-#ifndef FIZZY_ZIP_WASM
-#include
-#include
-#else
-#include
-extern void *memcpy(void *dest, const void *src, size_t n);
-extern void *memset(void *dest, int c, size_t n);
-extern void *memmove(void *dest, const void *src, size_t n);
-#endif
-
-/* ------------------- Types and macros */
-typedef unsigned char mz_uint8;
-typedef signed short mz_int16;
-typedef unsigned short mz_uint16;
-typedef unsigned int mz_uint32;
-typedef unsigned int mz_uint;
-typedef int64_t mz_int64;
-typedef uint64_t mz_uint64;
-typedef int mz_bool;
-
-#define MZ_FALSE (0)
-#define MZ_TRUE (1)
-
-/* Works around MSVC's spammy "warning C4127: conditional expression is
- * constant" message. */
-#ifdef _MSC_VER
-#define MZ_MACRO_END while (0, 0)
-#else
-#define MZ_MACRO_END while (0)
-#endif
-
-#ifdef MINIZ_NO_STDIO
-#define MZ_FILE void *
-#else
-#include
-#define MZ_FILE FILE
-#endif /* #ifdef MINIZ_NO_STDIO */
-
-#ifdef MINIZ_NO_TIME
-typedef struct mz_dummy_time_t_tag {
- int m_dummy;
-} mz_dummy_time_t;
-#define MZ_TIME_T mz_dummy_time_t
-#else
-#define MZ_TIME_T time_t
-#endif
-
-#define MZ_ASSERT(x) assert(x)
-
-#ifdef MINIZ_NO_MALLOC
-#define MZ_MALLOC(x) NULL
-#define MZ_FREE(x) (void)x, ((void)0)
-#define MZ_REALLOC(p, x) NULL
-#else
-#ifndef MZ_MALLOC
-#define MZ_MALLOC(x) malloc(x)
-#endif
-#ifndef MZ_FREE
-#define MZ_FREE(x) free(x)
-#endif
-#ifndef MZ_REALLOC
-#define MZ_REALLOC(p, x) realloc(p, x)
-#endif
-#endif
-
-#define MZ_MAX(a, b) (((a) > (b)) ? (a) : (b))
-#define MZ_MIN(a, b) (((a) < (b)) ? (a) : (b))
-#define MZ_CLEAR_OBJ(obj) memset(&(obj), 0, sizeof(obj))
-
-#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN
-#define MZ_READ_LE16(p) *((const mz_uint16 *)(p))
-#define MZ_READ_LE32(p) *((const mz_uint32 *)(p))
-#else
-#define MZ_READ_LE16(p) \
- ((mz_uint32)(((const mz_uint8 *)(p))[0]) | \
- ((mz_uint32)(((const mz_uint8 *)(p))[1]) << 8U))
-#define MZ_READ_LE32(p) \
- ((mz_uint32)(((const mz_uint8 *)(p))[0]) | \
- ((mz_uint32)(((const mz_uint8 *)(p))[1]) << 8U) | \
- ((mz_uint32)(((const mz_uint8 *)(p))[2]) << 16U) | \
- ((mz_uint32)(((const mz_uint8 *)(p))[3]) << 24U))
-#endif
-
-#define MZ_READ_LE64(p) \
- (((mz_uint64)MZ_READ_LE32(p)) | \
- (((mz_uint64)MZ_READ_LE32((const mz_uint8 *)(p) + sizeof(mz_uint32))) \
- << 32U))
-
-#ifdef _MSC_VER
-#define MZ_FORCEINLINE __forceinline
-#elif defined(__GNUC__)
-#define MZ_FORCEINLINE __inline__ __attribute__((__always_inline__))
-#else
-#define MZ_FORCEINLINE inline
-#endif
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-extern MINIZ_EXPORT void *miniz_def_alloc_func(void *opaque, size_t items,
- size_t size);
-extern MINIZ_EXPORT void miniz_def_free_func(void *opaque, void *address);
-extern MINIZ_EXPORT void *miniz_def_realloc_func(void *opaque, void *address,
- size_t items, size_t size);
-
-#define MZ_UINT16_MAX (0xFFFFU)
-#define MZ_UINT32_MAX (0xFFFFFFFFU)
-
-#ifdef __cplusplus
-}
-#endif
-#pragma once
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-/* ------------------- Low-level Compression API Definitions */
-
-/* Set TDEFL_LESS_MEMORY to 1 to use less memory (compression will be slightly
- * slower, and raw/dynamic blocks will be output more frequently). */
-#define TDEFL_LESS_MEMORY 0
-
-/* tdefl_init() compression flags logically OR'd together (low 12 bits contain
- * the max. number of probes per dictionary search): */
-/* TDEFL_DEFAULT_MAX_PROBES: The compressor defaults to 128 dictionary probes
- * per dictionary search. 0=Huffman only, 1=Huffman+LZ (fastest/crap
- * compression), 4095=Huffman+LZ (slowest/best compression). */
-enum {
- TDEFL_HUFFMAN_ONLY = 0,
- TDEFL_DEFAULT_MAX_PROBES = 128,
- TDEFL_MAX_PROBES_MASK = 0xFFF
-};
-
-/* TDEFL_WRITE_ZLIB_HEADER: If set, the compressor outputs a zlib header before
- * the deflate data, and the Adler-32 of the source data at the end. Otherwise,
- * you'll get raw deflate data. */
-/* TDEFL_COMPUTE_ADLER32: Always compute the adler-32 of the input data (even
- * when not writing zlib headers). */
-/* TDEFL_GREEDY_PARSING_FLAG: Set to use faster greedy parsing, instead of more
- * efficient lazy parsing. */
-/* TDEFL_NONDETERMINISTIC_PARSING_FLAG: Enable to decrease the compressor's
- * initialization time to the minimum, but the output may vary from run to run
- * given the same input (depending on the contents of memory). */
-/* TDEFL_RLE_MATCHES: Only look for RLE matches (matches with a distance of 1)
- */
-/* TDEFL_FILTER_MATCHES: Discards matches <= 5 chars if enabled. */
-/* TDEFL_FORCE_ALL_STATIC_BLOCKS: Disable usage of optimized Huffman tables. */
-/* TDEFL_FORCE_ALL_RAW_BLOCKS: Only use raw (uncompressed) deflate blocks. */
-/* The low 12 bits are reserved to control the max # of hash probes per
- * dictionary lookup (see TDEFL_MAX_PROBES_MASK). */
-enum {
- TDEFL_WRITE_ZLIB_HEADER = 0x01000,
- TDEFL_COMPUTE_ADLER32 = 0x02000,
- TDEFL_GREEDY_PARSING_FLAG = 0x04000,
- TDEFL_NONDETERMINISTIC_PARSING_FLAG = 0x08000,
- TDEFL_RLE_MATCHES = 0x10000,
- TDEFL_FILTER_MATCHES = 0x20000,
- TDEFL_FORCE_ALL_STATIC_BLOCKS = 0x40000,
- TDEFL_FORCE_ALL_RAW_BLOCKS = 0x80000
-};
-
-/* High level compression functions: */
-/* tdefl_compress_mem_to_heap() compresses a block in memory to a heap block
- * allocated via malloc(). */
-/* On entry: */
-/* pSrc_buf, src_buf_len: Pointer and size of source block to compress. */
-/* flags: The max match finder probes (default is 128) logically OR'd against
- * the above flags. Higher probes are slower but improve compression. */
-/* On return: */
-/* Function returns a pointer to the compressed data, or NULL on failure. */
-/* *pOut_len will be set to the compressed data's size, which could be larger
- * than src_buf_len on uncompressible data. */
-/* The caller must free() the returned block when it's no longer needed. */
-MINIZ_EXPORT void *tdefl_compress_mem_to_heap(const void *pSrc_buf,
- size_t src_buf_len,
- size_t *pOut_len, int flags);
-
-/* tdefl_compress_mem_to_mem() compresses a block in memory to another block in
- * memory. */
-/* Returns 0 on failure. */
-MINIZ_EXPORT size_t tdefl_compress_mem_to_mem(void *pOut_buf,
- size_t out_buf_len,
- const void *pSrc_buf,
- size_t src_buf_len, int flags);
-
-/* Compresses an image to a compressed PNG file in memory. */
-/* On entry: */
-/* pImage, w, h, and num_chans describe the image to compress. num_chans may be
- * 1, 2, 3, or 4. */
-/* The image pitch in bytes per scanline will be w*num_chans. The leftmost
- * pixel on the top scanline is stored first in memory. */
-/* level may range from [0,10], use MZ_NO_COMPRESSION, MZ_BEST_SPEED,
- * MZ_BEST_COMPRESSION, etc. or a decent default is MZ_DEFAULT_LEVEL */
-/* If flip is true, the image will be flipped on the Y axis (useful for OpenGL
- * apps). */
-/* On return: */
-/* Function returns a pointer to the compressed data, or NULL on failure. */
-/* *pLen_out will be set to the size of the PNG image file. */
-/* The caller must mz_free() the returned heap block (which will typically be
- * larger than *pLen_out) when it's no longer needed. */
-MINIZ_EXPORT void *
-tdefl_write_image_to_png_file_in_memory_ex(const void *pImage, int w, int h,
- int num_chans, size_t *pLen_out,
- mz_uint level, mz_bool flip);
-MINIZ_EXPORT void *tdefl_write_image_to_png_file_in_memory(const void *pImage,
- int w, int h,
- int num_chans,
- size_t *pLen_out);
-
-/* Output stream interface. The compressor uses this interface to write
- * compressed data. It'll typically be called TDEFL_OUT_BUF_SIZE at a time. */
-typedef mz_bool (*tdefl_put_buf_func_ptr)(const void *pBuf, int len,
- void *pUser);
-
-/* tdefl_compress_mem_to_output() compresses a block to an output stream. The
- * above helpers use this function internally. */
-MINIZ_EXPORT mz_bool tdefl_compress_mem_to_output(
- const void *pBuf, size_t buf_len, tdefl_put_buf_func_ptr pPut_buf_func,
- void *pPut_buf_user, int flags);
-
-enum {
- TDEFL_MAX_HUFF_TABLES = 3,
- TDEFL_MAX_HUFF_SYMBOLS_0 = 288,
- TDEFL_MAX_HUFF_SYMBOLS_1 = 32,
- TDEFL_MAX_HUFF_SYMBOLS_2 = 19,
- TDEFL_LZ_DICT_SIZE = 32768,
- TDEFL_LZ_DICT_SIZE_MASK = TDEFL_LZ_DICT_SIZE - 1,
- TDEFL_MIN_MATCH_LEN = 3,
- TDEFL_MAX_MATCH_LEN = 258
-};
-
-/* TDEFL_OUT_BUF_SIZE MUST be large enough to hold a single entire compressed
- * output block (using static/fixed Huffman codes). */
-#if TDEFL_LESS_MEMORY
-enum {
- TDEFL_LZ_CODE_BUF_SIZE = 24 * 1024,
- TDEFL_OUT_BUF_SIZE = (TDEFL_LZ_CODE_BUF_SIZE * 13) / 10,
- TDEFL_MAX_HUFF_SYMBOLS = 288,
- TDEFL_LZ_HASH_BITS = 12,
- TDEFL_LEVEL1_HASH_SIZE_MASK = 4095,
- TDEFL_LZ_HASH_SHIFT = (TDEFL_LZ_HASH_BITS + 2) / 3,
- TDEFL_LZ_HASH_SIZE = 1 << TDEFL_LZ_HASH_BITS
-};
-#else
-enum {
- TDEFL_LZ_CODE_BUF_SIZE = 64 * 1024,
- TDEFL_OUT_BUF_SIZE = (TDEFL_LZ_CODE_BUF_SIZE * 13) / 10,
- TDEFL_MAX_HUFF_SYMBOLS = 288,
- TDEFL_LZ_HASH_BITS = 15,
- TDEFL_LEVEL1_HASH_SIZE_MASK = 4095,
- TDEFL_LZ_HASH_SHIFT = (TDEFL_LZ_HASH_BITS + 2) / 3,
- TDEFL_LZ_HASH_SIZE = 1 << TDEFL_LZ_HASH_BITS
-};
-#endif
-
-/* The low-level tdefl functions below may be used directly if the above helper
- * functions aren't flexible enough. The low-level functions don't make any heap
- * allocations, unlike the above helper functions. */
-typedef enum {
- TDEFL_STATUS_BAD_PARAM = -2,
- TDEFL_STATUS_PUT_BUF_FAILED = -1,
- TDEFL_STATUS_OKAY = 0,
- TDEFL_STATUS_DONE = 1
-} tdefl_status;
-
-/* Must map to MZ_NO_FLUSH, MZ_SYNC_FLUSH, etc. enums */
-typedef enum {
- TDEFL_NO_FLUSH = 0,
- TDEFL_SYNC_FLUSH = 2,
- TDEFL_FULL_FLUSH = 3,
- TDEFL_FINISH = 4
-} tdefl_flush;
-
-/* tdefl's compression state structure. */
-typedef struct {
- tdefl_put_buf_func_ptr m_pPut_buf_func;
- void *m_pPut_buf_user;
- mz_uint m_flags, m_max_probes[2];
- int m_greedy_parsing;
- mz_uint m_adler32, m_lookahead_pos, m_lookahead_size, m_dict_size;
- mz_uint8 *m_pLZ_code_buf, *m_pLZ_flags, *m_pOutput_buf, *m_pOutput_buf_end;
- mz_uint m_num_flags_left, m_total_lz_bytes, m_lz_code_buf_dict_pos, m_bits_in,
- m_bit_buffer;
- mz_uint m_saved_match_dist, m_saved_match_len, m_saved_lit,
- m_output_flush_ofs, m_output_flush_remaining, m_finished, m_block_index,
- m_wants_to_finish;
- tdefl_status m_prev_return_status;
- const void *m_pIn_buf;
- void *m_pOut_buf;
- size_t *m_pIn_buf_size, *m_pOut_buf_size;
- tdefl_flush m_flush;
- const mz_uint8 *m_pSrc;
- size_t m_src_buf_left, m_out_buf_ofs;
- mz_uint8 m_dict[TDEFL_LZ_DICT_SIZE + TDEFL_MAX_MATCH_LEN - 1];
- mz_uint16 m_huff_count[TDEFL_MAX_HUFF_TABLES][TDEFL_MAX_HUFF_SYMBOLS];
- mz_uint16 m_huff_codes[TDEFL_MAX_HUFF_TABLES][TDEFL_MAX_HUFF_SYMBOLS];
- mz_uint8 m_huff_code_sizes[TDEFL_MAX_HUFF_TABLES][TDEFL_MAX_HUFF_SYMBOLS];
- mz_uint8 m_lz_code_buf[TDEFL_LZ_CODE_BUF_SIZE];
- mz_uint16 m_next[TDEFL_LZ_DICT_SIZE];
- mz_uint16 m_hash[TDEFL_LZ_HASH_SIZE];
- mz_uint8 m_output_buf[TDEFL_OUT_BUF_SIZE];
-} tdefl_compressor;
-
-/* Initializes the compressor. */
-/* There is no corresponding deinit() function because the tdefl API's do not
- * dynamically allocate memory. */
-/* pBut_buf_func: If NULL, output data will be supplied to the specified
- * callback. In this case, the user should call the tdefl_compress_buffer() API
- * for compression. */
-/* If pBut_buf_func is NULL the user should always call the tdefl_compress()
- * API. */
-/* flags: See the above enums (TDEFL_HUFFMAN_ONLY, TDEFL_WRITE_ZLIB_HEADER,
- * etc.) */
-MINIZ_EXPORT tdefl_status tdefl_init(tdefl_compressor *d,
- tdefl_put_buf_func_ptr pPut_buf_func,
- void *pPut_buf_user, int flags);
-
-/* Compresses a block of data, consuming as much of the specified input buffer
- * as possible, and writing as much compressed data to the specified output
- * buffer as possible. */
-MINIZ_EXPORT tdefl_status tdefl_compress(tdefl_compressor *d,
- const void *pIn_buf,
- size_t *pIn_buf_size, void *pOut_buf,
- size_t *pOut_buf_size,
- tdefl_flush flush);
-
-/* tdefl_compress_buffer() is only usable when the tdefl_init() is called with a
- * non-NULL tdefl_put_buf_func_ptr. */
-/* tdefl_compress_buffer() always consumes the entire input buffer. */
-MINIZ_EXPORT tdefl_status tdefl_compress_buffer(tdefl_compressor *d,
- const void *pIn_buf,
- size_t in_buf_size,
- tdefl_flush flush);
-
-MINIZ_EXPORT tdefl_status tdefl_get_prev_return_status(tdefl_compressor *d);
-MINIZ_EXPORT mz_uint32 tdefl_get_adler32(tdefl_compressor *d);
-
-/* Create tdefl_compress() flags given zlib-style compression parameters. */
-/* level may range from [0,10] (where 10 is absolute max compression, but may be
- * much slower on some files) */
-/* window_bits may be -15 (raw deflate) or 15 (zlib) */
-/* strategy may be either MZ_DEFAULT_STRATEGY, MZ_FILTERED, MZ_HUFFMAN_ONLY,
- * MZ_RLE, or MZ_FIXED */
-MINIZ_EXPORT mz_uint tdefl_create_comp_flags_from_zip_params(int level,
- int window_bits,
- int strategy);
-
-#ifndef MINIZ_NO_MALLOC
-/* Allocate the tdefl_compressor structure in C so that */
-/* non-C language bindings to tdefl_ API don't need to worry about */
-/* structure size and allocation mechanism. */
-MINIZ_EXPORT tdefl_compressor *tdefl_compressor_alloc(void);
-MINIZ_EXPORT void tdefl_compressor_free(tdefl_compressor *pComp);
-#endif
-
-#ifdef __cplusplus
-}
-#endif
-#pragma once
-
-/* ------------------- Low-level Decompression API Definitions */
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-/* Decompression flags used by tinfl_decompress(). */
-/* TINFL_FLAG_PARSE_ZLIB_HEADER: If set, the input has a valid zlib header and
- * ends with an adler32 checksum (it's a valid zlib stream). Otherwise, the
- * input is a raw deflate stream. */
-/* TINFL_FLAG_HAS_MORE_INPUT: If set, there are more input bytes available
- * beyond the end of the supplied input buffer. If clear, the input buffer
- * contains all remaining input. */
-/* TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF: If set, the output buffer is large
- * enough to hold the entire decompressed stream. If clear, the output buffer is
- * at least the size of the dictionary (typically 32KB). */
-/* TINFL_FLAG_COMPUTE_ADLER32: Force adler-32 checksum computation of the
- * decompressed bytes. */
-enum {
- TINFL_FLAG_PARSE_ZLIB_HEADER = 1,
- TINFL_FLAG_HAS_MORE_INPUT = 2,
- TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF = 4,
- TINFL_FLAG_COMPUTE_ADLER32 = 8
-};
-
-/* High level decompression functions: */
-/* tinfl_decompress_mem_to_heap() decompresses a block in memory to a heap block
- * allocated via malloc(). */
-/* On entry: */
-/* pSrc_buf, src_buf_len: Pointer and size of the Deflate or zlib source data
- * to decompress. */
-/* On return: */
-/* Function returns a pointer to the decompressed data, or NULL on failure. */
-/* *pOut_len will be set to the decompressed data's size, which could be larger
- * than src_buf_len on uncompressible data. */
-/* The caller must call mz_free() on the returned block when it's no longer
- * needed. */
-MINIZ_EXPORT void *tinfl_decompress_mem_to_heap(const void *pSrc_buf,
- size_t src_buf_len,
- size_t *pOut_len, int flags);
-
-/* tinfl_decompress_mem_to_mem() decompresses a block in memory to another block
- * in memory. */
-/* Returns TINFL_DECOMPRESS_MEM_TO_MEM_FAILED on failure, or the number of bytes
- * written on success. */
-#define TINFL_DECOMPRESS_MEM_TO_MEM_FAILED ((size_t)(-1))
-MINIZ_EXPORT size_t tinfl_decompress_mem_to_mem(void *pOut_buf,
- size_t out_buf_len,
- const void *pSrc_buf,
- size_t src_buf_len, int flags);
-
-/* tinfl_decompress_mem_to_callback() decompresses a block in memory to an
- * internal 32KB buffer, and a user provided callback function will be called to
- * flush the buffer. */
-/* Returns 1 on success or 0 on failure. */
-typedef int (*tinfl_put_buf_func_ptr)(const void *pBuf, int len, void *pUser);
-MINIZ_EXPORT int
-tinfl_decompress_mem_to_callback(const void *pIn_buf, size_t *pIn_buf_size,
- tinfl_put_buf_func_ptr pPut_buf_func,
- void *pPut_buf_user, int flags);
-
-struct tinfl_decompressor_tag;
-typedef struct tinfl_decompressor_tag tinfl_decompressor;
-
-#ifndef MINIZ_NO_MALLOC
-/* Allocate the tinfl_decompressor structure in C so that */
-/* non-C language bindings to tinfl_ API don't need to worry about */
-/* structure size and allocation mechanism. */
-MINIZ_EXPORT tinfl_decompressor *tinfl_decompressor_alloc(void);
-MINIZ_EXPORT void tinfl_decompressor_free(tinfl_decompressor *pDecomp);
-#endif
-
-/* Max size of LZ dictionary. */
-#define TINFL_LZ_DICT_SIZE 32768
-
-/* Return status. */
-typedef enum {
- /* This flags indicates the inflator needs 1 or more input bytes to make
- forward progress, but the caller is indicating that no more are available.
- The compressed data */
- /* is probably corrupted. If you call the inflator again with more bytes it'll
- try to continue processing the input but this is a BAD sign (either the
- data is corrupted or you called it incorrectly). */
- /* If you call it again with no input you'll just get
- TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS again. */
- TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS = -4,
-
- /* This flag indicates that one or more of the input parameters was obviously
- bogus. (You can try calling it again, but if you get this error the calling
- code is wrong.) */
- TINFL_STATUS_BAD_PARAM = -3,
-
- /* This flags indicate the inflator is finished but the adler32 check of the
- uncompressed data didn't match. If you call it again it'll return
- TINFL_STATUS_DONE. */
- TINFL_STATUS_ADLER32_MISMATCH = -2,
-
- /* This flags indicate the inflator has somehow failed (bad code, corrupted
- input, etc.). If you call it again without resetting via tinfl_init() it
- it'll just keep on returning the same status failure code. */
- TINFL_STATUS_FAILED = -1,
-
- /* Any status code less than TINFL_STATUS_DONE must indicate a failure. */
-
- /* This flag indicates the inflator has returned every byte of uncompressed
- data that it can, has consumed every byte that it needed, has successfully
- reached the end of the deflate stream, and */
- /* if zlib headers and adler32 checking enabled that it has successfully
- checked the uncompressed data's adler32. If you call it again you'll just
- get TINFL_STATUS_DONE over and over again. */
- TINFL_STATUS_DONE = 0,
-
- /* This flag indicates the inflator MUST have more input data (even 1 byte)
- before it can make any more forward progress, or you need to clear the
- TINFL_FLAG_HAS_MORE_INPUT */
- /* flag on the next call if you don't have any more source data. If the source
- data was somehow corrupted it's also possible (but unlikely) for the
- inflator to keep on demanding input to */
- /* proceed, so be sure to properly set the TINFL_FLAG_HAS_MORE_INPUT flag. */
- TINFL_STATUS_NEEDS_MORE_INPUT = 1,
-
- /* This flag indicates the inflator definitely has 1 or more bytes of
- uncompressed data available, but it cannot write this data into the output
- buffer. */
- /* Note if the source compressed data was corrupted it's possible for the
- inflator to return a lot of uncompressed data to the caller. I've been
- assuming you know how much uncompressed data to expect */
- /* (either exact or worst case) and will stop calling the inflator and fail
- after receiving too much. In pure streaming scenarios where you have no
- idea how many bytes to expect this may not be possible */
- /* so I may need to add some code to address this. */
- TINFL_STATUS_HAS_MORE_OUTPUT = 2
-} tinfl_status;
-
-/* Initializes the decompressor to its initial state. */
-#define tinfl_init(r) \
- do { \
- (r)->m_state = 0; \
- } \
- MZ_MACRO_END
-#define tinfl_get_adler32(r) (r)->m_check_adler32
-
-/* Main low-level decompressor coroutine function. This is the only function
- * actually needed for decompression. All the other functions are just
- * high-level helpers for improved usability. */
-/* This is a universal API, i.e. it can be used as a building block to build any
- * desired higher level decompression API. In the limit case, it can be called
- * once per every byte input or output. */
-MINIZ_EXPORT tinfl_status tinfl_decompress(
- tinfl_decompressor *r, const mz_uint8 *pIn_buf_next, size_t *pIn_buf_size,
- mz_uint8 *pOut_buf_start, mz_uint8 *pOut_buf_next, size_t *pOut_buf_size,
- const mz_uint32 decomp_flags);
-
-/* Internal/private bits follow. */
-enum {
- TINFL_MAX_HUFF_TABLES = 3,
- TINFL_MAX_HUFF_SYMBOLS_0 = 288,
- TINFL_MAX_HUFF_SYMBOLS_1 = 32,
- TINFL_MAX_HUFF_SYMBOLS_2 = 19,
- TINFL_FAST_LOOKUP_BITS = 10,
- TINFL_FAST_LOOKUP_SIZE = 1 << TINFL_FAST_LOOKUP_BITS
-};
-
-typedef struct {
- mz_uint8 m_code_size[TINFL_MAX_HUFF_SYMBOLS_0];
- mz_int16 m_look_up[TINFL_FAST_LOOKUP_SIZE],
- m_tree[TINFL_MAX_HUFF_SYMBOLS_0 * 2];
-} tinfl_huff_table;
-
-#if MINIZ_HAS_64BIT_REGISTERS
-#define TINFL_USE_64BIT_BITBUF 1
-#else
-#define TINFL_USE_64BIT_BITBUF 0
-#endif
-
-#if TINFL_USE_64BIT_BITBUF
-typedef mz_uint64 tinfl_bit_buf_t;
-#define TINFL_BITBUF_SIZE (64)
-#else
-typedef mz_uint32 tinfl_bit_buf_t;
-#define TINFL_BITBUF_SIZE (32)
-#endif
-
-struct tinfl_decompressor_tag {
- mz_uint32 m_state, m_num_bits, m_zhdr0, m_zhdr1, m_z_adler32, m_final, m_type,
- m_check_adler32, m_dist, m_counter, m_num_extra,
- m_table_sizes[TINFL_MAX_HUFF_TABLES];
- tinfl_bit_buf_t m_bit_buf;
- size_t m_dist_from_out_buf_start;
- tinfl_huff_table m_tables[TINFL_MAX_HUFF_TABLES];
- mz_uint8 m_raw_header[4],
- m_len_codes[TINFL_MAX_HUFF_SYMBOLS_0 + TINFL_MAX_HUFF_SYMBOLS_1 + 137];
-};
-
-#ifdef __cplusplus
-}
-#endif
-
-#pragma once
-
-/* ------------------- ZIP archive reading/writing */
-
-#ifndef MINIZ_NO_ARCHIVE_APIS
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-enum {
- /* Note: These enums can be reduced as needed to save memory or stack space -
- they are pretty conservative. */
- MZ_ZIP_MAX_IO_BUF_SIZE = 8 * 1024,
- MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE = 512,
- MZ_ZIP_MAX_ARCHIVE_FILE_COMMENT_SIZE = 512
-};
-
-typedef struct {
- /* Central directory file index. */
- mz_uint32 m_file_index;
-
- /* Byte offset of this entry in the archive's central directory. Note we
- * currently only support up to UINT_MAX or less bytes in the central dir. */
- mz_uint64 m_central_dir_ofs;
-
- /* These fields are copied directly from the zip's central dir. */
- mz_uint16 m_version_made_by;
- mz_uint16 m_version_needed;
- mz_uint16 m_bit_flag;
- mz_uint16 m_method;
-
-#ifndef MINIZ_NO_TIME
- MZ_TIME_T m_time;
-#endif
-
- /* CRC-32 of uncompressed data. */
- mz_uint32 m_crc32;
-
- /* File's compressed size. */
- mz_uint64 m_comp_size;
-
- /* File's uncompressed size. Note, I've seen some old archives where directory
- * entries had 512 bytes for their uncompressed sizes, but when you try to
- * unpack them you actually get 0 bytes. */
- mz_uint64 m_uncomp_size;
-
- /* Zip internal and external file attributes. */
- mz_uint16 m_internal_attr;
- mz_uint32 m_external_attr;
-
- /* Entry's local header file offset in bytes. */
- mz_uint64 m_local_header_ofs;
-
- /* Size of comment in bytes. */
- mz_uint32 m_comment_size;
-
- /* MZ_TRUE if the entry appears to be a directory. */
- mz_bool m_is_directory;
-
- /* MZ_TRUE if the entry uses encryption/strong encryption (which miniz_zip
- * doesn't support) */
- mz_bool m_is_encrypted;
-
- /* MZ_TRUE if the file is not encrypted, a patch file, and if it uses a
- * compression method we support. */
- mz_bool m_is_supported;
-
- /* Filename. If string ends in '/' it's a subdirectory entry. */
- /* Guaranteed to be zero terminated, may be truncated to fit. */
- char m_filename[MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE];
-
- /* Comment field. */
- /* Guaranteed to be zero terminated, may be truncated to fit. */
- char m_comment[MZ_ZIP_MAX_ARCHIVE_FILE_COMMENT_SIZE];
-
-} mz_zip_archive_file_stat;
-
-typedef size_t (*mz_file_read_func)(void *pOpaque, mz_uint64 file_ofs,
- void *pBuf, size_t n);
-typedef size_t (*mz_file_write_func)(void *pOpaque, mz_uint64 file_ofs,
- const void *pBuf, size_t n);
-typedef mz_bool (*mz_file_needs_keepalive)(void *pOpaque);
-
-struct mz_zip_internal_state_tag;
-typedef struct mz_zip_internal_state_tag mz_zip_internal_state;
-
-typedef enum {
- MZ_ZIP_MODE_INVALID = 0,
- MZ_ZIP_MODE_READING = 1,
- MZ_ZIP_MODE_WRITING = 2,
- MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED = 3
-} mz_zip_mode;
-
-typedef enum {
- MZ_ZIP_FLAG_CASE_SENSITIVE = 0x0100,
- MZ_ZIP_FLAG_IGNORE_PATH = 0x0200,
- MZ_ZIP_FLAG_COMPRESSED_DATA = 0x0400,
- MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY = 0x0800,
- MZ_ZIP_FLAG_VALIDATE_LOCATE_FILE_FLAG =
- 0x1000, /* if enabled, mz_zip_reader_locate_file() will be called on each
- file as its validated to ensure the func finds the file in the
- central dir (intended for testing) */
- MZ_ZIP_FLAG_VALIDATE_HEADERS_ONLY =
- 0x2000, /* validate the local headers, but don't decompress the entire
- file and check the crc32 */
- MZ_ZIP_FLAG_WRITE_ZIP64 =
- 0x4000, /* always use the zip64 file format, instead of the original zip
- file format with automatic switch to zip64. Use as flags
- parameter with mz_zip_writer_init*_v2 */
- MZ_ZIP_FLAG_WRITE_ALLOW_READING = 0x8000,
- MZ_ZIP_FLAG_ASCII_FILENAME = 0x10000,
- /*After adding a compressed file, seek back
- to local file header and set the correct sizes*/
- MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE = 0x20000
-} mz_zip_flags;
-
-typedef enum {
- MZ_ZIP_TYPE_INVALID = 0,
- MZ_ZIP_TYPE_USER,
- MZ_ZIP_TYPE_MEMORY,
- MZ_ZIP_TYPE_HEAP,
- MZ_ZIP_TYPE_FILE,
- MZ_ZIP_TYPE_CFILE,
- MZ_ZIP_TOTAL_TYPES
-} mz_zip_type;
-
-/* miniz error codes. Be sure to update mz_zip_get_error_string() if you add or
- * modify this enum. */
-typedef enum {
- MZ_ZIP_NO_ERROR = 0,
- MZ_ZIP_UNDEFINED_ERROR,
- MZ_ZIP_TOO_MANY_FILES,
- MZ_ZIP_FILE_TOO_LARGE,
- MZ_ZIP_UNSUPPORTED_METHOD,
- MZ_ZIP_UNSUPPORTED_ENCRYPTION,
- MZ_ZIP_UNSUPPORTED_FEATURE,
- MZ_ZIP_FAILED_FINDING_CENTRAL_DIR,
- MZ_ZIP_NOT_AN_ARCHIVE,
- MZ_ZIP_INVALID_HEADER_OR_CORRUPTED,
- MZ_ZIP_UNSUPPORTED_MULTIDISK,
- MZ_ZIP_DECOMPRESSION_FAILED,
- MZ_ZIP_COMPRESSION_FAILED,
- MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE,
- MZ_ZIP_CRC_CHECK_FAILED,
- MZ_ZIP_UNSUPPORTED_CDIR_SIZE,
- MZ_ZIP_ALLOC_FAILED,
- MZ_ZIP_FILE_OPEN_FAILED,
- MZ_ZIP_FILE_CREATE_FAILED,
- MZ_ZIP_FILE_WRITE_FAILED,
- MZ_ZIP_FILE_READ_FAILED,
- MZ_ZIP_FILE_CLOSE_FAILED,
- MZ_ZIP_FILE_SEEK_FAILED,
- MZ_ZIP_FILE_STAT_FAILED,
- MZ_ZIP_INVALID_PARAMETER,
- MZ_ZIP_INVALID_FILENAME,
- MZ_ZIP_BUF_TOO_SMALL,
- MZ_ZIP_INTERNAL_ERROR,
- MZ_ZIP_FILE_NOT_FOUND,
- MZ_ZIP_ARCHIVE_TOO_LARGE,
- MZ_ZIP_VALIDATION_FAILED,
- MZ_ZIP_WRITE_CALLBACK_FAILED,
- MZ_ZIP_TOTAL_ERRORS
-} mz_zip_error;
-
-typedef struct {
- mz_uint64 m_archive_size;
- mz_uint64 m_central_directory_file_ofs;
-
- /* We only support up to UINT32_MAX files in zip64 mode. */
- mz_uint32 m_total_files;
- mz_zip_mode m_zip_mode;
- mz_zip_type m_zip_type;
- mz_zip_error m_last_error;
-
- mz_uint64 m_file_offset_alignment;
-
- mz_alloc_func m_pAlloc;
- mz_free_func m_pFree;
- mz_realloc_func m_pRealloc;
- void *m_pAlloc_opaque;
-
- mz_file_read_func m_pRead;
- mz_file_write_func m_pWrite;
- mz_file_needs_keepalive m_pNeeds_keepalive;
- void *m_pIO_opaque;
-
- mz_zip_internal_state *m_pState;
-
-} mz_zip_archive;
-
-typedef struct {
- mz_zip_archive *pZip;
- mz_uint flags;
-
- int status;
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- mz_uint file_crc32;
-#endif
- mz_uint64 read_buf_size, read_buf_ofs, read_buf_avail, comp_remaining,
- out_buf_ofs, cur_file_ofs;
- mz_zip_archive_file_stat file_stat;
- void *pRead_buf;
- void *pWrite_buf;
-
- size_t out_blk_remain;
-
- tinfl_decompressor inflator;
-
-} mz_zip_reader_extract_iter_state;
-
-/* -------- ZIP reading */
-
-/* Inits a ZIP archive reader. */
-/* These functions read and validate the archive's central directory. */
-MINIZ_EXPORT mz_bool mz_zip_reader_init(mz_zip_archive *pZip, mz_uint64 size,
- mz_uint flags);
-
-MINIZ_EXPORT mz_bool mz_zip_reader_init_mem(mz_zip_archive *pZip,
- const void *pMem, size_t size,
- mz_uint flags);
-
-#ifndef MINIZ_NO_STDIO
-/* Read a archive from a disk file. */
-/* file_start_ofs is the file offset where the archive actually begins, or 0. */
-/* actual_archive_size is the true total size of the archive, which may be
- * smaller than the file's actual size on disk. If zero the entire file is
- * treated as the archive. */
-MINIZ_EXPORT mz_bool mz_zip_reader_init_file(mz_zip_archive *pZip,
- const char *pFilename,
- mz_uint32 flags);
-MINIZ_EXPORT mz_bool mz_zip_reader_init_file_v2(mz_zip_archive *pZip,
- const char *pFilename,
- mz_uint flags,
- mz_uint64 file_start_ofs,
- mz_uint64 archive_size);
-MINIZ_EXPORT mz_bool mz_zip_reader_init_file_v2_rpb(mz_zip_archive *pZip,
- const char *pFilename,
- mz_uint flags,
- mz_uint64 file_start_ofs,
- mz_uint64 archive_size);
-
-/* Read an archive from an already opened FILE, beginning at the current file
- * position. */
-/* The archive is assumed to be archive_size bytes long. If archive_size is 0,
- * then the entire rest of the file is assumed to contain the archive. */
-/* The FILE will NOT be closed when mz_zip_reader_end() is called. */
-MINIZ_EXPORT mz_bool mz_zip_reader_init_cfile(mz_zip_archive *pZip,
- MZ_FILE *pFile,
- mz_uint64 archive_size,
- mz_uint flags);
-#endif
-
-/* Ends archive reading, freeing all allocations, and closing the input archive
- * file if mz_zip_reader_init_file() was used. */
-MINIZ_EXPORT mz_bool mz_zip_reader_end(mz_zip_archive *pZip);
-
-/* -------- ZIP reading or writing */
-
-/* Clears a mz_zip_archive struct to all zeros. */
-/* Important: This must be done before passing the struct to any mz_zip
- * functions. */
-MINIZ_EXPORT void mz_zip_zero_struct(mz_zip_archive *pZip);
-
-MINIZ_EXPORT mz_zip_mode mz_zip_get_mode(mz_zip_archive *pZip);
-MINIZ_EXPORT mz_zip_type mz_zip_get_type(mz_zip_archive *pZip);
-
-/* Returns the total number of files in the archive. */
-MINIZ_EXPORT mz_uint mz_zip_reader_get_num_files(mz_zip_archive *pZip);
-
-MINIZ_EXPORT mz_uint64 mz_zip_get_archive_size(mz_zip_archive *pZip);
-MINIZ_EXPORT mz_uint64
-mz_zip_get_archive_file_start_offset(mz_zip_archive *pZip);
-MINIZ_EXPORT MZ_FILE *mz_zip_get_cfile(mz_zip_archive *pZip);
-
-/* Reads n bytes of raw archive data, starting at file offset file_ofs, to pBuf.
- */
-MINIZ_EXPORT size_t mz_zip_read_archive_data(mz_zip_archive *pZip,
- mz_uint64 file_ofs, void *pBuf,
- size_t n);
-
-/* All mz_zip funcs set the m_last_error field in the mz_zip_archive struct.
- * These functions retrieve/manipulate this field. */
-/* Note that the m_last_error functionality is not thread safe. */
-MINIZ_EXPORT mz_zip_error mz_zip_set_last_error(mz_zip_archive *pZip,
- mz_zip_error err_num);
-MINIZ_EXPORT mz_zip_error mz_zip_peek_last_error(mz_zip_archive *pZip);
-MINIZ_EXPORT mz_zip_error mz_zip_clear_last_error(mz_zip_archive *pZip);
-MINIZ_EXPORT mz_zip_error mz_zip_get_last_error(mz_zip_archive *pZip);
-MINIZ_EXPORT const char *mz_zip_get_error_string(mz_zip_error mz_err);
-
-/* MZ_TRUE if the archive file entry is a directory entry. */
-MINIZ_EXPORT mz_bool mz_zip_reader_is_file_a_directory(mz_zip_archive *pZip,
- mz_uint file_index);
-
-/* MZ_TRUE if the file is encrypted/strong encrypted. */
-MINIZ_EXPORT mz_bool mz_zip_reader_is_file_encrypted(mz_zip_archive *pZip,
- mz_uint file_index);
-
-/* MZ_TRUE if the compression method is supported, and the file is not
- * encrypted, and the file is not a compressed patch file. */
-MINIZ_EXPORT mz_bool mz_zip_reader_is_file_supported(mz_zip_archive *pZip,
- mz_uint file_index);
-
-/* Retrieves the filename of an archive file entry. */
-/* Returns the number of bytes written to pFilename, or if filename_buf_size is
- * 0 this function returns the number of bytes needed to fully store the
- * filename. */
-MINIZ_EXPORT mz_uint mz_zip_reader_get_filename(mz_zip_archive *pZip,
- mz_uint file_index,
- char *pFilename,
- mz_uint filename_buf_size);
-
-/* Attempts to locates a file in the archive's central directory. */
-/* Valid flags: MZ_ZIP_FLAG_CASE_SENSITIVE, MZ_ZIP_FLAG_IGNORE_PATH */
-/* Returns -1 if the file cannot be found. */
-MINIZ_EXPORT int mz_zip_reader_locate_file(mz_zip_archive *pZip,
- const char *pName,
- const char *pComment, mz_uint flags);
-MINIZ_EXPORT mz_bool mz_zip_reader_locate_file_v2(mz_zip_archive *pZip,
- const char *pName,
- const char *pComment,
- mz_uint flags,
- mz_uint32 *file_index);
-
-/* Returns detailed information about an archive file entry. */
-MINIZ_EXPORT mz_bool mz_zip_reader_file_stat(mz_zip_archive *pZip,
- mz_uint file_index,
- mz_zip_archive_file_stat *pStat);
-
-/* MZ_TRUE if the file is in zip64 format. */
-/* A file is considered zip64 if it contained a zip64 end of central directory
- * marker, or if it contained any zip64 extended file information fields in the
- * central directory. */
-MINIZ_EXPORT mz_bool mz_zip_is_zip64(mz_zip_archive *pZip);
-
-/* Returns the total central directory size in bytes. */
-/* The current max supported size is <= MZ_UINT32_MAX. */
-MINIZ_EXPORT size_t mz_zip_get_central_dir_size(mz_zip_archive *pZip);
-
-/* Extracts a archive file to a memory buffer using no memory allocation. */
-/* There must be at least enough room on the stack to store the inflator's state
- * (~34KB or so). */
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_mem_no_alloc(
- mz_zip_archive *pZip, mz_uint file_index, void *pBuf, size_t buf_size,
- mz_uint flags, void *pUser_read_buf, size_t user_read_buf_size);
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_mem_no_alloc(
- mz_zip_archive *pZip, const char *pFilename, void *pBuf, size_t buf_size,
- mz_uint flags, void *pUser_read_buf, size_t user_read_buf_size);
-
-/* Extracts a archive file to a memory buffer. */
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_mem(mz_zip_archive *pZip,
- mz_uint file_index,
- void *pBuf, size_t buf_size,
- mz_uint flags);
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_mem(mz_zip_archive *pZip,
- const char *pFilename,
- void *pBuf,
- size_t buf_size,
- mz_uint flags);
-
-/* Extracts a archive file to a dynamically allocated heap buffer. */
-/* The memory will be allocated via the mz_zip_archive's alloc/realloc
- * functions. */
-/* Returns NULL and sets the last error on failure. */
-MINIZ_EXPORT void *mz_zip_reader_extract_to_heap(mz_zip_archive *pZip,
- mz_uint file_index,
- size_t *pSize, mz_uint flags);
-MINIZ_EXPORT void *mz_zip_reader_extract_file_to_heap(mz_zip_archive *pZip,
- const char *pFilename,
- size_t *pSize,
- mz_uint flags);
-
-/* Extracts a archive file using a callback function to output the file's data.
- */
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_callback(
- mz_zip_archive *pZip, mz_uint file_index, mz_file_write_func pCallback,
- void *pOpaque, mz_uint flags);
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_callback(
- mz_zip_archive *pZip, const char *pFilename, mz_file_write_func pCallback,
- void *pOpaque, mz_uint flags);
-
-/* Extract a file iteratively */
-MINIZ_EXPORT mz_zip_reader_extract_iter_state *
-mz_zip_reader_extract_iter_new(mz_zip_archive *pZip, mz_uint file_index,
- mz_uint flags);
-MINIZ_EXPORT mz_zip_reader_extract_iter_state *
-mz_zip_reader_extract_file_iter_new(mz_zip_archive *pZip, const char *pFilename,
- mz_uint flags);
-MINIZ_EXPORT size_t mz_zip_reader_extract_iter_read(
- mz_zip_reader_extract_iter_state *pState, void *pvBuf, size_t buf_size);
-MINIZ_EXPORT mz_bool
-mz_zip_reader_extract_iter_free(mz_zip_reader_extract_iter_state *pState);
-
-#ifndef MINIZ_NO_STDIO
-/* Extracts a archive file to a disk file and sets its last accessed and
- * modified times. */
-/* This function only extracts files, not archive directory records. */
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_file(mz_zip_archive *pZip,
- mz_uint file_index,
- const char *pDst_filename,
- mz_uint flags);
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_file(
- mz_zip_archive *pZip, const char *pArchive_filename,
- const char *pDst_filename, mz_uint flags);
-
-/* Extracts a archive file starting at the current position in the destination
- * FILE stream. */
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_to_cfile(mz_zip_archive *pZip,
- mz_uint file_index,
- MZ_FILE *File,
- mz_uint flags);
-MINIZ_EXPORT mz_bool mz_zip_reader_extract_file_to_cfile(
- mz_zip_archive *pZip, const char *pArchive_filename, MZ_FILE *pFile,
- mz_uint flags);
-#endif
-
-#if 0
-/* TODO */
- typedef void *mz_zip_streaming_extract_state_ptr;
- mz_zip_streaming_extract_state_ptr mz_zip_streaming_extract_begin(mz_zip_archive *pZip, mz_uint file_index, mz_uint flags);
- uint64_t mz_zip_streaming_extract_get_size(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState);
- uint64_t mz_zip_streaming_extract_get_cur_ofs(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState);
- mz_bool mz_zip_streaming_extract_seek(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState, uint64_t new_ofs);
- size_t mz_zip_streaming_extract_read(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState, void *pBuf, size_t buf_size);
- mz_bool mz_zip_streaming_extract_end(mz_zip_archive *pZip, mz_zip_streaming_extract_state_ptr pState);
-#endif
-
-/* This function compares the archive's local headers, the optional local zip64
- * extended information block, and the optional descriptor following the
- * compressed data vs. the data in the central directory. */
-/* It also validates that each file can be successfully uncompressed unless the
- * MZ_ZIP_FLAG_VALIDATE_HEADERS_ONLY is specified. */
-MINIZ_EXPORT mz_bool mz_zip_validate_file(mz_zip_archive *pZip,
- mz_uint file_index, mz_uint flags);
-
-/* Validates an entire archive by calling mz_zip_validate_file() on each file.
- */
-MINIZ_EXPORT mz_bool mz_zip_validate_archive(mz_zip_archive *pZip,
- mz_uint flags);
-
-/* Misc utils/helpers, valid for ZIP reading or writing */
-MINIZ_EXPORT mz_bool mz_zip_validate_mem_archive(const void *pMem, size_t size,
- mz_uint flags,
- mz_zip_error *pErr);
-MINIZ_EXPORT mz_bool mz_zip_validate_file_archive(const char *pFilename,
- mz_uint flags,
- mz_zip_error *pErr);
-
-/* Universal end function - calls either mz_zip_reader_end() or
- * mz_zip_writer_end(). */
-MINIZ_EXPORT mz_bool mz_zip_end(mz_zip_archive *pZip);
-
-/* -------- ZIP writing */
-
-#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS
-
-/* Inits a ZIP archive writer. */
-/*Set pZip->m_pWrite (and pZip->m_pIO_opaque) before calling mz_zip_writer_init
- * or mz_zip_writer_init_v2*/
-/*The output is streamable, i.e. file_ofs in mz_file_write_func always increases
- * only by n*/
-MINIZ_EXPORT mz_bool mz_zip_writer_init(mz_zip_archive *pZip,
- mz_uint64 existing_size);
-MINIZ_EXPORT mz_bool mz_zip_writer_init_v2(mz_zip_archive *pZip,
- mz_uint64 existing_size,
- mz_uint flags);
-
-MINIZ_EXPORT mz_bool mz_zip_writer_init_heap(
- mz_zip_archive *pZip, size_t size_to_reserve_at_beginning,
- size_t initial_allocation_size);
-MINIZ_EXPORT mz_bool mz_zip_writer_init_heap_v2(
- mz_zip_archive *pZip, size_t size_to_reserve_at_beginning,
- size_t initial_allocation_size, mz_uint flags);
-
-#ifndef MINIZ_NO_STDIO
-MINIZ_EXPORT mz_bool
-mz_zip_writer_init_file(mz_zip_archive *pZip, const char *pFilename,
- mz_uint64 size_to_reserve_at_beginning);
-MINIZ_EXPORT mz_bool mz_zip_writer_init_file_v2(
- mz_zip_archive *pZip, const char *pFilename,
- mz_uint64 size_to_reserve_at_beginning, mz_uint flags);
-MINIZ_EXPORT mz_bool mz_zip_writer_init_cfile(mz_zip_archive *pZip,
- MZ_FILE *pFile, mz_uint flags);
-#endif
-
-/* Converts a ZIP archive reader object into a writer object, to allow efficient
- * in-place file appends to occur on an existing archive. */
-/* For archives opened using mz_zip_reader_init_file, pFilename must be the
- * archive's filename so it can be reopened for writing. If the file can't be
- * reopened, mz_zip_reader_end() will be called. */
-/* For archives opened using mz_zip_reader_init_mem, the memory block must be
- * growable using the realloc callback (which defaults to realloc unless you've
- * overridden it). */
-/* Finally, for archives opened using mz_zip_reader_init, the mz_zip_archive's
- * user provided m_pWrite function cannot be NULL. */
-/* Note: In-place archive modification is not recommended unless you know what
- * you're doing, because if execution stops or something goes wrong before */
-/* the archive is finalized the file's central directory will be hosed. */
-MINIZ_EXPORT mz_bool mz_zip_writer_init_from_reader(mz_zip_archive *pZip,
- const char *pFilename);
-MINIZ_EXPORT mz_bool mz_zip_writer_init_from_reader_v2(mz_zip_archive *pZip,
- const char *pFilename,
- mz_uint flags);
-MINIZ_EXPORT mz_bool mz_zip_writer_init_from_reader_v2_noreopen(
- mz_zip_archive *pZip, const char *pFilename, mz_uint flags);
-
-/* Adds the contents of a memory buffer to an archive. These functions record
- * the current local time into the archive. */
-/* To add a directory entry, call this method with an archive name ending in a
- * forwardslash with an empty buffer. */
-/* level_and_flags - compression level (0-10, see MZ_BEST_SPEED,
- * MZ_BEST_COMPRESSION, etc.) logically OR'd with zero or more mz_zip_flags, or
- * just set to MZ_DEFAULT_COMPRESSION. */
-MINIZ_EXPORT mz_bool mz_zip_writer_add_mem(mz_zip_archive *pZip,
- const char *pArchive_name,
- const void *pBuf, size_t buf_size,
- mz_uint level_and_flags);
-
-/* Like mz_zip_writer_add_mem(), except you can specify a file comment field,
- * and optionally supply the function with already compressed data. */
-/* uncomp_size/uncomp_crc32 are only used if the MZ_ZIP_FLAG_COMPRESSED_DATA
- * flag is specified. */
-MINIZ_EXPORT mz_bool mz_zip_writer_add_mem_ex(
- mz_zip_archive *pZip, const char *pArchive_name, const void *pBuf,
- size_t buf_size, const void *pComment, mz_uint16 comment_size,
- mz_uint level_and_flags, mz_uint64 uncomp_size, mz_uint32 uncomp_crc32);
-
-MINIZ_EXPORT mz_bool mz_zip_writer_add_mem_ex_v2(
- mz_zip_archive *pZip, const char *pArchive_name, const void *pBuf,
- size_t buf_size, const void *pComment, mz_uint16 comment_size,
- mz_uint level_and_flags, mz_uint64 uncomp_size, mz_uint32 uncomp_crc32,
- MZ_TIME_T *last_modified, const char *user_extra_data_local,
- mz_uint user_extra_data_local_len, const char *user_extra_data_central,
- mz_uint user_extra_data_central_len);
-
-/* Adds the contents of a file to an archive. This function also records the
- * disk file's modified time into the archive. */
-/* File data is supplied via a read callback function. User
- * mz_zip_writer_add_(c)file to add a file directly.*/
-MINIZ_EXPORT mz_bool mz_zip_writer_add_read_buf_callback(
- mz_zip_archive *pZip, const char *pArchive_name,
- mz_file_read_func read_callback, void *callback_opaque, mz_uint64 max_size,
- const MZ_TIME_T *pFile_time, const void *pComment, mz_uint16 comment_size,
- mz_uint level_and_flags, mz_uint32 ext_attributes,
- const char *user_extra_data_local, mz_uint user_extra_data_local_len,
- const char *user_extra_data_central, mz_uint user_extra_data_central_len);
-
-#ifndef MINIZ_NO_STDIO
-/* Adds the contents of a disk file to an archive. This function also records
- * the disk file's modified time into the archive. */
-/* level_and_flags - compression level (0-10, see MZ_BEST_SPEED,
- * MZ_BEST_COMPRESSION, etc.) logically OR'd with zero or more mz_zip_flags, or
- * just set to MZ_DEFAULT_COMPRESSION. */
-MINIZ_EXPORT mz_bool mz_zip_writer_add_file(
- mz_zip_archive *pZip, const char *pArchive_name, const char *pSrc_filename,
- const void *pComment, mz_uint16 comment_size, mz_uint level_and_flags,
- mz_uint32 ext_attributes);
-
-/* Like mz_zip_writer_add_file(), except the file data is read from the
- * specified FILE stream. */
-MINIZ_EXPORT mz_bool mz_zip_writer_add_cfile(
- mz_zip_archive *pZip, const char *pArchive_name, MZ_FILE *pSrc_file,
- mz_uint64 max_size, const MZ_TIME_T *pFile_time, const void *pComment,
- mz_uint16 comment_size, mz_uint level_and_flags, mz_uint32 ext_attributes,
- const char *user_extra_data_local, mz_uint user_extra_data_local_len,
- const char *user_extra_data_central, mz_uint user_extra_data_central_len);
-#endif
-
-/* Adds a file to an archive by fully cloning the data from another archive. */
-/* This function fully clones the source file's compressed data (no
- * recompression), along with its full filename, extra data (it may add or
- * modify the zip64 local header extra data field), and the optional descriptor
- * following the compressed data. */
-MINIZ_EXPORT mz_bool mz_zip_writer_add_from_zip_reader(
- mz_zip_archive *pZip, mz_zip_archive *pSource_zip, mz_uint src_file_index);
-
-/* Finalizes the archive by writing the central directory records followed by
- * the end of central directory record. */
-/* After an archive is finalized, the only valid call on the mz_zip_archive
- * struct is mz_zip_writer_end(). */
-/* An archive must be manually finalized by calling this function for it to be
- * valid. */
-MINIZ_EXPORT mz_bool mz_zip_writer_finalize_archive(mz_zip_archive *pZip);
-
-/* Finalizes a heap archive, returning a pointer to the heap block and its size.
- */
-/* The heap block will be allocated using the mz_zip_archive's alloc/realloc
- * callbacks. */
-MINIZ_EXPORT mz_bool mz_zip_writer_finalize_heap_archive(mz_zip_archive *pZip,
- void **ppBuf,
- size_t *pSize);
-
-/* Ends archive writing, freeing all allocations, and closing the output file if
- * mz_zip_writer_init_file() was used. */
-/* Note for the archive to be valid, it *must* have been finalized before ending
- * (this function will not do it for you). */
-MINIZ_EXPORT mz_bool mz_zip_writer_end(mz_zip_archive *pZip);
-
-/* -------- Misc. high-level helper functions: */
-
-/* mz_zip_add_mem_to_archive_file_in_place() efficiently (but not atomically)
- * appends a memory blob to a ZIP archive. */
-/* Note this is NOT a fully safe operation. If it crashes or dies in some way
- * your archive can be left in a screwed up state (without a central directory).
- */
-/* level_and_flags - compression level (0-10, see MZ_BEST_SPEED,
- * MZ_BEST_COMPRESSION, etc.) logically OR'd with zero or more mz_zip_flags, or
- * just set to MZ_DEFAULT_COMPRESSION. */
-/* TODO: Perhaps add an option to leave the existing central dir in place in
- * case the add dies? We could then truncate the file (so the old central dir
- * would be at the end) if something goes wrong. */
-MINIZ_EXPORT mz_bool mz_zip_add_mem_to_archive_file_in_place(
- const char *pZip_filename, const char *pArchive_name, const void *pBuf,
- size_t buf_size, const void *pComment, mz_uint16 comment_size,
- mz_uint level_and_flags);
-MINIZ_EXPORT mz_bool mz_zip_add_mem_to_archive_file_in_place_v2(
- const char *pZip_filename, const char *pArchive_name, const void *pBuf,
- size_t buf_size, const void *pComment, mz_uint16 comment_size,
- mz_uint level_and_flags, mz_zip_error *pErr);
-
-/* Reads a single file from an archive into a heap block. */
-/* If pComment is not NULL, only the file with the specified comment will be
- * extracted. */
-/* Returns NULL on failure. */
-MINIZ_EXPORT void *
-mz_zip_extract_archive_file_to_heap(const char *pZip_filename,
- const char *pArchive_name, size_t *pSize,
- mz_uint flags);
-MINIZ_EXPORT void *mz_zip_extract_archive_file_to_heap_v2(
- const char *pZip_filename, const char *pArchive_name, const char *pComment,
- size_t *pSize, mz_uint flags, mz_zip_error *pErr);
-
-#endif /* #ifndef MINIZ_NO_ARCHIVE_WRITING_APIS */
-
-#ifdef __cplusplus
-}
-#endif
-
-#endif /* MINIZ_NO_ARCHIVE_APIS */
-/**************************************************************************
- *
- * Copyright 2013-2014 RAD Game Tools and Valve Software
- * Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC
- * All Rights Reserved.
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- **************************************************************************/
-
-typedef unsigned char mz_validate_uint16[sizeof(mz_uint16) == 2 ? 1 : -1];
-typedef unsigned char mz_validate_uint32[sizeof(mz_uint32) == 4 ? 1 : -1];
-typedef unsigned char mz_validate_uint64[sizeof(mz_uint64) == 8 ? 1 : -1];
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/* ------------------- zlib-style API's */
-
-mz_ulong mz_adler32(mz_ulong adler, const unsigned char *ptr, size_t buf_len) {
- mz_uint32 i, s1 = (mz_uint32)(adler & 0xffff), s2 = (mz_uint32)(adler >> 16);
- size_t block_len = buf_len % 5552;
- if (!ptr)
- return MZ_ADLER32_INIT;
- while (buf_len) {
- for (i = 0; i + 7 < block_len; i += 8, ptr += 8) {
- s1 += ptr[0], s2 += s1;
- s1 += ptr[1], s2 += s1;
- s1 += ptr[2], s2 += s1;
- s1 += ptr[3], s2 += s1;
- s1 += ptr[4], s2 += s1;
- s1 += ptr[5], s2 += s1;
- s1 += ptr[6], s2 += s1;
- s1 += ptr[7], s2 += s1;
- }
- for (; i < block_len; ++i)
- s1 += *ptr++, s2 += s1;
- s1 %= 65521U, s2 %= 65521U;
- buf_len -= block_len;
- block_len = 5552;
- }
- return (s2 << 16) + s1;
-}
-
-/* Karl Malbrain's compact CRC-32. See "A compact CCITT crc16 and crc32 C
- * implementation that balances processor cache usage against speed":
- * http://www.geocities.com/malbrain/ */
-#if 0
- mz_ulong mz_crc32(mz_ulong crc, const mz_uint8 *ptr, size_t buf_len)
- {
- static const mz_uint32 s_crc32[16] = { 0, 0x1db71064, 0x3b6e20c8, 0x26d930ac, 0x76dc4190, 0x6b6b51f4, 0x4db26158, 0x5005713c,
- 0xedb88320, 0xf00f9344, 0xd6d6a3e8, 0xcb61b38c, 0x9b64c2b0, 0x86d3d2d4, 0xa00ae278, 0xbdbdf21c };
- mz_uint32 crcu32 = (mz_uint32)crc;
- if (!ptr)
- return MZ_CRC32_INIT;
- crcu32 = ~crcu32;
- while (buf_len--)
- {
- mz_uint8 b = *ptr++;
- crcu32 = (crcu32 >> 4) ^ s_crc32[(crcu32 & 0xF) ^ (b & 0xF)];
- crcu32 = (crcu32 >> 4) ^ s_crc32[(crcu32 & 0xF) ^ (b >> 4)];
- }
- return ~crcu32;
- }
-#elif defined(USE_EXTERNAL_MZCRC)
-/* If USE_EXTERNAL_CRC is defined, an external module will export the
- * mz_crc32() symbol for us to use, e.g. an SSE-accelerated version.
- * Depending on the impl, it may be necessary to ~ the input/output crc values.
- */
-mz_ulong mz_crc32(mz_ulong crc, const mz_uint8 *ptr, size_t buf_len);
-#else
-/* Faster, but larger CPU cache footprint.
- */
-mz_ulong mz_crc32(mz_ulong crc, const mz_uint8 *ptr, size_t buf_len) {
- static const mz_uint32 s_crc_table[256] = {
- 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F,
- 0xE963A535, 0x9E6495A3, 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988,
- 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, 0x1DB71064, 0x6AB020F2,
- 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7,
- 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9,
- 0xFA0F3D63, 0x8D080DF5, 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172,
- 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, 0x35B5A8FA, 0x42B2986C,
- 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59,
- 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423,
- 0xCFBA9599, 0xB8BDA50F, 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924,
- 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, 0x76DC4190, 0x01DB7106,
- 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433,
- 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D,
- 0x91646C97, 0xE6635C01, 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E,
- 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, 0x65B0D9C6, 0x12B7E950,
- 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65,
- 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7,
- 0xA4D1C46D, 0xD3D6F4FB, 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0,
- 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, 0x5005713C, 0x270241AA,
- 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F,
- 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81,
- 0xB7BD5C3B, 0xC0BA6CAD, 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A,
- 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, 0xE3630B12, 0x94643B84,
- 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1,
- 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB,
- 0x196C3671, 0x6E6B06E7, 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC,
- 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, 0xD6D6A3E8, 0xA1D1937E,
- 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B,
- 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55,
- 0x316E8EEF, 0x4669BE79, 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236,
- 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, 0xC5BA3BBE, 0xB2BD0B28,
- 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D,
- 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F,
- 0x72076785, 0x05005713, 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38,
- 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, 0x86D3D2D4, 0xF1D4E242,
- 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777,
- 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69,
- 0x616BFFD3, 0x166CCF45, 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2,
- 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, 0xAED16A4A, 0xD9D65ADC,
- 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9,
- 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693,
- 0x54DE5729, 0x23D967BF, 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94,
- 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D};
-
- mz_uint32 crc32 = (mz_uint32)crc ^ 0xFFFFFFFF;
- const mz_uint8 *pByte_buf = (const mz_uint8 *)ptr;
-
- while (buf_len >= 4) {
- crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[0]) & 0xFF];
- crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[1]) & 0xFF];
- crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[2]) & 0xFF];
- crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[3]) & 0xFF];
- pByte_buf += 4;
- buf_len -= 4;
- }
-
- while (buf_len) {
- crc32 = (crc32 >> 8) ^ s_crc_table[(crc32 ^ pByte_buf[0]) & 0xFF];
- ++pByte_buf;
- --buf_len;
- }
-
- return ~crc32;
-}
-#endif
-
-void mz_free(void *p) { MZ_FREE(p); }
-
-MINIZ_EXPORT void *miniz_def_alloc_func(void *opaque, size_t items,
- size_t size) {
- (void)opaque, (void)items, (void)size;
- return MZ_MALLOC(items * size);
-}
-MINIZ_EXPORT void miniz_def_free_func(void *opaque, void *address) {
- (void)opaque, (void)address;
- MZ_FREE(address);
-}
-MINIZ_EXPORT void *miniz_def_realloc_func(void *opaque, void *address,
- size_t items, size_t size) {
- (void)opaque, (void)address, (void)items, (void)size;
- return MZ_REALLOC(address, items * size);
-}
-
-const char *mz_version(void) { return MZ_VERSION; }
-
-#ifndef MINIZ_NO_ZLIB_APIS
-
-int mz_deflateInit(mz_streamp pStream, int level) {
- return mz_deflateInit2(pStream, level, MZ_DEFLATED, MZ_DEFAULT_WINDOW_BITS, 9,
- MZ_DEFAULT_STRATEGY);
-}
-
-int mz_deflateInit2(mz_streamp pStream, int level, int method, int window_bits,
- int mem_level, int strategy) {
- tdefl_compressor *pComp;
- mz_uint comp_flags =
- TDEFL_COMPUTE_ADLER32 |
- tdefl_create_comp_flags_from_zip_params(level, window_bits, strategy);
-
- if (!pStream)
- return MZ_STREAM_ERROR;
- if ((method != MZ_DEFLATED) || ((mem_level < 1) || (mem_level > 9)) ||
- ((window_bits != MZ_DEFAULT_WINDOW_BITS) &&
- (-window_bits != MZ_DEFAULT_WINDOW_BITS)))
- return MZ_PARAM_ERROR;
-
- pStream->data_type = 0;
- pStream->adler = MZ_ADLER32_INIT;
- pStream->msg = NULL;
- pStream->reserved = 0;
- pStream->total_in = 0;
- pStream->total_out = 0;
- if (!pStream->zalloc)
- pStream->zalloc = miniz_def_alloc_func;
- if (!pStream->zfree)
- pStream->zfree = miniz_def_free_func;
-
- pComp = (tdefl_compressor *)pStream->zalloc(pStream->opaque, 1,
- sizeof(tdefl_compressor));
- if (!pComp)
- return MZ_MEM_ERROR;
-
- pStream->state = (struct mz_internal_state *)pComp;
-
- if (tdefl_init(pComp, NULL, NULL, comp_flags) != TDEFL_STATUS_OKAY) {
- mz_deflateEnd(pStream);
- return MZ_PARAM_ERROR;
- }
-
- return MZ_OK;
-}
-
-int mz_deflateReset(mz_streamp pStream) {
- if ((!pStream) || (!pStream->state) || (!pStream->zalloc) ||
- (!pStream->zfree))
- return MZ_STREAM_ERROR;
- pStream->total_in = pStream->total_out = 0;
- tdefl_init((tdefl_compressor *)pStream->state, NULL, NULL,
- ((tdefl_compressor *)pStream->state)->m_flags);
- return MZ_OK;
-}
-
-int mz_deflate(mz_streamp pStream, int flush) {
- size_t in_bytes, out_bytes;
- mz_ulong orig_total_in, orig_total_out;
- int mz_status = MZ_OK;
-
- if ((!pStream) || (!pStream->state) || (flush < 0) || (flush > MZ_FINISH) ||
- (!pStream->next_out))
- return MZ_STREAM_ERROR;
- if (!pStream->avail_out)
- return MZ_BUF_ERROR;
-
- if (flush == MZ_PARTIAL_FLUSH)
- flush = MZ_SYNC_FLUSH;
-
- if (((tdefl_compressor *)pStream->state)->m_prev_return_status ==
- TDEFL_STATUS_DONE)
- return (flush == MZ_FINISH) ? MZ_STREAM_END : MZ_BUF_ERROR;
-
- orig_total_in = pStream->total_in;
- orig_total_out = pStream->total_out;
- for (;;) {
- tdefl_status defl_status;
- in_bytes = pStream->avail_in;
- out_bytes = pStream->avail_out;
-
- defl_status = tdefl_compress((tdefl_compressor *)pStream->state,
- pStream->next_in, &in_bytes, pStream->next_out,
- &out_bytes, (tdefl_flush)flush);
- pStream->next_in += (mz_uint)in_bytes;
- pStream->avail_in -= (mz_uint)in_bytes;
- pStream->total_in += (mz_uint)in_bytes;
- pStream->adler = tdefl_get_adler32((tdefl_compressor *)pStream->state);
-
- pStream->next_out += (mz_uint)out_bytes;
- pStream->avail_out -= (mz_uint)out_bytes;
- pStream->total_out += (mz_uint)out_bytes;
-
- if (defl_status < 0) {
- mz_status = MZ_STREAM_ERROR;
- break;
- } else if (defl_status == TDEFL_STATUS_DONE) {
- mz_status = MZ_STREAM_END;
- break;
- } else if (!pStream->avail_out)
- break;
- else if ((!pStream->avail_in) && (flush != MZ_FINISH)) {
- if ((flush) || (pStream->total_in != orig_total_in) ||
- (pStream->total_out != orig_total_out))
- break;
- return MZ_BUF_ERROR; /* Can't make forward progress without some input.
- */
- }
- }
- return mz_status;
-}
-
-int mz_deflateEnd(mz_streamp pStream) {
- if (!pStream)
- return MZ_STREAM_ERROR;
- if (pStream->state) {
- pStream->zfree(pStream->opaque, pStream->state);
- pStream->state = NULL;
- }
- return MZ_OK;
-}
-
-mz_ulong mz_deflateBound(mz_streamp pStream, mz_ulong source_len) {
- (void)pStream;
- /* This is really over conservative. (And lame, but it's actually pretty
- * tricky to compute a true upper bound given the way tdefl's blocking works.)
- */
- return MZ_MAX(128 + (source_len * 110) / 100,
- 128 + source_len + ((source_len / (31 * 1024)) + 1) * 5);
-}
-
-int mz_compress2(unsigned char *pDest, mz_ulong *pDest_len,
- const unsigned char *pSource, mz_ulong source_len, int level) {
- int status;
- mz_stream stream;
- memset(&stream, 0, sizeof(stream));
-
- /* In case mz_ulong is 64-bits (argh I hate longs). */
- if ((source_len | *pDest_len) > 0xFFFFFFFFU)
- return MZ_PARAM_ERROR;
-
- stream.next_in = pSource;
- stream.avail_in = (mz_uint32)source_len;
- stream.next_out = pDest;
- stream.avail_out = (mz_uint32)*pDest_len;
-
- status = mz_deflateInit(&stream, level);
- if (status != MZ_OK)
- return status;
-
- status = mz_deflate(&stream, MZ_FINISH);
- if (status != MZ_STREAM_END) {
- mz_deflateEnd(&stream);
- return (status == MZ_OK) ? MZ_BUF_ERROR : status;
- }
-
- *pDest_len = stream.total_out;
- return mz_deflateEnd(&stream);
-}
-
-int mz_compress(unsigned char *pDest, mz_ulong *pDest_len,
- const unsigned char *pSource, mz_ulong source_len) {
- return mz_compress2(pDest, pDest_len, pSource, source_len,
- MZ_DEFAULT_COMPRESSION);
-}
-
-mz_ulong mz_compressBound(mz_ulong source_len) {
- return mz_deflateBound(NULL, source_len);
-}
-
-typedef struct {
- tinfl_decompressor m_decomp;
- mz_uint m_dict_ofs, m_dict_avail, m_first_call, m_has_flushed;
- int m_window_bits;
- mz_uint8 m_dict[TINFL_LZ_DICT_SIZE];
- tinfl_status m_last_status;
-} inflate_state;
-
-int mz_inflateInit2(mz_streamp pStream, int window_bits) {
- inflate_state *pDecomp;
- if (!pStream)
- return MZ_STREAM_ERROR;
- if ((window_bits != MZ_DEFAULT_WINDOW_BITS) &&
- (-window_bits != MZ_DEFAULT_WINDOW_BITS))
- return MZ_PARAM_ERROR;
-
- pStream->data_type = 0;
- pStream->adler = 0;
- pStream->msg = NULL;
- pStream->total_in = 0;
- pStream->total_out = 0;
- pStream->reserved = 0;
- if (!pStream->zalloc)
- pStream->zalloc = miniz_def_alloc_func;
- if (!pStream->zfree)
- pStream->zfree = miniz_def_free_func;
-
- pDecomp = (inflate_state *)pStream->zalloc(pStream->opaque, 1,
- sizeof(inflate_state));
- if (!pDecomp)
- return MZ_MEM_ERROR;
-
- pStream->state = (struct mz_internal_state *)pDecomp;
-
- tinfl_init(&pDecomp->m_decomp);
- pDecomp->m_dict_ofs = 0;
- pDecomp->m_dict_avail = 0;
- pDecomp->m_last_status = TINFL_STATUS_NEEDS_MORE_INPUT;
- pDecomp->m_first_call = 1;
- pDecomp->m_has_flushed = 0;
- pDecomp->m_window_bits = window_bits;
-
- return MZ_OK;
-}
-
-int mz_inflateInit(mz_streamp pStream) {
- return mz_inflateInit2(pStream, MZ_DEFAULT_WINDOW_BITS);
-}
-
-int mz_inflateReset(mz_streamp pStream) {
- inflate_state *pDecomp;
- if (!pStream)
- return MZ_STREAM_ERROR;
-
- pStream->data_type = 0;
- pStream->adler = 0;
- pStream->msg = NULL;
- pStream->total_in = 0;
- pStream->total_out = 0;
- pStream->reserved = 0;
-
- pDecomp = (inflate_state *)pStream->state;
-
- tinfl_init(&pDecomp->m_decomp);
- pDecomp->m_dict_ofs = 0;
- pDecomp->m_dict_avail = 0;
- pDecomp->m_last_status = TINFL_STATUS_NEEDS_MORE_INPUT;
- pDecomp->m_first_call = 1;
- pDecomp->m_has_flushed = 0;
- /* pDecomp->m_window_bits = window_bits */;
-
- return MZ_OK;
-}
-
-int mz_inflate(mz_streamp pStream, int flush) {
- inflate_state *pState;
- mz_uint n, first_call, decomp_flags = TINFL_FLAG_COMPUTE_ADLER32;
- size_t in_bytes, out_bytes, orig_avail_in;
- tinfl_status status;
-
- if ((!pStream) || (!pStream->state))
- return MZ_STREAM_ERROR;
- if (flush == MZ_PARTIAL_FLUSH)
- flush = MZ_SYNC_FLUSH;
- if ((flush) && (flush != MZ_SYNC_FLUSH) && (flush != MZ_FINISH))
- return MZ_STREAM_ERROR;
-
- pState = (inflate_state *)pStream->state;
- if (pState->m_window_bits > 0)
- decomp_flags |= TINFL_FLAG_PARSE_ZLIB_HEADER;
- orig_avail_in = pStream->avail_in;
-
- first_call = pState->m_first_call;
- pState->m_first_call = 0;
- if (pState->m_last_status < 0)
- return MZ_DATA_ERROR;
-
- if (pState->m_has_flushed && (flush != MZ_FINISH))
- return MZ_STREAM_ERROR;
- pState->m_has_flushed |= (flush == MZ_FINISH);
-
- if ((flush == MZ_FINISH) && (first_call)) {
- /* MZ_FINISH on the first call implies that the input and output buffers are
- * large enough to hold the entire compressed/decompressed file. */
- decomp_flags |= TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF;
- in_bytes = pStream->avail_in;
- out_bytes = pStream->avail_out;
- status = tinfl_decompress(&pState->m_decomp, pStream->next_in, &in_bytes,
- pStream->next_out, pStream->next_out, &out_bytes,
- decomp_flags);
- pState->m_last_status = status;
- pStream->next_in += (mz_uint)in_bytes;
- pStream->avail_in -= (mz_uint)in_bytes;
- pStream->total_in += (mz_uint)in_bytes;
- pStream->adler = tinfl_get_adler32(&pState->m_decomp);
- pStream->next_out += (mz_uint)out_bytes;
- pStream->avail_out -= (mz_uint)out_bytes;
- pStream->total_out += (mz_uint)out_bytes;
-
- if (status < 0)
- return MZ_DATA_ERROR;
- else if (status != TINFL_STATUS_DONE) {
- pState->m_last_status = TINFL_STATUS_FAILED;
- return MZ_BUF_ERROR;
- }
- return MZ_STREAM_END;
- }
- /* flush != MZ_FINISH then we must assume there's more input. */
- if (flush != MZ_FINISH)
- decomp_flags |= TINFL_FLAG_HAS_MORE_INPUT;
-
- if (pState->m_dict_avail) {
- n = MZ_MIN(pState->m_dict_avail, pStream->avail_out);
- memcpy(pStream->next_out, pState->m_dict + pState->m_dict_ofs, n);
- pStream->next_out += n;
- pStream->avail_out -= n;
- pStream->total_out += n;
- pState->m_dict_avail -= n;
- pState->m_dict_ofs = (pState->m_dict_ofs + n) & (TINFL_LZ_DICT_SIZE - 1);
- return ((pState->m_last_status == TINFL_STATUS_DONE) &&
- (!pState->m_dict_avail))
- ? MZ_STREAM_END
- : MZ_OK;
- }
-
- for (;;) {
- in_bytes = pStream->avail_in;
- out_bytes = TINFL_LZ_DICT_SIZE - pState->m_dict_ofs;
-
- status = tinfl_decompress(
- &pState->m_decomp, pStream->next_in, &in_bytes, pState->m_dict,
- pState->m_dict + pState->m_dict_ofs, &out_bytes, decomp_flags);
- pState->m_last_status = status;
-
- pStream->next_in += (mz_uint)in_bytes;
- pStream->avail_in -= (mz_uint)in_bytes;
- pStream->total_in += (mz_uint)in_bytes;
- pStream->adler = tinfl_get_adler32(&pState->m_decomp);
-
- pState->m_dict_avail = (mz_uint)out_bytes;
-
- n = MZ_MIN(pState->m_dict_avail, pStream->avail_out);
- memcpy(pStream->next_out, pState->m_dict + pState->m_dict_ofs, n);
- pStream->next_out += n;
- pStream->avail_out -= n;
- pStream->total_out += n;
- pState->m_dict_avail -= n;
- pState->m_dict_ofs = (pState->m_dict_ofs + n) & (TINFL_LZ_DICT_SIZE - 1);
-
- if (status < 0)
- return MZ_DATA_ERROR; /* Stream is corrupted (there could be some
- uncompressed data left in the output dictionary -
- oh well). */
- else if ((status == TINFL_STATUS_NEEDS_MORE_INPUT) && (!orig_avail_in))
- return MZ_BUF_ERROR; /* Signal caller that we can't make forward progress
- without supplying more input or by setting flush
- to MZ_FINISH. */
- else if (flush == MZ_FINISH) {
- /* The output buffer MUST be large to hold the remaining uncompressed data
- * when flush==MZ_FINISH. */
- if (status == TINFL_STATUS_DONE)
- return pState->m_dict_avail ? MZ_BUF_ERROR : MZ_STREAM_END;
- /* status here must be TINFL_STATUS_HAS_MORE_OUTPUT, which means there's
- * at least 1 more byte on the way. If there's no more room left in the
- * output buffer then something is wrong. */
- else if (!pStream->avail_out)
- return MZ_BUF_ERROR;
- } else if ((status == TINFL_STATUS_DONE) || (!pStream->avail_in) ||
- (!pStream->avail_out) || (pState->m_dict_avail))
- break;
- }
-
- return ((status == TINFL_STATUS_DONE) && (!pState->m_dict_avail))
- ? MZ_STREAM_END
- : MZ_OK;
-}
-
-int mz_inflateEnd(mz_streamp pStream) {
- if (!pStream)
- return MZ_STREAM_ERROR;
- if (pStream->state) {
- pStream->zfree(pStream->opaque, pStream->state);
- pStream->state = NULL;
- }
- return MZ_OK;
-}
-int mz_uncompress2(unsigned char *pDest, mz_ulong *pDest_len,
- const unsigned char *pSource, mz_ulong *pSource_len) {
- mz_stream stream;
- int status;
- memset(&stream, 0, sizeof(stream));
-
- /* In case mz_ulong is 64-bits (argh I hate longs). */
- if ((*pSource_len | *pDest_len) > 0xFFFFFFFFU)
- return MZ_PARAM_ERROR;
-
- stream.next_in = pSource;
- stream.avail_in = (mz_uint32)*pSource_len;
- stream.next_out = pDest;
- stream.avail_out = (mz_uint32)*pDest_len;
-
- status = mz_inflateInit(&stream);
- if (status != MZ_OK)
- return status;
-
- status = mz_inflate(&stream, MZ_FINISH);
- *pSource_len = *pSource_len - stream.avail_in;
- if (status != MZ_STREAM_END) {
- mz_inflateEnd(&stream);
- return ((status == MZ_BUF_ERROR) && (!stream.avail_in)) ? MZ_DATA_ERROR
- : status;
- }
- *pDest_len = stream.total_out;
-
- return mz_inflateEnd(&stream);
-}
-
-int mz_uncompress(unsigned char *pDest, mz_ulong *pDest_len,
- const unsigned char *pSource, mz_ulong source_len) {
- return mz_uncompress2(pDest, pDest_len, pSource, &source_len);
-}
-
-const char *mz_error(int err) {
- static struct {
- int m_err;
- const char *m_pDesc;
- } s_error_descs[] = {{MZ_OK, ""},
- {MZ_STREAM_END, "stream end"},
- {MZ_NEED_DICT, "need dictionary"},
- {MZ_ERRNO, "file error"},
- {MZ_STREAM_ERROR, "stream error"},
- {MZ_DATA_ERROR, "data error"},
- {MZ_MEM_ERROR, "out of memory"},
- {MZ_BUF_ERROR, "buf error"},
- {MZ_VERSION_ERROR, "version error"},
- {MZ_PARAM_ERROR, "parameter error"}};
- mz_uint i;
- for (i = 0; i < sizeof(s_error_descs) / sizeof(s_error_descs[0]); ++i)
- if (s_error_descs[i].m_err == err)
- return s_error_descs[i].m_pDesc;
- return NULL;
-}
-
-#endif /*MINIZ_NO_ZLIB_APIS */
-
-#ifdef __cplusplus
-}
-#endif
-
-/*
- This is free and unencumbered software released into the public domain.
-
- Anyone is free to copy, modify, publish, use, compile, sell, or
- distribute this software, either in source code form or as a compiled
- binary, for any purpose, commercial or non-commercial, and by any
- means.
-
- In jurisdictions that recognize copyright laws, the author or authors
- of this software dedicate any and all copyright interest in the
- software to the public domain. We make this dedication for the benefit
- of the public at large and to the detriment of our heirs and
- successors. We intend this dedication to be an overt act of
- relinquishment in perpetuity of all present and future rights to this
- software under copyright law.
-
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
- IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
- OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
- ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
- OTHER DEALINGS IN THE SOFTWARE.
-
- For more information, please refer to
-*/
-/**************************************************************************
- *
- * Copyright 2013-2014 RAD Game Tools and Valve Software
- * Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC
- * All Rights Reserved.
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- **************************************************************************/
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/* ------------------- Low-level Compression (independent from all decompression
- * API's) */
-
-/* Purposely making these tables static for faster init and thread safety. */
-static const mz_uint16 s_tdefl_len_sym[256] = {
- 257, 258, 259, 260, 261, 262, 263, 264, 265, 265, 266, 266, 267, 267, 268,
- 268, 269, 269, 269, 269, 270, 270, 270, 270, 271, 271, 271, 271, 272, 272,
- 272, 272, 273, 273, 273, 273, 273, 273, 273, 273, 274, 274, 274, 274, 274,
- 274, 274, 274, 275, 275, 275, 275, 275, 275, 275, 275, 276, 276, 276, 276,
- 276, 276, 276, 276, 277, 277, 277, 277, 277, 277, 277, 277, 277, 277, 277,
- 277, 277, 277, 277, 277, 278, 278, 278, 278, 278, 278, 278, 278, 278, 278,
- 278, 278, 278, 278, 278, 278, 279, 279, 279, 279, 279, 279, 279, 279, 279,
- 279, 279, 279, 279, 279, 279, 279, 280, 280, 280, 280, 280, 280, 280, 280,
- 280, 280, 280, 280, 280, 280, 280, 280, 281, 281, 281, 281, 281, 281, 281,
- 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 281,
- 281, 281, 281, 281, 281, 281, 281, 281, 281, 281, 282, 282, 282, 282, 282,
- 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282,
- 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 282, 283, 283, 283,
- 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283,
- 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 283, 284,
- 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284,
- 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, 284,
- 285};
-
-static const mz_uint8 s_tdefl_len_extra[256] = {
- 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2,
- 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
- 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4,
- 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
- 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
- 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
- 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
- 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
- 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
- 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
- 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0};
-
-static const mz_uint8 s_tdefl_small_dist_sym[512] = {
- 0, 1, 2, 3, 4, 4, 5, 5, 6, 6, 6, 6, 7, 7, 7, 7, 8, 8, 8,
- 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 10, 10,
- 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 11, 11, 11,
- 11, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
- 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
- 12, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
- 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 14, 14, 14, 14, 14,
- 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14,
- 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14,
- 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14,
- 14, 14, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
- 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
- 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
- 15, 15, 15, 15, 15, 15, 15, 15, 15, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
- 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
- 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
- 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
- 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
- 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
- 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
- 16, 16, 16, 16, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
- 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
- 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
- 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
- 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
- 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
- 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17};
-
-static const mz_uint8 s_tdefl_small_dist_extra[512] = {
- 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3,
- 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
- 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
- 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
- 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
- 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
- 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
- 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
- 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
- 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
- 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
- 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7};
-
-static const mz_uint8 s_tdefl_large_dist_sym[128] = {
- 0, 0, 18, 19, 20, 20, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24,
- 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26,
- 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27,
- 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,
- 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,
- 28, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29,
- 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29};
-
-static const mz_uint8 s_tdefl_large_dist_extra[128] = {
- 0, 0, 8, 8, 9, 9, 9, 9, 10, 10, 10, 10, 10, 10, 10, 10, 11, 11, 11,
- 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 12, 12,
- 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
- 12, 12, 12, 12, 12, 12, 12, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
- 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
- 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
- 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13};
-
-/* Radix sorts tdefl_sym_freq[] array by 16-bit key m_key. Returns ptr to sorted
- * values. */
-typedef struct {
- mz_uint16 m_key, m_sym_index;
-} tdefl_sym_freq;
-static tdefl_sym_freq *tdefl_radix_sort_syms(mz_uint num_syms,
- tdefl_sym_freq *pSyms0,
- tdefl_sym_freq *pSyms1) {
- mz_uint32 total_passes = 2, pass_shift, pass, i, hist[256 * 2];
- tdefl_sym_freq *pCur_syms = pSyms0, *pNew_syms = pSyms1;
- MZ_CLEAR_OBJ(hist);
- for (i = 0; i < num_syms; i++) {
- mz_uint freq = pSyms0[i].m_key;
- hist[freq & 0xFF]++;
- hist[256 + ((freq >> 8) & 0xFF)]++;
- }
- while ((total_passes > 1) && (num_syms == hist[(total_passes - 1) * 256]))
- total_passes--;
- for (pass_shift = 0, pass = 0; pass < total_passes; pass++, pass_shift += 8) {
- const mz_uint32 *pHist = &hist[pass << 8];
- mz_uint offsets[256], cur_ofs = 0;
- for (i = 0; i < 256; i++) {
- offsets[i] = cur_ofs;
- cur_ofs += pHist[i];
- }
- for (i = 0; i < num_syms; i++)
- pNew_syms[offsets[(pCur_syms[i].m_key >> pass_shift) & 0xFF]++] =
- pCur_syms[i];
- {
- tdefl_sym_freq *t = pCur_syms;
- pCur_syms = pNew_syms;
- pNew_syms = t;
- }
- }
- return pCur_syms;
-}
-
-/* tdefl_calculate_minimum_redundancy() originally written by: Alistair Moffat,
- * alistair@cs.mu.oz.au, Jyrki Katajainen, jyrki@diku.dk, November 1996. */
-static void tdefl_calculate_minimum_redundancy(tdefl_sym_freq *A, int n) {
- int root, leaf, next, avbl, used, dpth;
- if (n == 0)
- return;
- else if (n == 1) {
- A[0].m_key = 1;
- return;
- }
- A[0].m_key += A[1].m_key;
- root = 0;
- leaf = 2;
- for (next = 1; next < n - 1; next++) {
- if (leaf >= n || A[root].m_key < A[leaf].m_key) {
- A[next].m_key = A[root].m_key;
- A[root++].m_key = (mz_uint16)next;
- } else
- A[next].m_key = A[leaf++].m_key;
- if (leaf >= n || (root < next && A[root].m_key < A[leaf].m_key)) {
- A[next].m_key = (mz_uint16)(A[next].m_key + A[root].m_key);
- A[root++].m_key = (mz_uint16)next;
- } else
- A[next].m_key = (mz_uint16)(A[next].m_key + A[leaf++].m_key);
- }
- A[n - 2].m_key = 0;
- for (next = n - 3; next >= 0; next--)
- A[next].m_key = A[A[next].m_key].m_key + 1;
- avbl = 1;
- used = dpth = 0;
- root = n - 2;
- next = n - 1;
- while (avbl > 0) {
- while (root >= 0 && (int)A[root].m_key == dpth) {
- used++;
- root--;
- }
- while (avbl > used) {
- A[next--].m_key = (mz_uint16)(dpth);
- avbl--;
- }
- avbl = 2 * used;
- dpth++;
- used = 0;
- }
-}
-
-/* Limits canonical Huffman code table's max code size. */
-enum { TDEFL_MAX_SUPPORTED_HUFF_CODESIZE = 32 };
-static void tdefl_huffman_enforce_max_code_size(int *pNum_codes,
- int code_list_len,
- int max_code_size) {
- int i;
- mz_uint32 total = 0;
- if (code_list_len <= 1)
- return;
- for (i = max_code_size + 1; i <= TDEFL_MAX_SUPPORTED_HUFF_CODESIZE; i++)
- pNum_codes[max_code_size] += pNum_codes[i];
- for (i = max_code_size; i > 0; i--)
- total += (((mz_uint32)pNum_codes[i]) << (max_code_size - i));
- while (total != (1UL << max_code_size)) {
- pNum_codes[max_code_size]--;
- for (i = max_code_size - 1; i > 0; i--)
- if (pNum_codes[i]) {
- pNum_codes[i]--;
- pNum_codes[i + 1] += 2;
- break;
- }
- total--;
- }
-}
-
-static void tdefl_optimize_huffman_table(tdefl_compressor *d, int table_num,
- int table_len, int code_size_limit,
- int static_table) {
- int i, j, l, num_codes[1 + TDEFL_MAX_SUPPORTED_HUFF_CODESIZE];
- mz_uint next_code[TDEFL_MAX_SUPPORTED_HUFF_CODESIZE + 1];
- MZ_CLEAR_OBJ(num_codes);
- if (static_table) {
- for (i = 0; i < table_len; i++)
- num_codes[d->m_huff_code_sizes[table_num][i]]++;
- } else {
- tdefl_sym_freq syms0[TDEFL_MAX_HUFF_SYMBOLS], syms1[TDEFL_MAX_HUFF_SYMBOLS],
- *pSyms;
- int num_used_syms = 0;
- const mz_uint16 *pSym_count = &d->m_huff_count[table_num][0];
- for (i = 0; i < table_len; i++)
- if (pSym_count[i]) {
- syms0[num_used_syms].m_key = (mz_uint16)pSym_count[i];
- syms0[num_used_syms++].m_sym_index = (mz_uint16)i;
- }
-
- pSyms = tdefl_radix_sort_syms(num_used_syms, syms0, syms1);
- tdefl_calculate_minimum_redundancy(pSyms, num_used_syms);
-
- for (i = 0; i < num_used_syms; i++)
- num_codes[pSyms[i].m_key]++;
-
- tdefl_huffman_enforce_max_code_size(num_codes, num_used_syms,
- code_size_limit);
-
- MZ_CLEAR_OBJ(d->m_huff_code_sizes[table_num]);
- MZ_CLEAR_OBJ(d->m_huff_codes[table_num]);
- for (i = 1, j = num_used_syms; i <= code_size_limit; i++)
- for (l = num_codes[i]; l > 0; l--)
- d->m_huff_code_sizes[table_num][pSyms[--j].m_sym_index] = (mz_uint8)(i);
- }
-
- next_code[1] = 0;
- for (j = 0, i = 2; i <= code_size_limit; i++)
- next_code[i] = j = ((j + num_codes[i - 1]) << 1);
-
- for (i = 0; i < table_len; i++) {
- mz_uint rev_code = 0, code, code_size;
- if ((code_size = d->m_huff_code_sizes[table_num][i]) == 0)
- continue;
- code = next_code[code_size]++;
- for (l = code_size; l > 0; l--, code >>= 1)
- rev_code = (rev_code << 1) | (code & 1);
- d->m_huff_codes[table_num][i] = (mz_uint16)rev_code;
- }
-}
-
-#define TDEFL_PUT_BITS(b, l) \
- do { \
- mz_uint bits = b; \
- mz_uint len = l; \
- MZ_ASSERT(bits <= ((1U << len) - 1U)); \
- d->m_bit_buffer |= (bits << d->m_bits_in); \
- d->m_bits_in += len; \
- while (d->m_bits_in >= 8) { \
- if (d->m_pOutput_buf < d->m_pOutput_buf_end) \
- *d->m_pOutput_buf++ = (mz_uint8)(d->m_bit_buffer); \
- d->m_bit_buffer >>= 8; \
- d->m_bits_in -= 8; \
- } \
- } \
- MZ_MACRO_END
-
-#define TDEFL_RLE_PREV_CODE_SIZE() \
- { \
- if (rle_repeat_count) { \
- if (rle_repeat_count < 3) { \
- d->m_huff_count[2][prev_code_size] = \
- (mz_uint16)(d->m_huff_count[2][prev_code_size] + \
- rle_repeat_count); \
- while (rle_repeat_count--) \
- packed_code_sizes[num_packed_code_sizes++] = prev_code_size; \
- } else { \
- d->m_huff_count[2][16] = (mz_uint16)(d->m_huff_count[2][16] + 1); \
- packed_code_sizes[num_packed_code_sizes++] = 16; \
- packed_code_sizes[num_packed_code_sizes++] = \
- (mz_uint8)(rle_repeat_count - 3); \
- } \
- rle_repeat_count = 0; \
- } \
- }
-
-#define TDEFL_RLE_ZERO_CODE_SIZE() \
- { \
- if (rle_z_count) { \
- if (rle_z_count < 3) { \
- d->m_huff_count[2][0] = \
- (mz_uint16)(d->m_huff_count[2][0] + rle_z_count); \
- while (rle_z_count--) \
- packed_code_sizes[num_packed_code_sizes++] = 0; \
- } else if (rle_z_count <= 10) { \
- d->m_huff_count[2][17] = (mz_uint16)(d->m_huff_count[2][17] + 1); \
- packed_code_sizes[num_packed_code_sizes++] = 17; \
- packed_code_sizes[num_packed_code_sizes++] = \
- (mz_uint8)(rle_z_count - 3); \
- } else { \
- d->m_huff_count[2][18] = (mz_uint16)(d->m_huff_count[2][18] + 1); \
- packed_code_sizes[num_packed_code_sizes++] = 18; \
- packed_code_sizes[num_packed_code_sizes++] = \
- (mz_uint8)(rle_z_count - 11); \
- } \
- rle_z_count = 0; \
- } \
- }
-
-static mz_uint8 s_tdefl_packed_code_size_syms_swizzle[] = {
- 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
-
-static void tdefl_start_dynamic_block(tdefl_compressor *d) {
- int num_lit_codes, num_dist_codes, num_bit_lengths;
- mz_uint i, total_code_sizes_to_pack, num_packed_code_sizes, rle_z_count,
- rle_repeat_count, packed_code_sizes_index;
- mz_uint8
- code_sizes_to_pack[TDEFL_MAX_HUFF_SYMBOLS_0 + TDEFL_MAX_HUFF_SYMBOLS_1],
- packed_code_sizes[TDEFL_MAX_HUFF_SYMBOLS_0 + TDEFL_MAX_HUFF_SYMBOLS_1],
- prev_code_size = 0xFF;
-
- d->m_huff_count[0][256] = 1;
-
- tdefl_optimize_huffman_table(d, 0, TDEFL_MAX_HUFF_SYMBOLS_0, 15, MZ_FALSE);
- tdefl_optimize_huffman_table(d, 1, TDEFL_MAX_HUFF_SYMBOLS_1, 15, MZ_FALSE);
-
- for (num_lit_codes = 286; num_lit_codes > 257; num_lit_codes--)
- if (d->m_huff_code_sizes[0][num_lit_codes - 1])
- break;
- for (num_dist_codes = 30; num_dist_codes > 1; num_dist_codes--)
- if (d->m_huff_code_sizes[1][num_dist_codes - 1])
- break;
-
- memcpy(code_sizes_to_pack, &d->m_huff_code_sizes[0][0], num_lit_codes);
- memcpy(code_sizes_to_pack + num_lit_codes, &d->m_huff_code_sizes[1][0],
- num_dist_codes);
- total_code_sizes_to_pack = num_lit_codes + num_dist_codes;
- num_packed_code_sizes = 0;
- rle_z_count = 0;
- rle_repeat_count = 0;
-
- memset(&d->m_huff_count[2][0], 0,
- sizeof(d->m_huff_count[2][0]) * TDEFL_MAX_HUFF_SYMBOLS_2);
- for (i = 0; i < total_code_sizes_to_pack; i++) {
- mz_uint8 code_size = code_sizes_to_pack[i];
- if (!code_size) {
- TDEFL_RLE_PREV_CODE_SIZE();
- if (++rle_z_count == 138) {
- TDEFL_RLE_ZERO_CODE_SIZE();
- }
- } else {
- TDEFL_RLE_ZERO_CODE_SIZE();
- if (code_size != prev_code_size) {
- TDEFL_RLE_PREV_CODE_SIZE();
- d->m_huff_count[2][code_size] =
- (mz_uint16)(d->m_huff_count[2][code_size] + 1);
- packed_code_sizes[num_packed_code_sizes++] = code_size;
- } else if (++rle_repeat_count == 6) {
- TDEFL_RLE_PREV_CODE_SIZE();
- }
- }
- prev_code_size = code_size;
- }
- if (rle_repeat_count) {
- TDEFL_RLE_PREV_CODE_SIZE();
- } else {
- TDEFL_RLE_ZERO_CODE_SIZE();
- }
-
- tdefl_optimize_huffman_table(d, 2, TDEFL_MAX_HUFF_SYMBOLS_2, 7, MZ_FALSE);
-
- TDEFL_PUT_BITS(2, 2);
-
- TDEFL_PUT_BITS(num_lit_codes - 257, 5);
- TDEFL_PUT_BITS(num_dist_codes - 1, 5);
-
- for (num_bit_lengths = 18; num_bit_lengths >= 0; num_bit_lengths--)
- if (d->m_huff_code_sizes
- [2][s_tdefl_packed_code_size_syms_swizzle[num_bit_lengths]])
- break;
- num_bit_lengths = MZ_MAX(4, (num_bit_lengths + 1));
- TDEFL_PUT_BITS(num_bit_lengths - 4, 4);
- for (i = 0; (int)i < num_bit_lengths; i++)
- TDEFL_PUT_BITS(
- d->m_huff_code_sizes[2][s_tdefl_packed_code_size_syms_swizzle[i]], 3);
-
- for (packed_code_sizes_index = 0;
- packed_code_sizes_index < num_packed_code_sizes;) {
- mz_uint code = packed_code_sizes[packed_code_sizes_index++];
- MZ_ASSERT(code < TDEFL_MAX_HUFF_SYMBOLS_2);
- TDEFL_PUT_BITS(d->m_huff_codes[2][code], d->m_huff_code_sizes[2][code]);
- if (code >= 16)
- TDEFL_PUT_BITS(packed_code_sizes[packed_code_sizes_index++],
- "\02\03\07"[code - 16]);
- }
-}
-
-static void tdefl_start_static_block(tdefl_compressor *d) {
- mz_uint i;
- mz_uint8 *p = &d->m_huff_code_sizes[0][0];
-
- for (i = 0; i <= 143; ++i)
- *p++ = 8;
- for (; i <= 255; ++i)
- *p++ = 9;
- for (; i <= 279; ++i)
- *p++ = 7;
- for (; i <= 287; ++i)
- *p++ = 8;
-
- memset(d->m_huff_code_sizes[1], 5, 32);
-
- tdefl_optimize_huffman_table(d, 0, 288, 15, MZ_TRUE);
- tdefl_optimize_huffman_table(d, 1, 32, 15, MZ_TRUE);
-
- TDEFL_PUT_BITS(1, 2);
-}
-
-static const mz_uint mz_bitmasks[17] = {
- 0x0000, 0x0001, 0x0003, 0x0007, 0x000F, 0x001F, 0x003F, 0x007F, 0x00FF,
- 0x01FF, 0x03FF, 0x07FF, 0x0FFF, 0x1FFF, 0x3FFF, 0x7FFF, 0xFFFF};
-
-#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN && \
- MINIZ_HAS_64BIT_REGISTERS
-static mz_bool tdefl_compress_lz_codes(tdefl_compressor *d) {
- mz_uint flags;
- mz_uint8 *pLZ_codes;
- mz_uint8 *pOutput_buf = d->m_pOutput_buf;
- mz_uint8 *pLZ_code_buf_end = d->m_pLZ_code_buf;
- mz_uint64 bit_buffer = d->m_bit_buffer;
- mz_uint bits_in = d->m_bits_in;
-
-#define TDEFL_PUT_BITS_FAST(b, l) \
- { \
- bit_buffer |= (((mz_uint64)(b)) << bits_in); \
- bits_in += (l); \
- }
-
- flags = 1;
- for (pLZ_codes = d->m_lz_code_buf; pLZ_codes < pLZ_code_buf_end;
- flags >>= 1) {
- if (flags == 1)
- flags = *pLZ_codes++ | 0x100;
-
- if (flags & 1) {
- mz_uint s0, s1, n0, n1, sym, num_extra_bits;
- mz_uint match_len = pLZ_codes[0],
- match_dist = *(const mz_uint16 *)(pLZ_codes + 1);
- pLZ_codes += 3;
-
- MZ_ASSERT(d->m_huff_code_sizes[0][s_tdefl_len_sym[match_len]]);
- TDEFL_PUT_BITS_FAST(d->m_huff_codes[0][s_tdefl_len_sym[match_len]],
- d->m_huff_code_sizes[0][s_tdefl_len_sym[match_len]]);
- TDEFL_PUT_BITS_FAST(match_len & mz_bitmasks[s_tdefl_len_extra[match_len]],
- s_tdefl_len_extra[match_len]);
-
- /* This sequence coaxes MSVC into using cmov's vs. jmp's. */
- s0 = s_tdefl_small_dist_sym[match_dist & 511];
- n0 = s_tdefl_small_dist_extra[match_dist & 511];
- s1 = s_tdefl_large_dist_sym[match_dist >> 8];
- n1 = s_tdefl_large_dist_extra[match_dist >> 8];
- sym = (match_dist < 512) ? s0 : s1;
- num_extra_bits = (match_dist < 512) ? n0 : n1;
-
- MZ_ASSERT(d->m_huff_code_sizes[1][sym]);
- TDEFL_PUT_BITS_FAST(d->m_huff_codes[1][sym],
- d->m_huff_code_sizes[1][sym]);
- TDEFL_PUT_BITS_FAST(match_dist & mz_bitmasks[num_extra_bits],
- num_extra_bits);
- } else {
- mz_uint lit = *pLZ_codes++;
- MZ_ASSERT(d->m_huff_code_sizes[0][lit]);
- TDEFL_PUT_BITS_FAST(d->m_huff_codes[0][lit],
- d->m_huff_code_sizes[0][lit]);
-
- if (((flags & 2) == 0) && (pLZ_codes < pLZ_code_buf_end)) {
- flags >>= 1;
- lit = *pLZ_codes++;
- MZ_ASSERT(d->m_huff_code_sizes[0][lit]);
- TDEFL_PUT_BITS_FAST(d->m_huff_codes[0][lit],
- d->m_huff_code_sizes[0][lit]);
-
- if (((flags & 2) == 0) && (pLZ_codes < pLZ_code_buf_end)) {
- flags >>= 1;
- lit = *pLZ_codes++;
- MZ_ASSERT(d->m_huff_code_sizes[0][lit]);
- TDEFL_PUT_BITS_FAST(d->m_huff_codes[0][lit],
- d->m_huff_code_sizes[0][lit]);
- }
- }
- }
-
- if (pOutput_buf >= d->m_pOutput_buf_end)
- return MZ_FALSE;
-
- *(mz_uint64 *)pOutput_buf = bit_buffer;
- pOutput_buf += (bits_in >> 3);
- bit_buffer >>= (bits_in & ~7);
- bits_in &= 7;
- }
-
-#undef TDEFL_PUT_BITS_FAST
-
- d->m_pOutput_buf = pOutput_buf;
- d->m_bits_in = 0;
- d->m_bit_buffer = 0;
-
- while (bits_in) {
- mz_uint32 n = MZ_MIN(bits_in, 16);
- TDEFL_PUT_BITS((mz_uint)bit_buffer & mz_bitmasks[n], n);
- bit_buffer >>= n;
- bits_in -= n;
- }
-
- TDEFL_PUT_BITS(d->m_huff_codes[0][256], d->m_huff_code_sizes[0][256]);
-
- return (d->m_pOutput_buf < d->m_pOutput_buf_end);
-}
-#else
-static mz_bool tdefl_compress_lz_codes(tdefl_compressor *d) {
- mz_uint flags;
- mz_uint8 *pLZ_codes;
-
- flags = 1;
- for (pLZ_codes = d->m_lz_code_buf; pLZ_codes < d->m_pLZ_code_buf;
- flags >>= 1) {
- if (flags == 1)
- flags = *pLZ_codes++ | 0x100;
- if (flags & 1) {
- mz_uint sym, num_extra_bits;
- mz_uint match_len = pLZ_codes[0],
- match_dist = (pLZ_codes[1] | (pLZ_codes[2] << 8));
- pLZ_codes += 3;
-
- MZ_ASSERT(d->m_huff_code_sizes[0][s_tdefl_len_sym[match_len]]);
- TDEFL_PUT_BITS(d->m_huff_codes[0][s_tdefl_len_sym[match_len]],
- d->m_huff_code_sizes[0][s_tdefl_len_sym[match_len]]);
- TDEFL_PUT_BITS(match_len & mz_bitmasks[s_tdefl_len_extra[match_len]],
- s_tdefl_len_extra[match_len]);
-
- if (match_dist < 512) {
- sym = s_tdefl_small_dist_sym[match_dist];
- num_extra_bits = s_tdefl_small_dist_extra[match_dist];
- } else {
- sym = s_tdefl_large_dist_sym[match_dist >> 8];
- num_extra_bits = s_tdefl_large_dist_extra[match_dist >> 8];
- }
- MZ_ASSERT(d->m_huff_code_sizes[1][sym]);
- TDEFL_PUT_BITS(d->m_huff_codes[1][sym], d->m_huff_code_sizes[1][sym]);
- TDEFL_PUT_BITS(match_dist & mz_bitmasks[num_extra_bits], num_extra_bits);
- } else {
- mz_uint lit = *pLZ_codes++;
- MZ_ASSERT(d->m_huff_code_sizes[0][lit]);
- TDEFL_PUT_BITS(d->m_huff_codes[0][lit], d->m_huff_code_sizes[0][lit]);
- }
- }
-
- TDEFL_PUT_BITS(d->m_huff_codes[0][256], d->m_huff_code_sizes[0][256]);
-
- return (d->m_pOutput_buf < d->m_pOutput_buf_end);
-}
-#endif /* MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN && \
- MINIZ_HAS_64BIT_REGISTERS */
-
-static mz_bool tdefl_compress_block(tdefl_compressor *d, mz_bool static_block) {
- if (static_block)
- tdefl_start_static_block(d);
- else
- tdefl_start_dynamic_block(d);
- return tdefl_compress_lz_codes(d);
-}
-
-static int tdefl_flush_block(tdefl_compressor *d, int flush) {
- mz_uint saved_bit_buf, saved_bits_in;
- mz_uint8 *pSaved_output_buf;
- mz_bool comp_block_succeeded = MZ_FALSE;
- int n, use_raw_block =
- ((d->m_flags & TDEFL_FORCE_ALL_RAW_BLOCKS) != 0) &&
- (d->m_lookahead_pos - d->m_lz_code_buf_dict_pos) <= d->m_dict_size;
- mz_uint8 *pOutput_buf_start =
- ((d->m_pPut_buf_func == NULL) &&
- ((*d->m_pOut_buf_size - d->m_out_buf_ofs) >= TDEFL_OUT_BUF_SIZE))
- ? ((mz_uint8 *)d->m_pOut_buf + d->m_out_buf_ofs)
- : d->m_output_buf;
-
- d->m_pOutput_buf = pOutput_buf_start;
- d->m_pOutput_buf_end = d->m_pOutput_buf + TDEFL_OUT_BUF_SIZE - 16;
-
- MZ_ASSERT(!d->m_output_flush_remaining);
- d->m_output_flush_ofs = 0;
- d->m_output_flush_remaining = 0;
-
- *d->m_pLZ_flags = (mz_uint8)(*d->m_pLZ_flags >> d->m_num_flags_left);
- d->m_pLZ_code_buf -= (d->m_num_flags_left == 8);
-
- if ((d->m_flags & TDEFL_WRITE_ZLIB_HEADER) && (!d->m_block_index)) {
- TDEFL_PUT_BITS(0x78, 8);
- TDEFL_PUT_BITS(0x01, 8);
- }
-
- TDEFL_PUT_BITS(flush == TDEFL_FINISH, 1);
-
- pSaved_output_buf = d->m_pOutput_buf;
- saved_bit_buf = d->m_bit_buffer;
- saved_bits_in = d->m_bits_in;
-
- if (!use_raw_block)
- comp_block_succeeded =
- tdefl_compress_block(d, (d->m_flags & TDEFL_FORCE_ALL_STATIC_BLOCKS) ||
- (d->m_total_lz_bytes < 48));
-
- /* If the block gets expanded, forget the current contents of the output
- * buffer and send a raw block instead. */
- if (((use_raw_block) ||
- ((d->m_total_lz_bytes) && ((d->m_pOutput_buf - pSaved_output_buf + 1U) >=
- d->m_total_lz_bytes))) &&
- ((d->m_lookahead_pos - d->m_lz_code_buf_dict_pos) <= d->m_dict_size)) {
- mz_uint i;
- d->m_pOutput_buf = pSaved_output_buf;
- d->m_bit_buffer = saved_bit_buf, d->m_bits_in = saved_bits_in;
- TDEFL_PUT_BITS(0, 2);
- if (d->m_bits_in) {
- TDEFL_PUT_BITS(0, 8 - d->m_bits_in);
- }
- for (i = 2; i; --i, d->m_total_lz_bytes ^= 0xFFFF) {
- TDEFL_PUT_BITS(d->m_total_lz_bytes & 0xFFFF, 16);
- }
- for (i = 0; i < d->m_total_lz_bytes; ++i) {
- TDEFL_PUT_BITS(
- d->m_dict[(d->m_lz_code_buf_dict_pos + i) & TDEFL_LZ_DICT_SIZE_MASK],
- 8);
- }
- }
- /* Check for the extremely unlikely (if not impossible) case of the compressed
- block not fitting into the output buffer when using dynamic codes. */
- else if (!comp_block_succeeded) {
- d->m_pOutput_buf = pSaved_output_buf;
- d->m_bit_buffer = saved_bit_buf, d->m_bits_in = saved_bits_in;
- tdefl_compress_block(d, MZ_TRUE);
- }
-
- if (flush) {
- if (flush == TDEFL_FINISH) {
- if (d->m_bits_in) {
- TDEFL_PUT_BITS(0, 8 - d->m_bits_in);
- }
- if (d->m_flags & TDEFL_WRITE_ZLIB_HEADER) {
- mz_uint i, a = d->m_adler32;
- for (i = 0; i < 4; i++) {
- TDEFL_PUT_BITS((a >> 24) & 0xFF, 8);
- a <<= 8;
- }
- }
- } else {
- mz_uint i, z = 0;
- TDEFL_PUT_BITS(0, 3);
- if (d->m_bits_in) {
- TDEFL_PUT_BITS(0, 8 - d->m_bits_in);
- }
- for (i = 2; i; --i, z ^= 0xFFFF) {
- TDEFL_PUT_BITS(z & 0xFFFF, 16);
- }
- }
- }
-
- MZ_ASSERT(d->m_pOutput_buf < d->m_pOutput_buf_end);
-
- memset(&d->m_huff_count[0][0], 0,
- sizeof(d->m_huff_count[0][0]) * TDEFL_MAX_HUFF_SYMBOLS_0);
- memset(&d->m_huff_count[1][0], 0,
- sizeof(d->m_huff_count[1][0]) * TDEFL_MAX_HUFF_SYMBOLS_1);
-
- d->m_pLZ_code_buf = d->m_lz_code_buf + 1;
- d->m_pLZ_flags = d->m_lz_code_buf;
- d->m_num_flags_left = 8;
- d->m_lz_code_buf_dict_pos += d->m_total_lz_bytes;
- d->m_total_lz_bytes = 0;
- d->m_block_index++;
-
- if ((n = (int)(d->m_pOutput_buf - pOutput_buf_start)) != 0) {
- if (d->m_pPut_buf_func) {
- *d->m_pIn_buf_size = d->m_pSrc - (const mz_uint8 *)d->m_pIn_buf;
- if (!(*d->m_pPut_buf_func)(d->m_output_buf, n, d->m_pPut_buf_user))
- return (d->m_prev_return_status = TDEFL_STATUS_PUT_BUF_FAILED);
- } else if (pOutput_buf_start == d->m_output_buf) {
- int bytes_to_copy = (int)MZ_MIN(
- (size_t)n, (size_t)(*d->m_pOut_buf_size - d->m_out_buf_ofs));
- memcpy((mz_uint8 *)d->m_pOut_buf + d->m_out_buf_ofs, d->m_output_buf,
- bytes_to_copy);
- d->m_out_buf_ofs += bytes_to_copy;
- if ((n -= bytes_to_copy) != 0) {
- d->m_output_flush_ofs = bytes_to_copy;
- d->m_output_flush_remaining = n;
- }
- } else {
- d->m_out_buf_ofs += n;
- }
- }
-
- return d->m_output_flush_remaining;
-}
-
-#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES
-#ifdef MINIZ_UNALIGNED_USE_MEMCPY
-static mz_uint16 TDEFL_READ_UNALIGNED_WORD(const mz_uint8 *p) {
- mz_uint16 ret;
- memcpy(&ret, p, sizeof(mz_uint16));
- return ret;
-}
-static mz_uint16 TDEFL_READ_UNALIGNED_WORD2(const mz_uint16 *p) {
- mz_uint16 ret;
- memcpy(&ret, p, sizeof(mz_uint16));
- return ret;
-}
-#else
-#define TDEFL_READ_UNALIGNED_WORD(p) *(const mz_uint16 *)(p)
-#define TDEFL_READ_UNALIGNED_WORD2(p) *(const mz_uint16 *)(p)
-#endif
-static MZ_FORCEINLINE void
-tdefl_find_match(tdefl_compressor *d, mz_uint lookahead_pos, mz_uint max_dist,
- mz_uint max_match_len, mz_uint *pMatch_dist,
- mz_uint *pMatch_len) {
- mz_uint dist, pos = lookahead_pos & TDEFL_LZ_DICT_SIZE_MASK,
- match_len = *pMatch_len, probe_pos = pos, next_probe_pos,
- probe_len;
- mz_uint num_probes_left = d->m_max_probes[match_len >= 32];
- const mz_uint16 *s = (const mz_uint16 *)(d->m_dict + pos), *p, *q;
- mz_uint16 c01 = TDEFL_READ_UNALIGNED_WORD(&d->m_dict[pos + match_len - 1]),
- s01 = TDEFL_READ_UNALIGNED_WORD2(s);
- MZ_ASSERT(max_match_len <= TDEFL_MAX_MATCH_LEN);
- if (max_match_len <= match_len)
- return;
- for (;;) {
- for (;;) {
- if (--num_probes_left == 0)
- return;
-#define TDEFL_PROBE \
- next_probe_pos = d->m_next[probe_pos]; \
- if ((!next_probe_pos) || \
- ((dist = (mz_uint16)(lookahead_pos - next_probe_pos)) > max_dist)) \
- return; \
- probe_pos = next_probe_pos & TDEFL_LZ_DICT_SIZE_MASK; \
- if (TDEFL_READ_UNALIGNED_WORD(&d->m_dict[probe_pos + match_len - 1]) == c01) \
- break;
- TDEFL_PROBE;
- TDEFL_PROBE;
- TDEFL_PROBE;
- }
- if (!dist)
- break;
- q = (const mz_uint16 *)(d->m_dict + probe_pos);
- if (TDEFL_READ_UNALIGNED_WORD2(q) != s01)
- continue;
- p = s;
- probe_len = 32;
- do {
- } while (
- (TDEFL_READ_UNALIGNED_WORD2(++p) == TDEFL_READ_UNALIGNED_WORD2(++q)) &&
- (TDEFL_READ_UNALIGNED_WORD2(++p) == TDEFL_READ_UNALIGNED_WORD2(++q)) &&
- (TDEFL_READ_UNALIGNED_WORD2(++p) == TDEFL_READ_UNALIGNED_WORD2(++q)) &&
- (TDEFL_READ_UNALIGNED_WORD2(++p) == TDEFL_READ_UNALIGNED_WORD2(++q)) &&
- (--probe_len > 0));
- if (!probe_len) {
- *pMatch_dist = dist;
- *pMatch_len = MZ_MIN(max_match_len, (mz_uint)TDEFL_MAX_MATCH_LEN);
- break;
- } else if ((probe_len = ((mz_uint)(p - s) * 2) +
- (mz_uint)(*(const mz_uint8 *)p ==
- *(const mz_uint8 *)q)) > match_len) {
- *pMatch_dist = dist;
- if ((*pMatch_len = match_len = MZ_MIN(max_match_len, probe_len)) ==
- max_match_len)
- break;
- c01 = TDEFL_READ_UNALIGNED_WORD(&d->m_dict[pos + match_len - 1]);
- }
- }
-}
-#else
-static MZ_FORCEINLINE void
-tdefl_find_match(tdefl_compressor *d, mz_uint lookahead_pos, mz_uint max_dist,
- mz_uint max_match_len, mz_uint *pMatch_dist,
- mz_uint *pMatch_len) {
- mz_uint dist, pos = lookahead_pos & TDEFL_LZ_DICT_SIZE_MASK,
- match_len = *pMatch_len, probe_pos = pos, next_probe_pos,
- probe_len;
- mz_uint num_probes_left = d->m_max_probes[match_len >= 32];
- const mz_uint8 *s = d->m_dict + pos, *p, *q;
- mz_uint8 c0 = d->m_dict[pos + match_len], c1 = d->m_dict[pos + match_len - 1];
- MZ_ASSERT(max_match_len <= TDEFL_MAX_MATCH_LEN);
- if (max_match_len <= match_len)
- return;
- for (;;) {
- for (;;) {
- if (--num_probes_left == 0)
- return;
-#define TDEFL_PROBE \
- next_probe_pos = d->m_next[probe_pos]; \
- if ((!next_probe_pos) || \
- ((dist = (mz_uint16)(lookahead_pos - next_probe_pos)) > max_dist)) \
- return; \
- probe_pos = next_probe_pos & TDEFL_LZ_DICT_SIZE_MASK; \
- if ((d->m_dict[probe_pos + match_len] == c0) && \
- (d->m_dict[probe_pos + match_len - 1] == c1)) \
- break;
- TDEFL_PROBE;
- TDEFL_PROBE;
- TDEFL_PROBE;
- }
- if (!dist)
- break;
- p = s;
- q = d->m_dict + probe_pos;
- for (probe_len = 0; probe_len < max_match_len; probe_len++)
- if (*p++ != *q++)
- break;
- if (probe_len > match_len) {
- *pMatch_dist = dist;
- if ((*pMatch_len = match_len = probe_len) == max_match_len)
- return;
- c0 = d->m_dict[pos + match_len];
- c1 = d->m_dict[pos + match_len - 1];
- }
- }
-}
-#endif /* #if MINIZ_USE_UNALIGNED_LOADS_AND_STORES */
-
-#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN
-#ifdef MINIZ_UNALIGNED_USE_MEMCPY
-static mz_uint32 TDEFL_READ_UNALIGNED_WORD32(const mz_uint8 *p) {
- mz_uint32 ret;
- memcpy(&ret, p, sizeof(mz_uint32));
- return ret;
-}
-#else
-#define TDEFL_READ_UNALIGNED_WORD32(p) *(const mz_uint32 *)(p)
-#endif
-static mz_bool tdefl_compress_fast(tdefl_compressor *d) {
- /* Faster, minimally featured LZRW1-style match+parse loop with better
- * register utilization. Intended for applications where raw throughput is
- * valued more highly than ratio. */
- mz_uint lookahead_pos = d->m_lookahead_pos,
- lookahead_size = d->m_lookahead_size, dict_size = d->m_dict_size,
- total_lz_bytes = d->m_total_lz_bytes,
- num_flags_left = d->m_num_flags_left;
- mz_uint8 *pLZ_code_buf = d->m_pLZ_code_buf, *pLZ_flags = d->m_pLZ_flags;
- mz_uint cur_pos = lookahead_pos & TDEFL_LZ_DICT_SIZE_MASK;
-
- while ((d->m_src_buf_left) || ((d->m_flush) && (lookahead_size))) {
- const mz_uint TDEFL_COMP_FAST_LOOKAHEAD_SIZE = 4096;
- mz_uint dst_pos =
- (lookahead_pos + lookahead_size) & TDEFL_LZ_DICT_SIZE_MASK;
- mz_uint num_bytes_to_process = (mz_uint)MZ_MIN(
- d->m_src_buf_left, TDEFL_COMP_FAST_LOOKAHEAD_SIZE - lookahead_size);
- d->m_src_buf_left -= num_bytes_to_process;
- lookahead_size += num_bytes_to_process;
-
- while (num_bytes_to_process) {
- mz_uint32 n = MZ_MIN(TDEFL_LZ_DICT_SIZE - dst_pos, num_bytes_to_process);
- memcpy(d->m_dict + dst_pos, d->m_pSrc, n);
- if (dst_pos < (TDEFL_MAX_MATCH_LEN - 1))
- memcpy(d->m_dict + TDEFL_LZ_DICT_SIZE + dst_pos, d->m_pSrc,
- MZ_MIN(n, (TDEFL_MAX_MATCH_LEN - 1) - dst_pos));
- d->m_pSrc += n;
- dst_pos = (dst_pos + n) & TDEFL_LZ_DICT_SIZE_MASK;
- num_bytes_to_process -= n;
- }
-
- dict_size = MZ_MIN(TDEFL_LZ_DICT_SIZE - lookahead_size, dict_size);
- if ((!d->m_flush) && (lookahead_size < TDEFL_COMP_FAST_LOOKAHEAD_SIZE))
- break;
-
- while (lookahead_size >= 4) {
- mz_uint cur_match_dist, cur_match_len = 1;
- mz_uint8 *pCur_dict = d->m_dict + cur_pos;
- mz_uint first_trigram = TDEFL_READ_UNALIGNED_WORD32(pCur_dict) & 0xFFFFFF;
- mz_uint hash =
- (first_trigram ^ (first_trigram >> (24 - (TDEFL_LZ_HASH_BITS - 8)))) &
- TDEFL_LEVEL1_HASH_SIZE_MASK;
- mz_uint probe_pos = d->m_hash[hash];
- d->m_hash[hash] = (mz_uint16)lookahead_pos;
-
- if (((cur_match_dist = (mz_uint16)(lookahead_pos - probe_pos)) <=
- dict_size) &&
- ((TDEFL_READ_UNALIGNED_WORD32(
- d->m_dict + (probe_pos &= TDEFL_LZ_DICT_SIZE_MASK)) &
- 0xFFFFFF) == first_trigram)) {
- const mz_uint16 *p = (const mz_uint16 *)pCur_dict;
- const mz_uint16 *q = (const mz_uint16 *)(d->m_dict + probe_pos);
- mz_uint32 probe_len = 32;
- do {
- } while ((TDEFL_READ_UNALIGNED_WORD2(++p) ==
- TDEFL_READ_UNALIGNED_WORD2(++q)) &&
- (TDEFL_READ_UNALIGNED_WORD2(++p) ==
- TDEFL_READ_UNALIGNED_WORD2(++q)) &&
- (TDEFL_READ_UNALIGNED_WORD2(++p) ==
- TDEFL_READ_UNALIGNED_WORD2(++q)) &&
- (TDEFL_READ_UNALIGNED_WORD2(++p) ==
- TDEFL_READ_UNALIGNED_WORD2(++q)) &&
- (--probe_len > 0));
- cur_match_len = ((mz_uint)(p - (const mz_uint16 *)pCur_dict) * 2) +
- (mz_uint)(*(const mz_uint8 *)p == *(const mz_uint8 *)q);
- if (!probe_len)
- cur_match_len = cur_match_dist ? TDEFL_MAX_MATCH_LEN : 0;
-
- if ((cur_match_len < TDEFL_MIN_MATCH_LEN) ||
- ((cur_match_len == TDEFL_MIN_MATCH_LEN) &&
- (cur_match_dist >= 8U * 1024U))) {
- cur_match_len = 1;
- *pLZ_code_buf++ = (mz_uint8)first_trigram;
- *pLZ_flags = (mz_uint8)(*pLZ_flags >> 1);
- d->m_huff_count[0][(mz_uint8)first_trigram]++;
- } else {
- mz_uint32 s0, s1;
- cur_match_len = MZ_MIN(cur_match_len, lookahead_size);
-
- MZ_ASSERT((cur_match_len >= TDEFL_MIN_MATCH_LEN) &&
- (cur_match_dist >= 1) &&
- (cur_match_dist <= TDEFL_LZ_DICT_SIZE));
-
- cur_match_dist--;
-
- pLZ_code_buf[0] = (mz_uint8)(cur_match_len - TDEFL_MIN_MATCH_LEN);
-#ifdef MINIZ_UNALIGNED_USE_MEMCPY
- memcpy(&pLZ_code_buf[1], &cur_match_dist, sizeof(cur_match_dist));
-#else
- *(mz_uint16 *)(&pLZ_code_buf[1]) = (mz_uint16)cur_match_dist;
-#endif
- pLZ_code_buf += 3;
- *pLZ_flags = (mz_uint8)((*pLZ_flags >> 1) | 0x80);
-
- s0 = s_tdefl_small_dist_sym[cur_match_dist & 511];
- s1 = s_tdefl_large_dist_sym[cur_match_dist >> 8];
- d->m_huff_count[1][(cur_match_dist < 512) ? s0 : s1]++;
-
- d->m_huff_count[0][s_tdefl_len_sym[cur_match_len -
- TDEFL_MIN_MATCH_LEN]]++;
- }
- } else {
- *pLZ_code_buf++ = (mz_uint8)first_trigram;
- *pLZ_flags = (mz_uint8)(*pLZ_flags >> 1);
- d->m_huff_count[0][(mz_uint8)first_trigram]++;
- }
-
- if (--num_flags_left == 0) {
- num_flags_left = 8;
- pLZ_flags = pLZ_code_buf++;
- }
-
- total_lz_bytes += cur_match_len;
- lookahead_pos += cur_match_len;
- dict_size =
- MZ_MIN(dict_size + cur_match_len, (mz_uint)TDEFL_LZ_DICT_SIZE);
- cur_pos = (cur_pos + cur_match_len) & TDEFL_LZ_DICT_SIZE_MASK;
- MZ_ASSERT(lookahead_size >= cur_match_len);
- lookahead_size -= cur_match_len;
-
- if (pLZ_code_buf > &d->m_lz_code_buf[TDEFL_LZ_CODE_BUF_SIZE - 8]) {
- int n;
- d->m_lookahead_pos = lookahead_pos;
- d->m_lookahead_size = lookahead_size;
- d->m_dict_size = dict_size;
- d->m_total_lz_bytes = total_lz_bytes;
- d->m_pLZ_code_buf = pLZ_code_buf;
- d->m_pLZ_flags = pLZ_flags;
- d->m_num_flags_left = num_flags_left;
- if ((n = tdefl_flush_block(d, 0)) != 0)
- return (n < 0) ? MZ_FALSE : MZ_TRUE;
- total_lz_bytes = d->m_total_lz_bytes;
- pLZ_code_buf = d->m_pLZ_code_buf;
- pLZ_flags = d->m_pLZ_flags;
- num_flags_left = d->m_num_flags_left;
- }
- }
-
- while (lookahead_size) {
- mz_uint8 lit = d->m_dict[cur_pos];
-
- total_lz_bytes++;
- *pLZ_code_buf++ = lit;
- *pLZ_flags = (mz_uint8)(*pLZ_flags >> 1);
- if (--num_flags_left == 0) {
- num_flags_left = 8;
- pLZ_flags = pLZ_code_buf++;
- }
-
- d->m_huff_count[0][lit]++;
-
- lookahead_pos++;
- dict_size = MZ_MIN(dict_size + 1, (mz_uint)TDEFL_LZ_DICT_SIZE);
- cur_pos = (cur_pos + 1) & TDEFL_LZ_DICT_SIZE_MASK;
- lookahead_size--;
-
- if (pLZ_code_buf > &d->m_lz_code_buf[TDEFL_LZ_CODE_BUF_SIZE - 8]) {
- int n;
- d->m_lookahead_pos = lookahead_pos;
- d->m_lookahead_size = lookahead_size;
- d->m_dict_size = dict_size;
- d->m_total_lz_bytes = total_lz_bytes;
- d->m_pLZ_code_buf = pLZ_code_buf;
- d->m_pLZ_flags = pLZ_flags;
- d->m_num_flags_left = num_flags_left;
- if ((n = tdefl_flush_block(d, 0)) != 0)
- return (n < 0) ? MZ_FALSE : MZ_TRUE;
- total_lz_bytes = d->m_total_lz_bytes;
- pLZ_code_buf = d->m_pLZ_code_buf;
- pLZ_flags = d->m_pLZ_flags;
- num_flags_left = d->m_num_flags_left;
- }
- }
- }
-
- d->m_lookahead_pos = lookahead_pos;
- d->m_lookahead_size = lookahead_size;
- d->m_dict_size = dict_size;
- d->m_total_lz_bytes = total_lz_bytes;
- d->m_pLZ_code_buf = pLZ_code_buf;
- d->m_pLZ_flags = pLZ_flags;
- d->m_num_flags_left = num_flags_left;
- return MZ_TRUE;
-}
-#endif /* MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN */
-
-static MZ_FORCEINLINE void tdefl_record_literal(tdefl_compressor *d,
- mz_uint8 lit) {
- d->m_total_lz_bytes++;
- *d->m_pLZ_code_buf++ = lit;
- *d->m_pLZ_flags = (mz_uint8)(*d->m_pLZ_flags >> 1);
- if (--d->m_num_flags_left == 0) {
- d->m_num_flags_left = 8;
- d->m_pLZ_flags = d->m_pLZ_code_buf++;
- }
- d->m_huff_count[0][lit]++;
-}
-
-static MZ_FORCEINLINE void
-tdefl_record_match(tdefl_compressor *d, mz_uint match_len, mz_uint match_dist) {
- mz_uint32 s0, s1;
-
- MZ_ASSERT((match_len >= TDEFL_MIN_MATCH_LEN) && (match_dist >= 1) &&
- (match_dist <= TDEFL_LZ_DICT_SIZE));
-
- d->m_total_lz_bytes += match_len;
-
- d->m_pLZ_code_buf[0] = (mz_uint8)(match_len - TDEFL_MIN_MATCH_LEN);
-
- match_dist -= 1;
- d->m_pLZ_code_buf[1] = (mz_uint8)(match_dist & 0xFF);
- d->m_pLZ_code_buf[2] = (mz_uint8)(match_dist >> 8);
- d->m_pLZ_code_buf += 3;
-
- *d->m_pLZ_flags = (mz_uint8)((*d->m_pLZ_flags >> 1) | 0x80);
- if (--d->m_num_flags_left == 0) {
- d->m_num_flags_left = 8;
- d->m_pLZ_flags = d->m_pLZ_code_buf++;
- }
-
- s0 = s_tdefl_small_dist_sym[match_dist & 511];
- s1 = s_tdefl_large_dist_sym[(match_dist >> 8) & 127];
- d->m_huff_count[1][(match_dist < 512) ? s0 : s1]++;
- d->m_huff_count[0][s_tdefl_len_sym[match_len - TDEFL_MIN_MATCH_LEN]]++;
-}
-
-static mz_bool tdefl_compress_normal(tdefl_compressor *d) {
- const mz_uint8 *pSrc = d->m_pSrc;
- size_t src_buf_left = d->m_src_buf_left;
- tdefl_flush flush = d->m_flush;
-
- while ((src_buf_left) || ((flush) && (d->m_lookahead_size))) {
- mz_uint len_to_move, cur_match_dist, cur_match_len, cur_pos;
- /* Update dictionary and hash chains. Keeps the lookahead size equal to
- * TDEFL_MAX_MATCH_LEN. */
- if ((d->m_lookahead_size + d->m_dict_size) >= (TDEFL_MIN_MATCH_LEN - 1)) {
- mz_uint dst_pos = (d->m_lookahead_pos + d->m_lookahead_size) &
- TDEFL_LZ_DICT_SIZE_MASK,
- ins_pos = d->m_lookahead_pos + d->m_lookahead_size - 2;
- mz_uint hash = (d->m_dict[ins_pos & TDEFL_LZ_DICT_SIZE_MASK]
- << TDEFL_LZ_HASH_SHIFT) ^
- d->m_dict[(ins_pos + 1) & TDEFL_LZ_DICT_SIZE_MASK];
- mz_uint num_bytes_to_process = (mz_uint)MZ_MIN(
- src_buf_left, TDEFL_MAX_MATCH_LEN - d->m_lookahead_size);
- const mz_uint8 *pSrc_end = pSrc + num_bytes_to_process;
- src_buf_left -= num_bytes_to_process;
- d->m_lookahead_size += num_bytes_to_process;
- while (pSrc != pSrc_end) {
- mz_uint8 c = *pSrc++;
- d->m_dict[dst_pos] = c;
- if (dst_pos < (TDEFL_MAX_MATCH_LEN - 1))
- d->m_dict[TDEFL_LZ_DICT_SIZE + dst_pos] = c;
- hash = ((hash << TDEFL_LZ_HASH_SHIFT) ^ c) & (TDEFL_LZ_HASH_SIZE - 1);
- d->m_next[ins_pos & TDEFL_LZ_DICT_SIZE_MASK] = d->m_hash[hash];
- d->m_hash[hash] = (mz_uint16)(ins_pos);
- dst_pos = (dst_pos + 1) & TDEFL_LZ_DICT_SIZE_MASK;
- ins_pos++;
- }
- } else {
- while ((src_buf_left) && (d->m_lookahead_size < TDEFL_MAX_MATCH_LEN)) {
- mz_uint8 c = *pSrc++;
- mz_uint dst_pos = (d->m_lookahead_pos + d->m_lookahead_size) &
- TDEFL_LZ_DICT_SIZE_MASK;
- src_buf_left--;
- d->m_dict[dst_pos] = c;
- if (dst_pos < (TDEFL_MAX_MATCH_LEN - 1))
- d->m_dict[TDEFL_LZ_DICT_SIZE + dst_pos] = c;
- if ((++d->m_lookahead_size + d->m_dict_size) >= TDEFL_MIN_MATCH_LEN) {
- mz_uint ins_pos = d->m_lookahead_pos + (d->m_lookahead_size - 1) - 2;
- mz_uint hash = ((d->m_dict[ins_pos & TDEFL_LZ_DICT_SIZE_MASK]
- << (TDEFL_LZ_HASH_SHIFT * 2)) ^
- (d->m_dict[(ins_pos + 1) & TDEFL_LZ_DICT_SIZE_MASK]
- << TDEFL_LZ_HASH_SHIFT) ^
- c) &
- (TDEFL_LZ_HASH_SIZE - 1);
- d->m_next[ins_pos & TDEFL_LZ_DICT_SIZE_MASK] = d->m_hash[hash];
- d->m_hash[hash] = (mz_uint16)(ins_pos);
- }
- }
- }
- d->m_dict_size =
- MZ_MIN(TDEFL_LZ_DICT_SIZE - d->m_lookahead_size, d->m_dict_size);
- if ((!flush) && (d->m_lookahead_size < TDEFL_MAX_MATCH_LEN))
- break;
-
- /* Simple lazy/greedy parsing state machine. */
- len_to_move = 1;
- cur_match_dist = 0;
- cur_match_len =
- d->m_saved_match_len ? d->m_saved_match_len : (TDEFL_MIN_MATCH_LEN - 1);
- cur_pos = d->m_lookahead_pos & TDEFL_LZ_DICT_SIZE_MASK;
- if (d->m_flags & (TDEFL_RLE_MATCHES | TDEFL_FORCE_ALL_RAW_BLOCKS)) {
- if ((d->m_dict_size) && (!(d->m_flags & TDEFL_FORCE_ALL_RAW_BLOCKS))) {
- mz_uint8 c = d->m_dict[(cur_pos - 1) & TDEFL_LZ_DICT_SIZE_MASK];
- cur_match_len = 0;
- while (cur_match_len < d->m_lookahead_size) {
- if (d->m_dict[cur_pos + cur_match_len] != c)
- break;
- cur_match_len++;
- }
- if (cur_match_len < TDEFL_MIN_MATCH_LEN)
- cur_match_len = 0;
- else
- cur_match_dist = 1;
- }
- } else {
- tdefl_find_match(d, d->m_lookahead_pos, d->m_dict_size,
- d->m_lookahead_size, &cur_match_dist, &cur_match_len);
- }
- if (((cur_match_len == TDEFL_MIN_MATCH_LEN) &&
- (cur_match_dist >= 8U * 1024U)) ||
- (cur_pos == cur_match_dist) ||
- ((d->m_flags & TDEFL_FILTER_MATCHES) && (cur_match_len <= 5))) {
- cur_match_dist = cur_match_len = 0;
- }
- if (d->m_saved_match_len) {
- if (cur_match_len > d->m_saved_match_len) {
- tdefl_record_literal(d, (mz_uint8)d->m_saved_lit);
- if (cur_match_len >= 128) {
- tdefl_record_match(d, cur_match_len, cur_match_dist);
- d->m_saved_match_len = 0;
- len_to_move = cur_match_len;
- } else {
- d->m_saved_lit = d->m_dict[cur_pos];
- d->m_saved_match_dist = cur_match_dist;
- d->m_saved_match_len = cur_match_len;
- }
- } else {
- tdefl_record_match(d, d->m_saved_match_len, d->m_saved_match_dist);
- len_to_move = d->m_saved_match_len - 1;
- d->m_saved_match_len = 0;
- }
- } else if (!cur_match_dist)
- tdefl_record_literal(d,
- d->m_dict[MZ_MIN(cur_pos, sizeof(d->m_dict) - 1)]);
- else if ((d->m_greedy_parsing) || (d->m_flags & TDEFL_RLE_MATCHES) ||
- (cur_match_len >= 128)) {
- tdefl_record_match(d, cur_match_len, cur_match_dist);
- len_to_move = cur_match_len;
- } else {
- d->m_saved_lit = d->m_dict[MZ_MIN(cur_pos, sizeof(d->m_dict) - 1)];
- d->m_saved_match_dist = cur_match_dist;
- d->m_saved_match_len = cur_match_len;
- }
- /* Move the lookahead forward by len_to_move bytes. */
- d->m_lookahead_pos += len_to_move;
- MZ_ASSERT(d->m_lookahead_size >= len_to_move);
- d->m_lookahead_size -= len_to_move;
- d->m_dict_size =
- MZ_MIN(d->m_dict_size + len_to_move, (mz_uint)TDEFL_LZ_DICT_SIZE);
- /* Check if it's time to flush the current LZ codes to the internal output
- * buffer. */
- if ((d->m_pLZ_code_buf > &d->m_lz_code_buf[TDEFL_LZ_CODE_BUF_SIZE - 8]) ||
- ((d->m_total_lz_bytes > 31 * 1024) &&
- (((((mz_uint)(d->m_pLZ_code_buf - d->m_lz_code_buf) * 115) >> 7) >=
- d->m_total_lz_bytes) ||
- (d->m_flags & TDEFL_FORCE_ALL_RAW_BLOCKS)))) {
- int n;
- d->m_pSrc = pSrc;
- d->m_src_buf_left = src_buf_left;
- if ((n = tdefl_flush_block(d, 0)) != 0)
- return (n < 0) ? MZ_FALSE : MZ_TRUE;
- }
- }
-
- d->m_pSrc = pSrc;
- d->m_src_buf_left = src_buf_left;
- return MZ_TRUE;
-}
-
-static tdefl_status tdefl_flush_output_buffer(tdefl_compressor *d) {
- if (d->m_pIn_buf_size) {
- *d->m_pIn_buf_size = d->m_pSrc - (const mz_uint8 *)d->m_pIn_buf;
- }
-
- if (d->m_pOut_buf_size) {
- size_t n = MZ_MIN(*d->m_pOut_buf_size - d->m_out_buf_ofs,
- d->m_output_flush_remaining);
- memcpy((mz_uint8 *)d->m_pOut_buf + d->m_out_buf_ofs,
- d->m_output_buf + d->m_output_flush_ofs, n);
- d->m_output_flush_ofs += (mz_uint)n;
- d->m_output_flush_remaining -= (mz_uint)n;
- d->m_out_buf_ofs += n;
-
- *d->m_pOut_buf_size = d->m_out_buf_ofs;
- }
-
- return (d->m_finished && !d->m_output_flush_remaining) ? TDEFL_STATUS_DONE
- : TDEFL_STATUS_OKAY;
-}
-
-tdefl_status tdefl_compress(tdefl_compressor *d, const void *pIn_buf,
- size_t *pIn_buf_size, void *pOut_buf,
- size_t *pOut_buf_size, tdefl_flush flush) {
- if (!d) {
- if (pIn_buf_size)
- *pIn_buf_size = 0;
- if (pOut_buf_size)
- *pOut_buf_size = 0;
- return TDEFL_STATUS_BAD_PARAM;
- }
-
- d->m_pIn_buf = pIn_buf;
- d->m_pIn_buf_size = pIn_buf_size;
- d->m_pOut_buf = pOut_buf;
- d->m_pOut_buf_size = pOut_buf_size;
- d->m_pSrc = (const mz_uint8 *)(pIn_buf);
- d->m_src_buf_left = pIn_buf_size ? *pIn_buf_size : 0;
- d->m_out_buf_ofs = 0;
- d->m_flush = flush;
-
- if (((d->m_pPut_buf_func != NULL) ==
- ((pOut_buf != NULL) || (pOut_buf_size != NULL))) ||
- (d->m_prev_return_status != TDEFL_STATUS_OKAY) ||
- (d->m_wants_to_finish && (flush != TDEFL_FINISH)) ||
- (pIn_buf_size && *pIn_buf_size && !pIn_buf) ||
- (pOut_buf_size && *pOut_buf_size && !pOut_buf)) {
- if (pIn_buf_size)
- *pIn_buf_size = 0;
- if (pOut_buf_size)
- *pOut_buf_size = 0;
- return (d->m_prev_return_status = TDEFL_STATUS_BAD_PARAM);
- }
- d->m_wants_to_finish |= (flush == TDEFL_FINISH);
-
- if ((d->m_output_flush_remaining) || (d->m_finished))
- return (d->m_prev_return_status = tdefl_flush_output_buffer(d));
-
-#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN
- if (((d->m_flags & TDEFL_MAX_PROBES_MASK) == 1) &&
- ((d->m_flags & TDEFL_GREEDY_PARSING_FLAG) != 0) &&
- ((d->m_flags & (TDEFL_FILTER_MATCHES | TDEFL_FORCE_ALL_RAW_BLOCKS |
- TDEFL_RLE_MATCHES)) == 0)) {
- if (!tdefl_compress_fast(d))
- return d->m_prev_return_status;
- } else
-#endif /* #if MINIZ_USE_UNALIGNED_LOADS_AND_STORES && MINIZ_LITTLE_ENDIAN */
- {
- if (!tdefl_compress_normal(d))
- return d->m_prev_return_status;
- }
-
- if ((d->m_flags & (TDEFL_WRITE_ZLIB_HEADER | TDEFL_COMPUTE_ADLER32)) &&
- (pIn_buf))
- d->m_adler32 =
- (mz_uint32)mz_adler32(d->m_adler32, (const mz_uint8 *)pIn_buf,
- d->m_pSrc - (const mz_uint8 *)pIn_buf);
-
- if ((flush) && (!d->m_lookahead_size) && (!d->m_src_buf_left) &&
- (!d->m_output_flush_remaining)) {
- if (tdefl_flush_block(d, flush) < 0)
- return d->m_prev_return_status;
- d->m_finished = (flush == TDEFL_FINISH);
- if (flush == TDEFL_FULL_FLUSH) {
- MZ_CLEAR_OBJ(d->m_hash);
- MZ_CLEAR_OBJ(d->m_next);
- d->m_dict_size = 0;
- }
- }
-
- return (d->m_prev_return_status = tdefl_flush_output_buffer(d));
-}
-
-tdefl_status tdefl_compress_buffer(tdefl_compressor *d, const void *pIn_buf,
- size_t in_buf_size, tdefl_flush flush) {
- MZ_ASSERT(d->m_pPut_buf_func);
- return tdefl_compress(d, pIn_buf, &in_buf_size, NULL, NULL, flush);
-}
-
-tdefl_status tdefl_init(tdefl_compressor *d,
- tdefl_put_buf_func_ptr pPut_buf_func,
- void *pPut_buf_user, int flags) {
- d->m_pPut_buf_func = pPut_buf_func;
- d->m_pPut_buf_user = pPut_buf_user;
- d->m_flags = (mz_uint)(flags);
- d->m_max_probes[0] = 1 + ((flags & 0xFFF) + 2) / 3;
- d->m_greedy_parsing = (flags & TDEFL_GREEDY_PARSING_FLAG) != 0;
- d->m_max_probes[1] = 1 + (((flags & 0xFFF) >> 2) + 2) / 3;
- if (!(flags & TDEFL_NONDETERMINISTIC_PARSING_FLAG))
- MZ_CLEAR_OBJ(d->m_hash);
- d->m_lookahead_pos = d->m_lookahead_size = d->m_dict_size =
- d->m_total_lz_bytes = d->m_lz_code_buf_dict_pos = d->m_bits_in = 0;
- d->m_output_flush_ofs = d->m_output_flush_remaining = d->m_finished =
- d->m_block_index = d->m_bit_buffer = d->m_wants_to_finish = 0;
- d->m_pLZ_code_buf = d->m_lz_code_buf + 1;
- d->m_pLZ_flags = d->m_lz_code_buf;
- *d->m_pLZ_flags = 0;
- d->m_num_flags_left = 8;
- d->m_pOutput_buf = d->m_output_buf;
- d->m_pOutput_buf_end = d->m_output_buf;
- d->m_prev_return_status = TDEFL_STATUS_OKAY;
- d->m_saved_match_dist = d->m_saved_match_len = d->m_saved_lit = 0;
- d->m_adler32 = 1;
- d->m_pIn_buf = NULL;
- d->m_pOut_buf = NULL;
- d->m_pIn_buf_size = NULL;
- d->m_pOut_buf_size = NULL;
- d->m_flush = TDEFL_NO_FLUSH;
- d->m_pSrc = NULL;
- d->m_src_buf_left = 0;
- d->m_out_buf_ofs = 0;
- if (!(flags & TDEFL_NONDETERMINISTIC_PARSING_FLAG))
- MZ_CLEAR_OBJ(d->m_dict);
- memset(&d->m_huff_count[0][0], 0,
- sizeof(d->m_huff_count[0][0]) * TDEFL_MAX_HUFF_SYMBOLS_0);
- memset(&d->m_huff_count[1][0], 0,
- sizeof(d->m_huff_count[1][0]) * TDEFL_MAX_HUFF_SYMBOLS_1);
- return TDEFL_STATUS_OKAY;
-}
-
-tdefl_status tdefl_get_prev_return_status(tdefl_compressor *d) {
- return d->m_prev_return_status;
-}
-
-mz_uint32 tdefl_get_adler32(tdefl_compressor *d) { return d->m_adler32; }
-
-mz_bool tdefl_compress_mem_to_output(const void *pBuf, size_t buf_len,
- tdefl_put_buf_func_ptr pPut_buf_func,
- void *pPut_buf_user, int flags) {
- tdefl_compressor *pComp;
- mz_bool succeeded;
- if (((buf_len) && (!pBuf)) || (!pPut_buf_func))
- return MZ_FALSE;
- pComp = (tdefl_compressor *)MZ_MALLOC(sizeof(tdefl_compressor));
- if (!pComp)
- return MZ_FALSE;
- succeeded = (tdefl_init(pComp, pPut_buf_func, pPut_buf_user, flags) ==
- TDEFL_STATUS_OKAY);
- succeeded =
- succeeded && (tdefl_compress_buffer(pComp, pBuf, buf_len, TDEFL_FINISH) ==
- TDEFL_STATUS_DONE);
- MZ_FREE(pComp);
- return succeeded;
-}
-
-typedef struct {
- size_t m_size, m_capacity;
- mz_uint8 *m_pBuf;
- mz_bool m_expandable;
-} tdefl_output_buffer;
-
-static mz_bool tdefl_output_buffer_putter(const void *pBuf, int len,
- void *pUser) {
- tdefl_output_buffer *p = (tdefl_output_buffer *)pUser;
- size_t new_size = p->m_size + len;
- if (new_size > p->m_capacity) {
- size_t new_capacity = p->m_capacity;
- mz_uint8 *pNew_buf;
- if (!p->m_expandable)
- return MZ_FALSE;
- do {
- new_capacity = MZ_MAX(128U, new_capacity << 1U);
- } while (new_size > new_capacity);
- pNew_buf = (mz_uint8 *)MZ_REALLOC(p->m_pBuf, new_capacity);
- if (!pNew_buf)
- return MZ_FALSE;
- p->m_pBuf = pNew_buf;
- p->m_capacity = new_capacity;
- }
- memcpy((mz_uint8 *)p->m_pBuf + p->m_size, pBuf, len);
- p->m_size = new_size;
- return MZ_TRUE;
-}
-
-void *tdefl_compress_mem_to_heap(const void *pSrc_buf, size_t src_buf_len,
- size_t *pOut_len, int flags) {
- tdefl_output_buffer out_buf;
- MZ_CLEAR_OBJ(out_buf);
- if (!pOut_len)
- return MZ_FALSE;
- else
- *pOut_len = 0;
- out_buf.m_expandable = MZ_TRUE;
- if (!tdefl_compress_mem_to_output(
- pSrc_buf, src_buf_len, tdefl_output_buffer_putter, &out_buf, flags))
- return NULL;
- *pOut_len = out_buf.m_size;
- return out_buf.m_pBuf;
-}
-
-size_t tdefl_compress_mem_to_mem(void *pOut_buf, size_t out_buf_len,
- const void *pSrc_buf, size_t src_buf_len,
- int flags) {
- tdefl_output_buffer out_buf;
- MZ_CLEAR_OBJ(out_buf);
- if (!pOut_buf)
- return 0;
- out_buf.m_pBuf = (mz_uint8 *)pOut_buf;
- out_buf.m_capacity = out_buf_len;
- if (!tdefl_compress_mem_to_output(
- pSrc_buf, src_buf_len, tdefl_output_buffer_putter, &out_buf, flags))
- return 0;
- return out_buf.m_size;
-}
-
-static const mz_uint s_tdefl_num_probes[11] = {0, 1, 6, 32, 16, 32,
- 128, 256, 512, 768, 1500};
-
-/* level may actually range from [0,10] (10 is a "hidden" max level, where we
- * want a bit more compression and it's fine if throughput to fall off a cliff
- * on some files). */
-mz_uint tdefl_create_comp_flags_from_zip_params(int level, int window_bits,
- int strategy) {
- mz_uint comp_flags =
- s_tdefl_num_probes[(level >= 0) ? MZ_MIN(10, level) : MZ_DEFAULT_LEVEL] |
- ((level <= 3) ? TDEFL_GREEDY_PARSING_FLAG : 0);
- if (window_bits > 0)
- comp_flags |= TDEFL_WRITE_ZLIB_HEADER;
-
- if (!level)
- comp_flags |= TDEFL_FORCE_ALL_RAW_BLOCKS;
- else if (strategy == MZ_FILTERED)
- comp_flags |= TDEFL_FILTER_MATCHES;
- else if (strategy == MZ_HUFFMAN_ONLY)
- comp_flags &= ~TDEFL_MAX_PROBES_MASK;
- else if (strategy == MZ_FIXED)
- comp_flags |= TDEFL_FORCE_ALL_STATIC_BLOCKS;
- else if (strategy == MZ_RLE)
- comp_flags |= TDEFL_RLE_MATCHES;
-
- return comp_flags;
-}
-
-#ifdef _MSC_VER
-#pragma warning(push)
-#pragma warning(disable : 4204) /* nonstandard extension used : non-constant \
- aggregate initializer (also supported by \
- GNU C and C99, so no big deal) */
-#endif
-
-/* Simple PNG writer function by Alex Evans, 2011. Released into the public
- domain: https://gist.github.com/908299, more context at
- http://altdevblogaday.org/2011/04/06/a-smaller-jpg-encoder/.
- This is actually a modification of Alex's original code so PNG files generated
- by this function pass pngcheck. */
-void *tdefl_write_image_to_png_file_in_memory_ex(const void *pImage, int w,
- int h, int num_chans,
- size_t *pLen_out,
- mz_uint level, mz_bool flip) {
- /* Using a local copy of this array here in case MINIZ_NO_ZLIB_APIS was
- * defined. */
- static const mz_uint s_tdefl_png_num_probes[11] = {
- 0, 1, 6, 32, 16, 32, 128, 256, 512, 768, 1500};
- tdefl_compressor *pComp =
- (tdefl_compressor *)MZ_MALLOC(sizeof(tdefl_compressor));
- tdefl_output_buffer out_buf;
- int i, bpl = w * num_chans, y, z;
- mz_uint32 c;
- *pLen_out = 0;
- if (!pComp)
- return NULL;
- MZ_CLEAR_OBJ(out_buf);
- out_buf.m_expandable = MZ_TRUE;
- out_buf.m_capacity = 57 + MZ_MAX(64, (1 + bpl) * h);
- if (NULL == (out_buf.m_pBuf = (mz_uint8 *)MZ_MALLOC(out_buf.m_capacity))) {
- MZ_FREE(pComp);
- return NULL;
- }
- /* write dummy header */
- for (z = 41; z; --z)
- tdefl_output_buffer_putter(&z, 1, &out_buf);
- /* compress image data */
- tdefl_init(pComp, tdefl_output_buffer_putter, &out_buf,
- s_tdefl_png_num_probes[MZ_MIN(10, level)] |
- TDEFL_WRITE_ZLIB_HEADER);
- for (y = 0; y < h; ++y) {
- tdefl_compress_buffer(pComp, &z, 1, TDEFL_NO_FLUSH);
- tdefl_compress_buffer(pComp,
- (mz_uint8 *)pImage + (flip ? (h - 1 - y) : y) * bpl,
- bpl, TDEFL_NO_FLUSH);
- }
- if (tdefl_compress_buffer(pComp, NULL, 0, TDEFL_FINISH) !=
- TDEFL_STATUS_DONE) {
- MZ_FREE(pComp);
- MZ_FREE(out_buf.m_pBuf);
- return NULL;
- }
- /* write real header */
- *pLen_out = out_buf.m_size - 41;
- {
- static const mz_uint8 chans[] = {0x00, 0x00, 0x04, 0x02, 0x06};
- mz_uint8 pnghdr[41] = {0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00,
- 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x49, 0x44, 0x41, 0x54};
- pnghdr[18] = (mz_uint8)(w >> 8);
- pnghdr[19] = (mz_uint8)w;
- pnghdr[22] = (mz_uint8)(h >> 8);
- pnghdr[23] = (mz_uint8)h;
- pnghdr[25] = chans[num_chans];
- pnghdr[33] = (mz_uint8)(*pLen_out >> 24);
- pnghdr[34] = (mz_uint8)(*pLen_out >> 16);
- pnghdr[35] = (mz_uint8)(*pLen_out >> 8);
- pnghdr[36] = (mz_uint8)*pLen_out;
- c = (mz_uint32)mz_crc32(MZ_CRC32_INIT, pnghdr + 12, 17);
- for (i = 0; i < 4; ++i, c <<= 8)
- ((mz_uint8 *)(pnghdr + 29))[i] = (mz_uint8)(c >> 24);
- memcpy(out_buf.m_pBuf, pnghdr, 41);
- }
- /* write footer (IDAT CRC-32, followed by IEND chunk) */
- if (!tdefl_output_buffer_putter(
- "\0\0\0\0\0\0\0\0\x49\x45\x4e\x44\xae\x42\x60\x82", 16, &out_buf)) {
- *pLen_out = 0;
- MZ_FREE(pComp);
- MZ_FREE(out_buf.m_pBuf);
- return NULL;
- }
- c = (mz_uint32)mz_crc32(MZ_CRC32_INIT, out_buf.m_pBuf + 41 - 4,
- *pLen_out + 4);
- for (i = 0; i < 4; ++i, c <<= 8)
- (out_buf.m_pBuf + out_buf.m_size - 16)[i] = (mz_uint8)(c >> 24);
- /* compute final size of file, grab compressed data buffer and return */
- *pLen_out += 57;
- MZ_FREE(pComp);
- return out_buf.m_pBuf;
-}
-void *tdefl_write_image_to_png_file_in_memory(const void *pImage, int w, int h,
- int num_chans, size_t *pLen_out) {
- /* Level 6 corresponds to TDEFL_DEFAULT_MAX_PROBES or MZ_DEFAULT_LEVEL (but we
- * can't depend on MZ_DEFAULT_LEVEL being available in case the zlib API's
- * where #defined out) */
- return tdefl_write_image_to_png_file_in_memory_ex(pImage, w, h, num_chans,
- pLen_out, 6, MZ_FALSE);
-}
-
-#ifndef MINIZ_NO_MALLOC
-/* Allocate the tdefl_compressor and tinfl_decompressor structures in C so that
- */
-/* non-C language bindings to tdefL_ and tinfl_ API don't need to worry about */
-/* structure size and allocation mechanism. */
-tdefl_compressor *tdefl_compressor_alloc(void) {
- return (tdefl_compressor *)MZ_MALLOC(sizeof(tdefl_compressor));
-}
-
-void tdefl_compressor_free(tdefl_compressor *pComp) { MZ_FREE(pComp); }
-#endif
-
-#ifdef _MSC_VER
-#pragma warning(pop)
-#endif
-
-#ifdef __cplusplus
-}
-#endif
-/**************************************************************************
- *
- * Copyright 2013-2014 RAD Game Tools and Valve Software
- * Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC
- * All Rights Reserved.
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- **************************************************************************/
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/* ------------------- Low-level Decompression (completely independent from all
- * compression API's) */
-
-#define TINFL_MEMCPY(d, s, l) memcpy(d, s, l)
-#define TINFL_MEMSET(p, c, l) memset(p, c, l)
-
-#define TINFL_CR_BEGIN \
- switch (r->m_state) { \
- case 0:
-#define TINFL_CR_RETURN(state_index, result) \
- do { \
- status = result; \
- r->m_state = state_index; \
- goto common_exit; \
- case state_index:; \
- } \
- MZ_MACRO_END
-#define TINFL_CR_RETURN_FOREVER(state_index, result) \
- do { \
- for (;;) { \
- TINFL_CR_RETURN(state_index, result); \
- } \
- } \
- MZ_MACRO_END
-#define TINFL_CR_FINISH }
-
-#define TINFL_GET_BYTE(state_index, c) \
- do { \
- while (pIn_buf_cur >= pIn_buf_end) { \
- TINFL_CR_RETURN(state_index, \
- (decomp_flags & TINFL_FLAG_HAS_MORE_INPUT) \
- ? TINFL_STATUS_NEEDS_MORE_INPUT \
- : TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS); \
- } \
- c = *pIn_buf_cur++; \
- } \
- MZ_MACRO_END
-
-#define TINFL_NEED_BITS(state_index, n) \
- do { \
- mz_uint c; \
- TINFL_GET_BYTE(state_index, c); \
- bit_buf |= (((tinfl_bit_buf_t)c) << num_bits); \
- num_bits += 8; \
- } while (num_bits < (mz_uint)(n))
-#define TINFL_SKIP_BITS(state_index, n) \
- do { \
- if (num_bits < (mz_uint)(n)) { \
- TINFL_NEED_BITS(state_index, n); \
- } \
- bit_buf >>= (n); \
- num_bits -= (n); \
- } \
- MZ_MACRO_END
-#define TINFL_GET_BITS(state_index, b, n) \
- do { \
- if (num_bits < (mz_uint)(n)) { \
- TINFL_NEED_BITS(state_index, n); \
- } \
- b = bit_buf & ((1 << (n)) - 1); \
- bit_buf >>= (n); \
- num_bits -= (n); \
- } \
- MZ_MACRO_END
-
-/* TINFL_HUFF_BITBUF_FILL() is only used rarely, when the number of bytes
- * remaining in the input buffer falls below 2. */
-/* It reads just enough bytes from the input stream that are needed to decode
- * the next Huffman code (and absolutely no more). It works by trying to fully
- * decode a */
-/* Huffman code by using whatever bits are currently present in the bit buffer.
- * If this fails, it reads another byte, and tries again until it succeeds or
- * until the */
-/* bit buffer contains >=15 bits (deflate's max. Huffman code size). */
-#define TINFL_HUFF_BITBUF_FILL(state_index, pHuff) \
- do { \
- temp = (pHuff)->m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]; \
- if (temp >= 0) { \
- code_len = temp >> 9; \
- if ((code_len) && (num_bits >= code_len)) \
- break; \
- } else if (num_bits > TINFL_FAST_LOOKUP_BITS) { \
- code_len = TINFL_FAST_LOOKUP_BITS; \
- do { \
- temp = (pHuff)->m_tree[~temp + ((bit_buf >> code_len++) & 1)]; \
- } while ((temp < 0) && (num_bits >= (code_len + 1))); \
- if (temp >= 0) \
- break; \
- } \
- TINFL_GET_BYTE(state_index, c); \
- bit_buf |= (((tinfl_bit_buf_t)c) << num_bits); \
- num_bits += 8; \
- } while (num_bits < 15);
-
-/* TINFL_HUFF_DECODE() decodes the next Huffman coded symbol. It's more complex
- * than you would initially expect because the zlib API expects the decompressor
- * to never read */
-/* beyond the final byte of the deflate stream. (In other words, when this macro
- * wants to read another byte from the input, it REALLY needs another byte in
- * order to fully */
-/* decode the next Huffman code.) Handling this properly is particularly
- * important on raw deflate (non-zlib) streams, which aren't followed by a byte
- * aligned adler-32. */
-/* The slow path is only executed at the very end of the input buffer. */
-/* v1.16: The original macro handled the case at the very end of the passed-in
- * input buffer, but we also need to handle the case where the user passes in
- * 1+zillion bytes */
-/* following the deflate data and our non-conservative read-ahead path won't
- * kick in here on this code. This is much trickier. */
-#define TINFL_HUFF_DECODE(state_index, sym, pHuff) \
- do { \
- int temp; \
- mz_uint code_len, c; \
- if (num_bits < 15) { \
- if ((pIn_buf_end - pIn_buf_cur) < 2) { \
- TINFL_HUFF_BITBUF_FILL(state_index, pHuff); \
- } else { \
- bit_buf |= (((tinfl_bit_buf_t)pIn_buf_cur[0]) << num_bits) | \
- (((tinfl_bit_buf_t)pIn_buf_cur[1]) << (num_bits + 8)); \
- pIn_buf_cur += 2; \
- num_bits += 16; \
- } \
- } \
- if ((temp = (pHuff)->m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]) >= \
- 0) \
- code_len = temp >> 9, temp &= 511; \
- else { \
- code_len = TINFL_FAST_LOOKUP_BITS; \
- do { \
- temp = (pHuff)->m_tree[~temp + ((bit_buf >> code_len++) & 1)]; \
- } while (temp < 0); \
- } \
- sym = temp; \
- bit_buf >>= code_len; \
- num_bits -= code_len; \
- } \
- MZ_MACRO_END
-
-tinfl_status tinfl_decompress(tinfl_decompressor *r,
- const mz_uint8 *pIn_buf_next,
- size_t *pIn_buf_size, mz_uint8 *pOut_buf_start,
- mz_uint8 *pOut_buf_next, size_t *pOut_buf_size,
- const mz_uint32 decomp_flags) {
- static const int s_length_base[31] = {
- 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31,
- 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, 0, 0};
- static const int s_length_extra[31] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1,
- 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4,
- 4, 4, 5, 5, 5, 5, 0, 0, 0};
- static const int s_dist_base[32] = {
- 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33,
- 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537,
- 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577, 0, 0};
- static const int s_dist_extra[32] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3,
- 4, 4, 5, 5, 6, 6, 7, 7, 8, 8,
- 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
- static const mz_uint8 s_length_dezigzag[19] = {
- 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
- static const int s_min_table_sizes[3] = {257, 1, 4};
-
- tinfl_status status = TINFL_STATUS_FAILED;
- mz_uint32 num_bits, dist, counter, num_extra;
- tinfl_bit_buf_t bit_buf;
- const mz_uint8 *pIn_buf_cur = pIn_buf_next, *const pIn_buf_end =
- pIn_buf_next + *pIn_buf_size;
- mz_uint8 *pOut_buf_cur = pOut_buf_next, *const pOut_buf_end =
- pOut_buf_next + *pOut_buf_size;
- size_t out_buf_size_mask =
- (decomp_flags & TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF)
- ? (size_t)-1
- : ((pOut_buf_next - pOut_buf_start) + *pOut_buf_size) - 1,
- dist_from_out_buf_start;
-
- /* Ensure the output buffer's size is a power of 2, unless the output buffer
- * is large enough to hold the entire output file (in which case it doesn't
- * matter). */
- if (((out_buf_size_mask + 1) & out_buf_size_mask) ||
- (pOut_buf_next < pOut_buf_start)) {
- *pIn_buf_size = *pOut_buf_size = 0;
- return TINFL_STATUS_BAD_PARAM;
- }
-
- num_bits = r->m_num_bits;
- bit_buf = r->m_bit_buf;
- dist = r->m_dist;
- counter = r->m_counter;
- num_extra = r->m_num_extra;
- dist_from_out_buf_start = r->m_dist_from_out_buf_start;
- TINFL_CR_BEGIN
-
- bit_buf = num_bits = dist = counter = num_extra = r->m_zhdr0 = r->m_zhdr1 = 0;
- r->m_z_adler32 = r->m_check_adler32 = 1;
- if (decomp_flags & TINFL_FLAG_PARSE_ZLIB_HEADER) {
- TINFL_GET_BYTE(1, r->m_zhdr0);
- TINFL_GET_BYTE(2, r->m_zhdr1);
- counter = (((r->m_zhdr0 * 256 + r->m_zhdr1) % 31 != 0) ||
- (r->m_zhdr1 & 32) || ((r->m_zhdr0 & 15) != 8));
- if (!(decomp_flags & TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF))
- counter |= (((1U << (8U + (r->m_zhdr0 >> 4))) > 32768U) ||
- ((out_buf_size_mask + 1) <
- (size_t)(1U << (8U + (r->m_zhdr0 >> 4)))));
- if (counter) {
- TINFL_CR_RETURN_FOREVER(36, TINFL_STATUS_FAILED);
- }
- }
-
- do {
- TINFL_GET_BITS(3, r->m_final, 3);
- r->m_type = r->m_final >> 1;
- if (r->m_type == 0) {
- TINFL_SKIP_BITS(5, num_bits & 7);
- for (counter = 0; counter < 4; ++counter) {
- if (num_bits)
- TINFL_GET_BITS(6, r->m_raw_header[counter], 8);
- else
- TINFL_GET_BYTE(7, r->m_raw_header[counter]);
- }
- if ((counter = (r->m_raw_header[0] | (r->m_raw_header[1] << 8))) !=
- (mz_uint)(0xFFFF ^
- (r->m_raw_header[2] | (r->m_raw_header[3] << 8)))) {
- TINFL_CR_RETURN_FOREVER(39, TINFL_STATUS_FAILED);
- }
- while ((counter) && (num_bits)) {
- TINFL_GET_BITS(51, dist, 8);
- while (pOut_buf_cur >= pOut_buf_end) {
- TINFL_CR_RETURN(52, TINFL_STATUS_HAS_MORE_OUTPUT);
- }
- *pOut_buf_cur++ = (mz_uint8)dist;
- counter--;
- }
- while (counter) {
- size_t n;
- while (pOut_buf_cur >= pOut_buf_end) {
- TINFL_CR_RETURN(9, TINFL_STATUS_HAS_MORE_OUTPUT);
- }
- while (pIn_buf_cur >= pIn_buf_end) {
- TINFL_CR_RETURN(38, (decomp_flags & TINFL_FLAG_HAS_MORE_INPUT)
- ? TINFL_STATUS_NEEDS_MORE_INPUT
- : TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS);
- }
- n = MZ_MIN(MZ_MIN((size_t)(pOut_buf_end - pOut_buf_cur),
- (size_t)(pIn_buf_end - pIn_buf_cur)),
- counter);
- TINFL_MEMCPY(pOut_buf_cur, pIn_buf_cur, n);
- pIn_buf_cur += n;
- pOut_buf_cur += n;
- counter -= (mz_uint)n;
- }
- } else if (r->m_type == 3) {
- TINFL_CR_RETURN_FOREVER(10, TINFL_STATUS_FAILED);
- } else {
- if (r->m_type == 1) {
- mz_uint8 *p = r->m_tables[0].m_code_size;
- mz_uint i;
- r->m_table_sizes[0] = 288;
- r->m_table_sizes[1] = 32;
- TINFL_MEMSET(r->m_tables[1].m_code_size, 5, 32);
- for (i = 0; i <= 143; ++i)
- *p++ = 8;
- for (; i <= 255; ++i)
- *p++ = 9;
- for (; i <= 279; ++i)
- *p++ = 7;
- for (; i <= 287; ++i)
- *p++ = 8;
- } else {
- for (counter = 0; counter < 3; counter++) {
- TINFL_GET_BITS(11, r->m_table_sizes[counter], "\05\05\04"[counter]);
- r->m_table_sizes[counter] += s_min_table_sizes[counter];
- }
- MZ_CLEAR_OBJ(r->m_tables[2].m_code_size);
- for (counter = 0; counter < r->m_table_sizes[2]; counter++) {
- mz_uint s;
- TINFL_GET_BITS(14, s, 3);
- r->m_tables[2].m_code_size[s_length_dezigzag[counter]] = (mz_uint8)s;
- }
- r->m_table_sizes[2] = 19;
- }
- for (; (int)r->m_type >= 0; r->m_type--) {
- int tree_next, tree_cur;
- tinfl_huff_table *pTable;
- mz_uint i, j, used_syms, total, sym_index, next_code[17],
- total_syms[16];
- pTable = &r->m_tables[r->m_type];
- MZ_CLEAR_OBJ(total_syms);
- MZ_CLEAR_OBJ(pTable->m_look_up);
- MZ_CLEAR_OBJ(pTable->m_tree);
- for (i = 0; i < r->m_table_sizes[r->m_type]; ++i)
- total_syms[pTable->m_code_size[i]]++;
- used_syms = 0, total = 0;
- next_code[0] = next_code[1] = 0;
- for (i = 1; i <= 15; ++i) {
- used_syms += total_syms[i];
- next_code[i + 1] = (total = ((total + total_syms[i]) << 1));
- }
- if ((65536 != total) && (used_syms > 1)) {
- TINFL_CR_RETURN_FOREVER(35, TINFL_STATUS_FAILED);
- }
- for (tree_next = -1, sym_index = 0;
- sym_index < r->m_table_sizes[r->m_type]; ++sym_index) {
- mz_uint rev_code = 0, l, cur_code,
- code_size = pTable->m_code_size[sym_index];
-
- if (!code_size)
- continue;
- cur_code = next_code[code_size]++;
- for (l = code_size; l > 0; l--, cur_code >>= 1)
- rev_code = (rev_code << 1) | (cur_code & 1);
- if (code_size <= TINFL_FAST_LOOKUP_BITS) {
- mz_int16 k = (mz_int16)((code_size << 9) | sym_index);
- while (rev_code < TINFL_FAST_LOOKUP_SIZE) {
- pTable->m_look_up[rev_code] = k;
- rev_code += (1 << code_size);
- }
- continue;
- }
- if (0 ==
- (tree_cur = pTable->m_look_up[rev_code &
- (TINFL_FAST_LOOKUP_SIZE - 1)])) {
- pTable->m_look_up[rev_code & (TINFL_FAST_LOOKUP_SIZE - 1)] =
- (mz_int16)tree_next;
- tree_cur = tree_next;
- tree_next -= 2;
- }
- rev_code >>= (TINFL_FAST_LOOKUP_BITS - 1);
- for (j = code_size; j > (TINFL_FAST_LOOKUP_BITS + 1); j--) {
- tree_cur -= ((rev_code >>= 1) & 1);
- if (!pTable->m_tree[-tree_cur - 1]) {
- pTable->m_tree[-tree_cur - 1] = (mz_int16)tree_next;
- tree_cur = tree_next;
- tree_next -= 2;
- } else
- tree_cur = pTable->m_tree[-tree_cur - 1];
- }
- tree_cur -= ((rev_code >>= 1) & 1);
- (void)rev_code; // unused
- pTable->m_tree[-tree_cur - 1] = (mz_int16)sym_index;
- }
- if (r->m_type == 2) {
- for (counter = 0;
- counter < (r->m_table_sizes[0] + r->m_table_sizes[1]);) {
- mz_uint s;
- TINFL_HUFF_DECODE(16, dist, &r->m_tables[2]);
- if (dist < 16) {
- r->m_len_codes[counter++] = (mz_uint8)dist;
- continue;
- }
- if ((dist == 16) && (!counter)) {
- TINFL_CR_RETURN_FOREVER(17, TINFL_STATUS_FAILED);
- }
- num_extra = "\02\03\07"[dist - 16];
- TINFL_GET_BITS(18, s, num_extra);
- s += "\03\03\013"[dist - 16];
- TINFL_MEMSET(r->m_len_codes + counter,
- (dist == 16) ? r->m_len_codes[counter - 1] : 0, s);
- counter += s;
- }
- if ((r->m_table_sizes[0] + r->m_table_sizes[1]) != counter) {
- TINFL_CR_RETURN_FOREVER(21, TINFL_STATUS_FAILED);
- }
- TINFL_MEMCPY(r->m_tables[0].m_code_size, r->m_len_codes,
- r->m_table_sizes[0]);
- TINFL_MEMCPY(r->m_tables[1].m_code_size,
- r->m_len_codes + r->m_table_sizes[0],
- r->m_table_sizes[1]);
- }
- }
- for (;;) {
- mz_uint8 *pSrc;
- for (;;) {
- if (((pIn_buf_end - pIn_buf_cur) < 4) ||
- ((pOut_buf_end - pOut_buf_cur) < 2)) {
- TINFL_HUFF_DECODE(23, counter, &r->m_tables[0]);
- if (counter >= 256)
- break;
- while (pOut_buf_cur >= pOut_buf_end) {
- TINFL_CR_RETURN(24, TINFL_STATUS_HAS_MORE_OUTPUT);
- }
- *pOut_buf_cur++ = (mz_uint8)counter;
- } else {
- int sym2;
- mz_uint code_len;
-#if TINFL_USE_64BIT_BITBUF
- if (num_bits < 30) {
- bit_buf |=
- (((tinfl_bit_buf_t)MZ_READ_LE32(pIn_buf_cur)) << num_bits);
- pIn_buf_cur += 4;
- num_bits += 32;
- }
-#else
- if (num_bits < 15) {
- bit_buf |=
- (((tinfl_bit_buf_t)MZ_READ_LE16(pIn_buf_cur)) << num_bits);
- pIn_buf_cur += 2;
- num_bits += 16;
- }
-#endif
- if ((sym2 =
- r->m_tables[0]
- .m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]) >=
- 0)
- code_len = sym2 >> 9;
- else {
- code_len = TINFL_FAST_LOOKUP_BITS;
- do {
- sym2 = r->m_tables[0]
- .m_tree[~sym2 + ((bit_buf >> code_len++) & 1)];
- } while (sym2 < 0);
- }
- counter = sym2;
- bit_buf >>= code_len;
- num_bits -= code_len;
- if (counter & 256)
- break;
-
-#if !TINFL_USE_64BIT_BITBUF
- if (num_bits < 15) {
- bit_buf |=
- (((tinfl_bit_buf_t)MZ_READ_LE16(pIn_buf_cur)) << num_bits);
- pIn_buf_cur += 2;
- num_bits += 16;
- }
-#endif
- if ((sym2 =
- r->m_tables[0]
- .m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]) >=
- 0)
- code_len = sym2 >> 9;
- else {
- code_len = TINFL_FAST_LOOKUP_BITS;
- do {
- sym2 = r->m_tables[0]
- .m_tree[~sym2 + ((bit_buf >> code_len++) & 1)];
- } while (sym2 < 0);
- }
- bit_buf >>= code_len;
- num_bits -= code_len;
-
- pOut_buf_cur[0] = (mz_uint8)counter;
- if (sym2 & 256) {
- pOut_buf_cur++;
- counter = sym2;
- break;
- }
- pOut_buf_cur[1] = (mz_uint8)sym2;
- pOut_buf_cur += 2;
- }
- }
- if ((counter &= 511) == 256)
- break;
-
- num_extra = s_length_extra[counter - 257];
- counter = s_length_base[counter - 257];
- if (num_extra) {
- mz_uint extra_bits;
- TINFL_GET_BITS(25, extra_bits, num_extra);
- counter += extra_bits;
- }
-
- TINFL_HUFF_DECODE(26, dist, &r->m_tables[1]);
- num_extra = s_dist_extra[dist];
- dist = s_dist_base[dist];
- if (num_extra) {
- mz_uint extra_bits;
- TINFL_GET_BITS(27, extra_bits, num_extra);
- dist += extra_bits;
- }
-
- dist_from_out_buf_start = pOut_buf_cur - pOut_buf_start;
- if ((dist == 0 || dist > dist_from_out_buf_start ||
- dist_from_out_buf_start == 0) &&
- (decomp_flags & TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF)) {
- TINFL_CR_RETURN_FOREVER(37, TINFL_STATUS_FAILED);
- }
-
- pSrc = pOut_buf_start +
- ((dist_from_out_buf_start - dist) & out_buf_size_mask);
-
- if ((MZ_MAX(pOut_buf_cur, pSrc) + counter) > pOut_buf_end) {
- while (counter--) {
- while (pOut_buf_cur >= pOut_buf_end) {
- TINFL_CR_RETURN(53, TINFL_STATUS_HAS_MORE_OUTPUT);
- }
- *pOut_buf_cur++ =
- pOut_buf_start[(dist_from_out_buf_start++ - dist) &
- out_buf_size_mask];
- }
- continue;
- }
-#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES
- else if ((counter >= 9) && (counter <= dist)) {
- const mz_uint8 *pSrc_end = pSrc + (counter & ~7);
- do {
-#ifdef MINIZ_UNALIGNED_USE_MEMCPY
- memcpy(pOut_buf_cur, pSrc, sizeof(mz_uint32) * 2);
-#else
- ((mz_uint32 *)pOut_buf_cur)[0] = ((const mz_uint32 *)pSrc)[0];
- ((mz_uint32 *)pOut_buf_cur)[1] = ((const mz_uint32 *)pSrc)[1];
-#endif
- pOut_buf_cur += 8;
- } while ((pSrc += 8) < pSrc_end);
- if ((counter &= 7) < 3) {
- if (counter) {
- pOut_buf_cur[0] = pSrc[0];
- if (counter > 1)
- pOut_buf_cur[1] = pSrc[1];
- pOut_buf_cur += counter;
- }
- continue;
- }
- }
-#endif
- while (counter > 2) {
- pOut_buf_cur[0] = pSrc[0];
- pOut_buf_cur[1] = pSrc[1];
- pOut_buf_cur[2] = pSrc[2];
- pOut_buf_cur += 3;
- pSrc += 3;
- counter -= 3;
- }
- if (counter > 0) {
- pOut_buf_cur[0] = pSrc[0];
- if (counter > 1)
- pOut_buf_cur[1] = pSrc[1];
- pOut_buf_cur += counter;
- }
- }
- }
- } while (!(r->m_final & 1));
-
- /* Ensure byte alignment and put back any bytes from the bitbuf if we've
- * looked ahead too far on gzip, or other Deflate streams followed by
- * arbitrary data. */
- /* I'm being super conservative here. A number of simplifications can be made
- * to the byte alignment part, and the Adler32 check shouldn't ever need to
- * worry about reading from the bitbuf now. */
- TINFL_SKIP_BITS(32, num_bits & 7);
- while ((pIn_buf_cur > pIn_buf_next) && (num_bits >= 8)) {
- --pIn_buf_cur;
- num_bits -= 8;
- }
- bit_buf &= (tinfl_bit_buf_t)((((mz_uint64)1) << num_bits) - (mz_uint64)1);
- MZ_ASSERT(!num_bits); /* if this assert fires then we've read beyond the end
- of non-deflate/zlib streams with following data (such
- as gzip streams). */
-
- if (decomp_flags & TINFL_FLAG_PARSE_ZLIB_HEADER) {
- for (counter = 0; counter < 4; ++counter) {
- mz_uint s;
- if (num_bits)
- TINFL_GET_BITS(41, s, 8);
- else
- TINFL_GET_BYTE(42, s);
- r->m_z_adler32 = (r->m_z_adler32 << 8) | s;
- }
- }
- TINFL_CR_RETURN_FOREVER(34, TINFL_STATUS_DONE);
-
- TINFL_CR_FINISH
-
-common_exit:
- /* As long as we aren't telling the caller that we NEED more input to make
- * forward progress: */
- /* Put back any bytes from the bitbuf in case we've looked ahead too far on
- * gzip, or other Deflate streams followed by arbitrary data. */
- /* We need to be very careful here to NOT push back any bytes we definitely
- * know we need to make forward progress, though, or we'll lock the caller up
- * into an inf loop. */
- if ((status != TINFL_STATUS_NEEDS_MORE_INPUT) &&
- (status != TINFL_STATUS_FAILED_CANNOT_MAKE_PROGRESS)) {
- while ((pIn_buf_cur > pIn_buf_next) && (num_bits >= 8)) {
- --pIn_buf_cur;
- num_bits -= 8;
- }
- }
- r->m_num_bits = num_bits;
- r->m_bit_buf =
- bit_buf & (tinfl_bit_buf_t)((((mz_uint64)1) << num_bits) - (mz_uint64)1);
- r->m_dist = dist;
- r->m_counter = counter;
- r->m_num_extra = num_extra;
- r->m_dist_from_out_buf_start = dist_from_out_buf_start;
- *pIn_buf_size = pIn_buf_cur - pIn_buf_next;
- *pOut_buf_size = pOut_buf_cur - pOut_buf_next;
- if ((decomp_flags &
- (TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_COMPUTE_ADLER32)) &&
- (status >= 0)) {
- const mz_uint8 *ptr = pOut_buf_next;
- size_t buf_len = *pOut_buf_size;
- mz_uint32 i, s1 = r->m_check_adler32 & 0xffff,
- s2 = r->m_check_adler32 >> 16;
- size_t block_len = buf_len % 5552;
- while (buf_len) {
- for (i = 0; i + 7 < block_len; i += 8, ptr += 8) {
- s1 += ptr[0], s2 += s1;
- s1 += ptr[1], s2 += s1;
- s1 += ptr[2], s2 += s1;
- s1 += ptr[3], s2 += s1;
- s1 += ptr[4], s2 += s1;
- s1 += ptr[5], s2 += s1;
- s1 += ptr[6], s2 += s1;
- s1 += ptr[7], s2 += s1;
- }
- for (; i < block_len; ++i)
- s1 += *ptr++, s2 += s1;
- s1 %= 65521U, s2 %= 65521U;
- buf_len -= block_len;
- block_len = 5552;
- }
- r->m_check_adler32 = (s2 << 16) + s1;
- if ((status == TINFL_STATUS_DONE) &&
- (decomp_flags & TINFL_FLAG_PARSE_ZLIB_HEADER) &&
- (r->m_check_adler32 != r->m_z_adler32))
- status = TINFL_STATUS_ADLER32_MISMATCH;
- }
- return status;
-}
-
-/* Higher level helper functions. */
-void *tinfl_decompress_mem_to_heap(const void *pSrc_buf, size_t src_buf_len,
- size_t *pOut_len, int flags) {
- tinfl_decompressor decomp;
- void *pBuf = NULL, *pNew_buf;
- size_t src_buf_ofs = 0, out_buf_capacity = 0;
- *pOut_len = 0;
- tinfl_init(&decomp);
- for (;;) {
- size_t src_buf_size = src_buf_len - src_buf_ofs,
- dst_buf_size = out_buf_capacity - *pOut_len, new_out_buf_capacity;
- tinfl_status status = tinfl_decompress(
- &decomp, (const mz_uint8 *)pSrc_buf + src_buf_ofs, &src_buf_size,
- (mz_uint8 *)pBuf, pBuf ? (mz_uint8 *)pBuf + *pOut_len : NULL,
- &dst_buf_size,
- (flags & ~TINFL_FLAG_HAS_MORE_INPUT) |
- TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
- if ((status < 0) || (status == TINFL_STATUS_NEEDS_MORE_INPUT)) {
- MZ_FREE(pBuf);
- *pOut_len = 0;
- return NULL;
- }
- src_buf_ofs += src_buf_size;
- *pOut_len += dst_buf_size;
- if (status == TINFL_STATUS_DONE)
- break;
- new_out_buf_capacity = out_buf_capacity * 2;
- if (new_out_buf_capacity < 128)
- new_out_buf_capacity = 128;
- pNew_buf = MZ_REALLOC(pBuf, new_out_buf_capacity);
- if (!pNew_buf) {
- MZ_FREE(pBuf);
- *pOut_len = 0;
- return NULL;
- }
- pBuf = pNew_buf;
- out_buf_capacity = new_out_buf_capacity;
- }
- return pBuf;
-}
-
-size_t tinfl_decompress_mem_to_mem(void *pOut_buf, size_t out_buf_len,
- const void *pSrc_buf, size_t src_buf_len,
- int flags) {
- tinfl_decompressor decomp;
- tinfl_status status;
- tinfl_init(&decomp);
- status =
- tinfl_decompress(&decomp, (const mz_uint8 *)pSrc_buf, &src_buf_len,
- (mz_uint8 *)pOut_buf, (mz_uint8 *)pOut_buf, &out_buf_len,
- (flags & ~TINFL_FLAG_HAS_MORE_INPUT) |
- TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
- return (status != TINFL_STATUS_DONE) ? TINFL_DECOMPRESS_MEM_TO_MEM_FAILED
- : out_buf_len;
-}
-
-int tinfl_decompress_mem_to_callback(const void *pIn_buf, size_t *pIn_buf_size,
- tinfl_put_buf_func_ptr pPut_buf_func,
- void *pPut_buf_user, int flags) {
- int result = 0;
- tinfl_decompressor decomp;
- mz_uint8 *pDict = (mz_uint8 *)MZ_MALLOC(TINFL_LZ_DICT_SIZE);
- size_t in_buf_ofs = 0, dict_ofs = 0;
- if (!pDict)
- return TINFL_STATUS_FAILED;
- tinfl_init(&decomp);
- for (;;) {
- size_t in_buf_size = *pIn_buf_size - in_buf_ofs,
- dst_buf_size = TINFL_LZ_DICT_SIZE - dict_ofs;
- tinfl_status status =
- tinfl_decompress(&decomp, (const mz_uint8 *)pIn_buf + in_buf_ofs,
- &in_buf_size, pDict, pDict + dict_ofs, &dst_buf_size,
- (flags & ~(TINFL_FLAG_HAS_MORE_INPUT |
- TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF)));
- in_buf_ofs += in_buf_size;
- if ((dst_buf_size) &&
- (!(*pPut_buf_func)(pDict + dict_ofs, (int)dst_buf_size, pPut_buf_user)))
- break;
- if (status != TINFL_STATUS_HAS_MORE_OUTPUT) {
- result = (status == TINFL_STATUS_DONE);
- break;
- }
- dict_ofs = (dict_ofs + dst_buf_size) & (TINFL_LZ_DICT_SIZE - 1);
- }
- MZ_FREE(pDict);
- *pIn_buf_size = in_buf_ofs;
- return result;
-}
-
-#ifndef MINIZ_NO_MALLOC
-tinfl_decompressor *tinfl_decompressor_alloc(void) {
- tinfl_decompressor *pDecomp =
- (tinfl_decompressor *)MZ_MALLOC(sizeof(tinfl_decompressor));
- if (pDecomp)
- tinfl_init(pDecomp);
- return pDecomp;
-}
-
-void tinfl_decompressor_free(tinfl_decompressor *pDecomp) { MZ_FREE(pDecomp); }
-#endif
-
-#ifdef __cplusplus
-}
-#endif
-/**************************************************************************
- *
- * Copyright 2013-2014 RAD Game Tools and Valve Software
- * Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC
- * Copyright 2016 Martin Raiber
- * All Rights Reserved.
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- **************************************************************************/
-
-#ifndef MINIZ_NO_ARCHIVE_APIS
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/* ------------------- .ZIP archive reading */
-
-#ifdef MINIZ_NO_STDIO
-#define MZ_FILE void *
-#else
-#include
-
-#if defined(_MSC_VER)
-#include
-#ifndef MINIZ_NO_TIME
-#include
-#endif
-static wchar_t *str2wstr(const char *str) {
- size_t len = strlen(str) + 1;
- wchar_t *wstr = (wchar_t *)malloc(len * sizeof(wchar_t));
- MultiByteToWideChar(CP_UTF8, 0, str, (int)(len * sizeof(char)), wstr,
- (int)len);
- return wstr;
-}
-
-static FILE *mz_fopen(const char *pFilename, const char *pMode) {
- FILE *pFile = NULL;
- wchar_t *wFilename = str2wstr(pFilename);
- wchar_t *wMode = str2wstr(pMode);
-
-#ifdef ZIP_ENABLE_SHARABLE_FILE_OPEN
- pFile = _wfopen(wFilename, wMode);
-#else
- _wfopen_s(&pFile, wFilename, wMode);
-#endif
- free(wFilename);
- free(wMode);
-
- return pFile;
-}
-
-static FILE *mz_freopen(const char *pPath, const char *pMode, FILE *pStream) {
- FILE *pFile = NULL;
- int res = 0;
-
- wchar_t *wPath = str2wstr(pPath);
- wchar_t *wMode = str2wstr(pMode);
-
-#ifdef ZIP_ENABLE_SHARABLE_FILE_OPEN
- pFile = _wfreopen(wPath, wMode, pStream);
-#else
- res = _wfreopen_s(&pFile, wPath, wMode, pStream);
-#endif
-
- free(wPath);
- free(wMode);
-
-#ifndef ZIP_ENABLE_SHARABLE_FILE_OPEN
- if (res) {
- return NULL;
- }
-#endif
-
- return pFile;
-}
-
-static int mz_stat(const char *pPath, struct _stat64 *buffer) {
- wchar_t *wPath = str2wstr(pPath);
- int res = _wstat64(wPath, buffer);
-
- free(wPath);
-
- return res;
-}
-
-static int mz_mkdir(const char *pDirname) {
- wchar_t *wDirname = str2wstr(pDirname);
- int res = _wmkdir(wDirname);
-
- free(wDirname);
-
- return res;
-}
-
-#define MZ_FOPEN mz_fopen
-#define MZ_FCLOSE fclose
-#define MZ_FREAD fread
-#define MZ_FWRITE fwrite
-#define MZ_FTELL64 _ftelli64
-#define MZ_FSEEK64 _fseeki64
-#define MZ_FILE_STAT_STRUCT _stat64
-#define MZ_FILE_STAT mz_stat
-#define MZ_FFLUSH fflush
-#define MZ_FREOPEN mz_freopen
-#define MZ_DELETE_FILE remove
-#define MZ_MKDIR(d) mz_mkdir(d)
-
-#elif defined(__MINGW32__) || defined(__MINGW64__)
-#include
-#ifndef MINIZ_NO_TIME
-#include
-#endif
-
-#define MZ_FOPEN(f, m) fopen(f, m)
-#define MZ_FCLOSE fclose
-#define MZ_FREAD fread
-#define MZ_FWRITE fwrite
-#define MZ_FTELL64 ftell
-#define MZ_FSEEK64 fseek
-#define MZ_FILE_STAT_STRUCT stat
-#define MZ_FILE_STAT stat
-#define MZ_FFLUSH fflush
-#define MZ_FREOPEN(f, m, s) freopen(f, m, s)
-#define MZ_DELETE_FILE remove
-#define MZ_MKDIR(d) _mkdir(d)
-
-#elif defined(__TINYC__)
-#ifndef MINIZ_NO_TIME
-#include
-#endif
-
-#define MZ_FOPEN(f, m) fopen(f, m)
-#define MZ_FCLOSE fclose
-#define MZ_FREAD fread
-#define MZ_FWRITE fwrite
-#define MZ_FTELL64 ftell
-#define MZ_FSEEK64 fseek
-#define MZ_FILE_STAT_STRUCT stat
-#define MZ_FILE_STAT stat
-#define MZ_FFLUSH fflush
-#define MZ_FREOPEN(f, m, s) freopen(f, m, s)
-#define MZ_DELETE_FILE remove
-#if defined(_WIN32) || defined(_WIN64)
-#define MZ_MKDIR(d) _mkdir(d)
-#else
-#define MZ_MKDIR(d) mkdir(d, 0755)
-#endif
-
-#elif defined(__USE_LARGEFILE64) /* gcc, clang */
-#ifndef MINIZ_NO_TIME
-#include
-#endif
-
-#define MZ_FOPEN(f, m) fopen64(f, m)
-#define MZ_FCLOSE fclose
-#define MZ_FREAD fread
-#define MZ_FWRITE fwrite
-#define MZ_FTELL64 ftello64
-#define MZ_FSEEK64 fseeko64
-#define MZ_FILE_STAT_STRUCT stat64
-#define MZ_FILE_STAT stat64
-#define MZ_FFLUSH fflush
-#define MZ_FREOPEN(p, m, s) freopen64(p, m, s)
-#define MZ_DELETE_FILE remove
-#define MZ_MKDIR(d) mkdir(d, 0755)
-
-#elif defined(__APPLE__)
-#ifndef MINIZ_NO_TIME
-#include
-#endif
-
-#define MZ_FOPEN(f, m) fopen(f, m)
-#define MZ_FCLOSE fclose
-#define MZ_FREAD fread
-#define MZ_FWRITE fwrite
-#define MZ_FTELL64 ftello
-#define MZ_FSEEK64 fseeko
-#define MZ_FILE_STAT_STRUCT stat
-#define MZ_FILE_STAT stat
-#define MZ_FFLUSH fflush
-#define MZ_FREOPEN(p, m, s) freopen(p, m, s)
-#define MZ_DELETE_FILE remove
-#define MZ_MKDIR(d) mkdir(d, 0755)
-
-#else
-#pragma message( \
- "Using fopen, ftello, fseeko, stat() etc. path for file I/O - this path may not support large files.")
-#ifndef MINIZ_NO_TIME
-#include
-#endif
-
-#define MZ_FOPEN(f, m) fopen(f, m)
-#define MZ_FCLOSE fclose
-#define MZ_FREAD fread
-#define MZ_FWRITE fwrite
-#ifdef __STRICT_ANSI__
-#define MZ_FTELL64 ftell
-#define MZ_FSEEK64 fseek
-#else
-#define MZ_FTELL64 ftello
-#define MZ_FSEEK64 fseeko
-#endif
-#define MZ_FILE_STAT_STRUCT stat
-#define MZ_FILE_STAT stat
-#define MZ_FFLUSH fflush
-#define MZ_FREOPEN(f, m, s) freopen(f, m, s)
-#define MZ_DELETE_FILE remove
-#define MZ_MKDIR(d) mkdir(d, 0755)
-
-#endif /* #ifdef _MSC_VER */
-#endif /* #ifdef MINIZ_NO_STDIO */
-
-#ifndef CHMOD
-// Upon successful completion, a value of 0 is returned.
-// Otherwise, a value of -1 is returned and errno is set to indicate the error.
-// int chmod(const char *path, mode_t mode);
-#define CHMOD(f, m) chmod(f, m)
-#endif
-
-#define MZ_TOLOWER(c) ((((c) >= 'A') && ((c) <= 'Z')) ? ((c) - 'A' + 'a') : (c))
-
-/* Various ZIP archive enums. To completely avoid cross platform compiler
- * alignment and platform endian issues, miniz.c doesn't use structs for any of
- * this stuff. */
-enum {
- /* ZIP archive identifiers and record sizes */
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIG = 0x06054b50,
- MZ_ZIP_CENTRAL_DIR_HEADER_SIG = 0x02014b50,
- MZ_ZIP_LOCAL_DIR_HEADER_SIG = 0x04034b50,
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE = 30,
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE = 46,
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE = 22,
-
- /* ZIP64 archive identifier and record sizes */
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIG = 0x06064b50,
- MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIG = 0x07064b50,
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE = 56,
- MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE = 20,
- MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID = 0x0001,
- MZ_ZIP_DATA_DESCRIPTOR_ID = 0x08074b50,
- MZ_ZIP_DATA_DESCRIPTER_SIZE64 = 24,
- MZ_ZIP_DATA_DESCRIPTER_SIZE32 = 16,
-
- /* Central directory header record offsets */
- MZ_ZIP_CDH_SIG_OFS = 0,
- MZ_ZIP_CDH_VERSION_MADE_BY_OFS = 4,
- MZ_ZIP_CDH_VERSION_NEEDED_OFS = 6,
- MZ_ZIP_CDH_BIT_FLAG_OFS = 8,
- MZ_ZIP_CDH_METHOD_OFS = 10,
- MZ_ZIP_CDH_FILE_TIME_OFS = 12,
- MZ_ZIP_CDH_FILE_DATE_OFS = 14,
- MZ_ZIP_CDH_CRC32_OFS = 16,
- MZ_ZIP_CDH_COMPRESSED_SIZE_OFS = 20,
- MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS = 24,
- MZ_ZIP_CDH_FILENAME_LEN_OFS = 28,
- MZ_ZIP_CDH_EXTRA_LEN_OFS = 30,
- MZ_ZIP_CDH_COMMENT_LEN_OFS = 32,
- MZ_ZIP_CDH_DISK_START_OFS = 34,
- MZ_ZIP_CDH_INTERNAL_ATTR_OFS = 36,
- MZ_ZIP_CDH_EXTERNAL_ATTR_OFS = 38,
- MZ_ZIP_CDH_LOCAL_HEADER_OFS = 42,
-
- /* Local directory header offsets */
- MZ_ZIP_LDH_SIG_OFS = 0,
- MZ_ZIP_LDH_VERSION_NEEDED_OFS = 4,
- MZ_ZIP_LDH_BIT_FLAG_OFS = 6,
- MZ_ZIP_LDH_METHOD_OFS = 8,
- MZ_ZIP_LDH_FILE_TIME_OFS = 10,
- MZ_ZIP_LDH_FILE_DATE_OFS = 12,
- MZ_ZIP_LDH_CRC32_OFS = 14,
- MZ_ZIP_LDH_COMPRESSED_SIZE_OFS = 18,
- MZ_ZIP_LDH_DECOMPRESSED_SIZE_OFS = 22,
- MZ_ZIP_LDH_FILENAME_LEN_OFS = 26,
- MZ_ZIP_LDH_EXTRA_LEN_OFS = 28,
- MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR = 1 << 3,
-
- /* End of central directory offsets */
- MZ_ZIP_ECDH_SIG_OFS = 0,
- MZ_ZIP_ECDH_NUM_THIS_DISK_OFS = 4,
- MZ_ZIP_ECDH_NUM_DISK_CDIR_OFS = 6,
- MZ_ZIP_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS = 8,
- MZ_ZIP_ECDH_CDIR_TOTAL_ENTRIES_OFS = 10,
- MZ_ZIP_ECDH_CDIR_SIZE_OFS = 12,
- MZ_ZIP_ECDH_CDIR_OFS_OFS = 16,
- MZ_ZIP_ECDH_COMMENT_SIZE_OFS = 20,
-
- /* ZIP64 End of central directory locator offsets */
- MZ_ZIP64_ECDL_SIG_OFS = 0, /* 4 bytes */
- MZ_ZIP64_ECDL_NUM_DISK_CDIR_OFS = 4, /* 4 bytes */
- MZ_ZIP64_ECDL_REL_OFS_TO_ZIP64_ECDR_OFS = 8, /* 8 bytes */
- MZ_ZIP64_ECDL_TOTAL_NUMBER_OF_DISKS_OFS = 16, /* 4 bytes */
-
- /* ZIP64 End of central directory header offsets */
- MZ_ZIP64_ECDH_SIG_OFS = 0, /* 4 bytes */
- MZ_ZIP64_ECDH_SIZE_OF_RECORD_OFS = 4, /* 8 bytes */
- MZ_ZIP64_ECDH_VERSION_MADE_BY_OFS = 12, /* 2 bytes */
- MZ_ZIP64_ECDH_VERSION_NEEDED_OFS = 14, /* 2 bytes */
- MZ_ZIP64_ECDH_NUM_THIS_DISK_OFS = 16, /* 4 bytes */
- MZ_ZIP64_ECDH_NUM_DISK_CDIR_OFS = 20, /* 4 bytes */
- MZ_ZIP64_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS = 24, /* 8 bytes */
- MZ_ZIP64_ECDH_CDIR_TOTAL_ENTRIES_OFS = 32, /* 8 bytes */
- MZ_ZIP64_ECDH_CDIR_SIZE_OFS = 40, /* 8 bytes */
- MZ_ZIP64_ECDH_CDIR_OFS_OFS = 48, /* 8 bytes */
- MZ_ZIP_VERSION_MADE_BY_DOS_FILESYSTEM_ID = 0,
- MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG = 0x10,
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED = 1,
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG = 32,
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION = 64,
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_LOCAL_DIR_IS_MASKED = 8192,
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8 = 1 << 11
-};
-
-typedef struct {
- void *m_p;
- size_t m_size, m_capacity;
- mz_uint m_element_size;
-} mz_zip_array;
-
-struct mz_zip_internal_state_tag {
- mz_zip_array m_central_dir;
- mz_zip_array m_central_dir_offsets;
- mz_zip_array m_sorted_central_dir_offsets;
-
- /* The flags passed in when the archive is initially opened. */
- uint32_t m_init_flags;
-
- /* MZ_TRUE if the archive has a zip64 end of central directory headers, etc.
- */
- mz_bool m_zip64;
-
- /* MZ_TRUE if we found zip64 extended info in the central directory (m_zip64
- * will also be slammed to true too, even if we didn't find a zip64 end of
- * central dir header, etc.) */
- mz_bool m_zip64_has_extended_info_fields;
-
- /* These fields are used by the file, FILE, memory, and memory/heap read/write
- * helpers. */
- MZ_FILE *m_pFile;
- mz_uint64 m_file_archive_start_ofs;
-
- void *m_pMem;
- size_t m_mem_size;
- size_t m_mem_capacity;
-};
-
-#define MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(array_ptr, element_size) \
- (array_ptr)->m_element_size = element_size
-
-#if defined(DEBUG) || defined(_DEBUG)
-static MZ_FORCEINLINE mz_uint
-mz_zip_array_range_check(const mz_zip_array *pArray, mz_uint index) {
- MZ_ASSERT(index < pArray->m_size);
- return index;
-}
-#define MZ_ZIP_ARRAY_ELEMENT(array_ptr, element_type, index) \
- ((element_type *)((array_ptr) \
- ->m_p))[mz_zip_array_range_check(array_ptr, index)]
-#else
-#define MZ_ZIP_ARRAY_ELEMENT(array_ptr, element_type, index) \
- ((element_type *)((array_ptr)->m_p))[index]
-#endif
-
-static MZ_FORCEINLINE void mz_zip_array_init(mz_zip_array *pArray,
- mz_uint32 element_size) {
- memset(pArray, 0, sizeof(mz_zip_array));
- pArray->m_element_size = element_size;
-}
-
-static MZ_FORCEINLINE void mz_zip_array_clear(mz_zip_archive *pZip,
- mz_zip_array *pArray) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pArray->m_p);
- memset(pArray, 0, sizeof(mz_zip_array));
-}
-
-static mz_bool mz_zip_array_ensure_capacity(mz_zip_archive *pZip,
- mz_zip_array *pArray,
- size_t min_new_capacity,
- mz_uint growing) {
- void *pNew_p;
- size_t new_capacity = min_new_capacity;
- MZ_ASSERT(pArray->m_element_size);
- if (pArray->m_capacity >= min_new_capacity)
- return MZ_TRUE;
- if (growing) {
- new_capacity = MZ_MAX(1, pArray->m_capacity);
- while (new_capacity < min_new_capacity)
- new_capacity *= 2;
- }
- if (NULL == (pNew_p = pZip->m_pRealloc(pZip->m_pAlloc_opaque, pArray->m_p,
- pArray->m_element_size, new_capacity)))
- return MZ_FALSE;
- pArray->m_p = pNew_p;
- pArray->m_capacity = new_capacity;
- return MZ_TRUE;
-}
-
-static MZ_FORCEINLINE mz_bool mz_zip_array_reserve(mz_zip_archive *pZip,
- mz_zip_array *pArray,
- size_t new_capacity,
- mz_uint growing) {
- if (new_capacity > pArray->m_capacity) {
- if (!mz_zip_array_ensure_capacity(pZip, pArray, new_capacity, growing))
- return MZ_FALSE;
- }
- return MZ_TRUE;
-}
-
-static MZ_FORCEINLINE mz_bool mz_zip_array_resize(mz_zip_archive *pZip,
- mz_zip_array *pArray,
- size_t new_size,
- mz_uint growing) {
- if (new_size > pArray->m_capacity) {
- if (!mz_zip_array_ensure_capacity(pZip, pArray, new_size, growing))
- return MZ_FALSE;
- }
- pArray->m_size = new_size;
- return MZ_TRUE;
-}
-
-static MZ_FORCEINLINE mz_bool mz_zip_array_ensure_room(mz_zip_archive *pZip,
- mz_zip_array *pArray,
- size_t n) {
- return mz_zip_array_reserve(pZip, pArray, pArray->m_size + n, MZ_TRUE);
-}
-
-static MZ_FORCEINLINE mz_bool mz_zip_array_push_back(mz_zip_archive *pZip,
- mz_zip_array *pArray,
- const void *pElements,
- size_t n) {
- size_t orig_size = pArray->m_size;
- if (!mz_zip_array_resize(pZip, pArray, orig_size + n, MZ_TRUE))
- return MZ_FALSE;
- if (n > 0)
- memcpy((mz_uint8 *)pArray->m_p + orig_size * pArray->m_element_size,
- pElements, n * pArray->m_element_size);
- return MZ_TRUE;
-}
-
-#ifndef MINIZ_NO_TIME
-static MZ_TIME_T mz_zip_dos_to_time_t(int dos_time, int dos_date) {
- struct tm tm;
- memset(&tm, 0, sizeof(tm));
- tm.tm_isdst = -1;
- tm.tm_year = ((dos_date >> 9) & 127) + 1980 - 1900;
- tm.tm_mon = ((dos_date >> 5) & 15) - 1;
- tm.tm_mday = dos_date & 31;
- tm.tm_hour = (dos_time >> 11) & 31;
- tm.tm_min = (dos_time >> 5) & 63;
- tm.tm_sec = (dos_time << 1) & 62;
- return mktime(&tm);
-}
-
-#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS
-static void mz_zip_time_t_to_dos_time(MZ_TIME_T time, mz_uint16 *pDOS_time,
- mz_uint16 *pDOS_date) {
-#ifdef _MSC_VER
- struct tm tm_struct;
- struct tm *tm = &tm_struct;
- errno_t err = localtime_s(tm, &time);
- if (err) {
- *pDOS_date = 0;
- *pDOS_time = 0;
- return;
- }
-#else
- struct tm *tm = localtime(&time);
-#endif /* #ifdef _MSC_VER */
-
- *pDOS_time = (mz_uint16)(((tm->tm_hour) << 11) + ((tm->tm_min) << 5) +
- ((tm->tm_sec) >> 1));
- *pDOS_date = (mz_uint16)(((tm->tm_year + 1900 - 1980) << 9) +
- ((tm->tm_mon + 1) << 5) + tm->tm_mday);
-}
-#endif /* MINIZ_NO_ARCHIVE_WRITING_APIS */
-
-#ifndef MINIZ_NO_STDIO
-#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS
-static mz_bool mz_zip_get_file_modified_time(const char *pFilename,
- MZ_TIME_T *pTime) {
- struct MZ_FILE_STAT_STRUCT file_stat;
-
- /* On Linux with x86 glibc, this call will fail on large files (I think >=
- * 0x80000000 bytes) unless you compiled with _LARGEFILE64_SOURCE. Argh. */
- if (MZ_FILE_STAT(pFilename, &file_stat) != 0)
- return MZ_FALSE;
-
- *pTime = file_stat.st_mtime;
-
- return MZ_TRUE;
-}
-#endif /* #ifndef MINIZ_NO_ARCHIVE_WRITING_APIS*/
-
-static mz_bool mz_zip_set_file_times(const char *pFilename,
- MZ_TIME_T access_time,
- MZ_TIME_T modified_time) {
- struct utimbuf t;
-
- memset(&t, 0, sizeof(t));
- t.actime = access_time;
- t.modtime = modified_time;
-
- return !utime(pFilename, &t);
-}
-#endif /* #ifndef MINIZ_NO_STDIO */
-#endif /* #ifndef MINIZ_NO_TIME */
-
-static MZ_FORCEINLINE mz_bool mz_zip_set_error(mz_zip_archive *pZip,
- mz_zip_error err_num) {
- if (pZip)
- pZip->m_last_error = err_num;
- return MZ_FALSE;
-}
-
-static mz_bool mz_zip_reader_init_internal(mz_zip_archive *pZip,
- mz_uint flags) {
- (void)flags;
- if ((!pZip) || (pZip->m_pState) || (pZip->m_zip_mode != MZ_ZIP_MODE_INVALID))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (!pZip->m_pAlloc)
- pZip->m_pAlloc = miniz_def_alloc_func;
- if (!pZip->m_pFree)
- pZip->m_pFree = miniz_def_free_func;
- if (!pZip->m_pRealloc)
- pZip->m_pRealloc = miniz_def_realloc_func;
-
- pZip->m_archive_size = 0;
- pZip->m_central_directory_file_ofs = 0;
- pZip->m_total_files = 0;
- pZip->m_last_error = MZ_ZIP_NO_ERROR;
-
- if (NULL == (pZip->m_pState = (mz_zip_internal_state *)pZip->m_pAlloc(
- pZip->m_pAlloc_opaque, 1, sizeof(mz_zip_internal_state))))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- memset(pZip->m_pState, 0, sizeof(mz_zip_internal_state));
- MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_central_dir,
- sizeof(mz_uint8));
- MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_central_dir_offsets,
- sizeof(mz_uint32));
- MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_sorted_central_dir_offsets,
- sizeof(mz_uint32));
- pZip->m_pState->m_init_flags = flags;
- pZip->m_pState->m_zip64 = MZ_FALSE;
- pZip->m_pState->m_zip64_has_extended_info_fields = MZ_FALSE;
-
- pZip->m_zip_mode = MZ_ZIP_MODE_READING;
-
- return MZ_TRUE;
-}
-
-static MZ_FORCEINLINE mz_bool
-mz_zip_reader_filename_less(const mz_zip_array *pCentral_dir_array,
- const mz_zip_array *pCentral_dir_offsets,
- mz_uint l_index, mz_uint r_index) {
- const mz_uint8 *pL = &MZ_ZIP_ARRAY_ELEMENT(
- pCentral_dir_array, mz_uint8,
- MZ_ZIP_ARRAY_ELEMENT(pCentral_dir_offsets, mz_uint32,
- l_index)),
- *pE;
- const mz_uint8 *pR = &MZ_ZIP_ARRAY_ELEMENT(
- pCentral_dir_array, mz_uint8,
- MZ_ZIP_ARRAY_ELEMENT(pCentral_dir_offsets, mz_uint32, r_index));
- mz_uint l_len = MZ_READ_LE16(pL + MZ_ZIP_CDH_FILENAME_LEN_OFS),
- r_len = MZ_READ_LE16(pR + MZ_ZIP_CDH_FILENAME_LEN_OFS);
- mz_uint8 l = 0, r = 0;
- pL += MZ_ZIP_CENTRAL_DIR_HEADER_SIZE;
- pR += MZ_ZIP_CENTRAL_DIR_HEADER_SIZE;
- pE = pL + MZ_MIN(l_len, r_len);
- while (pL < pE) {
- if ((l = MZ_TOLOWER(*pL)) != (r = MZ_TOLOWER(*pR)))
- break;
- pL++;
- pR++;
- }
- return (pL == pE) ? (l_len < r_len) : (l < r);
-}
-
-#define MZ_SWAP_UINT32(a, b) \
- do { \
- mz_uint32 t = a; \
- a = b; \
- b = t; \
- } \
- MZ_MACRO_END
-
-/* Heap sort of lowercased filenames, used to help accelerate plain central
- * directory searches by mz_zip_reader_locate_file(). (Could also use qsort(),
- * but it could allocate memory.) */
-static void
-mz_zip_reader_sort_central_dir_offsets_by_filename(mz_zip_archive *pZip) {
- mz_zip_internal_state *pState = pZip->m_pState;
- const mz_zip_array *pCentral_dir_offsets = &pState->m_central_dir_offsets;
- const mz_zip_array *pCentral_dir = &pState->m_central_dir;
- mz_uint32 *pIndices;
- mz_uint32 start, end;
- const mz_uint32 size = pZip->m_total_files;
-
- if (size <= 1U)
- return;
-
- pIndices = &MZ_ZIP_ARRAY_ELEMENT(&pState->m_sorted_central_dir_offsets,
- mz_uint32, 0);
-
- start = (size - 2U) >> 1U;
- for (;;) {
- mz_uint64 child, root = start;
- for (;;) {
- if ((child = (root << 1U) + 1U) >= size)
- break;
- child += (((child + 1U) < size) &&
- (mz_zip_reader_filename_less(pCentral_dir, pCentral_dir_offsets,
- pIndices[child],
- pIndices[child + 1U])));
- if (!mz_zip_reader_filename_less(pCentral_dir, pCentral_dir_offsets,
- pIndices[root], pIndices[child]))
- break;
- MZ_SWAP_UINT32(pIndices[root], pIndices[child]);
- root = child;
- }
- if (!start)
- break;
- start--;
- }
-
- end = size - 1;
- while (end > 0) {
- mz_uint64 child, root = 0;
- MZ_SWAP_UINT32(pIndices[end], pIndices[0]);
- for (;;) {
- if ((child = (root << 1U) + 1U) >= end)
- break;
- child +=
- (((child + 1U) < end) &&
- mz_zip_reader_filename_less(pCentral_dir, pCentral_dir_offsets,
- pIndices[child], pIndices[child + 1U]));
- if (!mz_zip_reader_filename_less(pCentral_dir, pCentral_dir_offsets,
- pIndices[root], pIndices[child]))
- break;
- MZ_SWAP_UINT32(pIndices[root], pIndices[child]);
- root = child;
- }
- end--;
- }
-}
-
-static mz_bool mz_zip_reader_locate_header_sig(mz_zip_archive *pZip,
- mz_uint32 record_sig,
- mz_uint32 record_size,
- mz_int64 *pOfs) {
- mz_int64 cur_file_ofs;
- mz_uint32 buf_u32[4096 / sizeof(mz_uint32)];
- mz_uint8 *pBuf = (mz_uint8 *)buf_u32;
-
- /* Basic sanity checks - reject files which are too small */
- if (pZip->m_archive_size < record_size)
- return MZ_FALSE;
-
- /* Find the record by scanning the file from the end towards the beginning. */
- cur_file_ofs =
- MZ_MAX((mz_int64)pZip->m_archive_size - (mz_int64)sizeof(buf_u32), 0);
- for (;;) {
- int i,
- n = (int)MZ_MIN(sizeof(buf_u32), pZip->m_archive_size - cur_file_ofs);
-
- if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pBuf, n) != (mz_uint)n)
- return MZ_FALSE;
-
- for (i = n - 4; i >= 0; --i) {
- mz_uint s = MZ_READ_LE32(pBuf + i);
- if (s == record_sig) {
- if ((pZip->m_archive_size - (cur_file_ofs + i)) >= record_size)
- break;
- }
- }
-
- if (i >= 0) {
- cur_file_ofs += i;
- break;
- }
-
- /* Give up if we've searched the entire file, or we've gone back "too far"
- * (~64kb) */
- if ((!cur_file_ofs) || ((pZip->m_archive_size - cur_file_ofs) >=
- (MZ_UINT16_MAX + record_size)))
- return MZ_FALSE;
-
- cur_file_ofs = MZ_MAX(cur_file_ofs - (sizeof(buf_u32) - 3), 0);
- }
-
- *pOfs = cur_file_ofs;
- return MZ_TRUE;
-}
-
-static mz_bool mz_zip_reader_read_central_dir(mz_zip_archive *pZip,
- mz_uint flags) {
- mz_uint cdir_size = 0, cdir_entries_on_this_disk = 0, num_this_disk = 0,
- cdir_disk_index = 0;
- mz_uint64 cdir_ofs = 0;
- mz_int64 cur_file_ofs = 0;
- const mz_uint8 *p;
-
- mz_uint32 buf_u32[4096 / sizeof(mz_uint32)];
- mz_uint8 *pBuf = (mz_uint8 *)buf_u32;
- mz_bool sort_central_dir =
- ((flags & MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY) == 0);
- mz_uint32 zip64_end_of_central_dir_locator_u32
- [(MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE + sizeof(mz_uint32) - 1) /
- sizeof(mz_uint32)];
- mz_uint8 *pZip64_locator = (mz_uint8 *)zip64_end_of_central_dir_locator_u32;
-
- mz_uint32 zip64_end_of_central_dir_header_u32
- [(MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) /
- sizeof(mz_uint32)];
- mz_uint8 *pZip64_end_of_central_dir =
- (mz_uint8 *)zip64_end_of_central_dir_header_u32;
-
- mz_uint64 zip64_end_of_central_dir_ofs = 0;
-
- /* Basic sanity checks - reject files which are too small, and check the first
- * 4 bytes of the file to make sure a local header is there. */
- if (pZip->m_archive_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE);
-
- if (!mz_zip_reader_locate_header_sig(
- pZip, MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIG,
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE, &cur_file_ofs))
- return mz_zip_set_error(pZip, MZ_ZIP_FAILED_FINDING_CENTRAL_DIR);
-
- /* Read and verify the end of central directory record. */
- if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pBuf,
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) !=
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
-
- if (MZ_READ_LE32(pBuf + MZ_ZIP_ECDH_SIG_OFS) !=
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIG)
- return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE);
-
- if (cur_file_ofs >= (MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE +
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE)) {
- if (pZip->m_pRead(pZip->m_pIO_opaque,
- cur_file_ofs - MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE,
- pZip64_locator,
- MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE) ==
- MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE) {
- if (MZ_READ_LE32(pZip64_locator + MZ_ZIP64_ECDL_SIG_OFS) ==
- MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIG) {
- zip64_end_of_central_dir_ofs = MZ_READ_LE64(
- pZip64_locator + MZ_ZIP64_ECDL_REL_OFS_TO_ZIP64_ECDR_OFS);
- if (zip64_end_of_central_dir_ofs >
- (pZip->m_archive_size - MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE))
- return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE);
-
- if (pZip->m_pRead(pZip->m_pIO_opaque, zip64_end_of_central_dir_ofs,
- pZip64_end_of_central_dir,
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE) ==
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE) {
- if (MZ_READ_LE32(pZip64_end_of_central_dir + MZ_ZIP64_ECDH_SIG_OFS) ==
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIG) {
- pZip->m_pState->m_zip64 = MZ_TRUE;
- }
- }
- }
- }
- }
-
- pZip->m_total_files = MZ_READ_LE16(pBuf + MZ_ZIP_ECDH_CDIR_TOTAL_ENTRIES_OFS);
- cdir_entries_on_this_disk =
- MZ_READ_LE16(pBuf + MZ_ZIP_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS);
- num_this_disk = MZ_READ_LE16(pBuf + MZ_ZIP_ECDH_NUM_THIS_DISK_OFS);
- cdir_disk_index = MZ_READ_LE16(pBuf + MZ_ZIP_ECDH_NUM_DISK_CDIR_OFS);
- cdir_size = MZ_READ_LE32(pBuf + MZ_ZIP_ECDH_CDIR_SIZE_OFS);
- cdir_ofs = MZ_READ_LE32(pBuf + MZ_ZIP_ECDH_CDIR_OFS_OFS);
-
- if (pZip->m_pState->m_zip64) {
- mz_uint32 zip64_total_num_of_disks =
- MZ_READ_LE32(pZip64_locator + MZ_ZIP64_ECDL_TOTAL_NUMBER_OF_DISKS_OFS);
- mz_uint64 zip64_cdir_total_entries = MZ_READ_LE64(
- pZip64_end_of_central_dir + MZ_ZIP64_ECDH_CDIR_TOTAL_ENTRIES_OFS);
- mz_uint64 zip64_cdir_total_entries_on_this_disk = MZ_READ_LE64(
- pZip64_end_of_central_dir + MZ_ZIP64_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS);
- mz_uint64 zip64_size_of_end_of_central_dir_record = MZ_READ_LE64(
- pZip64_end_of_central_dir + MZ_ZIP64_ECDH_SIZE_OF_RECORD_OFS);
- mz_uint64 zip64_size_of_central_directory =
- MZ_READ_LE64(pZip64_end_of_central_dir + MZ_ZIP64_ECDH_CDIR_SIZE_OFS);
-
- if (zip64_size_of_end_of_central_dir_record <
- (MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE - 12))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- if (zip64_total_num_of_disks != 1U)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_MULTIDISK);
-
- /* Check for miniz's practical limits */
- if (zip64_cdir_total_entries > MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
-
- pZip->m_total_files = (mz_uint32)zip64_cdir_total_entries;
-
- if (zip64_cdir_total_entries_on_this_disk > MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
-
- cdir_entries_on_this_disk =
- (mz_uint32)zip64_cdir_total_entries_on_this_disk;
-
- /* Check for miniz's current practical limits (sorry, this should be enough
- * for millions of files) */
- if (zip64_size_of_central_directory > MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE);
-
- cdir_size = (mz_uint32)zip64_size_of_central_directory;
-
- num_this_disk = MZ_READ_LE32(pZip64_end_of_central_dir +
- MZ_ZIP64_ECDH_NUM_THIS_DISK_OFS);
-
- cdir_disk_index = MZ_READ_LE32(pZip64_end_of_central_dir +
- MZ_ZIP64_ECDH_NUM_DISK_CDIR_OFS);
-
- cdir_ofs =
- MZ_READ_LE64(pZip64_end_of_central_dir + MZ_ZIP64_ECDH_CDIR_OFS_OFS);
- }
-
- if (pZip->m_total_files != cdir_entries_on_this_disk)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_MULTIDISK);
-
- if (((num_this_disk | cdir_disk_index) != 0) &&
- ((num_this_disk != 1) || (cdir_disk_index != 1)))
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_MULTIDISK);
-
- if (cdir_size < pZip->m_total_files * MZ_ZIP_CENTRAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- if ((cdir_ofs + (mz_uint64)cdir_size) > pZip->m_archive_size)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- pZip->m_central_directory_file_ofs = cdir_ofs;
-
- if (pZip->m_total_files) {
- mz_uint i, n;
- /* Read the entire central directory into a heap block, and allocate another
- * heap block to hold the unsorted central dir file record offsets, and
- * possibly another to hold the sorted indices. */
- if ((!mz_zip_array_resize(pZip, &pZip->m_pState->m_central_dir, cdir_size,
- MZ_FALSE)) ||
- (!mz_zip_array_resize(pZip, &pZip->m_pState->m_central_dir_offsets,
- pZip->m_total_files, MZ_FALSE)))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- if (sort_central_dir) {
- if (!mz_zip_array_resize(pZip,
- &pZip->m_pState->m_sorted_central_dir_offsets,
- pZip->m_total_files, MZ_FALSE))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- if (pZip->m_pRead(pZip->m_pIO_opaque, cdir_ofs,
- pZip->m_pState->m_central_dir.m_p,
- cdir_size) != cdir_size)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
-
- /* Now create an index into the central directory file records, do some
- * basic sanity checking on each record */
- p = (const mz_uint8 *)pZip->m_pState->m_central_dir.m_p;
- for (n = cdir_size, i = 0; i < pZip->m_total_files; ++i) {
- mz_uint total_header_size, disk_index, bit_flags, filename_size,
- ext_data_size;
- mz_uint64 comp_size, decomp_size, local_header_ofs;
-
- if ((n < MZ_ZIP_CENTRAL_DIR_HEADER_SIZE) ||
- (MZ_READ_LE32(p) != MZ_ZIP_CENTRAL_DIR_HEADER_SIG))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_central_dir_offsets, mz_uint32,
- i) =
- (mz_uint32)(p - (const mz_uint8 *)pZip->m_pState->m_central_dir.m_p);
-
- if (sort_central_dir)
- MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_sorted_central_dir_offsets,
- mz_uint32, i) = i;
-
- comp_size = MZ_READ_LE32(p + MZ_ZIP_CDH_COMPRESSED_SIZE_OFS);
- decomp_size = MZ_READ_LE32(p + MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS);
- local_header_ofs = MZ_READ_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS);
- filename_size = MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS);
- ext_data_size = MZ_READ_LE16(p + MZ_ZIP_CDH_EXTRA_LEN_OFS);
-
- if ((!pZip->m_pState->m_zip64_has_extended_info_fields) &&
- (ext_data_size) &&
- (MZ_MAX(MZ_MAX(comp_size, decomp_size), local_header_ofs) ==
- MZ_UINT32_MAX)) {
- /* Attempt to find zip64 extended information field in the entry's extra
- * data */
- mz_uint32 extra_size_remaining = ext_data_size;
-
- if (extra_size_remaining) {
- const mz_uint8 *pExtra_data;
- void *buf = NULL;
-
- if (MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + filename_size + ext_data_size >
- n) {
- buf = MZ_MALLOC(ext_data_size);
- if (buf == NULL)
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- if (pZip->m_pRead(pZip->m_pIO_opaque,
- cdir_ofs + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE +
- filename_size,
- buf, ext_data_size) != ext_data_size) {
- MZ_FREE(buf);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- }
-
- pExtra_data = (mz_uint8 *)buf;
- } else {
- pExtra_data = p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + filename_size;
- }
-
- do {
- mz_uint32 field_id;
- mz_uint32 field_data_size;
-
- if (extra_size_remaining < (sizeof(mz_uint16) * 2)) {
- MZ_FREE(buf);
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- }
-
- field_id = MZ_READ_LE16(pExtra_data);
- field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16));
-
- if ((field_data_size + sizeof(mz_uint16) * 2) >
- extra_size_remaining) {
- MZ_FREE(buf);
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- }
-
- if (field_id == MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) {
- /* Ok, the archive didn't have any zip64 headers but it uses a
- * zip64 extended information field so mark it as zip64 anyway
- * (this can occur with infozip's zip util when it reads
- * compresses files from stdin). */
- pZip->m_pState->m_zip64 = MZ_TRUE;
- pZip->m_pState->m_zip64_has_extended_info_fields = MZ_TRUE;
- break;
- }
-
- pExtra_data += sizeof(mz_uint16) * 2 + field_data_size;
- extra_size_remaining =
- extra_size_remaining - sizeof(mz_uint16) * 2 - field_data_size;
- } while (extra_size_remaining);
-
- MZ_FREE(buf);
- }
- }
-
- /* I've seen archives that aren't marked as zip64 that uses zip64 ext
- * data, argh */
- if ((comp_size != MZ_UINT32_MAX) && (decomp_size != MZ_UINT32_MAX)) {
- if (((!MZ_READ_LE32(p + MZ_ZIP_CDH_METHOD_OFS)) &&
- (decomp_size != comp_size)) ||
- (decomp_size && !comp_size))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- }
-
- disk_index = MZ_READ_LE16(p + MZ_ZIP_CDH_DISK_START_OFS);
- if ((disk_index == MZ_UINT16_MAX) ||
- ((disk_index != num_this_disk) && (disk_index != 1)))
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_MULTIDISK);
-
- if (comp_size != MZ_UINT32_MAX) {
- if (((mz_uint64)MZ_READ_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS) +
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE + comp_size) > pZip->m_archive_size)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- }
-
- bit_flags = MZ_READ_LE16(p + MZ_ZIP_CDH_BIT_FLAG_OFS);
- if (bit_flags & MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_LOCAL_DIR_IS_MASKED)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION);
-
- if ((total_header_size = MZ_ZIP_CENTRAL_DIR_HEADER_SIZE +
- MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS) +
- MZ_READ_LE16(p + MZ_ZIP_CDH_EXTRA_LEN_OFS) +
- MZ_READ_LE16(p + MZ_ZIP_CDH_COMMENT_LEN_OFS)) >
- n)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- n -= total_header_size;
- p += total_header_size;
- }
- }
-
- if (sort_central_dir)
- mz_zip_reader_sort_central_dir_offsets_by_filename(pZip);
-
- return MZ_TRUE;
-}
-
-void mz_zip_zero_struct(mz_zip_archive *pZip) {
- if (pZip)
- MZ_CLEAR_OBJ(*pZip);
-}
-
-static mz_bool mz_zip_reader_end_internal(mz_zip_archive *pZip,
- mz_bool set_last_error) {
- mz_bool status = MZ_TRUE;
-
- if (!pZip)
- return MZ_FALSE;
-
- if ((!pZip->m_pState) || (!pZip->m_pAlloc) || (!pZip->m_pFree) ||
- (pZip->m_zip_mode != MZ_ZIP_MODE_READING)) {
- if (set_last_error)
- pZip->m_last_error = MZ_ZIP_INVALID_PARAMETER;
-
- return MZ_FALSE;
- }
-
- if (pZip->m_pState) {
- mz_zip_internal_state *pState = pZip->m_pState;
- pZip->m_pState = NULL;
-
- mz_zip_array_clear(pZip, &pState->m_central_dir);
- mz_zip_array_clear(pZip, &pState->m_central_dir_offsets);
- mz_zip_array_clear(pZip, &pState->m_sorted_central_dir_offsets);
-
-#ifndef MINIZ_NO_STDIO
- if (pState->m_pFile) {
- if (pZip->m_zip_type == MZ_ZIP_TYPE_FILE) {
- if (MZ_FCLOSE(pState->m_pFile) == EOF) {
- if (set_last_error)
- pZip->m_last_error = MZ_ZIP_FILE_CLOSE_FAILED;
- status = MZ_FALSE;
- }
- }
- pState->m_pFile = NULL;
- }
-#endif /* #ifndef MINIZ_NO_STDIO */
-
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- }
- pZip->m_zip_mode = MZ_ZIP_MODE_INVALID;
-
- return status;
-}
-
-mz_bool mz_zip_reader_end(mz_zip_archive *pZip) {
- return mz_zip_reader_end_internal(pZip, MZ_TRUE);
-}
-mz_bool mz_zip_reader_init(mz_zip_archive *pZip, mz_uint64 size,
- mz_uint flags) {
- if ((!pZip) || (!pZip->m_pRead))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (!mz_zip_reader_init_internal(pZip, flags))
- return MZ_FALSE;
-
- pZip->m_zip_type = MZ_ZIP_TYPE_USER;
- pZip->m_archive_size = size;
-
- if (!mz_zip_reader_read_central_dir(pZip, flags)) {
- mz_zip_reader_end_internal(pZip, MZ_FALSE);
- return MZ_FALSE;
- }
-
- return MZ_TRUE;
-}
-
-static size_t mz_zip_mem_read_func(void *pOpaque, mz_uint64 file_ofs,
- void *pBuf, size_t n) {
- mz_zip_archive *pZip = (mz_zip_archive *)pOpaque;
- size_t s = (file_ofs >= pZip->m_archive_size)
- ? 0
- : (size_t)MZ_MIN(pZip->m_archive_size - file_ofs, n);
- memcpy(pBuf, (const mz_uint8 *)pZip->m_pState->m_pMem + file_ofs, s);
- return s;
-}
-
-mz_bool mz_zip_reader_init_mem(mz_zip_archive *pZip, const void *pMem,
- size_t size, mz_uint flags) {
- if (!pMem)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE);
-
- if (!mz_zip_reader_init_internal(pZip, flags))
- return MZ_FALSE;
-
- pZip->m_zip_type = MZ_ZIP_TYPE_MEMORY;
- pZip->m_archive_size = size;
- pZip->m_pRead = mz_zip_mem_read_func;
- pZip->m_pIO_opaque = pZip;
- pZip->m_pNeeds_keepalive = NULL;
-
-#ifdef __cplusplus
- pZip->m_pState->m_pMem = const_cast(pMem);
-#else
- pZip->m_pState->m_pMem = (void *)pMem;
-#endif
-
- pZip->m_pState->m_mem_size = size;
-
- if (!mz_zip_reader_read_central_dir(pZip, flags)) {
- mz_zip_reader_end_internal(pZip, MZ_FALSE);
- return MZ_FALSE;
- }
-
- return MZ_TRUE;
-}
-
-#ifndef MINIZ_NO_STDIO
-static size_t mz_zip_file_read_func(void *pOpaque, mz_uint64 file_ofs,
- void *pBuf, size_t n) {
- mz_zip_archive *pZip = (mz_zip_archive *)pOpaque;
- mz_int64 cur_ofs = MZ_FTELL64(pZip->m_pState->m_pFile);
-
- file_ofs += pZip->m_pState->m_file_archive_start_ofs;
-
- if (((mz_int64)file_ofs < 0) ||
- (((cur_ofs != (mz_int64)file_ofs)) &&
- (MZ_FSEEK64(pZip->m_pState->m_pFile, (mz_int64)file_ofs, SEEK_SET))))
- return 0;
-
- return MZ_FREAD(pBuf, 1, n, pZip->m_pState->m_pFile);
-}
-
-mz_bool mz_zip_reader_init_file(mz_zip_archive *pZip, const char *pFilename,
- mz_uint32 flags) {
- return mz_zip_reader_init_file_v2(pZip, pFilename, flags, 0, 0);
-}
-
-mz_bool mz_zip_reader_init_file_v2(mz_zip_archive *pZip, const char *pFilename,
- mz_uint flags, mz_uint64 file_start_ofs,
- mz_uint64 archive_size) {
- mz_uint64 file_size;
- MZ_FILE *pFile;
-
- if ((!pZip) || (!pFilename) ||
- ((archive_size) &&
- (archive_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE)))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pFile = MZ_FOPEN(pFilename, "rb");
- if (!pFile)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED);
-
- file_size = archive_size;
- if (!file_size) {
- if (MZ_FSEEK64(pFile, 0, SEEK_END)) {
- MZ_FCLOSE(pFile);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_SEEK_FAILED);
- }
-
- file_size = MZ_FTELL64(pFile);
- }
-
- /* TODO: Better sanity check archive_size and the # of actual remaining bytes
- */
-
- if (file_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) {
- MZ_FCLOSE(pFile);
- return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE);
- }
-
- if (!mz_zip_reader_init_internal(pZip, flags)) {
- MZ_FCLOSE(pFile);
- return MZ_FALSE;
- }
-
- pZip->m_zip_type = MZ_ZIP_TYPE_FILE;
- pZip->m_pRead = mz_zip_file_read_func;
- pZip->m_pIO_opaque = pZip;
- pZip->m_pState->m_pFile = pFile;
- pZip->m_archive_size = file_size;
- pZip->m_pState->m_file_archive_start_ofs = file_start_ofs;
-
- if (!mz_zip_reader_read_central_dir(pZip, flags)) {
- mz_zip_reader_end_internal(pZip, MZ_FALSE);
- return MZ_FALSE;
- }
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_reader_init_file_v2_rpb(mz_zip_archive *pZip,
- const char *pFilename, mz_uint flags,
- mz_uint64 file_start_ofs,
- mz_uint64 archive_size) {
- mz_uint64 file_size;
- MZ_FILE *pFile;
-
- if ((!pZip) || (!pFilename) ||
- ((archive_size) &&
- (archive_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE)))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pFile = MZ_FOPEN(pFilename, "r+b");
- if (!pFile)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED);
-
- file_size = archive_size;
- if (!file_size) {
- if (MZ_FSEEK64(pFile, 0, SEEK_END)) {
- MZ_FCLOSE(pFile);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_SEEK_FAILED);
- }
-
- file_size = MZ_FTELL64(pFile);
- }
-
- /* TODO: Better sanity check archive_size and the # of actual remaining bytes
- */
-
- if (file_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) {
- MZ_FCLOSE(pFile);
- return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE);
- }
-
- if (!mz_zip_reader_init_internal(pZip, flags)) {
- MZ_FCLOSE(pFile);
- return MZ_FALSE;
- }
-
- pZip->m_zip_type = MZ_ZIP_TYPE_FILE;
- pZip->m_pRead = mz_zip_file_read_func;
- pZip->m_pIO_opaque = pZip;
- pZip->m_pState->m_pFile = pFile;
- pZip->m_archive_size = file_size;
- pZip->m_pState->m_file_archive_start_ofs = file_start_ofs;
-
- if (!mz_zip_reader_read_central_dir(pZip, flags)) {
- mz_zip_reader_end_internal(pZip, MZ_FALSE);
- return MZ_FALSE;
- }
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_reader_init_cfile(mz_zip_archive *pZip, MZ_FILE *pFile,
- mz_uint64 archive_size, mz_uint flags) {
- mz_uint64 cur_file_ofs;
-
- if ((!pZip) || (!pFile))
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED);
-
- cur_file_ofs = MZ_FTELL64(pFile);
-
- if (!archive_size) {
- if (MZ_FSEEK64(pFile, 0, SEEK_END))
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_SEEK_FAILED);
-
- archive_size = MZ_FTELL64(pFile) - cur_file_ofs;
-
- if (archive_size < MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_NOT_AN_ARCHIVE);
- }
-
- if (!mz_zip_reader_init_internal(pZip, flags))
- return MZ_FALSE;
-
- pZip->m_zip_type = MZ_ZIP_TYPE_CFILE;
- pZip->m_pRead = mz_zip_file_read_func;
-
- pZip->m_pIO_opaque = pZip;
- pZip->m_pState->m_pFile = pFile;
- pZip->m_archive_size = archive_size;
- pZip->m_pState->m_file_archive_start_ofs = cur_file_ofs;
-
- if (!mz_zip_reader_read_central_dir(pZip, flags)) {
- mz_zip_reader_end_internal(pZip, MZ_FALSE);
- return MZ_FALSE;
- }
-
- return MZ_TRUE;
-}
-
-#endif /* #ifndef MINIZ_NO_STDIO */
-
-static MZ_FORCEINLINE const mz_uint8 *mz_zip_get_cdh(mz_zip_archive *pZip,
- mz_uint file_index) {
- if ((!pZip) || (!pZip->m_pState) || (file_index >= pZip->m_total_files))
- return NULL;
- return &MZ_ZIP_ARRAY_ELEMENT(
- &pZip->m_pState->m_central_dir, mz_uint8,
- MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_central_dir_offsets, mz_uint32,
- file_index));
-}
-
-mz_bool mz_zip_reader_is_file_encrypted(mz_zip_archive *pZip,
- mz_uint file_index) {
- mz_uint m_bit_flag;
- const mz_uint8 *p = mz_zip_get_cdh(pZip, file_index);
- if (!p) {
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- return MZ_FALSE;
- }
-
- m_bit_flag = MZ_READ_LE16(p + MZ_ZIP_CDH_BIT_FLAG_OFS);
- return (m_bit_flag &
- (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED |
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION)) != 0;
-}
-
-mz_bool mz_zip_reader_is_file_supported(mz_zip_archive *pZip,
- mz_uint file_index) {
- mz_uint bit_flag;
- mz_uint method;
-
- const mz_uint8 *p = mz_zip_get_cdh(pZip, file_index);
- if (!p) {
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- return MZ_FALSE;
- }
-
- method = MZ_READ_LE16(p + MZ_ZIP_CDH_METHOD_OFS);
- bit_flag = MZ_READ_LE16(p + MZ_ZIP_CDH_BIT_FLAG_OFS);
-
- if ((method != 0) && (method != MZ_DEFLATED)) {
- mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD);
- return MZ_FALSE;
- }
-
- if (bit_flag & (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED |
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION)) {
- mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION);
- return MZ_FALSE;
- }
-
- if (bit_flag & MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG) {
- mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_FEATURE);
- return MZ_FALSE;
- }
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_reader_is_file_a_directory(mz_zip_archive *pZip,
- mz_uint file_index) {
- mz_uint filename_len, attribute_mapping_id, external_attr;
- const mz_uint8 *p = mz_zip_get_cdh(pZip, file_index);
- if (!p) {
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- return MZ_FALSE;
- }
-
- filename_len = MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS);
- if (filename_len) {
- if (*(p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + filename_len - 1) == '/')
- return MZ_TRUE;
- }
-
- /* Bugfix: This code was also checking if the internal attribute was non-zero,
- * which wasn't correct. */
- /* Most/all zip writers (hopefully) set DOS file/directory attributes in the
- * low 16-bits, so check for the DOS directory flag and ignore the source OS
- * ID in the created by field. */
- /* FIXME: Remove this check? Is it necessary - we already check the filename.
- */
- attribute_mapping_id = MZ_READ_LE16(p + MZ_ZIP_CDH_VERSION_MADE_BY_OFS) >> 8;
- (void)attribute_mapping_id;
-
- external_attr = MZ_READ_LE32(p + MZ_ZIP_CDH_EXTERNAL_ATTR_OFS);
- if ((external_attr & MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG) != 0) {
- return MZ_TRUE;
- }
-
- return MZ_FALSE;
-}
-
-static mz_bool mz_zip_file_stat_internal(mz_zip_archive *pZip,
- mz_uint file_index,
- const mz_uint8 *pCentral_dir_header,
- mz_zip_archive_file_stat *pStat,
- mz_bool *pFound_zip64_extra_data) {
- mz_uint n;
- const mz_uint8 *p = pCentral_dir_header;
-
- if (pFound_zip64_extra_data)
- *pFound_zip64_extra_data = MZ_FALSE;
-
- if ((!p) || (!pStat))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- /* Extract fields from the central directory record. */
- pStat->m_file_index = file_index;
- pStat->m_central_dir_ofs = MZ_ZIP_ARRAY_ELEMENT(
- &pZip->m_pState->m_central_dir_offsets, mz_uint32, file_index);
- pStat->m_version_made_by = MZ_READ_LE16(p + MZ_ZIP_CDH_VERSION_MADE_BY_OFS);
- pStat->m_version_needed = MZ_READ_LE16(p + MZ_ZIP_CDH_VERSION_NEEDED_OFS);
- pStat->m_bit_flag = MZ_READ_LE16(p + MZ_ZIP_CDH_BIT_FLAG_OFS);
- pStat->m_method = MZ_READ_LE16(p + MZ_ZIP_CDH_METHOD_OFS);
-#ifndef MINIZ_NO_TIME
- pStat->m_time =
- mz_zip_dos_to_time_t(MZ_READ_LE16(p + MZ_ZIP_CDH_FILE_TIME_OFS),
- MZ_READ_LE16(p + MZ_ZIP_CDH_FILE_DATE_OFS));
-#endif
- pStat->m_crc32 = MZ_READ_LE32(p + MZ_ZIP_CDH_CRC32_OFS);
- pStat->m_comp_size = MZ_READ_LE32(p + MZ_ZIP_CDH_COMPRESSED_SIZE_OFS);
- pStat->m_uncomp_size = MZ_READ_LE32(p + MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS);
- pStat->m_internal_attr = MZ_READ_LE16(p + MZ_ZIP_CDH_INTERNAL_ATTR_OFS);
- pStat->m_external_attr = MZ_READ_LE32(p + MZ_ZIP_CDH_EXTERNAL_ATTR_OFS);
- pStat->m_local_header_ofs = MZ_READ_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS);
-
- /* Copy as much of the filename and comment as possible. */
- n = MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS);
- n = MZ_MIN(n, MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE - 1);
- memcpy(pStat->m_filename, p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE, n);
- pStat->m_filename[n] = '\0';
-
- n = MZ_READ_LE16(p + MZ_ZIP_CDH_COMMENT_LEN_OFS);
- n = MZ_MIN(n, MZ_ZIP_MAX_ARCHIVE_FILE_COMMENT_SIZE - 1);
- pStat->m_comment_size = n;
- memcpy(pStat->m_comment,
- p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE +
- MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS) +
- MZ_READ_LE16(p + MZ_ZIP_CDH_EXTRA_LEN_OFS),
- n);
- pStat->m_comment[n] = '\0';
-
- /* Set some flags for convienance */
- pStat->m_is_directory = mz_zip_reader_is_file_a_directory(pZip, file_index);
- pStat->m_is_encrypted = mz_zip_reader_is_file_encrypted(pZip, file_index);
- pStat->m_is_supported = mz_zip_reader_is_file_supported(pZip, file_index);
-
- /* See if we need to read any zip64 extended information fields. */
- /* Confusingly, these zip64 fields can be present even on non-zip64 archives
- * (Debian zip on a huge files from stdin piped to stdout creates them). */
- if (MZ_MAX(MZ_MAX(pStat->m_comp_size, pStat->m_uncomp_size),
- pStat->m_local_header_ofs) == MZ_UINT32_MAX) {
- /* Attempt to find zip64 extended information field in the entry's extra
- * data */
- mz_uint32 extra_size_remaining = MZ_READ_LE16(p + MZ_ZIP_CDH_EXTRA_LEN_OFS);
-
- if (extra_size_remaining) {
- const mz_uint8 *pExtra_data =
- p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE +
- MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS);
-
- do {
- mz_uint32 field_id;
- mz_uint32 field_data_size;
-
- if (extra_size_remaining < (sizeof(mz_uint16) * 2))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- field_id = MZ_READ_LE16(pExtra_data);
- field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16));
-
- if ((field_data_size + sizeof(mz_uint16) * 2) > extra_size_remaining)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- if (field_id == MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) {
- const mz_uint8 *pField_data = pExtra_data + sizeof(mz_uint16) * 2;
- mz_uint32 field_data_remaining = field_data_size;
-
- if (pFound_zip64_extra_data)
- *pFound_zip64_extra_data = MZ_TRUE;
-
- if (pStat->m_uncomp_size == MZ_UINT32_MAX) {
- if (field_data_remaining < sizeof(mz_uint64))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- pStat->m_uncomp_size = MZ_READ_LE64(pField_data);
- pField_data += sizeof(mz_uint64);
- field_data_remaining -= sizeof(mz_uint64);
- }
-
- if (pStat->m_comp_size == MZ_UINT32_MAX) {
- if (field_data_remaining < sizeof(mz_uint64))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- pStat->m_comp_size = MZ_READ_LE64(pField_data);
- pField_data += sizeof(mz_uint64);
- field_data_remaining -= sizeof(mz_uint64);
- }
-
- if (pStat->m_local_header_ofs == MZ_UINT32_MAX) {
- if (field_data_remaining < sizeof(mz_uint64))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- pStat->m_local_header_ofs = MZ_READ_LE64(pField_data);
- pField_data += sizeof(mz_uint64);
- (void)pField_data; // unused
-
- field_data_remaining -= sizeof(mz_uint64);
- (void)field_data_remaining; // unused
- }
-
- break;
- }
-
- pExtra_data += sizeof(mz_uint16) * 2 + field_data_size;
- extra_size_remaining =
- extra_size_remaining - sizeof(mz_uint16) * 2 - field_data_size;
- } while (extra_size_remaining);
- }
- }
-
- return MZ_TRUE;
-}
-
-static MZ_FORCEINLINE mz_bool mz_zip_string_equal(const char *pA,
- const char *pB, mz_uint len,
- mz_uint flags) {
- mz_uint i;
- if (flags & MZ_ZIP_FLAG_CASE_SENSITIVE)
- return 0 == memcmp(pA, pB, len);
- for (i = 0; i < len; ++i)
- if (MZ_TOLOWER(pA[i]) != MZ_TOLOWER(pB[i]))
- return MZ_FALSE;
- return MZ_TRUE;
-}
-
-static MZ_FORCEINLINE int
-mz_zip_filename_compare(const mz_zip_array *pCentral_dir_array,
- const mz_zip_array *pCentral_dir_offsets,
- mz_uint l_index, const char *pR, mz_uint r_len) {
- const mz_uint8 *pL = &MZ_ZIP_ARRAY_ELEMENT(
- pCentral_dir_array, mz_uint8,
- MZ_ZIP_ARRAY_ELEMENT(pCentral_dir_offsets, mz_uint32,
- l_index)),
- *pE;
- mz_uint l_len = MZ_READ_LE16(pL + MZ_ZIP_CDH_FILENAME_LEN_OFS);
- mz_uint8 l = 0, r = 0;
- pL += MZ_ZIP_CENTRAL_DIR_HEADER_SIZE;
- pE = pL + MZ_MIN(l_len, r_len);
- while (pL < pE) {
- if ((l = MZ_TOLOWER(*pL)) != (r = MZ_TOLOWER(*pR)))
- break;
- pL++;
- pR++;
- }
- return (pL == pE) ? (int)(l_len - r_len) : (l - r);
-}
-
-static mz_bool mz_zip_locate_file_binary_search(mz_zip_archive *pZip,
- const char *pFilename,
- mz_uint32 *pIndex) {
- mz_zip_internal_state *pState = pZip->m_pState;
- const mz_zip_array *pCentral_dir_offsets = &pState->m_central_dir_offsets;
- const mz_zip_array *pCentral_dir = &pState->m_central_dir;
- mz_uint32 *pIndices = &MZ_ZIP_ARRAY_ELEMENT(
- &pState->m_sorted_central_dir_offsets, mz_uint32, 0);
- const uint32_t size = pZip->m_total_files;
- const mz_uint filename_len = (mz_uint)strlen(pFilename);
-
- if (pIndex)
- *pIndex = 0;
-
- if (size) {
- /* yes I could use uint32_t's, but then we would have to add some special
- * case checks in the loop, argh, and */
- /* honestly the major expense here on 32-bit CPU's will still be the
- * filename compare */
- mz_int64 l = 0, h = (mz_int64)size - 1;
-
- while (l <= h) {
- mz_int64 m = l + ((h - l) >> 1);
- uint32_t file_index = pIndices[(uint32_t)m];
-
- int comp = mz_zip_filename_compare(pCentral_dir, pCentral_dir_offsets,
- file_index, pFilename, filename_len);
- if (!comp) {
- if (pIndex)
- *pIndex = file_index;
- return MZ_TRUE;
- } else if (comp < 0)
- l = m + 1;
- else
- h = m - 1;
- }
- }
-
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_NOT_FOUND);
-}
-
-int mz_zip_reader_locate_file(mz_zip_archive *pZip, const char *pName,
- const char *pComment, mz_uint flags) {
- mz_uint32 index;
- if (!mz_zip_reader_locate_file_v2(pZip, pName, pComment, flags, &index))
- return -1;
- else
- return (int)index;
-}
-
-mz_bool mz_zip_reader_locate_file_v2(mz_zip_archive *pZip, const char *pName,
- const char *pComment, mz_uint flags,
- mz_uint32 *pIndex) {
- mz_uint file_index;
- size_t name_len, comment_len;
-
- if (pIndex)
- *pIndex = 0;
-
- if ((!pZip) || (!pZip->m_pState) || (!pName))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- /* See if we can use a binary search */
- if (((pZip->m_pState->m_init_flags &
- MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY) == 0) &&
- (pZip->m_zip_mode == MZ_ZIP_MODE_READING) &&
- ((flags & (MZ_ZIP_FLAG_IGNORE_PATH | MZ_ZIP_FLAG_CASE_SENSITIVE)) == 0) &&
- (!pComment) && (pZip->m_pState->m_sorted_central_dir_offsets.m_size)) {
- return mz_zip_locate_file_binary_search(pZip, pName, pIndex);
- }
-
- /* Locate the entry by scanning the entire central directory */
- name_len = strlen(pName);
- if (name_len > MZ_UINT16_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- comment_len = pComment ? strlen(pComment) : 0;
- if (comment_len > MZ_UINT16_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- for (file_index = 0; file_index < pZip->m_total_files; file_index++) {
- const mz_uint8 *pHeader = &MZ_ZIP_ARRAY_ELEMENT(
- &pZip->m_pState->m_central_dir, mz_uint8,
- MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_central_dir_offsets, mz_uint32,
- file_index));
- mz_uint filename_len = MZ_READ_LE16(pHeader + MZ_ZIP_CDH_FILENAME_LEN_OFS);
- const char *pFilename =
- (const char *)pHeader + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE;
- if (filename_len < name_len)
- continue;
- if (comment_len) {
- mz_uint file_extra_len = MZ_READ_LE16(pHeader + MZ_ZIP_CDH_EXTRA_LEN_OFS),
- file_comment_len =
- MZ_READ_LE16(pHeader + MZ_ZIP_CDH_COMMENT_LEN_OFS);
- const char *pFile_comment = pFilename + filename_len + file_extra_len;
- if ((file_comment_len != comment_len) ||
- (!mz_zip_string_equal(pComment, pFile_comment, file_comment_len,
- flags)))
- continue;
- }
- if ((flags & MZ_ZIP_FLAG_IGNORE_PATH) && (filename_len)) {
- int ofs = filename_len - 1;
- do {
- if ((pFilename[ofs] == '/') || (pFilename[ofs] == '\\') ||
- (pFilename[ofs] == ':'))
- break;
- } while (--ofs >= 0);
- ofs++;
- pFilename += ofs;
- filename_len -= ofs;
- }
- if ((filename_len == name_len) &&
- (mz_zip_string_equal(pName, pFilename, filename_len, flags))) {
- if (pIndex)
- *pIndex = file_index;
- return MZ_TRUE;
- }
- }
-
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_NOT_FOUND);
-}
-
-static mz_bool mz_zip_reader_extract_to_mem_no_alloc1(
- mz_zip_archive *pZip, mz_uint file_index, void *pBuf, size_t buf_size,
- mz_uint flags, void *pUser_read_buf, size_t user_read_buf_size,
- const mz_zip_archive_file_stat *st) {
- int status = TINFL_STATUS_DONE;
- mz_uint64 needed_size, cur_file_ofs, comp_remaining,
- out_buf_ofs = 0, read_buf_size, read_buf_ofs = 0, read_buf_avail;
- mz_zip_archive_file_stat file_stat;
- void *pRead_buf;
- mz_uint32
- local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) /
- sizeof(mz_uint32)];
- mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32;
- tinfl_decompressor inflator;
-
- if ((!pZip) || (!pZip->m_pState) || ((buf_size) && (!pBuf)) ||
- ((user_read_buf_size) && (!pUser_read_buf)) || (!pZip->m_pRead))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (st) {
- file_stat = *st;
- } else if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat))
- return MZ_FALSE;
-
- /* A directory or zero length file */
- if ((file_stat.m_is_directory) || (!file_stat.m_comp_size))
- return MZ_TRUE;
-
- /* Encryption and patch files are not supported. */
- if (file_stat.m_bit_flag &
- (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED |
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION |
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG))
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION);
-
- /* This function only supports decompressing stored and deflate. */
- if ((!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) && (file_stat.m_method != 0) &&
- (file_stat.m_method != MZ_DEFLATED))
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD);
-
- /* Ensure supplied output buffer is large enough. */
- needed_size = (flags & MZ_ZIP_FLAG_COMPRESSED_DATA) ? file_stat.m_comp_size
- : file_stat.m_uncomp_size;
- if (buf_size < needed_size)
- return mz_zip_set_error(pZip, MZ_ZIP_BUF_TOO_SMALL);
-
- /* Read and parse the local directory entry. */
- cur_file_ofs = file_stat.m_local_header_ofs;
- if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pLocal_header,
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE) !=
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
-
- if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- cur_file_ofs += MZ_ZIP_LOCAL_DIR_HEADER_SIZE +
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS) +
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS);
- if ((cur_file_ofs + file_stat.m_comp_size) > pZip->m_archive_size)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- if ((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) || (!file_stat.m_method)) {
- /* The file is stored or the caller has requested the compressed data. */
- if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pBuf,
- (size_t)needed_size) != needed_size)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
-
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- if ((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) == 0) {
- if (mz_crc32(MZ_CRC32_INIT, (const mz_uint8 *)pBuf,
- (size_t)file_stat.m_uncomp_size) != file_stat.m_crc32)
- return mz_zip_set_error(pZip, MZ_ZIP_CRC_CHECK_FAILED);
- }
-#endif
-
- return MZ_TRUE;
- }
-
- /* Decompress the file either directly from memory or from a file input
- * buffer. */
- tinfl_init(&inflator);
-
- if (pZip->m_pState->m_pMem) {
- /* Read directly from the archive in memory. */
- pRead_buf = (mz_uint8 *)pZip->m_pState->m_pMem + cur_file_ofs;
- read_buf_size = read_buf_avail = file_stat.m_comp_size;
- comp_remaining = 0;
- } else if (pUser_read_buf) {
- /* Use a user provided read buffer. */
- if (!user_read_buf_size)
- return MZ_FALSE;
- pRead_buf = (mz_uint8 *)pUser_read_buf;
- read_buf_size = user_read_buf_size;
- read_buf_avail = 0;
- comp_remaining = file_stat.m_comp_size;
- } else {
- /* Temporarily allocate a read buffer. */
- read_buf_size =
- MZ_MIN(file_stat.m_comp_size, (mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE);
- if (((sizeof(size_t) == sizeof(mz_uint32))) && (read_buf_size > 0x7FFFFFFF))
- return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
-
- if (NULL == (pRead_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1,
- (size_t)read_buf_size)))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- read_buf_avail = 0;
- comp_remaining = file_stat.m_comp_size;
- }
-
- do {
- /* The size_t cast here should be OK because we've verified that the output
- * buffer is >= file_stat.m_uncomp_size above */
- size_t in_buf_size,
- out_buf_size = (size_t)(file_stat.m_uncomp_size - out_buf_ofs);
- if ((!read_buf_avail) && (!pZip->m_pState->m_pMem)) {
- read_buf_avail = MZ_MIN(read_buf_size, comp_remaining);
- if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pRead_buf,
- (size_t)read_buf_avail) != read_buf_avail) {
- status = TINFL_STATUS_FAILED;
- mz_zip_set_error(pZip, MZ_ZIP_DECOMPRESSION_FAILED);
- break;
- }
- cur_file_ofs += read_buf_avail;
- comp_remaining -= read_buf_avail;
- read_buf_ofs = 0;
- }
- in_buf_size = (size_t)read_buf_avail;
- status = tinfl_decompress(
- &inflator, (mz_uint8 *)pRead_buf + read_buf_ofs, &in_buf_size,
- (mz_uint8 *)pBuf, (mz_uint8 *)pBuf + out_buf_ofs, &out_buf_size,
- TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF |
- (comp_remaining ? TINFL_FLAG_HAS_MORE_INPUT : 0));
- read_buf_avail -= in_buf_size;
- read_buf_ofs += in_buf_size;
- out_buf_ofs += out_buf_size;
- } while (status == TINFL_STATUS_NEEDS_MORE_INPUT);
-
- if (status == TINFL_STATUS_DONE) {
- /* Make sure the entire file was decompressed, and check its CRC. */
- if (out_buf_ofs != file_stat.m_uncomp_size) {
- mz_zip_set_error(pZip, MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE);
- status = TINFL_STATUS_FAILED;
- }
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- else if (mz_crc32(MZ_CRC32_INIT, (const mz_uint8 *)pBuf,
- (size_t)file_stat.m_uncomp_size) != file_stat.m_crc32) {
- mz_zip_set_error(pZip, MZ_ZIP_CRC_CHECK_FAILED);
- status = TINFL_STATUS_FAILED;
- }
-#endif
- }
-
- if ((!pZip->m_pState->m_pMem) && (!pUser_read_buf))
- pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf);
-
- return status == TINFL_STATUS_DONE;
-}
-
-mz_bool mz_zip_reader_extract_to_mem_no_alloc(mz_zip_archive *pZip,
- mz_uint file_index, void *pBuf,
- size_t buf_size, mz_uint flags,
- void *pUser_read_buf,
- size_t user_read_buf_size) {
- return mz_zip_reader_extract_to_mem_no_alloc1(pZip, file_index, pBuf,
- buf_size, flags, pUser_read_buf,
- user_read_buf_size, NULL);
-}
-
-mz_bool mz_zip_reader_extract_file_to_mem_no_alloc(
- mz_zip_archive *pZip, const char *pFilename, void *pBuf, size_t buf_size,
- mz_uint flags, void *pUser_read_buf, size_t user_read_buf_size) {
- mz_uint32 file_index;
- if (!mz_zip_reader_locate_file_v2(pZip, pFilename, NULL, flags, &file_index))
- return MZ_FALSE;
- return mz_zip_reader_extract_to_mem_no_alloc(pZip, file_index, pBuf, buf_size,
- flags, pUser_read_buf,
- user_read_buf_size);
-}
-
-mz_bool mz_zip_reader_extract_to_mem(mz_zip_archive *pZip, mz_uint file_index,
- void *pBuf, size_t buf_size,
- mz_uint flags) {
- return mz_zip_reader_extract_to_mem_no_alloc(pZip, file_index, pBuf, buf_size,
- flags, NULL, 0);
-}
-
-mz_bool mz_zip_reader_extract_file_to_mem(mz_zip_archive *pZip,
- const char *pFilename, void *pBuf,
- size_t buf_size, mz_uint flags) {
- return mz_zip_reader_extract_file_to_mem_no_alloc(pZip, pFilename, pBuf,
- buf_size, flags, NULL, 0);
-}
-
-void *mz_zip_reader_extract_to_heap(mz_zip_archive *pZip, mz_uint file_index,
- size_t *pSize, mz_uint flags) {
- mz_zip_archive_file_stat file_stat;
- mz_uint64 alloc_size;
- void *pBuf;
-
- if (pSize)
- *pSize = 0;
-
- if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat))
- return NULL;
-
- alloc_size = (flags & MZ_ZIP_FLAG_COMPRESSED_DATA) ? file_stat.m_comp_size
- : file_stat.m_uncomp_size;
- if (((sizeof(size_t) == sizeof(mz_uint32))) && (alloc_size > 0x7FFFFFFF)) {
- mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
- return NULL;
- }
-
- if (NULL ==
- (pBuf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1, (size_t)alloc_size))) {
- mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- return NULL;
- }
-
- if (!mz_zip_reader_extract_to_mem_no_alloc1(pZip, file_index, pBuf,
- (size_t)alloc_size, flags, NULL,
- 0, &file_stat)) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf);
- return NULL;
- }
-
- if (pSize)
- *pSize = (size_t)alloc_size;
- return pBuf;
-}
-
-void *mz_zip_reader_extract_file_to_heap(mz_zip_archive *pZip,
- const char *pFilename, size_t *pSize,
- mz_uint flags) {
- mz_uint32 file_index;
- if (!mz_zip_reader_locate_file_v2(pZip, pFilename, NULL, flags,
- &file_index)) {
- if (pSize)
- *pSize = 0;
- return MZ_FALSE;
- }
- return mz_zip_reader_extract_to_heap(pZip, file_index, pSize, flags);
-}
-
-mz_bool mz_zip_reader_extract_to_callback(mz_zip_archive *pZip,
- mz_uint file_index,
- mz_file_write_func pCallback,
- void *pOpaque, mz_uint flags) {
- int status = TINFL_STATUS_DONE;
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- mz_uint file_crc32 = MZ_CRC32_INIT;
-#endif
- mz_uint64 read_buf_size, read_buf_ofs = 0, read_buf_avail, comp_remaining,
- out_buf_ofs = 0, cur_file_ofs;
- mz_zip_archive_file_stat file_stat;
- void *pRead_buf = NULL;
- void *pWrite_buf = NULL;
- mz_uint32
- local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) /
- sizeof(mz_uint32)];
- mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32;
-
- if ((!pZip) || (!pZip->m_pState) || (!pCallback) || (!pZip->m_pRead))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat))
- return MZ_FALSE;
-
- /* A directory or zero length file */
- if (file_stat.m_is_directory || (!file_stat.m_comp_size))
- return MZ_TRUE;
-
- /* Encryption and patch files are not supported. */
- if (file_stat.m_bit_flag &
- (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED |
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION |
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG))
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION);
-
- /* This function only supports decompressing stored and deflate. */
- if ((!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) && (file_stat.m_method != 0) &&
- (file_stat.m_method != MZ_DEFLATED))
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD);
-
- /* Read and do some minimal validation of the local directory entry (this
- * doesn't crack the zip64 stuff, which we already have from the central dir)
- */
- cur_file_ofs = file_stat.m_local_header_ofs;
- if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pLocal_header,
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE) !=
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
-
- if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- cur_file_ofs += MZ_ZIP_LOCAL_DIR_HEADER_SIZE +
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS) +
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS);
- if ((cur_file_ofs + file_stat.m_comp_size) > pZip->m_archive_size)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- /* Decompress the file either directly from memory or from a file input
- * buffer. */
- if (pZip->m_pState->m_pMem) {
- pRead_buf = (mz_uint8 *)pZip->m_pState->m_pMem + cur_file_ofs;
- read_buf_size = read_buf_avail = file_stat.m_comp_size;
- comp_remaining = 0;
- } else {
- read_buf_size =
- MZ_MIN(file_stat.m_comp_size, (mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE);
- if (NULL == (pRead_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1,
- (size_t)read_buf_size)))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- read_buf_avail = 0;
- comp_remaining = file_stat.m_comp_size;
- }
-
- if ((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) || (!file_stat.m_method)) {
- /* The file is stored or the caller has requested the compressed data. */
- if (pZip->m_pState->m_pMem) {
- if (((sizeof(size_t) == sizeof(mz_uint32))) &&
- (file_stat.m_comp_size > MZ_UINT32_MAX))
- return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
-
- if (pCallback(pOpaque, out_buf_ofs, pRead_buf,
- (size_t)file_stat.m_comp_size) != file_stat.m_comp_size) {
- mz_zip_set_error(pZip, MZ_ZIP_WRITE_CALLBACK_FAILED);
- status = TINFL_STATUS_FAILED;
- } else if (!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) {
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- file_crc32 =
- (mz_uint32)mz_crc32(file_crc32, (const mz_uint8 *)pRead_buf,
- (size_t)file_stat.m_comp_size);
-#endif
- }
-
- cur_file_ofs += file_stat.m_comp_size;
- out_buf_ofs += file_stat.m_comp_size;
- comp_remaining = 0;
- } else {
- while (comp_remaining) {
- read_buf_avail = MZ_MIN(read_buf_size, comp_remaining);
- if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pRead_buf,
- (size_t)read_buf_avail) != read_buf_avail) {
- mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- status = TINFL_STATUS_FAILED;
- break;
- }
-
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- if (!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) {
- file_crc32 = (mz_uint32)mz_crc32(
- file_crc32, (const mz_uint8 *)pRead_buf, (size_t)read_buf_avail);
- }
-#endif
-
- if (pCallback(pOpaque, out_buf_ofs, pRead_buf,
- (size_t)read_buf_avail) != read_buf_avail) {
- mz_zip_set_error(pZip, MZ_ZIP_WRITE_CALLBACK_FAILED);
- status = TINFL_STATUS_FAILED;
- break;
- }
-
- cur_file_ofs += read_buf_avail;
- out_buf_ofs += read_buf_avail;
- comp_remaining -= read_buf_avail;
- }
- }
- } else {
- tinfl_decompressor inflator;
- tinfl_init(&inflator);
-
- if (NULL == (pWrite_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1,
- TINFL_LZ_DICT_SIZE))) {
- mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- status = TINFL_STATUS_FAILED;
- } else {
- do {
- mz_uint8 *pWrite_buf_cur =
- (mz_uint8 *)pWrite_buf + (out_buf_ofs & (TINFL_LZ_DICT_SIZE - 1));
- size_t in_buf_size,
- out_buf_size =
- TINFL_LZ_DICT_SIZE - (out_buf_ofs & (TINFL_LZ_DICT_SIZE - 1));
- if ((!read_buf_avail) && (!pZip->m_pState->m_pMem)) {
- read_buf_avail = MZ_MIN(read_buf_size, comp_remaining);
- if (pZip->m_pRead(pZip->m_pIO_opaque, cur_file_ofs, pRead_buf,
- (size_t)read_buf_avail) != read_buf_avail) {
- mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- status = TINFL_STATUS_FAILED;
- break;
- }
- cur_file_ofs += read_buf_avail;
- comp_remaining -= read_buf_avail;
- read_buf_ofs = 0;
- }
-
- in_buf_size = (size_t)read_buf_avail;
- status = tinfl_decompress(
- &inflator, (const mz_uint8 *)pRead_buf + read_buf_ofs, &in_buf_size,
- (mz_uint8 *)pWrite_buf, pWrite_buf_cur, &out_buf_size,
- comp_remaining ? TINFL_FLAG_HAS_MORE_INPUT : 0);
- read_buf_avail -= in_buf_size;
- read_buf_ofs += in_buf_size;
-
- if (out_buf_size) {
- if (pCallback(pOpaque, out_buf_ofs, pWrite_buf_cur, out_buf_size) !=
- out_buf_size) {
- mz_zip_set_error(pZip, MZ_ZIP_WRITE_CALLBACK_FAILED);
- status = TINFL_STATUS_FAILED;
- break;
- }
-
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- file_crc32 =
- (mz_uint32)mz_crc32(file_crc32, pWrite_buf_cur, out_buf_size);
-#endif
- if ((out_buf_ofs += out_buf_size) > file_stat.m_uncomp_size) {
- mz_zip_set_error(pZip, MZ_ZIP_DECOMPRESSION_FAILED);
- status = TINFL_STATUS_FAILED;
- break;
- }
- }
- } while ((status == TINFL_STATUS_NEEDS_MORE_INPUT) ||
- (status == TINFL_STATUS_HAS_MORE_OUTPUT));
- }
- }
-
- if ((status == TINFL_STATUS_DONE) &&
- (!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA))) {
- /* Make sure the entire file was decompressed, and check its CRC. */
- if (out_buf_ofs != file_stat.m_uncomp_size) {
- mz_zip_set_error(pZip, MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE);
- status = TINFL_STATUS_FAILED;
- }
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- else if (file_crc32 != file_stat.m_crc32) {
- mz_zip_set_error(pZip, MZ_ZIP_DECOMPRESSION_FAILED);
- status = TINFL_STATUS_FAILED;
- }
-#endif
- }
-
- if (!pZip->m_pState->m_pMem)
- pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf);
-
- if (pWrite_buf)
- pZip->m_pFree(pZip->m_pAlloc_opaque, pWrite_buf);
-
- return status == TINFL_STATUS_DONE;
-}
-
-mz_bool mz_zip_reader_extract_file_to_callback(mz_zip_archive *pZip,
- const char *pFilename,
- mz_file_write_func pCallback,
- void *pOpaque, mz_uint flags) {
- mz_uint32 file_index;
- if (!mz_zip_reader_locate_file_v2(pZip, pFilename, NULL, flags, &file_index))
- return MZ_FALSE;
-
- return mz_zip_reader_extract_to_callback(pZip, file_index, pCallback, pOpaque,
- flags);
-}
-
-mz_zip_reader_extract_iter_state *
-mz_zip_reader_extract_iter_new(mz_zip_archive *pZip, mz_uint file_index,
- mz_uint flags) {
- mz_zip_reader_extract_iter_state *pState;
- mz_uint32
- local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) /
- sizeof(mz_uint32)];
- mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32;
-
- /* Argument sanity check */
- if ((!pZip) || (!pZip->m_pState))
- return NULL;
-
- /* Allocate an iterator status structure */
- pState = (mz_zip_reader_extract_iter_state *)pZip->m_pAlloc(
- pZip->m_pAlloc_opaque, 1, sizeof(mz_zip_reader_extract_iter_state));
- if (!pState) {
- mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- return NULL;
- }
-
- /* Fetch file details */
- if (!mz_zip_reader_file_stat(pZip, file_index, &pState->file_stat)) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- return NULL;
- }
-
- /* Encryption and patch files are not supported. */
- if (pState->file_stat.m_bit_flag &
- (MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_IS_ENCRYPTED |
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_USES_STRONG_ENCRYPTION |
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_COMPRESSED_PATCH_FLAG)) {
- mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION);
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- return NULL;
- }
-
- /* This function only supports decompressing stored and deflate. */
- if ((!(flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) &&
- (pState->file_stat.m_method != 0) &&
- (pState->file_stat.m_method != MZ_DEFLATED)) {
- mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD);
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- return NULL;
- }
-
- /* Init state - save args */
- pState->pZip = pZip;
- pState->flags = flags;
-
- /* Init state - reset variables to defaults */
- pState->status = TINFL_STATUS_DONE;
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- pState->file_crc32 = MZ_CRC32_INIT;
-#endif
- pState->read_buf_ofs = 0;
- pState->out_buf_ofs = 0;
- pState->pRead_buf = NULL;
- pState->pWrite_buf = NULL;
- pState->out_blk_remain = 0;
-
- /* Read and parse the local directory entry. */
- pState->cur_file_ofs = pState->file_stat.m_local_header_ofs;
- if (pZip->m_pRead(pZip->m_pIO_opaque, pState->cur_file_ofs, pLocal_header,
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE) !=
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE) {
- mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- return NULL;
- }
-
- if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG) {
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- return NULL;
- }
-
- pState->cur_file_ofs +=
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE +
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS) +
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS);
- if ((pState->cur_file_ofs + pState->file_stat.m_comp_size) >
- pZip->m_archive_size) {
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- return NULL;
- }
-
- /* Decompress the file either directly from memory or from a file input
- * buffer. */
- if (pZip->m_pState->m_pMem) {
- pState->pRead_buf =
- (mz_uint8 *)pZip->m_pState->m_pMem + pState->cur_file_ofs;
- pState->read_buf_size = pState->read_buf_avail =
- pState->file_stat.m_comp_size;
- pState->comp_remaining = pState->file_stat.m_comp_size;
- } else {
- if (!((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) ||
- (!pState->file_stat.m_method))) {
- /* Decompression required, therefore intermediate read buffer required */
- pState->read_buf_size = MZ_MIN(pState->file_stat.m_comp_size,
- (mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE);
- if (NULL ==
- (pState->pRead_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1,
- (size_t)pState->read_buf_size))) {
- mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- return NULL;
- }
- } else {
- /* Decompression not required - we will be reading directly into user
- * buffer, no temp buf required */
- pState->read_buf_size = 0;
- }
- pState->read_buf_avail = 0;
- pState->comp_remaining = pState->file_stat.m_comp_size;
- }
-
- if (!((flags & MZ_ZIP_FLAG_COMPRESSED_DATA) ||
- (!pState->file_stat.m_method))) {
- /* Decompression required, init decompressor */
- tinfl_init(&pState->inflator);
-
- /* Allocate write buffer */
- if (NULL == (pState->pWrite_buf = pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1,
- TINFL_LZ_DICT_SIZE))) {
- mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- if (pState->pRead_buf)
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState->pRead_buf);
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- return NULL;
- }
- }
-
- return pState;
-}
-
-mz_zip_reader_extract_iter_state *
-mz_zip_reader_extract_file_iter_new(mz_zip_archive *pZip, const char *pFilename,
- mz_uint flags) {
- mz_uint32 file_index;
-
- /* Locate file index by name */
- if (!mz_zip_reader_locate_file_v2(pZip, pFilename, NULL, flags, &file_index))
- return NULL;
-
- /* Construct iterator */
- return mz_zip_reader_extract_iter_new(pZip, file_index, flags);
-}
-
-size_t mz_zip_reader_extract_iter_read(mz_zip_reader_extract_iter_state *pState,
- void *pvBuf, size_t buf_size) {
- size_t copied_to_caller = 0;
-
- /* Argument sanity check */
- if ((!pState) || (!pState->pZip) || (!pState->pZip->m_pState) || (!pvBuf))
- return 0;
-
- if ((pState->flags & MZ_ZIP_FLAG_COMPRESSED_DATA) ||
- (!pState->file_stat.m_method)) {
- /* The file is stored or the caller has requested the compressed data, calc
- * amount to return. */
- copied_to_caller = (size_t)MZ_MIN(buf_size, pState->comp_remaining);
-
- /* Zip is in memory....or requires reading from a file? */
- if (pState->pZip->m_pState->m_pMem) {
- /* Copy data to caller's buffer */
- memcpy(pvBuf, pState->pRead_buf, copied_to_caller);
- pState->pRead_buf = ((mz_uint8 *)pState->pRead_buf) + copied_to_caller;
- } else {
- /* Read directly into caller's buffer */
- if (pState->pZip->m_pRead(pState->pZip->m_pIO_opaque,
- pState->cur_file_ofs, pvBuf,
- copied_to_caller) != copied_to_caller) {
- /* Failed to read all that was asked for, flag failure and alert user */
- mz_zip_set_error(pState->pZip, MZ_ZIP_FILE_READ_FAILED);
- pState->status = TINFL_STATUS_FAILED;
- copied_to_caller = 0;
- }
- }
-
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- /* Compute CRC if not returning compressed data only */
- if (!(pState->flags & MZ_ZIP_FLAG_COMPRESSED_DATA))
- pState->file_crc32 = (mz_uint32)mz_crc32(
- pState->file_crc32, (const mz_uint8 *)pvBuf, copied_to_caller);
-#endif
-
- /* Advance offsets, dec counters */
- pState->cur_file_ofs += copied_to_caller;
- pState->out_buf_ofs += copied_to_caller;
- pState->comp_remaining -= copied_to_caller;
- } else {
- do {
- /* Calc ptr to write buffer - given current output pos and block size */
- mz_uint8 *pWrite_buf_cur =
- (mz_uint8 *)pState->pWrite_buf +
- (pState->out_buf_ofs & (TINFL_LZ_DICT_SIZE - 1));
-
- /* Calc max output size - given current output pos and block size */
- size_t in_buf_size,
- out_buf_size = TINFL_LZ_DICT_SIZE -
- (pState->out_buf_ofs & (TINFL_LZ_DICT_SIZE - 1));
-
- if (!pState->out_blk_remain) {
- /* Read more data from file if none available (and reading from file) */
- if ((!pState->read_buf_avail) && (!pState->pZip->m_pState->m_pMem)) {
- /* Calc read size */
- pState->read_buf_avail =
- MZ_MIN(pState->read_buf_size, pState->comp_remaining);
- if (pState->pZip->m_pRead(pState->pZip->m_pIO_opaque,
- pState->cur_file_ofs, pState->pRead_buf,
- (size_t)pState->read_buf_avail) !=
- pState->read_buf_avail) {
- mz_zip_set_error(pState->pZip, MZ_ZIP_FILE_READ_FAILED);
- pState->status = TINFL_STATUS_FAILED;
- break;
- }
-
- /* Advance offsets, dec counters */
- pState->cur_file_ofs += pState->read_buf_avail;
- pState->comp_remaining -= pState->read_buf_avail;
- pState->read_buf_ofs = 0;
- }
-
- /* Perform decompression */
- in_buf_size = (size_t)pState->read_buf_avail;
- pState->status = tinfl_decompress(
- &pState->inflator,
- (const mz_uint8 *)pState->pRead_buf + pState->read_buf_ofs,
- &in_buf_size, (mz_uint8 *)pState->pWrite_buf, pWrite_buf_cur,
- &out_buf_size,
- pState->comp_remaining ? TINFL_FLAG_HAS_MORE_INPUT : 0);
- pState->read_buf_avail -= in_buf_size;
- pState->read_buf_ofs += in_buf_size;
-
- /* Update current output block size remaining */
- pState->out_blk_remain = out_buf_size;
- }
-
- if (pState->out_blk_remain) {
- /* Calc amount to return. */
- size_t to_copy =
- MZ_MIN((buf_size - copied_to_caller), pState->out_blk_remain);
-
- /* Copy data to caller's buffer */
- memcpy((uint8_t *)pvBuf + copied_to_caller, pWrite_buf_cur, to_copy);
-
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- /* Perform CRC */
- pState->file_crc32 =
- (mz_uint32)mz_crc32(pState->file_crc32, pWrite_buf_cur, to_copy);
-#endif
-
- /* Decrement data consumed from block */
- pState->out_blk_remain -= to_copy;
-
- /* Inc output offset, while performing sanity check */
- if ((pState->out_buf_ofs += to_copy) >
- pState->file_stat.m_uncomp_size) {
- mz_zip_set_error(pState->pZip, MZ_ZIP_DECOMPRESSION_FAILED);
- pState->status = TINFL_STATUS_FAILED;
- break;
- }
-
- /* Increment counter of data copied to caller */
- copied_to_caller += to_copy;
- }
- } while ((copied_to_caller < buf_size) &&
- ((pState->status == TINFL_STATUS_NEEDS_MORE_INPUT) ||
- (pState->status == TINFL_STATUS_HAS_MORE_OUTPUT)));
- }
-
- /* Return how many bytes were copied into user buffer */
- return copied_to_caller;
-}
-
-mz_bool
-mz_zip_reader_extract_iter_free(mz_zip_reader_extract_iter_state *pState) {
- int status;
-
- /* Argument sanity check */
- if ((!pState) || (!pState->pZip) || (!pState->pZip->m_pState))
- return MZ_FALSE;
-
- /* Was decompression completed and requested? */
- if ((pState->status == TINFL_STATUS_DONE) &&
- (!(pState->flags & MZ_ZIP_FLAG_COMPRESSED_DATA))) {
- /* Make sure the entire file was decompressed, and check its CRC. */
- if (pState->out_buf_ofs != pState->file_stat.m_uncomp_size) {
- mz_zip_set_error(pState->pZip, MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE);
- pState->status = TINFL_STATUS_FAILED;
- }
-#ifndef MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS
- else if (pState->file_crc32 != pState->file_stat.m_crc32) {
- mz_zip_set_error(pState->pZip, MZ_ZIP_DECOMPRESSION_FAILED);
- pState->status = TINFL_STATUS_FAILED;
- }
-#endif
- }
-
- /* Free buffers */
- if (!pState->pZip->m_pState->m_pMem)
- pState->pZip->m_pFree(pState->pZip->m_pAlloc_opaque, pState->pRead_buf);
- if (pState->pWrite_buf)
- pState->pZip->m_pFree(pState->pZip->m_pAlloc_opaque, pState->pWrite_buf);
-
- /* Save status */
- status = pState->status;
-
- /* Free context */
- pState->pZip->m_pFree(pState->pZip->m_pAlloc_opaque, pState);
-
- return status == TINFL_STATUS_DONE;
-}
-
-#ifndef MINIZ_NO_STDIO
-static size_t mz_zip_file_write_callback(void *pOpaque, mz_uint64 ofs,
- const void *pBuf, size_t n) {
- (void)ofs;
-
- return MZ_FWRITE(pBuf, 1, n, (MZ_FILE *)pOpaque);
-}
-
-mz_bool mz_zip_reader_extract_to_file(mz_zip_archive *pZip, mz_uint file_index,
- const char *pDst_filename,
- mz_uint flags) {
- mz_bool status;
- mz_zip_archive_file_stat file_stat;
- MZ_FILE *pFile;
-
- if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat))
- return MZ_FALSE;
-
- if (file_stat.m_is_directory || (!file_stat.m_is_supported))
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_FEATURE);
-
- pFile = MZ_FOPEN(pDst_filename, "wb");
- if (!pFile)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED);
-
- status = mz_zip_reader_extract_to_callback(
- pZip, file_index, mz_zip_file_write_callback, pFile, flags);
-
- if (MZ_FCLOSE(pFile) == EOF) {
- if (status)
- mz_zip_set_error(pZip, MZ_ZIP_FILE_CLOSE_FAILED);
-
- status = MZ_FALSE;
- }
-
-#if !defined(MINIZ_NO_TIME) && !defined(MINIZ_NO_STDIO)
- if (status)
- mz_zip_set_file_times(pDst_filename, file_stat.m_time, file_stat.m_time);
-#endif
-
- return status;
-}
-
-mz_bool mz_zip_reader_extract_file_to_file(mz_zip_archive *pZip,
- const char *pArchive_filename,
- const char *pDst_filename,
- mz_uint flags) {
- mz_uint32 file_index;
- if (!mz_zip_reader_locate_file_v2(pZip, pArchive_filename, NULL, flags,
- &file_index))
- return MZ_FALSE;
-
- return mz_zip_reader_extract_to_file(pZip, file_index, pDst_filename, flags);
-}
-
-mz_bool mz_zip_reader_extract_to_cfile(mz_zip_archive *pZip, mz_uint file_index,
- MZ_FILE *pFile, mz_uint flags) {
- mz_zip_archive_file_stat file_stat;
-
- if (!mz_zip_reader_file_stat(pZip, file_index, &file_stat))
- return MZ_FALSE;
-
- if (file_stat.m_is_directory || (!file_stat.m_is_supported))
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_FEATURE);
-
- return mz_zip_reader_extract_to_callback(
- pZip, file_index, mz_zip_file_write_callback, pFile, flags);
-}
-
-mz_bool mz_zip_reader_extract_file_to_cfile(mz_zip_archive *pZip,
- const char *pArchive_filename,
- MZ_FILE *pFile, mz_uint flags) {
- mz_uint32 file_index;
- if (!mz_zip_reader_locate_file_v2(pZip, pArchive_filename, NULL, flags,
- &file_index))
- return MZ_FALSE;
-
- return mz_zip_reader_extract_to_cfile(pZip, file_index, pFile, flags);
-}
-#endif /* #ifndef MINIZ_NO_STDIO */
-
-static size_t mz_zip_compute_crc32_callback(void *pOpaque, mz_uint64 file_ofs,
- const void *pBuf, size_t n) {
- mz_uint32 *p = (mz_uint32 *)pOpaque;
- (void)file_ofs;
- *p = (mz_uint32)mz_crc32(*p, (const mz_uint8 *)pBuf, n);
- return n;
-}
-
-mz_bool mz_zip_validate_file(mz_zip_archive *pZip, mz_uint file_index,
- mz_uint flags) {
- mz_zip_archive_file_stat file_stat;
- mz_zip_internal_state *pState;
- const mz_uint8 *pCentral_dir_header;
- mz_bool found_zip64_ext_data_in_cdir = MZ_FALSE;
- mz_bool found_zip64_ext_data_in_ldir = MZ_FALSE;
- mz_uint32
- local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) /
- sizeof(mz_uint32)];
- mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32;
- mz_uint64 local_header_ofs = 0;
- mz_uint32 local_header_filename_len, local_header_extra_len,
- local_header_crc32;
- mz_uint64 local_header_comp_size, local_header_uncomp_size;
- mz_uint32 uncomp_crc32 = MZ_CRC32_INIT;
- mz_bool has_data_descriptor;
- mz_uint32 local_header_bit_flags;
-
- mz_zip_array file_data_array;
- mz_zip_array_init(&file_data_array, 1);
-
- if ((!pZip) || (!pZip->m_pState) || (!pZip->m_pAlloc) || (!pZip->m_pFree) ||
- (!pZip->m_pRead))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (file_index > pZip->m_total_files)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pState = pZip->m_pState;
-
- pCentral_dir_header = mz_zip_get_cdh(pZip, file_index);
-
- if (!mz_zip_file_stat_internal(pZip, file_index, pCentral_dir_header,
- &file_stat, &found_zip64_ext_data_in_cdir))
- return MZ_FALSE;
-
- /* A directory or zero length file */
- if (file_stat.m_is_directory || (!file_stat.m_uncomp_size))
- return MZ_TRUE;
-
- /* Encryption and patch files are not supported. */
- if (file_stat.m_is_encrypted)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_ENCRYPTION);
-
- /* This function only supports stored and deflate. */
- if ((file_stat.m_method != 0) && (file_stat.m_method != MZ_DEFLATED))
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_METHOD);
-
- if (!file_stat.m_is_supported)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_FEATURE);
-
- /* Read and parse the local directory entry. */
- local_header_ofs = file_stat.m_local_header_ofs;
- if (pZip->m_pRead(pZip->m_pIO_opaque, local_header_ofs, pLocal_header,
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE) !=
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
-
- if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- local_header_filename_len =
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS);
- local_header_extra_len =
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS);
- local_header_comp_size =
- MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_COMPRESSED_SIZE_OFS);
- local_header_uncomp_size =
- MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_DECOMPRESSED_SIZE_OFS);
- local_header_crc32 = MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_CRC32_OFS);
- local_header_bit_flags =
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_BIT_FLAG_OFS);
- has_data_descriptor = (local_header_bit_flags & 8) != 0;
-
- if (local_header_filename_len != strlen(file_stat.m_filename))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- if ((local_header_ofs + MZ_ZIP_LOCAL_DIR_HEADER_SIZE +
- local_header_filename_len + local_header_extra_len +
- file_stat.m_comp_size) > pZip->m_archive_size)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- if (!mz_zip_array_resize(
- pZip, &file_data_array,
- MZ_MAX(local_header_filename_len, local_header_extra_len),
- MZ_FALSE)) {
- mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- goto handle_failure;
- }
-
- if (local_header_filename_len) {
- if (pZip->m_pRead(pZip->m_pIO_opaque,
- local_header_ofs + MZ_ZIP_LOCAL_DIR_HEADER_SIZE,
- file_data_array.m_p,
- local_header_filename_len) != local_header_filename_len) {
- mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- goto handle_failure;
- }
-
- /* I've seen 1 archive that had the same pathname, but used backslashes in
- * the local dir and forward slashes in the central dir. Do we care about
- * this? For now, this case will fail validation. */
- if (memcmp(file_stat.m_filename, file_data_array.m_p,
- local_header_filename_len) != 0) {
- mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED);
- goto handle_failure;
- }
- }
-
- if ((local_header_extra_len) &&
- ((local_header_comp_size == MZ_UINT32_MAX) ||
- (local_header_uncomp_size == MZ_UINT32_MAX))) {
- mz_uint32 extra_size_remaining = local_header_extra_len;
- const mz_uint8 *pExtra_data = (const mz_uint8 *)file_data_array.m_p;
-
- if (pZip->m_pRead(pZip->m_pIO_opaque,
- local_header_ofs + MZ_ZIP_LOCAL_DIR_HEADER_SIZE +
- local_header_filename_len,
- file_data_array.m_p,
- local_header_extra_len) != local_header_extra_len) {
- mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- goto handle_failure;
- }
-
- do {
- mz_uint32 field_id, field_data_size, field_total_size;
-
- if (extra_size_remaining < (sizeof(mz_uint16) * 2)) {
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- goto handle_failure;
- }
-
- field_id = MZ_READ_LE16(pExtra_data);
- field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16));
- field_total_size = field_data_size + sizeof(mz_uint16) * 2;
-
- if (field_total_size > extra_size_remaining) {
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- goto handle_failure;
- }
-
- if (field_id == MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) {
- const mz_uint8 *pSrc_field_data = pExtra_data + sizeof(mz_uint32);
-
- if (field_data_size < sizeof(mz_uint64) * 2) {
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- goto handle_failure;
- }
-
- local_header_uncomp_size = MZ_READ_LE64(pSrc_field_data);
- local_header_comp_size =
- MZ_READ_LE64(pSrc_field_data + sizeof(mz_uint64));
-
- found_zip64_ext_data_in_ldir = MZ_TRUE;
- break;
- }
-
- pExtra_data += field_total_size;
- extra_size_remaining -= field_total_size;
- } while (extra_size_remaining);
- }
-
- /* TODO: parse local header extra data when local_header_comp_size is
- * 0xFFFFFFFF! (big_descriptor.zip) */
- /* I've seen zips in the wild with the data descriptor bit set, but proper
- * local header values and bogus data descriptors */
- if ((has_data_descriptor) && (!local_header_comp_size) &&
- (!local_header_crc32)) {
- mz_uint8 descriptor_buf[32];
- mz_bool has_id;
- const mz_uint8 *pSrc;
- mz_uint32 file_crc32;
- mz_uint64 comp_size = 0, uncomp_size = 0;
-
- mz_uint32 num_descriptor_uint32s =
- ((pState->m_zip64) || (found_zip64_ext_data_in_ldir)) ? 6 : 4;
-
- if (pZip->m_pRead(pZip->m_pIO_opaque,
- local_header_ofs + MZ_ZIP_LOCAL_DIR_HEADER_SIZE +
- local_header_filename_len + local_header_extra_len +
- file_stat.m_comp_size,
- descriptor_buf,
- sizeof(mz_uint32) * num_descriptor_uint32s) !=
- (sizeof(mz_uint32) * num_descriptor_uint32s)) {
- mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- goto handle_failure;
- }
-
- has_id = (MZ_READ_LE32(descriptor_buf) == MZ_ZIP_DATA_DESCRIPTOR_ID);
- pSrc = has_id ? (descriptor_buf + sizeof(mz_uint32)) : descriptor_buf;
-
- file_crc32 = MZ_READ_LE32(pSrc);
-
- if ((pState->m_zip64) || (found_zip64_ext_data_in_ldir)) {
- comp_size = MZ_READ_LE64(pSrc + sizeof(mz_uint32));
- uncomp_size = MZ_READ_LE64(pSrc + sizeof(mz_uint32) + sizeof(mz_uint64));
- } else {
- comp_size = MZ_READ_LE32(pSrc + sizeof(mz_uint32));
- uncomp_size = MZ_READ_LE32(pSrc + sizeof(mz_uint32) + sizeof(mz_uint32));
- }
-
- if ((file_crc32 != file_stat.m_crc32) ||
- (comp_size != file_stat.m_comp_size) ||
- (uncomp_size != file_stat.m_uncomp_size)) {
- mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED);
- goto handle_failure;
- }
- } else {
- if ((local_header_crc32 != file_stat.m_crc32) ||
- (local_header_comp_size != file_stat.m_comp_size) ||
- (local_header_uncomp_size != file_stat.m_uncomp_size)) {
- mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED);
- goto handle_failure;
- }
- }
-
- mz_zip_array_clear(pZip, &file_data_array);
-
- if ((flags & MZ_ZIP_FLAG_VALIDATE_HEADERS_ONLY) == 0) {
- if (!mz_zip_reader_extract_to_callback(
- pZip, file_index, mz_zip_compute_crc32_callback, &uncomp_crc32, 0))
- return MZ_FALSE;
-
- /* 1 more check to be sure, although the extract checks too. */
- if (uncomp_crc32 != file_stat.m_crc32) {
- mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED);
- return MZ_FALSE;
- }
- }
-
- return MZ_TRUE;
-
-handle_failure:
- mz_zip_array_clear(pZip, &file_data_array);
- return MZ_FALSE;
-}
-
-mz_bool mz_zip_validate_archive(mz_zip_archive *pZip, mz_uint flags) {
- mz_zip_internal_state *pState;
- uint32_t i;
-
- if ((!pZip) || (!pZip->m_pState) || (!pZip->m_pAlloc) || (!pZip->m_pFree) ||
- (!pZip->m_pRead))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pState = pZip->m_pState;
-
- /* Basic sanity checks */
- if (!pState->m_zip64) {
- if (pZip->m_total_files > MZ_UINT16_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
-
- if (pZip->m_archive_size > MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
- } else {
- if (pZip->m_total_files >= MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
-
- if (pState->m_central_dir.m_size >= MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
- }
-
- for (i = 0; i < pZip->m_total_files; i++) {
- if (MZ_ZIP_FLAG_VALIDATE_LOCATE_FILE_FLAG & flags) {
- mz_uint32 found_index;
- mz_zip_archive_file_stat stat;
-
- if (!mz_zip_reader_file_stat(pZip, i, &stat))
- return MZ_FALSE;
-
- if (!mz_zip_reader_locate_file_v2(pZip, stat.m_filename, NULL, 0,
- &found_index))
- return MZ_FALSE;
-
- /* This check can fail if there are duplicate filenames in the archive
- * (which we don't check for when writing - that's up to the user) */
- if (found_index != i)
- return mz_zip_set_error(pZip, MZ_ZIP_VALIDATION_FAILED);
- }
-
- if (!mz_zip_validate_file(pZip, i, flags))
- return MZ_FALSE;
- }
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_validate_mem_archive(const void *pMem, size_t size,
- mz_uint flags, mz_zip_error *pErr) {
- mz_bool success = MZ_TRUE;
- mz_zip_archive zip;
- mz_zip_error actual_err = MZ_ZIP_NO_ERROR;
-
- if ((!pMem) || (!size)) {
- if (pErr)
- *pErr = MZ_ZIP_INVALID_PARAMETER;
- return MZ_FALSE;
- }
-
- mz_zip_zero_struct(&zip);
-
- if (!mz_zip_reader_init_mem(&zip, pMem, size, flags)) {
- if (pErr)
- *pErr = zip.m_last_error;
- return MZ_FALSE;
- }
-
- if (!mz_zip_validate_archive(&zip, flags)) {
- actual_err = zip.m_last_error;
- success = MZ_FALSE;
- }
-
- if (!mz_zip_reader_end_internal(&zip, success)) {
- if (!actual_err)
- actual_err = zip.m_last_error;
- success = MZ_FALSE;
- }
-
- if (pErr)
- *pErr = actual_err;
-
- return success;
-}
-
-#ifndef MINIZ_NO_STDIO
-mz_bool mz_zip_validate_file_archive(const char *pFilename, mz_uint flags,
- mz_zip_error *pErr) {
- mz_bool success = MZ_TRUE;
- mz_zip_archive zip;
- mz_zip_error actual_err = MZ_ZIP_NO_ERROR;
-
- if (!pFilename) {
- if (pErr)
- *pErr = MZ_ZIP_INVALID_PARAMETER;
- return MZ_FALSE;
- }
-
- mz_zip_zero_struct(&zip);
-
- if (!mz_zip_reader_init_file_v2(&zip, pFilename, flags, 0, 0)) {
- if (pErr)
- *pErr = zip.m_last_error;
- return MZ_FALSE;
- }
-
- if (!mz_zip_validate_archive(&zip, flags)) {
- actual_err = zip.m_last_error;
- success = MZ_FALSE;
- }
-
- if (!mz_zip_reader_end_internal(&zip, success)) {
- if (!actual_err)
- actual_err = zip.m_last_error;
- success = MZ_FALSE;
- }
-
- if (pErr)
- *pErr = actual_err;
-
- return success;
-}
-#endif /* #ifndef MINIZ_NO_STDIO */
-
-/* ------------------- .ZIP archive writing */
-
-#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS
-
-static MZ_FORCEINLINE void mz_write_le16(mz_uint8 *p, mz_uint16 v) {
- p[0] = (mz_uint8)v;
- p[1] = (mz_uint8)(v >> 8);
-}
-static MZ_FORCEINLINE void mz_write_le32(mz_uint8 *p, mz_uint32 v) {
- p[0] = (mz_uint8)v;
- p[1] = (mz_uint8)(v >> 8);
- p[2] = (mz_uint8)(v >> 16);
- p[3] = (mz_uint8)(v >> 24);
-}
-static MZ_FORCEINLINE void mz_write_le64(mz_uint8 *p, mz_uint64 v) {
- mz_write_le32(p, (mz_uint32)v);
- mz_write_le32(p + sizeof(mz_uint32), (mz_uint32)(v >> 32));
-}
-
-#define MZ_WRITE_LE16(p, v) mz_write_le16((mz_uint8 *)(p), (mz_uint16)(v))
-#define MZ_WRITE_LE32(p, v) mz_write_le32((mz_uint8 *)(p), (mz_uint32)(v))
-#define MZ_WRITE_LE64(p, v) mz_write_le64((mz_uint8 *)(p), (mz_uint64)(v))
-
-static size_t mz_zip_heap_write_func(void *pOpaque, mz_uint64 file_ofs,
- const void *pBuf, size_t n) {
- mz_zip_archive *pZip = (mz_zip_archive *)pOpaque;
- mz_zip_internal_state *pState = pZip->m_pState;
- mz_uint64 new_size = MZ_MAX(file_ofs + n, pState->m_mem_size);
-
- if (!n)
- return 0;
-
- /* An allocation this big is likely to just fail on 32-bit systems, so don't
- * even go there. */
- if ((sizeof(size_t) == sizeof(mz_uint32)) && (new_size > 0x7FFFFFFF)) {
- mz_zip_set_error(pZip, MZ_ZIP_FILE_TOO_LARGE);
- return 0;
- }
-
- if (new_size > pState->m_mem_capacity) {
- void *pNew_block;
- size_t new_capacity = MZ_MAX(64, pState->m_mem_capacity);
-
- while (new_capacity < new_size)
- new_capacity *= 2;
-
- if (NULL == (pNew_block = pZip->m_pRealloc(
- pZip->m_pAlloc_opaque, pState->m_pMem, 1, new_capacity))) {
- mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- return 0;
- }
-
- pState->m_pMem = pNew_block;
- pState->m_mem_capacity = new_capacity;
- }
- memcpy((mz_uint8 *)pState->m_pMem + file_ofs, pBuf, n);
- pState->m_mem_size = (size_t)new_size;
- return n;
-}
-
-static mz_bool mz_zip_writer_end_internal(mz_zip_archive *pZip,
- mz_bool set_last_error) {
- mz_zip_internal_state *pState;
- mz_bool status = MZ_TRUE;
-
- if ((!pZip) || (!pZip->m_pState) || (!pZip->m_pAlloc) || (!pZip->m_pFree) ||
- ((pZip->m_zip_mode != MZ_ZIP_MODE_WRITING) &&
- (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED))) {
- if (set_last_error)
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- return MZ_FALSE;
- }
-
- pState = pZip->m_pState;
- pZip->m_pState = NULL;
- mz_zip_array_clear(pZip, &pState->m_central_dir);
- mz_zip_array_clear(pZip, &pState->m_central_dir_offsets);
- mz_zip_array_clear(pZip, &pState->m_sorted_central_dir_offsets);
-
-#ifndef MINIZ_NO_STDIO
- if (pState->m_pFile) {
- if (pZip->m_zip_type == MZ_ZIP_TYPE_FILE) {
- if (MZ_FCLOSE(pState->m_pFile) == EOF) {
- if (set_last_error)
- mz_zip_set_error(pZip, MZ_ZIP_FILE_CLOSE_FAILED);
- status = MZ_FALSE;
- }
- }
-
- pState->m_pFile = NULL;
- }
-#endif /* #ifndef MINIZ_NO_STDIO */
-
- if ((pZip->m_pWrite == mz_zip_heap_write_func) && (pState->m_pMem)) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState->m_pMem);
- pState->m_pMem = NULL;
- }
-
- pZip->m_pFree(pZip->m_pAlloc_opaque, pState);
- pZip->m_zip_mode = MZ_ZIP_MODE_INVALID;
- return status;
-}
-
-mz_bool mz_zip_writer_init_v2(mz_zip_archive *pZip, mz_uint64 existing_size,
- mz_uint flags) {
- mz_bool zip64 = (flags & MZ_ZIP_FLAG_WRITE_ZIP64) != 0;
-
- if ((!pZip) || (pZip->m_pState) || (!pZip->m_pWrite) ||
- (pZip->m_zip_mode != MZ_ZIP_MODE_INVALID))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING) {
- if (!pZip->m_pRead)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- }
-
- if (pZip->m_file_offset_alignment) {
- /* Ensure user specified file offset alignment is a power of 2. */
- if (pZip->m_file_offset_alignment & (pZip->m_file_offset_alignment - 1))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- }
-
- if (!pZip->m_pAlloc)
- pZip->m_pAlloc = miniz_def_alloc_func;
- if (!pZip->m_pFree)
- pZip->m_pFree = miniz_def_free_func;
- if (!pZip->m_pRealloc)
- pZip->m_pRealloc = miniz_def_realloc_func;
-
- pZip->m_archive_size = existing_size;
- pZip->m_central_directory_file_ofs = 0;
- pZip->m_total_files = 0;
-
- if (NULL == (pZip->m_pState = (mz_zip_internal_state *)pZip->m_pAlloc(
- pZip->m_pAlloc_opaque, 1, sizeof(mz_zip_internal_state))))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- memset(pZip->m_pState, 0, sizeof(mz_zip_internal_state));
-
- MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_central_dir,
- sizeof(mz_uint8));
- MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_central_dir_offsets,
- sizeof(mz_uint32));
- MZ_ZIP_ARRAY_SET_ELEMENT_SIZE(&pZip->m_pState->m_sorted_central_dir_offsets,
- sizeof(mz_uint32));
-
- pZip->m_pState->m_zip64 = zip64;
- pZip->m_pState->m_zip64_has_extended_info_fields = zip64;
-
- pZip->m_zip_type = MZ_ZIP_TYPE_USER;
- pZip->m_zip_mode = MZ_ZIP_MODE_WRITING;
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_init(mz_zip_archive *pZip, mz_uint64 existing_size) {
- return mz_zip_writer_init_v2(pZip, existing_size, 0);
-}
-
-mz_bool mz_zip_writer_init_heap_v2(mz_zip_archive *pZip,
- size_t size_to_reserve_at_beginning,
- size_t initial_allocation_size,
- mz_uint flags) {
- pZip->m_pWrite = mz_zip_heap_write_func;
- pZip->m_pNeeds_keepalive = NULL;
-
- if (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING)
- pZip->m_pRead = mz_zip_mem_read_func;
-
- pZip->m_pIO_opaque = pZip;
-
- if (!mz_zip_writer_init_v2(pZip, size_to_reserve_at_beginning, flags))
- return MZ_FALSE;
-
- pZip->m_zip_type = MZ_ZIP_TYPE_HEAP;
-
- if (0 != (initial_allocation_size = MZ_MAX(initial_allocation_size,
- size_to_reserve_at_beginning))) {
- if (NULL == (pZip->m_pState->m_pMem = pZip->m_pAlloc(
- pZip->m_pAlloc_opaque, 1, initial_allocation_size))) {
- mz_zip_writer_end_internal(pZip, MZ_FALSE);
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
- pZip->m_pState->m_mem_capacity = initial_allocation_size;
- }
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_init_heap(mz_zip_archive *pZip,
- size_t size_to_reserve_at_beginning,
- size_t initial_allocation_size) {
- return mz_zip_writer_init_heap_v2(pZip, size_to_reserve_at_beginning,
- initial_allocation_size, 0);
-}
-
-#ifndef MINIZ_NO_STDIO
-static size_t mz_zip_file_write_func(void *pOpaque, mz_uint64 file_ofs,
- const void *pBuf, size_t n) {
- mz_zip_archive *pZip = (mz_zip_archive *)pOpaque;
- mz_int64 cur_ofs = MZ_FTELL64(pZip->m_pState->m_pFile);
-
- file_ofs += pZip->m_pState->m_file_archive_start_ofs;
-
- if (((mz_int64)file_ofs < 0) ||
- (((cur_ofs != (mz_int64)file_ofs)) &&
- (MZ_FSEEK64(pZip->m_pState->m_pFile, (mz_int64)file_ofs, SEEK_SET)))) {
- mz_zip_set_error(pZip, MZ_ZIP_FILE_SEEK_FAILED);
- return 0;
- }
-
- return MZ_FWRITE(pBuf, 1, n, pZip->m_pState->m_pFile);
-}
-
-mz_bool mz_zip_writer_init_file(mz_zip_archive *pZip, const char *pFilename,
- mz_uint64 size_to_reserve_at_beginning) {
- return mz_zip_writer_init_file_v2(pZip, pFilename,
- size_to_reserve_at_beginning, 0);
-}
-
-mz_bool mz_zip_writer_init_file_v2(mz_zip_archive *pZip, const char *pFilename,
- mz_uint64 size_to_reserve_at_beginning,
- mz_uint flags) {
- MZ_FILE *pFile;
-
- pZip->m_pWrite = mz_zip_file_write_func;
- pZip->m_pNeeds_keepalive = NULL;
-
- if (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING)
- pZip->m_pRead = mz_zip_file_read_func;
-
- pZip->m_pIO_opaque = pZip;
-
- if (!mz_zip_writer_init_v2(pZip, size_to_reserve_at_beginning, flags))
- return MZ_FALSE;
-
- if (NULL == (pFile = MZ_FOPEN(
- pFilename,
- (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING) ? "w+b" : "wb"))) {
- mz_zip_writer_end(pZip);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED);
- }
-
- pZip->m_pState->m_pFile = pFile;
- pZip->m_zip_type = MZ_ZIP_TYPE_FILE;
-
- if (size_to_reserve_at_beginning) {
- mz_uint64 cur_ofs = 0;
- char buf[4096];
-
- MZ_CLEAR_OBJ(buf);
-
- do {
- size_t n = (size_t)MZ_MIN(sizeof(buf), size_to_reserve_at_beginning);
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_ofs, buf, n) != n) {
- mz_zip_writer_end(pZip);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
- cur_ofs += n;
- size_to_reserve_at_beginning -= n;
- } while (size_to_reserve_at_beginning);
- }
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_init_cfile(mz_zip_archive *pZip, MZ_FILE *pFile,
- mz_uint flags) {
- pZip->m_pWrite = mz_zip_file_write_func;
- pZip->m_pNeeds_keepalive = NULL;
-
- if (flags & MZ_ZIP_FLAG_WRITE_ALLOW_READING)
- pZip->m_pRead = mz_zip_file_read_func;
-
- pZip->m_pIO_opaque = pZip;
-
- if (!mz_zip_writer_init_v2(pZip, 0, flags))
- return MZ_FALSE;
-
- pZip->m_pState->m_pFile = pFile;
- pZip->m_pState->m_file_archive_start_ofs =
- MZ_FTELL64(pZip->m_pState->m_pFile);
- pZip->m_zip_type = MZ_ZIP_TYPE_CFILE;
-
- return MZ_TRUE;
-}
-#endif /* #ifndef MINIZ_NO_STDIO */
-
-mz_bool mz_zip_writer_init_from_reader_v2(mz_zip_archive *pZip,
- const char *pFilename,
- mz_uint flags) {
- mz_zip_internal_state *pState;
-
- if ((!pZip) || (!pZip->m_pState) || (pZip->m_zip_mode != MZ_ZIP_MODE_READING))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (flags & MZ_ZIP_FLAG_WRITE_ZIP64) {
- /* We don't support converting a non-zip64 file to zip64 - this seems like
- * more trouble than it's worth. (What about the existing 32-bit data
- * descriptors that could follow the compressed data?) */
- if (!pZip->m_pState->m_zip64)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- }
-
- /* No sense in trying to write to an archive that's already at the support max
- * size */
- if (pZip->m_pState->m_zip64) {
- if (pZip->m_total_files == MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
- } else {
- if (pZip->m_total_files == MZ_UINT16_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
-
- if ((pZip->m_archive_size + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE +
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE) > MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_TOO_LARGE);
- }
-
- pState = pZip->m_pState;
-
- if (pState->m_pFile) {
-#ifdef MINIZ_NO_STDIO
- (void)pFilename;
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-#else
- if (pZip->m_pIO_opaque != pZip)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (pZip->m_zip_type == MZ_ZIP_TYPE_FILE) {
- if (!pFilename)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- /* Archive is being read from stdio and was originally opened only for
- * reading. Try to reopen as writable. */
- if (NULL ==
- (pState->m_pFile = MZ_FREOPEN(pFilename, "r+b", pState->m_pFile))) {
- /* The mz_zip_archive is now in a bogus state because pState->m_pFile is
- * NULL, so just close it. */
- mz_zip_reader_end_internal(pZip, MZ_FALSE);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED);
- }
- }
-
- pZip->m_pWrite = mz_zip_file_write_func;
- pZip->m_pNeeds_keepalive = NULL;
-#endif /* #ifdef MINIZ_NO_STDIO */
- } else if (pState->m_pMem) {
- /* Archive lives in a memory block. Assume it's from the heap that we can
- * resize using the realloc callback. */
- if (pZip->m_pIO_opaque != pZip)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pState->m_mem_capacity = pState->m_mem_size;
- pZip->m_pWrite = mz_zip_heap_write_func;
- pZip->m_pNeeds_keepalive = NULL;
- }
- /* Archive is being read via a user provided read function - make sure the
- user has specified a write function too. */
- else if (!pZip->m_pWrite)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- /* Start writing new files at the archive's current central directory
- * location. */
- /* TODO: We could add a flag that lets the user start writing immediately
- * AFTER the existing central dir - this would be safer. */
- pZip->m_archive_size = pZip->m_central_directory_file_ofs;
- pZip->m_central_directory_file_ofs = 0;
-
- /* Clear the sorted central dir offsets, they aren't useful or maintained now.
- */
- /* Even though we're now in write mode, files can still be extracted and
- * verified, but file locates will be slow. */
- /* TODO: We could easily maintain the sorted central directory offsets. */
- mz_zip_array_clear(pZip, &pZip->m_pState->m_sorted_central_dir_offsets);
-
- pZip->m_zip_mode = MZ_ZIP_MODE_WRITING;
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_init_from_reader_v2_noreopen(mz_zip_archive *pZip,
- const char *pFilename,
- mz_uint flags) {
- mz_zip_internal_state *pState;
-
- if ((!pZip) || (!pZip->m_pState) || (pZip->m_zip_mode != MZ_ZIP_MODE_READING))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (flags & MZ_ZIP_FLAG_WRITE_ZIP64) {
- /* We don't support converting a non-zip64 file to zip64 - this seems like
- * more trouble than it's worth. (What about the existing 32-bit data
- * descriptors that could follow the compressed data?) */
- if (!pZip->m_pState->m_zip64)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- }
-
- /* No sense in trying to write to an archive that's already at the support max
- * size */
- if (pZip->m_pState->m_zip64) {
- if (pZip->m_total_files == MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
- } else {
- if (pZip->m_total_files == MZ_UINT16_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
-
- if ((pZip->m_archive_size + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE +
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE) > MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_TOO_LARGE);
- }
-
- pState = pZip->m_pState;
-
- if (pState->m_pFile) {
-#ifdef MINIZ_NO_STDIO
- (void)pFilename;
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-#else
- if (pZip->m_pIO_opaque != pZip)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (pZip->m_zip_type == MZ_ZIP_TYPE_FILE) {
- if (!pFilename)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- }
-
- pZip->m_pWrite = mz_zip_file_write_func;
- pZip->m_pNeeds_keepalive = NULL;
-#endif /* #ifdef MINIZ_NO_STDIO */
- } else if (pState->m_pMem) {
- /* Archive lives in a memory block. Assume it's from the heap that we can
- * resize using the realloc callback. */
- if (pZip->m_pIO_opaque != pZip)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pState->m_mem_capacity = pState->m_mem_size;
- pZip->m_pWrite = mz_zip_heap_write_func;
- pZip->m_pNeeds_keepalive = NULL;
- }
- /* Archive is being read via a user provided read function - make sure the
- user has specified a write function too. */
- else if (!pZip->m_pWrite)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- /* Start writing new files at the archive's current central directory
- * location. */
- /* TODO: We could add a flag that lets the user start writing immediately
- * AFTER the existing central dir - this would be safer. */
- pZip->m_archive_size = pZip->m_central_directory_file_ofs;
- pZip->m_central_directory_file_ofs = 0;
-
- /* Clear the sorted central dir offsets, they aren't useful or maintained now.
- */
- /* Even though we're now in write mode, files can still be extracted and
- * verified, but file locates will be slow. */
- /* TODO: We could easily maintain the sorted central directory offsets. */
- mz_zip_array_clear(pZip, &pZip->m_pState->m_sorted_central_dir_offsets);
-
- pZip->m_zip_mode = MZ_ZIP_MODE_WRITING;
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_init_from_reader(mz_zip_archive *pZip,
- const char *pFilename) {
- return mz_zip_writer_init_from_reader_v2(pZip, pFilename, 0);
-}
-
-/* TODO: pArchive_name is a terrible name here! */
-mz_bool mz_zip_writer_add_mem(mz_zip_archive *pZip, const char *pArchive_name,
- const void *pBuf, size_t buf_size,
- mz_uint level_and_flags) {
- return mz_zip_writer_add_mem_ex(pZip, pArchive_name, pBuf, buf_size, NULL, 0,
- level_and_flags, 0, 0);
-}
-
-typedef struct {
- mz_zip_archive *m_pZip;
- mz_uint64 m_cur_archive_file_ofs;
- mz_uint64 m_comp_size;
-} mz_zip_writer_add_state;
-
-static mz_bool mz_zip_writer_add_put_buf_callback(const void *pBuf, int len,
- void *pUser) {
- mz_zip_writer_add_state *pState = (mz_zip_writer_add_state *)pUser;
- if ((int)pState->m_pZip->m_pWrite(pState->m_pZip->m_pIO_opaque,
- pState->m_cur_archive_file_ofs, pBuf,
- len) != len)
- return MZ_FALSE;
-
- pState->m_cur_archive_file_ofs += len;
- pState->m_comp_size += len;
- return MZ_TRUE;
-}
-
-#define MZ_ZIP64_MAX_LOCAL_EXTRA_FIELD_SIZE \
- (sizeof(mz_uint16) * 2 + sizeof(mz_uint64) * 2)
-#define MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE \
- (sizeof(mz_uint16) * 2 + sizeof(mz_uint64) * 3)
-static mz_uint32
-mz_zip_writer_create_zip64_extra_data(mz_uint8 *pBuf, mz_uint64 *pUncomp_size,
- mz_uint64 *pComp_size,
- mz_uint64 *pLocal_header_ofs) {
- mz_uint8 *pDst = pBuf;
- mz_uint32 field_size = 0;
-
- MZ_WRITE_LE16(pDst + 0, MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID);
- MZ_WRITE_LE16(pDst + 2, 0);
- pDst += sizeof(mz_uint16) * 2;
-
- if (pUncomp_size) {
- MZ_WRITE_LE64(pDst, *pUncomp_size);
- pDst += sizeof(mz_uint64);
- field_size += sizeof(mz_uint64);
- }
-
- if (pComp_size) {
- MZ_WRITE_LE64(pDst, *pComp_size);
- pDst += sizeof(mz_uint64);
- field_size += sizeof(mz_uint64);
- }
-
- if (pLocal_header_ofs) {
- MZ_WRITE_LE64(pDst, *pLocal_header_ofs);
- pDst += sizeof(mz_uint64);
- field_size += sizeof(mz_uint64);
- }
-
- MZ_WRITE_LE16(pBuf + 2, field_size);
-
- return (mz_uint32)(pDst - pBuf);
-}
-
-static mz_bool mz_zip_writer_create_local_dir_header(
- mz_zip_archive *pZip, mz_uint8 *pDst, mz_uint16 filename_size,
- mz_uint16 extra_size, mz_uint64 uncomp_size, mz_uint64 comp_size,
- mz_uint32 uncomp_crc32, mz_uint16 method, mz_uint16 bit_flags,
- mz_uint16 dos_time, mz_uint16 dos_date) {
- (void)pZip;
- memset(pDst, 0, MZ_ZIP_LOCAL_DIR_HEADER_SIZE);
- MZ_WRITE_LE32(pDst + MZ_ZIP_LDH_SIG_OFS, MZ_ZIP_LOCAL_DIR_HEADER_SIG);
- MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_VERSION_NEEDED_OFS, method ? 20 : 0);
- MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_BIT_FLAG_OFS, bit_flags);
- MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_METHOD_OFS, method);
- MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_FILE_TIME_OFS, dos_time);
- MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_FILE_DATE_OFS, dos_date);
- MZ_WRITE_LE32(pDst + MZ_ZIP_LDH_CRC32_OFS, uncomp_crc32);
- MZ_WRITE_LE32(pDst + MZ_ZIP_LDH_COMPRESSED_SIZE_OFS,
- MZ_MIN(comp_size, MZ_UINT32_MAX));
- MZ_WRITE_LE32(pDst + MZ_ZIP_LDH_DECOMPRESSED_SIZE_OFS,
- MZ_MIN(uncomp_size, MZ_UINT32_MAX));
- MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_FILENAME_LEN_OFS, filename_size);
- MZ_WRITE_LE16(pDst + MZ_ZIP_LDH_EXTRA_LEN_OFS, extra_size);
- return MZ_TRUE;
-}
-
-static mz_bool mz_zip_writer_create_central_dir_header(
- mz_zip_archive *pZip, mz_uint8 *pDst, mz_uint16 filename_size,
- mz_uint16 extra_size, mz_uint16 comment_size, mz_uint64 uncomp_size,
- mz_uint64 comp_size, mz_uint32 uncomp_crc32, mz_uint16 method,
- mz_uint16 bit_flags, mz_uint16 dos_time, mz_uint16 dos_date,
- mz_uint64 local_header_ofs, mz_uint32 ext_attributes) {
- (void)pZip;
- memset(pDst, 0, MZ_ZIP_CENTRAL_DIR_HEADER_SIZE);
- MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_SIG_OFS, MZ_ZIP_CENTRAL_DIR_HEADER_SIG);
- MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_VERSION_NEEDED_OFS, method ? 20 : 0);
- MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_BIT_FLAG_OFS, bit_flags);
- MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_METHOD_OFS, method);
- MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_FILE_TIME_OFS, dos_time);
- MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_FILE_DATE_OFS, dos_date);
- MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_CRC32_OFS, uncomp_crc32);
- MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_COMPRESSED_SIZE_OFS,
- MZ_MIN(comp_size, MZ_UINT32_MAX));
- MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS,
- MZ_MIN(uncomp_size, MZ_UINT32_MAX));
- MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_FILENAME_LEN_OFS, filename_size);
- MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_EXTRA_LEN_OFS, extra_size);
- MZ_WRITE_LE16(pDst + MZ_ZIP_CDH_COMMENT_LEN_OFS, comment_size);
- MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_EXTERNAL_ATTR_OFS, ext_attributes);
- MZ_WRITE_LE32(pDst + MZ_ZIP_CDH_LOCAL_HEADER_OFS,
- MZ_MIN(local_header_ofs, MZ_UINT32_MAX));
- return MZ_TRUE;
-}
-
-static mz_bool mz_zip_writer_add_to_central_dir(
- mz_zip_archive *pZip, const char *pFilename, mz_uint16 filename_size,
- const void *pExtra, mz_uint16 extra_size, const void *pComment,
- mz_uint16 comment_size, mz_uint64 uncomp_size, mz_uint64 comp_size,
- mz_uint32 uncomp_crc32, mz_uint16 method, mz_uint16 bit_flags,
- mz_uint16 dos_time, mz_uint16 dos_date, mz_uint64 local_header_ofs,
- mz_uint32 ext_attributes, const char *user_extra_data,
- mz_uint user_extra_data_len) {
- mz_zip_internal_state *pState = pZip->m_pState;
- mz_uint32 central_dir_ofs = (mz_uint32)pState->m_central_dir.m_size;
- size_t orig_central_dir_size = pState->m_central_dir.m_size;
- mz_uint8 central_dir_header[MZ_ZIP_CENTRAL_DIR_HEADER_SIZE];
-
- if (!pZip->m_pState->m_zip64) {
- if (local_header_ofs > 0xFFFFFFFF)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_TOO_LARGE);
- }
-
- /* miniz doesn't support central dirs >= MZ_UINT32_MAX bytes yet */
- if (((mz_uint64)pState->m_central_dir.m_size +
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + filename_size + extra_size +
- user_extra_data_len + comment_size) >= MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE);
-
- if (!mz_zip_writer_create_central_dir_header(
- pZip, central_dir_header, filename_size,
- (mz_uint16)(extra_size + user_extra_data_len), comment_size,
- uncomp_size, comp_size, uncomp_crc32, method, bit_flags, dos_time,
- dos_date, local_header_ofs, ext_attributes))
- return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
-
- if ((!mz_zip_array_push_back(pZip, &pState->m_central_dir, central_dir_header,
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE)) ||
- (!mz_zip_array_push_back(pZip, &pState->m_central_dir, pFilename,
- filename_size)) ||
- (!mz_zip_array_push_back(pZip, &pState->m_central_dir, pExtra,
- extra_size)) ||
- (!mz_zip_array_push_back(pZip, &pState->m_central_dir, user_extra_data,
- user_extra_data_len)) ||
- (!mz_zip_array_push_back(pZip, &pState->m_central_dir, pComment,
- comment_size)) ||
- (!mz_zip_array_push_back(pZip, &pState->m_central_dir_offsets,
- ¢ral_dir_ofs, 1))) {
- /* Try to resize the central directory array back into its original state.
- */
- mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size,
- MZ_FALSE);
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- return MZ_TRUE;
-}
-
-static mz_bool mz_zip_writer_validate_archive_name(const char *pArchive_name) {
- /* Basic ZIP archive filename validity checks: Valid filenames cannot start
- * with a forward slash, cannot contain a drive letter, and cannot use
- * DOS-style backward slashes. */
- if (*pArchive_name == '/')
- return MZ_FALSE;
-
- /* Making sure the name does not contain drive letters or DOS style backward
- * slashes is the responsibility of the program using miniz*/
-
- return MZ_TRUE;
-}
-
-static mz_uint
-mz_zip_writer_compute_padding_needed_for_file_alignment(mz_zip_archive *pZip) {
- mz_uint32 n;
- if (!pZip->m_file_offset_alignment)
- return 0;
- n = (mz_uint32)(pZip->m_archive_size & (pZip->m_file_offset_alignment - 1));
- return (mz_uint)((pZip->m_file_offset_alignment - n) &
- (pZip->m_file_offset_alignment - 1));
-}
-
-static mz_bool mz_zip_writer_write_zeros(mz_zip_archive *pZip,
- mz_uint64 cur_file_ofs, mz_uint32 n) {
- char buf[4096];
- memset(buf, 0, MZ_MIN(sizeof(buf), n));
- while (n) {
- mz_uint32 s = MZ_MIN(sizeof(buf), n);
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_file_ofs, buf, s) != s)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_file_ofs += s;
- n -= s;
- }
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_add_mem_ex(mz_zip_archive *pZip,
- const char *pArchive_name, const void *pBuf,
- size_t buf_size, const void *pComment,
- mz_uint16 comment_size,
- mz_uint level_and_flags, mz_uint64 uncomp_size,
- mz_uint32 uncomp_crc32) {
- return mz_zip_writer_add_mem_ex_v2(
- pZip, pArchive_name, pBuf, buf_size, pComment, comment_size,
- level_and_flags, uncomp_size, uncomp_crc32, NULL, NULL, 0, NULL, 0);
-}
-
-mz_bool mz_zip_writer_add_mem_ex_v2(
- mz_zip_archive *pZip, const char *pArchive_name, const void *pBuf,
- size_t buf_size, const void *pComment, mz_uint16 comment_size,
- mz_uint level_and_flags, mz_uint64 uncomp_size, mz_uint32 uncomp_crc32,
- MZ_TIME_T *last_modified, const char *user_extra_data,
- mz_uint user_extra_data_len, const char *user_extra_data_central,
- mz_uint user_extra_data_central_len) {
- mz_uint16 method = 0, dos_time = 0, dos_date = 0;
- mz_uint level, ext_attributes = 0, num_alignment_padding_bytes;
- mz_uint64 local_dir_header_ofs = 0, cur_archive_file_ofs = 0, comp_size = 0;
- size_t archive_name_size;
- mz_uint8 local_dir_header[MZ_ZIP_LOCAL_DIR_HEADER_SIZE];
- tdefl_compressor *pComp = NULL;
- mz_bool store_data_uncompressed;
- mz_zip_internal_state *pState;
- mz_uint8 *pExtra_data = NULL;
- mz_uint32 extra_size = 0;
- mz_uint8 extra_data[MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE];
- mz_uint16 bit_flags = 0;
-
- if ((int)level_and_flags < 0)
- level_and_flags = MZ_DEFAULT_LEVEL;
-
- if (uncomp_size ||
- (buf_size && !(level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA)))
- bit_flags |= MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR;
-
- if (!(level_and_flags & MZ_ZIP_FLAG_ASCII_FILENAME))
- bit_flags |= MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8;
-
- level = level_and_flags & 0xF;
- store_data_uncompressed =
- ((!level) || (level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA));
-
- if ((!pZip) || (!pZip->m_pState) ||
- (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING) || ((buf_size) && (!pBuf)) ||
- (!pArchive_name) || ((comment_size) && (!pComment)) ||
- (level > MZ_UBER_COMPRESSION))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pState = pZip->m_pState;
- local_dir_header_ofs = pZip->m_archive_size;
- cur_archive_file_ofs = pZip->m_archive_size;
-
- if (pState->m_zip64) {
- if (pZip->m_total_files == MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
- } else {
- if (pZip->m_total_files == MZ_UINT16_MAX) {
- pState->m_zip64 = MZ_TRUE;
- /*return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); */
- }
- if ((buf_size > 0xFFFFFFFF) || (uncomp_size > 0xFFFFFFFF)) {
- pState->m_zip64 = MZ_TRUE;
- /*return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); */
- }
- }
-
- if ((!(level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) && (uncomp_size))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (!mz_zip_writer_validate_archive_name(pArchive_name))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_FILENAME);
-
-#ifndef MINIZ_NO_TIME
- if (last_modified != NULL) {
- mz_zip_time_t_to_dos_time(*last_modified, &dos_time, &dos_date);
- } else {
- MZ_TIME_T cur_time;
- time(&cur_time);
- mz_zip_time_t_to_dos_time(cur_time, &dos_time, &dos_date);
- }
-#endif /* #ifndef MINIZ_NO_TIME */
-
- if (!(level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) {
- uncomp_crc32 =
- (mz_uint32)mz_crc32(MZ_CRC32_INIT, (const mz_uint8 *)pBuf, buf_size);
- uncomp_size = buf_size;
- if (uncomp_size <= 3) {
- level = 0;
- store_data_uncompressed = MZ_TRUE;
- }
- }
-
- archive_name_size = strlen(pArchive_name);
- if (archive_name_size > MZ_UINT16_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_FILENAME);
-
- num_alignment_padding_bytes =
- mz_zip_writer_compute_padding_needed_for_file_alignment(pZip);
-
- /* miniz doesn't support central dirs >= MZ_UINT32_MAX bytes yet */
- if (((mz_uint64)pState->m_central_dir.m_size +
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size +
- MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE + comment_size) >= MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE);
-
- if (!pState->m_zip64) {
- /* Bail early if the archive would obviously become too large */
- if ((pZip->m_archive_size + num_alignment_padding_bytes +
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE + archive_name_size +
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size + comment_size +
- user_extra_data_len + pState->m_central_dir.m_size +
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE + user_extra_data_central_len +
- MZ_ZIP_DATA_DESCRIPTER_SIZE32) > 0xFFFFFFFF) {
- pState->m_zip64 = MZ_TRUE;
- /*return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); */
- }
- }
-
- if ((archive_name_size) && (pArchive_name[archive_name_size - 1] == '/')) {
- /* Set DOS Subdirectory attribute bit. */
- ext_attributes |= MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG;
-
- /* Subdirectories cannot contain data. */
- if ((buf_size) || (uncomp_size))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- }
-
- /* Try to do any allocations before writing to the archive, so if an
- * allocation fails the file remains unmodified. (A good idea if we're doing
- * an in-place modification.) */
- if ((!mz_zip_array_ensure_room(
- pZip, &pState->m_central_dir,
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size + comment_size +
- (pState->m_zip64 ? MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE : 0))) ||
- (!mz_zip_array_ensure_room(pZip, &pState->m_central_dir_offsets, 1)))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- if ((!store_data_uncompressed) && (buf_size)) {
- if (NULL == (pComp = (tdefl_compressor *)pZip->m_pAlloc(
- pZip->m_pAlloc_opaque, 1, sizeof(tdefl_compressor))))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- if (!mz_zip_writer_write_zeros(pZip, cur_archive_file_ofs,
- num_alignment_padding_bytes)) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pComp);
- return MZ_FALSE;
- }
-
- local_dir_header_ofs += num_alignment_padding_bytes;
- if (pZip->m_file_offset_alignment) {
- MZ_ASSERT((local_dir_header_ofs & (pZip->m_file_offset_alignment - 1)) ==
- 0);
- }
- cur_archive_file_ofs += num_alignment_padding_bytes;
-
- MZ_CLEAR_OBJ(local_dir_header);
-
- if (!store_data_uncompressed ||
- (level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA)) {
- method = MZ_DEFLATED;
- }
-
- if (pState->m_zip64) {
- if (uncomp_size >= MZ_UINT32_MAX || local_dir_header_ofs >= MZ_UINT32_MAX) {
- pExtra_data = extra_data;
- extra_size = mz_zip_writer_create_zip64_extra_data(
- extra_data, (uncomp_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL,
- (uncomp_size >= MZ_UINT32_MAX) ? &comp_size : NULL,
- (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs
- : NULL);
- }
-
- if (!mz_zip_writer_create_local_dir_header(
- pZip, local_dir_header, (mz_uint16)archive_name_size,
- (mz_uint16)(extra_size + user_extra_data_len), 0, 0, 0, method,
- bit_flags, dos_time, dos_date))
- return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, local_dir_header_ofs,
- local_dir_header,
- sizeof(local_dir_header)) != sizeof(local_dir_header))
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_archive_file_ofs += sizeof(local_dir_header);
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pArchive_name,
- archive_name_size) != archive_name_size) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pComp);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
- cur_archive_file_ofs += archive_name_size;
-
- if (pExtra_data != NULL) {
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, extra_data,
- extra_size) != extra_size)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_archive_file_ofs += extra_size;
- }
- } else {
- if ((comp_size > MZ_UINT32_MAX) || (cur_archive_file_ofs > MZ_UINT32_MAX))
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
- if (!mz_zip_writer_create_local_dir_header(
- pZip, local_dir_header, (mz_uint16)archive_name_size,
- (mz_uint16)user_extra_data_len, 0, 0, 0, method, bit_flags,
- dos_time, dos_date))
- return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, local_dir_header_ofs,
- local_dir_header,
- sizeof(local_dir_header)) != sizeof(local_dir_header))
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_archive_file_ofs += sizeof(local_dir_header);
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pArchive_name,
- archive_name_size) != archive_name_size) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pComp);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
- cur_archive_file_ofs += archive_name_size;
- }
-
- if (user_extra_data_len > 0) {
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs,
- user_extra_data,
- user_extra_data_len) != user_extra_data_len)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_archive_file_ofs += user_extra_data_len;
- }
-
- if (store_data_uncompressed) {
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pBuf,
- buf_size) != buf_size) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pComp);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
-
- cur_archive_file_ofs += buf_size;
- comp_size = buf_size;
- } else if (buf_size) {
- mz_zip_writer_add_state state;
-
- state.m_pZip = pZip;
- state.m_cur_archive_file_ofs = cur_archive_file_ofs;
- state.m_comp_size = 0;
-
- if ((tdefl_init(pComp, mz_zip_writer_add_put_buf_callback, &state,
- tdefl_create_comp_flags_from_zip_params(
- level, -15, MZ_DEFAULT_STRATEGY)) !=
- TDEFL_STATUS_OKAY) ||
- (tdefl_compress_buffer(pComp, pBuf, buf_size, TDEFL_FINISH) !=
- TDEFL_STATUS_DONE)) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pComp);
- return mz_zip_set_error(pZip, MZ_ZIP_COMPRESSION_FAILED);
- }
-
- comp_size = state.m_comp_size;
- cur_archive_file_ofs = state.m_cur_archive_file_ofs;
- }
-
- pZip->m_pFree(pZip->m_pAlloc_opaque, pComp);
- pComp = NULL;
-
- if (uncomp_size) {
- mz_uint8 local_dir_footer[MZ_ZIP_DATA_DESCRIPTER_SIZE64];
- mz_uint32 local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE32;
-
- MZ_ASSERT(bit_flags & MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR);
-
- MZ_WRITE_LE32(local_dir_footer + 0, MZ_ZIP_DATA_DESCRIPTOR_ID);
- MZ_WRITE_LE32(local_dir_footer + 4, uncomp_crc32);
- if (pExtra_data == NULL) {
- if (comp_size > MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
-
- MZ_WRITE_LE32(local_dir_footer + 8, comp_size);
- MZ_WRITE_LE32(local_dir_footer + 12, uncomp_size);
- } else {
- MZ_WRITE_LE64(local_dir_footer + 8, comp_size);
- MZ_WRITE_LE64(local_dir_footer + 16, uncomp_size);
- local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE64;
- }
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs,
- local_dir_footer,
- local_dir_footer_size) != local_dir_footer_size)
- return MZ_FALSE;
-
- cur_archive_file_ofs += local_dir_footer_size;
- }
-
- if (pExtra_data != NULL) {
- extra_size = mz_zip_writer_create_zip64_extra_data(
- extra_data, (uncomp_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL,
- (uncomp_size >= MZ_UINT32_MAX) ? &comp_size : NULL,
- (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs : NULL);
- }
-
- if (!mz_zip_writer_add_to_central_dir(
- pZip, pArchive_name, (mz_uint16)archive_name_size, pExtra_data,
- (mz_uint16)extra_size, pComment, comment_size, uncomp_size, comp_size,
- uncomp_crc32, method, bit_flags, dos_time, dos_date,
- local_dir_header_ofs, ext_attributes, user_extra_data_central,
- user_extra_data_central_len))
- return MZ_FALSE;
-
- pZip->m_total_files++;
- pZip->m_archive_size = cur_archive_file_ofs;
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_add_read_buf_callback(
- mz_zip_archive *pZip, const char *pArchive_name,
- mz_file_read_func read_callback, void *callback_opaque, mz_uint64 max_size,
- const MZ_TIME_T *pFile_time, const void *pComment, mz_uint16 comment_size,
- mz_uint level_and_flags, mz_uint32 ext_attributes,
- const char *user_extra_data, mz_uint user_extra_data_len,
- const char *user_extra_data_central, mz_uint user_extra_data_central_len) {
- mz_uint16 gen_flags = (level_and_flags & MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE)
- ? 0
- : MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR;
- mz_uint uncomp_crc32 = MZ_CRC32_INIT, level, num_alignment_padding_bytes;
- mz_uint16 method = 0, dos_time = 0, dos_date = 0;
- mz_uint64 local_dir_header_ofs, cur_archive_file_ofs = 0, uncomp_size = 0,
- comp_size = 0;
- size_t archive_name_size;
- mz_uint8 local_dir_header[MZ_ZIP_LOCAL_DIR_HEADER_SIZE];
- mz_uint8 *pExtra_data = NULL;
- mz_uint32 extra_size = 0;
- mz_uint8 extra_data[MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE];
- mz_zip_internal_state *pState;
- mz_uint64 file_ofs = 0, cur_archive_header_file_ofs;
-
- if (!(level_and_flags & MZ_ZIP_FLAG_ASCII_FILENAME))
- gen_flags |= MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8;
-
- if ((int)level_and_flags < 0)
- level_and_flags = MZ_DEFAULT_LEVEL;
- level = level_and_flags & 0xF;
-
- /* Sanity checks */
- if ((!pZip) || (!pZip->m_pState) ||
- (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING) || (!pArchive_name) ||
- ((comment_size) && (!pComment)) || (level > MZ_UBER_COMPRESSION))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pState = pZip->m_pState;
- cur_archive_file_ofs = pZip->m_archive_size;
-
- if ((!pState->m_zip64) && (max_size > MZ_UINT32_MAX)) {
- /* Source file is too large for non-zip64 */
- /*return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); */
- pState->m_zip64 = MZ_TRUE;
- }
-
- /* We could support this, but why? */
- if (level_and_flags & MZ_ZIP_FLAG_COMPRESSED_DATA)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (!mz_zip_writer_validate_archive_name(pArchive_name))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_FILENAME);
-
- if (pState->m_zip64) {
- if (pZip->m_total_files == MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
- } else {
- if (pZip->m_total_files == MZ_UINT16_MAX) {
- pState->m_zip64 = MZ_TRUE;
- /*return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES); */
- }
- }
-
- archive_name_size = strlen(pArchive_name);
- if (archive_name_size > MZ_UINT16_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_FILENAME);
-
- num_alignment_padding_bytes =
- mz_zip_writer_compute_padding_needed_for_file_alignment(pZip);
-
- /* miniz doesn't support central dirs >= MZ_UINT32_MAX bytes yet */
- if (((mz_uint64)pState->m_central_dir.m_size +
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size +
- MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE + comment_size) >= MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE);
-
- if (!pState->m_zip64) {
- /* Bail early if the archive would obviously become too large */
- if ((pZip->m_archive_size + num_alignment_padding_bytes +
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE + archive_name_size +
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + archive_name_size + comment_size +
- user_extra_data_len + pState->m_central_dir.m_size +
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE + 1024 +
- MZ_ZIP_DATA_DESCRIPTER_SIZE32 + user_extra_data_central_len) >
- 0xFFFFFFFF) {
- pState->m_zip64 = MZ_TRUE;
- /*return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE); */
- }
- }
-
-#ifndef MINIZ_NO_TIME
- if (pFile_time) {
- mz_zip_time_t_to_dos_time(*pFile_time, &dos_time, &dos_date);
- }
-#endif
-
- if (max_size <= 3)
- level = 0;
-
- if (!mz_zip_writer_write_zeros(pZip, cur_archive_file_ofs,
- num_alignment_padding_bytes)) {
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
-
- cur_archive_file_ofs += num_alignment_padding_bytes;
- local_dir_header_ofs = cur_archive_file_ofs;
-
- if (pZip->m_file_offset_alignment) {
- MZ_ASSERT((cur_archive_file_ofs & (pZip->m_file_offset_alignment - 1)) ==
- 0);
- }
-
- if (max_size && level) {
- method = MZ_DEFLATED;
- }
-
- MZ_CLEAR_OBJ(local_dir_header);
- if (pState->m_zip64) {
- if (max_size >= MZ_UINT32_MAX || local_dir_header_ofs >= MZ_UINT32_MAX) {
- pExtra_data = extra_data;
- if (level_and_flags & MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE)
- extra_size = mz_zip_writer_create_zip64_extra_data(
- extra_data, (max_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL,
- (max_size >= MZ_UINT32_MAX) ? &comp_size : NULL,
- (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs
- : NULL);
- else
- extra_size = mz_zip_writer_create_zip64_extra_data(
- extra_data, NULL, NULL,
- (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs
- : NULL);
- }
-
- if (!mz_zip_writer_create_local_dir_header(
- pZip, local_dir_header, (mz_uint16)archive_name_size,
- (mz_uint16)(extra_size + user_extra_data_len), 0, 0, 0, method,
- gen_flags, dos_time, dos_date))
- return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs,
- local_dir_header,
- sizeof(local_dir_header)) != sizeof(local_dir_header))
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_archive_file_ofs += sizeof(local_dir_header);
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pArchive_name,
- archive_name_size) != archive_name_size) {
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
-
- cur_archive_file_ofs += archive_name_size;
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, extra_data,
- extra_size) != extra_size)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_archive_file_ofs += extra_size;
- } else {
- if ((comp_size > MZ_UINT32_MAX) || (cur_archive_file_ofs > MZ_UINT32_MAX))
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
- if (!mz_zip_writer_create_local_dir_header(
- pZip, local_dir_header, (mz_uint16)archive_name_size,
- (mz_uint16)user_extra_data_len, 0, 0, 0, method, gen_flags,
- dos_time, dos_date))
- return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs,
- local_dir_header,
- sizeof(local_dir_header)) != sizeof(local_dir_header))
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_archive_file_ofs += sizeof(local_dir_header);
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pArchive_name,
- archive_name_size) != archive_name_size) {
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
-
- cur_archive_file_ofs += archive_name_size;
- }
-
- if (user_extra_data_len > 0) {
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs,
- user_extra_data,
- user_extra_data_len) != user_extra_data_len)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_archive_file_ofs += user_extra_data_len;
- }
-
- if (max_size) {
- void *pRead_buf =
- pZip->m_pAlloc(pZip->m_pAlloc_opaque, 1, MZ_ZIP_MAX_IO_BUF_SIZE);
- if (!pRead_buf) {
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- if (!level) {
- while (1) {
- size_t n = read_callback(callback_opaque, file_ofs, pRead_buf,
- MZ_ZIP_MAX_IO_BUF_SIZE);
- if (n == 0)
- break;
-
- if ((n > MZ_ZIP_MAX_IO_BUF_SIZE) || (file_ofs + n > max_size)) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- }
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs, pRead_buf,
- n) != n) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
- file_ofs += n;
- uncomp_crc32 =
- (mz_uint32)mz_crc32(uncomp_crc32, (const mz_uint8 *)pRead_buf, n);
- cur_archive_file_ofs += n;
- }
- uncomp_size = file_ofs;
- comp_size = uncomp_size;
- } else {
- mz_bool result = MZ_FALSE;
- mz_zip_writer_add_state state;
- tdefl_compressor *pComp = (tdefl_compressor *)pZip->m_pAlloc(
- pZip->m_pAlloc_opaque, 1, sizeof(tdefl_compressor));
- if (!pComp) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf);
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- state.m_pZip = pZip;
- state.m_cur_archive_file_ofs = cur_archive_file_ofs;
- state.m_comp_size = 0;
-
- if (tdefl_init(pComp, mz_zip_writer_add_put_buf_callback, &state,
- tdefl_create_comp_flags_from_zip_params(
- level, -15, MZ_DEFAULT_STRATEGY)) !=
- TDEFL_STATUS_OKAY) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pComp);
- pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf);
- return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
- }
-
- for (;;) {
- tdefl_status status;
- tdefl_flush flush = TDEFL_NO_FLUSH;
-
- size_t n = read_callback(callback_opaque, file_ofs, pRead_buf,
- MZ_ZIP_MAX_IO_BUF_SIZE);
- if ((n > MZ_ZIP_MAX_IO_BUF_SIZE) || (file_ofs + n > max_size)) {
- mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- break;
- }
-
- file_ofs += n;
- uncomp_crc32 =
- (mz_uint32)mz_crc32(uncomp_crc32, (const mz_uint8 *)pRead_buf, n);
-
- if (pZip->m_pNeeds_keepalive != NULL &&
- pZip->m_pNeeds_keepalive(pZip->m_pIO_opaque))
- flush = TDEFL_FULL_FLUSH;
-
- if (n == 0)
- flush = TDEFL_FINISH;
-
- status = tdefl_compress_buffer(pComp, pRead_buf, n, flush);
- if (status == TDEFL_STATUS_DONE) {
- result = MZ_TRUE;
- break;
- } else if (status != TDEFL_STATUS_OKAY) {
- mz_zip_set_error(pZip, MZ_ZIP_COMPRESSION_FAILED);
- break;
- }
- }
-
- pZip->m_pFree(pZip->m_pAlloc_opaque, pComp);
-
- if (!result) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf);
- return MZ_FALSE;
- }
-
- uncomp_size = file_ofs;
- comp_size = state.m_comp_size;
- cur_archive_file_ofs = state.m_cur_archive_file_ofs;
- }
-
- pZip->m_pFree(pZip->m_pAlloc_opaque, pRead_buf);
- }
-
- if (!(level_and_flags & MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE)) {
- mz_uint8 local_dir_footer[MZ_ZIP_DATA_DESCRIPTER_SIZE64];
- mz_uint32 local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE32;
-
- MZ_WRITE_LE32(local_dir_footer + 0, MZ_ZIP_DATA_DESCRIPTOR_ID);
- MZ_WRITE_LE32(local_dir_footer + 4, uncomp_crc32);
- if (pExtra_data == NULL) {
- if (comp_size > MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
-
- MZ_WRITE_LE32(local_dir_footer + 8, comp_size);
- MZ_WRITE_LE32(local_dir_footer + 12, uncomp_size);
- } else {
- MZ_WRITE_LE64(local_dir_footer + 8, comp_size);
- MZ_WRITE_LE64(local_dir_footer + 16, uncomp_size);
- local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE64;
- }
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_file_ofs,
- local_dir_footer,
- local_dir_footer_size) != local_dir_footer_size)
- return MZ_FALSE;
-
- cur_archive_file_ofs += local_dir_footer_size;
- }
-
- if (level_and_flags & MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE) {
- if (pExtra_data != NULL) {
- extra_size = mz_zip_writer_create_zip64_extra_data(
- extra_data, (max_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL,
- (max_size >= MZ_UINT32_MAX) ? &comp_size : NULL,
- (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs
- : NULL);
- }
-
- if (!mz_zip_writer_create_local_dir_header(
- pZip, local_dir_header, (mz_uint16)archive_name_size,
- (mz_uint16)(extra_size + user_extra_data_len),
- (max_size >= MZ_UINT32_MAX) ? MZ_UINT32_MAX : uncomp_size,
- (max_size >= MZ_UINT32_MAX) ? MZ_UINT32_MAX : comp_size,
- uncomp_crc32, method, gen_flags, dos_time, dos_date))
- return mz_zip_set_error(pZip, MZ_ZIP_INTERNAL_ERROR);
-
- cur_archive_header_file_ofs = local_dir_header_ofs;
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_header_file_ofs,
- local_dir_header,
- sizeof(local_dir_header)) != sizeof(local_dir_header))
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- if (pExtra_data != NULL) {
- cur_archive_header_file_ofs += sizeof(local_dir_header);
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_header_file_ofs,
- pArchive_name,
- archive_name_size) != archive_name_size) {
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
-
- cur_archive_header_file_ofs += archive_name_size;
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_archive_header_file_ofs,
- extra_data, extra_size) != extra_size)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_archive_header_file_ofs += extra_size;
- }
- }
-
- if (pExtra_data != NULL) {
- extra_size = mz_zip_writer_create_zip64_extra_data(
- extra_data, (uncomp_size >= MZ_UINT32_MAX) ? &uncomp_size : NULL,
- (uncomp_size >= MZ_UINT32_MAX) ? &comp_size : NULL,
- (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs : NULL);
- }
-
- if (!mz_zip_writer_add_to_central_dir(
- pZip, pArchive_name, (mz_uint16)archive_name_size, pExtra_data,
- (mz_uint16)extra_size, pComment, comment_size, uncomp_size, comp_size,
- uncomp_crc32, method, gen_flags, dos_time, dos_date,
- local_dir_header_ofs, ext_attributes, user_extra_data_central,
- user_extra_data_central_len))
- return MZ_FALSE;
-
- pZip->m_total_files++;
- pZip->m_archive_size = cur_archive_file_ofs;
-
- return MZ_TRUE;
-}
-
-#ifndef MINIZ_NO_STDIO
-
-static size_t mz_file_read_func_stdio(void *pOpaque, mz_uint64 file_ofs,
- void *pBuf, size_t n) {
- MZ_FILE *pSrc_file = (MZ_FILE *)pOpaque;
- mz_int64 cur_ofs = MZ_FTELL64(pSrc_file);
-
- if (((mz_int64)file_ofs < 0) ||
- (((cur_ofs != (mz_int64)file_ofs)) &&
- (MZ_FSEEK64(pSrc_file, (mz_int64)file_ofs, SEEK_SET))))
- return 0;
-
- return MZ_FREAD(pBuf, 1, n, pSrc_file);
-}
-
-mz_bool mz_zip_writer_add_cfile(
- mz_zip_archive *pZip, const char *pArchive_name, MZ_FILE *pSrc_file,
- mz_uint64 max_size, const MZ_TIME_T *pFile_time, const void *pComment,
- mz_uint16 comment_size, mz_uint level_and_flags, mz_uint32 ext_attributes,
- const char *user_extra_data, mz_uint user_extra_data_len,
- const char *user_extra_data_central, mz_uint user_extra_data_central_len) {
- return mz_zip_writer_add_read_buf_callback(
- pZip, pArchive_name, mz_file_read_func_stdio, pSrc_file, max_size,
- pFile_time, pComment, comment_size, level_and_flags, ext_attributes,
- user_extra_data, user_extra_data_len, user_extra_data_central,
- user_extra_data_central_len);
-}
-
-mz_bool mz_zip_writer_add_file(mz_zip_archive *pZip, const char *pArchive_name,
- const char *pSrc_filename, const void *pComment,
- mz_uint16 comment_size, mz_uint level_and_flags,
- mz_uint32 ext_attributes) {
- MZ_FILE *pSrc_file = NULL;
- mz_uint64 uncomp_size = 0;
- MZ_TIME_T file_modified_time;
- MZ_TIME_T *pFile_time = NULL;
- mz_bool status;
-
- memset(&file_modified_time, 0, sizeof(file_modified_time));
-
-#if !defined(MINIZ_NO_TIME) && !defined(MINIZ_NO_STDIO)
- pFile_time = &file_modified_time;
- if (!mz_zip_get_file_modified_time(pSrc_filename, &file_modified_time))
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_STAT_FAILED);
-#endif
-
- pSrc_file = MZ_FOPEN(pSrc_filename, "rb");
- if (!pSrc_file)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_OPEN_FAILED);
-
- MZ_FSEEK64(pSrc_file, 0, SEEK_END);
- uncomp_size = MZ_FTELL64(pSrc_file);
- MZ_FSEEK64(pSrc_file, 0, SEEK_SET);
-
- status = mz_zip_writer_add_cfile(
- pZip, pArchive_name, pSrc_file, uncomp_size, pFile_time, pComment,
- comment_size, level_and_flags, ext_attributes, NULL, 0, NULL, 0);
-
- MZ_FCLOSE(pSrc_file);
-
- return status;
-}
-#endif /* #ifndef MINIZ_NO_STDIO */
-
-static mz_bool mz_zip_writer_update_zip64_extension_block(
- mz_zip_array *pNew_ext, mz_zip_archive *pZip, const mz_uint8 *pExt,
- uint32_t ext_len, mz_uint64 *pComp_size, mz_uint64 *pUncomp_size,
- mz_uint64 *pLocal_header_ofs, mz_uint32 *pDisk_start) {
- /* + 64 should be enough for any new zip64 data */
- if (!mz_zip_array_reserve(pZip, pNew_ext, ext_len + 64, MZ_FALSE))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- mz_zip_array_resize(pZip, pNew_ext, 0, MZ_FALSE);
-
- if ((pUncomp_size) || (pComp_size) || (pLocal_header_ofs) || (pDisk_start)) {
- mz_uint8 new_ext_block[64];
- mz_uint8 *pDst = new_ext_block;
- mz_write_le16(pDst, MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID);
- mz_write_le16(pDst + sizeof(mz_uint16), 0);
- pDst += sizeof(mz_uint16) * 2;
-
- if (pUncomp_size) {
- mz_write_le64(pDst, *pUncomp_size);
- pDst += sizeof(mz_uint64);
- }
-
- if (pComp_size) {
- mz_write_le64(pDst, *pComp_size);
- pDst += sizeof(mz_uint64);
- }
-
- if (pLocal_header_ofs) {
- mz_write_le64(pDst, *pLocal_header_ofs);
- pDst += sizeof(mz_uint64);
- }
-
- if (pDisk_start) {
- mz_write_le32(pDst, *pDisk_start);
- pDst += sizeof(mz_uint32);
- }
-
- mz_write_le16(new_ext_block + sizeof(mz_uint16),
- (mz_uint16)((pDst - new_ext_block) - sizeof(mz_uint16) * 2));
-
- if (!mz_zip_array_push_back(pZip, pNew_ext, new_ext_block,
- pDst - new_ext_block))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- if ((pExt) && (ext_len)) {
- mz_uint32 extra_size_remaining = ext_len;
- const mz_uint8 *pExtra_data = pExt;
-
- do {
- mz_uint32 field_id, field_data_size, field_total_size;
-
- if (extra_size_remaining < (sizeof(mz_uint16) * 2))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- field_id = MZ_READ_LE16(pExtra_data);
- field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16));
- field_total_size = field_data_size + sizeof(mz_uint16) * 2;
-
- if (field_total_size > extra_size_remaining)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- if (field_id != MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) {
- if (!mz_zip_array_push_back(pZip, pNew_ext, pExtra_data,
- field_total_size))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- pExtra_data += field_total_size;
- extra_size_remaining -= field_total_size;
- } while (extra_size_remaining);
- }
-
- return MZ_TRUE;
-}
-
-/* TODO: This func is now pretty freakin complex due to zip64, split it up? */
-mz_bool mz_zip_writer_add_from_zip_reader(mz_zip_archive *pZip,
- mz_zip_archive *pSource_zip,
- mz_uint src_file_index) {
- mz_uint n, bit_flags, num_alignment_padding_bytes,
- src_central_dir_following_data_size;
- mz_uint64 src_archive_bytes_remaining, local_dir_header_ofs;
- mz_uint64 cur_src_file_ofs, cur_dst_file_ofs;
- mz_uint32
- local_header_u32[(MZ_ZIP_LOCAL_DIR_HEADER_SIZE + sizeof(mz_uint32) - 1) /
- sizeof(mz_uint32)];
- mz_uint8 *pLocal_header = (mz_uint8 *)local_header_u32;
- mz_uint8 new_central_header[MZ_ZIP_CENTRAL_DIR_HEADER_SIZE];
- size_t orig_central_dir_size;
- mz_zip_internal_state *pState;
- void *pBuf;
- const mz_uint8 *pSrc_central_header;
- mz_zip_archive_file_stat src_file_stat;
- mz_uint32 src_filename_len, src_comment_len, src_ext_len;
- mz_uint32 local_header_filename_size, local_header_extra_len;
- mz_uint64 local_header_comp_size, local_header_uncomp_size;
- mz_bool found_zip64_ext_data_in_ldir = MZ_FALSE;
-
- /* Sanity checks */
- if ((!pZip) || (!pZip->m_pState) ||
- (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING) || (!pSource_zip->m_pRead))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pState = pZip->m_pState;
-
- /* Don't support copying files from zip64 archives to non-zip64, even though
- * in some cases this is possible */
- if ((pSource_zip->m_pState->m_zip64) && (!pZip->m_pState->m_zip64))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- /* Get pointer to the source central dir header and crack it */
- if (NULL ==
- (pSrc_central_header = mz_zip_get_cdh(pSource_zip, src_file_index)))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (MZ_READ_LE32(pSrc_central_header + MZ_ZIP_CDH_SIG_OFS) !=
- MZ_ZIP_CENTRAL_DIR_HEADER_SIG)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- src_filename_len =
- MZ_READ_LE16(pSrc_central_header + MZ_ZIP_CDH_FILENAME_LEN_OFS);
- src_comment_len =
- MZ_READ_LE16(pSrc_central_header + MZ_ZIP_CDH_COMMENT_LEN_OFS);
- src_ext_len = MZ_READ_LE16(pSrc_central_header + MZ_ZIP_CDH_EXTRA_LEN_OFS);
- src_central_dir_following_data_size =
- src_filename_len + src_ext_len + src_comment_len;
-
- /* TODO: We don't support central dir's >= MZ_UINT32_MAX bytes right now (+32
- * fudge factor in case we need to add more extra data) */
- if ((pState->m_central_dir.m_size + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE +
- src_central_dir_following_data_size + 32) >= MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE);
-
- num_alignment_padding_bytes =
- mz_zip_writer_compute_padding_needed_for_file_alignment(pZip);
-
- if (!pState->m_zip64) {
- if (pZip->m_total_files == MZ_UINT16_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
- } else {
- /* TODO: Our zip64 support still has some 32-bit limits that may not be
- * worth fixing. */
- if (pZip->m_total_files == MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
- }
-
- if (!mz_zip_file_stat_internal(pSource_zip, src_file_index,
- pSrc_central_header, &src_file_stat, NULL))
- return MZ_FALSE;
-
- cur_src_file_ofs = src_file_stat.m_local_header_ofs;
- cur_dst_file_ofs = pZip->m_archive_size;
-
- /* Read the source archive's local dir header */
- if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque, cur_src_file_ofs,
- pLocal_header, MZ_ZIP_LOCAL_DIR_HEADER_SIZE) !=
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
-
- if (MZ_READ_LE32(pLocal_header) != MZ_ZIP_LOCAL_DIR_HEADER_SIG)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
-
- cur_src_file_ofs += MZ_ZIP_LOCAL_DIR_HEADER_SIZE;
-
- /* Compute the total size we need to copy (filename+extra data+compressed
- * data) */
- local_header_filename_size =
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_FILENAME_LEN_OFS);
- local_header_extra_len =
- MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_EXTRA_LEN_OFS);
- local_header_comp_size =
- MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_COMPRESSED_SIZE_OFS);
- local_header_uncomp_size =
- MZ_READ_LE32(pLocal_header + MZ_ZIP_LDH_DECOMPRESSED_SIZE_OFS);
- src_archive_bytes_remaining = local_header_filename_size +
- local_header_extra_len +
- src_file_stat.m_comp_size;
-
- /* Try to find a zip64 extended information field */
- if ((local_header_extra_len) &&
- ((local_header_comp_size == MZ_UINT32_MAX) ||
- (local_header_uncomp_size == MZ_UINT32_MAX))) {
- mz_zip_array file_data_array;
- const mz_uint8 *pExtra_data;
- mz_uint32 extra_size_remaining = local_header_extra_len;
-
- mz_zip_array_init(&file_data_array, 1);
- if (!mz_zip_array_resize(pZip, &file_data_array, local_header_extra_len,
- MZ_FALSE)) {
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque,
- src_file_stat.m_local_header_ofs +
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE +
- local_header_filename_size,
- file_data_array.m_p, local_header_extra_len) !=
- local_header_extra_len) {
- mz_zip_array_clear(pZip, &file_data_array);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- }
-
- pExtra_data = (const mz_uint8 *)file_data_array.m_p;
-
- do {
- mz_uint32 field_id, field_data_size, field_total_size;
-
- if (extra_size_remaining < (sizeof(mz_uint16) * 2)) {
- mz_zip_array_clear(pZip, &file_data_array);
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- }
-
- field_id = MZ_READ_LE16(pExtra_data);
- field_data_size = MZ_READ_LE16(pExtra_data + sizeof(mz_uint16));
- field_total_size = field_data_size + sizeof(mz_uint16) * 2;
-
- if (field_total_size > extra_size_remaining) {
- mz_zip_array_clear(pZip, &file_data_array);
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- }
-
- if (field_id == MZ_ZIP64_EXTENDED_INFORMATION_FIELD_HEADER_ID) {
- const mz_uint8 *pSrc_field_data = pExtra_data + sizeof(mz_uint32);
-
- if (field_data_size < sizeof(mz_uint64) * 2) {
- mz_zip_array_clear(pZip, &file_data_array);
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_HEADER_OR_CORRUPTED);
- }
-
- local_header_uncomp_size = MZ_READ_LE64(pSrc_field_data);
- local_header_comp_size = MZ_READ_LE64(
- pSrc_field_data +
- sizeof(mz_uint64)); /* may be 0 if there's a descriptor */
-
- found_zip64_ext_data_in_ldir = MZ_TRUE;
- break;
- }
-
- pExtra_data += field_total_size;
- extra_size_remaining -= field_total_size;
- } while (extra_size_remaining);
-
- mz_zip_array_clear(pZip, &file_data_array);
- }
-
- if (!pState->m_zip64) {
- /* Try to detect if the new archive will most likely wind up too big and
- * bail early (+(sizeof(mz_uint32) * 4) is for the optional descriptor which
- * could be present, +64 is a fudge factor). */
- /* We also check when the archive is finalized so this doesn't need to be
- * perfect. */
- mz_uint64 approx_new_archive_size =
- cur_dst_file_ofs + num_alignment_padding_bytes +
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE + src_archive_bytes_remaining +
- (sizeof(mz_uint32) * 4) + pState->m_central_dir.m_size +
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + src_central_dir_following_data_size +
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE + 64;
-
- if (approx_new_archive_size >= MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
- }
-
- /* Write dest archive padding */
- if (!mz_zip_writer_write_zeros(pZip, cur_dst_file_ofs,
- num_alignment_padding_bytes))
- return MZ_FALSE;
-
- cur_dst_file_ofs += num_alignment_padding_bytes;
-
- local_dir_header_ofs = cur_dst_file_ofs;
- if (pZip->m_file_offset_alignment) {
- MZ_ASSERT((local_dir_header_ofs & (pZip->m_file_offset_alignment - 1)) ==
- 0);
- }
-
- /* The original zip's local header+ext block doesn't change, even with zip64,
- * so we can just copy it over to the dest zip */
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_dst_file_ofs, pLocal_header,
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE) !=
- MZ_ZIP_LOCAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- cur_dst_file_ofs += MZ_ZIP_LOCAL_DIR_HEADER_SIZE;
-
- /* Copy over the source archive bytes to the dest archive, also ensure we have
- * enough buf space to handle optional data descriptor */
- if (NULL == (pBuf = pZip->m_pAlloc(
- pZip->m_pAlloc_opaque, 1,
- (size_t)MZ_MAX(32U, MZ_MIN((mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE,
- src_archive_bytes_remaining)))))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- while (src_archive_bytes_remaining) {
- n = (mz_uint)MZ_MIN((mz_uint64)MZ_ZIP_MAX_IO_BUF_SIZE,
- src_archive_bytes_remaining);
- if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque, cur_src_file_ofs, pBuf,
- n) != n) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- }
- cur_src_file_ofs += n;
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_dst_file_ofs, pBuf, n) != n) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
- cur_dst_file_ofs += n;
-
- src_archive_bytes_remaining -= n;
- }
-
- /* Now deal with the optional data descriptor */
- bit_flags = MZ_READ_LE16(pLocal_header + MZ_ZIP_LDH_BIT_FLAG_OFS);
- if (bit_flags & 8) {
- /* Copy data descriptor */
- if ((pSource_zip->m_pState->m_zip64) || (found_zip64_ext_data_in_ldir)) {
- /* src is zip64, dest must be zip64 */
-
- /* name uint32_t's */
- /* id 1 (optional in zip64?) */
- /* crc 1 */
- /* comp_size 2 */
- /* uncomp_size 2 */
- if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque, cur_src_file_ofs,
- pBuf, (sizeof(mz_uint32) * 6)) !=
- (sizeof(mz_uint32) * 6)) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- }
-
- n = sizeof(mz_uint32) *
- ((MZ_READ_LE32(pBuf) == MZ_ZIP_DATA_DESCRIPTOR_ID) ? 6 : 5);
- } else {
- /* src is NOT zip64 */
- mz_bool has_id;
-
- if (pSource_zip->m_pRead(pSource_zip->m_pIO_opaque, cur_src_file_ofs,
- pBuf, sizeof(mz_uint32) * 4) !=
- sizeof(mz_uint32) * 4) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_READ_FAILED);
- }
-
- has_id = (MZ_READ_LE32(pBuf) == MZ_ZIP_DATA_DESCRIPTOR_ID);
-
- if (pZip->m_pState->m_zip64) {
- /* dest is zip64, so upgrade the data descriptor */
- const mz_uint32 *pSrc_descriptor =
- (const mz_uint32 *)((const mz_uint8 *)pBuf +
- (has_id ? sizeof(mz_uint32) : 0));
- const mz_uint32 src_crc32 = pSrc_descriptor[0];
- const mz_uint64 src_comp_size = pSrc_descriptor[1];
- const mz_uint64 src_uncomp_size = pSrc_descriptor[2];
-
- mz_write_le32((mz_uint8 *)pBuf, MZ_ZIP_DATA_DESCRIPTOR_ID);
- mz_write_le32((mz_uint8 *)pBuf + sizeof(mz_uint32) * 1, src_crc32);
- mz_write_le64((mz_uint8 *)pBuf + sizeof(mz_uint32) * 2, src_comp_size);
- mz_write_le64((mz_uint8 *)pBuf + sizeof(mz_uint32) * 4,
- src_uncomp_size);
-
- n = sizeof(mz_uint32) * 6;
- } else {
- /* dest is NOT zip64, just copy it as-is */
- n = sizeof(mz_uint32) * (has_id ? 4 : 3);
- }
- }
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, cur_dst_file_ofs, pBuf, n) != n) {
- pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf);
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
- }
-
- cur_src_file_ofs += n;
- cur_dst_file_ofs += n;
- }
- pZip->m_pFree(pZip->m_pAlloc_opaque, pBuf);
-
- /* Finally, add the new central dir header */
- orig_central_dir_size = pState->m_central_dir.m_size;
-
- memcpy(new_central_header, pSrc_central_header,
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE);
-
- if (pState->m_zip64) {
- /* This is the painful part: We need to write a new central dir header + ext
- * block with updated zip64 fields, and ensure the old fields (if any) are
- * not included. */
- const mz_uint8 *pSrc_ext =
- pSrc_central_header + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE + src_filename_len;
- mz_zip_array new_ext_block;
-
- mz_zip_array_init(&new_ext_block, sizeof(mz_uint8));
-
- MZ_WRITE_LE32(new_central_header + MZ_ZIP_CDH_COMPRESSED_SIZE_OFS,
- MZ_UINT32_MAX);
- MZ_WRITE_LE32(new_central_header + MZ_ZIP_CDH_DECOMPRESSED_SIZE_OFS,
- MZ_UINT32_MAX);
- MZ_WRITE_LE32(new_central_header + MZ_ZIP_CDH_LOCAL_HEADER_OFS,
- MZ_UINT32_MAX);
-
- if (!mz_zip_writer_update_zip64_extension_block(
- &new_ext_block, pZip, pSrc_ext, src_ext_len,
- &src_file_stat.m_comp_size, &src_file_stat.m_uncomp_size,
- &local_dir_header_ofs, NULL)) {
- mz_zip_array_clear(pZip, &new_ext_block);
- return MZ_FALSE;
- }
-
- MZ_WRITE_LE16(new_central_header + MZ_ZIP_CDH_EXTRA_LEN_OFS,
- new_ext_block.m_size);
-
- if (!mz_zip_array_push_back(pZip, &pState->m_central_dir,
- new_central_header,
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE)) {
- mz_zip_array_clear(pZip, &new_ext_block);
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- if (!mz_zip_array_push_back(pZip, &pState->m_central_dir,
- pSrc_central_header +
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE,
- src_filename_len)) {
- mz_zip_array_clear(pZip, &new_ext_block);
- mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size,
- MZ_FALSE);
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- if (!mz_zip_array_push_back(pZip, &pState->m_central_dir, new_ext_block.m_p,
- new_ext_block.m_size)) {
- mz_zip_array_clear(pZip, &new_ext_block);
- mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size,
- MZ_FALSE);
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- if (!mz_zip_array_push_back(pZip, &pState->m_central_dir,
- pSrc_central_header +
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE +
- src_filename_len + src_ext_len,
- src_comment_len)) {
- mz_zip_array_clear(pZip, &new_ext_block);
- mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size,
- MZ_FALSE);
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- mz_zip_array_clear(pZip, &new_ext_block);
- } else {
- /* sanity checks */
- if (cur_dst_file_ofs > MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
-
- if (local_dir_header_ofs >= MZ_UINT32_MAX)
- return mz_zip_set_error(pZip, MZ_ZIP_ARCHIVE_TOO_LARGE);
-
- MZ_WRITE_LE32(new_central_header + MZ_ZIP_CDH_LOCAL_HEADER_OFS,
- local_dir_header_ofs);
-
- if (!mz_zip_array_push_back(pZip, &pState->m_central_dir,
- new_central_header,
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE))
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
-
- if (!mz_zip_array_push_back(pZip, &pState->m_central_dir,
- pSrc_central_header +
- MZ_ZIP_CENTRAL_DIR_HEADER_SIZE,
- src_central_dir_following_data_size)) {
- mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size,
- MZ_FALSE);
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
- }
-
- /* This shouldn't trigger unless we screwed up during the initial sanity
- * checks */
- if (pState->m_central_dir.m_size >= MZ_UINT32_MAX) {
- /* TODO: Support central dirs >= 32-bits in size */
- mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size,
- MZ_FALSE);
- return mz_zip_set_error(pZip, MZ_ZIP_UNSUPPORTED_CDIR_SIZE);
- }
-
- n = (mz_uint32)orig_central_dir_size;
- if (!mz_zip_array_push_back(pZip, &pState->m_central_dir_offsets, &n, 1)) {
- mz_zip_array_resize(pZip, &pState->m_central_dir, orig_central_dir_size,
- MZ_FALSE);
- return mz_zip_set_error(pZip, MZ_ZIP_ALLOC_FAILED);
- }
-
- pZip->m_total_files++;
- pZip->m_archive_size = cur_dst_file_ofs;
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_finalize_archive(mz_zip_archive *pZip) {
- mz_zip_internal_state *pState;
- mz_uint64 central_dir_ofs, central_dir_size;
- mz_uint8 hdr[256];
-
- if ((!pZip) || (!pZip->m_pState) || (pZip->m_zip_mode != MZ_ZIP_MODE_WRITING))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- pState = pZip->m_pState;
-
- if (pState->m_zip64) {
- if ((pZip->m_total_files > MZ_UINT32_MAX) ||
- (pState->m_central_dir.m_size >= MZ_UINT32_MAX))
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
- } else {
- if ((pZip->m_total_files > MZ_UINT16_MAX) ||
- ((pZip->m_archive_size + pState->m_central_dir.m_size +
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) > MZ_UINT32_MAX))
- return mz_zip_set_error(pZip, MZ_ZIP_TOO_MANY_FILES);
- }
-
- central_dir_ofs = 0;
- central_dir_size = 0;
- if (pZip->m_total_files) {
- /* Write central directory */
- central_dir_ofs = pZip->m_archive_size;
- central_dir_size = pState->m_central_dir.m_size;
- pZip->m_central_directory_file_ofs = central_dir_ofs;
- if (pZip->m_pWrite(pZip->m_pIO_opaque, central_dir_ofs,
- pState->m_central_dir.m_p,
- (size_t)central_dir_size) != central_dir_size)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- pZip->m_archive_size += central_dir_size;
- }
-
- if (pState->m_zip64) {
- /* Write zip64 end of central directory header */
- mz_uint64 rel_ofs_to_zip64_ecdr = pZip->m_archive_size;
-
- MZ_CLEAR_OBJ(hdr);
- MZ_WRITE_LE32(hdr + MZ_ZIP64_ECDH_SIG_OFS,
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIG);
- MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_SIZE_OF_RECORD_OFS,
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE - sizeof(mz_uint32) -
- sizeof(mz_uint64));
- MZ_WRITE_LE16(hdr + MZ_ZIP64_ECDH_VERSION_MADE_BY_OFS,
- 0x031E); /* TODO: always Unix */
- MZ_WRITE_LE16(hdr + MZ_ZIP64_ECDH_VERSION_NEEDED_OFS, 0x002D);
- MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS,
- pZip->m_total_files);
- MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_CDIR_TOTAL_ENTRIES_OFS,
- pZip->m_total_files);
- MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_CDIR_SIZE_OFS, central_dir_size);
- MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDH_CDIR_OFS_OFS, central_dir_ofs);
- if (pZip->m_pWrite(pZip->m_pIO_opaque, pZip->m_archive_size, hdr,
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE) !=
- MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- pZip->m_archive_size += MZ_ZIP64_END_OF_CENTRAL_DIR_HEADER_SIZE;
-
- /* Write zip64 end of central directory locator */
- MZ_CLEAR_OBJ(hdr);
- MZ_WRITE_LE32(hdr + MZ_ZIP64_ECDL_SIG_OFS,
- MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIG);
- MZ_WRITE_LE64(hdr + MZ_ZIP64_ECDL_REL_OFS_TO_ZIP64_ECDR_OFS,
- rel_ofs_to_zip64_ecdr);
- MZ_WRITE_LE32(hdr + MZ_ZIP64_ECDL_TOTAL_NUMBER_OF_DISKS_OFS, 1);
- if (pZip->m_pWrite(pZip->m_pIO_opaque, pZip->m_archive_size, hdr,
- MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE) !=
- MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
- pZip->m_archive_size += MZ_ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIZE;
- }
-
- /* Write end of central directory record */
- MZ_CLEAR_OBJ(hdr);
- MZ_WRITE_LE32(hdr + MZ_ZIP_ECDH_SIG_OFS,
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIG);
- MZ_WRITE_LE16(hdr + MZ_ZIP_ECDH_CDIR_NUM_ENTRIES_ON_DISK_OFS,
- MZ_MIN(MZ_UINT16_MAX, pZip->m_total_files));
- MZ_WRITE_LE16(hdr + MZ_ZIP_ECDH_CDIR_TOTAL_ENTRIES_OFS,
- MZ_MIN(MZ_UINT16_MAX, pZip->m_total_files));
- MZ_WRITE_LE32(hdr + MZ_ZIP_ECDH_CDIR_SIZE_OFS,
- MZ_MIN(MZ_UINT32_MAX, central_dir_size));
- MZ_WRITE_LE32(hdr + MZ_ZIP_ECDH_CDIR_OFS_OFS,
- MZ_MIN(MZ_UINT32_MAX, central_dir_ofs));
-
- if (pZip->m_pWrite(pZip->m_pIO_opaque, pZip->m_archive_size, hdr,
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE) !=
- MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE)
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_WRITE_FAILED);
-
-#ifndef MINIZ_NO_STDIO
- if ((pState->m_pFile) && (MZ_FFLUSH(pState->m_pFile) == EOF))
- return mz_zip_set_error(pZip, MZ_ZIP_FILE_CLOSE_FAILED);
-#endif /* #ifndef MINIZ_NO_STDIO */
-
- pZip->m_archive_size += MZ_ZIP_END_OF_CENTRAL_DIR_HEADER_SIZE;
-
- pZip->m_zip_mode = MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED;
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_finalize_heap_archive(mz_zip_archive *pZip, void **ppBuf,
- size_t *pSize) {
- if ((!ppBuf) || (!pSize))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- *ppBuf = NULL;
- *pSize = 0;
-
- if ((!pZip) || (!pZip->m_pState))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (pZip->m_pWrite != mz_zip_heap_write_func)
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- if (!mz_zip_writer_finalize_archive(pZip))
- return MZ_FALSE;
-
- *ppBuf = pZip->m_pState->m_pMem;
- *pSize = pZip->m_pState->m_mem_size;
- pZip->m_pState->m_pMem = NULL;
- pZip->m_pState->m_mem_size = pZip->m_pState->m_mem_capacity = 0;
-
- return MZ_TRUE;
-}
-
-mz_bool mz_zip_writer_end(mz_zip_archive *pZip) {
- return mz_zip_writer_end_internal(pZip, MZ_TRUE);
-}
-
-#ifndef MINIZ_NO_STDIO
-mz_bool mz_zip_add_mem_to_archive_file_in_place(
- const char *pZip_filename, const char *pArchive_name, const void *pBuf,
- size_t buf_size, const void *pComment, mz_uint16 comment_size,
- mz_uint level_and_flags) {
- return mz_zip_add_mem_to_archive_file_in_place_v2(
- pZip_filename, pArchive_name, pBuf, buf_size, pComment, comment_size,
- level_and_flags, NULL);
-}
-
-mz_bool mz_zip_add_mem_to_archive_file_in_place_v2(
- const char *pZip_filename, const char *pArchive_name, const void *pBuf,
- size_t buf_size, const void *pComment, mz_uint16 comment_size,
- mz_uint level_and_flags, mz_zip_error *pErr) {
- mz_bool status, created_new_archive = MZ_FALSE;
- mz_zip_archive zip_archive;
- struct MZ_FILE_STAT_STRUCT file_stat;
- mz_zip_error actual_err = MZ_ZIP_NO_ERROR;
-
- mz_zip_zero_struct(&zip_archive);
- if ((int)level_and_flags < 0)
- level_and_flags = MZ_DEFAULT_LEVEL;
-
- if ((!pZip_filename) || (!pArchive_name) || ((buf_size) && (!pBuf)) ||
- ((comment_size) && (!pComment)) ||
- ((level_and_flags & 0xF) > MZ_UBER_COMPRESSION)) {
- if (pErr)
- *pErr = MZ_ZIP_INVALID_PARAMETER;
- return MZ_FALSE;
- }
-
- if (!mz_zip_writer_validate_archive_name(pArchive_name)) {
- if (pErr)
- *pErr = MZ_ZIP_INVALID_FILENAME;
- return MZ_FALSE;
- }
-
- /* Important: The regular non-64 bit version of stat() can fail here if the
- * file is very large, which could cause the archive to be overwritten. */
- /* So be sure to compile with _LARGEFILE64_SOURCE 1 */
- if (MZ_FILE_STAT(pZip_filename, &file_stat) != 0) {
- /* Create a new archive. */
- if (!mz_zip_writer_init_file_v2(&zip_archive, pZip_filename, 0,
- level_and_flags)) {
- if (pErr)
- *pErr = zip_archive.m_last_error;
- return MZ_FALSE;
- }
-
- created_new_archive = MZ_TRUE;
- } else {
- /* Append to an existing archive. */
- if (!mz_zip_reader_init_file_v2(
- &zip_archive, pZip_filename,
- level_and_flags | MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY, 0,
- 0)) {
- if (pErr)
- *pErr = zip_archive.m_last_error;
- return MZ_FALSE;
- }
-
- if (!mz_zip_writer_init_from_reader_v2(&zip_archive, pZip_filename,
- level_and_flags)) {
- if (pErr)
- *pErr = zip_archive.m_last_error;
-
- mz_zip_reader_end_internal(&zip_archive, MZ_FALSE);
-
- return MZ_FALSE;
- }
- }
-
- status =
- mz_zip_writer_add_mem_ex(&zip_archive, pArchive_name, pBuf, buf_size,
- pComment, comment_size, level_and_flags, 0, 0);
- actual_err = zip_archive.m_last_error;
-
- /* Always finalize, even if adding failed for some reason, so we have a valid
- * central directory. (This may not always succeed, but we can try.) */
- if (!mz_zip_writer_finalize_archive(&zip_archive)) {
- if (!actual_err)
- actual_err = zip_archive.m_last_error;
-
- status = MZ_FALSE;
- }
-
- if (!mz_zip_writer_end_internal(&zip_archive, status)) {
- if (!actual_err)
- actual_err = zip_archive.m_last_error;
-
- status = MZ_FALSE;
- }
-
- if ((!status) && (created_new_archive)) {
- /* It's a new archive and something went wrong, so just delete it. */
- int ignoredStatus = MZ_DELETE_FILE(pZip_filename);
- (void)ignoredStatus;
- }
-
- if (pErr)
- *pErr = actual_err;
-
- return status;
-}
-
-void *mz_zip_extract_archive_file_to_heap_v2(const char *pZip_filename,
- const char *pArchive_name,
- const char *pComment,
- size_t *pSize, mz_uint flags,
- mz_zip_error *pErr) {
- mz_uint32 file_index;
- mz_zip_archive zip_archive;
- void *p = NULL;
-
- if (pSize)
- *pSize = 0;
-
- if ((!pZip_filename) || (!pArchive_name)) {
- if (pErr)
- *pErr = MZ_ZIP_INVALID_PARAMETER;
-
- return NULL;
- }
-
- mz_zip_zero_struct(&zip_archive);
- if (!mz_zip_reader_init_file_v2(
- &zip_archive, pZip_filename,
- flags | MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY, 0, 0)) {
- if (pErr)
- *pErr = zip_archive.m_last_error;
-
- return NULL;
- }
-
- if (mz_zip_reader_locate_file_v2(&zip_archive, pArchive_name, pComment, flags,
- &file_index)) {
- p = mz_zip_reader_extract_to_heap(&zip_archive, file_index, pSize, flags);
- }
-
- mz_zip_reader_end_internal(&zip_archive, p != NULL);
-
- if (pErr)
- *pErr = zip_archive.m_last_error;
-
- return p;
-}
-
-void *mz_zip_extract_archive_file_to_heap(const char *pZip_filename,
- const char *pArchive_name,
- size_t *pSize, mz_uint flags) {
- return mz_zip_extract_archive_file_to_heap_v2(pZip_filename, pArchive_name,
- NULL, pSize, flags, NULL);
-}
-
-#endif /* #ifndef MINIZ_NO_STDIO */
-
-#endif /* #ifndef MINIZ_NO_ARCHIVE_WRITING_APIS */
-
-/* ------------------- Misc utils */
-
-mz_zip_mode mz_zip_get_mode(mz_zip_archive *pZip) {
- return pZip ? pZip->m_zip_mode : MZ_ZIP_MODE_INVALID;
-}
-
-mz_zip_type mz_zip_get_type(mz_zip_archive *pZip) {
- return pZip ? pZip->m_zip_type : MZ_ZIP_TYPE_INVALID;
-}
-
-mz_zip_error mz_zip_set_last_error(mz_zip_archive *pZip, mz_zip_error err_num) {
- mz_zip_error prev_err;
-
- if (!pZip)
- return MZ_ZIP_INVALID_PARAMETER;
-
- prev_err = pZip->m_last_error;
-
- pZip->m_last_error = err_num;
- return prev_err;
-}
-
-mz_zip_error mz_zip_peek_last_error(mz_zip_archive *pZip) {
- if (!pZip)
- return MZ_ZIP_INVALID_PARAMETER;
-
- return pZip->m_last_error;
-}
-
-mz_zip_error mz_zip_clear_last_error(mz_zip_archive *pZip) {
- return mz_zip_set_last_error(pZip, MZ_ZIP_NO_ERROR);
-}
-
-mz_zip_error mz_zip_get_last_error(mz_zip_archive *pZip) {
- mz_zip_error prev_err;
-
- if (!pZip)
- return MZ_ZIP_INVALID_PARAMETER;
-
- prev_err = pZip->m_last_error;
-
- pZip->m_last_error = MZ_ZIP_NO_ERROR;
- return prev_err;
-}
-
-const char *mz_zip_get_error_string(mz_zip_error mz_err) {
- switch (mz_err) {
- case MZ_ZIP_NO_ERROR:
- return "no error";
- case MZ_ZIP_UNDEFINED_ERROR:
- return "undefined error";
- case MZ_ZIP_TOO_MANY_FILES:
- return "too many files";
- case MZ_ZIP_FILE_TOO_LARGE:
- return "file too large";
- case MZ_ZIP_UNSUPPORTED_METHOD:
- return "unsupported method";
- case MZ_ZIP_UNSUPPORTED_ENCRYPTION:
- return "unsupported encryption";
- case MZ_ZIP_UNSUPPORTED_FEATURE:
- return "unsupported feature";
- case MZ_ZIP_FAILED_FINDING_CENTRAL_DIR:
- return "failed finding central directory";
- case MZ_ZIP_NOT_AN_ARCHIVE:
- return "not a ZIP archive";
- case MZ_ZIP_INVALID_HEADER_OR_CORRUPTED:
- return "invalid header or archive is corrupted";
- case MZ_ZIP_UNSUPPORTED_MULTIDISK:
- return "unsupported multidisk archive";
- case MZ_ZIP_DECOMPRESSION_FAILED:
- return "decompression failed or archive is corrupted";
- case MZ_ZIP_COMPRESSION_FAILED:
- return "compression failed";
- case MZ_ZIP_UNEXPECTED_DECOMPRESSED_SIZE:
- return "unexpected decompressed size";
- case MZ_ZIP_CRC_CHECK_FAILED:
- return "CRC-32 check failed";
- case MZ_ZIP_UNSUPPORTED_CDIR_SIZE:
- return "unsupported central directory size";
- case MZ_ZIP_ALLOC_FAILED:
- return "allocation failed";
- case MZ_ZIP_FILE_OPEN_FAILED:
- return "file open failed";
- case MZ_ZIP_FILE_CREATE_FAILED:
- return "file create failed";
- case MZ_ZIP_FILE_WRITE_FAILED:
- return "file write failed";
- case MZ_ZIP_FILE_READ_FAILED:
- return "file read failed";
- case MZ_ZIP_FILE_CLOSE_FAILED:
- return "file close failed";
- case MZ_ZIP_FILE_SEEK_FAILED:
- return "file seek failed";
- case MZ_ZIP_FILE_STAT_FAILED:
- return "file stat failed";
- case MZ_ZIP_INVALID_PARAMETER:
- return "invalid parameter";
- case MZ_ZIP_INVALID_FILENAME:
- return "invalid filename";
- case MZ_ZIP_BUF_TOO_SMALL:
- return "buffer too small";
- case MZ_ZIP_INTERNAL_ERROR:
- return "internal error";
- case MZ_ZIP_FILE_NOT_FOUND:
- return "file not found";
- case MZ_ZIP_ARCHIVE_TOO_LARGE:
- return "archive is too large";
- case MZ_ZIP_VALIDATION_FAILED:
- return "validation failed";
- case MZ_ZIP_WRITE_CALLBACK_FAILED:
- return "write callback failed";
- case MZ_ZIP_TOTAL_ERRORS:
- return "total errors";
- default:
- break;
- }
-
- return "unknown error";
-}
-
-/* Note: Just because the archive is not zip64 doesn't necessarily mean it
- * doesn't have Zip64 extended information extra field, argh. */
-mz_bool mz_zip_is_zip64(mz_zip_archive *pZip) {
- if ((!pZip) || (!pZip->m_pState))
- return MZ_FALSE;
-
- return pZip->m_pState->m_zip64;
-}
-
-size_t mz_zip_get_central_dir_size(mz_zip_archive *pZip) {
- if ((!pZip) || (!pZip->m_pState))
- return 0;
-
- return pZip->m_pState->m_central_dir.m_size;
-}
-
-mz_uint mz_zip_reader_get_num_files(mz_zip_archive *pZip) {
- return pZip ? pZip->m_total_files : 0;
-}
-
-mz_uint64 mz_zip_get_archive_size(mz_zip_archive *pZip) {
- if (!pZip)
- return 0;
- return pZip->m_archive_size;
-}
-
-mz_uint64 mz_zip_get_archive_file_start_offset(mz_zip_archive *pZip) {
- if ((!pZip) || (!pZip->m_pState))
- return 0;
- return pZip->m_pState->m_file_archive_start_ofs;
-}
-
-MZ_FILE *mz_zip_get_cfile(mz_zip_archive *pZip) {
- if ((!pZip) || (!pZip->m_pState))
- return 0;
- return pZip->m_pState->m_pFile;
-}
-
-size_t mz_zip_read_archive_data(mz_zip_archive *pZip, mz_uint64 file_ofs,
- void *pBuf, size_t n) {
- if ((!pZip) || (!pZip->m_pState) || (!pBuf) || (!pZip->m_pRead))
- return mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
-
- return pZip->m_pRead(pZip->m_pIO_opaque, file_ofs, pBuf, n);
-}
-
-mz_uint mz_zip_reader_get_filename(mz_zip_archive *pZip, mz_uint file_index,
- char *pFilename, mz_uint filename_buf_size) {
- mz_uint n;
- const mz_uint8 *p = mz_zip_get_cdh(pZip, file_index);
- if (!p) {
- if (filename_buf_size)
- pFilename[0] = '\0';
- mz_zip_set_error(pZip, MZ_ZIP_INVALID_PARAMETER);
- return 0;
- }
- n = MZ_READ_LE16(p + MZ_ZIP_CDH_FILENAME_LEN_OFS);
- if (filename_buf_size) {
- n = MZ_MIN(n, filename_buf_size - 1);
- memcpy(pFilename, p + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE, n);
- pFilename[n] = '\0';
- }
- return n + 1;
-}
-
-mz_bool mz_zip_reader_file_stat(mz_zip_archive *pZip, mz_uint file_index,
- mz_zip_archive_file_stat *pStat) {
- return mz_zip_file_stat_internal(
- pZip, file_index, mz_zip_get_cdh(pZip, file_index), pStat, NULL);
-}
-
-mz_bool mz_zip_end(mz_zip_archive *pZip) {
- if (!pZip)
- return MZ_FALSE;
-
- if (pZip->m_zip_mode == MZ_ZIP_MODE_READING)
- return mz_zip_reader_end(pZip);
-#ifndef MINIZ_NO_ARCHIVE_WRITING_APIS
- else if ((pZip->m_zip_mode == MZ_ZIP_MODE_WRITING) ||
- (pZip->m_zip_mode == MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED))
- return mz_zip_writer_end(pZip);
-#endif
-
- return MZ_FALSE;
-}
-
-#ifdef __cplusplus
-}
-#endif
-
-#endif /*#ifndef MINIZ_NO_ARCHIVE_APIS*/
diff --git a/src/deps/zip/src/zip.c b/src/deps/zip/src/zip.c
deleted file mode 100644
index 128974b7..00000000
--- a/src/deps/zip/src/zip.c
+++ /dev/null
@@ -1,1913 +0,0 @@
-/*
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
- * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
- * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
- * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
- * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
- * OTHER DEALINGS IN THE SOFTWARE.
- */
-#define __STDC_WANT_LIB_EXT1__ 1
-
-#ifdef FIZZY_ZIP_WASM
-#include "../fizzy_zip_wasm.h"
-#define STRCLONE(STR) ((STR) ? fizzy_strdup(STR) : NULL)
-#else
-#include
-#include
-#include
-
-#if defined(_WIN32) || defined(__WIN32__) || defined(_MSC_VER) || \
- defined(__MINGW32__)
-/* Win32, DOS, MSVC, MSVS */
-#include
-
-#define STRCLONE(STR) ((STR) ? _strdup(STR) : NULL)
-#define HAS_DEVICE(P) \
- ((((P)[0] >= 'A' && (P)[0] <= 'Z') || ((P)[0] >= 'a' && (P)[0] <= 'z')) && \
- (P)[1] == ':')
-#define FILESYSTEM_PREFIX_LEN(P) (HAS_DEVICE(P) ? 2 : 0)
-
-#else
-
-#include // needed for symlink()
-#define STRCLONE(STR) ((STR) ? strdup(STR) : NULL)
-
-#endif
-
-#ifdef __MINGW32__
-#include
-#include
-#endif
-
-#include "miniz.h"
-#endif
-#include "zip.h"
-
-#ifdef _MSC_VER
-#include
-
-#define ftruncate(fd, sz) (-(_chsize_s((fd), (sz)) != 0))
-#define fileno _fileno
-#endif
-
-#if defined(__TINYC__) && (defined(_WIN32) || defined(_WIN64))
-#include
-
-#define ftruncate(fd, sz) (-(_chsize_s((fd), (sz)) != 0))
-#define fileno _fileno
-#endif
-
-#ifndef HAS_DEVICE
-#define HAS_DEVICE(P) 0
-#endif
-
-#ifndef FILESYSTEM_PREFIX_LEN
-#define FILESYSTEM_PREFIX_LEN(P) 0
-#endif
-
-#ifndef ISSLASH
-#define ISSLASH(C) ((C) == '/' || (C) == '\\')
-#endif
-
-#define CLEANUP(ptr) \
- do { \
- if (ptr) { \
- free((void *)ptr); \
- ptr = NULL; \
- } \
- } while (0)
-
-#define UNX_IFDIR 0040000 /* Unix directory */
-#define UNX_IFREG 0100000 /* Unix regular file */
-#define UNX_IFSOCK 0140000 /* Unix socket (BSD, not SysV or Amiga) */
-#define UNX_IFLNK 0120000 /* Unix symbolic link (not SysV, Amiga) */
-#define UNX_IFBLK 0060000 /* Unix block special (not Amiga) */
-#define UNX_IFCHR 0020000 /* Unix character special (not Amiga) */
-#define UNX_IFIFO 0010000 /* Unix fifo (BCC, not MSC or Amiga) */
-
-struct zip_entry_t {
- ssize_t index;
- char *name;
- mz_uint64 uncomp_size;
- mz_uint64 comp_size;
- mz_uint32 uncomp_crc32;
- mz_uint64 offset;
- mz_uint8 header[MZ_ZIP_LOCAL_DIR_HEADER_SIZE];
- mz_uint64 header_offset;
- mz_uint16 method;
- mz_zip_writer_add_state state;
- tdefl_compressor comp;
- mz_uint32 external_attr;
-#ifdef MINIZ_NO_TIME
- mz_uint32 m_time;
-#else
- time_t m_time;
-#endif
-};
-
-struct zip_t {
- mz_zip_archive archive;
- mz_uint level;
- struct zip_entry_t entry;
-};
-
-enum zip_modify_t {
- MZ_KEEP = 0,
- MZ_DELETE = 1,
- MZ_MOVE = 2,
-};
-
-struct zip_entry_mark_t {
- ssize_t file_index;
- enum zip_modify_t type;
- mz_uint64 m_local_header_ofs;
- size_t lf_length;
-};
-
-static const char *const zip_errlist[30] = {
- NULL,
- "not initialized\0",
- "invalid entry name\0",
- "entry not found\0",
- "invalid zip mode\0",
- "invalid compression level\0",
- "no zip 64 support\0",
- "memset error\0",
- "cannot write data to entry\0",
- "cannot initialize tdefl compressor\0",
- "invalid index\0",
- "header not found\0",
- "cannot flush tdefl buffer\0",
- "cannot write entry header\0",
- "cannot create entry header\0",
- "cannot write to central dir\0",
- "cannot open file\0",
- "invalid entry type\0",
- "extracting data using no memory allocation\0",
- "file not found\0",
- "no permission\0",
- "out of memory\0",
- "invalid zip archive name\0",
- "make dir error\0",
- "symlink error\0",
- "close archive error\0",
- "capacity size too small\0",
- "fseek error\0",
- "fread error\0",
- "fwrite error\0",
-};
-
-const char *zip_strerror(int errnum) {
- errnum = -errnum;
- if (errnum <= 0 || errnum >= 30) {
- return NULL;
- }
-
- return zip_errlist[errnum];
-}
-
-#ifndef FIZZY_ZIP_WASM
-
-static const char *zip_basename(const char *name) {
- char const *p;
- char const *base = name += FILESYSTEM_PREFIX_LEN(name);
- int all_slashes = 1;
-
- for (p = name; *p; p++) {
- if (ISSLASH(*p))
- base = p + 1;
- else
- all_slashes = 0;
- }
-
- /* If NAME is all slashes, arrange to return `/'. */
- if (*base == '\0' && ISSLASH(*name) && all_slashes)
- --base;
-
- return base;
-}
-
-static int zip_mkpath(char *path) {
- char *p;
- char npath[MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE + 1];
- int len = 0;
- int has_device = HAS_DEVICE(path);
-
- memset(npath, 0, MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE + 1);
- if (has_device) {
- // only on windows
- npath[0] = path[0];
- npath[1] = path[1];
- len = 2;
- }
- for (p = path + len; *p && len < MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE; p++) {
- if (ISSLASH(*p) && ((!has_device && len > 0) || (has_device && len > 2))) {
-#if defined(_WIN32) || defined(__WIN32__) || defined(_MSC_VER) || \
- defined(__MINGW32__)
-#else
- if ('\\' == *p) {
- *p = '/';
- }
-#endif
-
- if (MZ_MKDIR(npath) == -1) {
- if (errno != EEXIST) {
- return ZIP_EMKDIR;
- }
- }
- }
- npath[len++] = *p;
- }
-
- return 0;
-}
-
-static char *zip_strrpl(const char *str, size_t n, char oldchar, char newchar) {
- char c;
- size_t i;
- char *rpl = (char *)calloc((1 + n), sizeof(char));
- char *begin = rpl;
- if (!rpl) {
- return NULL;
- }
-
- for (i = 0; (i < n) && (c = *str++); ++i) {
- if (c == oldchar) {
- c = newchar;
- }
- *rpl++ = c;
- }
-
- return begin;
-}
-
-static char *zip_name_normalize(char *name, char *const nname, size_t len) {
- size_t offn = 0;
- size_t offnn = 0, ncpy = 0;
-
- if (name == NULL || nname == NULL || len <= 0) {
- return NULL;
- }
- // skip trailing '/'
- while (ISSLASH(*name))
- name++;
-
- for (; offn < len; offn++) {
- if (ISSLASH(name[offn])) {
- if (ncpy > 0 && strcmp(&nname[offnn], ".\0") &&
- strcmp(&nname[offnn], "..\0")) {
- offnn += ncpy;
- nname[offnn++] = name[offn]; // append '/'
- }
- ncpy = 0;
- } else {
- nname[offnn + ncpy] = name[offn];
- ncpy++;
- }
- }
-
- // at the end, extra check what we've already copied
- if (ncpy == 0 || !strcmp(&nname[offnn], ".\0") ||
- !strcmp(&nname[offnn], "..\0")) {
- nname[offnn] = 0;
- }
- return nname;
-}
-
-static mz_bool zip_name_match(const char *name1, const char *name2) {
- char *nname2 = NULL;
-
-#ifdef ZIP_RAW_ENTRYNAME
- nname2 = STRCLONE(name2);
-#else
- nname2 = zip_strrpl(name2, strlen(name2), '\\', '/');
-#endif
-
- if (!nname2) {
- return MZ_FALSE;
- }
-
- mz_bool res = (strcmp(name1, nname2) == 0) ? MZ_TRUE : MZ_FALSE;
- CLEANUP(nname2);
- return res;
-}
-
-#endif /* !FIZZY_ZIP_WASM */
-
-static int zip_archive_truncate(mz_zip_archive *pzip) {
-#ifdef FIZZY_ZIP_WASM
- (void)pzip;
- return 0;
-#else
- mz_zip_internal_state *pState = pzip->m_pState;
- mz_uint64 file_size = pzip->m_archive_size;
- if ((pzip->m_pWrite == mz_zip_heap_write_func) && (pState->m_pMem)) {
- return 0;
- }
- if (pzip->m_zip_mode == MZ_ZIP_MODE_WRITING_HAS_BEEN_FINALIZED) {
- if (pState->m_pFile) {
- int fd = fileno(pState->m_pFile);
- return ftruncate(fd, file_size);
- }
- }
- return 0;
-#endif
-}
-
-#ifndef FIZZY_ZIP_WASM
-
-static int zip_archive_extract(mz_zip_archive *zip_archive, const char *dir,
- int (*on_extract)(const char *filename,
- void *arg),
- void *arg) {
- int err = 0;
- mz_uint i, n;
- char path[MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE + 1];
- char symlink_to[MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE + 1];
- mz_zip_archive_file_stat info;
- size_t dirlen = 0, filename_size = MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE;
- mz_uint32 xattr = 0;
-
- memset(path, 0, sizeof(path));
- memset(symlink_to, 0, sizeof(symlink_to));
-
- dirlen = strlen(dir);
- if (dirlen + 1 > MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE) {
- return ZIP_EINVENTNAME;
- }
-
- memset((void *)&info, 0, sizeof(mz_zip_archive_file_stat));
-
-#if defined(_MSC_VER)
- strcpy_s(path, MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE, dir);
-#else
- strcpy(path, dir);
-#endif
-
- if (!ISSLASH(path[dirlen - 1])) {
-#if defined(_WIN32) || defined(__WIN32__)
- path[dirlen] = '\\';
-#else
- path[dirlen] = '/';
-#endif
- ++dirlen;
- }
-
- if (filename_size > MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE - dirlen) {
- filename_size = MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE - dirlen;
- }
- // Get and print information about each file in the archive.
- n = mz_zip_reader_get_num_files(zip_archive);
- for (i = 0; i < n; ++i) {
- if (!mz_zip_reader_file_stat(zip_archive, i, &info)) {
- // Cannot get information about zip archive;
- err = ZIP_ENOENT;
- goto out;
- }
-
- if (!zip_name_normalize(info.m_filename, info.m_filename,
- strlen(info.m_filename))) {
- // Cannot normalize file name;
- err = ZIP_EINVENTNAME;
- goto out;
- }
-
-#if defined(_MSC_VER)
- strncpy_s(&path[dirlen], filename_size, info.m_filename,
- filename_size);
-#else
- strncpy(&path[dirlen], info.m_filename, filename_size);
-#endif
- err = zip_mkpath(path);
- if (err < 0) {
- // Cannot make a path
- goto out;
- }
-
- if ((((info.m_version_made_by >> 8) == 3) ||
- ((info.m_version_made_by >> 8) ==
- 19)) // if zip is produced on Unix or macOS (3 and 19 from
- // section 4.4.2.2 of zip standard)
- && info.m_external_attr &
- (0x20 << 24)) { // and has sym link attribute (0x80 is file, 0x40
- // is directory)
-#if defined(_WIN32) || defined(__WIN32__) || defined(_MSC_VER) || \
- defined(__MINGW32__)
-#else
- if (info.m_uncomp_size > MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE ||
- !mz_zip_reader_extract_to_mem_no_alloc(zip_archive, i, symlink_to,
- MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE, 0, NULL, 0)) {
- err = ZIP_EMEMNOALLOC;
- goto out;
- }
- symlink_to[info.m_uncomp_size] = '\0';
- if (symlink(symlink_to, path) != 0) {
- err = ZIP_ESYMLINK;
- goto out;
- }
-#endif
- } else {
- if (!mz_zip_reader_is_file_a_directory(zip_archive, i)) {
- if (!mz_zip_reader_extract_to_file(zip_archive, i, path, 0)) {
- // Cannot extract zip archive to file
- err = ZIP_ENOFILE;
- goto out;
- }
- }
-
-#if defined(_MSC_VER) || defined(PS4)
- (void)xattr; // unused
-#else
- xattr = (info.m_external_attr >> 16) & 0xFFFF;
- if (xattr > 0 && xattr <= MZ_UINT16_MAX) {
- if (CHMOD(path, (mode_t)xattr) < 0) {
- err = ZIP_ENOPERM;
- goto out;
- }
- }
-#endif
- }
-
- if (on_extract) {
- if (on_extract(path, arg) < 0) {
- goto out;
- }
- }
- }
-
-out:
- // Close the archive, freeing any resources it was using
- if (!mz_zip_reader_end(zip_archive)) {
- // Cannot end zip reader
- err = ZIP_ECLSZIP;
- }
- return err;
-}
-
-#endif /* !FIZZY_ZIP_WASM */
-
-static inline void zip_archive_finalize(mz_zip_archive *pzip) {
- mz_zip_writer_finalize_archive(pzip);
- zip_archive_truncate(pzip);
-}
-
-#ifndef FIZZY_ZIP_WASM
-
-static ssize_t zip_entry_mark(struct zip_t *zip,
- struct zip_entry_mark_t *entry_mark,
- const ssize_t n, char *const entries[],
- const size_t len) {
- ssize_t i = 0;
- ssize_t err = 0;
- if (!zip || !entry_mark || !entries) {
- return ZIP_ENOINIT;
- }
-
- mz_zip_archive_file_stat file_stat;
- mz_uint64 d_pos = UINT64_MAX;
- for (i = 0; i < n; ++i) {
- if ((err = zip_entry_openbyindex(zip, i))) {
- return (ssize_t)err;
- }
-
- mz_bool name_matches = MZ_FALSE;
- {
- size_t j;
- for (j = 0; j < len; ++j) {
- if (zip_name_match(zip->entry.name, entries[j])) {
- name_matches = MZ_TRUE;
- break;
- }
- }
- }
- if (name_matches) {
- entry_mark[i].type = MZ_DELETE;
- } else {
- entry_mark[i].type = MZ_KEEP;
- }
-
- if (!mz_zip_reader_file_stat(&zip->archive, i, &file_stat)) {
- return ZIP_ENOENT;
- }
-
- zip_entry_close(zip);
-
- entry_mark[i].m_local_header_ofs = file_stat.m_local_header_ofs;
- entry_mark[i].file_index = (ssize_t)-1;
- entry_mark[i].lf_length = 0;
- if ((entry_mark[i].type) == MZ_DELETE &&
- (d_pos > entry_mark[i].m_local_header_ofs)) {
- d_pos = entry_mark[i].m_local_header_ofs;
- }
- }
-
- for (i = 0; i < n; ++i) {
- if ((entry_mark[i].m_local_header_ofs > d_pos) &&
- (entry_mark[i].type != MZ_DELETE)) {
- entry_mark[i].type = MZ_MOVE;
- }
- }
- return err;
-}
-
-static ssize_t zip_index_next(mz_uint64 *local_header_ofs_array,
- ssize_t cur_index) {
- ssize_t new_index = 0, i;
- for (i = cur_index - 1; i >= 0; --i) {
- if (local_header_ofs_array[cur_index] > local_header_ofs_array[i]) {
- new_index = i + 1;
- return new_index;
- }
- }
- return new_index;
-}
-
-static ssize_t zip_sort(mz_uint64 *local_header_ofs_array, ssize_t cur_index) {
- ssize_t nxt_index = zip_index_next(local_header_ofs_array, cur_index);
-
- if (nxt_index != cur_index) {
- mz_uint64 temp = local_header_ofs_array[cur_index];
- ssize_t i;
- for (i = cur_index; i > nxt_index; i--) {
- local_header_ofs_array[i] = local_header_ofs_array[i - 1];
- }
- local_header_ofs_array[nxt_index] = temp;
- }
- return nxt_index;
-}
-
-static int zip_index_update(struct zip_entry_mark_t *entry_mark,
- ssize_t last_index, ssize_t nxt_index) {
- ssize_t j;
- for (j = 0; j < last_index; j++) {
- if (entry_mark[j].file_index >= nxt_index) {
- entry_mark[j].file_index += 1;
- }
- }
- entry_mark[nxt_index].file_index = last_index;
- return 0;
-}
-
-static int zip_entry_finalize(struct zip_t *zip,
- struct zip_entry_mark_t *entry_mark,
- const ssize_t n) {
-
- ssize_t i = 0;
- mz_uint64 *local_header_ofs_array = (mz_uint64 *)calloc(n, sizeof(mz_uint64));
- if (!local_header_ofs_array) {
- return ZIP_EOOMEM;
- }
-
- for (i = 0; i < n; ++i) {
- local_header_ofs_array[i] = entry_mark[i].m_local_header_ofs;
- ssize_t index = zip_sort(local_header_ofs_array, i);
-
- if (index != i) {
- zip_index_update(entry_mark, i, index);
- }
- entry_mark[i].file_index = index;
- }
-
- size_t *length = (size_t *)calloc(n, sizeof(size_t));
- if (!length) {
- CLEANUP(local_header_ofs_array);
- return ZIP_EOOMEM;
- }
- for (i = 0; i < n - 1; i++) {
- length[i] =
- (size_t)(local_header_ofs_array[i + 1] - local_header_ofs_array[i]);
- }
- length[n - 1] =
- (size_t)(zip->archive.m_archive_size - local_header_ofs_array[n - 1]);
-
- for (i = 0; i < n; i++) {
- entry_mark[i].lf_length = length[entry_mark[i].file_index];
- }
-
- CLEANUP(length);
- CLEANUP(local_header_ofs_array);
- return 0;
-}
-
-static ssize_t zip_entry_set(struct zip_t *zip,
- struct zip_entry_mark_t *entry_mark, ssize_t n,
- char *const entries[], const size_t len) {
- ssize_t err = 0;
-
- if ((err = zip_entry_mark(zip, entry_mark, n, entries, len)) < 0) {
- return err;
- }
- if ((err = zip_entry_finalize(zip, entry_mark, n)) < 0) {
- return err;
- }
- return 0;
-}
-
-static ssize_t zip_file_move(MZ_FILE *m_pFile, const mz_uint64 to,
- const mz_uint64 from, const size_t length,
- mz_uint8 *move_buf, const size_t capacity_size) {
- if (length > capacity_size) {
- return ZIP_ECAPSIZE;
- }
- if (MZ_FSEEK64(m_pFile, from, SEEK_SET)) {
- return ZIP_EFSEEK;
- }
- if (fread(move_buf, 1, length, m_pFile) != length) {
- return ZIP_EFREAD;
- }
- if (MZ_FSEEK64(m_pFile, to, SEEK_SET)) {
- return ZIP_EFSEEK;
- }
- if (fwrite(move_buf, 1, length, m_pFile) != length) {
- return ZIP_EFWRITE;
- }
- return (ssize_t)length;
-}
-
-static ssize_t zip_files_move(MZ_FILE *m_pFile, mz_uint64 writen_num,
- mz_uint64 read_num, size_t length) {
- ssize_t n = 0;
- const size_t page_size = 1 << 12; // 4K
- mz_uint8 *move_buf = (mz_uint8 *)calloc(1, page_size);
- if (!move_buf) {
- return ZIP_EOOMEM;
- }
-
- ssize_t moved_length = 0;
- ssize_t move_count = 0;
- while ((mz_int64)length > 0) {
- move_count = (length >= page_size) ? page_size : length;
- n = zip_file_move(m_pFile, writen_num, read_num, move_count, move_buf,
- page_size);
- if (n < 0) {
- moved_length = n;
- goto cleanup;
- }
-
- if (n != move_count) {
- goto cleanup;
- }
-
- writen_num += move_count;
- read_num += move_count;
- length -= move_count;
- moved_length += move_count;
- }
-
-cleanup:
- CLEANUP(move_buf);
- return moved_length;
-}
-
-static int zip_central_dir_move(mz_zip_internal_state *pState, int begin,
- int end, int entry_num) {
- if (begin == entry_num) {
- return 0;
- }
-
- size_t l_size = 0;
- size_t r_size = 0;
- mz_uint32 d_size = 0;
- mz_uint8 *next = NULL;
- mz_uint8 *deleted = &MZ_ZIP_ARRAY_ELEMENT(
- &pState->m_central_dir, mz_uint8,
- MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, begin));
- l_size = (size_t)(deleted - (mz_uint8 *)(pState->m_central_dir.m_p));
- if (end == entry_num) {
- r_size = 0;
- } else {
- next = &MZ_ZIP_ARRAY_ELEMENT(
- &pState->m_central_dir, mz_uint8,
- MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, end));
- r_size = pState->m_central_dir.m_size -
- (mz_uint32)(next - (mz_uint8 *)(pState->m_central_dir.m_p));
- d_size = (mz_uint32)(next - deleted);
- }
-
- if (next && l_size == 0) {
- memmove(pState->m_central_dir.m_p, next, r_size);
- pState->m_central_dir.m_p = MZ_REALLOC(pState->m_central_dir.m_p, r_size);
- {
- int i;
- for (i = end; i < entry_num; i++) {
- MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, i) -=
- d_size;
- }
- }
- }
-
- if (next && l_size * r_size != 0) {
- memmove(deleted, next, r_size);
- {
- int i;
- for (i = end; i < entry_num; i++) {
- MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, i) -=
- d_size;
- }
- }
- }
-
- pState->m_central_dir.m_size = l_size + r_size;
- return 0;
-}
-
-static int zip_central_dir_delete(mz_zip_internal_state *pState,
- int *deleted_entry_index_array,
- int entry_num) {
- int i = 0;
- int begin = 0;
- int end = 0;
- int d_num = 0;
- while (i < entry_num) {
- while ((i < entry_num) && (!deleted_entry_index_array[i])) {
- i++;
- }
- begin = i;
-
- while ((i < entry_num) && (deleted_entry_index_array[i])) {
- i++;
- }
- end = i;
- zip_central_dir_move(pState, begin, end, entry_num);
- }
-
- i = 0;
- while (i < entry_num) {
- while ((i < entry_num) && (!deleted_entry_index_array[i])) {
- i++;
- }
- begin = i;
- if (begin == entry_num) {
- break;
- }
- while ((i < entry_num) && (deleted_entry_index_array[i])) {
- i++;
- }
- end = i;
- int k = 0, j;
- for (j = end; j < entry_num; j++) {
- MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32,
- begin + k) =
- (mz_uint32)MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets,
- mz_uint32, j);
- k++;
- }
- d_num += end - begin;
- }
-
- pState->m_central_dir_offsets.m_size =
- sizeof(mz_uint32) * (entry_num - d_num);
- return 0;
-}
-
-static ssize_t zip_entries_delete_mark(struct zip_t *zip,
- struct zip_entry_mark_t *entry_mark,
- int entry_num) {
- mz_uint64 writen_num = 0;
- mz_uint64 read_num = 0;
- size_t deleted_length = 0;
- size_t move_length = 0;
- int i = 0;
- size_t deleted_entry_num = 0;
- ssize_t n = 0;
-
- mz_bool *deleted_entry_flag_array =
- (mz_bool *)calloc(entry_num, sizeof(mz_bool));
- if (deleted_entry_flag_array == NULL) {
- return ZIP_EOOMEM;
- }
-
- mz_zip_internal_state *pState = zip->archive.m_pState;
- zip->archive.m_zip_mode = MZ_ZIP_MODE_WRITING;
-
- if ((!pState->m_pFile) || MZ_FSEEK64(pState->m_pFile, 0, SEEK_SET)) {
- CLEANUP(deleted_entry_flag_array);
- return ZIP_ENOENT;
- }
-
- while (i < entry_num) {
- while ((i < entry_num) && (entry_mark[i].type == MZ_KEEP)) {
- writen_num += entry_mark[i].lf_length;
- read_num = writen_num;
- i++;
- }
-
- while ((i < entry_num) && (entry_mark[i].type == MZ_DELETE)) {
- deleted_entry_flag_array[i] = MZ_TRUE;
- read_num += entry_mark[i].lf_length;
- deleted_length += entry_mark[i].lf_length;
- i++;
- deleted_entry_num++;
- }
-
- while ((i < entry_num) && (entry_mark[i].type == MZ_MOVE)) {
- move_length += entry_mark[i].lf_length;
- mz_uint8 *p = &MZ_ZIP_ARRAY_ELEMENT(
- &pState->m_central_dir, mz_uint8,
- MZ_ZIP_ARRAY_ELEMENT(&pState->m_central_dir_offsets, mz_uint32, i));
- if (!p) {
- CLEANUP(deleted_entry_flag_array);
- return ZIP_ENOENT;
- }
- mz_uint32 offset = MZ_READ_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS);
- offset -= (mz_uint32)deleted_length;
- MZ_WRITE_LE32(p + MZ_ZIP_CDH_LOCAL_HEADER_OFS, offset);
- i++;
- }
-
- n = zip_files_move(pState->m_pFile, writen_num, read_num, move_length);
- if (n != (ssize_t)move_length) {
- CLEANUP(deleted_entry_flag_array);
- return n;
- }
- writen_num += move_length;
- read_num += move_length;
- }
-
- zip->archive.m_archive_size -= (mz_uint64)deleted_length;
- zip->archive.m_total_files =
- (mz_uint32)entry_num - (mz_uint32)deleted_entry_num;
-
- zip_central_dir_delete(pState, deleted_entry_flag_array, entry_num);
- CLEANUP(deleted_entry_flag_array);
-
- return (ssize_t)deleted_entry_num;
-}
-
-struct zip_t *zip_open(const char *zipname, int level, char mode) {
- struct zip_t *zip = NULL;
-
- if (!zipname || strlen(zipname) < 1) {
- // zip_t archive name is empty or NULL
- goto cleanup;
- }
-
- if (level < 0)
- level = MZ_DEFAULT_LEVEL;
- if ((level & 0xF) > MZ_UBER_COMPRESSION) {
- // Wrong compression level
- goto cleanup;
- }
-
- zip = (struct zip_t *)calloc((size_t)1, sizeof(struct zip_t));
- if (!zip)
- goto cleanup;
-
- zip->level = (mz_uint)level;
- switch (mode) {
- case 'w':
- // Create a new archive.
- if (!mz_zip_writer_init_file_v2(&(zip->archive), zipname, 0,
- MZ_ZIP_FLAG_WRITE_ZIP64)) {
- // Cannot initialize zip_archive writer
- goto cleanup;
- }
- break;
-
- case 'r':
- if (!mz_zip_reader_init_file_v2(
- &(zip->archive), zipname,
- zip->level | MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY, 0, 0)) {
- // An archive file does not exist or cannot initialize
- // zip_archive reader
- goto cleanup;
- }
- break;
-
- case 'a':
- case 'd':
- if (!mz_zip_reader_init_file_v2_rpb(
- &(zip->archive), zipname,
- zip->level | MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY, 0, 0)) {
- // An archive file does not exist or cannot initialize
- // zip_archive reader
- goto cleanup;
- }
- if ((mode == 'a' || mode == 'd')) {
- if (!mz_zip_writer_init_from_reader_v2_noreopen(&(zip->archive), zipname,
- 0)) {
- mz_zip_reader_end(&(zip->archive));
- goto cleanup;
- }
- }
- break;
-
- default:
- goto cleanup;
- }
-
- return zip;
-
-cleanup:
- CLEANUP(zip);
- return NULL;
-}
-
-void zip_close(struct zip_t *zip) {
- if (zip) {
- // Always finalize, even if adding failed for some reason, so we have a
- // valid central directory.
- mz_zip_writer_finalize_archive(&(zip->archive));
- zip_archive_truncate(&(zip->archive));
- mz_zip_writer_end(&(zip->archive));
- mz_zip_reader_end(&(zip->archive));
-
- CLEANUP(zip);
- }
-}
-
-int zip_is64(struct zip_t *zip) {
- if (!zip || !zip->archive.m_pState) {
- // zip_t handler or zip state is not initialized
- return ZIP_ENOINIT;
- }
-
- return (int)zip->archive.m_pState->m_zip64;
-}
-
-#endif /* !FIZZY_ZIP_WASM */
-
-static int _zip_entry_open(struct zip_t *zip, const char *entryname,
- int case_sensitive) {
- size_t entrylen = 0;
- mz_zip_archive *pzip = NULL;
- mz_uint num_alignment_padding_bytes, level;
- mz_zip_archive_file_stat stats;
- int err = 0;
- mz_uint16 dos_time = 0, dos_date = 0;
- mz_uint32 extra_size = 0;
- mz_uint8 extra_data[MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE];
- mz_uint64 local_dir_header_ofs = 0;
-
- if (!zip) {
- return ZIP_ENOINIT;
- }
-
- local_dir_header_ofs = zip->archive.m_archive_size;
-
- if (!entryname) {
- return ZIP_EINVENTNAME;
- }
-
- entrylen = strlen(entryname);
- if (entrylen == 0) {
- return ZIP_EINVENTNAME;
- }
-
- /*
- .ZIP File Format Specification Version: 6.3.3
-
- 4.4.17.1 The name of the file, with optional relative path.
- The path stored MUST not contain a drive or
- device letter, or a leading slash. All slashes
- MUST be forward slashes '/' as opposed to
- backwards slashes '\' for compatibility with Amiga
- and UNIX file systems etc. If input came from standard
- input, there is no file name field.
- */
- if (zip->entry.name) {
- CLEANUP(zip->entry.name);
- }
-#ifdef ZIP_RAW_ENTRYNAME
- zip->entry.name = STRCLONE(entryname);
-#else
- zip->entry.name = zip_strrpl(entryname, entrylen, '\\', '/');
-#endif
-
- if (!zip->entry.name) {
- // Cannot parse zip entry name
- return ZIP_EINVENTNAME;
- }
-
- pzip = &(zip->archive);
- if (pzip->m_zip_mode == MZ_ZIP_MODE_READING) {
- zip->entry.index = (ssize_t)mz_zip_reader_locate_file(
- pzip, zip->entry.name, NULL,
- case_sensitive ? MZ_ZIP_FLAG_CASE_SENSITIVE : 0);
- if (zip->entry.index < (ssize_t)0) {
- err = ZIP_ENOENT;
- goto cleanup;
- }
-
- if (!mz_zip_reader_file_stat(pzip, (mz_uint)zip->entry.index, &stats)) {
- err = ZIP_ENOENT;
- goto cleanup;
- }
-
- zip->entry.comp_size = stats.m_comp_size;
- zip->entry.uncomp_size = stats.m_uncomp_size;
- zip->entry.uncomp_crc32 = stats.m_crc32;
- zip->entry.offset = stats.m_central_dir_ofs;
- zip->entry.header_offset = stats.m_local_header_ofs;
- zip->entry.method = stats.m_method;
- zip->entry.external_attr = stats.m_external_attr;
-#ifndef MINIZ_NO_TIME
- zip->entry.m_time = stats.m_time;
-#endif
-
- return 0;
- }
-
- level = zip->level & 0xF;
-
- zip->entry.index = (ssize_t)zip->archive.m_total_files;
- zip->entry.comp_size = 0;
- zip->entry.uncomp_size = 0;
- zip->entry.uncomp_crc32 = MZ_CRC32_INIT;
- zip->entry.offset = zip->archive.m_archive_size;
- zip->entry.header_offset = zip->archive.m_archive_size;
- memset(zip->entry.header, 0, MZ_ZIP_LOCAL_DIR_HEADER_SIZE * sizeof(mz_uint8));
- zip->entry.method = level ? MZ_DEFLATED : 0;
-
- // UNIX or APPLE
-#if MZ_PLATFORM == 3 || MZ_PLATFORM == 19
- // regular file with rw-r--r-- permissions
- zip->entry.external_attr = (mz_uint32)(0100644) << 16;
-#else
- zip->entry.external_attr = 0;
-#endif
-
- num_alignment_padding_bytes =
- mz_zip_writer_compute_padding_needed_for_file_alignment(pzip);
-
- if (!pzip->m_pState || (pzip->m_zip_mode != MZ_ZIP_MODE_WRITING)) {
- // Invalid zip mode
- err = ZIP_EINVMODE;
- goto cleanup;
- }
- if (zip->level & MZ_ZIP_FLAG_COMPRESSED_DATA) {
- // Invalid zip compression level
- err = ZIP_EINVLVL;
- goto cleanup;
- }
-
- if (!mz_zip_writer_write_zeros(pzip, zip->entry.offset,
- num_alignment_padding_bytes)) {
- // Cannot memset zip entry header
- err = ZIP_EMEMSET;
- goto cleanup;
- }
- local_dir_header_ofs += num_alignment_padding_bytes;
-
-#ifndef MINIZ_NO_TIME
- zip->entry.m_time = time(NULL);
- mz_zip_time_t_to_dos_time(zip->entry.m_time, &dos_time, &dos_date);
-#endif
-
- // ZIP64 header with NULL sizes (sizes will be in the data descriptor, just
- // after file data)
- extra_size = mz_zip_writer_create_zip64_extra_data(
- extra_data, NULL, NULL,
- (local_dir_header_ofs >= MZ_UINT32_MAX) ? &local_dir_header_ofs : NULL);
-
- if (!mz_zip_writer_create_local_dir_header(
- pzip, zip->entry.header, entrylen, (mz_uint16)extra_size, 0, 0, 0,
- zip->entry.method,
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8 |
- MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR,
- dos_time, dos_date)) {
- // Cannot create zip entry header
- err = ZIP_EMEMSET;
- goto cleanup;
- }
-
- zip->entry.header_offset = zip->entry.offset + num_alignment_padding_bytes;
-
- if (pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.header_offset,
- zip->entry.header,
- sizeof(zip->entry.header)) != sizeof(zip->entry.header)) {
- // Cannot write zip entry header
- err = ZIP_EMEMSET;
- goto cleanup;
- }
-
- if (pzip->m_file_offset_alignment) {
- MZ_ASSERT(
- (zip->entry.header_offset & (pzip->m_file_offset_alignment - 1)) == 0);
- }
- zip->entry.offset += num_alignment_padding_bytes + sizeof(zip->entry.header);
-
- if (pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.offset, zip->entry.name,
- entrylen) != entrylen) {
- // Cannot write data to zip entry
- err = ZIP_EWRTENT;
- goto cleanup;
- }
-
- zip->entry.offset += entrylen;
-
- if (pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.offset, extra_data,
- extra_size) != extra_size) {
- // Cannot write ZIP64 data to zip entry
- err = ZIP_EWRTENT;
- goto cleanup;
- }
- zip->entry.offset += extra_size;
-
- if (level) {
- zip->entry.state.m_pZip = pzip;
- zip->entry.state.m_cur_archive_file_ofs = zip->entry.offset;
- zip->entry.state.m_comp_size = 0;
-
- if (tdefl_init(&(zip->entry.comp), mz_zip_writer_add_put_buf_callback,
- &(zip->entry.state),
- (int)tdefl_create_comp_flags_from_zip_params(
- (int)level, -15, MZ_DEFAULT_STRATEGY)) !=
- TDEFL_STATUS_OKAY) {
- // Cannot initialize the zip compressor
- err = ZIP_ETDEFLINIT;
- goto cleanup;
- }
- }
-
- return 0;
-
-cleanup:
- CLEANUP(zip->entry.name);
- return err;
-}
-
-int zip_entry_open(struct zip_t *zip, const char *entryname) {
- return _zip_entry_open(zip, entryname, 0);
-}
-
-int zip_entry_opencasesensitive(struct zip_t *zip, const char *entryname) {
- return _zip_entry_open(zip, entryname, 1);
-}
-
-int zip_entry_openbyindex(struct zip_t *zip, size_t index) {
- mz_zip_archive *pZip = NULL;
- mz_zip_archive_file_stat stats;
- mz_uint namelen;
- const mz_uint8 *pHeader;
- const char *pFilename;
-
- if (!zip) {
- // zip_t handler is not initialized
- return ZIP_ENOINIT;
- }
-
- pZip = &(zip->archive);
- if (pZip->m_zip_mode != MZ_ZIP_MODE_READING) {
- // open by index requires readonly mode
- return ZIP_EINVMODE;
- }
-
- if (index >= (size_t)pZip->m_total_files) {
- // index out of range
- return ZIP_EINVIDX;
- }
-
- if (!(pHeader = &MZ_ZIP_ARRAY_ELEMENT(
- &pZip->m_pState->m_central_dir, mz_uint8,
- MZ_ZIP_ARRAY_ELEMENT(&pZip->m_pState->m_central_dir_offsets,
- mz_uint32, index)))) {
- // cannot find header in central directory
- return ZIP_ENOHDR;
- }
-
- namelen = MZ_READ_LE16(pHeader + MZ_ZIP_CDH_FILENAME_LEN_OFS);
- pFilename = (const char *)pHeader + MZ_ZIP_CENTRAL_DIR_HEADER_SIZE;
-
- /*
- .ZIP File Format Specification Version: 6.3.3
-
- 4.4.17.1 The name of the file, with optional relative path.
- The path stored MUST not contain a drive or
- device letter, or a leading slash. All slashes
- MUST be forward slashes '/' as opposed to
- backwards slashes '\' for compatibility with Amiga
- and UNIX file systems etc. If input came from standard
- input, there is no file name field.
- */
- if (zip->entry.name) {
- CLEANUP(zip->entry.name);
- }
-#ifdef ZIP_RAW_ENTRYNAME
- zip->entry.name = STRCLONE(pFilename);
-#else
- zip->entry.name = zip_strrpl(pFilename, namelen, '\\', '/');
-#endif
-
- if (!zip->entry.name) {
- // local entry name is NULL
- return ZIP_EINVENTNAME;
- }
-
- if (!mz_zip_reader_file_stat(pZip, (mz_uint)index, &stats)) {
- return ZIP_ENOENT;
- }
-
- zip->entry.index = (ssize_t)index;
- zip->entry.comp_size = stats.m_comp_size;
- zip->entry.uncomp_size = stats.m_uncomp_size;
- zip->entry.uncomp_crc32 = stats.m_crc32;
- zip->entry.offset = stats.m_central_dir_ofs;
- zip->entry.header_offset = stats.m_local_header_ofs;
- zip->entry.method = stats.m_method;
- zip->entry.external_attr = stats.m_external_attr;
-#ifndef MINIZ_NO_TIME
- zip->entry.m_time = stats.m_time;
-#endif
-
- return 0;
-}
-
-int zip_entry_close(struct zip_t *zip) {
- mz_zip_archive *pzip = NULL;
- mz_uint level;
- tdefl_status done;
- mz_uint16 entrylen;
- mz_uint16 dos_time = 0, dos_date = 0;
- int err = 0;
- mz_uint8 *pExtra_data = NULL;
- mz_uint32 extra_size = 0;
- mz_uint8 extra_data[MZ_ZIP64_MAX_CENTRAL_EXTRA_FIELD_SIZE];
- mz_uint8 local_dir_footer[MZ_ZIP_DATA_DESCRIPTER_SIZE64];
- mz_uint32 local_dir_footer_size = MZ_ZIP_DATA_DESCRIPTER_SIZE64;
-
- if (!zip) {
- // zip_t handler is not initialized
- err = ZIP_ENOINIT;
- goto cleanup;
- }
-
- pzip = &(zip->archive);
- if (pzip->m_zip_mode == MZ_ZIP_MODE_READING) {
- goto cleanup;
- }
-
- level = zip->level & 0xF;
- if (level) {
- done = tdefl_compress_buffer(&(zip->entry.comp), "", 0, TDEFL_FINISH);
- if (done != TDEFL_STATUS_DONE && done != TDEFL_STATUS_OKAY) {
- // Cannot flush compressed buffer
- err = ZIP_ETDEFLBUF;
- goto cleanup;
- }
- zip->entry.comp_size = zip->entry.state.m_comp_size;
- zip->entry.offset = zip->entry.state.m_cur_archive_file_ofs;
- zip->entry.method = MZ_DEFLATED;
- }
-
- entrylen = (mz_uint16)strlen(zip->entry.name);
-#ifndef MINIZ_NO_TIME
- mz_zip_time_t_to_dos_time(zip->entry.m_time, &dos_time, &dos_date);
-#endif
-
- MZ_WRITE_LE32(local_dir_footer + 0, MZ_ZIP_DATA_DESCRIPTOR_ID);
- MZ_WRITE_LE32(local_dir_footer + 4, zip->entry.uncomp_crc32);
- MZ_WRITE_LE64(local_dir_footer + 8, zip->entry.comp_size);
- MZ_WRITE_LE64(local_dir_footer + 16, zip->entry.uncomp_size);
-
- if (pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.offset, local_dir_footer,
- local_dir_footer_size) != local_dir_footer_size) {
- // Cannot write zip entry header
- err = ZIP_EWRTHDR;
- goto cleanup;
- }
- zip->entry.offset += local_dir_footer_size;
-
- pExtra_data = extra_data;
- extra_size = mz_zip_writer_create_zip64_extra_data(
- extra_data,
- (zip->entry.uncomp_size >= MZ_UINT32_MAX) ? &zip->entry.uncomp_size
- : NULL,
- (zip->entry.comp_size >= MZ_UINT32_MAX) ? &zip->entry.comp_size : NULL,
- (zip->entry.header_offset >= MZ_UINT32_MAX) ? &zip->entry.header_offset
- : NULL);
-
- if ((entrylen) && (zip->entry.name[entrylen - 1] == '/') &&
- !zip->entry.uncomp_size) {
- /* Set DOS Subdirectory attribute bit. */
- zip->entry.external_attr |= MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG;
- }
-
- if (!mz_zip_writer_add_to_central_dir(
- pzip, zip->entry.name, entrylen, pExtra_data, (mz_uint16)extra_size,
- "", 0, zip->entry.uncomp_size, zip->entry.comp_size,
- zip->entry.uncomp_crc32, zip->entry.method,
- MZ_ZIP_GENERAL_PURPOSE_BIT_FLAG_UTF8 |
- MZ_ZIP_LDH_BIT_FLAG_HAS_LOCATOR,
- dos_time, dos_date, zip->entry.header_offset,
- zip->entry.external_attr, NULL, 0)) {
- // Cannot write to zip central dir
- err = ZIP_EWRTDIR;
- goto cleanup;
- }
-
- pzip->m_total_files++;
- pzip->m_archive_size = zip->entry.offset;
-
-cleanup:
- if (zip) {
- zip->entry.m_time = 0;
- CLEANUP(zip->entry.name);
- }
- return err;
-}
-
-const char *zip_entry_name(struct zip_t *zip) {
- if (!zip) {
- // zip_t handler is not initialized
- return NULL;
- }
-
- return zip->entry.name;
-}
-
-ssize_t zip_entry_index(struct zip_t *zip) {
- if (!zip) {
- // zip_t handler is not initialized
- return (ssize_t)ZIP_ENOINIT;
- }
-
- return zip->entry.index;
-}
-
-int zip_entry_isdir(struct zip_t *zip) {
- if (!zip) {
- // zip_t handler is not initialized
- return ZIP_ENOINIT;
- }
-
- if (zip->entry.index < (ssize_t)0) {
- // zip entry is not opened
- return ZIP_EINVIDX;
- }
-
- return (int)mz_zip_reader_is_file_a_directory(&zip->archive,
- (mz_uint)zip->entry.index);
-}
-
-unsigned long long zip_entry_size(struct zip_t *zip) {
- return zip_entry_uncomp_size(zip);
-}
-
-unsigned long long zip_entry_uncomp_size(struct zip_t *zip) {
- return zip ? zip->entry.uncomp_size : 0;
-}
-
-unsigned long long zip_entry_comp_size(struct zip_t *zip) {
- return zip ? zip->entry.comp_size : 0;
-}
-
-unsigned int zip_entry_crc32(struct zip_t *zip) {
- return zip ? zip->entry.uncomp_crc32 : 0;
-}
-
-int zip_entry_write(struct zip_t *zip, const void *buf, size_t bufsize) {
- mz_uint level;
- mz_zip_archive *pzip = NULL;
- tdefl_status status;
-
- if (!zip) {
- // zip_t handler is not initialized
- return ZIP_ENOINIT;
- }
-
- pzip = &(zip->archive);
- if (buf && bufsize > 0) {
- zip->entry.uncomp_size += bufsize;
- zip->entry.uncomp_crc32 = (mz_uint32)mz_crc32(
- zip->entry.uncomp_crc32, (const mz_uint8 *)buf, bufsize);
-
- level = zip->level & 0xF;
- if (!level) {
- if ((pzip->m_pWrite(pzip->m_pIO_opaque, zip->entry.offset, buf,
- bufsize) != bufsize)) {
- // Cannot write buffer
- return ZIP_EWRTENT;
- }
- zip->entry.offset += bufsize;
- zip->entry.comp_size += bufsize;
- } else {
- status = tdefl_compress_buffer(&(zip->entry.comp), buf, bufsize,
- TDEFL_NO_FLUSH);
- if (status != TDEFL_STATUS_DONE && status != TDEFL_STATUS_OKAY) {
- // Cannot compress buffer
- return ZIP_ETDEFLBUF;
- }
- }
- }
-
- return 0;
-}
-
-#ifndef FIZZY_ZIP_WASM
-
-int zip_entry_fwrite(struct zip_t *zip, const char *filename) {
- int err = 0;
- size_t n = 0;
- MZ_FILE *stream = NULL;
- mz_uint8 buf[MZ_ZIP_MAX_IO_BUF_SIZE];
- struct MZ_FILE_STAT_STRUCT file_stat;
- mz_uint16 modes;
-
- if (!zip) {
- // zip_t handler is not initialized
- return ZIP_ENOINIT;
- }
-
- memset(buf, 0, MZ_ZIP_MAX_IO_BUF_SIZE);
- memset((void *)&file_stat, 0, sizeof(struct MZ_FILE_STAT_STRUCT));
- if (MZ_FILE_STAT(filename, &file_stat) != 0) {
- // problem getting information - check errno
- return ZIP_ENOENT;
- }
-
-#if defined(_WIN32) || defined(__WIN32__) || defined(DJGPP)
- (void)modes; // unused
-#else
- /* Initialize with permission bits--which are not implementation-optional */
- modes = file_stat.st_mode &
- (S_IRWXU | S_IRWXG | S_IRWXO | S_ISUID | S_ISGID | S_ISVTX);
- if (S_ISDIR(file_stat.st_mode))
- modes |= UNX_IFDIR;
- if (S_ISREG(file_stat.st_mode))
- modes |= UNX_IFREG;
- if (S_ISLNK(file_stat.st_mode))
- modes |= UNX_IFLNK;
- if (S_ISBLK(file_stat.st_mode))
- modes |= UNX_IFBLK;
- if (S_ISCHR(file_stat.st_mode))
- modes |= UNX_IFCHR;
- if (S_ISFIFO(file_stat.st_mode))
- modes |= UNX_IFIFO;
- if (S_ISSOCK(file_stat.st_mode))
- modes |= UNX_IFSOCK;
- zip->entry.external_attr = (modes << 16) | !(file_stat.st_mode & S_IWUSR);
- if ((file_stat.st_mode & S_IFMT) == S_IFDIR) {
- zip->entry.external_attr |= MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG;
- }
-#endif
-
- zip->entry.m_time = file_stat.st_mtime;
-
- if (!(stream = MZ_FOPEN(filename, "rb"))) {
- // Cannot open filename
- return ZIP_EOPNFILE;
- }
-
- while ((n = fread(buf, sizeof(mz_uint8), MZ_ZIP_MAX_IO_BUF_SIZE, stream)) >
- 0) {
- if (zip_entry_write(zip, buf, n) < 0) {
- err = ZIP_EWRTENT;
- break;
- }
- }
- fclose(stream);
-
- return err;
-}
-
-#endif /* !FIZZY_ZIP_WASM */
-
-ssize_t zip_entry_read(struct zip_t *zip, void **buf, size_t *bufsize) {
- mz_zip_archive *pzip = NULL;
- mz_uint idx;
- size_t size = 0;
-
- if (!zip) {
- // zip_t handler is not initialized
- return (ssize_t)ZIP_ENOINIT;
- }
-
- pzip = &(zip->archive);
- if (pzip->m_zip_mode != MZ_ZIP_MODE_READING ||
- zip->entry.index < (ssize_t)0) {
- // the entry is not found or we do not have read access
- return (ssize_t)ZIP_ENOENT;
- }
-
- idx = (mz_uint)zip->entry.index;
- if (mz_zip_reader_is_file_a_directory(pzip, idx)) {
- // the entry is a directory
- return (ssize_t)ZIP_EINVENTTYPE;
- }
-
- *buf = mz_zip_reader_extract_to_heap(pzip, idx, &size, 0);
- if (*buf && bufsize) {
- *bufsize = size;
- }
- return (ssize_t)size;
-}
-
-ssize_t zip_entry_noallocread(struct zip_t *zip, void *buf, size_t bufsize) {
- mz_zip_archive *pzip = NULL;
-
- if (!zip) {
- // zip_t handler is not initialized
- return (ssize_t)ZIP_ENOINIT;
- }
-
- pzip = &(zip->archive);
- if (pzip->m_zip_mode != MZ_ZIP_MODE_READING ||
- zip->entry.index < (ssize_t)0) {
- // the entry is not found or we do not have read access
- return (ssize_t)ZIP_ENOENT;
- }
-
- if (!mz_zip_reader_extract_to_mem_no_alloc(pzip, (mz_uint)zip->entry.index,
- buf, bufsize, 0, NULL, 0)) {
- return (ssize_t)ZIP_EMEMNOALLOC;
- }
-
- return (ssize_t)zip->entry.uncomp_size;
-}
-
-#ifndef FIZZY_ZIP_WASM
-
-int zip_entry_fread(struct zip_t *zip, const char *filename) {
- mz_zip_archive *pzip = NULL;
- mz_uint idx;
- mz_uint32 xattr = 0;
- mz_zip_archive_file_stat info;
-
- if (!zip) {
- // zip_t handler is not initialized
- return ZIP_ENOINIT;
- }
-
- memset((void *)&info, 0, sizeof(mz_zip_archive_file_stat));
- pzip = &(zip->archive);
- if (pzip->m_zip_mode != MZ_ZIP_MODE_READING ||
- zip->entry.index < (ssize_t)0) {
- // the entry is not found or we do not have read access
- return ZIP_ENOENT;
- }
-
- idx = (mz_uint)zip->entry.index;
- if (mz_zip_reader_is_file_a_directory(pzip, idx)) {
- // the entry is a directory
- return ZIP_EINVENTTYPE;
- }
-
- if (!mz_zip_reader_extract_to_file(pzip, idx, filename, 0)) {
- return ZIP_ENOFILE;
- }
-
-#if defined(_MSC_VER) || defined(PS4)
- (void)xattr; // unused
-#else
- if (!mz_zip_reader_file_stat(pzip, idx, &info)) {
- // Cannot get information about zip archive;
- return ZIP_ENOFILE;
- }
-
- xattr = (info.m_external_attr >> 16) & 0xFFFF;
- if (xattr > 0 && xattr <= MZ_UINT16_MAX) {
- if (CHMOD(filename, (mode_t)xattr) < 0) {
- return ZIP_ENOPERM;
- }
- }
-#endif
-
- return 0;
-}
-
-int zip_entry_extract(struct zip_t *zip,
- size_t (*on_extract)(void *arg, uint64_t offset,
- const void *buf, size_t bufsize),
- void *arg) {
- mz_zip_archive *pzip = NULL;
- mz_uint idx;
-
- if (!zip) {
- // zip_t handler is not initialized
- return ZIP_ENOINIT;
- }
-
- pzip = &(zip->archive);
- if (pzip->m_zip_mode != MZ_ZIP_MODE_READING ||
- zip->entry.index < (ssize_t)0) {
- // the entry is not found or we do not have read access
- return ZIP_ENOENT;
- }
-
- idx = (mz_uint)zip->entry.index;
- return (mz_zip_reader_extract_to_callback(pzip, idx, on_extract, arg, 0))
- ? 0
- : ZIP_EINVIDX;
-}
-
-#endif /* !FIZZY_ZIP_WASM */
-
-ssize_t zip_entries_total(struct zip_t *zip) {
- if (!zip) {
- // zip_t handler is not initialized
- return ZIP_ENOINIT;
- }
-
- return (ssize_t)zip->archive.m_total_files;
-}
-
-#ifndef FIZZY_ZIP_WASM
-
-ssize_t zip_entries_delete(struct zip_t *zip, char *const entries[],
- size_t len) {
- ssize_t n = 0;
- ssize_t err = 0;
- struct zip_entry_mark_t *entry_mark = NULL;
-
- if (zip == NULL || (entries == NULL && len != 0)) {
- return ZIP_ENOINIT;
- }
-
- if (entries == NULL && len == 0) {
- return 0;
- }
-
- n = zip_entries_total(zip);
-
- entry_mark = (struct zip_entry_mark_t *)calloc(
- (size_t)n, sizeof(struct zip_entry_mark_t));
- if (!entry_mark) {
- return ZIP_EOOMEM;
- }
-
- zip->archive.m_zip_mode = MZ_ZIP_MODE_READING;
-
- err = zip_entry_set(zip, entry_mark, n, entries, len);
- if (err < 0) {
- CLEANUP(entry_mark);
- return err;
- }
-
- err = zip_entries_delete_mark(zip, entry_mark, (int)n);
- CLEANUP(entry_mark);
- return err;
-}
-
-#endif /* !FIZZY_ZIP_WASM */
-
-#ifndef FIZZY_ZIP_WASM
-
-int zip_stream_extract(const char *stream, size_t size, const char *dir,
- int (*on_extract)(const char *filename, void *arg),
- void *arg) {
- mz_zip_archive zip_archive;
- if (!stream || !dir) {
- // Cannot parse zip archive stream
- return ZIP_ENOINIT;
- }
- if (!memset(&zip_archive, 0, sizeof(mz_zip_archive))) {
- // Cannot memset zip archive
- return ZIP_EMEMSET;
- }
- if (!mz_zip_reader_init_mem(&zip_archive, stream, size, 0)) {
- // Cannot initialize zip_archive reader
- return ZIP_ENOINIT;
- }
-
- return zip_archive_extract(&zip_archive, dir, on_extract, arg);
-}
-
-#endif /* !FIZZY_ZIP_WASM */
-
-struct zip_t *zip_stream_open(const char *stream, size_t size, int level,
- char mode) {
- struct zip_t *zip = (struct zip_t *)calloc((size_t)1, sizeof(struct zip_t));
- if (!zip) {
- return NULL;
- }
-
- if (level < 0) {
- level = MZ_DEFAULT_LEVEL;
- }
- if ((level & 0xF) > MZ_UBER_COMPRESSION) {
- // Wrong compression level
- goto cleanup;
- }
- zip->level = (mz_uint)level;
-
- if ((stream != NULL) && (size > 0) && (mode == 'r')) {
- if (!mz_zip_reader_init_mem(&(zip->archive), stream, size, 0)) {
- goto cleanup;
- }
- } else if ((stream == NULL) && (size == 0) && (mode == 'w')) {
- // Create a new archive.
- if (!mz_zip_writer_init_heap(&(zip->archive), 0, 1024)) {
- // Cannot initialize zip_archive writer
- goto cleanup;
- }
- } else {
- goto cleanup;
- }
- return zip;
-
-cleanup:
- CLEANUP(zip);
- return NULL;
-}
-
-ssize_t zip_stream_copy(struct zip_t *zip, void **buf, size_t *bufsize) {
- size_t n;
-
- if (!zip) {
- return (ssize_t)ZIP_ENOINIT;
- }
- zip_archive_finalize(&(zip->archive));
-
- n = (size_t)zip->archive.m_archive_size;
- if (bufsize != NULL) {
- *bufsize = n;
- }
-
- *buf = calloc(sizeof(unsigned char), n);
- memcpy(*buf, zip->archive.m_pState->m_pMem, n);
-
- return (ssize_t)n;
-}
-
-void zip_stream_close(struct zip_t *zip) {
- if (zip) {
- mz_zip_writer_end(&(zip->archive));
- mz_zip_reader_end(&(zip->archive));
- CLEANUP(zip);
- }
-}
-
-#ifndef FIZZY_ZIP_WASM
-
-int zip_create(const char *zipname, const char *filenames[], size_t len) {
- int err = 0;
- size_t i;
- mz_zip_archive zip_archive;
- struct MZ_FILE_STAT_STRUCT file_stat;
- mz_uint32 ext_attributes = 0;
- mz_uint16 modes;
-
- if (!zipname || strlen(zipname) < 1) {
- // zip_t archive name is empty or NULL
- return ZIP_EINVZIPNAME;
- }
-
- // Create a new archive.
- if (!memset(&(zip_archive), 0, sizeof(zip_archive))) {
- // Cannot memset zip archive
- return ZIP_EMEMSET;
- }
-
- if (!mz_zip_writer_init_file(&zip_archive, zipname, 0)) {
- // Cannot initialize zip_archive writer
- return ZIP_ENOINIT;
- }
-
- if (!memset((void *)&file_stat, 0, sizeof(struct MZ_FILE_STAT_STRUCT))) {
- return ZIP_EMEMSET;
- }
-
- for (i = 0; i < len; ++i) {
- const char *name = filenames[i];
- if (!name) {
- err = ZIP_EINVENTNAME;
- break;
- }
-
- if (MZ_FILE_STAT(name, &file_stat) != 0) {
- // problem getting information - check errno
- err = ZIP_ENOFILE;
- break;
- }
-
-#if defined(_WIN32) || defined(__WIN32__) || defined(DJGPP)
- (void)modes; // unused
-#else
-
- /* Initialize with permission bits--which are not implementation-optional */
- modes = file_stat.st_mode &
- (S_IRWXU | S_IRWXG | S_IRWXO | S_ISUID | S_ISGID | S_ISVTX);
- if (S_ISDIR(file_stat.st_mode))
- modes |= UNX_IFDIR;
- if (S_ISREG(file_stat.st_mode))
- modes |= UNX_IFREG;
- if (S_ISLNK(file_stat.st_mode))
- modes |= UNX_IFLNK;
- if (S_ISBLK(file_stat.st_mode))
- modes |= UNX_IFBLK;
- if (S_ISCHR(file_stat.st_mode))
- modes |= UNX_IFCHR;
- if (S_ISFIFO(file_stat.st_mode))
- modes |= UNX_IFIFO;
- if (S_ISSOCK(file_stat.st_mode))
- modes |= UNX_IFSOCK;
- ext_attributes = (modes << 16) | !(file_stat.st_mode & S_IWUSR);
- if ((file_stat.st_mode & S_IFMT) == S_IFDIR) {
- ext_attributes |= MZ_ZIP_DOS_DIR_ATTRIBUTE_BITFLAG;
- }
-#endif
-
- if (!mz_zip_writer_add_file(&zip_archive, zip_basename(name), name, "", 0,
- ZIP_DEFAULT_COMPRESSION_LEVEL,
- ext_attributes)) {
- // Cannot add file to zip_archive
- err = ZIP_ENOFILE;
- break;
- }
- }
-
- mz_zip_writer_finalize_archive(&zip_archive);
- mz_zip_writer_end(&zip_archive);
- return err;
-}
-
-int zip_extract(const char *zipname, const char *dir,
- int (*on_extract)(const char *filename, void *arg), void *arg) {
- mz_zip_archive zip_archive;
-
- if (!zipname || !dir) {
- // Cannot parse zip archive name
- return ZIP_EINVZIPNAME;
- }
-
- if (!memset(&zip_archive, 0, sizeof(mz_zip_archive))) {
- // Cannot memset zip archive
- return ZIP_EMEMSET;
- }
-
- // Now try to open the archive.
- if (!mz_zip_reader_init_file(&zip_archive, zipname, 0)) {
- // Cannot initialize zip_archive reader
- return ZIP_ENOINIT;
- }
-
- return zip_archive_extract(&zip_archive, dir, on_extract, arg);
-}
-
-#else /* FIZZY_ZIP_WASM */
-
-struct zip_t *zip_open(const char *zipname, int level, char mode) {
- (void)zipname;
- (void)level;
- (void)mode;
- return NULL;
-}
-
-void zip_close(struct zip_t *zip) {
- if (zip) {
- mz_zip_writer_end(&(zip->archive));
- mz_zip_reader_end(&(zip->archive));
- CLEANUP(zip);
- }
-}
-
-int zip_entry_fwrite(struct zip_t *zip, const char *filename) {
- (void)zip;
- (void)filename;
- return ZIP_ENOFILE;
-}
-
-int zip_entry_fread(struct zip_t *zip, const char *filename) {
- (void)zip;
- (void)filename;
- return ZIP_ENOFILE;
-}
-
-int zip_entry_extract(struct zip_t *zip,
- size_t (*on_extract)(void *arg, uint64_t offset,
- const void *buf, size_t bufsize),
- void *arg) {
- (void)zip;
- (void)on_extract;
- (void)arg;
- return ZIP_ENOFILE;
-}
-
-ssize_t zip_entries_delete(struct zip_t *zip, char *const entries[],
- size_t len) {
- (void)zip;
- (void)entries;
- (void)len;
- return ZIP_ENOFILE;
-}
-
-int zip_stream_extract(const char *stream, size_t size, const char *dir,
- int (*on_extract)(const char *filename, void *arg),
- void *arg) {
- (void)stream;
- (void)size;
- (void)dir;
- (void)on_extract;
- (void)arg;
- return ZIP_ENOFILE;
-}
-
-int zip_create(const char *zipname, const char *filenames[], size_t len) {
- (void)zipname;
- (void)filenames;
- (void)len;
- return ZIP_ENOFILE;
-}
-
-int zip_extract(const char *zipname, const char *dir,
- int (*on_extract)(const char *filename, void *arg), void *arg) {
- (void)zipname;
- (void)dir;
- (void)on_extract;
- (void)arg;
- return ZIP_ENOFILE;
-}
-
-#endif /* FIZZY_ZIP_WASM */
diff --git a/src/deps/zip/src/zip.h b/src/deps/zip/src/zip.h
deleted file mode 100644
index 84973acd..00000000
--- a/src/deps/zip/src/zip.h
+++ /dev/null
@@ -1,477 +0,0 @@
-/*
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
- * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
- * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
- * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
- * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
- * OTHER DEALINGS IN THE SOFTWARE.
- */
-
-#pragma once
-#ifndef ZIP_H
-#define ZIP_H
-
-#include
-#ifdef FIZZY_ZIP_WASM
-extern void *memcpy(void *dest, const void *src, size_t n);
-extern void *memset(void *dest, int c, size_t n);
-#else
-#include
-#endif
-#ifdef FIZZY_ZIP_WASM
-#include
-#ifndef _SSIZE_T_DEFINED
-#define _SSIZE_T_DEFINED
-typedef ptrdiff_t ssize_t;
-#endif
-#else
-#include
-#endif
-
-#ifndef ZIP_SHARED
-#define ZIP_EXPORT
-#else
-#ifdef _WIN32
-#ifdef ZIP_BUILD_SHARED
-#define ZIP_EXPORT __declspec(dllexport)
-#else
-#define ZIP_EXPORT __declspec(dllimport)
-#endif
-#else
-#define ZIP_EXPORT __attribute__((visibility("default")))
-#endif
-#endif
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-#if !defined(_POSIX_C_SOURCE) && defined(_MSC_VER)
-// 64-bit Windows is the only mainstream platform
-// where sizeof(long) != sizeof(void*)
-#ifdef _WIN64
-typedef long long ssize_t; /* byte count or error */
-#else
-typedef long ssize_t; /* byte count or error */
-#endif
-#endif
-
-/**
- * @mainpage
- *
- * Documentation for @ref zip.
- */
-
-/**
- * @addtogroup zip
- * @{
- */
-
-/**
- * Default zip compression level.
- */
-#define ZIP_DEFAULT_COMPRESSION_LEVEL 6
-
-/**
- * Error codes
- */
-#define ZIP_ENOINIT -1 // not initialized
-#define ZIP_EINVENTNAME -2 // invalid entry name
-#define ZIP_ENOENT -3 // entry not found
-#define ZIP_EINVMODE -4 // invalid zip mode
-#define ZIP_EINVLVL -5 // invalid compression level
-#define ZIP_ENOSUP64 -6 // no zip 64 support
-#define ZIP_EMEMSET -7 // memset error
-#define ZIP_EWRTENT -8 // cannot write data to entry
-#define ZIP_ETDEFLINIT -9 // cannot initialize tdefl compressor
-#define ZIP_EINVIDX -10 // invalid index
-#define ZIP_ENOHDR -11 // header not found
-#define ZIP_ETDEFLBUF -12 // cannot flush tdefl buffer
-#define ZIP_ECRTHDR -13 // cannot create entry header
-#define ZIP_EWRTHDR -14 // cannot write entry header
-#define ZIP_EWRTDIR -15 // cannot write to central dir
-#define ZIP_EOPNFILE -16 // cannot open file
-#define ZIP_EINVENTTYPE -17 // invalid entry type
-#define ZIP_EMEMNOALLOC -18 // extracting data using no memory allocation
-#define ZIP_ENOFILE -19 // file not found
-#define ZIP_ENOPERM -20 // no permission
-#define ZIP_EOOMEM -21 // out of memory
-#define ZIP_EINVZIPNAME -22 // invalid zip archive name
-#define ZIP_EMKDIR -23 // make dir error
-#define ZIP_ESYMLINK -24 // symlink error
-#define ZIP_ECLSZIP -25 // close archive error
-#define ZIP_ECAPSIZE -26 // capacity size too small
-#define ZIP_EFSEEK -27 // fseek error
-#define ZIP_EFREAD -28 // fread error
-#define ZIP_EFWRITE -29 // fwrite error
-
-/**
- * Looks up the error message string corresponding to an error number.
- * @param errnum error number
- * @return error message string corresponding to errnum or NULL if error is not
- * found.
- */
-extern ZIP_EXPORT const char *zip_strerror(int errnum);
-
-/**
- * @struct zip_t
- *
- * This data structure is used throughout the library to represent zip archive -
- * forward declaration.
- */
-struct zip_t;
-
-/**
- * Opens zip archive with compression level using the given mode.
- *
- * @param zipname zip archive file name.
- * @param level compression level (0-9 are the standard zlib-style levels).
- * @param mode file access mode.
- * - 'r': opens a file for reading/extracting (the file must exists).
- * - 'w': creates an empty file for writing.
- * - 'a': appends to an existing archive.
- *
- * @return the zip archive handler or NULL on error
- */
-extern ZIP_EXPORT struct zip_t *zip_open(const char *zipname, int level,
- char mode);
-
-/**
- * Closes the zip archive, releases resources - always finalize.
- *
- * @param zip zip archive handler.
- */
-extern ZIP_EXPORT void zip_close(struct zip_t *zip);
-
-/**
- * Determines if the archive has a zip64 end of central directory headers.
- *
- * @param zip zip archive handler.
- *
- * @return the return code - 1 (true), 0 (false), negative number (< 0) on
- * error.
- */
-extern ZIP_EXPORT int zip_is64(struct zip_t *zip);
-
-/**
- * Opens an entry by name in the zip archive.
- *
- * For zip archive opened in 'w' or 'a' mode the function will append
- * a new entry. In readonly mode the function tries to locate the entry
- * in global dictionary.
- *
- * @param zip zip archive handler.
- * @param entryname an entry name in local dictionary.
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int zip_entry_open(struct zip_t *zip, const char *entryname);
-
-/**
- * Opens an entry by name in the zip archive.
- *
- * For zip archive opened in 'w' or 'a' mode the function will append
- * a new entry. In readonly mode the function tries to locate the entry
- * in global dictionary (case sensitive).
- *
- * @param zip zip archive handler.
- * @param entryname an entry name in local dictionary (case sensitive).
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int zip_entry_opencasesensitive(struct zip_t *zip,
- const char *entryname);
-
-/**
- * Opens a new entry by index in the zip archive.
- *
- * This function is only valid if zip archive was opened in 'r' (readonly) mode.
- *
- * @param zip zip archive handler.
- * @param index index in local dictionary.
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int zip_entry_openbyindex(struct zip_t *zip, size_t index);
-
-/**
- * Closes a zip entry, flushes buffer and releases resources.
- *
- * @param zip zip archive handler.
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int zip_entry_close(struct zip_t *zip);
-
-/**
- * Returns a local name of the current zip entry.
- *
- * The main difference between user's entry name and local entry name
- * is optional relative path.
- * Following .ZIP File Format Specification - the path stored MUST not contain
- * a drive or device letter, or a leading slash.
- * All slashes MUST be forward slashes '/' as opposed to backwards slashes '\'
- * for compatibility with Amiga and UNIX file systems etc.
- *
- * @param zip: zip archive handler.
- *
- * @return the pointer to the current zip entry name, or NULL on error.
- */
-extern ZIP_EXPORT const char *zip_entry_name(struct zip_t *zip);
-
-/**
- * Returns an index of the current zip entry.
- *
- * @param zip zip archive handler.
- *
- * @return the index on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT ssize_t zip_entry_index(struct zip_t *zip);
-
-/**
- * Determines if the current zip entry is a directory entry.
- *
- * @param zip zip archive handler.
- *
- * @return the return code - 1 (true), 0 (false), negative number (< 0) on
- * error.
- */
-extern ZIP_EXPORT int zip_entry_isdir(struct zip_t *zip);
-
-/**
- * Returns the uncompressed size of the current zip entry.
- * Alias for zip_entry_uncomp_size (for backward compatibility).
- *
- * @param zip zip archive handler.
- *
- * @return the uncompressed size in bytes.
- */
-extern ZIP_EXPORT unsigned long long zip_entry_size(struct zip_t *zip);
-
-/**
- * Returns the uncompressed size of the current zip entry.
- *
- * @param zip zip archive handler.
- *
- * @return the uncompressed size in bytes.
- */
-extern ZIP_EXPORT unsigned long long zip_entry_uncomp_size(struct zip_t *zip);
-
-/**
- * Returns the compressed size of the current zip entry.
- *
- * @param zip zip archive handler.
- *
- * @return the compressed size in bytes.
- */
-extern ZIP_EXPORT unsigned long long zip_entry_comp_size(struct zip_t *zip);
-
-/**
- * Returns CRC-32 checksum of the current zip entry.
- *
- * @param zip zip archive handler.
- *
- * @return the CRC-32 checksum.
- */
-extern ZIP_EXPORT unsigned int zip_entry_crc32(struct zip_t *zip);
-
-/**
- * Compresses an input buffer for the current zip entry.
- *
- * @param zip zip archive handler.
- * @param buf input buffer.
- * @param bufsize input buffer size (in bytes).
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int zip_entry_write(struct zip_t *zip, const void *buf,
- size_t bufsize);
-
-/**
- * Compresses a file for the current zip entry.
- *
- * @param zip zip archive handler.
- * @param filename input file.
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int zip_entry_fwrite(struct zip_t *zip, const char *filename);
-
-/**
- * Extracts the current zip entry into output buffer.
- *
- * The function allocates sufficient memory for a output buffer.
- *
- * @param zip zip archive handler.
- * @param buf output buffer.
- * @param bufsize output buffer size (in bytes).
- *
- * @note remember to release memory allocated for a output buffer.
- * for large entries, please take a look at zip_entry_extract function.
- *
- * @return the return code - the number of bytes actually read on success.
- * Otherwise a negative number (< 0) on error.
- */
-extern ZIP_EXPORT ssize_t zip_entry_read(struct zip_t *zip, void **buf,
- size_t *bufsize);
-
-/**
- * Extracts the current zip entry into a memory buffer using no memory
- * allocation.
- *
- * @param zip zip archive handler.
- * @param buf preallocated output buffer.
- * @param bufsize output buffer size (in bytes).
- *
- * @note ensure supplied output buffer is large enough.
- * zip_entry_size function (returns uncompressed size for the current
- * entry) can be handy to estimate how big buffer is needed.
- * For large entries, please take a look at zip_entry_extract function.
- *
- * @return the return code - the number of bytes actually read on success.
- * Otherwise a negative number (< 0) on error (e.g. bufsize is not large
- * enough).
- */
-extern ZIP_EXPORT ssize_t zip_entry_noallocread(struct zip_t *zip, void *buf,
- size_t bufsize);
-
-/**
- * Extracts the current zip entry into output file.
- *
- * @param zip zip archive handler.
- * @param filename output file.
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int zip_entry_fread(struct zip_t *zip, const char *filename);
-
-/**
- * Extracts the current zip entry using a callback function (on_extract).
- *
- * @param zip zip archive handler.
- * @param on_extract callback function.
- * @param arg opaque pointer (optional argument, which you can pass to the
- * on_extract callback)
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int
-zip_entry_extract(struct zip_t *zip,
- size_t (*on_extract)(void *arg, uint64_t offset,
- const void *data, size_t size),
- void *arg);
-
-/**
- * Returns the number of all entries (files and directories) in the zip archive.
- *
- * @param zip zip archive handler.
- *
- * @return the return code - the number of entries on success, negative number
- * (< 0) on error.
- */
-extern ZIP_EXPORT ssize_t zip_entries_total(struct zip_t *zip);
-
-/**
- * Deletes zip archive entries.
- *
- * @param zip zip archive handler.
- * @param entries array of zip archive entries to be deleted.
- * @param len the number of entries to be deleted.
- * @return the number of deleted entries, or negative number (< 0) on error.
- */
-extern ZIP_EXPORT ssize_t zip_entries_delete(struct zip_t *zip,
- char *const entries[], size_t len);
-
-/**
- * Extracts a zip archive stream into directory.
- *
- * If on_extract is not NULL, the callback will be called after
- * successfully extracted each zip entry.
- * Returning a negative value from the callback will cause abort and return an
- * error. The last argument (void *arg) is optional, which you can use to pass
- * data to the on_extract callback.
- *
- * @param stream zip archive stream.
- * @param size stream size.
- * @param dir output directory.
- * @param on_extract on extract callback.
- * @param arg opaque pointer.
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int
-zip_stream_extract(const char *stream, size_t size, const char *dir,
- int (*on_extract)(const char *filename, void *arg),
- void *arg);
-
-/**
- * Opens zip archive stream into memory.
- *
- * @param stream zip archive stream.
- * @param size stream size.
- *
- * @return the zip archive handler or NULL on error
- */
-extern ZIP_EXPORT struct zip_t *zip_stream_open(const char *stream, size_t size,
- int level, char mode);
-
-/**
- * Copy zip archive stream output buffer.
- *
- * @param zip zip archive handler.
- * @param buf output buffer. User should free buf.
- * @param bufsize output buffer size (in bytes).
- *
- * @return copy size
- */
-extern ZIP_EXPORT ssize_t zip_stream_copy(struct zip_t *zip, void **buf,
- size_t *bufsize);
-
-/**
- * Close zip archive releases resources.
- *
- * @param zip zip archive handler.
- *
- * @return
- */
-extern ZIP_EXPORT void zip_stream_close(struct zip_t *zip);
-
-/**
- * Creates a new archive and puts files into a single zip archive.
- *
- * @param zipname zip archive file.
- * @param filenames input files.
- * @param len: number of input files.
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int zip_create(const char *zipname, const char *filenames[],
- size_t len);
-
-/**
- * Extracts a zip archive file into directory.
- *
- * If on_extract_entry is not NULL, the callback will be called after
- * successfully extracted each zip entry.
- * Returning a negative value from the callback will cause abort and return an
- * error. The last argument (void *arg) is optional, which you can use to pass
- * data to the on_extract_entry callback.
- *
- * @param zipname zip archive file.
- * @param dir output directory.
- * @param on_extract_entry on extract callback.
- * @param arg opaque pointer.
- *
- * @return the return code - 0 on success, negative number (< 0) on error.
- */
-extern ZIP_EXPORT int zip_extract(const char *zipname, const char *dir,
- int (*on_extract_entry)(const char *filename,
- void *arg),
- void *arg);
-/** @} */
-#ifdef __cplusplus
-}
-#endif
-
-#endif
diff --git a/src/deps/zip/zip.zig b/src/deps/zip/zip.zig
deleted file mode 100644
index ea1e635a..00000000
--- a/src/deps/zip/zip.zig
+++ /dev/null
@@ -1,62 +0,0 @@
-//usingnamespace @cImport(@cInclude("zip.h"));
-pub extern fn zip_strerror(errnum: c_int) [*c]const u8;
-pub const struct_zip_t = opaque {};
-pub extern fn zip_open(zipname: [*c]const u8, level: c_int, mode: u8) ?*struct_zip_t;
-pub extern fn zip_close(zip: ?*struct_zip_t) void;
-pub extern fn zip_is64(zip: ?*struct_zip_t) c_int;
-pub extern fn zip_entry_open(zip: ?*struct_zip_t, entryname: [*c]const u8) c_int;
-pub extern fn zip_entry_openbyindex(zip: ?*struct_zip_t, index: c_int) c_int;
-pub extern fn zip_entry_close(zip: ?*struct_zip_t) c_int;
-pub extern fn zip_entry_name(zip: ?*struct_zip_t) [*c]const u8;
-pub extern fn zip_entry_index(zip: ?*struct_zip_t) c_int;
-pub extern fn zip_entry_isdir(zip: ?*struct_zip_t) c_int;
-pub extern fn zip_entry_size(zip: ?*struct_zip_t) c_ulonglong;
-pub extern fn zip_entry_crc32(zip: ?*struct_zip_t) c_uint;
-pub extern fn zip_entry_write(zip: ?*struct_zip_t, buf: ?*const anyopaque, bufsize: usize) c_int;
-pub extern fn zip_entry_fwrite(zip: ?*struct_zip_t, filename: [*c]const u8) c_int;
-pub extern fn zip_entry_read(zip: ?*struct_zip_t, buf: [*c]?*anyopaque, bufsize: [*c]usize) isize;
-pub extern fn zip_entry_noallocread(zip: ?*struct_zip_t, buf: ?*anyopaque, bufsize: usize) isize;
-pub extern fn zip_entry_fread(zip: ?*struct_zip_t, filename: [*c]const u8) c_int;
-pub extern fn zip_entry_extract(zip: ?*struct_zip_t, on_extract: ?fn (?*anyopaque, c_ulonglong, ?*const anyopaque, usize) callconv(.C) usize, arg: ?*anyopaque) c_int;
-pub extern fn zip_entries_total(zip: ?*struct_zip_t) c_int;
-pub extern fn zip_entries_delete(zip: ?*struct_zip_t, entries: [*c]const [*c]u8, len: usize) c_int;
-pub extern fn zip_stream_extract(stream: [*c]const u8, size: usize, dir: [*c]const u8, on_extract: ?fn ([*c]const u8, ?*anyopaque) callconv(.C) c_int, arg: ?*anyopaque) c_int;
-pub extern fn zip_stream_open(stream: [*c]const u8, size: usize, level: c_int, mode: u8) ?*struct_zip_t;
-pub extern fn zip_stream_copy(zip: ?*struct_zip_t, buf: [*c]?*anyopaque, bufsize: [*c]usize) isize;
-pub extern fn zip_stream_close(zip: ?*struct_zip_t) void;
-pub extern fn zip_create(zipname: [*c]const u8, filenames: [*c][*c]const u8, len: usize) c_int;
-pub extern fn zip_extract(zipname: [*c]const u8, dir: [*c]const u8, on_extract_entry: ?fn ([*c]const u8, ?*anyopaque) callconv(.C) c_int, arg: ?*anyopaque) c_int;
-pub const ZIP_DEFAULT_COMPRESSION_LEVEL = @as(c_int, 6);
-pub const ZIP_ENOINIT = -@as(c_int, 1);
-pub const ZIP_EINVENTNAME = -@as(c_int, 2);
-pub const ZIP_ENOENT = -@as(c_int, 3);
-pub const ZIP_EINVMODE = -@as(c_int, 4);
-pub const ZIP_EINVLVL = -@as(c_int, 5);
-pub const ZIP_ENOSUP64 = -@as(c_int, 6);
-pub const ZIP_EMEMSET = -@as(c_int, 7);
-pub const ZIP_EWRTENT = -@as(c_int, 8);
-pub const ZIP_ETDEFLINIT = -@as(c_int, 9);
-pub const ZIP_EINVIDX = -@as(c_int, 10);
-pub const ZIP_ENOHDR = -@as(c_int, 11);
-pub const ZIP_ETDEFLBUF = -@as(c_int, 12);
-pub const ZIP_ECRTHDR = -@as(c_int, 13);
-pub const ZIP_EWRTHDR = -@as(c_int, 14);
-pub const ZIP_EWRTDIR = -@as(c_int, 15);
-pub const ZIP_EOPNFILE = -@as(c_int, 16);
-pub const ZIP_EINVENTTYPE = -@as(c_int, 17);
-pub const ZIP_EMEMNOALLOC = -@as(c_int, 18);
-pub const ZIP_ENOFILE = -@as(c_int, 19);
-pub const ZIP_ENOPERM = -@as(c_int, 20);
-pub const ZIP_EOOMEM = -@as(c_int, 21);
-pub const ZIP_EINVZIPNAME = -@as(c_int, 22);
-pub const ZIP_EMKDIR = -@as(c_int, 23);
-pub const ZIP_ESYMLINK = -@as(c_int, 24);
-pub const ZIP_ECLSZIP = -@as(c_int, 25);
-pub const ZIP_ECAPSIZE = -@as(c_int, 26);
-pub const ZIP_EFSEEK = -@as(c_int, 27);
-pub const ZIP_EFREAD = -@as(c_int, 28);
-pub const ZIP_EFWRITE = -@as(c_int, 29);
-pub const zip_t = struct_zip_t;
-
-/// Frees a buffer returned by `zip_stream_copy` (C `calloc`). Only used on wasm.
-pub extern fn fizzy_zip_free(ptr: ?*anyopaque) void;
diff --git a/src/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..77930bb8 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 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 text_mod = @import("text");
+const example_mod = @import("example");
+const PluginLoader = if (builtin.target.cpu.arch == .wasm32)
+ @import("PluginLoader_stub.zig")
+else
+ @import("PluginLoader.zig");
+const InstalledPlugins = @import("InstalledPlugins.zig");
+const PluginStore = @import("PluginStore.zig");
+
+pub const Workspace = workbench_mod.Workspace;
pub const Explorer = @import("explorer/Explorer.zig");
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,
+/// Plugin registry + service locator exposed to plugins
+host: Host,
+
+/// 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-disabled plugin ids (store "disable"), each app-allocator-owned. This is the
+/// authoritative runtime set; `settings.disabled_plugins` is pointed at `.items` for
+/// persistence (see `seedDisabledPlugins` / `setDisabledPersisted`). Freed in `deinit`.
+disabled_plugin_ids: std.ArrayListUnmanaged([]const u8) = .empty,
+
+/// User plugins that failed to load this session, so the UI can tell the author what
+/// went wrong instead of failing silently into the log. Populated by `loadUserPlugins`;
+/// strings are owned here and freed in `deinit`.
+failed_user_plugins: std.ArrayListUnmanaged(FailedPlugin) = .empty,
+/// One-shot guard so the startup "plugin load failures" dialog is raised only once.
+plugin_failures_dialog_shown: bool = false,
settings: Settings = undefined,
recents: Recents = undefined,
@@ -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,
@@ -254,20 +257,23 @@ pub fn init(
.infobar = try .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"),
- .source = try fizzy.image.fromImageFileBytes("fizzy.png", assets.files.@"fizzy.png", .ptr),
- },
- .tools = try .init(app.allocator),
.themes = .empty,
+ .host = .init(app.allocator),
+ .workbench = .init(app.allocator),
};
- editor.settings = try Settings.load(app.allocator, try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" }));
+ try editor.workbench.registerBuiltins();
- // 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();
+ {
+ 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);
+ }
+
+ // Save-queue worker is owned by the pixel-art plugin (`initPlugin` in `postInit`).
{ // Setup themes
var fizzy_dark = dvui.themeGet();
@@ -428,26 +434,1064 @@ 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;
- };
-
- 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;
+ try editor.workbench.initDefaultWorkspace();
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 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 loadTextFromDylibEnabled() bool {
+ if (comptime builtin.target.cpu.arch == .wasm32) return false;
+ if (comptime build_opts.static_text) return false;
+ if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_TEXT")) |v| {
+ defer fizzy.app.allocator.free(v);
+ return v.len == 0 or v[0] == '0';
+ } else |_| {}
+ 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 text plugin (dylib or static). Panics if missing after `postInit`.
+pub fn textPlugin(editor: *Editor) *sdk.Plugin {
+ return editor.host.pluginById("text") orelse @panic("text plugin not registered");
+}
+
+/// Push host dvui state into every loaded plugin dylib image.
+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-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/text.{ext}` and register via dylib entry.
+pub fn loadTextDylib(editor: *Editor, exe_dir: []const u8) !void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ const path = try PluginLoader.builtinPluginPath(fizzy.app.allocator, exe_dir, "text");
+ errdefer fizzy.app.allocator.free(path);
+ const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "text", .{
+ .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 ("workbench", "text") are never overridden; any
+/// user directory whose name collides with an already-registered plugin is skipped.
+///
+/// On success each loaded lib is appended to `loaded_plugin_libs` and the dvui context
+/// + render bridge are synced once at the end. On wasm this is a no-op.
+///
+/// The user plugin directory does not need to exist; a missing directory is silently ignored.
+/// A user plugin that failed to load, retained so the UI can surface it. `id` and `reason`
+/// are heap-owned (app allocator) and freed in `deinit`.
+pub const FailedPlugin = struct {
+ id: []const u8,
+ reason: []const u8,
+ /// Optional version / SDK detail when the dylib could be opened for probing.
+ detail: ?[]const u8 = null,
+};
+
+/// Record a failed user-plugin load so the UI can surface it. `id` and `reason` are copied
+/// (the caller keeps ownership of its arguments). Best-effort: on OOM the failure is dropped
+/// after being logged at the call site.
+fn recordPluginFailure(editor: *Editor, id: []const u8, reason: []const u8, detail: ?[]const u8) void {
+ const id_owned = fizzy.app.allocator.dupe(u8, id) catch return;
+ const reason_owned = fizzy.app.allocator.dupe(u8, reason) catch {
+ fizzy.app.allocator.free(id_owned);
+ return;
+ };
+ const detail_owned: ?[]const u8 = if (detail) |d| fizzy.app.allocator.dupe(u8, d) catch null else null;
+ if (detail_owned == null and detail != null) {
+ fizzy.app.allocator.free(id_owned);
+ fizzy.app.allocator.free(reason_owned);
+ return;
+ }
+ editor.failed_user_plugins.append(fizzy.app.allocator, .{
+ .id = id_owned,
+ .reason = reason_owned,
+ .detail = detail_owned,
+ }) catch {
+ fizzy.app.allocator.free(id_owned);
+ fizzy.app.allocator.free(reason_owned);
+ if (detail_owned) |d| fizzy.app.allocator.free(d);
+ };
+}
+
+fn formatPluginProbeDetail(allocator: std.mem.Allocator, info: PluginLoader.PluginVersionInfo) ![]const u8 {
+ return std.fmt.allocPrint(allocator, "plugin {d}.{d}.{d}, min SDK {d}.{d}.{d}", .{
+ info.plugin_version.major,
+ info.plugin_version.minor,
+ info.plugin_version.patch,
+ info.min_sdk_version.major,
+ info.min_sdk_version.minor,
+ info.min_sdk_version.patch,
+ });
+}
+
+/// Human-readable, actionable explanation for a `PluginLoader.LoadError`.
+fn pluginLoadFailureReason(err: PluginLoader.LoadError) []const u8 {
+ return switch (err) {
+ error.AbiMismatch => "built against an incompatible Fizzy SDK — rebuild the plugin against this Fizzy build",
+ error.SdkVersionMismatch => "requires a newer Fizzy SDK — update Fizzy or install a matching plugin build",
+ error.PluginIdMismatch => "plugin id in the dylib does not match its filename — rename the file or fix manifest.id",
+ error.DylibOpenFailed => "the plugin library could not be opened (missing file, wrong architecture, or unresolved symbols)",
+ error.RegisterRejected => "the plugin's register() was rejected (often a duplicate plugin id — a built-in or another plugin already claims it)",
+ error.AbiFingerprintSymbolMissing,
+ error.RegisterSymbolMissing,
+ error.SetGlobalsSymbolMissing,
+ error.SetDvuiContextSymbolMissing,
+ error.SetRenderBridgeSymbolMissing,
+ error.SdkVersionSymbolMissing,
+ => "the plugin is missing required entry symbols — rebuild it from a current root.zig template",
+ };
+}
+
+pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+
+ const plugins_dir = std.fs.path.join(fizzy.app.allocator, &.{ config_folder, "plugins" }) catch return;
+ defer fizzy.app.allocator.free(plugins_dir);
+
+ var dir = std.Io.Dir.cwd().openDir(dvui.io, plugins_dir, .{ .iterate = true }) catch return;
+ defer dir.close(dvui.io);
+
+ const ext_suffix: []const u8 = switch (builtin.os.tag) {
+ .windows => ".dll",
+ .macos => ".dylib",
+ else => ".so",
+ };
+ var loaded_any = false;
+
+ var iter = dir.iterate();
+ while (iter.next(dvui.io) catch null) |entry| {
+ if (entry.kind != .file) continue;
+ if (!std.mem.endsWith(u8, entry.name, ext_suffix)) continue;
+
+ const dot = std.mem.lastIndexOf(u8, entry.name, ".") orelse continue;
+ const plugin_id = entry.name[0..dot];
+ if (plugin_id.len == 0) continue;
+
+ // User-disabled plugins (store "disable") stay on disk but are not loaded.
+ if (editor.isPluginDisabled(plugin_id)) {
+ dvui.log.info("user plugin '{s}' is disabled; skipped", .{plugin_id});
+ continue;
+ }
+
+ if (editor.host.pluginById(plugin_id) != null) {
+ dvui.log.err("user plugin '{s}': id already registered by a built-in; skipped", .{plugin_id});
+ editor.recordPluginFailure(plugin_id, "id already registered by a built-in plugin", null);
+ continue;
+ }
+
+ const path = std.fs.path.join(fizzy.app.allocator, &.{ plugins_dir, entry.name }) catch continue;
+
+ const loaded = PluginLoader.loadAndRegister(&editor.host, path, plugin_id, .{
+ .gpa = &fizzy.app.allocator,
+ .arg_b = @ptrCast(&editor.host),
+ .arg_c = null,
+ }) catch |err| {
+ const reason = pluginLoadFailureReason(err);
+ const probe = PluginLoader.probeVersionInfo(path);
+ const detail_owned: ?[]const u8 = if (probe) |info|
+ formatPluginProbeDetail(fizzy.app.allocator, info) catch null
+ else
+ null;
+ dvui.log.err("user plugin '{s}' ({s}): load failed: {s} — {s}", .{ plugin_id, path, @errorName(err), reason });
+ editor.recordPluginFailure(plugin_id, reason, detail_owned);
+ fizzy.app.allocator.free(path);
+ continue;
+ };
+
+ appendLoadedPluginLib(editor, loaded) catch {
+ dvui.log.err("user plugin '{s}': out of memory storing LoadedLib", .{plugin_id});
+ editor.recordPluginFailure(plugin_id, "ran out of memory while loading", null);
+ continue;
+ };
+ dvui.log.info("user plugin '{s}' loaded from {s}", .{ plugin_id, path });
+ loaded_any = true;
+ }
+
+ if (loaded_any) {
+ syncLoadedPluginDvuiContexts(editor);
+ syncLoadedPluginRenderBridge(editor);
+ }
+}
+
+fn unloadPluginLibs(editor: *Editor) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ for (editor.loaded_plugin_libs.items) |*entry| {
+ entry.lib.close();
+ fizzy.app.allocator.free(entry.plugin_id);
+ fizzy.app.allocator.free(entry.path);
+ }
+ editor.loaded_plugin_libs.deinit(fizzy.app.allocator);
+
+ for (editor.failed_user_plugins.items) |f| {
+ fizzy.app.allocator.free(f.id);
+ fizzy.app.allocator.free(f.reason);
+ if (f.detail) |d| fizzy.app.allocator.free(d);
+ }
+ editor.failed_user_plugins.deinit(fizzy.app.allocator);
+
+ for (editor.disabled_plugin_ids.items) |id| fizzy.app.allocator.free(id);
+ editor.disabled_plugin_ids.deinit(fizzy.app.allocator);
+}
+
+// ---- runtime plugin lifecycle (store: install / enable / disable / update) ---------
+//
+// Only dylib-loaded *user* plugins are managed here. Bundled built-ins (pixi/workbench/
+// code) ship in the app and are never unloaded, even though they also appear in
+// `loaded_plugin_libs` when loaded from their bundled dylibs.
+
+/// Built-in plugin ids that ship in the app and must never be store-managed.
+fn isBundledPluginId(id: []const u8) bool {
+ return std.mem.eql(u8, id, "workbench") or
+ std.mem.eql(u8, id, "text");
+}
+
+/// Static built-ins that are compiled in and registered unconditionally in `postInit` (so they
+/// never appear in `loaded_plugin_libs`). "Disabling" one hides its sidebar view rather than
+/// unloading a dylib — there is none. They are not in `isBundledPluginId` because, unlike the
+/// shell/workbench/text core, the user *can* toggle their visibility from the store.
+pub fn isStaticHidePlugin(id: []const u8) bool {
+ return std.mem.eql(u8, id, "example");
+}
+
+/// Show or hide every sidebar view owned by `id`. Used to toggle static-plugin visibility
+/// without load/unload.
+fn setPluginViewsHidden(editor: *Editor, id: []const u8, hidden: bool) void {
+ for (editor.host.sidebar_views.items) |*view| {
+ const owner = view.owner orelse continue;
+ if (std.mem.eql(u8, owner.id, id)) view.hidden = hidden;
+ }
+}
+
+/// True when `id` names a runtime-loaded user plugin that may be unloaded/disabled.
+pub fn isUnloadablePlugin(editor: *Editor, id: []const u8) bool {
+ if (isBundledPluginId(id)) return false;
+ for (editor.loaded_plugin_libs.items) |loaded| {
+ if (std.mem.eql(u8, loaded.plugin_id, id)) return true;
+ }
+ return false;
+}
+
+pub fn isPluginDisabled(editor: *Editor, id: []const u8) bool {
+ for (editor.disabled_plugin_ids.items) |d| {
+ if (std.mem.eql(u8, d, id)) return true;
+ }
+ return false;
+}
+
+/// True when `id` looks like a real plugin id (ASCII identifier), not corrupted settings data.
+fn isValidPluginId(id: []const u8) bool {
+ if (id.len == 0 or id.len > 64) return false;
+ if (!std.unicode.utf8ValidateSlice(id)) return false;
+ for (id) |c| {
+ if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') return false;
+ }
+ return true;
+}
+
+/// Seed the runtime disabled set from the persisted `settings.disabled_plugins`, then
+/// re-point `settings.disabled_plugins` at the owned list so future saves serialize it.
+/// Call once after settings load, before `loadUserPlugins`.
+fn seedDisabledPlugins(editor: *Editor) void {
+ var dropped_invalid = false;
+ for (editor.settings.disabled_plugins) |id| {
+ if (!isValidPluginId(id)) {
+ dropped_invalid = true;
+ dvui.log.warn("settings: dropping invalid disabled_plugins entry", .{});
+ continue;
+ }
+ const dup = fizzy.app.allocator.dupe(u8, id) catch continue;
+ editor.disabled_plugin_ids.append(fizzy.app.allocator, dup) catch {
+ fizzy.app.allocator.free(dup);
+ };
+ }
+ editor.settings.disabled_plugins = editor.disabled_plugin_ids.items;
+ if (dropped_invalid) editor.host.markSettingsDirty();
+}
+
+/// Add or remove `id` from the persisted disabled set and write it to disk **immediately**.
+/// Re-points `settings.disabled_plugins` because the backing list may have reallocated.
+///
+/// Enable/disable is a discrete, infrequent, important action, so it is flushed synchronously
+/// rather than through the debounced autosave: the debounce + idle frames + a shutdown that may
+/// never run `deinit` (fizzy ignores SIGTERM) previously let a toggle be lost if the app went idle
+/// or quit within the autosave window. On wasm (no filesystem) we fall back to the in-memory dirty
+/// flag.
+fn setDisabledPersisted(editor: *Editor, id: []const u8, disabled: bool) !void {
+ if (disabled and !isValidPluginId(id)) return error.InvalidPluginId;
+ const present_at: ?usize = blk: {
+ for (editor.disabled_plugin_ids.items, 0..) |d, i| {
+ if (std.mem.eql(u8, d, id)) break :blk i;
+ }
+ break :blk null;
+ };
+ if (disabled) {
+ if (present_at == null) {
+ const dup = try fizzy.app.allocator.dupe(u8, id);
+ errdefer fizzy.app.allocator.free(dup);
+ try editor.disabled_plugin_ids.append(fizzy.app.allocator, dup);
+ }
+ } else if (present_at) |i| {
+ const owned = editor.disabled_plugin_ids.orderedRemove(i);
+ fizzy.app.allocator.free(owned);
+ }
+ editor.settings.disabled_plugins = editor.disabled_plugin_ids.items;
+ if (comptime builtin.target.cpu.arch == .wasm32) {
+ editor.host.markSettingsDirty();
+ } else {
+ // Durable, synchronous write now; fall back to the autosave if the write fails.
+ editor.saveSettingsRaw() catch |err| {
+ dvui.log.err("Failed to persist disabled plugins immediately ({s}); deferring to autosave", .{@errorName(err)});
+ editor.host.markSettingsDirty();
+ };
+ }
+}
+
+/// Rebuild the whole window keybind map from scratch: shell binds + every *currently
+/// registered* plugin's `contributeKeybinds`. Used after a plugin is unregistered so its
+/// binds (whose key strings live in the soon-to-be-`dlclose`d image) are dropped.
+fn rebuildKeybinds(editor: *Editor) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ const window = dvui.currentWindow();
+ window.keybinds.clearRetainingCapacity();
+ Keybinds.register() catch |err| dvui.log.err("keybind rebuild (shell) failed: {s}", .{@errorName(err)});
+ for (editor.host.plugins.items) |plugin| {
+ plugin.contributeKeybinds(window) catch |err|
+ dvui.log.err("keybind rebuild ('{s}') failed: {s}", .{ plugin.id, @errorName(err) });
+ }
+}
+
+/// True if `plugin` owns any currently-dirty open document.
+fn pluginHasDirtyDocs(editor: *Editor, plugin: *sdk.Plugin) bool {
+ for (editor.open_files.values()) |doc| {
+ if (doc.owner == plugin and doc.owner.isDirty(doc)) return true;
+ }
+ return false;
+}
+
+pub const UnloadError = error{ NotUnloadable, DirtyDocuments };
+
+/// Load `{config}/plugins/{id}.{ext}` live and register it. Reuses the same loader +
+/// dvui/render-bridge sync path as `loadUserPlugins`. Caller ensures `id` is not already
+/// registered. On success the lib is appended to `loaded_plugin_libs`.
+pub fn loadUserPluginById(editor: *Editor, id: []const u8) !void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable;
+ const file_name = try PluginLoader.pluginFilename(id, fizzy.app.allocator);
+ defer fizzy.app.allocator.free(file_name);
+ const path = try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "plugins", file_name });
+ errdefer fizzy.app.allocator.free(path);
+
+ const loaded = try PluginLoader.loadAndRegister(&editor.host, path, id, .{
+ .gpa = &fizzy.app.allocator,
+ .arg_b = @ptrCast(&editor.host),
+ .arg_c = null,
+ });
+ try editor.appendLoadedPluginLib(loaded);
+ syncLoadedPluginDvuiContexts(editor);
+ syncLoadedPluginRenderBridge(editor);
+ rebuildKeybinds(editor);
+}
+
+/// Install (file already downloaded to the plugins dir by the store backend) + load live.
+/// Clears any disabled flag so the plugin stays enabled across restarts.
+pub fn installAndLoadPlugin(editor: *Editor, id: []const u8) !void {
+ if (isBundledPluginId(id)) return error.NotUnloadable;
+ if (editor.host.pluginById(id) != null) return; // already loaded
+ try editor.setDisabledPersisted(id, false);
+ try editor.loadUserPluginById(id);
+}
+
+/// True if `plugin` owns any document with an async save still in flight.
+fn pluginHasSavingDocs(editor: *Editor, plugin: *sdk.Plugin) bool {
+ for (editor.open_files.values()) |doc| {
+ if (doc.owner == plugin and doc.owner.isDocumentSaving(doc)) return true;
+ }
+ return false;
+}
+
+/// Spin until none of `plugin`'s open documents report `isDocumentSaving`. Called from
+/// `unloadPlugin` on the GUI thread while the save-queue worker runs concurrently.
+fn waitForPluginSaves(editor: *Editor, plugin: *sdk.Plugin) void {
+ while (pluginHasSavingDocs(editor, plugin)) {
+ std.Thread.yield() catch {};
+ }
+}
+
+/// Cancel and await every in-flight `FileLoadJob` owned by `plugin`, then drop its staging
+/// buffer — so `unloadPlugin` can never `dlclose` the image while a worker thread is still
+/// inside `owner.loadDocument` / `deinitDocumentBuffer` (use-after-free). Owner-scoped so a
+/// load belonging to an unrelated plugin survives. Mirrors `waitForPluginSaves`; runs on the
+/// GUI thread before any teardown.
+fn cancelPluginLoadingJobs(editor: *Editor, plugin: *sdk.Plugin) void {
+ if (editor.loading_jobs.count() == 0) return;
+
+ // Signal cancellation first so a worker that has not yet entered (or has just exited) the
+ // loader bails at its next checkpoint instead of re-entering the soon-unmapped image.
+ {
+ var it = editor.loading_jobs.valueIterator();
+ while (it.next()) |job_ptr| {
+ if (job_ptr.*.owner == plugin) job_ptr.*.cancelled.store(true, .monotonic);
+ }
+ }
+
+ // Collect this plugin's jobs up front — cleanup mutates `loading_jobs`, so we can't hold
+ // the map iterator across removal.
+ var owned: std.ArrayListUnmanaged(*FileLoadJob) = .empty;
+ defer owned.deinit(fizzy.app.allocator);
+ {
+ var it = editor.loading_jobs.valueIterator();
+ while (it.next()) |job_ptr| {
+ if (job_ptr.*.owner == plugin) owned.append(fizzy.app.allocator, job_ptr.*) catch {};
+ }
+ }
+
+ for (owned.items) |job| {
+ // Block until the worker has fully left the dylib before we free through the owner.
+ while (!job.done.load(.acquire)) std.Thread.yield() catch {};
+ _ = editor.loading_jobs.remove(job.path);
+ // Drop the partial open without inserting it into `open_files`. `ready`/`failed`
+ // need exactly one `deinitDocumentBuffer`; a `cancelled` job was either freed by the
+ // worker (late cancel) or never constructed (early cancel), so skip it to avoid a
+ // double-free / deinit-on-uninitialized buffer.
+ switch (job.currentPhase()) {
+ .ready, .failed => job.owner.deinitDocumentBuffer(job.doc_buf.ptr),
+ else => {},
+ }
+ job.destroy();
+ }
+}
+
+/// Unload a runtime user plugin live: close its documents, tear down its contributions,
+/// deinit its state, then `dlclose`. With `force == false`, aborts with `DirtyDocuments`
+/// if any owned document is dirty (the caller decides whether to prompt/save first).
+pub fn unloadPlugin(editor: *Editor, id: []const u8, force: bool) UnloadError!void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable;
+ if (!editor.isUnloadablePlugin(id)) return error.NotUnloadable;
+ const plugin = editor.host.pluginById(id) orelse return error.NotUnloadable;
+
+ const lib_index: usize = blk: {
+ for (editor.loaded_plugin_libs.items, 0..) |loaded, i| {
+ if (std.mem.eql(u8, loaded.plugin_id, id)) break :blk i;
+ }
+ return error.NotUnloadable;
+ };
+
+ if (!force and editor.pluginHasDirtyDocs(plugin)) return error.DirtyDocuments;
+
+ // Let in-flight async saves finish while the owning `File` records still exist.
+ editor.waitForPluginSaves(plugin);
+
+ // Cancel + await any in-flight file loads owned by this plugin so no worker calls into
+ // the dylib after we `dlclose` it below.
+ editor.cancelPluginLoadingJobs(plugin);
+
+ // Close every document this plugin owns. Collect ids first — closing mutates
+ // `open_files` underneath us.
+ var owned: std.ArrayListUnmanaged(u64) = .empty;
+ defer owned.deinit(fizzy.app.allocator);
+ for (editor.open_files.values()) |doc| {
+ if (doc.owner == plugin) owned.append(fizzy.app.allocator, doc.id) catch {};
+ }
+ for (owned.items) |doc_id| editor.rawCloseFileID(doc_id) catch |err|
+ dvui.log.err("unloadPlugin '{s}': closing doc {d} failed: {s}", .{ id, doc_id, @errorName(err) });
+
+ // Drop empty workspace panes (and plugin canvas chrome) before plugin `deinit`.
+ editor.rebuildWorkspaces() catch |err|
+ dvui.log.err("unloadPlugin '{s}': rebuildWorkspaces failed: {s}", .{ id, @errorName(err) });
+
+ // Remove all contributions + services + active-id references (before dlclose), then
+ // run the plugin's own teardown.
+ editor.host.unregisterPlugin(plugin);
+ plugin.deinit();
+
+ // Drop the unloaded plugin's keybinds by rebuilding from the survivors.
+ rebuildKeybinds(editor);
+
+ // Unmap the image and free our bookkeeping for it.
+ var loaded = editor.loaded_plugin_libs.orderedRemove(lib_index);
+ loaded.lib.close();
+ fizzy.app.allocator.free(loaded.plugin_id);
+ fizzy.app.allocator.free(loaded.path);
+}
+
+/// Enable or disable a plugin, persisting the choice and applying it live: disabling
+/// unloads now; enabling loads the installed dylib now.
+pub fn setPluginEnabled(editor: *Editor, id: []const u8, enabled: bool, force: bool) !void {
+ if (isBundledPluginId(id)) return error.NotUnloadable;
+
+ // Static built-ins (e.g. `example`) stay registered for the whole session; toggle them by
+ // persisting the disabled flag + hiding/showing their sidebar views instead of load/unload.
+ if (isStaticHidePlugin(id)) {
+ try editor.setDisabledPersisted(id, !enabled);
+ editor.setPluginViewsHidden(id, !enabled);
+ return;
+ }
+
+ if (enabled) {
+ try editor.setDisabledPersisted(id, false);
+ if (editor.host.pluginById(id) == null) try editor.loadUserPluginById(id);
+ } else {
+ // Persist before unload: `id` may point at static memory inside the plugin image.
+ try editor.setDisabledPersisted(id, true);
+ try editor.unloadPlugin(id, force);
+ }
+}
+
+/// Replace an installed plugin with a freshly downloaded build (in the plugins dir already)
+/// by unloading then reloading. `force` controls dirty-document handling on the unload.
+pub fn updatePlugin(editor: *Editor, id: []const u8, force: bool) !void {
+ if (isBundledPluginId(id)) return error.NotUnloadable;
+ try editor.unloadPlugin(id, force);
+ try editor.loadUserPluginById(id);
+}
+
+/// Fully remove a user plugin: unload it if loaded, clear any disabled flag, and delete its
+/// dylib from `{config}/plugins/`. `force` controls dirty-document handling on the unload.
+pub fn uninstallPlugin(editor: *Editor, id: []const u8, force: bool) !void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable;
+ if (isBundledPluginId(id)) return error.NotUnloadable;
+ // Static built-ins have no dylib on disk to delete — they can only be hidden, not removed.
+ if (isStaticHidePlugin(id)) return error.NotUnloadable;
+ if (editor.host.pluginById(id) != null) try editor.unloadPlugin(id, force);
+ // Drop any persisted disabled flag — the plugin no longer exists to be disabled.
+ try editor.setDisabledPersisted(id, false);
+
+ const file_name = try PluginLoader.pluginFilename(id, fizzy.app.allocator);
+ defer fizzy.app.allocator.free(file_name);
+ const path = try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "plugins", file_name });
+ defer fizzy.app.allocator.free(path);
+ std.Io.Dir.deleteFileAbsolute(dvui.io, path) catch |err|
+ dvui.log.warn("uninstallPlugin '{s}': could not delete {s}: {s}", .{ id, path, @errorName(err) });
+}
+
+pub fn postInit(editor: *Editor) !void {
+ sdk.installRuntime(&fizzy.app.allocator, &editor.host, null);
+
+ // Install the shell's read/utility surface so plugins reach shared shell state
+ // (per-frame arena, project folder, content opacity, settings dirty-mark) through
+ // the Host instead of importing the concrete Editor.
+ 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 (loadTextFromDylibEnabled()) {
+ editor.loadTextDylib(fizzy.app.root_path) catch |err| {
+ dvui.log.warn("text dylib load failed ({s}); falling back to static plugin", .{@errorName(err)});
+ try text_mod.plugin.register(&editor.host);
+ };
+ } else {
+ try text_mod.plugin.register(&editor.host);
+ }
+ // Example plugin: the minimal built-in / template. Registered statically here; it also
+ // builds standalone as a dylib (`cd src/plugins/example && zig build`), so it exercises
+ // both link modes. See docs/PLUGINS.md.
+ try example_mod.plugin.register(&editor.host);
+
+ // Seed the runtime disabled set from settings (and re-point the persisted slice at
+ // it) before scanning, so disabled plugins are skipped at startup.
+ editor.seedDisabledPlugins();
+
+ // Static built-ins (example) stay registered even when disabled; honor a persisted or
+ // first-run-default disabled state by hiding their sidebar views.
+ if (editor.isPluginDisabled("example")) editor.setPluginViewsHidden("example", true);
+
+ // User-installed plugins from `/plugins/{id}.{dylib,so,dll}`.
+ editor.loadUserPlugins(editor.config_folder);
+
+ try InstalledPlugins.register(&editor.host);
+
+ for (editor.host.plugins.items) |p| try p.initPlugin();
+
+ // Shell built-in: Plugin store (owner = null; not a plugin). Registered just before
+ // Settings so its icon sits directly above the cog in the sidebar rail.
+ try PluginStore.register(&editor.host);
+
+ // Shell built-in: Settings (owner = null; not a plugin).
+ try editor.host.registerSidebarView(.{
+ .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 = "shell.menu.edit", .draw = Menu.drawEditMenu });
+ try editor.host.registerMenu(.{ .id = "shell.menu.view", .draw = Menu.drawViewMenu });
+ try editor.host.registerMenu(.{ .id = "shell.menu.help", .draw = Menu.drawHelpMenu });
+
+ // 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,
+ editor.host.pluginById("workbench"),
+ );
+ }
+}
+
+/// 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,
+ .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 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 +1596,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 +1614,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 +1632,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 +1645,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 +1657,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 +1671,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| {
@@ -665,10 +1708,15 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
// Drain any "Save and Close" requests whose async save has settled.
editor.tickPendingSaveCloses();
+
+ // Complete any finished plugin downloads by loading them live. Done here, before the
+ // Host-registry iterations below, so a newly-registered plugin never mutates a list
+ // mid-iteration.
+ PluginStore.tick();
+
var needs_save_status_anim_tick = false;
- for (editor.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 +1739,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 +1754,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 +1767,6 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
// workspace/file iteration so that a just-loaded file is visible to the rest of this frame.
editor.processLoadingJobs();
if (comptime builtin.target.cpu.arch == .wasm32) fizzy.backend.pollWebFileIo(editor);
- editor.processPackJob();
// Build workspaces AFTER reaping load jobs so a freshly-loaded file with a new grouping
// (e.g. "Open to the side") gets its workspace created on the same frame it lands.
@@ -730,30 +1778,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 +1972,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 +2120,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 +2150,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 +2206,17 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
// out and removes itself when the timer expires.
editor.drawSaveToasts();
+ // First frame after startup: if any user plugin failed to load, tell the user once
+ // (otherwise the only trace is a log line they'll never see).
+ if (!editor.plugin_failures_dialog_shown and editor.failed_user_plugins.items.len > 0) {
+ editor.plugin_failures_dialog_shown = true;
+ Dialogs.PluginLoadFailures.request();
+ }
+
editor.saveSettingsGuarded() catch |err| {
dvui.log.err("Failed to autosave settings ({s})", .{@errorName(err)});
};
- if (comptime builtin.target.cpu.arch == .wasm32) {
- runWasmPackWorkers(editor);
- }
-
_ = editor.arena.reset(.retain_capacity);
if (editor.pending_app_close) {
@@ -1260,7 +2274,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 +2297,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 +2362,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 +2374,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 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;
- }
- }
+pub fn rebuildWorkspaces(editor: *Editor) !void {
+ try editor.workbench.rebuildWorkspaces();
+}
- return .ok;
+pub fn drawWorkspaces(editor: *Editor, index: usize) !dvui.App.Result {
+ 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 +2408,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 +2438,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 +2457,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 +2486,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 +2517,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 +2530,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 +2569,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 +2581,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 +2591,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 +2604,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 +2638,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 +2693,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 +2730,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 +2813,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 +2915,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 +2958,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 +3057,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 +3075,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 +3099,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 +3136,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 +3185,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 +3203,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 +3213,62 @@ 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 {
+ // Tear workspaces down first: `Workspace.deinit` calls back into the owning plugin
+ // (e.g. `removeCanvasPane`), so it must run while plugin state is still alive — i.e. before
+ // the plugin `deinit` loop below frees it.
+ editor.workbench.deinitWorkspaces();
+
// Drain & join the save-queue worker before tearing anything else down. Any
// queued jobs need to finish writing or be dropped before File data is freed.
- 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 +3287,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 +3298,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 +3313,21 @@ 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.tools.deinit(fizzy.app.allocator);
+ PluginStore.deinit();
+ editor.unloadPluginLibs();
+ editor.host.deinit();
+ editor.workbench.deinit();
+
+ // Pixel-art state is owned by the pixi plugin now: its `pluginDeinit` (run in the plugin
+ // loop above) persists the project and frees its own state + packer.
editor.ignore.deinit(fizzy.app.allocator);
+
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..252631f8
--- /dev/null
+++ b/src/editor/InstalledPlugins.zig
@@ -0,0 +1,25 @@
+//! Settings → Plugins: a pointer to the dedicated **Plugins** sidebar tab, which now owns the
+//! full inventory + install/enable/disable/update controls (see `PluginStore.zig`). Kept as a
+//! thin breadcrumb so users who look under Settings are directed to the right place.
+const std = @import("std");
+const dvui = @import("dvui");
+const sdk = @import("sdk");
+
+pub fn register(host: *sdk.Host) !void {
+ try host.registerSettingsSection(.{
+ .id = "shell.settings.plugins",
+ .title = "Plugins",
+ .draw = drawPlugins,
+ });
+}
+
+fn drawPlugins(_: ?*anyopaque) anyerror!void {
+ var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal });
+ defer vbox.deinit();
+ dvui.labelNoFmt(
+ @src(),
+ "Browse, install, enable/disable, and update plugins in the Plugins tab (the bag icon in the sidebar, above Settings).",
+ .{},
+ .{ .margin = .{ .y = 4 } },
+ );
+}
diff --git a/src/editor/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..a0037481 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("shell.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/PackJob.zig b/src/editor/PackJob.zig
deleted file mode 100644
index d5202743..00000000
--- a/src/editor/PackJob.zig
+++ /dev/null
@@ -1,737 +0,0 @@
-//! Background project-pack job. Owns a worker thread that runs the full append-pack-blit pipeline
-//! off the main thread so packing large projects doesn't stall the editor.
-//!
-//! Inputs are gathered on the main thread: open files are snapshotted into thread-isolated
-//! `PackFile` values (deep copies of layer pixels + sprite/animation metadata); unopened files
-//! are passed as paths and the worker loads them via `Internal.File.fromPath`. Either way the
-//! worker only ever touches its own `PackFile` values plus the app allocator.
-//!
-//! The worker produces a finished `Internal.Atlas` (RGBA pixels + sprite/animation data). The
-//! main thread swaps it into `fizzy.packer.atlas` via `Editor.processPackJob` once `done` is
-//! published.
-//!
-//! Ownership / threading model:
-//! - `inputs` is owned by the job; each input owns its own buffers. Freed in `destroy()`.
-//! - `result_atlas` is written by the worker, read by the main thread only after
-//! `done.load(.acquire)`. On consume the main thread takes ownership of its allocations.
-//! - `phase` / `cancelled` are atomic; either side may read or write them.
-
-const std = @import("std");
-const 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 PackJob = @This();
-
-pub const Phase = enum(u8) {
- queued = 0,
- loading = 1,
- appending = 2,
- packing = 3,
- blitting = 4,
- ready = 5,
- failed = 6,
- cancelled = 7,
-};
-
-// ----------------------------------------------------------------------------
-// Thread-safe snapshot of the pack-relevant data for a single file.
-// ----------------------------------------------------------------------------
-
-pub const PackLayer = struct {
- name: []u8,
- visible: bool,
- collapse: bool,
- width: u32,
- height: u32,
- pixels: [][4]u8,
-
- fn deinit(self: *PackLayer, allocator: std.mem.Allocator) void {
- allocator.free(self.name);
- allocator.free(self.pixels);
- }
-};
-
-pub const PackSprite = struct {
- origin: [2]f32,
-};
-
-pub const PackAnimation = struct {
- name: []u8,
- frames: []fizzy.Animation.Frame,
-
- fn deinit(self: *PackAnimation, allocator: std.mem.Allocator) void {
- allocator.free(self.name);
- allocator.free(self.frames);
- }
-};
-
-pub const PackFile = struct {
- columns: u32,
- column_width: u32,
- row_height: u32,
- width: u32,
- height: u32,
- layers: []PackLayer,
- sprites: []PackSprite,
- animations: []PackAnimation,
- allocator: std.mem.Allocator,
-
- /// Deep-copy the pack-relevant fields of an in-memory file. Caller must run on the main
- /// thread (reads the file's pixel buffers, which the editor may otherwise mutate).
- pub fn fromOpenFile(allocator: std.mem.Allocator, file: *const fizzy.Internal.File) !PackFile {
- const src_layers = file.layers.slice();
-
- var layers = try allocator.alloc(PackLayer, src_layers.len);
- var layers_initialized: usize = 0;
- errdefer {
- for (layers[0..layers_initialized]) |*l| l.deinit(allocator);
- allocator.free(layers);
- }
-
- var i: usize = 0;
- while (i < src_layers.len) : (i += 1) {
- const layer = src_layers.get(i);
- const sz = dvui.imageSize(layer.source) catch dvui.Size{ .w = 0, .h = 0 };
- const layer_w: u32 = @intFromFloat(sz.w);
- const layer_h: u32 = @intFromFloat(sz.h);
- const src_pixels = fizzy.image.pixels(layer.source);
-
- const name_copy = try allocator.dupe(u8, layer.name);
- errdefer allocator.free(name_copy);
-
- const pixels_copy = try allocator.dupe([4]u8, src_pixels);
-
- layers[i] = .{
- .name = name_copy,
- .visible = layer.visible,
- .collapse = layer.collapse,
- .width = layer_w,
- .height = layer_h,
- .pixels = pixels_copy,
- };
- layers_initialized = i + 1;
- }
-
- const src_sprites = file.sprites.slice();
- const sprites = try allocator.alloc(PackSprite, src_sprites.len);
- errdefer allocator.free(sprites);
- for (sprites, 0..) |*dst, idx| {
- const s = src_sprites.get(idx);
- dst.* = .{ .origin = s.origin };
- }
-
- const src_anims = file.animations.slice();
- var anims = try allocator.alloc(PackAnimation, src_anims.len);
- var anims_initialized: usize = 0;
- errdefer {
- for (anims[0..anims_initialized]) |*a| a.deinit(allocator);
- allocator.free(anims);
- }
- var a: usize = 0;
- while (a < src_anims.len) : (a += 1) {
- const anim = src_anims.get(a);
- const name_copy = try allocator.dupe(u8, anim.name);
- errdefer allocator.free(name_copy);
- const frames_copy = try allocator.dupe(fizzy.Animation.Frame, anim.frames);
- anims[a] = .{ .name = name_copy, .frames = frames_copy };
- anims_initialized = a + 1;
- }
-
- return .{
- .columns = file.columns,
- .column_width = file.column_width,
- .row_height = file.row_height,
- .width = file.width(),
- .height = file.height(),
- .layers = layers,
- .sprites = sprites,
- .animations = anims,
- .allocator = allocator,
- };
- }
-
- /// Build a snapshot by loading the file from disk. Safe to call from any thread.
- pub fn fromPath(allocator: std.mem.Allocator, path: []const u8) !?PackFile {
- const maybe_file = try fizzy.Internal.File.fromPath(path);
- var file = maybe_file orelse return null;
- defer file.deinit();
- return try PackFile.fromOpenFile(allocator, &file);
- }
-
- pub fn deinit(self: *PackFile) void {
- for (self.layers) |*l| l.deinit(self.allocator);
- self.allocator.free(self.layers);
- self.allocator.free(self.sprites);
- for (self.animations) |*anim| anim.deinit(self.allocator);
- self.allocator.free(self.animations);
- }
-};
-
-pub const PackInput = union(enum) {
- open: PackFile,
- /// Owned path string. Worker loads from disk and converts.
- path: []u8,
-
- pub fn deinit(self: *PackInput, allocator: std.mem.Allocator) void {
- switch (self.*) {
- .open => |*pf| pf.deinit(),
- .path => |p| allocator.free(p),
- }
- }
-};
-
-// ----------------------------------------------------------------------------
-// Job state
-// ----------------------------------------------------------------------------
-
-allocator: std.mem.Allocator,
-
-/// All inputs to pack, in deterministic order. Owned.
-inputs: []PackInput,
-
-/// Captured at create time on the GUI thread; the worker uses it to wake the main loop on
-/// completion via `dvui.refresh(window, ...)` so small projects don't sit completed-but-
-/// unconsumed waiting for an unrelated input event.
-window: *dvui.Window,
-
-started_at_ns: i128,
-
-phase: std.atomic.Value(u8) = .init(@intFromEnum(Phase.queued)),
-
-/// Worker reports `(done_inputs, total_inputs)` while in the `loading` / `appending` phases.
-progress_num: std.atomic.Value(u32) = .init(0),
-progress_den: std.atomic.Value(u32) = .init(0),
-
-cancelled: std.atomic.Value(bool) = .init(false),
-
-/// Worker → main publish flag. `release` on write, `acquire` on read.
-done: std.atomic.Value(bool) = .init(false),
-
-/// Worker output. Read only after `done.load(.acquire)`. The main thread takes ownership of
-/// the inner allocations when it consumes the job; subsequent `destroy()` will leave the
-/// fields alone.
-result_atlas: ?fizzy.Internal.Atlas = null,
-
-/// Set to `true` once the main thread has consumed `result_atlas` (so `destroy()` knows not
-/// to free the moved-out atlas allocations).
-result_consumed: bool = false,
-
-err: ?anyerror = null,
-
-pub fn create(allocator: std.mem.Allocator, inputs: []PackInput) !*PackJob {
- const job = try allocator.create(PackJob);
- job.* = .{
- .allocator = allocator,
- .inputs = inputs,
- .window = dvui.currentWindow(),
- .started_at_ns = perf.nanoTimestamp(),
- };
- return job;
-}
-
-pub fn destroy(job: *PackJob) void {
- const a = job.allocator;
- for (job.inputs) |*input| input.deinit(a);
- a.free(job.inputs);
-
- // Free any unconsumed result. `result_consumed` is set by the main thread when it moves
- // the atlas into `fizzy.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));
- for (atlas.data.animations) |*anim| a.free(anim.name);
- a.free(atlas.data.animations);
- a.free(atlas.data.sprites);
- }
- a.destroy(job);
-}
-
-pub fn elapsedExceeds(job: *const PackJob, threshold_ms: i64) bool {
- const elapsed_ns = perf.nanoTimestamp() - job.started_at_ns;
- return @divTrunc(elapsed_ns, std.time.ns_per_ms) >= threshold_ms;
-}
-
-pub fn currentPhase(job: *const PackJob) Phase {
- const raw = job.phase.load(.acquire);
- return switch (raw) {
- 0 => .queued,
- 1 => .loading,
- 2 => .appending,
- 3 => .packing,
- 4 => .blitting,
- 5 => .ready,
- 6 => .failed,
- 7 => .cancelled,
- else => .queued,
- };
-}
-
-pub fn phaseLabel(phase: Phase) []const u8 {
- return switch (phase) {
- .queued => "Queued",
- .loading => "Loading",
- .appending => "Reducing",
- .packing => "Packing",
- .blitting => "Compositing",
- .ready => "Done",
- .failed => "Failed",
- .cancelled => "Cancelled",
- };
-}
-
-// ----------------------------------------------------------------------------
-// Worker
-// ----------------------------------------------------------------------------
-
-/// Worker entry point. Spawn with `std.Thread.spawn(.{}, PackJob.workerMain, .{job})`.
-pub fn workerMain(job: *PackJob) void {
- defer {
- job.done.store(true, .release);
- dvui.refresh(job.window, @src(), null);
- }
-
- // Worker-local scratch. The final atlas allocations are made through `fizzy.app.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| {
- job.err = e;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- };
- var ws = work;
- defer ws.deinit();
-
- // Resolve and append each input. Both `.open` snapshots and `.path` loads must outlive
- // the append phase, because the sprite list stores borrowed pointers into their pixel
- // buffers and `buildAtlas` blits straight from those pointers. `.open` inputs are owned
- // by `job.inputs` for the job's full lifetime; `.path`-loaded files are parked in
- // `ws.loaded_files` (freed with `ws.deinit`).
- job.phase.store(@intFromEnum(Phase.loading), .release);
- job.progress_den.store(@intCast(job.inputs.len), .monotonic);
-
- for (job.inputs, 0..) |*input, idx| {
- if (job.cancelled.load(.monotonic)) {
- job.phase.store(@intFromEnum(Phase.cancelled), .release);
- return;
- }
-
- switch (input.*) {
- .open => |*pf| {
- job.phase.store(@intFromEnum(Phase.appending), .release);
- ws.appendPackFile(pf) catch |e| {
- job.err = e;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- };
- },
- .path => |path| {
- // The wasm path of `startPackProject` only ever emits `.open`
- // inputs (browser has no project folder to scan), so this
- // branch is unreachable on wasm. Static-gate it to keep
- // `File.fromPath` (which calls `Io.Dir.cwd()` / `posix.AT`,
- // unavailable on `wasm32-freestanding`) out of the wasm
- // reachability graph.
- if (comptime @import("builtin").target.cpu.arch == .wasm32) {
- job.err = error.PathInputsNotSupportedOnWasm;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- }
- job.phase.store(@intFromEnum(Phase.loading), .release);
- const maybe_pf = PackFile.fromPath(fizzy.app.allocator, path) catch |e| {
- job.err = e;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- };
- if (maybe_pf) |pf_val| {
- ws.loaded_files.append(pf_val) catch |e| {
- var tmp = pf_val;
- tmp.deinit();
- job.err = e;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- };
- job.phase.store(@intFromEnum(Phase.appending), .release);
- const ref = &ws.loaded_files.items[ws.loaded_files.items.len - 1];
- ws.appendPackFile(ref) catch |e| {
- job.err = e;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- };
- }
- },
- }
- job.progress_num.store(@intCast(idx + 1), .monotonic);
- }
-
- if (ws.frames.items.len == 0) {
- // Nothing to pack — keep `result_atlas == null`, surface as `ready`. The main thread
- // treats null-result the same as the old `packAndClear` early-out: leave the existing
- // atlas in place.
- job.phase.store(@intFromEnum(Phase.ready), .release);
- return;
- }
-
- // Try increasing texture sizes until everything fits.
- job.phase.store(@intFromEnum(Phase.packing), .release);
- const tex_size = ws.packRects() catch |e| {
- job.err = e;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- } orelse {
- job.err = error.PackFailed;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- };
-
- if (job.cancelled.load(.monotonic)) {
- job.phase.store(@intFromEnum(Phase.cancelled), .release);
- return;
- }
-
- // Blit each emitted sprite into a fresh atlas pixel buffer at the location chosen by
- // `packRects`, then assemble the `Internal.Atlas` value that the main thread will install.
- job.phase.store(@intFromEnum(Phase.blitting), .release);
- const atlas = ws.buildAtlas(tex_size) catch |e| {
- job.err = e;
- job.phase.store(@intFromEnum(Phase.failed), .release);
- return;
- };
-
- if (job.cancelled.load(.monotonic)) {
- // Free the atlas we just built since the consumer won't take it.
- 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);
- job.phase.store(@intFromEnum(Phase.cancelled), .release);
- return;
- }
-
- job.result_atlas = atlas;
- job.phase.store(@intFromEnum(Phase.ready), .release);
-}
-
-// ----------------------------------------------------------------------------
-// Worker-side state. Mirrors the layout the synchronous `Packer` built up across `append` and
-// `packAndClear`, but is wholly owned by the worker thread.
-// ----------------------------------------------------------------------------
-
-/// Borrowed view of a sprite's reduced pixel region inside its source buffer (a `PackLayer`'s
-/// pixels, or a carry-over buffer for collapse chains). `buildAtlas` blits directly from
-/// `source` using `stride`; no intermediate per-sprite allocation. The referenced buffer
-/// must outlive the worker state — see `loaded_files` / `carry_overs` in `WorkerState`.
-const WorkerSpriteRef = struct {
- source: [*]const [4]u8,
- src_x: u32,
- src_y: u32,
- w: u32,
- h: u32,
- stride: u32,
-};
-
-const WorkerSprite = struct {
- image: ?WorkerSpriteRef = null,
- origin: [2]f32 = .{ 0, 0 },
-};
-
-const WorkerAnimation = struct {
- name: []u8,
- frames: []fizzy.Animation.Frame,
-
- fn deinit(self: *WorkerAnimation, allocator: std.mem.Allocator) void {
- allocator.free(self.name);
- allocator.free(self.frames);
- }
-};
-
-const WorkerState = struct {
- allocator: std.mem.Allocator,
- frames: std.array_list.Managed(zstbi.Rect),
- sprites: std.array_list.Managed(WorkerSprite),
- animations: std.array_list.Managed(WorkerAnimation),
-
- /// `.path`-loaded `PackFile`s held alive for the worker's lifetime so the sprite refs
- /// recorded during append remain valid through `buildAtlas`. `.open` snapshots already
- /// live in `job.inputs` until the job is destroyed.
- loaded_files: std.array_list.Managed(PackFile),
-
- /// Per-collapse-chain carry-over buffers (file-sized RGBA grids). Retained for the same
- /// reason as `loaded_files`: sprite refs point into these.
- carry_overs: std.array_list.Managed([][4]u8),
-
- id_counter: u32 = 0,
-
- fn init(allocator: std.mem.Allocator) !WorkerState {
- return .{
- .allocator = allocator,
- .frames = std.array_list.Managed(zstbi.Rect).init(allocator),
- .sprites = std.array_list.Managed(WorkerSprite).init(allocator),
- .animations = std.array_list.Managed(WorkerAnimation).init(allocator),
- .loaded_files = std.array_list.Managed(PackFile).init(allocator),
- .carry_overs = std.array_list.Managed([][4]u8).init(allocator),
- };
- }
-
- fn deinit(self: *WorkerState) void {
- for (self.animations.items) |*anim| anim.deinit(self.allocator);
- for (self.loaded_files.items) |*pf| pf.deinit();
- for (self.carry_overs.items) |buf| self.allocator.free(buf);
- self.frames.deinit();
- self.sprites.deinit();
- self.animations.deinit();
- self.loaded_files.deinit();
- self.carry_overs.deinit();
- }
-
- fn newId(self: *WorkerState) u32 {
- const i = self.id_counter;
- self.id_counter += 1;
- return i;
- }
-
- /// Mirrors `Packer.append`: walks the layer stack, honours collapse / visibility, and
- /// emits sprite refs (borrowed pointers into either the layer's own pixel buffer or a
- /// chain-local carry-over buffer) + animation entries into the worker state. Allocates
- /// only the carry-over buffer per collapse chain — sprite pixels themselves are never
- /// copied here; `buildAtlas` blits straight from the borrowed source.
- fn appendPackFile(self: *WorkerState, pf: *const PackFile) !void {
- const layers = pf.layers;
-
- // Carry-over pixel buffer for the current collapse chain. Sized to the full file
- // canvas, matching the temporary `Layer.init(..., file.width(), file.height(), ...)`
- // the synchronous Packer used. `null` until a collapse chain starts; when the chain
- // ends the buffer moves into `self.carry_overs` so the sprite refs that point into it
- // stay valid through `buildAtlas`.
- var carry_over: ?[][4]u8 = null;
- var carry_w: u32 = 0;
- var carry_h: u32 = 0;
- errdefer if (carry_over) |buf| self.allocator.free(buf);
-
- var index: usize = 0;
- while (index < layers.len) : (index += 1) {
- const layer = &layers[index];
- if (!layer.visible) continue;
-
- const last_item = index == layers.len - 1;
- const prev_collapses = index != 0 and layers[index - 1].collapse;
-
- // True if we're inside (or just exited) a collapse chain involving `layer`.
- const in_chain = (layer.collapse and !last_item) or prev_collapses;
- if (in_chain) {
- if (carry_over == null) {
- const buf = try self.allocator.alloc([4]u8, pf.width * pf.height);
- @memset(buf, .{ 0, 0, 0, 0 });
- carry_over = buf;
- carry_w = pf.width;
- carry_h = pf.height;
- }
- const dst_pixels = carry_over.?;
- for (layer.pixels, dst_pixels) |src, *dst| {
- if (src[3] != 0 and dst[3] == 0) dst.* = src;
- }
- if (layer.collapse and !last_item) continue;
- }
-
- // Which pixels feed sprite reduction this iteration: the carry-over (if active)
- // or the layer itself. Either way the buffer must outlive `buildAtlas` (see
- // `loaded_files` / `carry_overs`).
- const cur_pixels: [][4]u8 = if (carry_over) |buf| buf else layer.pixels;
- const cur_w: u32 = if (carry_over != null) carry_w else layer.width;
- const cur_h: u32 = if (carry_over != null) carry_h else layer.height;
-
- // Same sprite count as `File.spriteCount`: columns * rows.
- const rows: u32 = if (pf.row_height == 0) 0 else pf.height / pf.row_height;
- const total_sprites: usize = @as(usize, pf.columns) * @as(usize, rows);
-
- var sprite_index: usize = 0;
- while (sprite_index < total_sprites) : (sprite_index += 1) {
- const column = @as(u32, @intCast(sprite_index)) % pf.columns;
- const row = @as(u32, @intCast(sprite_index)) / pf.columns;
- const src_x: u32 = @min(column * pf.column_width, pf.width);
- const src_y: u32 = @min(row * pf.row_height, pf.height);
-
- const src_rect: reduce_alg.Rect = .{
- .x = src_x,
- .y = src_y,
- .w = pf.column_width,
- .h = pf.row_height,
- };
-
- if (reduce_alg.reduce(cur_pixels, cur_w, cur_h, src_rect)) |r| {
- const offset_x = r.x - src_x;
- const offset_y = r.y - src_y;
-
- const orig_x: f32 = if (sprite_index < pf.sprites.len) pf.sprites[sprite_index].origin[0] else 0;
- const orig_y: f32 = if (sprite_index < pf.sprites.len) pf.sprites[sprite_index].origin[1] else 0;
-
- try self.sprites.append(.{
- .image = .{
- .source = cur_pixels.ptr,
- .src_x = r.x,
- .src_y = r.y,
- .w = r.w,
- .h = r.h,
- .stride = cur_w,
- },
- .origin = .{ orig_x - @as(f32, @floatFromInt(offset_x)), orig_y - @as(f32, @floatFromInt(offset_y)) },
- });
- try self.frames.append(.{
- .id = self.newId(),
- .w = @intCast(r.w),
- .h = @intCast(r.h),
- });
-
- const new_sprite_index = self.sprites.items.len - 1;
- for (pf.animations) |anim| {
- if (anim.frames.len == 0) continue;
- if (anim.frames[0].sprite_index != sprite_index) continue;
-
- const frames = try self.allocator.alloc(fizzy.Animation.Frame, anim.frames.len);
- for (frames, anim.frames, 0..) |*current_frame, src_frame, i| {
- current_frame.* = .{
- .sprite_index = new_sprite_index + i,
- .ms = src_frame.ms,
- };
- }
- const merged_name = try std.fmt.allocPrint(self.allocator, "{s}_{s}", .{ anim.name, layer.name });
- try self.animations.append(.{ .name = merged_name, .frames = frames });
- }
- } else {
- // Empty reduced region — but the sprite may still appear in an animation,
- // in which case we must emit a placeholder to keep frame indices stable.
- for (pf.animations) |anim| {
- for (anim.frames) |frame| {
- if (frame.sprite_index != sprite_index) continue;
- try self.sprites.append(.{ .image = null, .origin = .{ 0, 0 } });
- try self.frames.append(.{
- .id = self.newId(),
- .w = 2,
- .h = 2,
- });
- }
- }
- }
- }
-
- // End of a collapse chain. Move the carry-over into the worker's retained list so
- // any sprite refs that point into it stay valid past this iteration.
- if (carry_over) |buf| {
- try self.carry_overs.append(buf);
- carry_over = null;
- }
- }
-
- // If the file's last layer was still part of an unclosed chain (only happens when
- // every visible layer up to the last had `collapse = true`), move that buffer too.
- if (carry_over) |buf| {
- try self.carry_overs.append(buf);
- carry_over = null;
- }
- }
-
- fn packRects(self: *WorkerState) !?[2]u16 {
- if (self.frames.items.len == 0) return null;
-
- var ctx: zstbi.Context = undefined;
- const node_count = 4096 * 2;
- var nodes: [node_count]zstbi.Node = undefined;
-
- const texture_sizes = [_][2]u32{
- .{ 256, 256 }, .{ 512, 256 }, .{ 256, 512 },
- .{ 512, 512 }, .{ 1024, 512 }, .{ 512, 1024 },
- .{ 1024, 1024 }, .{ 2048, 1024 }, .{ 1024, 2048 },
- .{ 2048, 2048 }, .{ 4096, 2048 }, .{ 2048, 4096 },
- .{ 4096, 4096 }, .{ 8192, 4096 }, .{ 4096, 8192 },
- };
-
- for (texture_sizes) |tex_size| {
- zstbi.initTarget(&ctx, tex_size[0], tex_size[1], &nodes);
- zstbi.setupHeuristic(&ctx, zstbi.Heuristic.skyline_bl_sort_height);
- if (zstbi.packRects(&ctx, self.frames.items) == 1) {
- return .{ @intCast(tex_size[0]), @intCast(tex_size[1]) };
- }
- }
-
- return null;
- }
-
- /// Allocate the final atlas pixels and metadata, blit each emitted sprite into its packed
- /// slot, and return an `Internal.Atlas` that owns all of its allocations through the app
- /// allocator (so it survives past the job's lifetime).
- ///
- /// IMPORTANT: this runs on the worker thread, so we cannot use `Layer.blit` — it calls
- /// `invalidate()` → `dvui.textureInvalidateCache`, which dereferences `currentWindow()`
- /// and panics off the main thread. Build the atlas as a plain pixel buffer + raw
- /// `pixelsPMA` ImageSource directly; first use of the source on the main thread will pick
- /// up a fresh texture-cache key because `.invalidation = .ptr` keys on the pixel pointer.
- fn buildAtlas(self: *WorkerState, tex_size: [2]u16) !fizzy.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);
- @memset(pixels, .{ 0, 0, 0, 0 });
-
- const tex_w: usize = tex_size[0];
- for (self.frames.items, self.sprites.items) |frame, sprite| {
- if (sprite.image) |ref| {
- const slice = frame.slice();
- const dst_x: usize = slice[0];
- const dst_y: usize = slice[1];
- const w: usize = @intCast(ref.w);
- const h: usize = @intCast(ref.h);
- const stride: usize = @intCast(ref.stride);
- const src_x: usize = @intCast(ref.src_x);
- const src_y: usize = @intCast(ref.src_y);
- // Blit straight from the borrowed source buffer (a layer or carry-over) into
- // the atlas — no intermediate per-sprite copy, just one pass per pixel.
- var row: usize = 0;
- while (row < h) : (row += 1) {
- const src_row_start = (src_y + row) * stride + src_x;
- const src_row = ref.source[src_row_start .. src_row_start + w];
- const dst_row_start = (dst_y + row) * tex_w + dst_x;
- const dst_row = pixels[dst_row_start .. dst_row_start + w];
- @memcpy(dst_row, src_row);
- }
- }
- }
-
- const sprites_out = try fizzy.app.allocator.alloc(fizzy.Atlas.Sprite, self.sprites.items.len);
- errdefer fizzy.app.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);
- 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, 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);
- anims_initialized += 1;
- }
-
- return .{
- .source = .{
- .pixelsPMA = .{
- .rgba = @ptrCast(pixels),
- .width = tex_size[0],
- .height = tex_size[1],
- .interpolation = .nearest,
- .invalidation = .ptr,
- },
- },
- .data = .{
- .sprites = sprites_out,
- .animations = animations_out,
- },
- };
- }
-};
diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig
new file mode 100644
index 00000000..ba524f57
--- /dev/null
+++ b/src/editor/PluginLoader.zig
@@ -0,0 +1,299 @@
+//! 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 a plugin's user-facing display name straight from the dylib, **without
+/// registering it**. Opens the image, reads the optional `fizzy_plugin_name` export, copies the
+/// string out (the dylib is closed before returning), and unloads. Returns null when the dylib
+/// can't be opened or predates the `fizzy_plugin_name` symbol. Caller owns the returned slice.
+pub fn probeName(allocator: std.mem.Allocator, path: []const u8) ?[]u8 {
+ var lib = DynLib.open(path) catch return null;
+ defer lib.close();
+ const get_name = lib.lookup(dylib_api.GetPluginNameFn, dylib_api.symbol_plugin_name) orelse return null;
+ const name = std.mem.span(get_name());
+ if (name.len == 0) return null;
+ return allocator.dupe(u8, name) catch null;
+}
+
+/// Best-effort read of version exports from a dylib (for failure diagnostics).
+pub fn probeVersionInfo(path: []const u8) ?PluginVersionInfo {
+ var lib = DynLib.open(path) catch return null;
+ defer lib.close();
+ const get_sdk_version = lookupVersionFn(&lib, dylib_api.symbol_sdk_version);
+ const get_min_sdk = lookupVersionFn(&lib, dylib_api.symbol_min_sdk_version);
+ const get_plugin_version = lookupVersionFn(&lib, dylib_api.symbol_plugin_version);
+ return .{
+ .plugin_version = readVersionTriplet(get_plugin_version),
+ .built_with_sdk_version = readVersionTriplet(get_sdk_version),
+ .min_sdk_version = readVersionTriplet(get_min_sdk),
+ };
+}
+
+test "builtin plugin path joins exe_dir/plugins" {
+ const path = try builtinPluginPath(std.testing.allocator, "/app", "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/PluginStore.zig b/src/editor/PluginStore.zig
new file mode 100644
index 00000000..00564781
--- /dev/null
+++ b/src/editor/PluginStore.zig
@@ -0,0 +1,846 @@
+//! Shell built-in: the **Plugins** sidebar tab — discover / install / update / enable / disable
+//! / uninstall plugins. Registered above Settings.
+//!
+//! Downloads run on a worker thread (`Job`); the actual live load happens on the main thread in
+//! `tick` (it mutates the Host registries + dvui keybinds). The registry index is fetched +
+//! parsed by the backend (`store.Catalog`); compatibility is matched on the host ABI
+//! fingerprint + arch.
+const std = @import("std");
+const builtin = @import("builtin");
+const dvui = @import("dvui");
+const sdk = @import("sdk");
+const icons = @import("icons");
+const fizzy = @import("../fizzy.zig");
+const store = @import("../backend/plugin_store/store.zig");
+const PluginLoader = @import("PluginLoader.zig");
+
+const compat = store.compat;
+const version = sdk.version;
+const dylib = sdk.dylib;
+
+/// README rendering depends on the in-tree markdown engine, which links cmark (libc) and is
+/// native-only. The store never runs on wasm (`register` bails on wasm32), so the web build gets
+/// a no-op stub and the `readme`/`markdown` modules are only resolved on native.
+const Readme = if (builtin.target.cpu.arch == .wasm32) struct {
+ pub fn select(_: []const u8, _: []const u8) void {}
+ pub fn selectedId() ?[]const u8 {
+ return null;
+ }
+ pub fn draw() void {}
+ pub fn clear() void {}
+ pub fn deinit() void {}
+} else @import("readme.zig");
+
+pub const view_id = "shell.store";
+/// Center provider that renders the selected plugin's README. Mirrors the way the workbench
+/// center renders the active document: while the store tab is active and a plugin is selected,
+/// `tick` swaps the active center to this provider; deselecting (or leaving the tab) restores
+/// the previous center.
+pub const readme_center_id = "shell.store.readme";
+const default_registry_url = "https://plugins.fizzyed.it/index.json";
+
+/// True while we have hijacked the active center to show a README, plus the center id to restore
+/// when the selection is cleared or the store tab is no longer active.
+var readme_center_active = false;
+var saved_center: ?[]const u8 = null;
+
+var catalog: ?store.Catalog = null;
+var registry_url_owned: ?[]u8 = null;
+var first_draw_done = false;
+
+/// Transient status line shown in the header (e.g. an action error). Module-owned buffer.
+var status_message: [256]u8 = undefined;
+var status_len: usize = 0;
+
+fn setStatus(comptime fmt: []const u8, args: anytype) void {
+ const s = std.fmt.bufPrint(&status_message, fmt, args) catch {
+ status_len = 0;
+ return;
+ };
+ status_len = s.len;
+}
+
+// ---- async install jobs ----------------------------------------------------
+
+const JobStatus = enum(u8) { downloading, downloaded, failed };
+
+const Job = struct {
+ status: std.atomic.Value(u8),
+ id: []u8,
+ url: []u8,
+ sha256: []u8,
+ dest: []u8,
+ is_update: bool,
+ err_buf: [64]u8 = undefined,
+ err_len: usize = 0,
+};
+
+var jobs: std.StringArrayHashMapUnmanaged(*Job) = .empty;
+
+/// UI actions queued during `draw` and applied in `tick` so plugin unload never mutates
+/// `host.plugins` (or dlcloses an image) while the store view is still iterating it.
+const PendingAction = union(enum) {
+ set_enabled: struct { id: []u8, enabled: bool },
+ uninstall: struct { id: []u8 },
+};
+
+var pending_actions: std.ArrayListUnmanaged(PendingAction) = .empty;
+
+/// Last-known display name per plugin id (app-allocator owned). A sideloaded plugin only exposes
+/// its display name while loaded — once disabled it is unloaded and we'd otherwise fall back to the
+/// bare id, which changes the A→Z sort position (e.g. "Terminal" → "ghostty") every time it is
+/// toggled. We remember the name the first time we see it (loaded plugin or registry row) and reuse
+/// it as the stable title for the disabled/failed states. (Session-scoped: a plugin disabled before
+/// it was ever loaded this session still shows its id until enabled once.)
+var name_cache: std.StringArrayHashMapUnmanaged([]u8) = .empty;
+
+/// Cache `id`'s display name if it is a real name distinct from the id. Updates an existing entry
+/// when the name changes (e.g. a version that renamed itself).
+fn rememberName(id: []const u8, name: []const u8) void {
+ if (name.len == 0 or std.mem.eql(u8, name, id)) return;
+ const a = fizzy.app.allocator;
+ const gop = name_cache.getOrPut(a, id) catch return;
+ if (gop.found_existing) {
+ if (std.mem.eql(u8, gop.value_ptr.*, name)) return;
+ a.free(gop.value_ptr.*);
+ gop.value_ptr.* = a.dupe(u8, name) catch {
+ _ = name_cache.swapRemove(id);
+ return;
+ };
+ return;
+ }
+ // New entry: own the key independently of the (borrowed) caller slice.
+ const key = a.dupe(u8, id) catch {
+ _ = name_cache.swapRemove(id);
+ return;
+ };
+ gop.key_ptr.* = key;
+ gop.value_ptr.* = a.dupe(u8, name) catch {
+ _ = name_cache.swapRemove(id);
+ a.free(key);
+ return;
+ };
+}
+
+/// The remembered display name for `id`, or `fallback` (the id) when we've never seen it loaded.
+fn resolveTitle(id: []const u8, fallback: []const u8) []const u8 {
+ return name_cache.get(id) orelse fallback;
+}
+
+/// Query the real display name of every disabled plugin straight from its on-disk dylib (via the
+/// `fizzy_plugin_name` export — no register), seeding `name_cache`. This covers plugins that were
+/// disabled *before* they were ever loaded this session, so a disabled card shows its true name
+/// (and keeps its A→Z position) without a fragile on-disk name cache. Cheap and bounded: only runs
+/// on first draw / Refresh, and only probes ids whose name we don't already know.
+fn probeDisabledNames() void {
+ const editor = fizzy.editor;
+ const a = fizzy.app.allocator;
+ const plugins_dir = std.fs.path.join(a, &.{ editor.config_folder, "plugins" }) catch return;
+ defer a.free(plugins_dir);
+
+ for (editor.disabled_plugin_ids.items) |id| {
+ if (!std.unicode.utf8ValidateSlice(id)) continue;
+ if (name_cache.get(id) != null) continue; // already known (loaded / registry / prior probe)
+ if (editor.host.pluginById(id) != null) continue; // loaded → name comes from the live plugin
+ const file_name = PluginLoader.pluginFilename(id, a) catch continue;
+ defer a.free(file_name);
+ const path = std.fs.path.join(a, &.{ plugins_dir, file_name }) catch continue;
+ defer a.free(path);
+ if (PluginLoader.probeName(a, path)) |name| {
+ defer a.free(name);
+ rememberName(id, name);
+ }
+ }
+}
+
+pub fn register(host: *sdk.Host) !void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return; // no dylib loading on web
+ const url = resolveRegistryUrl();
+ catalog = store.Catalog.init(fizzy.app.allocator, dvui.io, url);
+ try host.registerSidebarView(.{
+ .id = view_id,
+ .icon = dvui.entypo.shop,
+ .title = "Plugins",
+ .draw = draw,
+ });
+ // README center provider. Registered after the workbench center (see `postInit` order) so it
+ // never becomes the default active center; `tick` activates it on demand.
+ try host.registerCenter(.{
+ .id = readme_center_id,
+ .draw = drawReadmeCenter,
+ });
+}
+
+/// Center provider: paint the selected plugin's README. Active only while `tick` has swapped us
+/// in (store tab active + a plugin selected). Uses the same rounded, content-colored card the
+/// workbench homepage / empty state draws (`sdk.pane_layout.emptyStateCard`) so the store center
+/// matches the rest of the app.
+fn drawReadmeCenter(_: ?*anyopaque) anyerror!dvui.App.Result {
+ const host = &fizzy.editor.host;
+ var content_color = dvui.themeGet().color(.window, .fill);
+ switch (builtin.os.tag) {
+ .macos, .windows => {
+ if (!host.isMaximized()) content_color = content_color.opacity(host.contentOpacity());
+ },
+ else => {},
+ }
+
+ var card = sdk.pane_layout.emptyStateCard(content_color, hashId(readme_center_id));
+ defer card.deinit();
+
+ var pane = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(16) });
+ defer pane.deinit();
+ Readme.draw();
+ return .ok;
+}
+
+/// `FIZZY_PLUGIN_REGISTRY_URL` overrides the default (used for local E2E testing). Owned for the
+/// process lifetime (freed in `deinit`).
+fn resolveRegistryUrl() []const u8 {
+ if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_PLUGIN_REGISTRY_URL")) |override| {
+ if (override.len > 0) {
+ registry_url_owned = override;
+ return override;
+ }
+ fizzy.app.allocator.free(override);
+ } else |_| {}
+ return default_registry_url;
+}
+
+pub fn deinit() void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ for (jobs.values()) |job| freeJob(job);
+ jobs.deinit(fizzy.app.allocator);
+ for (pending_actions.items) |action| switch (action) {
+ .set_enabled => |a| fizzy.app.allocator.free(a.id),
+ .uninstall => |a| fizzy.app.allocator.free(a.id),
+ };
+ pending_actions.deinit(fizzy.app.allocator);
+ for (name_cache.keys()) |k| fizzy.app.allocator.free(k);
+ for (name_cache.values()) |v| fizzy.app.allocator.free(v);
+ name_cache.deinit(fizzy.app.allocator);
+ Readme.deinit();
+ if (catalog) |*c| c.deinit();
+ catalog = null;
+ if (registry_url_owned) |u| {
+ fizzy.app.allocator.free(u);
+ registry_url_owned = null;
+ }
+}
+
+fn freeJob(job: *Job) void {
+ fizzy.app.allocator.free(job.id);
+ fizzy.app.allocator.free(job.url);
+ fizzy.app.allocator.free(job.sha256);
+ fizzy.app.allocator.free(job.dest);
+ fizzy.app.allocator.destroy(job);
+}
+
+// ---- per-frame completion (main thread) ------------------------------------
+
+/// Complete any finished downloads by loading them live, and apply plugin enable/disable /
+/// uninstall requests queued from the store UI. Called once per frame from `Editor.tick`,
+/// before the Host-registry iterations, so a freshly-registered or unloaded plugin never
+/// mutates a list mid-iteration.
+pub fn tick() void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+
+ syncReadmeCenter();
+
+ for (pending_actions.items) |action| switch (action) {
+ .set_enabled => |a| {
+ applySetEnabled(a.id, a.enabled);
+ fizzy.app.allocator.free(a.id);
+ },
+ .uninstall => |a| {
+ applyUninstall(a.id);
+ fizzy.app.allocator.free(a.id);
+ },
+ };
+ pending_actions.clearRetainingCapacity();
+
+ var i: usize = 0;
+ while (i < jobs.count()) {
+ const job = jobs.values()[i];
+ switch (@as(JobStatus, @enumFromInt(job.status.load(.acquire)))) {
+ .downloading, .failed => i += 1,
+ .downloaded => {
+ const loaded = if (job.is_update)
+ fizzy.editor.updatePlugin(job.id, true)
+ else
+ fizzy.editor.installAndLoadPlugin(job.id);
+ loaded catch |err| {
+ setStatus("'{s}' failed to load: {s}", .{ job.id, @errorName(err) });
+ const n = @min(@errorName(err).len, job.err_buf.len);
+ @memcpy(job.err_buf[0..n], @errorName(err)[0..n]);
+ job.err_len = n;
+ job.status.store(@intFromEnum(JobStatus.failed), .release);
+ i += 1;
+ continue;
+ };
+ // Installed + loaded: drop the job so the card shows normal installed state.
+ jobs.swapRemoveAt(i);
+ freeJob(job);
+ // do not advance i — swapRemove moved a new entry into slot i
+ },
+ }
+ }
+}
+
+/// Drive the active center from the store selection: while the store tab is active and a plugin
+/// is selected, show its README in the center; otherwise restore whatever center was active when
+/// we took over. Idempotent — safe to call every frame.
+fn syncReadmeCenter() void {
+ const host = &fizzy.editor.host;
+ const want = host.isActiveSidebarView(view_id) and Readme.selectedId() != null;
+ if (want and !readme_center_active) {
+ saved_center = host.active_center;
+ host.setActiveCenter(readme_center_id);
+ readme_center_active = true;
+ } else if (!want and readme_center_active) {
+ host.active_center = saved_center;
+ saved_center = null;
+ readme_center_active = false;
+ }
+}
+
+/// Select `entry` (showing its README in the center), or clear the selection if it is already the
+/// selected card. Only one plugin is selectable at a time.
+fn toggleSelect(entry: StoreEntry) void {
+ if (Readme.selectedId()) |sid| {
+ if (std.mem.eql(u8, sid, entry.id)) {
+ Readme.clear();
+ return;
+ }
+ }
+ Readme.select(entry.id, repoUrl(entry) orelse "");
+}
+
+fn worker(job: *Job) void {
+ store.download.download(fizzy.app.allocator, dvui.io, job.url, job.sha256, job.dest) catch |err| {
+ const n = @min(@errorName(err).len, job.err_buf.len);
+ @memcpy(job.err_buf[0..n], @errorName(err)[0..n]);
+ job.err_len = n;
+ job.status.store(@intFromEnum(JobStatus.failed), .release);
+ return;
+ };
+ job.status.store(@intFromEnum(JobStatus.downloaded), .release);
+}
+
+fn removeJob(id: []const u8) void {
+ if (jobs.fetchSwapRemove(id)) |kv| freeJob(kv.value);
+}
+
+/// Kick off a download for `id`'s selected release on a worker thread.
+fn startDownload(id: []const u8, release: store.Release, is_update: bool) void {
+ removeJob(id); // replace any prior failed job
+ const dl = release.downloadFor(compat.hostKey()) orelse return;
+
+ const job = buildJob(id, dl, is_update) catch {
+ setStatus("could not prepare download for '{s}'", .{id});
+ return;
+ };
+ jobs.put(fizzy.app.allocator, job.id, job) catch {
+ freeJob(job);
+ return;
+ };
+ const thread = std.Thread.spawn(.{}, worker, .{job}) catch {
+ _ = jobs.swapRemove(job.id);
+ freeJob(job);
+ setStatus("could not start download for '{s}'", .{id});
+ return;
+ };
+ thread.detach();
+}
+
+/// Allocate a `Job` with all strings owned; `errdefer` unwinds every partial allocation so a
+/// mid-build OOM never leaks.
+fn buildJob(id: []const u8, dl: store.registry.Download, is_update: bool) !*Job {
+ const a = fizzy.app.allocator;
+
+ const plugins_dir = try std.fs.path.join(a, &.{ fizzy.editor.config_folder, "plugins" });
+ defer a.free(plugins_dir);
+ std.Io.Dir.createDirAbsolute(dvui.io, plugins_dir, .default_dir) catch {}; // best-effort; exists is fine
+ const file_name = try PluginLoader.pluginFilename(id, a);
+ defer a.free(file_name);
+
+ const job = try a.create(Job);
+ errdefer a.destroy(job);
+ const id_dup = try a.dupe(u8, id);
+ errdefer a.free(id_dup);
+ const url_dup = try a.dupe(u8, dl.url);
+ errdefer a.free(url_dup);
+ const sha_dup = try a.dupe(u8, dl.sha256);
+ errdefer a.free(sha_dup);
+ const dest = try std.fs.path.join(a, &.{ plugins_dir, file_name });
+ errdefer a.free(dest);
+
+ job.* = .{
+ .status = .init(@intFromEnum(JobStatus.downloading)),
+ .id = id_dup,
+ .url = url_dup,
+ .sha256 = sha_dup,
+ .dest = dest,
+ .is_update = is_update,
+ };
+ return job;
+}
+
+// ---- drawing ---------------------------------------------------------------
+
+fn installedVersion(id: []const u8) ?std.SemanticVersion {
+ for (fizzy.editor.loaded_plugin_libs.items) |loaded| {
+ if (std.mem.eql(u8, loaded.plugin_id, id)) return loaded.version_info.plugin_version;
+ }
+ return null;
+}
+
+fn isBundled(id: []const u8) bool {
+ return std.mem.eql(u8, id, "workbench") or std.mem.eql(u8, id, "text");
+}
+
+/// One deterministic row in the store tree, merged from the registry index plus the local
+/// plugin/disabled/failed lists.
+///
+/// **Lifetime:** every slice here is *borrowed* with one of three lifetimes — registry strings
+/// are valid only while the catalog lock is held (the worker frees the arena on `refresh`),
+/// `plugin.display_name`/`plugin.id` live in dylib/static memory only while the plugin is
+/// loaded, and disabled ids are app-allocator-owned. The whole build → sort → draw pass runs
+/// inside a single `catalog.acquire()`/`release()` scope and the dvui frame arena, so none of
+/// these are retained past the lock release or across frames.
+const StoreEntry = struct {
+ id: []const u8,
+ title: []const u8,
+ kind: enum { registry, local, disabled, failed },
+ registry: ?store.PluginEntry = null,
+ plugin: ?*sdk.Plugin = null,
+ failed_reason: []const u8 = "",
+};
+
+/// Stable, position-independent widget/branch id for a plugin id (avoids the old loop-index
+/// ids that shifted as rows were added/removed).
+fn hashId(id: []const u8) usize {
+ return @truncate(std.hash.Wyhash.hash(0, id));
+}
+
+fn containsId(entries: []const StoreEntry, id: []const u8) bool {
+ for (entries) |e| {
+ if (std.mem.eql(u8, e.id, id)) return true;
+ }
+ return false;
+}
+
+/// A→Z by display title (case-insensitive ASCII), tie-broken on id for stability.
+fn entryLess(_: void, lhs: StoreEntry, rhs: StoreEntry) bool {
+ return switch (std.ascii.orderIgnoreCase(lhs.title, rhs.title)) {
+ .lt => true,
+ .gt => false,
+ .eq => std.mem.order(u8, lhs.id, rhs.id) == .lt,
+ };
+}
+
+fn fieldMatches(haystack: []const u8, needle: []const u8) bool {
+ return std.ascii.indexOfIgnoreCase(haystack, needle) != null;
+}
+
+/// Case-insensitive substring match across id, title, and (for registry rows) description,
+/// author, and tags — mirroring the Files tab filter behaviour.
+fn matchesFilter(entry: StoreEntry, filter: []const u8) bool {
+ if (filter.len == 0) return true;
+ if (fieldMatches(entry.id, filter)) return true;
+ if (fieldMatches(entry.title, filter)) return true;
+ if (entry.registry) |r| {
+ if (fieldMatches(r.description, filter)) return true;
+ if (fieldMatches(r.author, filter)) return true;
+ for (r.tags) |tag| if (fieldMatches(tag, filter)) return true;
+ }
+ if (entry.plugin) |p| {
+ if (fieldMatches(p.id, filter)) return true;
+ if (fieldMatches(p.display_name, filter)) return true;
+ }
+ return false;
+}
+
+fn draw(_: ?*anyopaque) anyerror!void {
+ var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal });
+ defer vbox.deinit();
+
+ // First time the tab is shown, fetch the registry and learn disabled plugins' real names.
+ if (!first_draw_done) {
+ first_draw_done = true;
+ if (catalog) |*c| c.refresh();
+ probeDisabledNames();
+ }
+
+ try drawHeader();
+
+ // Filter row — same shape as the file tree (search icon + borderless text entry).
+ var filter_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .y = 4 } });
+ dvui.icon(
+ @src(),
+ "FilterIcon",
+ icons.tvg.lucide.search,
+ .{ .stroke_color = dvui.themeGet().color(.window, .text) },
+ .{ .gravity_y = 0.5, .padding = dvui.Rect.all(0) },
+ );
+ const filter_edit = dvui.textEntry(@src(), .{ .placeholder = "Filter..." }, .{
+ .expand = .horizontal,
+ .background = false,
+ });
+ const filter_text = filter_edit.getText();
+ filter_edit.deinit();
+ filter_hbox.deinit();
+
+ const cat = if (catalog) |*c| c else return;
+ const maybe_index = cat.acquire();
+ defer cat.release();
+
+ // Build one deduped, A→Z model under the catalog lock, into the per-frame dvui arena.
+ const arena = dvui.currentWindow().arena();
+ var entries: std.ArrayListUnmanaged(StoreEntry) = .empty;
+
+ const editor = fizzy.editor;
+
+ // Precedence — one row per id: registry > loaded/local > disabled > failed. A registry row
+ // already reflects loaded/disabled/available/needs-rebuild for its id, so it is the richest
+ // representation whenever an id is published.
+ if (maybe_index) |index| {
+ for (index.plugins) |entry| {
+ rememberName(entry.id, entry.name);
+ entries.append(arena, .{
+ .id = entry.id,
+ .title = if (entry.name.len > 0) entry.name else resolveTitle(entry.id, entry.id),
+ .kind = .registry,
+ .registry = entry,
+ }) catch {};
+ }
+ }
+ // Locally-present plugins the registry doesn't list: bundled built-ins + sideloaded dylibs.
+ for (editor.host.plugins.items) |plugin| {
+ rememberName(plugin.id, plugin.display_name);
+ if (containsId(entries.items, plugin.id)) continue;
+ entries.append(arena, .{
+ .id = plugin.id,
+ .title = plugin.display_name,
+ .kind = .local,
+ .plugin = plugin,
+ }) catch {};
+ }
+ // Disabled plugins are unloaded (not in `host.plugins`) but remain on disk; reuse the name we
+ // remembered while they were loaded so they keep their A→Z position across enable/disable.
+ for (editor.disabled_plugin_ids.items) |id| {
+ if (!std.unicode.utf8ValidateSlice(id)) continue;
+ if (editor.host.pluginById(id) != null) continue;
+ if (containsId(entries.items, id)) continue;
+ entries.append(arena, .{ .id = id, .title = resolveTitle(id, id), .kind = .disabled }) catch {};
+ }
+ // Load failures (folded into the same dedup pass so an id never renders twice).
+ for (editor.failed_user_plugins.items) |f| {
+ if (containsId(entries.items, f.id)) continue;
+ entries.append(arena, .{ .id = f.id, .title = resolveTitle(f.id, f.id), .kind = .failed, .failed_reason = f.reason }) catch {};
+ }
+
+ std.sort.pdq(StoreEntry, entries.items, {}, entryLess);
+
+ // Surface a registry-fetch problem above the list (local plugins still render below it).
+ if (maybe_index == null) switch (cat.status()) {
+ .fetching => dvui.labelNoFmt(@src(), "Fetching plugin registry…", .{}, .{ .margin = .{ .y = 8 } }),
+ .failed => dvui.labelNoFmt(@src(), "Could not reach the plugin registry.", .{}, .{
+ .margin = .{ .y = 8 },
+ .color_text = dvui.themeGet().color(.err, .text),
+ }),
+ else => {},
+ };
+
+ // Flat A→Z card list. Selecting a card (clicking anywhere outside its controls) shows that
+ // plugin's README in the center pane — see `syncReadmeCenter`/`drawReadmeCenter`.
+ var shown: usize = 0;
+ for (entries.items) |entry| {
+ if (!matchesFilter(entry, filter_text)) continue;
+ shown += 1;
+ drawCard(entry);
+ }
+
+ if (shown == 0) {
+ if (filter_text.len > 0) {
+ dvui.labelNoFmt(@src(), "No plugins match the filter.", .{}, .{ .margin = .{ .y = 8 } });
+ } else if (maybe_index != null) {
+ dvui.labelNoFmt(@src(), "No plugins available.", .{}, .{ .margin = .{ .y = 8 } });
+ }
+ }
+}
+
+/// One flat store card: a clickable container (logo + info + state controls). Clicking anywhere
+/// outside the controls selects the plugin (its README shows in the center). The controls consume
+/// their own clicks so the card-level click never double-fires.
+fn drawCard(entry: StoreEntry) void {
+ const theme = dvui.themeGet();
+ const selected = if (Readme.selectedId()) |sid| std.mem.eql(u8, sid, entry.id) else false;
+ // Disabled plugins read as a faded card: half the surface fill opacity and half the shadow.
+ const disabled = fizzy.editor.isPluginDisabled(entry.id);
+
+ const fill = if (selected)
+ theme.color(.control, .fill).opacity(0.5)
+ else
+ theme.color(.content, .fill).opacity(if (disabled) 0.5 else 1.0);
+ const shadow_alpha: f32 = if (disabled) 0.125 else 0.25;
+
+ var bw: dvui.ButtonWidget = undefined;
+ bw.init(@src(), .{}, .{
+ .id_extra = hashId(entry.id),
+ .expand = .horizontal,
+ .margin = .all(4),
+ .padding = .all(8),
+ .corner_radius = dvui.Rect.all(8),
+ .background = true,
+ .color_fill = fill,
+ .color_fill_hover = theme.color(.control, .fill).opacity(0.5),
+ .color_fill_press = theme.color(.control, .fill_press),
+ .box_shadow = .{
+ .color = .black,
+ .corner_radius = dvui.Rect.all(8),
+ .fade = 4,
+ .alpha = shadow_alpha,
+ },
+ });
+ defer bw.deinit();
+ // Hover highlight without consuming click events, so the inner controls get first dibs; the
+ // card's own click is processed *after* the controls (see `bw.processEvents()` below).
+ bw.processHover();
+ bw.drawBackground();
+
+ {
+ var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
+ defer hbox.deinit();
+
+ // 1. Logo (gravity 0). Generic placeholder for now; real logos land in Phase META.
+ dvui.icon(
+ @src(),
+ "PluginLogo",
+ icons.tvg.lucide.package,
+ .{ .stroke_color = theme.color(.window, .text) },
+ .{ .gravity_y = 0.5, .min_size_content = .{ .w = 32, .h = 32 } },
+ );
+
+ // 2. Info column: large title + dim monospace "id · version · date" subtitle.
+ {
+ var info = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .horizontal,
+ .gravity_y = 0.5,
+ .margin = .{ .x = 8 },
+ });
+ defer info.deinit();
+
+ dvui.labelNoFmt(@src(), entry.title, .{}, .{ .font = dvui.Font.theme(.title) });
+
+ var sub_buf: [192]u8 = undefined;
+ dvui.labelNoFmt(@src(), subtitle(&sub_buf, entry), .{}, .{
+ .font = dvui.Font.theme(.mono),
+ .color_text = theme.color(.window, .text).opacity(0.65),
+ });
+ }
+
+ // 3. State controls, right-justified. These run their own processEvents (inside
+ // `drawCardControls`) and consume their clicks before the card does.
+ drawCardControls(entry);
+ }
+
+ // Now claim the card-body click — `dvui.clicked` skips events a control already handled.
+ bw.processEvents();
+ if (bw.clicked()) toggleSelect(entry);
+}
+
+/// Compose the dim subtitle `id · version · date` into `buf`, skipping parts we don't have.
+/// `version` prefers the loaded plugin's version, then the selected registry release's version;
+/// `date` is the selected release's publish date.
+fn subtitle(buf: []u8, entry: StoreEntry) []const u8 {
+ var ver_buf: [32]u8 = undefined;
+ const ver: ?[]const u8 = blk: {
+ if (installedVersion(entry.id)) |v|
+ break :blk std.fmt.bufPrint(&ver_buf, "v{d}.{d}.{d}", .{ v.major, v.minor, v.patch }) catch null;
+ if (selectedRelease(entry)) |rel|
+ break :blk std.fmt.bufPrint(&ver_buf, "v{s}", .{rel.version}) catch null;
+ break :blk null;
+ };
+ const date: ?[]const u8 = if (selectedRelease(entry)) |rel|
+ (if (rel.published.len > 0) rel.published else null)
+ else
+ null;
+
+ if (ver) |vv| {
+ if (date) |dd|
+ return std.fmt.bufPrint(buf, "{s} · {s} · {s}", .{ entry.id, vv, dd }) catch entry.id;
+ return std.fmt.bufPrint(buf, "{s} · {s}", .{ entry.id, vv }) catch entry.id;
+ }
+ if (date) |dd|
+ return std.fmt.bufPrint(buf, "{s} · {s}", .{ entry.id, dd }) catch entry.id;
+ return entry.id;
+}
+
+/// The registry release that is compatible with this host, if `entry` has a registry row.
+fn selectedRelease(entry: StoreEntry) ?store.Release {
+ const r = entry.registry orelse return null;
+ return compat.selectRelease(r, dylib.abi_fingerprint, compat.hostKey());
+}
+
+/// Right-justified controls whose shape depends on install state (see plan Phase 1R-c):
+/// * available in store → a single install button (down-to-line arrow);
+/// * installed → an Enabled checkbox + a trash uninstall button;
+/// * protected bundled fallback (text/workbench) → no controls;
+/// * static built-in (example) → just the Enabled checkbox (nothing to uninstall).
+fn drawCardControls(entry: StoreEntry) void {
+ const editor = fizzy.editor;
+ const theme = dvui.themeGet();
+ const muted = theme.color(.window, .text).opacity(0.7);
+
+ var ctl = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_x = 1.0, .gravity_y = 0.5 });
+ defer ctl.deinit();
+
+ // An in-flight / failed install job preempts the normal controls.
+ if (jobs.get(entry.id)) |job| switch (@as(JobStatus, @enumFromInt(job.status.load(.acquire)))) {
+ .downloading => {
+ dvui.labelNoFmt(@src(), "Installing…", .{}, .{ .gravity_y = 0.5, .color_text = muted, .font = dvui.Font.theme(.mono) });
+ return;
+ },
+ .failed => {
+ if (selectedRelease(entry)) |rel| {
+ if (dvui.buttonIcon(@src(), "Retry", icons.tvg.lucide.@"rotate-ccw", .{}, .{ .stroke_color = theme.color(.err, .text) }, .{ .gravity_y = 0.5 }))
+ startDownload(entry.id, rel, false);
+ }
+ return;
+ },
+ .downloaded => {}, // about to complete in tick(); fall through to installed controls
+ };
+
+ // Protected universal fallbacks: never disablable / uninstallable.
+ if (isBundled(entry.id)) {
+ dvui.labelNoFmt(@src(), "Built-in", .{}, .{ .gravity_y = 0.5, .color_text = muted, .font = dvui.Font.theme(.mono) });
+ return;
+ }
+
+ const loaded = editor.host.pluginById(entry.id) != null;
+ const disabled = editor.isPluginDisabled(entry.id);
+
+ // Static built-ins (example): toggle a hidden sidebar view; no dylib to uninstall.
+ if (fizzy.Editor.isStaticHidePlugin(entry.id)) {
+ var enabled = !disabled;
+ if (dvui.checkbox(@src(), &enabled, "Enabled", .{ .gravity_y = 0.5 })) queueSetEnabled(entry.id, enabled);
+ return;
+ }
+
+ // Installed (loaded dylib, or disabled-on-disk, or a sideloaded local): disable switch + trash.
+ if (loaded or disabled or entry.kind == .local or entry.kind == .disabled) {
+ var enabled = !disabled;
+ if (dvui.checkbox(@src(), &enabled, "Enabled", .{ .gravity_y = 0.5 })) queueSetEnabled(entry.id, enabled);
+ if (dvui.buttonIcon(@src(), "Uninstall", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = theme.color(.err, .text) }, .{ .gravity_y = 0.5 }))
+ queueUninstall(entry.id);
+ return;
+ }
+
+ // Available in the store but not installed.
+ if (selectedRelease(entry)) |rel| {
+ if (dvui.buttonIcon(@src(), "Install", icons.tvg.lucide.@"arrow-down-to-line", .{}, .{ .stroke_color = theme.color(.control, .text) }, .{ .gravity_y = 0.5 }))
+ startDownload(entry.id, rel, false);
+ return;
+ }
+
+ // Registry row with no host-compatible release, or a load failure.
+ const msg: []const u8 = if (entry.kind == .failed) "Failed" else "Needs rebuild";
+ dvui.labelNoFmt(@src(), msg, .{}, .{ .gravity_y = 0.5, .color_text = theme.color(.err, .text), .font = dvui.Font.theme(.mono) });
+}
+
+/// Best-effort repository URL for a store entry (registry homepage for now). Built-in / sideloaded
+/// plugins gain a `repository` field with the Phase 4a manifest bump.
+fn repoUrl(entry: StoreEntry) ?[]const u8 {
+ if (entry.registry) |r| {
+ if (r.homepage.len > 0) return r.homepage;
+ }
+ return null;
+}
+
+fn drawHeader() !void {
+ var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .h = 6 } });
+ defer hbox.deinit();
+
+ var buf: [96]u8 = undefined;
+ const host_sdk = std.fmt.bufPrint(&buf, "Fizzy SDK {d}.{d}.{d} · ABI 0x{x}", .{
+ version.sdk_version.major,
+ version.sdk_version.minor,
+ version.sdk_version.patch,
+ dylib.abi_fingerprint,
+ }) catch "Fizzy SDK ?";
+ dvui.labelNoFmt(@src(), host_sdk, .{}, .{ .gravity_y = 0.5 });
+
+ if (dvui.button(@src(), "Refresh", .{}, .{ .gravity_x = 1.0 })) {
+ status_len = 0;
+ if (catalog) |*c| c.refresh();
+ probeDisabledNames();
+ }
+
+ if (status_len > 0) {
+ dvui.labelNoFmt(@src(), status_message[0..status_len], .{}, .{
+ .gravity_x = 1.0,
+ .color_text = dvui.themeGet().color(.err, .text),
+ });
+ }
+}
+
+fn removePendingForId(id: []const u8) void {
+ var i: usize = 0;
+ while (i < pending_actions.items.len) {
+ const action = pending_actions.items[i];
+ const matches = switch (action) {
+ .set_enabled => |a| std.mem.eql(u8, a.id, id),
+ .uninstall => |a| std.mem.eql(u8, a.id, id),
+ };
+ if (matches) {
+ switch (action) {
+ .set_enabled => |a| fizzy.app.allocator.free(a.id),
+ .uninstall => |a| fizzy.app.allocator.free(a.id),
+ }
+ _ = pending_actions.orderedRemove(i);
+ } else {
+ i += 1;
+ }
+ }
+}
+
+fn queueSetEnabled(id: []const u8, enabled: bool) void {
+ removePendingForId(id);
+ const dup = fizzy.app.allocator.dupe(u8, id) catch {
+ setStatus("'{s}' could not be queued", .{id});
+ return;
+ };
+ pending_actions.append(fizzy.app.allocator, .{ .set_enabled = .{ .id = dup, .enabled = enabled } }) catch {
+ fizzy.app.allocator.free(dup);
+ setStatus("'{s}' could not be queued", .{id});
+ };
+}
+
+fn queueUninstall(id: []const u8) void {
+ removePendingForId(id);
+ const dup = fizzy.app.allocator.dupe(u8, id) catch {
+ setStatus("'{s}' could not be queued", .{id});
+ return;
+ };
+ pending_actions.append(fizzy.app.allocator, .{ .uninstall = .{ .id = dup } }) catch {
+ fizzy.app.allocator.free(dup);
+ setStatus("'{s}' could not be queued", .{id});
+ };
+}
+
+fn applySetEnabled(id: []const u8, enabled: bool) void {
+ status_len = 0;
+ fizzy.editor.setPluginEnabled(id, enabled, false) catch |err| switch (err) {
+ error.DirtyDocuments => setStatus("'{s}' has unsaved changes — save or close them first", .{id}),
+ else => setStatus("'{s}' could not be {s}: {s}", .{ id, if (enabled) "enabled" else "disabled", @errorName(err) }),
+ };
+}
+
+fn applyUninstall(id: []const u8) void {
+ status_len = 0;
+ fizzy.editor.uninstallPlugin(id, false) catch |err| switch (err) {
+ error.DirtyDocuments => setStatus("'{s}' has unsaved changes — save or close them first", .{id}),
+ else => setStatus("'{s}' could not be uninstalled: {s}", .{ id, @errorName(err) }),
+ };
+}
diff --git a/src/editor/Project.zig b/src/editor/Project.zig
deleted file mode 100644
index f7c63df3..00000000
--- a/src/editor/Project.zig
+++ /dev/null
@@ -1,113 +0,0 @@
-const std = @import("std");
-const builtin = @import("builtin");
-const fizzy = @import("../fizzy.zig");
-const dvui = @import("dvui");
-
-const Project = @This();
-
-pub var parsed: ?std.json.Parsed(Project) = null;
-pub var read: ?[]u8 = null;
-
-/// Path for the final packed texture to save
-packed_image_output: ?[]const u8 = null,
-
-/// Path for the final packed heightmap to save
-//packed_heightmap_output: ?[]const u8 = null,
-
-/// Path for the final packed atlas to save
-packed_atlas_output: ?[]const u8 = null,
-
-/// If true, the entire project will be repacked and exported on any project file save
-pack_on_save: bool = false,
-
-pub fn load(allocator: std.mem.Allocator) !?Project {
- if (comptime builtin.target.cpu.arch == .wasm32) return null;
- if (fizzy.editor.folder) |folder| {
- const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" });
-
- if (fizzy.fs.read(allocator, dvui.io, file) catch null) |r| {
- read = r;
-
- const options = std.json.ParseOptions{ .duplicate_field_behavior = .use_first, .ignore_unknown_fields = true };
- if (std.json.parseFromSlice(Project, allocator, r, options) catch null) |p| {
- parsed = p;
-
- // if (p.value.packed_atlas_output) |packed_atlas_output| {
- // @memcpy(fizzy.editor.buffers.atlas_path[0..packed_atlas_output.len], packed_atlas_output);
- // }
-
- // if (p.value.packed_image_output) |packed_image_output| {
- // @memcpy(fizzy.editor.buffers.texture_path[0..packed_image_output.len], packed_image_output);
- // }
-
- // if (p.value.packed_heightmap_output) |packed_heightmap_output| {
- // @memcpy(fizzy.editor.buffers.heightmap_path[0..packed_heightmap_output.len], packed_heightmap_output);
- // }
-
- return .{
- .packed_atlas_output = if (p.value.packed_atlas_output) |output| allocator.dupe(u8, output) catch null else null,
- .packed_image_output = if (p.value.packed_image_output) |output| allocator.dupe(u8, output) catch null else null,
- .pack_on_save = p.value.pack_on_save,
- };
- } else {
- std.log.debug("Failed to parse project file!", .{});
- }
- }
- }
-
- return null;
-}
-
-pub fn save(project: *Project) !void {
- if (comptime builtin.target.cpu.arch == .wasm32) return;
- if (fizzy.editor.folder) |folder| {
- const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" });
- const options = std.json.Stringify.Options{};
-
- const str = try std.json.Stringify.valueAlloc(fizzy.app.allocator, Project{
- .packed_atlas_output = project.packed_atlas_output,
- .packed_image_output = project.packed_image_output,
- //.packed_heightmap_output = project.packed_heightmap_output,
- .pack_on_save = project.pack_on_save,
- }, options);
-
- try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = file, .data = str });
-
- return;
- }
-
- return error.FailedToSaveProject;
-}
-
-/// Project output assets will be exported to a join of parent_folder and the individual output paths for each asset
-pub fn exportAssets(project: *Project) !void {
- if (project.packed_atlas_output) |packed_atlas_output| {
- try fizzy.editor.atlas.save(packed_atlas_output, .data);
- }
-
- if (project.packed_image_output) |packed_image_output| {
- try fizzy.editor.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);
- // }
-}
-
-pub fn deinit(self: *Project, allocator: std.mem.Allocator) void {
- if (read) |r| allocator.free(r);
-
- if (parsed) |p| {
- p.deinit();
- parsed = null;
- }
-
- if (self.packed_atlas_output) |output| {
- allocator.free(output);
- }
-
- if (self.packed_image_output) |output| {
- allocator.free(output);
- }
-}
diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig
index 83a012df..369eb3ed 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,31 @@ 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,
+
+/// Opacity of the content area (also drives plugin panes that match the shell chrome).
content_opacity: f32 = 0.7,
-/// Checkerboard / transparency tint behind sprites (grid cells).
-transparency_effect: TransparencyEffect = .none,
+/// Plugin ids the user has disabled in the store. Skipped at startup by
+/// `Editor.loadUserPlugins` and unloaded live by `Editor.setPluginEnabled`. The slice
+/// is pointed at an `Editor`-owned list at runtime (see `Editor.disabled_plugin_ids`);
+/// it is only read here for (de)serialization.
+///
+/// Default disables the bundled `example` plugin on a fresh install (it is a template, not a
+/// day-to-day tool). An existing `settings.json` overrides this — once the user enables it the
+/// persisted list no longer contains "example", so the choice sticks.
+disabled_plugins: []const []const u8 = &.{"example"},
titlebar_height: f32 = 26.0, // This is the height of the titlebar in pixels
/// Empty strip below the top window edge (non-macOS), above the main title row (in-window menu, etc.).
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 +83,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);
@@ -143,6 +94,8 @@ pub fn load(allocator: std.mem.Allocator, path: []const u8) !Settings {
const options = std.json.ParseOptions{
.duplicate_field_behavior = .use_first,
.ignore_unknown_fields = true,
+ // Copy *every* parsed string into the parse arena (kept alive in `parsed` until `deinit`).
+ .allocate = .alloc_always,
};
const p = std.json.parseFromSlice(Settings, allocator, data, options) catch |err| {
dvui.log.warn("Could not parse settings.json ({s}); using defaults.", .{@errorName(err)});
@@ -157,13 +110,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..9e7e439c 100644
--- a/src/editor/Sidebar.zig
+++ b/src/editor/Sidebar.zig
@@ -5,10 +5,21 @@ const dvui = @import("dvui");
const App = fizzy.App;
const Editor = fizzy.Editor;
-const Pane = @import("explorer/Explorer.zig").Pane;
+const SidebarView = fizzy.sdk.SidebarView;
+const PluginStore = @import("PluginStore.zig");
pub const Sidebar = @This();
+/// Persisted scroll position for the plugin-icon rail (retained across frames).
+var scroll_info: dvui.ScrollInfo = .{};
+
+/// Shell built-in views pinned to the bottom of the rail (always visible). Everything else —
+/// the plugin-contributed views — scrolls above them in registration (load) order.
+fn isPinned(id: []const u8) bool {
+ return std.mem.eql(u8, id, PluginStore.view_id) or
+ std.mem.eql(u8, id, Editor.view_settings);
+}
+
pub fn init() !Sidebar {
return .{};
}
@@ -32,28 +43,62 @@ 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);
- if (a != .none) ret = a;
+ // Plugin-contributed views scroll in a bounded area (load order). When more icons exist than
+ // fit, an edge shadow hints at the hidden ones — matching the scroll-shadow used elsewhere.
+ {
+ const pane = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .both,
+ .background = false,
+ });
+
+ var scroll = dvui.scrollArea(@src(), .{
+ .scroll_info = &scroll_info,
+ .horizontal_bar = .hide,
+ .vertical_bar = .hide,
+ }, .{
+ .expand = .both,
+ .background = false,
+ });
+
+ for (fizzy.editor.host.sidebar_views.items, 0..) |*view, i| {
+ if (view.hidden or isPinned(view.id)) continue;
+ const a = try drawOption(view, i, 20);
+ if (a != .none) ret = a;
+ }
+
+ const voff = scroll.si.offset(.vertical);
+ const vmax = scroll.si.scrollMax(.vertical);
+ scroll.deinit();
+
+ const cs = pane.data().contentRectScale();
+ if (voff > 0.5) fizzy.dvui.drawEdgeShadow(cs, .top, .{});
+ if (voff < vmax - 0.5) fizzy.dvui.drawEdgeShadow(cs, .bottom, .{});
+
+ pane.deinit();
+ }
+
+ // Plugin store + Settings: pinned to the bottom of the rail, always visible.
+ {
+ var bottom = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .gravity_y = 1.0,
+ .background = false,
+ });
+ defer bottom.deinit();
+
+ for (fizzy.editor.host.sidebar_views.items, 0..) |*view, i| {
+ if (view.hidden or !isPinned(view.id)) continue;
+ const a = try drawOption(view, i, 20);
+ if (a != .none) ret = a;
+ }
}
return ret;
}
-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 +106,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 +125,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 +144,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 +156,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 +190,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/Tools.zig b/src/editor/Tools.zig
deleted file mode 100644
index 68555989..00000000
--- a/src/editor/Tools.zig
+++ /dev/null
@@ -1,447 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const dvui = @import("dvui");
-
-const Tools = @This();
-
-pub const max_brush_size: u32 = 256;
-pub const max_brush_size_float: f32 = @as(f32, @floatFromInt(max_brush_size));
-pub const min_full_stroke_size: u32 = 10;
-
-pub const Tool = enum(u32) {
- pointer,
- pencil,
- eraser,
- bucket,
- selection,
-};
-
-pub const Shape = enum(u32) {
- circle,
- square,
-};
-
-/// Pixel selection uses the brush stroke; box selection uses a rectangular marquee;
-/// color selection flood-fills contiguous pixels of the clicked color on the active layer.
-pub const SelectionMode = enum {
- pixel,
- box,
- color,
-};
-
-pub const RadialMenu = struct {
- mouse_position: dvui.Point.Physical = .{ .x = 0.0, .y = 0.0 },
- center: dvui.Point.Physical = .{ .x = 0.0, .y = 0.0 },
- visible: bool = false,
- /// Opened by press-and-hold on empty workspace (not Space / quick-tools). Both paths pin
- /// `center` at open; this flag only selects hold-specific dismiss behavior.
- opened_by_press: bool = false,
- /// Ignore the first pointer release after a hold-open (lifting the opening finger).
- suppress_next_pointer_release: bool = false,
- /// Press began outside the menu while it is hold-open; used for click-outside dismiss.
- outside_click_press_p: ?dvui.Point.Physical = null,
-
- pub fn close(self: *RadialMenu) void {
- self.visible = false;
- self.opened_by_press = false;
- self.suppress_next_pointer_release = false;
- self.outside_click_press_p = null;
- }
-
- /// Physical hit radius for the radial tool ring (matches `drawRadialMenu` outer disc).
- pub fn hitRadiusPhysical() f32 {
- return 165.0;
- }
-
- pub fn containsPhysical(self: RadialMenu, p: dvui.Point.Physical) bool {
- const r = hitRadiusPhysical();
- const dx = p.x - self.center.x;
- const dy = p.y - self.center.y;
- return dx * dx + dy * dy <= r * r;
- }
-};
-
-pub const default_pencil_stroke_size: u8 = 1;
-pub const default_selection_stroke_size: u8 = 6;
-
-current: Tool = .pointer,
-previous: Tool = .pointer,
-/// The stroke size for the currently active tool. Mirrors either
-/// `pencil_stroke_size` or `selection_stroke_size` depending on `current`.
-stroke_size: u8 = default_pencil_stroke_size,
-/// Independent stroke size used by pencil/eraser/bucket.
-pencil_stroke_size: u8 = default_pencil_stroke_size,
-/// Independent stroke size used by the selection tool.
-selection_stroke_size: u8 = default_selection_stroke_size,
-stroke_shape: Shape = .circle,
-previous_drawing_tool: Tool = .pencil,
-radial_menu: RadialMenu = .{},
-selection_mode: SelectionMode = .box,
-
-stroke: std.StaticBitSet(max_brush_size * max_brush_size) = .initEmpty(),
-offset_table: [][2]f32 = undefined,
-
-pub fn init(allocator: std.mem.Allocator) !Tools {
- var tools: Tools = .{
- .offset_table = try allocator.alloc([2]f32, max_brush_size * max_brush_size),
- };
-
- for (0..(max_brush_size * max_brush_size)) |index| {
- const center: dvui.Point = .{ .x = @floor(max_brush_size_float / 2), .y = @floor(max_brush_size_float / 2) };
- const x: f32 = @as(f32, @floatFromInt(@mod(index, max_brush_size)));
- const y: f32 = @as(f32, @floatFromInt(index)) / max_brush_size_float;
- tools.offset_table[index] = .{ @floor(x - center.x), @floor(y - center.y) };
- }
-
- tools.setStrokeSize(tools.strokeSizeFor(tools.current));
-
- return tools;
-}
-
-/// Returns the stored stroke size for the given tool.
-fn strokeSizeFor(self: *const Tools, tool: Tool) u8 {
- return switch (tool) {
- .selection => self.selection_stroke_size,
- else => self.pencil_stroke_size,
- };
-}
-
-/// Recreates the stroke bitset and writes-through the size to the
-/// per-tool storage for the currently active tool.
-pub fn setStrokeSize(self: *Tools, size: u8) void {
- self.stroke_size = size;
- switch (self.current) {
- .selection => self.selection_stroke_size = size,
- .pencil, .eraser, .bucket => self.pencil_stroke_size = size,
- .pointer => {},
- }
-
- const stroke_size: usize = @intCast(size);
-
- self.stroke.setRangeValue(.{ .start = 0, .end = max_brush_size * max_brush_size }, false);
-
- const center: dvui.Point = .{ .x = @floor(max_brush_size_float / 2), .y = @floor(max_brush_size_float / 2) };
-
- for (0..(stroke_size * stroke_size)) |index| {
- if (self.getIndexShapeOffset(center, index)) |i| {
- self.stroke.set(i);
- }
- }
-}
-
-pub fn deinit(self: *Tools, allocator: std.mem.Allocator) void {
- allocator.free(self.offset_table);
-}
-
-pub fn set(self: *Tools, tool: Tool) void {
- if (self.current != tool) {
- // if (fizzy.editor.getFile(fizzy.editor.open_file_index)) |file| {
- // // if (file.transform_texture != null and tool != .pointer)
- // // return;
-
- // switch (tool) {
- // .heightmap => {
- // file.heightmap.enable();
- // if (file.heightmap.layer == null)
- // return;
- // },
- // .pointer => {
- // file.heightmap.disable();
-
- // // if (self.current == .selection)
- // // file.selection_layer.clear(true);
- // },
- // else => {},
- // }
- // }
- self.previous = self.current;
- switch (self.previous) {
- .pencil, .bucket => |t| self.previous_drawing_tool = t,
- else => {},
- }
- self.current = tool;
- self.setStrokeSize(self.strokeSizeFor(tool));
- if (tool == .pencil or tool == .eraser) {
- fizzy.editor.requestCompositeWarmup();
- }
- }
-}
-
-pub fn swap(self: *Tools) void {
- const temp = self.current;
- self.current = self.previous;
- self.previous = temp;
-}
-
-pub fn getIndex(_: *Tools, point: dvui.Point) ?usize {
- if (point.x < 0 or point.y < 0) {
- return null;
- }
-
- if (point.x >= max_brush_size_float or point.y >= max_brush_size_float) {
- return null;
- }
-
- const p: [2]usize = .{ @intFromFloat(point.x), @intFromFloat(point.y) };
-
- const index = p[0] + p[1] * @as(usize, @intFromFloat(max_brush_size_float));
- if (index >= max_brush_size * max_brush_size) {
- return 0;
- }
- return index;
-}
-
-/// Only used for handling getting the pixels surrounding the origin
-/// for stroke sizes larger than 1
-pub fn getIndexShapeOffset(self: *Tools, origin: dvui.Point, current_index: usize) ?usize {
- const shape = fizzy.editor.tools.stroke_shape;
- const s: i32 = @intCast(fizzy.editor.tools.stroke_size);
-
- if (s == 1) {
- if (current_index != 0)
- return null;
-
- if (self.getIndex(origin)) |index| {
- return index;
- }
- }
-
- const size_center_offset: i32 = -@divFloor(@as(i32, @intCast(s)), 2);
- const index_i32: i32 = @as(i32, @intCast(current_index));
- const pixel_offset: [2]i32 = .{ @mod(index_i32, s) + size_center_offset, @divFloor(index_i32, s) + size_center_offset };
-
- if (shape == .circle) {
- const extra_pixel_offset_circle: [2]i32 = if (@mod(s, 2) == 0) .{ 1, 1 } else .{ 0, 0 };
- const pixel_offset_circle: [2]i32 = .{ pixel_offset[0] * 2 + extra_pixel_offset_circle[0], pixel_offset[1] * 2 + extra_pixel_offset_circle[1] };
- const sqr_magnitude = pixel_offset_circle[0] * pixel_offset_circle[0] + pixel_offset_circle[1] * pixel_offset_circle[1];
-
- // adjust radius check for nicer looking circles
- const radius_check_mult: f32 = (if (s == 3 or s > 10) 0.7 else 0.8);
-
- if (@as(f32, @floatFromInt(sqr_magnitude)) > @as(f32, @floatFromInt(s * s)) * radius_check_mult) {
- return null;
- }
- }
-
- const pixel_i32: [2]i32 = .{ @as(i32, @intFromFloat(origin.x)) + pixel_offset[0], @as(i32, @intFromFloat(origin.y)) + pixel_offset[1] };
- const size_i32: [2]i32 = .{ @as(i32, @intCast(max_brush_size)), @as(i32, @intCast(max_brush_size)) };
-
- if (pixel_i32[0] < 0 or pixel_i32[1] < 0 or pixel_i32[0] >= size_i32[0] or pixel_i32[1] >= size_i32[1]) {
- return null;
- }
-
- const pixel: dvui.Point = .{ .x = @floatFromInt(pixel_i32[0]), .y = @floatFromInt(pixel_i32[1]) };
-
- if (self.getIndex(pixel)) |index| {
- return index;
- }
-
- return null;
-}
-
-pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64) !void {
- const tool_name = switch (tool) {
- .pointer => "POINTER",
- .pencil => "PENCIL",
- .eraser => "ERASER",
- .bucket => "BUCKET",
- .selection => "SELECTION",
- };
-
- const tool_description = switch (tool) {
- .pointer => "Select and move cells, rows, or columns. \n" ++
- "Hold cmd/ctrl to add to selection, and shift to subtract. \n" ++
- "Dragging can add multiple cells at once.",
- .pencil => "Draw on the canvas with the left mouse button.\n" ++
- "Right click to pick up a color from the canvas. \n" ++
- "[ & ] keys increase and decrease the stroke size.",
- .eraser => "Erase on the canvas.\n" ++
- "Right click an empty area to switch to the eraser tool. \n" ++
- "[ & ] keys increase and decrease the erase size.",
- .bucket => "Fill the canvas with a color.\n" ++ "Hold cmd/ctrl to replace all color, non-contiguously.\n",
- .selection => "Pixel mode brushes with stroke size.\nBox mode drags a rectangular marquee.\nColor mode selects contiguous pixels of the clicked color.\n" ++ "Hold cmd/ctrl to add to selection, and shift to subtract.\n",
- };
-
- var tooltip: dvui.FloatingTooltipWidget = undefined;
- tooltip.init(@src(), .{
- .active_rect = rect,
- .delay = 500_000,
- .interactive = if (tool == .selection) true else false,
- }, .{
- .id_extra = @intCast(id_extra),
- .color_fill = dvui.themeGet().color(.content, .fill).opacity(0.9),
- .border = dvui.Rect.all(0),
- .box_shadow = .{
- .color = .black,
- .shrink = 0,
- .corner_radius = dvui.Rect.all(8),
- .offset = .{ .x = 0, .y = 2 },
- .fade = 4,
- .alpha = 0.2,
- },
- });
- defer tooltip.deinit();
-
- if (tooltip.shown()) {
- var animator = dvui.animate(@src(), .{
- .kind = .alpha,
- .duration = 500_000,
- }, .{
- .expand = .both,
- });
- defer animator.deinit();
-
- var vbox2 = dvui.box(@src(), .{ .dir = .vertical }, dvui.FloatingTooltipWidget.defaults.override(.{
- .background = false,
- .expand = .both,
- .border = dvui.Rect.all(0),
- }));
- defer vbox2.deinit();
-
- fizzy.dvui.labelWithKeybind(
- tool_name,
- switch (tool) {
- .pointer => dvui.currentWindow().keybinds.get("pointer") orelse .{},
- .pencil => dvui.currentWindow().keybinds.get("pencil") orelse .{},
- .eraser => dvui.currentWindow().keybinds.get("eraser") orelse .{},
- .bucket => dvui.currentWindow().keybinds.get("bucket") orelse .{},
- .selection => dvui.currentWindow().keybinds.get("selection") orelse .{},
- },
- true,
- .{
- .font = dvui.Font.theme(.heading),
- },
- .{
- .font = dvui.Font.theme(.mono).larger(-2.0),
- .margin = dvui.Rect.all(4),
- },
- );
-
- _ = dvui.separator(@src(), .{ .expand = .horizontal });
-
- dvui.labelNoFmt(@src(), tool_description, .{}, .{
- .font = dvui.Font.theme(.body).larger(-1.0),
- .margin = dvui.Rect.all(4),
- });
-
- if (tool == .selection) {
- _ = dvui.separator(@src(), .{ .expand = .horizontal });
-
- var mode_row = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .gravity_x = 0.5,
- .margin = dvui.Rect.all(4),
- });
- defer mode_row.deinit();
-
- const atlas_size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 };
-
- var mode_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
- mode_color = palette.getDVUIColor(4);
- }
-
- {
- var mode_box = dvui.groupBox(@src(), "SELECTION MODE", .{
- .expand = .horizontal,
- .margin = dvui.Rect.all(4),
- .font = dvui.Font.theme(.heading),
- });
- defer mode_box.deinit();
-
- var mode_arrange_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- });
- defer mode_arrange_box.deinit();
-
- for (0..3) |mi| {
- const mode: SelectionMode = switch (mi) {
- 0 => .box,
- 1 => .pixel,
- 2 => .color,
- else => unreachable,
- };
- const cap = switch (mi) {
- 0 => "BOX",
- 1 => "PIXEL",
- 2 => "COLOR",
- else => unreachable,
- };
- const selected = fizzy.editor.tools.selection_mode == mode;
-
- var mode_col = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .none,
- .margin = dvui.Rect.rect(6, 0, 6, 0),
- .id_extra = @intCast(id_extra * 10 + mi),
- });
- defer mode_col.deinit();
-
- const sprite = switch (mode) {
- .box => 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 uv = dvui.Rect{
- .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w,
- .y = @as(f32, @floatFromInt(sprite.source[1])) / atlas_size.h,
- .w = @as(f32, @floatFromInt(sprite.source[2])) / atlas_size.w,
- .h = @as(f32, @floatFromInt(sprite.source[3])) / atlas_size.h,
- };
-
- dvui.labelNoFmt(@src(), cap, .{}, .{
- .font = dvui.Font.theme(.heading),
- .gravity_x = 0.5,
- .margin = dvui.Rect.rect(0, 0, 0, 6),
- .id_extra = @intCast(id_extra * 10 + mi),
- });
-
- var mode_button: dvui.ButtonWidget = undefined;
- mode_button.init(@src(), .{}, .{
- .expand = .none,
- .min_size_content = .{ .w = 40, .h = 40 },
- .id_extra = @intCast(id_extra * 10 + mi + 1),
- .background = true,
- .corner_radius = dvui.Rect.all(1000),
- .color_fill = if (selected) dvui.themeGet().color(.content, .fill) else .transparent,
- .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
- .box_shadow = if (selected) .{
- .color = .black,
- .offset = .{ .x = -2.5, .y = 2.5 },
- .fade = 4.0,
- .alpha = 0.25,
- .corner_radius = dvui.Rect.all(1000),
- } else null,
- .padding = .all(0),
- });
- defer mode_button.deinit();
-
- if (mode_button.hovered()) {
- mode_button.data().options.color_border = mode_color;
- }
-
- mode_button.processEvents();
- mode_button.drawBackground();
-
- var rs = mode_button.data().contentRectScale();
- const width = @as(f32, @floatFromInt(sprite.source[2])) * rs.s;
- const height = @as(f32, @floatFromInt(sprite.source[3])) * rs.s;
- rs.r.x = @round(rs.r.x + (rs.r.w - width) / 2.0);
- rs.r.y = @round(rs.r.y + (rs.r.h - height) / 2.0);
- rs.r.w = width;
- rs.r.h = height;
-
- dvui.renderImage(fizzy.editor.atlas.source, rs, .{
- .uv = uv,
- .fade = 0.0,
- }) catch {
- std.log.err("Failed to render selection mode icon", .{});
- };
-
- if (mode_button.clicked()) {
- fizzy.editor.tools.selection_mode = mode;
- }
- }
- }
- }
- }
-}
diff --git a/src/editor/Transform.zig b/src/editor/Transform.zig
deleted file mode 100644
index 38d58931..00000000
--- a/src/editor/Transform.zig
+++ /dev/null
@@ -1,280 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const dvui = @import("dvui");
-
-pub const Transform = @This();
-
-/// Points of the transform
-/// 1-4: the corner vertices of the transform
-/// 5: the pivot point, defaulted to the center of the transform
-/// 6: the rotation point
-target_texture: dvui.Texture.Target,
-data_points: [6]dvui.Point,
-track_pivot: bool = false,
-dragging: bool = false,
-active_point: ?TransformPoint = null,
-rotation: f32 = 0.0,
-start_rotation: f32 = 0.0,
-radius: f32 = 0.0,
-file_id: u64,
-layer_id: u64,
-source: dvui.ImageSource,
-ortho: bool = true,
-
-pub fn point(self: *Transform, transform_point: TransformPoint) *dvui.Point {
- return &self.data_points[@intFromEnum(transform_point)];
-}
-
-/// Accepts the current transform and applies it to the currently selected layer
-/// Actively transformed pixels are being copied to the temporary layer for display
-/// During a transform, the temporary layer is not used for anything else
-/// Transform layer contains the pixels being transformed prior to transformation,
-/// and the active layer has had those pixels removed.
-///
-/// Note: `textureReadTarget` reads the full render target; the dominant cost is often GPU→CPU
-/// bandwidth rather than the merge loops below.
-pub fn accept(self: *Transform) void {
- if (fizzy.editor.open_files.getPtr(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 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;
-
- 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;
- // Two passes: undo keys use the pre-write layer; writes are independent per index, so order
- // matches the original interleaved loop without mutating layer between undo decisions.
- for (pix, file.editor.transform_layer.pixels(), layer.pixels(), 0..) |temp_pixel, transform_pixel, layer_pixel, pixel_index| {
- if (layer_pixel[3] != 0) {
- file.buffers.stroke.appendAssumeCapacity(pixel_index, layer_pixel);
- } else if (transform_pixel[3] != 0 or temp_pixel.a != 0) {
- file.buffers.stroke.appendAssumeCapacity(pixel_index, transform_pixel);
- }
- }
- for (pix, 0..) |temp_pixel, pixel_index| {
- if (temp_pixel.a != 0) {
- @memcpy(&layer.pixels()[pixel_index], &[_]u8{ temp_pixel.r, temp_pixel.g, temp_pixel.b, temp_pixel.a });
- }
- }
-
- // Paste / transform accept writes new pixels but does not go through `processSelection`; the
- // overlay uses `selection_layer.mask ∩ active_layer.mask`. Keep the mask aligned with the
- // committed transform so copied/pasted (and moved) pixels show the selection outline.
- if (fizzy.editor.tools.current == .selection) {
- file.editor.selection_layer.clearMask();
- for (pix, 0..) |temp_pixel, pixel_index| {
- if (temp_pixel.a != 0) {
- file.editor.selection_layer.mask.set(pixel_index);
- }
- }
- }
-
- const t_after_loop: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
-
- const t_to_change: i128 = if (fizzy.perf.record) fizzy.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_hist: i128 = if (fizzy.perf.record) fizzy.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();
- }
-
- layer.invalidate();
- file.invalidateActiveLayerTransparencyMaskCache();
- file.editor.transform_layer.clear();
- file.editor.transform_layer.clearMask();
- file.editor.transform_layer.invalidate();
- file.editor.transform = null;
- fizzy.app.allocator.free(fizzy.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| {
- var layer = file.getLayer(self.layer_id) orelse return;
- var iterator = file.editor.transform_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
- while (iterator.next()) |pixel_index| {
- @memcpy(&layer.pixels()[pixel_index], &file.editor.transform_layer.pixels()[pixel_index]);
- }
- layer.invalidate();
- file.invalidateActiveLayerTransparencyMaskCache();
-
- file.editor.transform_layer.clear();
- file.editor.transform_layer.clearMask();
- file.editor.transform_layer.invalidate();
- file.editor.transform = null;
- fizzy.app.allocator.free(fizzy.image.bytes(self.source));
- self.* = undefined;
- }
-}
-
-pub fn updateRadius(self: *Transform) void {
- var radius: f32 = 0.0;
- for (self.data_points[0..4]) |*p| {
- const diff = p.diff(self.point(.pivot).*);
- if (diff.length() > radius) {
- radius = diff.length() + 4;
- }
- }
- self.radius = radius;
-}
-
-pub fn centroid(self: *Transform) dvui.Point {
- var ret = self.data_points[0];
- for (self.data_points[1..4]) |*p| {
- ret.x += p.x;
- ret.y += p.y;
- }
- ret.x /= 4;
- ret.y /= 4;
- return ret;
-}
-
-pub fn move(self: *Transform, delta: dvui.Point) void {
- self.point(.top_left).* = self.point(.top_left).plus(delta);
- self.point(.top_right).* = self.point(.top_right).plus(delta);
- self.point(.bottom_right).* = self.point(.bottom_right).plus(delta);
- self.point(.bottom_left).* = self.point(.bottom_left).plus(delta);
- self.point(.pivot).* = self.point(.pivot).plus(delta);
- self.point(.rotate).* = self.point(.rotate).plus(delta);
-}
-
-pub fn hovered(self: *Transform, data_point: dvui.Point) bool {
- var is_hovered = false;
-
- var path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- path.addPoint(.{ .x = self.point(.top_left).x, .y = self.point(.top_left).y });
- path.addPoint(.{ .x = self.point(.top_right).x, .y = self.point(.top_right).y });
- path.addPoint(.{ .x = self.point(.bottom_right).x, .y = self.point(.bottom_right).y });
- path.addPoint(.{ .x = self.point(.bottom_left).x, .y = self.point(.bottom_left).y });
-
- const cent = self.centroid();
-
- var triangles = path.build().fillConvexTriangles(dvui.currentWindow().arena(), .{
- .center = .{ .x = cent.x, .y = cent.y },
- .color = .white,
- }) catch null;
-
- if (triangles) |*t| {
- t.rotate(.{ .x = self.point(.pivot).x, .y = self.point(.pivot).y }, self.rotation);
-
- const top_left = t.vertexes[0];
- const top_right = t.vertexes[1];
- const bottom_right = t.vertexes[2];
- const bottom_left = t.vertexes[3];
-
- {
- const triangle_1 = [3]dvui.Point{
- .{ .x = top_left.pos.x, .y = top_left.pos.y },
- .{ .x = top_right.pos.x, .y = top_right.pos.y },
- .{ .x = data_point.x, .y = data_point.y },
- };
-
- const triangle_2 = [3]dvui.Point{
- .{ .x = top_right.pos.x, .y = top_right.pos.y },
- .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y },
- .{ .x = data_point.x, .y = data_point.y },
- };
-
- const triangle_3 = [3]dvui.Point{
- .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y },
- .{ .x = top_left.pos.x, .y = top_left.pos.y },
- .{ .x = data_point.x, .y = data_point.y },
- };
-
- const triangle_4 = [3]dvui.Point{
- .{ .x = top_left.pos.x, .y = top_left.pos.y },
- .{ .x = top_right.pos.x, .y = top_right.pos.y },
- .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y },
- };
-
- const area_1 = area(triangle_1);
- const area_2 = area(triangle_2);
- const area_3 = area(triangle_3);
- const area_4 = area(triangle_4);
-
- const combined = area_1 + area_2 + area_3;
- const diff = @abs(combined - area_4);
-
- if (!is_hovered)
- is_hovered = diff < 0.1;
- }
- {
- const triangle_1 = [3]dvui.Point{
- .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y },
- .{ .x = bottom_left.pos.x, .y = bottom_left.pos.y },
- .{ .x = data_point.x, .y = data_point.y },
- };
-
- const triangle_2 = [3]dvui.Point{
- .{ .x = bottom_left.pos.x, .y = bottom_left.pos.y },
- .{ .x = top_left.pos.x, .y = top_left.pos.y },
- .{ .x = data_point.x, .y = data_point.y },
- };
-
- const triangle_3 = [3]dvui.Point{
- .{ .x = top_left.pos.x, .y = top_left.pos.y },
- .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y },
- .{ .x = data_point.x, .y = data_point.y },
- };
-
- const triangle_4 = [3]dvui.Point{
- .{ .x = top_left.pos.x, .y = top_left.pos.y },
- .{ .x = bottom_right.pos.x, .y = bottom_right.pos.y },
- .{ .x = bottom_left.pos.x, .y = bottom_left.pos.y },
- };
-
- const area_1 = area(triangle_1);
- const area_2 = area(triangle_2);
- const area_3 = area(triangle_3);
- const area_4 = area(triangle_4);
-
- const combined = area_1 + area_2 + area_3;
- const diff = @abs(combined - area_4);
-
- if (!is_hovered)
- is_hovered = diff < 0.1;
- }
- }
-
- return is_hovered;
-}
-
-fn area(triangle: [3]dvui.Point) f32 {
- return @abs((triangle[0].x * (triangle[1].y - triangle[2].y) + triangle[1].x * (triangle[2].y - triangle[0].y) + triangle[2].x * (triangle[0].y - triangle[1].y)) / 2.0);
-}
-
-pub const TransformPoint = enum(usize) {
- top_left = 0,
- top_right = 1,
- bottom_right = 2,
- bottom_left = 3,
- pivot = 4,
- rotate = 5,
-};
diff --git a/src/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/Export.zig b/src/editor/dialogs/Export.zig
deleted file mode 100644
index 7f009fe4..00000000
--- a/src/editor/dialogs/Export.zig
+++ /dev/null
@@ -1,1041 +0,0 @@
-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 ExportImageFormat = enum { png, jpg };
-
-const Dialogs = @import("Dialogs.zig");
-
-pub var mode: enum(usize) {
- single,
- animation,
- layer,
- all,
-} = .animation;
-
-pub var scale: f32 = 1.0;
-
-pub var scroll_info: dvui.ScrollInfo = .{
- .horizontal = .auto,
- .vertical = .auto,
-};
-
-pub var scroll_info_full: dvui.ScrollInfo = .{
- .horizontal = .auto,
- .vertical = .auto,
-};
-
-pub const max_size: [2]u32 = .{ 4096, 4096 };
-pub const min_size: [2]u32 = .{ 1, 1 };
-
-pub const min_scale: u32 = 1;
-
-pub var anim_frame_index: usize = 0;
-
-/// Animation to export/preview: uses the animation selected in the editor.
-fn exportAnimationIndex(file: *fizzy.Internal.File) ?usize {
- const idx = file.selected_animation_index orelse return null;
- if (idx >= file.animations.len) return null;
- return idx;
-}
-
-pub fn dialog(id: dvui.Id) anyerror!bool {
- // Export stays non-modal so the user can click the canvas to adjust selections. Switch to
- // the pointer tool on open so marquee/sprite picks work; drawing tools stay off until close.
- if (dvui.firstFrame(id)) {
- fizzy.editor.tools.set(.pointer);
- }
-
- var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both });
- defer outer_box.deinit();
-
- { // Mode selector
-
- var horizontal_box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ .expand = .none, .gravity_x = 0.5, .margin = .all(4) });
- defer horizontal_box.deinit();
-
- const field_names = std.meta.fieldNames(@TypeOf(mode));
-
- for (field_names, 0..) |tag, i| {
- const corner_radius: dvui.Rect = if (i == 0) .{
- .x = 100000,
- .h = 100000,
- } else if (i == field_names.len - 1) .{
- .y = 100000,
- .w = 100000,
- } else .all(0);
-
- var name = dvui.currentWindow().arena().dupe(u8, tag) catch {
- dvui.log.err("Failed to dupe tag {s}", .{tag});
- return false;
- };
- @memcpy(name.ptr, tag);
- name[0] = std.ascii.toUpper(name[0]);
-
- var button: dvui.ButtonWidget = undefined;
- button.init(@src(), .{}, .{
- .corner_radius = corner_radius,
- .id_extra = i,
- .margin = .{ .y = 2, .h = 4 },
- .padding = .all(6),
- .expand = .horizontal,
- .color_fill = if (mode == @as(@TypeOf(mode), @enumFromInt(i))) dvui.themeGet().color(.window, .fill).lighten(-4) else dvui.themeGet().color(.control, .fill),
- .box_shadow = if (i != @intFromEnum(mode)) .{
- .color = .black,
- .offset = .{ .x = 0.0, .y = 2 },
- .fade = 7.0,
- .alpha = 0.2,
- .corner_radius = corner_radius,
- .shrink = 0,
- } else null,
- });
- defer button.deinit();
- if (i != @intFromEnum(mode)) {
- button.processEvents();
- }
-
- var clip_rect = button.data().rectScale().r;
-
- clip_rect.y -= 10000;
- clip_rect.h += 20000;
-
- if (i == 0) {
- clip_rect.x -= 10000;
- clip_rect.w += 10000;
- } else if (i == field_names.len - 1) {
- clip_rect.w += 10000;
- }
-
- const clip = dvui.clip(clip_rect);
- defer dvui.clipSet(clip);
-
- button.drawFocus();
- button.drawBackground();
-
- dvui.labelNoFmt(@src(), name, .{}, .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .color_text = if (mode == @as(@TypeOf(mode), @enumFromInt(i))) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
- .margin = .all(0),
- .padding = .all(0),
- });
-
- if (button.clicked()) {
- mode = @enumFromInt(i);
- if (mode == .animation) {
- anim_frame_index = 0;
- dvui.currentWindow().timerRemove(id);
- }
- // Second layout pass after the scroll+preview id stabilizes; avoids one blank frame.
- dvui.currentWindow().extra_frames_needed = 2;
- }
- }
- }
-
- const mode_valid: bool = switch (mode) {
- .single => try singleDialog(id),
- .animation => try animationDialog(id),
- .layer => try layerDialog(id),
- .all => try allDialog(id),
- };
-
- return mode_valid and (fizzy.editor.activeFile() != null);
-}
-
-pub fn singleDialog(_: dvui.Id) anyerror!bool {
- const max_gif_size: [2]f32 = .{ 1024, 1024 };
- var max_scale: f32 = 16.0;
- var valid: bool = false;
-
- if (fizzy.editor.activeFile()) |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 (file.editor.selected_sprites.findFirstSet()) |sprite_index| {
- renderExportPreviewSprite(file, sprite_index);
- }
- }
-
- exportScaleSlider(max_scale);
-
- if (fizzy.editor.activeFile()) |file| {
- if (file.editor.selected_sprites.findFirstSet() != null) {
- const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale);
- const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
- exportDimensionsLabelForExport(column_width, row_height);
- }
- }
-
- return valid;
-}
-
-pub fn animationDialog(id: dvui.Id) anyerror!bool {
- const max_gif_size: [2]f32 = .{ 1024, 1024 };
- var max_scale: f32 = 16.0;
- var preview_sprite: ?usize = null;
-
- if (fizzy.editor.activeFile()) |file| {
- max_scale = @min(
- @divTrunc(max_gif_size[0], @as(f32, @floatFromInt(file.column_width))),
- @divTrunc(max_gif_size[1], @as(f32, @floatFromInt(file.row_height))),
- );
- if (exportAnimationIndex(file)) |animation_index| {
- const anim = file.animations.get(animation_index);
-
- if (anim.frames.len > 0) {
- if (anim_frame_index >= anim.frames.len) anim_frame_index = 0;
-
- const frame_ms = anim.frames[anim_frame_index].ms;
- if (dvui.timerGet(id) == null) {
- dvui.timer(id, @intCast(frame_ms * 1000));
- } else if (dvui.timerDone(id)) {
- anim_frame_index = (anim_frame_index + 1) % anim.frames.len;
- const next_ms = anim.frames[anim_frame_index].ms;
- dvui.timer(id, @intCast(next_ms * 1000));
- dvui.currentWindow().extra_frames_needed = 1;
- }
-
- preview_sprite = anim.frames[anim_frame_index].sprite_index;
- }
- } else if (file.animations.len == 0) {
- dvui.labelNoFmt(@src(), "This file has no animations.", .{}, .{
- .gravity_x = 0.5,
- .color_text = dvui.themeGet().color(.control, .text),
- .margin = .{ .y = 8, .h = 8 },
- });
- } else {
- dvui.labelNoFmt(@src(), "Select an animation in the editor.", .{}, .{
- .gravity_x = 0.5,
- .color_text = dvui.themeGet().color(.control, .text),
- .margin = .{ .y = 8, .h = 8 },
- });
- }
- }
-
- if (fizzy.editor.activeFile()) |file| {
- if (preview_sprite) |sprite_index| {
- renderExportPreviewSprite(file, sprite_index);
- }
- }
-
- exportScaleSlider(max_scale);
-
- if (preview_sprite) |_| {
- if (fizzy.editor.activeFile()) |file| {
- const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale);
- const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
- exportDimensionsLabelForExport(column_width, row_height);
- }
- }
-
- return preview_sprite != null;
-}
-
-pub fn layerDialog(_: dvui.Id) anyerror!bool {
- if (fizzy.editor.activeFile()) |file| {
- renderExportPreview(file, .layer);
- }
- if (fizzy.editor.activeFile()) |file| {
- exportDimensionsLabelForExport(file.width(), file.height());
- }
- return true;
-}
-
-pub fn allDialog(_: dvui.Id) anyerror!bool {
- if (fizzy.editor.activeFile()) |file| {
- renderExportPreview(file, .composite);
- }
- if (fizzy.editor.activeFile()) |file| {
- exportDimensionsLabelForExport(file.width(), file.height());
- }
- return true;
-}
-
-pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void {
- switch (response) {
- .ok => {
- switch (mode) {
- .animation => {
- const default = blk: {
- const file = fizzy.editor.activeFile() orelse {
- break :blk "animation.gif";
- };
-
- const default_filename: [:0]const u8 = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.gif", .{
- if (exportAnimationIndex(file)) |animation_index| file.animations.items(.name)[animation_index] else "animation",
- }, 0) catch {
- dvui.log.err("Failed to allocate filename", .{});
- return;
- };
-
- break :blk default_filename;
- };
-
- fizzy.backend.showSaveFileDialog(
- saveAnimationCallback,
- &[_]fizzy.backend.DialogFileFilter{.{ .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 sprite_index = file.editor.selected_sprites.findFirstSet() orelse return;
-
- const base = file.spriteExportName(fizzy.app.allocator, sprite_index) catch {
- dvui.log.err("Failed to allocate default export name", .{});
- return;
- };
- defer fizzy.app.allocator.free(base);
-
- const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch {
- dvui.log.err("Failed to allocate filename", .{});
- return;
- };
- defer fizzy.app.allocator.free(default);
-
- fizzy.backend.showSaveFileDialog(
- exportCurrentSpriteCallback,
- &[_]fizzy.backend.DialogFileFilter{
- .{ .name = "PNG", .pattern = "png" },
- .{ .name = "JPEG", .pattern = "jpg;jpeg" },
- },
- default,
- null,
- );
- },
- .layer => {
- const file = fizzy.editor.activeFile() orelse return;
- const base = file.layerExportBaseName(fizzy.app.allocator) catch {
- dvui.log.err("Failed to allocate default export name", .{});
- return;
- };
- defer fizzy.app.allocator.free(base);
-
- const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch {
- dvui.log.err("Failed to allocate filename", .{});
- return;
- };
- defer fizzy.app.allocator.free(default);
-
- fizzy.backend.showSaveFileDialog(
- exportLayerCallback,
- &[_]fizzy.backend.DialogFileFilter{
- .{ .name = "PNG", .pattern = "png" },
- .{ .name = "JPEG", .pattern = "jpg;jpeg" },
- },
- default,
- null,
- );
- },
- .all => {
- const file = fizzy.editor.activeFile() orelse return;
- const base = file.allExportBaseName(fizzy.app.allocator) catch {
- dvui.log.err("Failed to allocate default export name", .{});
- return;
- };
- defer fizzy.app.allocator.free(base);
-
- const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch {
- dvui.log.err("Failed to allocate filename", .{});
- return;
- };
- defer fizzy.app.allocator.free(default);
-
- fizzy.backend.showSaveFileDialog(
- exportAllCallback,
- &[_]fizzy.backend.DialogFileFilter{
- .{ .name = "PNG", .pattern = "png" },
- .{ .name = "JPEG", .pattern = "jpg;jpeg" },
- },
- default,
- null,
- );
- },
- }
- },
- .cancel => {},
- else => {},
- }
-}
-
-/// One call site for the export preview scroll+tile so widget ids (and first-frame layout) stay
-/// stable when switching between Single and Animation. Otherwise `renderLayers` early-outs for
-/// one frame with `content_rs.s == 0` on a fresh scroll id.
-fn renderExportPreviewSprite(file: *fizzy.Internal.File, sprite_index: usize) void {
- const sprite_rect = file.spriteRect(sprite_index);
- const max_size_content: dvui.Size = .{
- .w = (dvui.currentWindow().rect_pixels.w / dvui.currentWindow().natural_scale) / 2,
- .h = (dvui.currentWindow().rect_pixels.h / dvui.currentWindow().natural_scale) / 2.0,
- };
- const min_size_content: dvui.Size = sprite_rect.justSize().scale(scale, dvui.Rect).size();
-
- var scroll_area = dvui.scrollArea(@src(), .{
- .scroll_info = &scroll_info,
- .horizontal_bar = .auto_overlay,
- .vertical_bar = .auto_overlay,
- }, .{
- .background = false,
- .expand = .both,
- .max_size_content = .{ .w = max_size_content.w, .h = max_size_content.h },
- });
- defer scroll_area.deinit();
-
- {
- var box = dvui.box(@src(), .{
- .dir = .horizontal,
- }, .{
- .expand = .none,
- .min_size_content = min_size_content,
- .gravity_x = 0.5,
- });
- defer box.deinit();
-
- const uv = dvui.Rect{
- .x = sprite_rect.x / @as(f32, @floatFromInt(file.width())),
- .y = sprite_rect.y / @as(f32, @floatFromInt(file.height())),
- .w = sprite_rect.w / @as(f32, @floatFromInt(file.width())),
- .h = sprite_rect.h / @as(f32, @floatFromInt(file.height())),
- };
-
- // Same tiled checker + tone as layer/all. Sprite box natural space is (0,0)–(sw×scale,sh×scale)
- // (see `min_size_content`), not file coordinates—geometry must be local, UVs use file `sprite_rect`.
- const local_natural = dvui.Rect{ .x = 0, .y = 0, .w = sprite_rect.w * scale, .h = sprite_rect.h * scale };
- drawCheckerboardCell(file, sprite_index, local_natural, box.data().rectScale());
-
- fizzy.render.renderLayers(.{
- .file = file,
- .rs = box.data().rectScale(),
- .uv = uv,
- }) catch {
- dvui.log.err("Failed to render layers", .{});
- };
- }
-}
-
-fn exportScaleSlider(max_scale_val: f32) void {
- if (dvui.sliderEntry(@src(), "Scale: {d}", .{ .value = &scale, .min = 1, .max = max_scale_val, .interval = 1 }, .{
- .expand = .horizontal,
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = 0.0, .y = 3 },
- .fade = 5.0,
- .alpha = 0.2,
- .corner_radius = .all(100000),
- },
- .color_fill = dvui.themeGet().color(.window, .fill).lighten(-4),
- .color_fill_hover = dvui.themeGet().color(.window, .fill).lighten(2),
- .corner_radius = .all(100000),
- .margin = .all(6),
- })) dvui.currentWindow().extra_frames_needed = 2;
-}
-
-fn exportDimensionsLabelForExport(column_w: u32, row_h: u32) void {
- const entry_font = dvui.Font.theme(.mono).larger(-2);
- Dialogs.drawDimensionsLabel(@src(), column_w, row_h, entry_font, "px", .{ .gravity_x = 0.5 });
-}
-
-const ExportFullPreviewKind = enum { layer, composite };
-
-const CheckerboardPalette = struct {
- tone: dvui.Color,
- c_tl: dvui.Color,
- c_tr: dvui.Color,
- c_bl: dvui.Color,
- c_br: dvui.Color,
-};
-
-fn exportCheckerboardPalette() CheckerboardPalette {
- const tone = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(dvui.currentWindow().alpha);
- const c_tl = tone;
- const c_tr = tone.lerp(.red, 0.18);
- const c_bl = tone.lerp(.blue, 0.12);
- const c_br = c_tr.lerp(c_bl, 0.5);
- return .{ .tone = tone, .c_tl = c_tl, .c_tr = c_tr, .c_bl = c_bl, .c_br = c_br };
-}
-
-fn exportCheckerboardGridColorBilinear(
- c_tl: dvui.Color,
- c_tr: dvui.Color,
- c_bl: dvui.Color,
- c_br: dvui.Color,
- u: f32,
- v: f32,
-) dvui.Color {
- const top = c_tl.lerp(c_tr, u);
- const bottom = c_bl.lerp(c_br, u);
- return top.lerp(bottom, v);
-}
-
-fn exportCheckerboardVertexColor(
- c_tl: dvui.Color,
- c_tr: dvui.Color,
- c_bl: dvui.Color,
- c_br: dvui.Color,
- u: f32,
- v: f32,
- mu: f32,
- mv: f32,
- tone: dvui.Color,
-) dvui.Color {
- const c_corner = exportCheckerboardGridColorBilinear(c_tl, c_tr, c_bl, c_br, u, v);
- const du = u - mu;
- const dv = v - mv;
- const dist = @sqrt(du * du + dv * dv);
- var t = @min(@max(dist * 1.55, 0), 1);
- t = t * t * (3.0 - 2.0 * t);
- return tone.lerp(c_corner, t);
-}
-
-fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color {
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
- var animation_index: ?usize = null;
-
- if (file.selected_animation_index) |selected_animation_index| {
- for (file.animations.items(.frames)[selected_animation_index]) |frame| {
- if (frame.sprite_index == sprite_index) {
- animation_index = selected_animation_index;
- break;
- }
- }
- }
-
- if (animation_index == null) {
- anim_blk: for (file.animations.items(.frames), 0..) |frames, i| {
- for (frames) |frame| {
- if (frame.sprite_index == sprite_index) {
- animation_index = i;
- break :anim_blk;
- }
- }
- }
- }
-
- if (animation_index) |ai| {
- const id = file.animations.get(ai).id;
- return palette.getDVUIColor(@intCast(id));
- }
- }
- return null;
-}
-
-fn exportCheckerboardCellCornerColor(
- file: *fizzy.Internal.File,
- sprite_index: usize,
- pal: CheckerboardPalette,
- u: f32,
- v: f32,
-) dvui.Color {
- switch (fizzy.editor.settings.transparency_effect) {
- .none => return pal.tone,
- .rainbow => return exportCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u, v, 0.5, 0.5, pal.tone),
- .animation => {
- if (exportSpriteAnimationPaletteColor(file, sprite_index)) |ac| {
- const row = file.rowFromIndex(sprite_index);
- const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0);
- const v_cell_top = @as(f32, @floatFromInt(row)) / rows_f;
- const v_cell_bot = @as(f32, @floatFromInt(row + 1)) / rows_f;
- const v_mid = (v_cell_top + v_cell_bot) * 0.5;
- if (v <= v_mid) return pal.tone;
- return pal.tone.lerp(ac, 0.4);
- }
- return pal.tone;
- },
- }
-}
-
-/// One quad per sprite cell, UV 0..1 (matches `FileWidget.drawCheckerboardCellsBatched`).
-fn appendCheckerboardCellQuad(
- builder: *dvui.Triangles.Builder,
- quad_idx: *usize,
- file: *fizzy.Internal.File,
- sprite_index: usize,
- pal: CheckerboardPalette,
- geometry_natural: dvui.Rect,
- rs_box: dvui.RectScale,
-) void {
- if (geometry_natural.w <= 0 or geometry_natural.h <= 0) return;
-
- const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0);
- const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0);
- const col_i = file.columnFromIndex(sprite_index);
- const row_i = file.rowFromIndex(sprite_index);
- const u_left = @as(f32, @floatFromInt(col_i)) / cols_f;
- const u_right = @as(f32, @floatFromInt(col_i + 1)) / cols_f;
- const v_top = @as(f32, @floatFromInt(row_i)) / rows_f;
- const v_bot = @as(f32, @floatFromInt(row_i + 1)) / rows_f;
-
- const r = rs_box.rectToPhysical(geometry_natural);
- const tl = r.topLeft();
- const tr = r.topRight();
- const br = r.bottomRight();
- const bl = r.bottomLeft();
-
- const pma_tl = dvui.Color.PMA.fromColor(exportCheckerboardCellCornerColor(file, sprite_index, pal, u_left, v_top));
- const pma_tr = dvui.Color.PMA.fromColor(exportCheckerboardCellCornerColor(file, sprite_index, pal, u_right, v_top));
- const pma_br = dvui.Color.PMA.fromColor(exportCheckerboardCellCornerColor(file, sprite_index, pal, u_right, v_bot));
- const pma_bl = dvui.Color.PMA.fromColor(exportCheckerboardCellCornerColor(file, sprite_index, pal, u_left, v_bot));
-
- builder.appendVertex(.{ .pos = tl, .col = pma_tl, .uv = .{ 0, 0 } });
- builder.appendVertex(.{ .pos = tr, .col = pma_tr, .uv = .{ 1, 0 } });
- builder.appendVertex(.{ .pos = br, .col = pma_br, .uv = .{ 1, 1 } });
- builder.appendVertex(.{ .pos = bl, .col = pma_bl, .uv = .{ 0, 1 } });
-
- const quad_base: dvui.Vertex.Index = @intCast(quad_idx.* * 4);
- builder.appendTriangles(&.{ quad_base + 1, quad_base + 0, quad_base + 3, quad_base + 1, quad_base + 3, quad_base + 2 });
- quad_idx.* += 1;
-}
-
-fn drawCheckerboardCell(
- file: *fizzy.Internal.File,
- sprite_index: usize,
- geometry_natural: dvui.Rect,
- rs_box: dvui.RectScale,
-) void {
- const tex = file.checkerboardTileTexture() orelse return;
-
- const pal = exportCheckerboardPalette();
- const arena = dvui.currentWindow().arena();
- var builder = dvui.Triangles.Builder.init(arena, 4, 6) catch return;
- defer builder.deinit(arena);
-
- var quad_idx: usize = 0;
- appendCheckerboardCellQuad(&builder, &quad_idx, file, sprite_index, pal, geometry_natural, rs_box);
- if (quad_idx == 0) return;
-
- const triangles = builder.build();
- dvui.renderTriangles(triangles, tex) catch {
- dvui.log.err("Failed to render export preview checkerboard", .{});
- };
-}
-
-fn drawCheckerboardFileGrid(file: *fizzy.Internal.File, rs_box: dvui.RectScale) void {
- const n = file.spriteCount();
- if (n == 0) return;
-
- const tex = file.checkerboardTileTexture() orelse return;
-
- const pal = exportCheckerboardPalette();
- const arena = dvui.currentWindow().arena();
- var builder = dvui.Triangles.Builder.init(arena, n * 4, n * 6) catch return;
- defer builder.deinit(arena);
-
- var quad_idx: usize = 0;
- for (0..n) |i| {
- appendCheckerboardCellQuad(&builder, &quad_idx, file, i, pal, file.spriteRect(i), rs_box);
- }
-
- if (quad_idx == 0) return;
-
- const triangles = builder.build();
- dvui.renderTriangles(triangles, tex) catch {
- dvui.log.err("Failed to render export preview checkerboard", .{});
- };
-}
-
-/// Full-canvas preview at 1:1 logical pixels: checkerboard + either the selected layer only or the
-/// flattened composite (all visible layers). One scroll + box `call site for stable widget ids.
-fn renderExportPreview(file: *fizzy.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 {
- dvui.log.err("Export preview: failed to build layer composite", .{});
- return;
- };
- }
-
- const max_size_content: dvui.Size = .{
- .w = (dvui.currentWindow().rect_pixels.w / dvui.currentWindow().natural_scale) / 2,
- .h = (dvui.currentWindow().rect_pixels.h / dvui.currentWindow().natural_scale) / 2.0,
- };
- const min_size_content: dvui.Size = .{ .w = @floatFromInt(w), .h = @floatFromInt(h) };
-
- var scroll_area = dvui.scrollArea(@src(), .{
- .scroll_info = &scroll_info_full,
- .horizontal_bar = .auto_overlay,
- .vertical_bar = .auto_overlay,
- }, .{
- .background = false,
- .expand = .both,
- .max_size_content = .{ .w = max_size_content.w, .h = max_size_content.h },
- });
- defer scroll_area.deinit();
-
- {
- var box = dvui.box(@src(), .{
- .dir = .horizontal,
- }, .{
- .expand = .none,
- .min_size_content = min_size_content,
- .gravity_x = 0.5,
- });
- defer box.deinit();
-
- drawCheckerboardFileGrid(file, box.data().rectScale());
-
- const full_uv = dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 };
- const rs = box.data().rectScale();
-
- var path_tris: dvui.Path.Builder = .init(fizzy.app.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 {
- return;
- };
- defer tris.deinit(fizzy.app.allocator);
- tris.uvFromRectuv(rs.r, full_uv);
-
- switch (kind) {
- .layer => {
- const layer = file.layers.get(file.selected_layer_index);
- if (layer.visible) {
- if (layer.source.getTexture() catch null) |tex| {
- dvui.renderTriangles(tris, tex) catch {
- dvui.log.err("Failed to render layer for export preview", .{});
- };
- }
- }
- },
- .composite => {
- if (file.editor.layer_composite_target) |ct| {
- if (dvui.Texture.fromTargetTemp(ct) catch null) |ctex| {
- dvui.renderTriangles(tris, ctex) catch {
- dvui.log.err("Failed to draw composite for export preview", .{});
- };
- }
- }
- },
- }
- }
-}
-
-fn writeImageToPath(source: dvui.ImageSource, path: []const u8, format: ExportImageFormat) !void {
- if (comptime builtin.target.cpu.arch == .wasm32) {
- var out = std.Io.Writer.Allocating.init(fizzy.app.allocator);
- errdefer out.deinit();
- switch (format) {
- .png => try fizzy.image.writePngToWriter(source, &out.writer, 0),
- .jpg => try fizzy.image.writeJpgPpiToWriter(source, &out.writer, 0),
- }
- const bytes = try out.toOwnedSlice();
- defer fizzy.app.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),
- }
-}
-
-fn writeGifBytes(path: []const u8, data: []const u8) !void {
- if (comptime builtin.target.cpu.arch == .wasm32) {
- try WebFileIo.downloadBytes(path, data);
- return;
- }
- try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = data });
-}
-
-/// Flatten visible layers for one sprite tile. Layer index `0` is the front (drawn last on canvas);
-/// higher indices sit behind. `blitData` composites its **first** buffer (upper) over the **second** (lower).
-fn compositedSpritePixels(allocator: std.mem.Allocator, file: *fizzy.Internal.File, sprite_index: usize) ![][4]u8 {
- const sprite_rect = file.spriteRect(sprite_index);
- const w: usize = @intFromFloat(sprite_rect.w);
- const h: usize = @intFromFloat(sprite_rect.h);
-
- var front: usize = 0;
- while (front < file.layers.len) : (front += 1) {
- const layer = file.layers.get(front);
- if (!layer.visible) continue;
-
- const pixels = layer.pixelsFromRect(allocator, sprite_rect) orelse continue;
- errdefer allocator.free(pixels);
-
- var behind = front + 1;
- while (behind < file.layers.len) : (behind += 1) {
- const lower = file.layers.get(behind);
- if (!lower.visible) continue;
-
- const layer_pixels = lower.pixelsFromRect(allocator, sprite_rect) orelse continue;
- defer allocator.free(layer_pixels);
-
- fizzy.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true);
- }
-
- return pixels;
- }
-
- return error.NoPixels;
-}
-
-// This is for use with the SDL dialogs, but currently the SDL dialogs dont support sending the default path
-// on macOS, so we are going to use the native dialogs instead.
-pub fn saveAnimationCallback(paths: ?[][:0]const u8) void {
- if (paths) |paths_| {
- for (paths_) |path| {
- createAnimationGif(path) catch |err| {
- dvui.log.err("Failed to save animation: {any}", .{err});
- };
- }
- }
-}
-
-pub fn exportCurrentSpriteCallback(paths: ?[][:0]const u8) void {
- if (paths) |paths_| {
- for (paths_) |path| {
- exportCurrentSprite(path) catch |err| {
- dvui.log.err("Failed to save image: {any}", .{err});
- };
- }
- }
-}
-
-pub fn exportLayerCallback(paths: ?[][:0]const u8) void {
- if (paths) |paths_| {
- for (paths_) |path| {
- exportLayerToPath(path) catch |err| {
- dvui.log.err("Failed to save layer: {any}", .{err});
- };
- }
- }
-}
-
-pub fn exportAllCallback(paths: ?[][:0]const u8) void {
- if (paths) |paths_| {
- for (paths_) |path| {
- exportAllToPath(path) catch |err| {
- dvui.log.err("Failed to save image: {any}", .{err});
- };
- }
- }
-}
-
-pub fn exportCurrentSprite(path: []const u8) anyerror!void {
- const ext = std.fs.path.extension(path);
- const is_png = std.mem.eql(u8, ext, ".png");
- const is_jpg = std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg");
- if (!is_png and !is_jpg) {
- dvui.log.err("Export: File must be .png or .jpg, got {s}", .{ext});
- return error.InvalidExtension;
- }
-
- const file = fizzy.editor.activeFile() orelse {
- dvui.log.err("Export: No active file", .{});
- return error.NoActiveFile;
- };
- const sprite_index = file.editor.selected_sprites.findFirstSet() orelse {
- dvui.log.err("Export: No tile selected", .{});
- return error.NoSelectedTile;
- };
-
- var export_width: u32 = file.column_width;
- var export_height: u32 = file.row_height;
- if (scale != 1.0) {
- export_width = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale);
- export_height = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
- }
-
- const pixels = try compositedSpritePixels(fizzy.app.allocator, file, sprite_index);
- defer fizzy.app.allocator.free(pixels);
-
- if (scale != 1.0) {
- const resized = fizzy.app.allocator.alloc([4]u8, export_width * export_height) catch {
- return error.OutOfMemory;
- };
- defer fizzy.app.allocator.free(resized);
- if (zstbi.resize(
- pixels,
- file.column_width,
- file.row_height,
- resized,
- export_width,
- export_height,
- ) == null) {
- return error.ResizeFailed;
- }
-
- const src: dvui.ImageSource = .{ .pixels = .{
- .rgba = std.mem.sliceAsBytes(resized),
- .width = export_width,
- .height = export_height,
- } };
- const format: ExportImageFormat = if (is_png) .png else .jpg;
- try writeImageToPath(src, path, format);
- } else {
- const src: dvui.ImageSource = .{ .pixels = .{
- .rgba = std.mem.sliceAsBytes(pixels),
- .width = file.column_width,
- .height = file.row_height,
- } };
- const format: ExportImageFormat = if (is_png) .png else .jpg;
- try writeImageToPath(src, path, format);
- }
-}
-
-pub fn exportLayerToPath(path: []const u8) anyerror!void {
- const ext = std.fs.path.extension(path);
- const is_png = std.mem.eql(u8, ext, ".png");
- const is_jpg = std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg");
- if (!is_png and !is_jpg) {
- dvui.log.err("Export: File must be .png, .jpg, or .jpeg, got {s}", .{ext});
- return error.InvalidExtension;
- }
-
- const file = fizzy.editor.activeFile() orelse {
- dvui.log.err("Export: No active file", .{});
- return error.NoActiveFile;
- };
-
- const layer = file.layers.get(file.selected_layer_index);
- const src = layer.source;
- const format: ExportImageFormat = if (is_png) .png else .jpg;
- try writeImageToPath(src, path, format);
-}
-
-pub fn exportAllToPath(path: []const u8) anyerror!void {
- const ext = std.fs.path.extension(path);
- const is_png = std.mem.eql(u8, ext, ".png");
- const is_jpg = std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg");
- if (!is_png and !is_jpg) {
- dvui.log.err("Export: File must be .png, .jpg, or .jpeg, got {s}", .{ext});
- return error.InvalidExtension;
- }
-
- const file = fizzy.editor.activeFile() orelse {
- dvui.log.err("Export: No active file", .{});
- return error.NoActiveFile;
- };
-
- const w = file.width();
- const h = file.height();
- if (w == 0 or h == 0) return error.InvalidImageSize;
-
- try fizzy.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);
- defer {
- const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.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);
- defer tmp_layer.deinit();
-
- const format: ExportImageFormat = if (is_png) .png else .jpg;
- try writeImageToPath(tmp_layer.source, path, format);
-}
-
-pub fn createAnimationGif(path: []const u8) anyerror!void {
- const ext = std.fs.path.extension(path);
- const is_gif = std.mem.eql(u8, ext, ".gif");
-
- if (!is_gif) {
- dvui.log.err("Export: File must end with .gif extension, got {s}", .{ext});
- return error.InvalidExtension;
- }
-
- const file = fizzy.editor.activeFile() orelse {
- dvui.log.err("Export: No active file", .{});
- return error.NoActiveFile;
- };
-
- if (file.animations.len == 0) {
- dvui.log.err("Export: No animations in file", .{});
- return error.NoAnimations;
- }
-
- const animation_index = exportAnimationIndex(file) orelse return error.NoSelectedAnimation;
- {
- const anim: fizzy.Internal.Animation = file.animations.get(animation_index);
-
- var export_width = file.column_width;
- var export_height = file.row_height;
-
- if (scale != 1.0) {
- export_width = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale);
- export_height = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
- }
-
- var handle: msf_gif.MSFGifState = undefined;
- _ = msf_gif.begin(&handle, export_width, export_height);
-
- // Anything less than this number will be considered transparent
- // When resizing, sometimes we see a small outline of the pixels?
- // Only see in some gif readers, but not all.
- msf_gif.msf_gif_alpha_threshold = 240;
-
- for (anim.frames) |frame| {
- const pixels = compositedSpritePixels(fizzy.app.allocator, file, frame.sprite_index) catch |err| {
- if (err == error.NoPixels) continue;
- return err;
- };
- defer fizzy.app.allocator.free(pixels);
-
- { // msf_gif will error if there are only transparent pixels
- const valid = blk: {
- for (pixels) |pixel| {
- if (pixel[3] > msf_gif.msf_gif_alpha_threshold) {
- break :blk true;
- }
- }
-
- break :blk false;
- };
-
- if (!valid) {
- dvui.log.debug("Export: No valid pixels, skipping animation frame", .{});
- continue;
- }
- }
-
- if (scale != 1.0) {
- const resized_pixels = fizzy.app.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);
-
- _ = zstbi.resize(
- pixels,
- file.column_width,
- file.row_height,
- resized_pixels,
- export_width,
- export_height,
- );
-
- _ = msf_gif.frame(&handle, @ptrCast(resized_pixels.ptr), @divTrunc(@as(i32, @intCast(frame.ms)), 10));
- } else {
- _ = msf_gif.frame(&handle, @ptrCast(pixels.ptr), @divTrunc(@as(i32, @intCast(frame.ms)), 10));
- }
- }
-
- const result = msf_gif.end(&handle);
- defer msf_gif.free(result);
-
- if (result.data) |data| {
- writeGifBytes(path, data[0..result.dataSize]) catch {
- dvui.log.err("Failed to write to file {s}", .{path});
- return;
- };
- }
-
- return;
- }
-}
diff --git a/src/editor/dialogs/FlatRasterSaveWarning.zig b/src/editor/dialogs/FlatRasterSaveWarning.zig
deleted file mode 100644
index 26de3119..00000000
--- a/src/editor/dialogs/FlatRasterSaveWarning.zig
+++ /dev/null
@@ -1,174 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
-const dvui = @import("dvui");
-
-/// When `pending_mode == .save_and_close`, resume `Editor.advanceSaveAllQuit` after flat save.
-pub var pending_from_save_all_quit: bool = false;
-
-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 {
- pending_mode = mode;
- if (mode == .editor_save) {
- pending_from_save_all_quit = false;
- }
- var mutex = fizzy.dvui.dialog(@src(), .{
- .displayFn = dialog,
- .callafterFn = callAfter,
- .title = "Save as .fiz or current extension?",
- .ok_label = "",
- .cancel_label = "",
- .resizeable = false,
- .default = .cancel,
- .hide_footer = true,
- .max_size = .{ .w = 520, .h = 300 },
- .header_kind = .warning,
- });
- dvui.dataSet(null, mutex.id, "_flat_raster_file_id", file_id);
- mutex.mutex.unlock(dvui.io);
-}
-
-fn fileRef(file_id: u64) ?*fizzy.Internal.File {
- return fizzy.editor.open_files.getPtr(file_id);
-}
-
-fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool {
- const opts: dvui.Options = .{
- .tab_index = tab_idx,
- .style = style,
- .id_extra = id_extra,
- .box_shadow = .{
- .color = .black,
- .alpha = 0.25,
- .offset = .{ .x = -4, .y = 4 },
- .fade = 8,
- },
- };
- var button: dvui.ButtonWidget = undefined;
- button.init(src, .{}, opts);
- defer button.deinit();
- button.processEvents();
- button.drawFocus();
- button.drawBackground();
- dvui.labelNoFmt(src, label_text, .{}, opts.strip().override(button.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 }));
- return button.clicked();
-}
-
-pub fn dialog(id: dvui.Id) anyerror!bool {
- const file_id = dvui.dataGet(null, id, "_flat_raster_file_id", u64) orelse return false;
- const file = fileRef(file_id) orelse return false;
-
- const ext_raw = std.fs.path.extension(file.path);
- const ext_disp = blk: {
- var buf: [32]u8 = undefined;
- if (ext_raw.len > buf.len) break :blk ext_raw;
- break :blk std.ascii.lowerString(&buf, ext_raw);
- };
-
- const bold_hi = dvui.Font.theme(.body).withWeight(.bold);
- const hi_fill = dvui.themeGet().color(.highlight, .fill);
-
- var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(8) });
- defer outer.deinit();
-
- {
- var tl = dvui.textLayout(@src(), .{}, .{
- .expand = .horizontal,
- .background = false,
- });
- tl.addText("File contains data only compatible with the ", .{ .font = dvui.Font.theme(.body) });
- tl.addText(".fiz", .{ .font = bold_hi, .color_text = hi_fill });
- tl.addText(" extension. Would you like to save a copy of your file as a ", .{ .font = dvui.Font.theme(.body) });
- tl.addText(".fiz", .{ .font = bold_hi, .color_text = hi_fill });
- tl.format(" extension or proceed saving as a {s}?", .{ext_disp}, .{ .font = dvui.Font.theme(.body) });
- tl.deinit();
- }
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 16 } });
-
- var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer row.deinit();
-
- var btn_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .gravity_x = 0.5 });
- defer btn_row.deinit();
-
- if (dialogButton(@src(), ".fiz", .highlight, 1, 0)) {
- try onChooseFizzy(file_id);
- }
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } });
- if (dialogButton(@src(), ext_disp, .control, 2, 1)) {
- try onChooseFlatRaster(file_id);
- }
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } });
- if (dialogButton(@src(), "Cancel", .control, 3, 2)) {
- onCancel();
- }
-
- return true;
-}
-
-fn onChooseFizzy(file_id: u64) !void {
- const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
- fizzy.editor.setActiveFile(idx);
- if (pending_mode == .save_and_close) {
- fizzy.editor.pending_close_file_id = file_id;
- }
- fizzy.dvui.closeFloatingDialogAnchored();
- fizzy.editor.requestSaveAs();
-}
-
-fn onChooseFlatRaster(file_id: u64) !void {
- const f = fileRef(file_id) orelse return;
- switch (pending_mode) {
- .editor_save => {
- fizzy.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);
- } else {
- try f.saveAsync();
- }
- },
- .save_and_close => {
- // Kick off async; close happens once the worker settles (see
- // Editor.tickPendingSaveCloses / advanceSaveAllQuit). When this dialog
- // was reached from save-all quit, route the close through the quit
- // walker's in-flight set so it gates pending_app_close correctly;
- // otherwise this is a single-doc save-and-close.
- f.saveAsync() catch |err| {
- dvui.log.err("Save failed: {s}", .{@errorName(err)});
- if (pending_from_save_all_quit) fizzy.editor.abortSaveAllQuit();
- return;
- };
- if (pending_from_save_all_quit) {
- fizzy.editor.quit_saves_in_flight.put(fizzy.app.allocator, file_id, {}) catch |err| {
- dvui.log.err("Save all quit track: {s}", .{@errorName(err)});
- fizzy.editor.abortSaveAllQuit();
- return;
- };
- fizzy.editor.pending_quit_continue = true;
- } else {
- try fizzy.editor.pending_close_after_save.put(fizzy.app.allocator, file_id, {});
- }
- fizzy.dvui.closeFloatingDialogAnchored();
- },
- }
-}
-
-fn onCancel() void {
- fizzy.editor.cancelPendingSaveDialog();
- fizzy.dvui.closeFloatingDialogAnchored();
-}
-
-pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) !void {
- switch (response) {
- .cancel => fizzy.editor.cancelPendingSaveDialog(),
- else => {},
- }
-}
diff --git a/src/editor/dialogs/GridLayout.zig b/src/editor/dialogs/GridLayout.zig
deleted file mode 100644
index bb55014e..00000000
--- a/src/editor/dialogs/GridLayout.zig
+++ /dev/null
@@ -1,1735 +0,0 @@
-//! "Grid Layout" dialog: change the file's `column_width × row_height` (cell size)
-//! and `columns × rows` (cell count) with a per-cell anchor that decides how each
-//! existing tile is padded into a larger cell or cropped into a smaller one.
-//!
-//! The middle is a horizontal strip: intrinsic-width form column on the left (vertical fill),
-//! preview on the right that expands with the window. The preview uses `CanvasWidget` so
-//! panning / zooming honour `Settings.resolvedPanZoomScheme` (`auto` follows DVUI scroll heuristics).
-
-const 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");
-
-/// Editable grid fields for one mode (Slice vs Resize each keep their own backing).
-pub const GridFormState = struct {
- column_width: u32 = 32,
- row_height: u32 = 32,
- columns: u32 = 1,
- rows: u32 = 1,
-};
-
-/// Resize tab form — module scope so `callAfter` can read it after the window closes.
-pub var resize_form: GridFormState = .{};
-/// Slice tab form — independent from resize so switching pills does not overwrite the other side.
-pub var slice_form: GridFormState = .{};
-/// Index into `anchors`/`anchor_labels`. 4 == .c (centered). Resize mode only.
-pub var anchor_ix: usize = 4;
-
-/// Two top-level operations on the grid:
-/// `.resize` — change cell count and/or cell pixel size; per-cell content is re-anchored
-/// (padded on growth, cropped on shrink) using the user-chosen anchor.
-/// `.slice` — metadata-only grid; own form backing (`slice_form`). Preview draws the full
-/// layer composite (not per-sprite remapping) plus grid overlay.
-pub const Mode = enum { slice, resize };
-pub var mode: Mode = .resize;
-
-// Slice auto-link: previous frame's slice form fields.
-var slice_prev_columns: u32 = 1;
-var slice_prev_rows: u32 = 1;
-var slice_prev_column_width: u32 = 32;
-var slice_prev_row_height: u32 = 32;
-
-var preview_canvas: CanvasWidget = .{};
-
-var left_scroll: dvui.ScrollInfo = .{ .horizontal = .auto };
-/// Middle region only (below the fixed header + mode pill): scrolls when form + preview exceed viewport height.
-var dialog_middle_scroll: dvui.ScrollInfo = .{ .horizontal = .auto, .vertical = .auto };
-
-/// Last preview pane size used for `applyPreferredScaleToHost`; reset in `presetFromFile`.
-var preview_pane_fit_w: f32 = 0;
-var preview_pane_fit_h: f32 = 0;
-
-/// Scroll viewport size from the previous frame — fits scale/center to the real preview port, not a too-large parent rect before layout settles.
-var preview_viewport_fit_w: f32 = 0;
-var preview_viewport_fit_h: f32 = 0;
-
-/// `slice_full_layer` + `nw` + `nh` — when the Slice/Resize tab or pixel size changes, refit even if `nw==preview_last_nw`.
-var preview_fit_key_cache: u64 = 0;
-
-/// Last `{nw, nh}` we applied a preferred scale/fit for; reset in `presetFromFile`.
-var preview_last_nw: u32 = 0;
-var preview_last_nh: u32 = 0;
-
-/// Fade preview after Slice↔Resize or the first fit-key refit on open — not window resize.
-var preview_content_alpha: f32 = 1.0;
-var preview_first_open_fade_pending: bool = false;
-var preview_have_prev_slice_mode: bool = false;
-var preview_prev_slice_full_layer: bool = false;
-
-/// Refit the preview when host/viewport sizes differ by more than this many **logical** pixels.
-/// A threshold of ~1px skipped refits during very slow corner-resize drags (sub-pixel per frame on
-/// a trackpad). Small epsilon tracks real layout drift; fit only runs when dimensions actually move.
-const preview_layout_min_delta: f32 = 0.01;
-
-const anchors: [9]fizzy.math.layout_anchor.LayoutAnchor = .{
- .nw, .n, .ne,
- .w, .c, .e,
- .sw, .s, .se,
-};
-
-const anchor_labels = [_][]const u8{ "NW", "N", "NE", "W", "C", "E", "SW", "S", "SE" };
-
-/// 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 {
- resize_form = .{
- .column_width = file.column_width,
- .row_height = file.row_height,
- .columns = file.columns,
- .rows = file.rows,
- };
- slice_form = resize_form;
- anchor_ix = 4;
- mode = .resize;
- preview_last_nw = 0;
- preview_last_nh = 0;
-
- slice_prev_columns = slice_form.columns;
- slice_prev_rows = slice_form.rows;
- slice_prev_column_width = slice_form.column_width;
- slice_prev_row_height = slice_form.row_height;
-
- // The preview canvas is module-global so its state (scale, origin, prev_size, first/second
- // center flags, scroll viewport) survives across dialog opens. On a re-open the cached
- // `prev_size` matches `data_size` and `second_center` is false, so `install` skips the
- // rescale/recenter pass and the preview ends up offscreen / at a stale zoom. Resetting to
- // a fresh widget forces a fit-to-pane on the next frame.
- preview_canvas = .{ .pointer_scope = .dialog };
- left_scroll = .{ .horizontal = .auto };
- dialog_middle_scroll = .{ .horizontal = .auto, .vertical = .auto };
- preview_pane_fit_w = 0;
- preview_pane_fit_h = 0;
- preview_viewport_fit_w = 0;
- preview_viewport_fit_h = 0;
- preview_fit_key_cache = 0;
- preview_content_alpha = 0.0;
- preview_first_open_fade_pending = true;
- preview_have_prev_slice_mode = false;
-}
-
-/// Same as `Workspace.drawCanvas` / `workspaceMainCanvasVbox` behind the file widget.
-fn workspaceCanvasChromeColor() dvui.Color {
- var content_color = dvui.themeGet().color(.window, .fill);
- 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;
-}
-
-/// Match `FileWidget.drawLayers`: window fill, then content fill (`fade = 1.5`), same order and colors.
-fn drawPreviewViewportBackdrop(rs_box: dvui.RectScale, nw: f32, nh: f32) void {
- if (nw <= 0 or nh <= 0) return;
- const natural = dvui.Rect{ .x = 0, .y = 0, .w = nw, .h = nh };
- const phys = rs_box.rectToPhysical(natural);
- phys.fill(.all(0), .{ .color = dvui.themeGet().color(.window, .fill), .fade = 1.0 });
- phys.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 });
-}
-
-fn previewCheckerboardPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr: dvui.Color, c_bl: dvui.Color, c_br: dvui.Color } {
- const tone = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(dvui.currentWindow().alpha);
- const c_tl = tone;
- const c_tr = tone.lerp(.red, 0.18);
- const c_bl = tone.lerp(.blue, 0.12);
- const c_br = c_tr.lerp(c_bl, 0.5);
- return .{ .tone = tone, .c_tl = c_tl, .c_tr = c_tr, .c_bl = c_bl, .c_br = c_br };
-}
-
-fn previewCheckerboardGridColorBilinear(c_tl: dvui.Color, c_tr: dvui.Color, c_bl: dvui.Color, c_br: dvui.Color, u: f32, v: f32) dvui.Color {
- const top = c_tl.lerp(c_tr, u);
- const bottom = c_bl.lerp(c_br, u);
- return top.lerp(bottom, v);
-}
-
-/// Same rule as `FileWidget.checkerboardVertexColor` (see drawing viewport).
-fn previewCheckerboardVertexColor(
- c_tl: dvui.Color,
- c_tr: dvui.Color,
- c_bl: dvui.Color,
- c_br: dvui.Color,
- u: f32,
- v: f32,
- mu: f32,
- mv: f32,
- tone: dvui.Color,
-) dvui.Color {
- const c_corner = previewCheckerboardGridColorBilinear(c_tl, c_tr, c_bl, c_br, u, v);
- const du = u - mu;
- const dv = v - mv;
- const dist = std.math.sqrt(du * du + dv * dv);
- var t = std.math.clamp(dist * 1.55, 0, 1);
- t = t * t * (3.0 - 2.0 * t);
- return tone.lerp(c_corner, t);
-}
-
-fn updatePreviewCheckerboardMouseUv(cv: *CanvasWidget, nw: f32, nh: f32) dvui.Point {
- const mouse_screen = dvui.currentWindow().mouse_pt;
- var target_mu: f32 = 0.5;
- var target_mv: f32 = 0.5;
- if (cv.rect.contains(mouse_screen)) {
- const md = cv.screen_rect_scale.pointFromPhysical(mouse_screen);
- if (nw > 0) target_mu = std.math.clamp(md.x / nw, 0, 1);
- if (nh > 0) target_mv = std.math.clamp(md.y / nh, 0, 1);
- }
- const prev_uv = dvui.dataGet(null, cv.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 };
- const smooth_t: f32 = 0.15;
- const mu = prev_uv.x + (target_mu - prev_uv.x) * smooth_t;
- const mv = prev_uv.y + (target_mv - prev_uv.y) * smooth_t;
- dvui.dataSet(null, cv.id, "checkerboard_mouse_uv", dvui.Point{ .x = mu, .y = mv });
- return .{ .x = mu, .y = mv };
-}
-
-/// Grid line color: reads clearly on top of the checker / content fill (brighter in dark theme, darker in light).
-fn previewGridLineColor() dvui.Color {
- return dvui.themeGet().color(.window, .text).opacity(if (dvui.themeGet().dark) 0.58 else 0.52);
-}
-
-fn font() dvui.Font {
- return dvui.Font.theme(.body);
-}
-
-/// Checkerboard behind the preview: one quad per grid cell with UV 0..1 (same as
-/// `FileWidget.drawCheckerboardCellsBatched`). Per-cell so vertex colors can vary.
-fn drawCheckerboardPreviewTiled(
- file: *fizzy.Internal.File,
- cv: *CanvasWidget,
- rs_box: dvui.RectScale,
- cols: u32,
- rows: u32,
- cell_w: f32,
- cell_h: f32,
-) void {
- if (cell_w <= 0 or cell_h <= 0 or cols == 0 or rows == 0) return;
-
- const pal = previewCheckerboardPalette();
- const te = fizzy.editor.settings.transparency_effect;
- const cols_f = @max(@as(f32, @floatFromInt(cols)), 1.0);
- const rows_f = @max(@as(f32, @floatFromInt(rows)), 1.0);
- const nw = cell_w * cols_f;
- const nh = cell_h * rows_f;
- const mu_mv = updatePreviewCheckerboardMouseUv(cv, nw, nh);
- const mu = mu_mv.x;
- const mv = mu_mv.y;
-
- const n_cells = @as(usize, cols) * @as(usize, rows);
- const arena = dvui.currentWindow().arena();
- var builder = dvui.Triangles.Builder.init(arena, n_cells * 4, n_cells * 6) catch return;
- defer builder.deinit(arena);
-
- var quad_idx: usize = 0;
- var row: u32 = 0;
- while (row < rows) : (row += 1) {
- var col: u32 = 0;
- while (col < cols) : (col += 1) {
- const natural = dvui.Rect{
- .x = @as(f32, @floatFromInt(col)) * cell_w,
- .y = @as(f32, @floatFromInt(row)) * cell_h,
- .w = cell_w,
- .h = cell_h,
- };
- const r = rs_box.rectToPhysical(natural);
- const tl = r.topLeft();
- const tr = r.topRight();
- const br = r.bottomRight();
- const bl = r.bottomLeft();
-
- const u_left = @as(f32, @floatFromInt(col)) / cols_f;
- const u_right = @as(f32, @floatFromInt(col + 1)) / cols_f;
- const v_top = @as(f32, @floatFromInt(row)) / rows_f;
- const v_bot = @as(f32, @floatFromInt(row + 1)) / rows_f;
-
- switch (te) {
- .rainbow => {
- const p_tl = dvui.Color.PMA.fromColor(previewCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u_left, v_top, mu, mv, pal.tone));
- const p_tr = dvui.Color.PMA.fromColor(previewCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u_right, v_top, mu, mv, pal.tone));
- const p_br = dvui.Color.PMA.fromColor(previewCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u_right, v_bot, mu, mv, pal.tone));
- const p_bl = dvui.Color.PMA.fromColor(previewCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u_left, v_bot, mu, mv, pal.tone));
- builder.appendVertex(.{ .pos = tl, .col = p_tl, .uv = .{ 0, 0 } });
- builder.appendVertex(.{ .pos = tr, .col = p_tr, .uv = .{ 1, 0 } });
- builder.appendVertex(.{ .pos = br, .col = p_br, .uv = .{ 1, 1 } });
- builder.appendVertex(.{ .pos = bl, .col = p_bl, .uv = .{ 0, 1 } });
- },
- .none, .animation => {
- const pma = dvui.Color.PMA.fromColor(pal.tone);
- builder.appendVertex(.{ .pos = tl, .col = pma, .uv = .{ 0, 0 } });
- builder.appendVertex(.{ .pos = tr, .col = pma, .uv = .{ 1, 0 } });
- builder.appendVertex(.{ .pos = br, .col = pma, .uv = .{ 1, 1 } });
- builder.appendVertex(.{ .pos = bl, .col = pma, .uv = .{ 0, 1 } });
- },
- }
-
- const quad_base: dvui.Vertex.Index = @intCast(quad_idx * 4);
- builder.appendTriangles(&.{ quad_base + 1, quad_base + 0, quad_base + 3, quad_base + 1, quad_base + 3, quad_base + 2 });
- quad_idx += 1;
- }
- }
-
- if (quad_idx == 0) return;
-
- const triangles = builder.build();
- dvui.renderTriangles(triangles, file.checkerboardTileTexture()) catch {
- dvui.log.err("Grid layout preview: failed to render checkerboard", .{});
- };
-}
-
-fn appendGridLineQuad(builder: *dvui.Triangles.Builder, tl: dvui.Point.Physical, br: dvui.Point.Physical, col: dvui.Color.PMA) void {
- const base: dvui.Vertex.Index = @intCast(builder.vertexes.items.len);
- builder.appendVertex(.{ .pos = tl, .col = col });
- builder.appendVertex(.{ .pos = .{ .x = br.x, .y = tl.y }, .col = col });
- builder.appendVertex(.{ .pos = br, .col = col });
- builder.appendVertex(.{ .pos = .{ .x = tl.x, .y = br.y }, .col = col });
- builder.appendTriangles(&.{ base, base + 1, base + 2, base, base + 2, base + 3 });
-}
-
-fn drawPreviewGridOverlay(
- rs_box: dvui.RectScale,
- nw: f32,
- nh: f32,
- cols_vis: usize,
- rows_vis: usize,
- proto_cell_w: f32,
- proto_cell_h: f32,
- canvas_scale: f32,
- grid_color: dvui.Color,
-) void {
- var line_slots: usize = 0;
- if (cols_vis > 1) line_slots += cols_vis - 1;
- if (rows_vis > 1) line_slots += rows_vis - 1;
- if (line_slots == 0) return;
-
- // Each grid line is drawn twice: a 2×-thick content-fill understroke, then the text-colored stroke on top.
- var builder = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), line_slots * 8, line_slots * 12) catch return;
- defer builder.deinit(dvui.currentWindow().arena());
-
- const cw = dvui.currentWindow();
- const grid_thickness = std.math.clamp(cw.natural_scale * canvas_scale, 0, cw.natural_scale);
- const rs_den = @max(rs_box.s, 0.0001);
-
- const half_phys_fg = @max(grid_thickness, 1.0) * 0.5;
- const half_nat_fg = half_phys_fg / rs_den;
-
- const half_phys_halo = @max(grid_thickness, 1.0);
- const half_nat_halo = half_phys_halo / rs_den;
-
- const pma_halo: dvui.Color.PMA = .fromColor(dvui.themeGet().color(.content, .fill).opacity(cw.alpha));
- const pma_fg: dvui.Color.PMA = .fromColor(grid_color.opacity(cw.alpha));
-
- const grid_passes = [_]struct { half_nat: f32, col: dvui.Color.PMA }{
- .{ .half_nat = half_nat_halo, .col = pma_halo },
- .{ .half_nat = half_nat_fg, .col = pma_fg },
- };
-
- var ix: usize = 1;
- while (ix < cols_vis) : (ix += 1) {
- const xf = @as(f32, @floatFromInt(ix)) * proto_cell_w;
- for (grid_passes) |pass| {
- const r_phys = rs_box.rectToPhysical(.{
- .x = xf - pass.half_nat,
- .y = 0,
- .w = pass.half_nat * 2,
- .h = nh,
- });
- appendGridLineQuad(&builder, .{ .x = r_phys.x, .y = r_phys.y }, .{ .x = r_phys.x + r_phys.w, .y = r_phys.y + r_phys.h }, pass.col);
- }
- }
-
- var iy: usize = 1;
- while (iy < rows_vis) : (iy += 1) {
- const yf = @as(f32, @floatFromInt(iy)) * proto_cell_h;
- for (grid_passes) |pass| {
- const r_phys = rs_box.rectToPhysical(.{
- .x = 0,
- .y = yf - pass.half_nat,
- .w = nw,
- .h = pass.half_nat * 2,
- });
- appendGridLineQuad(&builder, .{ .x = r_phys.x, .y = r_phys.y }, .{ .x = r_phys.x + r_phys.w, .y = r_phys.y + r_phys.h }, pass.col);
- }
- }
-
- const tris = builder.build_unowned();
- dvui.renderTriangles(tris, null) catch {
- dvui.log.err("Grid layout preview: failed to render grid overlay", .{});
- };
-}
-
-fn appendTexturedRectQuad(
- builder: *dvui.Triangles.Builder,
- dest_phys: dvui.Rect.Physical,
- uv: dvui.Rect,
- tint: dvui.Color.PMA,
-) void {
- const base: dvui.Vertex.Index = @intCast(builder.vertexes.items.len);
- const tl = dest_phys.topLeft();
- const tr = dest_phys.topRight();
- const br = dest_phys.bottomRight();
- const bl = dest_phys.bottomLeft();
- builder.appendVertex(.{ .pos = tl, .col = tint, .uv = .{ uv.x, uv.y } });
- builder.appendVertex(.{ .pos = tr, .col = tint, .uv = .{ uv.x + uv.w, uv.y } });
- builder.appendVertex(.{ .pos = br, .col = tint, .uv = .{ uv.x + uv.w, uv.y + uv.h } });
- builder.appendVertex(.{ .pos = bl, .col = tint, .uv = .{ uv.x, uv.y + uv.h } });
- builder.appendTriangles(&.{ base + 1, base, base + 3, base + 1, base + 3, base + 2 });
-}
-
-/// Samples the layer composite texture per **old grid cell**, mapping each sprite through `cellAnchoredBlit`
-/// so the preview matches the result of `applyGridLayout` independently in every tile.
-fn drawCompositePreviewPerCells(
- file: *fizzy.Internal.File,
- rs_box: dvui.RectScale,
- old_cols: u32,
- old_rows: u32,
- old_cw: u32,
- old_rh: u32,
- new_cols: u32,
- new_rows: u32,
- new_cw_: u32,
- new_rh_: u32,
- anchor_vis: fizzy.math.layout_anchor.LayoutAnchor,
-) void {
- fizzy.render.syncLayerComposite(file) catch {
- dvui.log.err("Grid layout preview: composite failed", .{});
- return;
- };
- const ct = file.editor.layer_composite_target orelse return;
- const ctex = dvui.Texture.fromTargetTemp(ct) catch return;
-
- const fw_f = @as(f32, @floatFromInt(ct.width));
- const fh_f = @as(f32, @floatFromInt(ct.height));
-
- const visible_cols = @min(new_cols, old_cols);
- const visible_rows = @min(new_rows, old_rows);
- if (visible_cols == 0 or visible_rows == 0) return;
-
- const quad_count: u32 = visible_cols * visible_rows;
- const arena = dvui.currentWindow().arena();
- var builder = dvui.Triangles.Builder.init(arena, quad_count * 4, quad_count * 6) catch return;
- defer builder.deinit(arena);
-
- const tint = dvui.Color.PMA.fromColor(dvui.Color.white.opacity(dvui.currentWindow().alpha));
- const blk = fizzy.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw_, new_rh_, anchor_vis);
- if (blk.sw == 0 or blk.sh == 0) return;
-
- var nrow: u32 = 0;
- while (nrow < visible_rows) : (nrow += 1) {
- var ncol: u32 = 0;
- while (ncol < visible_cols) : (ncol += 1) {
- const dest_natural = dvui.Rect{
- .x = @as(f32, @floatFromInt(ncol * new_cw_ + blk.dx)),
- .y = @as(f32, @floatFromInt(nrow * new_rh_ + blk.dy)),
- .w = @as(f32, @floatFromInt(blk.sw)),
- .h = @as(f32, @floatFromInt(blk.sh)),
- };
- const x0_px = ncol * old_cw + blk.sx;
- const y0_px = nrow * old_rh + blk.sy;
- const uv = dvui.Rect{
- .x = @as(f32, @floatFromInt(x0_px)) / fw_f,
- .y = @as(f32, @floatFromInt(y0_px)) / fh_f,
- .w = @as(f32, @floatFromInt(blk.sw)) / fw_f,
- .h = @as(f32, @floatFromInt(blk.sh)) / fh_f,
- };
- const dest_phys = rs_box.rectToPhysical(dest_natural);
- appendTexturedRectQuad(&builder, dest_phys, uv, tint);
- }
- }
-
- const tris = builder.build_unowned();
- dvui.renderTriangles(tris, ctex) catch {
- dvui.log.err("Grid layout preview: failed to render batched composite", .{});
- };
-}
-
-/// One quad for the full layer composite (slice preview — no per-cell remapping).
-fn drawCompositePreviewFullLayer(file: *fizzy.Internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void {
- if (nw <= 0 or nh <= 0) return;
- fizzy.render.syncLayerComposite(file) catch {
- dvui.log.err("Grid layout preview: composite failed", .{});
- return;
- };
- const ct = file.editor.layer_composite_target orelse return;
- const ctex = dvui.Texture.fromTargetTemp(ct) catch return;
-
- const dest_natural = dvui.Rect{ .x = 0, .y = 0, .w = nw, .h = nh };
- const dest_phys = rs_box.rectToPhysical(dest_natural);
- const tint = dvui.Color.PMA.fromColor(dvui.Color.white.opacity(dvui.currentWindow().alpha));
- const uv = dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 };
-
- var builder = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), 4, 6) catch return;
- defer builder.deinit(dvui.currentWindow().arena());
- appendTexturedRectQuad(&builder, dest_phys, uv, tint);
- const tris = builder.build_unowned();
- dvui.renderTriangles(tris, ctex) catch {
- dvui.log.err("Grid layout preview: failed to render full composite", .{});
- };
-}
-
-/// When entering Slice, keep the current form values if they already tile the layer exactly;
-/// otherwise snap from the file's authoritative grid (never force 1×1 unless metadata disagrees
-/// with pixel dimensions).
-fn harmonizeSliceStateWithLayer(file: *fizzy.Internal.File) void {
- const canvas = file.canvasPixelSize();
- const tw = canvas.w;
- const th = canvas.h;
- if (tw == 0 or th == 0) return;
- const s = &slice_form;
- const form_tiles_layer = s.columns > 0 and s.column_width > 0 and s.rows > 0 and s.row_height > 0 and
- s.columns * s.column_width == tw and s.rows * s.row_height == th;
-
- if (!form_tiles_layer) {
- s.column_width = file.column_width;
- s.row_height = file.row_height;
- s.columns = file.columns;
- s.rows = file.rows;
- if (!(s.columns * s.column_width == tw and s.rows * s.row_height == th)) {
- s.columns = 1;
- s.rows = 1;
- s.column_width = tw;
- s.row_height = th;
- }
- }
- slice_prev_columns = s.columns;
- slice_prev_rows = s.rows;
- slice_prev_column_width = s.column_width;
- slice_prev_row_height = s.row_height;
-}
-
-fn renderPreview(
- mutex_id: dvui.Id,
- dlg_id: dvui.Id,
- file: *fizzy.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,
- slice_full_layer: bool,
- host_rect: dvui.Rect,
-) void {
- if (nw == 0 or nh == 0) return;
-
- const old_cols = file.columns;
- const old_rows = file.rows;
- const old_cw = file.column_width;
- const old_rh = file.row_height;
-
- // Prefer the live host rect (this frame's parent contentRect) so resize tracks immediately;
- // fall back to last frame's viewport before the parent has a real rect (first frame open).
- const live_host_ok = host_rect.w > 8 and host_rect.h > 8;
- const vp_host_w = if (live_host_ok) host_rect.w else preview_pane_fit_w;
- const vp_host_h = if (live_host_ok) host_rect.h else preview_pane_fit_h;
- const host_vp_ok = vp_host_w > 8 and vp_host_h > 8;
-
- const fit_key: u64 = (@as(u64, @intFromBool(slice_full_layer)) << 63) |
- (@as(u64, @intCast(nw)) << 32) |
- @as(u64, @intCast(nh));
- const fit_key_changed = fit_key != preview_fit_key_cache;
- if (fit_key_changed) {
- preview_viewport_fit_w = 0;
- preview_viewport_fit_h = 0;
- preview_canvas.scroll_info.viewport.x = 0;
- preview_canvas.scroll_info.viewport.y = 0;
- }
-
- const dims_changed = nw != preview_last_nw or nh != preview_last_nh;
-
- const shell_resize_drag = blk: {
- const wid = dvui.dataGet(null, mutex_id, "_grid_layout_float_wd_id", dvui.Id) orelse break :blk false;
- break :blk FloatingWindowWidget.DragPart.isResizeDrag(wid);
- };
-
- const host_changed = host_vp_ok and (preview_pane_fit_w < 4 or preview_pane_fit_h < 4 or
- @abs(vp_host_w - preview_pane_fit_w) >= preview_layout_min_delta or
- @abs(vp_host_h - preview_pane_fit_h) >= preview_layout_min_delta);
- const needs_preinstall_refit = host_vp_ok and (fit_key_changed or dims_changed or host_changed or shell_resize_drag);
-
- const preview_data: dvui.Size = .{ .w = @floatFromInt(nw), .h = @floatFromInt(nh) };
-
- const reset_viewport_after_fit = fit_key_changed or dims_changed or host_changed or shell_resize_drag;
-
- if (needs_preinstall_refit) {
- preview_canvas.fitContentContainInHost(
- preview_data,
- dvui.Rect{ .x = 0, .y = 0, .w = vp_host_w, .h = vp_host_h },
- 1.2,
- );
- if (reset_viewport_after_fit) {
- preview_canvas.scroll_info.viewport.x = 0;
- preview_canvas.scroll_info.viewport.y = 0;
- }
- if (fit_key_changed) {
- preview_fit_key_cache = fit_key;
- }
- if (dims_changed) {
- preview_last_nw = nw;
- preview_last_nh = nh;
- }
- dvui.refresh(null, @src(), preview_canvas.id);
- }
-
- // `CanvasWidget.install` rescale/recenter uses `parentGet()` under the scroll/scaler — wrong
- // for this dialog. Skip that branch; scale/center from real scroll viewport.
- preview_canvas.prev_size = .{ .w = @floatFromInt(nw), .h = @floatFromInt(nh) };
-
- preview_canvas.install(@src(), .{
- .id = dlg_id.update("glp_cv"),
- .data_size = .{ .w = @floatFromInt(nw), .h = @floatFromInt(nh) },
- .center = false,
- }, .{
- .expand = .both,
- .background = true,
- .color_fill = workspaceCanvasChromeColor(),
- });
- defer preview_canvas.deinit();
-
- const vpw = preview_canvas.scroll_info.viewport.w;
- const vph = preview_canvas.scroll_info.viewport.h;
-
- const vp_ok = vpw > 8 and vph > 8;
-
- var did_post_install_refit = false;
- const needs_bootstrap_refit = !host_vp_ok and vp_ok and (fit_key_changed or dims_changed);
- const needs_post_install_host_refit = vp_ok and (host_changed or shell_resize_drag);
- if (needs_bootstrap_refit or needs_post_install_host_refit) {
- preview_canvas.fitContentContainInHost(
- preview_data,
- dvui.Rect{ .x = 0, .y = 0, .w = vpw, .h = vph },
- 1.2,
- );
- if (reset_viewport_after_fit or needs_post_install_host_refit) {
- preview_canvas.scroll_info.viewport.x = 0;
- preview_canvas.scroll_info.viewport.y = 0;
- }
- if (fit_key_changed) {
- preview_fit_key_cache = fit_key;
- }
- if (dims_changed) {
- preview_last_nw = nw;
- preview_last_nh = nh;
- }
- did_post_install_refit = true;
- preview_canvas.syncTransformCachesFromWidgets();
- dvui.refresh(null, @src(), preview_canvas.id);
- }
-
- // `CanvasWidget.install` restores this snapshot while the parent rect is mid-resize; keep
- // origin and viewport in sync so the preview stays centered, not pinned to the upper-left.
- if (needs_preinstall_refit or did_post_install_refit) {
- preview_canvas.stable_origin = preview_canvas.origin;
- preview_canvas.stable_viewport = preview_canvas.scroll_info.viewport;
- preview_canvas.stable_virtual_size = preview_canvas.scroll_info.virtual_size;
- preview_canvas.has_stable_snapshot = true;
- }
-
- preview_viewport_fit_w = vpw;
- preview_viewport_fit_h = vph;
- if (host_vp_ok) {
- preview_pane_fit_w = vp_host_w;
- preview_pane_fit_h = vp_host_h;
- }
-
- const any_refit = needs_preinstall_refit or did_post_install_refit;
-
- {
- const slice_mode_changed = preview_have_prev_slice_mode and
- (preview_prev_slice_full_layer != slice_full_layer);
- const refit_triggers_fade = slice_mode_changed or
- (preview_first_open_fade_pending and fit_key_changed);
- const should_zero_fade_alpha = vp_ok and any_refit and refit_triggers_fade;
-
- const dt = @min(@max(dvui.secondsSinceLastFrame(), 0.0), 0.05);
- if (should_zero_fade_alpha) {
- preview_content_alpha = 0.0;
- } else if (vp_ok and preview_content_alpha < 1.0) {
- const fade_s: f32 = 0.1;
- preview_content_alpha = @min(1.0, preview_content_alpha + dt / fade_s);
- }
- if (preview_first_open_fade_pending and preview_content_alpha >= 1.0) {
- preview_first_open_fade_pending = false;
- }
- if (preview_content_alpha < 1.0) {
- dvui.refresh(null, @src(), preview_canvas.id);
- }
- }
-
- preview_prev_slice_full_layer = slice_full_layer;
- preview_have_prev_slice_mode = true;
-
- const preview_alpha_saved = dvui.alpha(preview_content_alpha);
- defer dvui.alphaSet(preview_alpha_saved);
-
- // Drop shadow under the preview texture, mirroring `FileWidget.drawLayers` so the preview
- // reads as a "document" floating over the dialog's right pane.
- {
- const scale = @max(preview_canvas.scale, 0.0001);
- const shadow_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .rect = .{ .x = 0, .y = 0, .w = @floatFromInt(nw), .h = @floatFromInt(nh) },
- .border = dvui.Rect.all(0),
- .box_shadow = .{
- .fade = 20 * 1 / scale,
- .corner_radius = dvui.Rect.all(2 * 1 / scale),
- .alpha = if (dvui.themeGet().dark) 0.4 else 0.2,
- .offset = .{
- .x = 2 * 1 / scale,
- .y = 2 * 1 / scale,
- },
- },
- });
- shadow_box.deinit();
- }
-
- const nw_f: f32 = @floatFromInt(nw);
- const nh_f: f32 = @floatFromInt(nh);
- const rs = preview_canvas.screen_rect_scale;
- drawPreviewViewportBackdrop(rs, nw_f, nh_f);
- drawCheckerboardPreviewTiled(
- file,
- &preview_canvas,
- rs,
- @max(new_cols, 1),
- @max(new_rows, 1),
- @floatFromInt(new_cw_),
- @floatFromInt(new_rh_),
- );
-
- if (slice_full_layer) {
- drawCompositePreviewFullLayer(file, rs, @floatFromInt(nw), @floatFromInt(nh));
- } else {
- drawCompositePreviewPerCells(
- file,
- rs,
- old_cols,
- old_rows,
- old_cw,
- old_rh,
- new_cols,
- new_rows,
- new_cw_,
- new_rh_,
- anchor_vis,
- );
- }
-
- const grid_col = previewGridLineColor();
- drawPreviewGridOverlay(
- rs,
- nw_f,
- nh_f,
- @max(new_cols, 1),
- @max(new_rows, 1),
- @floatFromInt(new_cw_),
- @floatFromInt(new_rh_),
- preview_canvas.scale,
- grid_col,
- );
-
- // Same order as `FileWidget`: draw first, then scroll/zoom input (wheel applies next frame).
- preview_canvas.processEvents();
-}
-
-/// Slice/Resize mode pill — lives in the dialog shell header strip (non-scrolling).
-fn gridLayoutDrawModePill(dlg_id: dvui.Id) void {
- const file_id_for_dialog = dvui.dataGet(null, dlg_id, "_grid_layout_file_id", u64);
-
- var horizontal_box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{
- .expand = .none,
- .gravity_x = 0.5,
- .margin = .all(4),
- });
- defer horizontal_box.deinit();
-
- const field_names = std.meta.fieldNames(@TypeOf(mode));
-
- for (field_names, 0..) |tag, i| {
- const corner_radius: dvui.Rect = if (i == 0) .{
- .x = 100000,
- .h = 100000,
- } else if (i == field_names.len - 1) .{
- .y = 100000,
- .w = 100000,
- } else .all(0);
-
- var name = dvui.currentWindow().arena().dupe(u8, tag) catch {
- dvui.log.err("Failed to dupe tag {s}", .{tag});
- return;
- };
- @memcpy(name.ptr, tag);
- name[0] = std.ascii.toUpper(name[0]);
-
- var button: dvui.ButtonWidget = undefined;
- button.init(@src(), .{}, .{
- .corner_radius = corner_radius,
- .id_extra = i,
- .margin = .{ .y = 2, .h = 4 },
- .padding = .{ .x = 12, .y = 6, .w = 12, .h = 6 },
- .expand = .horizontal,
- .color_fill = if (mode == @as(@TypeOf(mode), @enumFromInt(i))) dvui.themeGet().color(.window, .fill).lighten(-4) else dvui.themeGet().color(.control, .fill),
- .box_shadow = if (i != @intFromEnum(mode)) .{
- .color = .black,
- .offset = .{ .x = 0.0, .y = 2 },
- .fade = 7.0,
- .alpha = 0.2,
- .corner_radius = corner_radius,
- .shrink = 0,
- } else null,
- });
- defer button.deinit();
- if (i != @intFromEnum(mode)) {
- button.processEvents();
- }
-
- var clip_rect = button.data().rectScale().r;
-
- clip_rect.y -= 10000;
- clip_rect.h += 20000;
-
- if (i == 0) {
- clip_rect.x -= 10000;
- clip_rect.w += 10000;
- } else if (i == field_names.len - 1) {
- clip_rect.w += 10000;
- }
-
- const clip = dvui.clip(clip_rect);
- defer dvui.clipSet(clip);
-
- button.drawFocus();
- button.drawBackground();
-
- dvui.labelNoFmt(@src(), name, .{}, .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .color_text = if (mode == @as(@TypeOf(mode), @enumFromInt(i))) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
- .margin = .all(0),
- .padding = .all(0),
- });
-
- if (button.clicked()) {
- const new_mode: Mode = @enumFromInt(i);
- if (new_mode == .slice and mode != .slice) {
- if (file_id_for_dialog) |fid| if (fizzy.editor.open_files.getPtr(fid)) |tf|
- harmonizeSliceStateWithLayer(tf);
- }
- mode = new_mode;
- dvui.currentWindow().extra_frames_needed = 2;
- }
- }
-}
-
-/// Returns true while the form input is valid AND differs from the active file's current
-/// grid (column_width / row_height / columns / rows). The dialog framework uses this to enable/disable
-/// the OK button — re-applying an identical grid is a no-op so we disable accept rather than invoke.
-pub fn dialog(id: dvui.Id) anyerror!bool {
- const form_font = font();
-
- const file_id_for_dialog = dvui.dataGet(null, id, "_grid_layout_file_id", u64);
- const target_file: ?*fizzy.Internal.File = if (file_id_for_dialog) |fid|
- fizzy.editor.open_files.getPtr(fid)
- else
- null;
-
- const unique_id = id.update("grid_layout");
-
- var valid: bool = true;
-
- // While opening, `windowFn` runs autoSize — allow the scroll area to report full content height
- // so the dialog grows to fit (up to main window size). After open, cap reported min height so
- // a short user resize does not push the footer off-screen (see DVUI `scrolling.zig` main_area).
- const grid_dialog_open_done = dvui.dataGet(null, id, "_grid_dialog_open_done", bool) orelse false;
- const mid_scroll_max: dvui.Options.MaxSize = if (grid_dialog_open_done)
- .height(0)
- else
- .height(dvui.max_float_safe);
-
- var mid_scroll = dvui.scrollArea(@src(), .{
- .scroll_info = &dialog_middle_scroll,
- .horizontal_bar = .auto_overlay,
- }, .{
- .expand = .both,
- .gravity_y = 0,
- .background = false,
- .max_size_content = mid_scroll_max,
- .id_extra = unique_id.update("glp_mid_sc").asUsize(),
- });
- defer mid_scroll.deinit();
-
- defer {
- if (dialog_middle_scroll.offset(.vertical) > 0.0)
- fizzy.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, .{});
- }
-
- // Form (intrinsic width, full height) + preview (expands horizontally with the window).
- var form_preview_row = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .both,
- .gravity_y = 0,
- .background = false,
- .id_extra = unique_id.update("glp_main_row").asUsize(),
- });
- defer form_preview_row.deinit();
-
- {
- const shell_left = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .vertical,
- .gravity_x = 0,
- .gravity_y = 0,
- .background = false,
- .id_extra = unique_id.update("glp_shell_l").asUsize(),
- });
-
- const pane_left = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .vertical,
- .gravity_x = 0,
- .gravity_y = 0,
- .background = false,
- .id_extra = unique_id.update("glp_pane_l").asUsize(),
- });
-
- var scroll_left = dvui.scrollArea(@src(), .{
- .scroll_info = &left_scroll,
- .horizontal_bar = .auto_overlay,
- .vertical_bar = .auto_overlay,
- }, .{
- .expand = .both,
- .gravity_x = 0,
- .gravity_y = 0,
- .background = false,
- .id_extra = unique_id.update("glp_sc_l").asUsize(),
- });
-
- var inner_left = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .none,
- .gravity_x = 0,
- .gravity_y = 0,
- .padding = .all(4),
- .id_extra = unique_id.update("glp_inner_l").asUsize(),
- });
-
- switch (mode) {
- .resize => valid = drawResizeForm(unique_id, target_file, form_font) and valid,
- .slice => valid = drawSliceForm(unique_id, target_file, form_font) and valid,
- }
-
- inner_left.deinit();
- scroll_left.deinit();
-
- const v_scroll = left_scroll.offset(.vertical);
- const h_scroll = left_scroll.offset(.horizontal);
- if (v_scroll > 0.0) {
- fizzy.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, .{});
- }
- pane_left.deinit();
-
- if (left_scroll.virtual_size.w > left_scroll.viewport.w) {
- fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{});
- }
- if (h_scroll > 0.0) {
- fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{});
- }
- shell_left.deinit();
- }
-
- const preview_w: u32 = blk: {
- if (target_file) |tf| {
- if (mode == .slice) break :blk tf.canvasPixelSize().w;
- }
- break :blk resize_form.column_width * resize_form.columns;
- };
- const preview_h: u32 = blk: {
- if (target_file) |tf| {
- if (mode == .slice) break :blk tf.canvasPixelSize().h;
- }
- break :blk resize_form.row_height * resize_form.rows;
- };
-
- const slice_grid_ok: bool = if (mode == .slice) blk: {
- const tf = target_file orelse break :blk false;
- const c = tf.canvasPixelSize();
- if (c.w == 0 or c.h == 0) break :blk false;
- const s = slice_form;
- break :blk s.column_width * s.columns == c.w and s.row_height * s.rows == c.h;
- } else true;
- // Invalid slice proposal: still show the full layer in the preview (no shrink) using the
- // on-disk grid for sampling until the form is a valid tiling again.
- const pv_cw, const pv_rh, const pv_cols, const pv_rows, const pv_anchor = blk: {
- if (mode == .slice and !slice_grid_ok) {
- const tf = target_file orelse break :blk .{
- slice_form.column_width,
- slice_form.row_height,
- slice_form.columns,
- slice_form.rows,
- anchors[@min(anchor_ix, anchors.len - 1)],
- };
- break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(fizzy.math.layout_anchor.LayoutAnchor, .nw) };
- }
- break :blk switch (mode) {
- .slice => .{
- slice_form.column_width,
- slice_form.row_height,
- slice_form.columns,
- slice_form.rows,
- anchors[@min(anchor_ix, anchors.len - 1)],
- },
- .resize => .{
- resize_form.column_width,
- resize_form.row_height,
- resize_form.columns,
- resize_form.rows,
- anchors[@min(anchor_ix, anchors.len - 1)],
- },
- };
- };
-
- {
- var preview_host = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .gravity_y = 0,
- .background = false,
- .id_extra = unique_id.update("glp_preview_host").asUsize(),
- });
- defer preview_host.deinit();
-
- defer {
- const rs_scroll = preview_host.data().rectScale();
- fizzy.dvui.drawEdgeShadow(rs_scroll, .top, .{});
- fizzy.dvui.drawEdgeShadow(rs_scroll, .bottom, .{});
- fizzy.dvui.drawEdgeShadow(rs_scroll, .left, .{});
- fizzy.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);
- if (dims_ok) {
- renderPreview(
- id,
- unique_id,
- tf,
- preview_w,
- preview_h,
- pv_cw,
- pv_rh,
- pv_cols,
- pv_rows,
- pv_anchor,
- mode == .slice,
- host_rect,
- );
- } else {
- // Keep the preview pane filled: invalid form state still shows the current layer using on-disk grid.
- renderPreview(
- id,
- unique_id,
- tf,
- preview_w,
- preview_h,
- tf.column_width,
- tf.row_height,
- tf.columns,
- tf.rows,
- .nw,
- mode == .slice,
- host_rect,
- );
- }
- }
- }
-
- // OK is enabled only when the form is valid AND the proposed grid actually changes something.
- const changed: bool = blk: {
- const tf = target_file orelse break :blk false;
- break :blk switch (mode) {
- .slice => !(slice_form.column_width == tf.column_width and
- slice_form.row_height == tf.row_height and
- slice_form.columns == tf.columns and
- slice_form.rows == tf.rows),
- .resize => !(resize_form.column_width == tf.column_width and
- resize_form.row_height == tf.row_height and
- resize_form.columns == tf.columns and
- resize_form.rows == tf.rows),
- };
- };
-
- return valid and changed and (target_file != null);
-}
-
-/// Resize-mode form: cell width (x), cell height (y), columns (x), rows (y); 9-way anchor; current vs after readout.
-fn drawResizeForm(
- unique_id: dvui.Id,
- target_file: ?*fizzy.Internal.File,
- form_font: dvui.Font,
-) bool {
- var valid: bool = true;
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } });
-
- if (target_file) |af| {
- dvui.label(@src(), "Current size: {d} × {d} px", .{ af.width(), af.height() }, .{
- .gravity_x = 0,
- .font = form_font,
- .color_text = dvui.themeGet().color(.control, .text),
- });
- } else {
- valid = false;
- }
-
- dvui.label(@src(), "After apply: {d} × {d} px", .{
- resize_form.column_width * resize_form.columns,
- resize_form.row_height * resize_form.rows,
- }, .{
- .gravity_x = 0,
- .font = form_font,
- .color_text = dvui.themeGet().color(.control, .text),
- });
-
- if (!fizzy.Internal.File.validateGridLayoutProposedDims(
- resize_form.column_width,
- resize_form.row_height,
- resize_form.columns,
- resize_form.rows,
- )) {
- valid = false;
- dvui.label(
- @src(),
- "Resulting size must fit within 4096 × 4096 px.",
- .{},
- .{
- .gravity_x = 0,
- .color_text = dvui.themeGet().color(.err, .text),
- .font = form_font,
- },
- );
- }
-
- // ── Cell width (x)
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- dvui.label(@src(), "Cell width (x):", .{}, .{ .gravity_y = 0.5, .font = form_font });
- const res_cw = dvui.textEntryNumber(@src(), u32, .{
- .min = NewFile.min_size[0],
- .max = NewFile.max_size[0],
- .value = &resize_form.column_width,
- .show_min_max = true,
- }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.update("cw").asUsize(),
- .font = form_font,
- });
- if (res_cw.value == .Valid) {
- resize_form.column_width = res_cw.value.Valid;
- } else {
- valid = false;
- }
- }
-
- // ── Cell height (y)
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- dvui.label(@src(), "Cell height (y):", .{}, .{ .gravity_y = 0.5, .font = form_font });
- const res_rh = dvui.textEntryNumber(@src(), u32, .{
- .min = NewFile.min_size[1],
- .max = NewFile.max_size[1],
- .value = &resize_form.row_height,
- .show_min_max = true,
- }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.update("rh").asUsize(),
- .font = form_font,
- });
- if (res_rh.value == .Valid) {
- resize_form.row_height = res_rh.value.Valid;
- } else {
- valid = false;
- }
- }
-
- // ── Columns (x)
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- dvui.label(@src(), "Columns (x):", .{}, .{ .gravity_y = 0.5, .font = form_font });
- const res_col = dvui.textEntryNumber(@src(), u32, .{
- .min = 1,
- .max = NewFile.max_size[0],
- .value = &resize_form.columns,
- .show_min_max = true,
- }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.update("cols").asUsize(),
- .font = form_font,
- });
- if (res_col.value == .Valid) {
- resize_form.columns = res_col.value.Valid;
- } else {
- valid = false;
- }
- }
-
- // ── Rows (y)
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- dvui.label(@src(), "Rows (y):", .{}, .{ .gravity_y = 0.5, .font = form_font });
- const res_row = dvui.textEntryNumber(@src(), u32, .{
- .min = 1,
- .max = NewFile.max_size[1],
- .value = &resize_form.rows,
- .show_min_max = true,
- }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.update("rows").asUsize(),
- .font = form_font,
- });
- if (res_row.value == .Valid) {
- resize_form.rows = res_row.value.Valid;
- } else {
- valid = false;
- }
- }
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 6, .h = 8 } });
-
- // ── Anchor 3×3 button grid (single-select toggle).
- dvui.label(@src(), "Anchor", .{}, .{ .gravity_x = 0, .font = form_font });
-
- const row_tag = [_][]const u8{ "_r0", "_r1", "_r2" };
- {
- var grid_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .none,
- .gravity_x = 0.5,
- .id_extra = unique_id.update("agrid").asUsize(),
- });
- defer grid_box.deinit();
-
- var r: usize = 0;
- while (r < 3) : (r += 1) {
- var row_b = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .gravity_x = 0.5,
- .id_extra = unique_id.update(row_tag[r]).asUsize(),
- });
- defer row_b.deinit();
-
- var c: usize = 0;
- while (c < 3) : (c += 1) {
- const ix = r * 3 + c;
- const selected = ix == anchor_ix;
- const color = if (selected)
- dvui.themeGet().color(.window, .fill).lighten(-4)
- else
- dvui.themeGet().color(.control, .fill);
- const button_opts: dvui.Options = .{
- .padding = .all(4),
- .margin = .all(2),
- .corner_radius = .all(4),
- .min_size_content = .{ .w = 36, .h = 28 },
- .color_fill = color,
- .color_fill_hover = if (selected) color else null,
- .id_extra = unique_id.update(anchor_labels[ix]).asUsize(),
- };
-
- var button: dvui.ButtonWidget = undefined;
- button.init(@src(), .{}, button_opts);
- defer button.deinit();
-
- if (!selected) button.processEvents();
- button.drawBackground();
-
- dvui.labelNoFmt(@src(), anchor_labels[ix], .{}, button_opts.strip().override(button.style()).override(.{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .color_text = if (selected)
- dvui.themeGet().color(.window, .text)
- else
- dvui.themeGet().color(.control, .text),
- .font = form_font,
- }));
- if (button.clicked()) {
- anchor_ix = ix;
- dvui.currentWindow().extra_frames_needed = 2;
- }
- }
- }
- }
-
- return valid;
-}
-
-/// Slice-mode form: image dimensions are pinned to the file's current `width × height`. Field order
-/// matches resize: cell width, cell height, columns, rows. The user edits any field and the dialog
-/// auto-fills the linked value whenever it divides evenly. The grid is invalid if values don't
-/// multiply back to the locked total.
-fn drawSliceForm(
- unique_id: dvui.Id,
- target_file: ?*fizzy.Internal.File,
- form_font: dvui.Font,
-) bool {
- var valid: bool = true;
- const tf = target_file orelse return false;
- const canvas = tf.canvasPixelSize();
- const total_w: u32 = canvas.w;
- const total_h: u32 = canvas.h;
- if (total_w == 0 or total_h == 0) {
- dvui.label(@src(), "No layer pixels to slice.", .{}, .{
- .gravity_x = 0,
- .color_text = dvui.themeGet().color(.err, .text),
- .font = form_font,
- });
- return false;
- }
-
- dvui.label(@src(), "Image size: {d} × {d} px (locked)", .{ total_w, total_h }, .{
- .gravity_x = 0,
- .font = form_font,
- .color_text = dvui.themeGet().color(.control, .text),
- });
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 4, .h = 6 } });
-
- // ── Cell width (x)
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- dvui.label(@src(), "Cell width (x):", .{}, .{ .gravity_y = 0.5, .font = form_font });
- const res = dvui.textEntryNumber(@src(), u32, .{
- .min = 1,
- .max = @max(total_w, 1),
- .value = &slice_form.column_width,
- .show_min_max = true,
- }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.update("s_cw").asUsize(),
- .font = form_font,
- });
- if (res.value == .Valid) slice_form.column_width = res.value.Valid else valid = false;
- }
-
- // ── Cell height (y)
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- dvui.label(@src(), "Cell height (y):", .{}, .{ .gravity_y = 0.5, .font = form_font });
- const res = dvui.textEntryNumber(@src(), u32, .{
- .min = 1,
- .max = @max(total_h, 1),
- .value = &slice_form.row_height,
- .show_min_max = true,
- }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.update("s_ch").asUsize(),
- .font = form_font,
- });
- if (res.value == .Valid) slice_form.row_height = res.value.Valid else valid = false;
- }
-
- // ── Columns (x)
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- dvui.label(@src(), "Columns (x):", .{}, .{ .gravity_y = 0.5, .font = form_font });
- const res = dvui.textEntryNumber(@src(), u32, .{
- .min = 1,
- .max = @max(total_w, 1),
- .value = &slice_form.columns,
- .show_min_max = true,
- }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.update("s_cols").asUsize(),
- .font = form_font,
- });
- if (res.value == .Valid) slice_form.columns = res.value.Valid else valid = false;
- }
-
- // ── Rows (y)
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- dvui.label(@src(), "Rows (y):", .{}, .{ .gravity_y = 0.5, .font = form_font });
- const res = dvui.textEntryNumber(@src(), u32, .{
- .min = 1,
- .max = @max(total_h, 1),
- .value = &slice_form.rows,
- .show_min_max = true,
- }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.update("s_rows").asUsize(),
- .font = form_font,
- });
- if (res.value == .Valid) slice_form.rows = res.value.Valid else valid = false;
- }
-
- // Auto-link: prefer count-driven autofill (if columns or rows changed, derive the cell size).
- // If only the cell size changed, derive the count. Single-frame lag is fine — both fields
- // converge on the next render.
- if (slice_form.columns != slice_prev_columns and slice_form.columns > 0 and total_w % slice_form.columns == 0) {
- slice_form.column_width = total_w / slice_form.columns;
- } else if (slice_form.column_width != slice_prev_column_width and slice_form.column_width > 0 and total_w % slice_form.column_width == 0) {
- slice_form.columns = total_w / slice_form.column_width;
- }
- if (slice_form.rows != slice_prev_rows and slice_form.rows > 0 and total_h % slice_form.rows == 0) {
- slice_form.row_height = total_h / slice_form.rows;
- } else if (slice_form.row_height != slice_prev_row_height and slice_form.row_height > 0 and total_h % slice_form.row_height == 0) {
- slice_form.rows = total_h / slice_form.row_height;
- }
- slice_prev_columns = slice_form.columns;
- slice_prev_rows = slice_form.rows;
- slice_prev_column_width = slice_form.column_width;
- slice_prev_row_height = slice_form.row_height;
-
- // Validation: in slice mode the *only* legal grids are those whose cell × count matches the
- // locked image size. We surface the mismatch inline rather than silently snapping so the
- // user sees what the constraint is.
- const cw_eff = slice_form.column_width;
- const rh_eff = slice_form.row_height;
- const w_match = cw_eff > 0 and slice_form.columns > 0 and cw_eff * slice_form.columns == total_w;
- const h_match = rh_eff > 0 and slice_form.rows > 0 and rh_eff * slice_form.rows == total_h;
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 6, .h = 6 } });
-
- if (!(w_match and h_match)) {
- valid = false;
- dvui.label(@src(), "Cells must tile the image exactly.", .{}, .{
- .gravity_x = 0,
- .color_text = dvui.themeGet().color(.err, .text),
- .font = form_font,
- });
- if (!w_match) {
- dvui.label(@src(), " • {d} × {d} ≠ {d} (x)", .{ cw_eff, slice_form.columns, total_w }, .{
- .gravity_x = 0,
- .color_text = dvui.themeGet().color(.err, .text),
- .font = form_font,
- });
- }
- if (!h_match) {
- dvui.label(@src(), " • {d} × {d} ≠ {d} (y)", .{ rh_eff, slice_form.rows, total_h }, .{
- .gravity_x = 0,
- .color_text = dvui.themeGet().color(.err, .text),
- .font = form_font,
- });
- }
- }
-
- return valid;
-}
-
-/// Custom window shell for the grid-layout dialog: matches `fizzy.dvui.dialogWindow` (open
-/// `autoSize()` animation, nudge + center on modal rect). `min_size_content` is half the main
-/// window so the first layout pass does not collapse the shell; DVUI then grows to fit content
-/// (see `FloatingWindowWidget` `Size.max(min_size, min_sizeGet)`). Do not use `max_size_content`
-/// here — in DVUI it *caps* reported min size and was shrinking the dialog.
-pub fn windowFn(id: dvui.Id) anyerror!void {
- const modal = dvui.dataGet(null, id, "_modal", bool) orelse {
- dvui.log.err("GridLayout windowFn lost data for dialog {x}", .{id});
- dvui.dialogRemove(id);
- return;
- };
-
- if (modal) {
- fizzy.editor.dim_titlebar = true;
- }
-
- const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse {
- dvui.dialogRemove(id);
- return;
- };
- const ok_label = dvui.dataGetSlice(null, id, "_ok_label", []u8) orelse {
- dvui.dialogRemove(id);
- return;
- };
- const cancel_label = dvui.dataGetSlice(null, id, "_cancel_label", []u8);
- const default = dvui.dataGet(null, id, "_default", dvui.enums.DialogResponse);
- const callafter = dvui.dataGet(null, id, "_callafter", fizzy.dvui.CallAfterFn);
- const displayFn = dvui.dataGet(null, id, "_displayFn", fizzy.dvui.DisplayFn);
-
- // Default shell: wide enough for form + preview; DVUI autoSize grows to content if larger.
- const wr = dvui.windowRect();
- const init_w = @round(wr.w * 0.62);
- const init_h = @round(wr.h * 0.52);
- const center_on = dvui.currentWindow().subwindows.current_rect;
-
- var win = fizzy.dvui.floatingWindow(@src(), .{
- .modal = modal,
- .center_on = center_on,
- .window_avoid = .nudge,
- .process_events_in_deinit = true,
- .resize = .all,
- }, .{
- .id_extra = id.asUsize(),
- .color_text = .black,
- .corner_radius = dvui.Rect.all(10),
- .min_size_content = .{ .w = init_w, .h = @max(init_h, 400) },
- .border = .all(0),
- .color_fill = dvui.themeGet().color(.content, .fill).opacity(0.85),
- .box_shadow = .{
- .color = .black,
- .alpha = 0.35,
- .fade = 10,
- .corner_radius = dvui.Rect.all(10),
- },
- });
- defer win.deinit();
- // `renderPreview` refits when the preview viewport changes; during very slow resize drags the
- // scroll viewport can lag the shell by sub-pixel amounts for multiple frames. While this window
- // holds capture (resize or drag), refit every frame so scale/center stay correct.
- dvui.dataSet(null, id, "_grid_layout_float_wd_id", win.data().id);
-
- if (dvui.dataGet(null, id, "_grid_dialog_open_done", bool) orelse false) {
- win.stopAutoSizing();
- }
-
- if (dvui.animationGet(win.data().id, "_close_x")) |a| {
- if (a.done()) {
- fizzy.Editor.Explorer.files.new_file_close_rect = null;
- dvui.dialogRemove(id);
- }
- } else if (fizzy.Editor.Explorer.files.new_file_close_rect) |close_rect| {
- dvui.dataSet(null, win.data().id, "_close_rect", close_rect);
- fizzy.Editor.Explorer.files.new_file_close_rect = null;
- } else {
- // Call `autoSize` only while opening. Doing it every frame leaves `auto_size` true and the
- // window keeps animating/snapping to content min size — user resize appears "locked".
- const open_done = dvui.dataGet(null, id, "_grid_dialog_open_done", bool) orelse false;
- if (!open_done) {
- win.autoSize();
- var anim_busy = false;
- if (dvui.animationGet(win.data().id, "_auto_width")) |a| {
- if (!a.done()) anim_busy = true;
- }
- if (dvui.animationGet(win.data().id, "_auto_height")) |a2| {
- if (!a2.done()) anim_busy = true;
- }
- if (!anim_busy and !dvui.firstFrame(win.data().id) and win.data().rect.w > 32 and win.data().rect.h > 32) {
- dvui.dataSet(null, id, "_grid_dialog_open_done", true);
- win.stopAutoSizing();
- }
- }
- }
-
- // Header (title) + mode pill are fixed; middle expands and scrolls inside `dialog()`; footer fixed.
- var shell = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both });
- defer shell.deinit();
-
- const header_kind: 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,
- else => .none,
- };
-
- var header_openflag = true;
- win.dragAreaSet(fizzy.dvui.windowHeader(title, "", &header_openflag, header_kind));
- if (!header_openflag) {
- if (callafter) |ca| {
- ca(id, .cancel) catch {
- dvui.log.err("GridLayout dialog callafter cancel failed", .{});
- return;
- };
- }
- var close_rect = win.data().rectScale().r;
- close_rect.x = close_rect.center().x;
- close_rect.y = close_rect.center().y;
- close_rect.w = 1;
- close_rect.h = 1;
- dvui.dataSet(null, win.data().id, "_close_rect", close_rect);
- }
-
- gridLayoutDrawModePill(id);
-
- var valid: bool = true;
-
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .padding = .all(8),
- .expand = .both,
- .gravity_x = 0.5,
- });
- defer hbox.deinit();
-
- if (displayFn) |df| {
- valid = df(id) catch false;
- }
- }
-
- { // Footer — match `fizzy.dvui.dialogWindow` (horizontal strip, gravity_x centered).
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .gravity_x = 0.5,
- .padding = .{ .y = 6, .h = 8 },
- });
- defer hbox.deinit();
-
- if (cancel_label) |cl| {
- var cancel_data: dvui.WidgetData = undefined;
- const gravx: f32, const tindex: u16 = switch (dvui.currentWindow().button_order) {
- .cancel_ok => .{ 0.0, 1 },
- .ok_cancel => .{ 1.0, 3 },
- };
- if (dvui.button(@src(), cl, .{}, .{
- .tab_index = tindex,
- .data_out = &cancel_data,
- .gravity_x = gravx,
- .box_shadow = .{
- .color = .black,
- .alpha = 0.25,
- .offset = .{ .x = -4, .y = 4 },
- .fade = 8,
- },
- })) {
- if (callafter) |ca| {
- ca(id, .cancel) catch {
- dvui.log.err("GridLayout dialog callafter cancel failed", .{});
- return;
- };
- }
- var close_rect = win.data().rectScale().r;
- close_rect.x = close_rect.center().x;
- close_rect.y = close_rect.center().y;
- close_rect.w = 1;
- close_rect.h = 1;
- dvui.dataSet(null, win.data().id, "_close_rect", close_rect);
- }
- if (default != null and dvui.firstFrame(hbox.data().id) and default.? == .cancel and !valid) {
- dvui.focusWidget(cancel_data.id, null, null);
- }
- }
-
- const alpha = dvui.alpha(if (valid) 1.0 else 0.5);
- defer dvui.alphaSet(alpha);
-
- var ok_data: dvui.WidgetData = undefined;
- const ok_opts: dvui.Options = .{
- .tab_index = 2,
- .data_out = &ok_data,
- .style = if (valid) .highlight else .control,
- .box_shadow = .{
- .color = .black,
- .alpha = 0.25,
- .offset = .{ .x = -4, .y = 4 },
- .fade = 8,
- },
- };
- var ok_button: dvui.ButtonWidget = undefined;
- ok_button.init(@src(), .{}, ok_opts);
- defer ok_button.deinit();
- if (valid) ok_button.processEvents();
- ok_button.drawFocus();
- ok_button.drawBackground();
- dvui.labelNoFmt(@src(), ok_label, .{}, ok_opts.strip().override(ok_button.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 }));
-
- if (ok_button.clicked()) {
- if (!valid) return;
- if (callafter) |ca| {
- ca(id, .ok) catch {
- dvui.log.err("GridLayout dialog callafter ok failed", .{});
- return;
- };
- }
- var close_rect_ok = win.data().rectScale().r;
- close_rect_ok.x = close_rect_ok.center().x;
- close_rect_ok.y = close_rect_ok.center().y;
- close_rect_ok.w = 1;
- close_rect_ok.h = 1;
- dvui.dataSet(null, win.data().id, "_close_rect", close_rect_ok);
- }
- if (default != null and dvui.firstFrame(hbox.data().id) and default.? == .ok and valid) {
- dvui.focusWidget(ok_data.id, null, null);
- }
- }
-}
-
-pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void {
- switch (response) {
- .ok => {
- const file_id = dvui.dataGet(null, id, "_grid_layout_file_id", u64) orelse return;
- const file = fizzy.editor.open_files.getPtr(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))
- return;
- file.applyGridSliceOnly(.{
- .column_width = s.column_width,
- .row_height = s.row_height,
- .columns = s.columns,
- .rows = s.rows,
- }) catch |err| {
- dvui.log.err("Failed to apply grid slice: {s}", .{@errorName(err)});
- return;
- };
- },
- .resize => {
- const r = resize_form;
- if (!fizzy.Internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows))
- return;
- file.applyGridLayout(.{
- .column_width = r.column_width,
- .row_height = r.row_height,
- .columns = r.columns,
- .rows = r.rows,
- .anchor = anchors[@min(anchor_ix, anchors.len - 1)],
- }) catch |err| {
- dvui.log.err("Failed to apply grid layout: {s}", .{@errorName(err)});
- return;
- };
- },
- }
-
- dvui.refresh(null, @src(), dvui.currentWindow().data().id);
- fizzy.editor.requestCompositeWarmup();
- },
- .cancel => {},
- else => {},
- }
-}
diff --git a/src/editor/dialogs/NewFile.zig b/src/editor/dialogs/NewFile.zig
deleted file mode 100644
index a4a0a462..00000000
--- a/src/editor/dialogs/NewFile.zig
+++ /dev/null
@@ -1,233 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
-const dvui = @import("dvui");
-
-const Dialogs = @import("Dialogs.zig");
-
-pub var mode: enum(usize) {
- single,
- grid,
-} = .single;
-
-pub var columns: u32 = 1;
-pub var rows: u32 = 1;
-pub var column_width: u32 = 32;
-pub var row_height: u32 = 32;
-
-pub const max_size: [2]u32 = .{ 4096, 4096 };
-pub const min_size: [2]u32 = .{ 1, 1 };
-
-pub fn dialog(id: dvui.Id) anyerror!bool {
- const entry_font = dvui.Font.theme(.mono).larger(-2);
-
- // Touch explorer target path every frame so dvui does not drop it at Window.end before OK.
- _ = dvui.dataGetSlice(null, id, "_parent_path", []u8);
-
- var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both });
- defer outer_box.deinit();
-
- {
- var valid: bool = true;
-
- var unique_id = id.update(if (mode == .single) "single" else "grid");
-
- {
- const hbox = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ .expand = .horizontal, .corner_radius = .all(100000), .margin = .all(4) });
- defer hbox.deinit();
-
- for (0..2) |i| {
- const color = if (i == @intFromEnum(mode)) dvui.themeGet().color(.window, .fill).lighten(-4) else dvui.themeGet().color(.control, .fill);
- const button_opts: dvui.Options = .{
- .padding = .all(6),
- .margin = .{ .y = 2, .h = 4 },
- .corner_radius = if (i == 0) .{ .x = 100000, .h = 100000 } else .{ .y = 100000, .w = 100000 },
- .expand = .horizontal,
- .color_fill = color,
- .color_fill_hover = if (i == @intFromEnum(mode)) color else null,
- .id_extra = i,
- .box_shadow = if (i != @intFromEnum(mode)) .{
- .color = .black,
- .offset = .{ .x = 0.0, .y = 2.0 },
- .fade = 7.0,
- .alpha = 0.2,
- .corner_radius = if (i == 0) .{ .x = 100000, .h = 100000 } else .{ .y = 100000, .w = 100000 },
- } else null,
- };
-
- var button: dvui.ButtonWidget = undefined;
- button.init(@src(), .{}, button_opts);
- defer button.deinit();
-
- if (i != @intFromEnum(mode)) {
- button.processEvents();
- }
-
- button.drawBackground();
-
- if (i == 0) {
- dvui.labelNoFmt(@src(), "Single", .{}, button_opts.strip().override(button.style()).override(.{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .color_text = if (i == @intFromEnum(mode)) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
- }));
- if (button.clicked()) {
- mode = .single;
- _ = dvui.dataSet(null, id, "_id_extra", id.update("single_tile").asUsize());
- }
- } else {
- dvui.labelNoFmt(@src(), "Grid", .{}, button_opts.strip().override(button.style()).override(.{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .color_text = if (i == @intFromEnum(mode)) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
- }));
- if (button.clicked()) {
- mode = .grid;
- _ = dvui.dataSet(null, id, "_id_extra", id.update("grid").asUsize());
- }
- }
- }
- }
-
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- {
- dvui.label(@src(), "{s}", .{if (mode == .single) "Width (x):" else "Column Width (x):"}, .{ .gravity_y = 0.5, .gravity_x = 0.0 });
- const result = dvui.textEntryNumber(@src(), u32, .{ .min = min_size[0], .max = max_size[0], .value = &column_width, .show_min_max = true }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.asUsize(),
- .font = entry_font,
- });
- if (result.value == .Valid) {
- column_width = result.value.Valid;
- } else {
- valid = false;
- }
- }
- }
-
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- defer hbox.deinit();
-
- {
- dvui.label(@src(), "{s}", .{if (mode == .single) "Height (y):" else "Row Height (y):"}, .{ .gravity_y = 0.5, .gravity_x = 0.0 });
- const result = dvui.textEntryNumber(@src(), u32, .{ .min = min_size[1], .max = max_size[1], .value = &row_height, .show_min_max = true }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.asUsize(),
- .font = entry_font,
- });
- if (result.value == .Valid) {
- row_height = result.value.Valid;
- } else {
- valid = false;
- }
- }
- }
-
- if (mode == .grid) {
- {
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both });
- defer hbox.deinit();
-
- dvui.label(@src(), "Columns (x):", .{}, .{ .gravity_y = 0.5 });
- const result = dvui.textEntryNumber(@src(), u32, .{ .min = 1, .max = @divTrunc(max_size[0], column_width), .value = &columns, .show_min_max = true }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.asUsize(),
- .font = entry_font,
- });
- if (result.value == .Valid) {
- columns = result.value.Valid;
- } else {
- valid = false;
- }
- }
- {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both });
- defer hbox.deinit();
- dvui.label(@src(), "Rows (y):", .{}, .{ .gravity_y = 0.5 });
- const result = dvui.textEntryNumber(@src(), u32, .{ .min = 1, .max = @divTrunc(max_size[1], row_height), .value = &rows, .show_min_max = true }, .{
- .box_shadow = .{ .color = .black, .alpha = 0.25, .offset = .{ .x = -4, .y = 4 }, .fade = 8 },
- .label = .{ .label_widget = .prev },
- .gravity_x = 1.0,
- .id_extra = unique_id.asUsize(),
- .font = entry_font,
- });
- if (result.value == .Valid) {
- rows = result.value.Valid;
- } else {
- valid = false;
- }
- }
- }
- }
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } });
-
- const width = column_width * (if (mode == .single) 1 else columns);
- const height = row_height * (if (mode == .single) 1 else rows);
-
- Dialogs.drawDimensionsLabel(@src(), width, height, entry_font, "px", .{ .gravity_x = 0.5 });
-
- return valid;
- }
-
- return false;
-}
-
-pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void {
- const parent_path = dvui.dataGetSlice(null, id, "_parent_path", []u8);
-
- switch (response) {
- .ok => {
- if (parent_path) |parent| {
- const new_path = try std.fs.path.join(fizzy.app.allocator, &.{ parent, "untitled.fiz" });
- defer fizzy.app.allocator.free(new_path);
-
- const file = fizzy.editor.newFile(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;
- };
-
- // Save synchronously so the tree's directory scan sees the new file on the next draw
- // (saveAsync would finish later and the fly-to / rename row would never match).
- file.saveAsync() catch {
- dvui.log.err("Failed to save file: {s}", .{new_path});
- return error.FailedToSaveFile;
- };
-
- 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);
- 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, .{
- .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 => {},
- else => {},
- }
-}
diff --git a/src/editor/dialogs/PluginLoadFailures.zig b/src/editor/dialogs/PluginLoadFailures.zig
new file mode 100644
index 00000000..8bb1f350
--- /dev/null
+++ b/src/editor/dialogs/PluginLoadFailures.zig
@@ -0,0 +1,111 @@
+//! Shown once at startup when one or more user plugins failed to load, so an author isn't
+//! left guessing why their plugin didn't appear. Reads the recorded failures off the live
+//! `fizzy.editor` (populated by `Editor.loadUserPlugins`); the shell calls `request()` after
+//! user-plugin loading when `editor.failed_user_plugins` is non-empty.
+
+const std = @import("std");
+const dvui = @import("dvui");
+const sdk = @import("sdk");
+const fizzy = @import("../../fizzy.zig");
+
+const version = sdk.version;
+const dylib = sdk.dylib;
+
+pub fn request() void {
+ if (active(dvui.currentWindow())) return;
+ var mutex = fizzy.dvui.dialog(@src(), .{
+ .displayFn = dialog,
+ .callafterFn = callAfter,
+ .title = "Plugin Load Failures",
+ .ok_label = "",
+ .cancel_label = "",
+ .resizeable = false,
+ .default = .cancel,
+ .hide_footer = true,
+ .header_kind = .err,
+ });
+ mutex.mutex.unlock(dvui.io);
+}
+
+pub fn active(win: *dvui.Window) bool {
+ var it = win.dialogs.iterator(null);
+ while (it.next()) |d| {
+ const df = dvui.dataGet(null, d.id, "_displayFn", fizzy.dvui.DisplayFn) orelse continue;
+ if (df == dialog) return true;
+ }
+ return false;
+}
+
+fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8) bool {
+ const opts: dvui.Options = .{
+ .tab_index = 1,
+ .style = .control,
+ .box_shadow = .{
+ .color = .black,
+ .alpha = 0.25,
+ .offset = .{ .x = -4, .y = 4 },
+ .fade = 8,
+ },
+ };
+ var button: dvui.ButtonWidget = undefined;
+ button.init(src, .{}, opts);
+ defer button.deinit();
+ button.processEvents();
+ button.drawFocus();
+ button.drawBackground();
+ dvui.labelNoFmt(src, label_text, .{}, opts.strip().override(button.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 }));
+ return button.clicked();
+}
+
+pub fn dialog(_: dvui.Id) anyerror!bool {
+ var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(12) });
+ defer outer.deinit();
+
+ var host_line_buf: [96]u8 = undefined;
+ const host_line = std.fmt.bufPrint(&host_line_buf, "Host SDK {d}.{d}.{d} · ABI 0x{x}", .{
+ version.sdk_version.major,
+ version.sdk_version.minor,
+ version.sdk_version.patch,
+ dylib.abi_fingerprint,
+ }) catch "Host SDK ?";
+
+ dvui.labelNoFmt(
+ @src(),
+ "Some installed plugins could not be loaded:",
+ .{},
+ .{ .color_text = dvui.themeGet().color(.window, .text), .margin = .{ .h = 8 } },
+ );
+ dvui.labelNoFmt(@src(), host_line, .{}, .{
+ .color_text = dvui.themeGet().color(.window, .text),
+ .margin = .{ .h = 4 },
+ });
+
+ for (fizzy.editor.failed_user_plugins.items, 0..) |f, i| {
+ if (f.detail) |detail| {
+ dvui.label(
+ @src(),
+ "• {s} — {s} ({s})",
+ .{ f.id, f.reason, detail },
+ .{ .id_extra = i, .color_text = dvui.themeGet().color(.window, .text), .margin = .{ .h = 4 } },
+ );
+ } else {
+ dvui.label(
+ @src(),
+ "• {s} — {s}",
+ .{ f.id, f.reason },
+ .{ .id_extra = i, .color_text = dvui.themeGet().color(.window, .text), .margin = .{ .h = 4 } },
+ );
+ }
+ }
+
+ var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_x = 0.5 });
+ defer row.deinit();
+
+ if (dialogButton(@src(), "OK")) {
+ fizzy.dvui.closeFloatingDialogAnchored();
+ }
+
+ return true;
+}
+
+pub fn callAfter(_: dvui.Id, _: dvui.enums.DialogResponse) anyerror!void {}
diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig
index 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/project.zig b/src/editor/explorer/project.zig
deleted file mode 100644
index ccc1bfe5..00000000
--- a/src/editor/explorer/project.zig
+++ /dev/null
@@ -1,529 +0,0 @@
-const std = @import("std");
-const builtin = @import("builtin");
-const icons = @import("icons");
-
-const fizzy = @import("../../fizzy.zig");
-const dvui = @import("dvui");
-
-pub fn draw() !void {
- // On web there's no project folder concept. Render a simplified pane that
- // only exposes the Pack button (operates on currently-open files via
- // `startPackProject`'s wasm path). Native flow below assumes a folder.
- if (comptime builtin.target.cpu.arch == .wasm32) {
- try drawWeb();
- return;
- }
-
- if (fizzy.editor.folder) |folder| {
- if (fizzy.editor.project) |_| {
- const tl = dvui.textLayout(@src(), .{}, .{
- .expand = .none,
- .margin = dvui.Rect.all(0),
- .background = false,
- });
- defer tl.deinit();
-
- const project_path = std.fs.path.join(dvui.currentWindow().lifo(), &.{ folder, ".fizproject" }) catch {
- dvui.log.err("Failed to join project path", .{});
- return;
- };
- defer dvui.currentWindow().lifo().free(project_path);
-
- tl.addText(project_path, .{ .color_text = dvui.themeGet().color(.control, .text) });
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } });
- } else {
- var box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .horizontal,
- .max_size_content = .{ .w = fizzy.editor.explorer.scroll_info.virtual_size.w, .h = std.math.floatMax(f32) },
- });
- defer box.deinit();
-
- const tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false });
- tl.addText("No project file found!\n\n", .{});
- tl.addText("Would you like to create a project file to specify constant output paths and other project-specific behaviors?\n", .{ .color_text = dvui.themeGet().color(.control, .text) });
- tl.deinit();
-
- if (dvui.button(@src(), "Create Project", .{}, .{ .expand = .horizontal })) {
- fizzy.editor.project = .{};
- }
- return;
- }
-
- const packing = fizzy.editor.isPackingActive();
- if (packProjectButton(packing)) {
- fizzy.editor.startPackProject() catch |err| {
- dvui.log.err("Failed to start project pack: {any}", .{err});
- };
- }
-
- if (fizzy.packer.atlas != null) {
- drawPackedAtlasStats();
- }
-
- pathTextEntry(.atlas) catch {
- dvui.log.err("Failed to draw path text entry", .{});
- };
- pathTextEntry(.image) catch {
- dvui.log.err("Failed to draw path text entry", .{});
- };
-
- if (fizzy.editor.project) |project| {
- if (fizzy.packer.atlas) |atlas| {
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } });
- if (dvui.button(@src(), "Export Project", .{ .draw_focus = false }, .{
- .expand = .horizontal,
- .style = .highlight,
- })) {
- if (project.packed_atlas_output) |output| {
- atlas.save(output, .data) catch {
- dvui.log.err("Failed to save atlas data", .{});
- };
- }
-
- if (project.packed_image_output) |image_output| {
- atlas.save(image_output, .source) catch {
- dvui.log.err("Failed to save atlas image", .{});
- };
- }
- }
- }
- }
- }
-
- // {
- // var set_text: bool = false;
- // dvui.labelNoFmt(@src(), "Atlas Data Output:", .{}, .{});
-
- // var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- // defer box.deinit();
-
- // if (dvui.buttonIcon(@src(), "example.atlas", icons.tvg.lucide.@"folder-open", .{}, .{
- // .fill_color = .fromTheme(.text_press),
- // }, .{
- // .gravity_y = 0.5,
- // .padding = dvui.Rect.all(4),
- // .border = dvui.Rect.all(1),
- // .margin = .{ .x = 1, .w = 1 },
- // })) {
- // const valid_path: bool = blk: {
- // if (project.packed_atlas_output) |output| {
- // const base_name = std.fs.path.basename(output);
- // if (std.mem.indexOf(u8, output, base_name)) |i| {
- // if (!std.fs.path.isAbsolute(output[0..i])) {
- // break :blk false;
- // }
-
- // std.Io.Dir.accessAbsolute(dvui.io, output[0..i], .{}) catch {
- // break :blk false;
- // };
- // } else {
- // if (!std.fs.path.isAbsolute(output)) {
- // break :blk false;
- // }
- // std.Io.Dir.accessAbsolute(dvui.io, output, .{}) catch {
- // break :blk false;
- // };
- // }
- // }
-
- // break :blk true;
- // };
-
- // if (dvui.dialogNativeFileSave(fizzy.app.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;
- // set_text = true;
- // } else {
- // dvui.log.err("Project failed to copy new path", .{});
- // }
- // }
-
- // const te = dvui.textEntry(@src(), .{
- // .placeholder = "example.atlas",
- // }, .{
- // .padding = dvui.Rect.all(5),
- // .expand = .horizontal,
- // .margin = dvui.Rect.all(0),
- // .color_text = if (project.packed_atlas_output) |_| .text else .text_press,
- // });
-
- // defer te.deinit();
-
- // if (project.packed_atlas_output) |packed_atlas_output| {
- // if (dvui.firstFrame(te.data().id) or set_text) {
- // te.textSet(packed_atlas_output, false);
- // }
- // }
-
- // if (te.text_changed) {
- // const t = te.getText();
- // if (t.len > 0) {
- // project.packed_atlas_output = fizzy.app.allocator.dupe(u8, t) catch null;
- // } else {
- // project.packed_atlas_output = null;
- // }
- // }
- // }
-
- // _ = dvui.spacer(@src(), .{ .expand = .horizontal, .min_size_content = .{ .h = 10 } });
-
- // {
- // var set_text: bool = false;
- // dvui.labelNoFmt(@src(), "Atlas Image Output:", .{}, .{});
-
- // var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
- // defer box.deinit();
-
- // if (dvui.buttonIcon(@src(), "example.atlas", icons.tvg.lucide.@"folder-open", .{}, .{
- // .fill_color = .fromTheme(.text_press),
- // }, .{
- // .gravity_y = 0.5,
- // .padding = dvui.Rect.all(4),
- // .border = dvui.Rect.all(1),
- // .margin = .{ .x = 1, .w = 1 },
- // })) {
- // const valid_path: bool = blk: {
- // if (project.packed_image_output) |output| {
- // const base_name = std.fs.path.basename(output);
- // if (std.mem.indexOf(u8, output, base_name)) |i| {
- // if (!std.fs.path.isAbsolute(output[0..i])) {
- // break :blk false;
- // }
-
- // std.Io.Dir.accessAbsolute(dvui.io, output[0..i], .{}) catch {
- // break :blk false;
- // };
- // } else {
- // if (!std.fs.path.isAbsolute(output)) {
- // break :blk false;
- // }
- // std.Io.Dir.accessAbsolute(dvui.io, output, .{}) catch {
- // break :blk false;
- // };
- // }
- // }
-
- // break :blk true;
- // };
-
- // if (dvui.dialogNativeFileSave(fizzy.app.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;
- // set_text = true;
- // } else {
- // dvui.log.err("Project failed to copy new path", .{});
- // }
- // }
-
- // const te = dvui.textEntry(@src(), .{
- // .placeholder = "example.png",
- // }, .{
- // .padding = dvui.Rect.all(5),
- // .expand = .horizontal,
- // .margin = dvui.Rect.all(0),
- // .color_text = if (project.packed_image_output) |_| .text else .text_press,
- // });
-
- // defer te.deinit();
-
- // if (project.packed_image_output) |packed_image_output| {
- // if (dvui.firstFrame(te.data().id) or set_text) {
- // te.textSet(packed_image_output, false);
- // }
- // }
-
- // if (te.text_changed) {
- // const t = te.getText();
- // if (t.len > 0) {
- // project.packed_image_output = fizzy.app.allocator.dupe(u8, t) catch null;
- // } else {
- // project.packed_image_output = null;
- // }
- // }
- // }
-
-}
-
-const PathType = enum {
- atlas,
- image,
-};
-
-fn pathTextEntry(path_type: PathType) !void {
- if (fizzy.editor.project) |*project| {
- const output_path = switch (path_type) {
- .atlas => &project.packed_atlas_output,
- .image => &project.packed_image_output,
- };
-
- const index: usize = switch (path_type) {
- .atlas => 0,
- .image => 1,
- };
-
- defer _ = dvui.spacer(@src(), .{ .id_extra = index });
-
- const label_text = switch (path_type) {
- .atlas => "Atlas Data Output:",
- .image => "Atlas Image Output:",
- };
-
- var set_text: bool = false;
- dvui.labelNoFmt(@src(), label_text, .{}, .{
- .id_extra = index,
- });
-
- var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .id_extra = index });
- defer box.deinit();
-
- if (dvui.buttonIcon(@src(), "example.atlas", icons.tvg.lucide.@"folder-open", .{}, .{}, .{
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- .border = dvui.Rect.all(1),
- .margin = .{ .x = 1, .w = 1 },
- .id_extra = index,
- })) {
- const valid_path: bool = blk: {
- if (output_path.*) |output| {
- const base_name = std.fs.path.basename(output);
- if (std.mem.indexOf(u8, output, base_name)) |i| {
- if (!std.fs.path.isAbsolute(output[0..i])) {
- break :blk false;
- }
-
- std.Io.Dir.accessAbsolute(dvui.io, output[0..i], .{}) catch {
- break :blk false;
- };
- } else {
- if (!std.fs.path.isAbsolute(output)) {
- break :blk false;
- }
- std.Io.Dir.accessAbsolute(dvui.io, output, .{}) catch {
- break :blk false;
- };
- }
- }
-
- break :blk true;
- };
-
- fizzy.backend.showSaveFileDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{
- if (path_type == .atlas) .{ .name = "Atlas Data", .pattern = "atlas" } else .{ .name = "Atlas Image", .pattern = "png;jpg;jpeg" },
- }, "", if (valid_path) output_path.* else null);
- set_text = true;
- }
-
- const te = dvui.textEntry(@src(), .{
- .placeholder = "example.atlas",
- }, .{
- .padding = dvui.Rect.all(5),
- .expand = .horizontal,
- .margin = dvui.Rect.all(0),
- .color_text = if (output_path.*) |_| dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
- .id_extra = index,
- });
-
- defer te.deinit();
-
- if (output_path.*) |packed_atlas_output| {
- if (dvui.firstFrame(te.data().id) or dvui.focusedWidgetId() != te.data().id) {
- te.textSet(packed_atlas_output, false);
- }
- }
-
- if (te.text_changed) {
- const t = te.getText();
- if (t.len > 0) {
- output_path.* = fizzy.app.allocator.dupe(u8, t) catch null;
- } else {
- output_path.* = null;
- }
- }
- }
-}
-
-fn drawPackedAtlasStats() void {
- const atlas = &fizzy.packer.atlas.?;
- const image_size = fizzy.image.size(atlas.source);
- const atlas_w: u32 = @intFromFloat(image_size.w);
- const atlas_h: u32 = @intFromFloat(image_size.h);
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } });
-
- const tl = dvui.textLayout(@src(), .{}, .{
- .expand = .horizontal,
- .margin = dvui.Rect.all(0),
- .background = false,
- });
- defer tl.deinit();
-
- const body = dvui.Font.theme(.body);
- const label_color = dvui.themeGet().color(.window, .text);
- const value_color = dvui.themeGet().color(.control, .text);
- const label_opts: dvui.Options = .{ .font = body, .color_text = label_color };
- const value_opts: dvui.Options = .{ .font = body, .color_text = value_color };
-
- if (fizzy.packer.last_packed_at_ns) |packed_at_ns| {
- var when_buf: [64]u8 = undefined;
- const when = formatLastPacked(&when_buf, packed_at_ns);
- tl.addText("Last packed: ", label_opts);
- tl.addText(when, value_opts);
- tl.addText("\n", value_opts);
- }
-
- var value_buf: [48]u8 = undefined;
- const sprites = std.fmt.bufPrint(&value_buf, "{d}", .{atlas.data.sprites.len}) catch "0";
- tl.addText("Sprites: ", label_opts);
- tl.addText(sprites, value_opts);
- tl.addText("\n", value_opts);
-
- const animations = std.fmt.bufPrint(&value_buf, "{d}", .{atlas.data.animations.len}) catch "0";
- tl.addText("Animations: ", label_opts);
- tl.addText(animations, value_opts);
- tl.addText("\n", value_opts);
-
- const atlas_size = std.fmt.bufPrint(&value_buf, "{d} px x {d} px", .{ atlas_w, atlas_h }) catch "0 px x 0 px";
- tl.addText("Atlas size: ", label_opts);
- tl.addText(atlas_size, value_opts);
-}
-
-fn formatLastPacked(buf: []u8, packed_at_ns: i128) []const u8 {
- const elapsed_s = @divTrunc(fizzy.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s);
- if (elapsed_s < 10) {
- return std.fmt.bufPrint(buf, "just now", .{}) catch "recently";
- }
- if (elapsed_s < 60) {
- return std.fmt.bufPrint(buf, "{d}s ago", .{elapsed_s}) catch "recently";
- }
- const elapsed_min = @divTrunc(elapsed_s, 60);
- if (elapsed_min < 60) {
- return std.fmt.bufPrint(buf, "{d} min ago", .{elapsed_min}) catch "recently";
- }
- const elapsed_hr = @divTrunc(elapsed_min, 60);
- if (elapsed_hr < 48) {
- return std.fmt.bufPrint(buf, "{d} hr ago", .{elapsed_hr}) catch "recently";
- }
- const elapsed_day = @divTrunc(elapsed_hr, 24);
- return std.fmt.bufPrint(buf, "{d} days ago", .{elapsed_day}) catch "recently";
-}
-
-/// "Pack Project" button. Same look-and-feel as `dvui.button`, but with a bubble spinner
-/// pinned to the right edge while a pack is in flight. Always interactive — rapid clicks /
-/// per-save repack triggers coalesce via `Editor.startPackProject` cancelling predecessors.
-fn packProjectButton(packing: bool) bool {
- var bw: dvui.ButtonWidget = undefined;
- bw.init(@src(), .{ .draw_focus = false }, .{
- .expand = .horizontal,
- .style = .highlight,
- });
- defer bw.deinit();
-
- bw.processEvents();
- bw.drawBackground();
- const clicked = bw.clicked();
-
- // Center label across the full button rect via gravity. Mirrors `dvui.button`'s call
- // signature so the text picks up the same hovered/pressed colors.
- const label_text: []const u8 = if (packing) "Packing…" else "Pack Project";
- const content_opts = (dvui.Options{}).strip().override(bw.style()).override(.{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- });
- dvui.labelNoFmt(@src(), label_text, .{ .align_x = 0.5, .align_y = 0.5 }, content_opts);
-
- // Spinner overlays at the right edge — same content rect as the label, but anchored to
- // `gravity_x = 1.0`. Sized to roughly match the cap height so it doesn't fight the label.
- if (packing) {
- fizzy.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{
- .min_size_content = .{ .w = 16, .h = 16 },
- .gravity_x = 1.0,
- .gravity_y = 0.5,
- .padding = .{ .w = 4 },
- }), .{});
- }
-
- bw.drawFocus();
- return clicked;
-}
-
-pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void {
- if (fizzy.editor.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;
- }
- }
- }
-}
-
-pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void {
- if (fizzy.editor.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;
- }
- }
- }
-}
-
-/// Wasm-specific simplified pack pane. No folder, no `.fizproject` UI — just
-/// the Pack button (operates on currently-open files) and Download buttons for
-/// the resulting atlas/image data.
-fn drawWeb() !void {
- if (fizzy.editor.open_files.count() == 0) {
- dvui.labelNoFmt(
- @src(),
- "Open one or more files to pack.",
- .{},
- .{ .color_text = dvui.themeGet().color(.control, .text) },
- );
- return;
- }
-
- var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal });
- defer vbox.deinit();
-
- const btn_opts = dvui.Options{
- .expand = .horizontal,
- .style = .highlight,
- };
-
- const packing = fizzy.editor.isPackingActive();
- if (packProjectButton(packing)) {
- fizzy.editor.startPackProject() catch |err| {
- dvui.log.err("Failed to pack open files: {any}", .{err});
- };
- }
-
- if (fizzy.packer.atlas != null) {
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } });
- drawPackedAtlasStats();
- }
-
- if (fizzy.packer.atlas) |atlas| {
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } });
- if (dvui.button(@src(), "Download Atlas JSON", .{ .draw_focus = false }, btn_opts)) {
- atlas.save("atlas.atlas", .data) catch {
- dvui.log.err("Failed to download atlas data", .{});
- };
- }
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } });
- if (dvui.button(@src(), "Download Atlas PNG", .{ .draw_focus = false }, btn_opts)) {
- atlas.save("atlas.png", .source) catch {
- dvui.log.err("Failed to download atlas image", .{});
- };
- }
- }
-}
diff --git a/src/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/explorer/sprites.zig b/src/editor/explorer/sprites.zig
deleted file mode 100644
index 8e0caea8..00000000
--- a/src/editor/explorer/sprites.zig
+++ /dev/null
@@ -1,2499 +0,0 @@
-const std = @import("std");
-const dvui = @import("dvui");
-const icons = @import("icons");
-
-const fizzy = @import("../../fizzy.zig");
-const Editor = fizzy.Editor;
-
-const Sprites = @This();
-
-/// Edge shadows on animation/frame lists only when scroll range exceeds this (avoids subpixel overflow).
-const scroll_list_shadow_deadzone_ns: f32 = 4.0;
-
-fn pointerReleaseInRectWithoutSelectionModifier(r: dvui.Rect.Physical) bool {
- for (dvui.events()) |*e| {
- switch (e.evt) {
- .mouse => |me| {
- if (me.action == .release and me.button.pointer() and r.contains(me.p)) {
- return !me.mod.shift() and !me.mod.control() and !me.mod.command();
- }
- },
- else => {},
- }
- }
- return false;
-}
-
-/// In-flight primary-button gesture for the animation list (reorder / click / rename).
-const AnimationRowGesture = struct {
- file_id: u64,
- press_idx: usize,
- press_p: dvui.Point.Physical,
- drag_branch: ?usize,
- moved: bool,
- reorder_drag: bool,
- /// Finder-style: plain click on an already-multi-selected row preserves the set while the
- /// user might start a drag; if they release without dragging, narrow to just this row.
- narrow_on_release: bool,
-};
-var animation_row_gesture: ?AnimationRowGesture = null;
-var anim_rename_hit_te_id: ?dvui.Id = null;
-var anim_rename_hit_rect: ?dvui.Rect.Physical = null;
-
-/// In-flight primary-button gesture for the frame list (reorder / click).
-const FrameRowGesture = struct {
- file_id: u64,
- anim_id: u64,
- press_idx: usize,
- press_p: dvui.Point.Physical,
- drag_branch: ?usize,
- moved: bool,
- reorder_drag: bool,
- narrow_on_release: bool,
-};
-var frame_row_gesture: ?FrameRowGesture = null;
-
-/// Sorted (ascending) indices whose animation-tree branch reported `removed()` last frame. Used
-/// by the drop handler to move multiple selected animations as a group.
-var removed_animation_indices_buf: [64]usize = undefined;
-var removed_animation_indices_len: usize = 0;
-
-/// Sorted (ascending) frame indices whose frame-tree branch reported `removed()` last frame.
-var removed_frame_indices_buf: [256]usize = undefined;
-var removed_frame_indices_len: usize = 0;
-
-animation_insert_before_index: ?usize = null,
-sprite_insert_before_index: ?usize = null,
-edit_anim_id: ?u64 = null,
-prev_anim_count: usize = 0,
-prev_anim_id: u64 = 0,
-prev_sprite_count: usize = 0,
-
-/// Origin axis values for sprites tab (slider + text); resync when `origin_fields_sync_key` changes.
-origin_edit_x: f32 = 0,
-origin_edit_y: f32 = 0,
-origin_fields_sync_key: u64 = 0,
-
-/// Mouse-drag batching for origin sliders: snapshot until drag ends, then one history step if origins changed.
-origin_x_drag_indices: ?[]usize = null,
-origin_x_drag_old_vals: ?[][2]f32 = null,
-origin_x_slider_drag_prev: bool = false,
-origin_y_drag_indices: ?[]usize = null,
-origin_y_drag_old_vals: ?[][2]f32 = null,
-origin_y_slider_drag_prev: bool = false,
-
-/// Visible clip of the animation list scroll area (for pointer gating, same idea as layers).
-animations_scroll_viewport_rect: ?dvui.Rect.Physical = null,
-/// Visible clip of the frames list scroll area.
-frames_scroll_viewport_rect: ?dvui.Rect.Physical = null,
-
-pub fn init() Sprites {
- return .{};
-}
-
-fn selectionUiKey(file: *fizzy.Internal.File) u64 {
- const c = file.editor.selected_sprites.count();
- if (c == 0) return 0;
- const first = file.editor.selected_sprites.findFirstSet() orelse return 0;
- const last = file.editor.selected_sprites.findLastSet() orelse return 0;
- // Widen to u64 before shifting: `usize` is `u32` on wasm32, so `c << 48`
- // would overflow without the cast.
- return (@as(u64, c) << 48) ^ (@as(u64, first) << 24) ^ @as(u64, last);
-}
-
-fn selectionOriginsDifferFrom(file: *fizzy.Internal.File, indices: []const usize, old_vals: []const [2]f32) bool {
- for (indices, old_vals) |si, ov| {
- const cur = file.sprites.get(si).origin;
- if (cur[0] != ov[0] or cur[1] != ov[1]) return true;
- }
- return false;
-}
-
-fn freeOriginAxisDragSnapshot(self: *Sprites, axis: enum { x, y }) void {
- switch (axis) {
- .x => {
- if (self.origin_x_drag_indices) |s| {
- fizzy.app.allocator.free(s);
- self.origin_x_drag_indices = null;
- }
- if (self.origin_x_drag_old_vals) |v| {
- fizzy.app.allocator.free(v);
- self.origin_x_drag_old_vals = null;
- }
- },
- .y => {
- if (self.origin_y_drag_indices) |s| {
- fizzy.app.allocator.free(s);
- self.origin_y_drag_indices = null;
- }
- if (self.origin_y_drag_old_vals) |v| {
- fizzy.app.allocator.free(v);
- self.origin_y_drag_old_vals = null;
- }
- },
- }
-}
-
-fn beginOriginAxisDragSnapshot(self: *Sprites, file: *fizzy.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);
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- var i: usize = 0;
- while (iter.next()) |si| : (i += 1) {
- indices[i] = si;
- old_vals[i] = file.sprites.items(.origin)[si];
- }
- switch (axis) {
- .x => {
- self.origin_x_drag_indices = indices;
- self.origin_x_drag_old_vals = old_vals;
- },
- .y => {
- self.origin_y_drag_indices = indices;
- self.origin_y_drag_old_vals = old_vals;
- },
- }
-}
-
-fn appendOriginsHistory(file: *fizzy.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);
- return err;
- };
-}
-
-fn applySpriteOriginAxisNoHistory(file: *fizzy.Internal.File, axis: enum { x, y }, new_val: f32) void {
- const cw = @as(f32, @floatFromInt(file.column_width));
- const rh = @as(f32, @floatFromInt(file.row_height));
- const max_v: f32 = switch (axis) {
- .x => cw,
- .y => rh,
- };
- const clamped = std.math.clamp(new_val, 0, max_v);
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |si| {
- switch (axis) {
- .x => file.sprites.items(.origin)[si][0] = clamped,
- .y => file.sprites.items(.origin)[si][1] = clamped,
- }
- }
-}
-
-fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_val: f32) !void {
- const cw = @as(f32, @floatFromInt(file.column_width));
- const rh = @as(f32, @floatFromInt(file.row_height));
- const max_v: f32 = switch (axis) {
- .x => cw,
- .y => rh,
- };
- const clamped = std.math.clamp(new_val, 0, max_v);
-
- const count = file.editor.selected_sprites.count();
- if (count == 0) return;
-
- const indices = try 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);
-
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- var i: usize = 0;
- while (iter.next()) |si| : (i += 1) {
- indices[i] = si;
- old_vals[i] = file.sprites.items(.origin)[si];
- }
-
- for (indices) |si| {
- switch (axis) {
- .x => file.sprites.items(.origin)[si][0] = clamped,
- .y => file.sprites.items(.origin)[si][1] = clamped,
- }
- }
-
- file.history.append(.{ .origins = .{ .indices = indices, .values = old_vals } }) catch |err| {
- for (indices, 0..) |si, j| {
- file.sprites.items(.origin)[si] = old_vals[j];
- }
- fizzy.app.allocator.free(indices);
- fizzy.app.allocator.free(old_vals);
- return err;
- };
-}
-
-pub fn draw(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
- const parent_height = dvui.parentGet().data().rect.h - 2.0 * dvui.currentWindow().natural_scale;
- const parent_data = dvui.parentGet().data();
-
- const vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .max_size_content = .{ .w = std.math.floatMax(f32), .h = parent_height },
- });
- defer vbox.deinit();
-
- const hbox = dvui.box(@src(), .{
- .dir = .vertical,
- .equal_space = false,
- }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer hbox.deinit();
-
- self.drawOriginControls() catch {
- dvui.log.err("Failed to draw origin controls", .{});
- };
-
- {
- var animations_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .horizontal,
- });
- defer animations_box.deinit();
-
- self.drawAnimations() catch {
- dvui.log.err("Failed to draw layers", .{});
- };
-
- if (file.selected_animation_index != null) {
- self.drawFrames() catch {
- dvui.log.err("Failed to draw sprites", .{});
- };
- }
- }
-
- for (dvui.events()) |*e| {
- if (e.evt == .mouse and e.evt.mouse.action == .press) {
- if (dvui.eventMatchSimple(e, parent_data)) {
- const p = e.evt.mouse.p;
- var in_sprite_list: bool = false;
- if (self.animations_scroll_viewport_rect) |r| {
- if (r.contains(p)) in_sprite_list = true;
- }
- if (self.frames_scroll_viewport_rect) |r| {
- if (r.contains(p)) in_sprite_list = true;
- }
- if (!in_sprite_list) {
- file.clearSelectedSprites();
- }
- }
- }
- }
- }
-}
-
-pub fn drawOriginControls(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
- if (file.editor.selected_sprites.count() == 0) return;
-
- const key = selectionUiKey(file);
- if (key != self.origin_fields_sync_key) {
- self.origin_fields_sync_key = key;
- freeOriginAxisDragSnapshot(self, .x);
- freeOriginAxisDragSnapshot(self, .y);
- self.origin_x_slider_drag_prev = false;
- self.origin_y_slider_drag_prev = false;
-
- var ox_unified: ?f32 = null;
- var oy_unified: ?f32 = null;
- if (file.editor.selected_sprites.findFirstSet()) |first_si| {
- const first_sp = file.sprites.get(first_si);
- ox_unified = first_sp.origin[0];
- oy_unified = first_sp.origin[1];
-
- var iter = file.editor.selected_sprites.iterator(.{ .direction = .forward, .kind = .set });
- while (iter.next()) |si| {
- const sp = file.sprites.get(si);
- if (ox_unified) |u| {
- if (sp.origin[0] != u) ox_unified = null;
- }
- if (oy_unified) |u| {
- if (sp.origin[1] != u) oy_unified = null;
- }
- if (ox_unified == null and oy_unified == null) break;
- }
- }
-
- self.origin_edit_x = ox_unified orelse if (file.editor.selected_sprites.findFirstSet()) |first_si| file.sprites.get(first_si).origin[0] else 0;
- self.origin_edit_y = oy_unified orelse if (file.editor.selected_sprites.findFirstSet()) |first_si| file.sprites.get(first_si).origin[1] else 0;
- }
-
- const cw = @as(f32, @floatFromInt(file.column_width));
- const rh = @as(f32, @floatFromInt(file.row_height));
-
- var mixed_x = false;
- var mixed_y = false;
- if (file.editor.selected_sprites.findFirstSet()) |first_si| {
- const o0 = file.sprites.get(first_si).origin;
- var iter = file.editor.selected_sprites.iterator(.{ .direction = .forward, .kind = .set });
- while (iter.next()) |si| {
- const o = file.sprites.get(si).origin;
- if (o[0] != o0[0]) mixed_x = true;
- if (o[1] != o0[1]) mixed_y = true;
- }
- }
-
- var origin_group = dvui.groupBox(@src(), "Origin", .{
- .expand = .horizontal,
- });
- defer origin_group.deinit();
-
- var animation = dvui.animate(@src(), .{ .duration = 400_000, .easing = dvui.easing.outBack, .kind = .vertical }, .{
- .expand = .horizontal,
- });
- defer animation.deinit();
-
- var fields = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .horizontal,
- });
- defer fields.deinit();
-
- {
- var row = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .horizontal,
- });
- defer row.deinit();
-
- dvui.labelNoFmt(@src(), "X", .{}, .{ .font = dvui.Font.theme(.body) });
- if (mixed_x) {
- dvui.icon(@src(), "OriginXIcon", icons.tvg.lucide.@"link-2-off", .{
- .stroke_color = dvui.themeGet().color(.control, .text),
- }, .{
- .gravity_y = 0.5,
- .expand = .none,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- });
- } else {
- dvui.icon(@src(), "OriginXIcon", icons.tvg.lucide.@"link-2", .{
- .stroke_color = dvui.themeGet().color(.control, .text),
- }, .{
- .gravity_y = 0.5,
- .expand = .none,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- });
- }
- var x_slider_wd: dvui.WidgetData = undefined;
- const x_changed = dvui.sliderEntry(@src(), "{d:0.0}", .{
- .value = &self.origin_edit_x,
- .min = 0,
- .max = cw,
- .interval = 1,
- }, .{
- .id_extra = 0xb00001,
- .expand = .horizontal,
- .data_out = &x_slider_wd,
- });
- const x_slider_dragging = dvui.dataGet(null, x_slider_wd.id, "_start_v", f32) != null;
-
- if (x_slider_dragging and self.origin_x_drag_indices == null) {
- try beginOriginAxisDragSnapshot(self, file, .x);
- }
-
- if (x_changed) {
- const cl = std.math.clamp(self.origin_edit_x, 0, cw);
- if (x_slider_dragging) {
- applySpriteOriginAxisNoHistory(file, .x, cl);
- } else {
- freeOriginAxisDragSnapshot(self, .x);
- try commitSpriteOriginAxis(file, .x, cl);
- }
- self.origin_edit_x = cl;
- }
-
- if (self.origin_x_slider_drag_prev and !x_slider_dragging) {
- if (self.origin_x_drag_indices) |indices| {
- const old_vals = self.origin_x_drag_old_vals.?;
- defer {
- self.origin_x_drag_indices = null;
- self.origin_x_drag_old_vals = null;
- }
- if (selectionOriginsDifferFrom(file, indices, old_vals)) {
- try appendOriginsHistory(file, indices, old_vals);
- } else {
- fizzy.app.allocator.free(indices);
- fizzy.app.allocator.free(old_vals);
- }
- }
- }
- self.origin_x_slider_drag_prev = x_slider_dragging;
- }
- {
- var row = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .horizontal,
- });
- defer row.deinit();
-
- dvui.labelNoFmt(@src(), "Y", .{}, .{ .font = dvui.Font.theme(.body) });
- if (mixed_y) {
- dvui.icon(@src(), "OriginYIcon", icons.tvg.lucide.@"link-2-off", .{
- .stroke_color = dvui.themeGet().color(.control, .text),
- }, .{
- .gravity_y = 0.5,
- .expand = .none,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- });
- } else {
- dvui.icon(@src(), "OriginYIcon", icons.tvg.lucide.@"link-2", .{
- .stroke_color = dvui.themeGet().color(.control, .text),
- }, .{
- .gravity_y = 0.5,
- .expand = .none,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- });
- }
- var y_slider_wd: dvui.WidgetData = undefined;
- const y_changed = dvui.sliderEntry(@src(), "{d:0.0}", .{
- .value = &self.origin_edit_y,
- .min = 0,
- .max = rh,
- .interval = 1,
- }, .{
- .id_extra = 0xb00002,
- .expand = .horizontal,
- .data_out = &y_slider_wd,
- });
- const y_slider_dragging = dvui.dataGet(null, y_slider_wd.id, "_start_v", f32) != null;
-
- if (y_slider_dragging and self.origin_y_drag_indices == null) {
- try beginOriginAxisDragSnapshot(self, file, .y);
- }
-
- if (y_changed) {
- const cl = std.math.clamp(self.origin_edit_y, 0, rh);
- if (y_slider_dragging) {
- applySpriteOriginAxisNoHistory(file, .y, cl);
- } else {
- freeOriginAxisDragSnapshot(self, .y);
- try commitSpriteOriginAxis(file, .y, cl);
- }
- self.origin_edit_y = cl;
- }
-
- if (self.origin_y_slider_drag_prev and !y_slider_dragging) {
- if (self.origin_y_drag_indices) |indices| {
- const old_vals = self.origin_y_drag_old_vals.?;
- defer {
- self.origin_y_drag_indices = null;
- self.origin_y_drag_old_vals = null;
- }
- if (selectionOriginsDifferFrom(file, indices, old_vals)) {
- try appendOriginsHistory(file, indices, old_vals);
- } else {
- fizzy.app.allocator.free(indices);
- fizzy.app.allocator.free(old_vals);
- }
- }
- }
- self.origin_y_slider_drag_prev = y_slider_dragging;
- }
- }
-}
-
-pub fn drawAnimationControls(self: *Sprites) !void {
- var box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- });
- defer box.deinit();
-
- const icon_color = dvui.themeGet().color(.control, .text);
-
- if (fizzy.editor.activeFile()) |file| {
- {
- var add_animation_button: dvui.ButtonWidget = undefined;
- add_animation_button.init(@src(), .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- });
- defer add_animation_button.deinit();
-
- add_animation_button.processEvents();
- add_animation_button.drawBackground();
-
- dvui.icon(
- @src(),
- "AddAnimationIcon",
- icons.tvg.lucide.plus,
- .{
- .fill_color = icon_color,
- .stroke_color = icon_color,
- },
- .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .expand = .ratio,
- .color_text = add_animation_button.data().options.color_text,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- },
- );
-
- if (add_animation_button.clicked()) {
- const anim_index = try file.createAnimation();
- file.selected_animation_index = anim_index;
- file.editor.animations_scroll_to_index = anim_index;
- self.edit_anim_id = file.animations.items(.id)[anim_index];
-
- file.history.append(.{
- .animation_restore_delete = .{
- .action = .delete,
- .index = anim_index,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- }
- }
-
- {
- var duplicate_animation_button: dvui.ButtonWidget = undefined;
- duplicate_animation_button.init(@src(), .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- });
-
- defer duplicate_animation_button.deinit();
- duplicate_animation_button.processEvents();
- const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5);
- duplicate_animation_button.drawBackground();
-
- dvui.icon(
- @src(),
- "DuplicateAnimationIcon",
- icons.tvg.lucide.@"copy-plus",
- .{ .fill_color = icon_color, .stroke_color = icon_color },
- .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .expand = .ratio,
- .color_text = duplicate_animation_button.data().options.color_text,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- },
- );
-
- dvui.alphaSet(alpha);
-
- if (duplicate_animation_button.clicked()) {
- if (file.animations.len > 0) {
- if (file.selected_animation_index) |index| {
- const anim_index = try file.duplicateAnimation(index);
- file.selected_animation_index = anim_index;
- file.editor.animations_scroll_to_index = anim_index;
- self.edit_anim_id = file.animations.items(.id)[anim_index];
-
- file.history.append(.{
- .animation_restore_delete = .{
- .action = .delete,
- .index = anim_index,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- }
- }
- }
- }
-
- {
- var delete_animation_button: dvui.ButtonWidget = undefined;
- delete_animation_button.init(@src(), .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.err, .fill),
- });
- defer delete_animation_button.deinit();
- delete_animation_button.processEvents();
-
- const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5);
- delete_animation_button.drawBackground();
-
- dvui.icon(
- @src(),
- "DeleteAnimationIcon",
- icons.tvg.lucide.trash,
- .{ .fill_color = dvui.themeGet().color(.window, .fill), .stroke_color = dvui.themeGet().color(.window, .fill) },
- .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .expand = .ratio,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- },
- );
-
- dvui.alphaSet(alpha);
-
- if (delete_animation_button.clicked()) {
- if (file.animations.len > 0) {
- if (file.selected_animation_index) |index| {
- file.deleteAnimation(index) catch {
- dvui.log.err("Failed to delete animation", .{});
- };
- if (index > 0) {
- file.selected_animation_index = index - 1;
- } else {
- file.selected_animation_index = null;
- }
- }
- }
- }
- }
- }
-}
-
-pub fn drawAnimations(self: *Sprites) !void {
- const outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer outer_box.deinit();
-
- const parent_width = dvui.parentGet().data().rect.w;
- const controls_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .horizontal,
- .background = false,
- });
- dvui.labelNoFmt(@src(), "ANIMATIONS", .{}, .{ .font = dvui.Font.theme(.heading) });
-
- self.drawAnimationControls() catch {};
-
- controls_box.deinit();
-
- if (fizzy.editor.activeFile()) |file| {
- // Make sure to update the prev anim count!
- defer self.prev_anim_count = file.animations.len;
-
- self.animations_scroll_viewport_rect = null;
- anim_rename_hit_te_id = null;
- anim_rename_hit_rect = null;
-
- var scroll_area = dvui.scrollArea(@src(), .{
- .scroll_info = &file.editor.animations_scroll_info,
- .horizontal_bar = .auto_overlay,
- .vertical_bar = .auto_overlay,
- }, .{
- .expand = .horizontal,
- .background = false,
-
- .max_size_content = .{ .h = std.math.floatMax(f32), .w = parent_width / 2.0 },
- });
- defer scroll_area.deinit();
-
- if (dvui.ScrollContainerWidget.current()) |sc| {
- self.animations_scroll_viewport_rect = sc.data().contentRectScale().r;
- }
-
- var inner_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .background = false,
- .margin = .{ .h = 6, .w = 6 },
- });
- defer inner_box.deinit();
-
- defer {
- if (file.editor.animations_scroll_info.viewport.w < file.editor.animations_scroll_info.virtual_size.w) {
- if (file.editor.animations_scroll_info.offset(.horizontal) < file.editor.animations_scroll_info.scrollMax(.horizontal)) {
- fizzy.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, .{});
- }
- }
- }
-
- const vertical_scroll = file.editor.animations_scroll_info.offset(.vertical);
-
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer tree.deinit();
-
- var anim_hits_buf: [256]AnimationRowHit = undefined;
- var anim_hits_len: usize = 0;
-
- // Drag and drop is completing — supports single- and multi-row drags.
- if (self.animation_insert_before_index) |insert_before_raw| {
- if (removed_animation_indices_len > 0) {
- const sources = removed_animation_indices_buf[0..removed_animation_indices_len];
-
- const primary_before_opt = file.selected_animation_index;
- var primary_was_moved = false;
- var primary_pos_in_sources: usize = 0;
- if (primary_before_opt) |pb| {
- for (sources, 0..) |s, pi| {
- if (s == pb) {
- primary_was_moved = true;
- primary_pos_in_sources = pi;
- break;
- }
- }
- }
-
- var moved = try fizzy.app.allocator.alloc(fizzy.Internal.Animation, sources.len);
- defer fizzy.app.allocator.free(moved);
- for (sources, 0..) |s, i| {
- moved[i] = file.animations.get(s);
- }
-
- var ri = sources.len;
- while (ri > 0) {
- ri -= 1;
- file.animations.orderedRemove(sources[ri]);
- }
-
- const target_raw = fizzy.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 {
- dvui.log.err("Failed to insert animation", .{});
- };
- }
-
- if (primary_was_moved) {
- file.selected_animation_index = target + primary_pos_in_sources;
- }
-
- file.editor.selected_animation_indices.clearRetainingCapacity();
- for (0..moved.len) |i| {
- file.editor.selected_animation_indices.append(fizzy.app.allocator, target + i) catch {
- dvui.log.err("Failed to update animation selection", .{});
- };
- }
- file.editor.animation_selection_anchor = file.selected_animation_index;
-
- self.animation_insert_before_index = null;
- removed_animation_indices_len = 0;
- } else {
- self.animation_insert_before_index = null;
- }
- } else if (removed_animation_indices_len > 0) {
- removed_animation_indices_len = 0;
- }
-
- ensureAnimationSelection(file);
-
- const box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .horizontal,
- .background = false,
- .corner_radius = dvui.Rect.all(1000),
- .margin = dvui.Rect.rect(4, 0, 4, 4),
- });
- defer box.deinit();
-
- const no_buttons_r: dvui.Rect.Physical = .{ .x = 0, .y = 0, .w = 0, .h = 0 };
-
- for (file.animations.items(.id), 0..) |anim_id, anim_index| {
- const in_multi = animationIndexInMulti(file, anim_index);
- const is_primary_row = if (file.selected_animation_index) |p| p == anim_index else false;
- const selected = if (self.edit_anim_id) |id| id == anim_id else (is_primary_row or in_multi);
-
- var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
- color = palette.getDVUIColor(@intCast(anim_id));
- }
-
- var branch = tree.branch(@src(), .{
- .expanded = false,
- .process_events = false,
- .can_accept_children = false,
- .animation_duration = 250_000,
- .animation_easing = dvui.easing.outBack,
- }, .{
- .id_extra = @intCast(anim_id),
- .expand = .horizontal,
- .corner_radius = dvui.Rect.all(1000),
- .background = false,
- .margin = .all(0),
- .padding = dvui.Rect.all(1),
- });
- defer branch.deinit();
-
- if (branch.removed()) {
- if (removed_animation_indices_len < removed_animation_indices_buf.len) {
- removed_animation_indices_buf[removed_animation_indices_len] = anim_index;
- removed_animation_indices_len += 1;
- }
- } else if (branch.insertBefore()) {
- self.animation_insert_before_index = anim_index;
- }
-
- const row_r = branch.data().borderRectScale().r;
- const mp = dvui.currentWindow().mouse_pt;
- const row_hovered = row_r.contains(mp) and animationPointerInScrollViewport(mp, self.animations_scroll_viewport_rect);
-
- const ctrl_hover = dvui.themeGet().color(.control, .fill).opacity(0.5);
- const row_highlight = blk: {
- if (tree.reorderDragActive()) {
- if (tree.id_branch) |idb| {
- break :blk idb == branch.data().id.asUsize();
- }
- break :blk false;
- }
- break :blk row_hovered and tree.drag_point == null;
- };
-
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .both,
- .background = true,
- .color_fill = if (branch.floating())
- .transparent
- else if (selected or row_highlight)
- ctrl_hover
- else
- .transparent,
- .color_fill_hover = .transparent,
- .margin = .all(0),
- .padding = dvui.Rect.all(5),
- .corner_radius = dvui.Rect.all(8),
- });
- defer hbox.deinit();
-
- var color_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .background = true,
- .gravity_y = 0.5,
- .min_size_content = .{ .w = 8.0, .h = 8.0 },
- .color_fill = color,
- .corner_radius = dvui.Rect.all(1000),
- .margin = dvui.Rect.all(2),
- .padding = dvui.Rect.all(0),
- });
- color_box.deinit();
-
- const font = dvui.Font.theme(.body);
- const rename_padding = dvui.Rect.all(0);
-
- if (self.edit_anim_id != anim_id) {
- var name_label_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .background = false,
- .gravity_y = 0.5,
- .margin = dvui.Rect.rect(2, 0, 2, 0),
- .padding = dvui.Rect.all(0),
- });
- defer name_label_box.deinit();
-
- const anim_name = file.animations.items(.name)[anim_index];
- const name_color: dvui.Color = if (!selected)
- dvui.themeGet().color(.control, .text)
- else if (is_primary_row)
- dvui.themeGet().color(.window, .text)
- else
- dvui.themeGet().color(.control, .text);
-
- if (selected) {
- if (dvui.labelClick(@src(), "{s}", .{anim_name}, .{ .label_opts = .{ .ellipsize = true } }, .{
- .expand = .none,
- .gravity_y = 0.5,
- .margin = dvui.Rect{},
- .font = font,
- .padding = .{ .y = 1 },
- .color_text = name_color,
- })) {
- const lr = name_label_box.data().borderRectScale().r;
- if (pointerReleaseInRectWithoutSelectionModifier(lr)) {
- self.edit_anim_id = anim_id;
- }
- }
- } else {
- dvui.labelNoFmt(@src(), anim_name, .{ .ellipsize = true }, .{
- .expand = .none,
- .gravity_y = 0.5,
- .margin = dvui.Rect{},
- .font = font,
- .padding = .{ .y = 1 },
- .color_text = name_color,
- });
- }
-
- var drag_sink = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .both,
- .background = false,
- .min_size_content = .{ .w = 0, .h = 0 },
- .gravity_y = 0.5,
- });
- defer drag_sink.deinit();
-
- if (row_hovered and animationPointerInScrollViewport(mp, self.animations_scroll_viewport_rect)) {
- dvui.cursorSet(.hand);
- }
-
- if (anim_hits_len < anim_hits_buf.len) {
- anim_hits_buf[anim_hits_len] = .{
- .row_r = branch.data().borderRectScale().r,
- .buttons_r = no_buttons_r,
- .branch_usize = branch.data().id.asUsize(),
- .anim_index = anim_index,
- .hbox_tl = hbox.data().rectScale().r.topLeft(),
- };
- anim_hits_len += 1;
- }
- } else {
- var te = dvui.textEntry(@src(), .{}, .{
- .expand = .horizontal,
- .background = false,
- .padding = rename_padding,
- .margin = dvui.Rect.all(0),
- .font = font,
- .gravity_y = 0.5,
- });
- defer te.deinit();
-
- if (dvui.firstFrame(te.data().id)) {
- te.textSet(file.animations.items(.name)[anim_index], true);
- dvui.focusWidget(te.data().id, null, null);
- }
-
- anim_rename_hit_te_id = te.data().id;
- anim_rename_hit_rect = te.data().borderRectScale().r;
-
- const should_commit_rename = te.enter_pressed or dvui.focusedWidgetId() != te.data().id;
- if (should_commit_rename) {
- if (!std.mem.eql(u8, file.animations.items(.name)[anim_index], te.getText()) and te.getText().len > 0) {
- file.history.append(.{
- .animation_name = .{
- .index = anim_index,
- .name = try fizzy.app.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());
- }
- if (te.enter_pressed) {
- file.selected_animation_index = anim_index;
- }
- dvui.captureMouse(null, 0);
- dvui.focusWidget(null, null, null);
- self.edit_anim_id = null;
- dvui.refresh(null, @src(), tree.data().id);
- }
- }
-
- if (file.editor.animations_scroll_to_index != null and dvui.timerGet(hbox.data().id) == null) {
- dvui.timer(hbox.data().id, 1);
- }
-
- if (dvui.timerDone(hbox.data().id)) {
- if (file.editor.animations_scroll_to_index) |index| {
- if (index == anim_index) {
- dvui.scrollTo(.{ .screen_rect = hbox.data().rectScale().r, .over_scroll = true });
- file.editor.animations_scroll_to_index = null;
- }
- }
- }
- }
-
- processAnimationTreePointerEvents(self, tree, file, anim_hits_buf[0..anim_hits_len], self.animations_scroll_viewport_rect);
-
- if (tree.drag_point != null) {
- var tail = tree.branch(@src(), .{
- .expanded = false,
- .process_events = false,
- .can_accept_children = false,
- }, .{
- .id_extra = 0x7fff_fffd,
- .expand = .horizontal,
- .min_size_content = .{ .w = 0, .h = 14 },
- .color_fill = .transparent,
- .color_fill_hover = .transparent,
- .color_fill_press = .transparent,
- });
- defer tail.deinit();
- if (tail.insertBefore()) {
- self.animation_insert_before_index = file.animations.len;
- }
- }
-
- const anim_si = file.editor.animations_scroll_info;
- const anim_v_max = anim_si.scrollMax(.vertical);
- if (vertical_scroll > scroll_list_shadow_deadzone_ns)
- fizzy.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, .{});
- }
-}
-
-pub fn drawFrameControls(_: *Sprites) !void {
- var box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- });
- defer box.deinit();
-
- if (fizzy.editor.activeFile()) |file| {
- const index = if (file.selected_animation_index) |i| i else 0;
- var animation = file.animations.get(index);
-
- const icon_color = dvui.themeGet().color(.control, .text);
-
- {
- var sort_anim_asc_button: dvui.ButtonWidget = undefined;
- sort_anim_asc_button.init(@src(), .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- });
-
- defer sort_anim_asc_button.deinit();
- sort_anim_asc_button.processEvents();
- const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5);
- sort_anim_asc_button.drawBackground();
-
- dvui.icon(
- @src(),
- "SortAnimationAscIcon",
- icons.tvg.lucide.@"arrow-up-from-line",
- .{ .fill_color = icon_color, .stroke_color = icon_color },
- .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .expand = .ratio,
- .color_text = sort_anim_asc_button.data().options.color_text,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- },
- );
-
- dvui.alphaSet(alpha);
-
- if (sort_anim_asc_button.clicked()) {
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
- std.mem.sort(fizzy.Animation.Frame, animation.frames, {}, FrameSort.asc);
-
- if (!animation.eqlFrames(prev_order)) {
- file.history.append(.{
- .animation_frames = .{
- .index = index,
- .frames = prev_order,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
-
- file.animations.set(index, animation);
- } else {
- fizzy.app.allocator.free(prev_order);
- }
- }
- }
- {
- {
- var sort_anim_desc_button: dvui.ButtonWidget = undefined;
- sort_anim_desc_button.init(@src(), .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- });
-
- defer sort_anim_desc_button.deinit();
- sort_anim_desc_button.processEvents();
- const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5);
- sort_anim_desc_button.drawBackground();
-
- dvui.icon(
- @src(),
- "SortAnimationDescIcon",
- icons.tvg.lucide.@"arrow-down-from-line",
- .{ .fill_color = icon_color, .stroke_color = icon_color },
- .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .expand = .ratio,
- .color_text = sort_anim_desc_button.data().options.color_text,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- },
- );
-
- dvui.alphaSet(alpha);
-
- if (sort_anim_desc_button.clicked()) {
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
- std.mem.sort(fizzy.Animation.Frame, animation.frames, {}, FrameSort.desc);
-
- if (!animation.eqlFrames(prev_order)) {
- file.history.append(.{
- .animation_frames = .{
- .index = index,
- .frames = prev_order,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
-
- file.animations.set(index, animation);
- } else {
- fizzy.app.allocator.free(prev_order);
- }
- }
- }
- }
-
- {
- var add_sprite_button: dvui.ButtonWidget = undefined;
- add_sprite_button.init(@src(), .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- });
-
- defer add_sprite_button.deinit();
- add_sprite_button.processEvents();
- const alpha = dvui.alpha(if (file.selected_animation_index != null and file.animations.len > 0) 1.0 else 0.5);
- add_sprite_button.drawBackground();
-
- dvui.icon(
- @src(),
- "AddSpriteIcon",
- icons.tvg.lucide.plus,
- .{ .fill_color = icon_color, .stroke_color = icon_color },
- .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .expand = .ratio,
- .color_text = add_sprite_button.data().options.color_text,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- },
- );
-
- dvui.alphaSet(alpha);
-
- if (add_sprite_button.clicked()) {
- if (file.editor.selected_sprites.count() > 0) {
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- var frames = std.array_list.Managed(fizzy.Animation.Frame).init(dvui.currentWindow().arena());
- while (iter.next()) |sprite_index| {
- frames.append(.{
- .sprite_index = sprite_index,
- .ms = @intFromFloat(1000.0 / @as(f32, @floatFromInt(file.editor.selected_sprites.count()))),
- }) catch {
- dvui.log.err("Failed to append frame", .{});
- return;
- };
- }
-
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
-
- animation.appendFrames(fizzy.app.allocator, frames.items) catch {
- dvui.log.err("Failed to append frames", .{});
- };
-
- if (!animation.eqlFrames(prev_order)) {
- file.history.append(.{
- .animation_frames = .{
- .index = index,
- .frames = prev_order,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
-
- file.animations.set(index, animation);
- } else {
- fizzy.app.allocator.free(prev_order);
- }
- }
- }
- }
-
- var selection_in_animation = false;
-
- var selection_iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- blk: while (selection_iter.next()) |sprite_index| {
- for (animation.frames) |frame| {
- if (frame.sprite_index == sprite_index) {
- selection_in_animation = true;
- break :blk;
- }
- }
- }
-
- {
- var duplicate_animation_button: dvui.ButtonWidget = undefined;
- duplicate_animation_button.init(@src(), .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- });
-
- defer duplicate_animation_button.deinit();
- duplicate_animation_button.processEvents();
- const alpha = dvui.alpha(if (selection_in_animation) 1.0 else 0.5);
- duplicate_animation_button.drawBackground();
-
- dvui.icon(
- @src(),
- "DuplicateAnimationIcon",
- icons.tvg.lucide.@"copy-plus",
- .{ .fill_color = icon_color, .stroke_color = icon_color },
- .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .expand = .ratio,
- .color_text = duplicate_animation_button.data().options.color_text,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- },
- );
-
- dvui.alphaSet(alpha);
-
- if (duplicate_animation_button.clicked()) {
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- const prev_order = try fizzy.app.allocator.dupe(fizzy.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, .{
- .sprite_index = frame.sprite_index,
- .ms = frame.ms,
- });
- break;
- }
- }
- }
-
- if (!animation.eqlFrames(prev_order)) {
- file.history.append(.{
- .animation_frames = .{
- .index = index,
- .frames = prev_order,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- file.selected_animation_frame_index = 0;
- file.animations.set(index, animation);
- } else {
- fizzy.app.allocator.free(prev_order);
- }
- }
- }
-
- {
- var delete_animation_button: dvui.ButtonWidget = undefined;
- delete_animation_button.init(@src(), .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .padding = dvui.Rect.all(4),
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.err, .fill).opacity(0.75),
- });
-
- defer delete_animation_button.deinit();
- delete_animation_button.processEvents();
- const alpha = dvui.alpha(if (selection_in_animation) 1.0 else 0.5);
- delete_animation_button.drawBackground();
-
- dvui.icon(
- @src(),
- "DeleteAnimationIcon",
- icons.tvg.lucide.minus,
- .{ .fill_color = dvui.themeGet().color(.err, .text), .stroke_color = dvui.themeGet().color(.err, .text) },
- .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .color_text = dvui.themeGet().color(.err, .text),
- .expand = .ratio,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- },
- );
-
- dvui.alphaSet(alpha);
-
- if (delete_animation_button.clicked()) {
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- const prev_order = try fizzy.app.allocator.dupe(fizzy.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);
- break;
- }
- }
- }
-
- if (!animation.eqlFrames(prev_order)) {
- file.history.append(.{
- .animation_frames = .{
- .index = index,
- .frames = prev_order,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- file.selected_animation_frame_index = 0;
- file.animations.set(index, animation);
- } else {
- fizzy.app.allocator.free(prev_order);
- }
- }
- }
- }
-}
-
-pub fn drawFrames(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
- var anim = dvui.animate(@src(), .{ .kind = .horizontal, .duration = 450_000, .easing = dvui.easing.outBack }, .{});
- defer anim.deinit();
-
- const outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer outer_box.deinit();
-
- const controls_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .none,
- .background = false,
- });
- dvui.labelNoFmt(@src(), "FRAMES", .{}, .{ .font = dvui.Font.theme(.heading) });
-
- self.drawFrameControls() catch {};
-
- controls_box.deinit();
-
- self.frames_scroll_viewport_rect = null;
-
- // Vertical-only: wide frame rows widen this column; the explorer scroll pans horizontally so
- // controls stay reachable. A nested horizontal scroller here fought the explorer at limits (bounce).
- file.editor.sprites_scroll_info.horizontal = .none;
- file.editor.sprites_scroll_info.viewport.x = 0;
- file.editor.sprites_scroll_info.velocity.x = 0;
-
- var scroll_area = dvui.scrollArea(@src(), .{ .scroll_info = &file.editor.sprites_scroll_info, .horizontal_bar = .hide, .vertical_bar = .auto_overlay }, .{
- .expand = .horizontal,
- .background = false,
- .corner_radius = dvui.Rect.all(1000),
- });
-
- defer scroll_area.deinit();
-
- if (dvui.ScrollContainerWidget.current()) |sc| {
- self.frames_scroll_viewport_rect = sc.data().contentRectScale().r;
- }
-
- var inner_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .background = false,
- .margin = .{ .h = 6, .w = 6 },
- });
- defer inner_box.deinit();
-
- const vertical_scroll = file.editor.sprites_scroll_info.offset(.vertical);
-
- if (file.selected_animation_index) |animation_index| {
- var animation = file.animations.get(animation_index);
- if (animation.id != self.prev_anim_id) {
- frame_row_gesture = null;
- }
-
- defer self.prev_sprite_count = animation.frames.len;
- defer self.prev_anim_id = animation.id;
-
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer tree.deinit();
-
- var frame_hits_buf: [512]FrameRowHit = undefined;
- var frame_hits_len: usize = 0;
-
- if (self.sprite_insert_before_index) |insert_before_raw| {
- if (removed_frame_indices_len > 0) {
- const sources = removed_frame_indices_buf[0..removed_frame_indices_len];
-
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
- defer file.animations.set(animation_index, animation);
-
- const primary_before = file.selected_animation_frame_index;
- var primary_was_moved = false;
- var primary_pos_in_sources: usize = 0;
- for (sources, 0..) |s, pi| {
- if (s == primary_before) {
- primary_was_moved = true;
- primary_pos_in_sources = pi;
- break;
- }
- }
-
- var moved = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, sources.len);
- defer fizzy.app.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 ri: usize = 0;
- var wi: usize = 0;
- for (animation.frames, 0..) |f, idx| {
- _ = f;
- var is_source = false;
- for (sources) |s| if (s == idx) {
- is_source = true;
- break;
- };
- if (!is_source) {
- remaining[wi] = animation.frames[idx];
- wi += 1;
- }
- ri += 1;
- }
- }
-
- const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
- const target = @min(target_raw, remaining.len);
-
- var wi: usize = 0;
- for (remaining[0..target]) |f| {
- animation.frames[wi] = f;
- wi += 1;
- }
- for (moved) |f| {
- animation.frames[wi] = f;
- wi += 1;
- }
- for (remaining[target..]) |f| {
- animation.frames[wi] = f;
- wi += 1;
- }
-
- if (primary_was_moved) {
- file.selected_animation_frame_index = target + primary_pos_in_sources;
- }
-
- file.editor.selected_frame_indices.clearRetainingCapacity();
- for (0..moved.len) |i| {
- file.editor.selected_frame_indices.append(fizzy.app.allocator, target + i) catch {
- dvui.log.err("Failed to update frame selection", .{});
- };
- }
- file.editor.selected_frame_indices_for_animation_id = animation.id;
- file.editor.frame_selection_anchor = file.selected_animation_frame_index;
- syncSpritesFromCurrentFrameSelection(file, animation_index);
-
- if (!animation.eqlFrames(prev_order)) {
- file.history.append(.{
- .animation_frames = .{
- .index = animation_index,
- .frames = prev_order,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- } else {
- fizzy.app.allocator.free(prev_order);
- }
-
- self.sprite_insert_before_index = null;
- removed_frame_indices_len = 0;
- } else {
- self.sprite_insert_before_index = null;
- }
- } else if (removed_frame_indices_len > 0) {
- removed_frame_indices_len = 0;
- }
-
- ensureFrameSelection(file, animation_index, animation.id);
-
- const box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .horizontal,
- .background = false,
- .corner_radius = dvui.Rect.all(1000),
- .margin = dvui.Rect.rect(4, 0, 4, 4),
- });
- defer box.deinit();
-
- for (animation.frames, 0..) |*frame, frame_index| {
- var anim_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
- anim_color = palette.getDVUIColor(@intCast(animation.id));
- }
-
- var branch = tree.branch(@src(), .{
- .expanded = false,
- .process_events = false,
- .can_accept_children = false,
- .animation_duration = 250_000,
- .animation_easing = dvui.easing.outBack,
- }, .{
- .id_extra = @intCast(frame_index),
- .expand = .horizontal,
- .corner_radius = dvui.Rect.all(1000),
- .background = false,
- .margin = .all(0),
- .padding = dvui.Rect.all(1),
- });
- defer branch.deinit();
-
- if (branch.removed()) {
- if (removed_frame_indices_len < removed_frame_indices_buf.len) {
- removed_frame_indices_buf[removed_frame_indices_len] = frame_index;
- removed_frame_indices_len += 1;
- }
- } else if (branch.insertBefore()) {
- self.sprite_insert_before_index = frame_index;
- }
-
- const row_r = branch.data().borderRectScale().r;
- const mp = dvui.currentWindow().mouse_pt;
- const row_hovered = row_r.contains(mp) and animationPointerInScrollViewport(mp, self.frames_scroll_viewport_rect);
-
- const sprite_selected = if (frame.sprite_index < file.editor.selected_sprites.capacity()) file.editor.selected_sprites.isSet(frame.sprite_index) else false;
- const ctrl_hover = dvui.themeGet().color(.control, .fill).opacity(0.5);
- const row_highlight = blk: {
- if (tree.reorderDragActive()) {
- if (tree.id_branch) |idb| {
- break :blk idb == branch.data().id.asUsize();
- }
- break :blk false;
- }
- break :blk row_hovered and tree.drag_point == null;
- };
-
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .both,
- .background = true,
- .color_fill = if (branch.floating())
- .transparent
- else if ((sprite_selected or row_highlight))
- ctrl_hover
- else
- .transparent,
- .color_fill_hover = .transparent,
- .margin = dvui.Rect{},
- .padding = .{ .x = 5, .y = 3, .w = 5, .h = 2 },
- .corner_radius = dvui.Rect.all(8),
- });
- defer hbox.deinit();
-
- var color_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .background = true,
- .gravity_y = 0.5,
- .min_size_content = .{ .w = 8.0, .h = 8.0 },
- .color_fill = anim_color,
- .corner_radius = dvui.Rect.all(1000),
- .margin = .{ .x = 2, .w = 4 },
- .padding = dvui.Rect.all(0),
- });
- color_box.deinit();
-
- dvui.labelNoFmt(@src(), try file.fmtSprite(dvui.currentWindow().arena(), frame.sprite_index, .grid), .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .margin = dvui.Rect.rect(2, 0, 2, 0),
- .padding = dvui.Rect.all(0),
- .corner_radius = dvui.Rect.all(1000),
- .color_text = if (sprite_selected) dvui.themeGet().color(.control, .text) else dvui.themeGet().color(.control, .text),
- });
-
- var drag_sink = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .both,
- .background = false,
- .min_size_content = .{ .w = 0, .h = 0 },
- .gravity_y = 0.5,
- });
- defer drag_sink.deinit();
-
- var ms_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .background = false,
- .gravity_y = 0.5,
- .gravity_x = 1.0,
- .padding = dvui.Rect.all(0),
- .margin = dvui.Rect.all(0),
- });
- defer ms_box.deinit();
-
- const frame_ms_text = std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{frame.ms}) catch {
- dvui.log.err("Failed to allocate frame ms text", .{});
- return;
- };
-
- const result = dvui.textEntryNumber(@src(), u32, .{ .value = &frame.ms, .min = 0, .max = 9999999 }, .{
- .expand = .horizontal,
- .background = false,
- .padding = dvui.Rect.all(2),
- .margin = dvui.Rect.all(0),
- .border = dvui.Rect.all(0),
- .min_size_content = .{
- .w = 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,
- },
- .font = dvui.Font.theme(.mono).larger(-2.0),
- .gravity_y = 0.5,
- });
-
- if (result.changed) {
- if (result.value == .Valid) {
- for (animation.frames) |*f| {
- if (file.editor.selected_sprites.isSet(f.sprite_index) and file.editor.selected_sprites.isSet(frame.sprite_index)) {
- f.ms = result.value.Valid;
- }
- }
- }
- }
-
- dvui.labelNoFmt(@src(), "ms", .{}, .{
- .gravity_y = 0.5,
- .margin = dvui.Rect.all(0),
- .font = dvui.Font.theme(.mono).larger(-4.0),
- .padding = .{ .x = 2, .w = 6 },
- });
-
- if (row_hovered and animationPointerInScrollViewport(mp, self.frames_scroll_viewport_rect)) {
- dvui.cursorSet(.hand);
- }
-
- const ms_buttons_r = ms_box.data().borderRectScale().r;
- if (frame_hits_len < frame_hits_buf.len) {
- // Hit-test the actual row chrome (hbox), not the branch shell — the branch
- // border rect can be taller/wider than the interactive row and skew pick-one
- // resolution when several rows' rects overlap the same point.
- frame_hits_buf[frame_hits_len] = .{
- .row_r = hbox.data().borderRectScale().r,
- .buttons_r = ms_buttons_r,
- .branch_usize = branch.data().id.asUsize(),
- .frame_index = frame_index,
- .sprite_index = frame.sprite_index,
- .hbox_tl = hbox.data().rectScale().r.topLeft(),
- };
- frame_hits_len += 1;
- }
- }
-
- processFrameTreePointerEvents(tree, file, animation.id, animation_index, frame_hits_buf[0..frame_hits_len], self.frames_scroll_viewport_rect);
-
- if (tree.drag_point != null) {
- var tail = tree.branch(@src(), .{
- .expanded = false,
- .process_events = false,
- .can_accept_children = false,
- }, .{
- .id_extra = 0x7fff_fffc,
- .expand = .horizontal,
- .min_size_content = .{ .w = 0, .h = 14 },
- .color_fill = .transparent,
- .color_fill_hover = .transparent,
- .color_fill_press = .transparent,
- });
- defer tail.deinit();
- if (tail.insertBefore()) {
- self.sprite_insert_before_index = animation.frames.len;
- }
- }
- }
-
- const frames_si = file.editor.sprites_scroll_info;
- const frames_v_max = frames_si.scrollMax(.vertical);
- if (vertical_scroll > scroll_list_shadow_deadzone_ns)
- fizzy.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, .{});
- }
-}
-
-/// Geometry for one frame row; used for tree pointer pass (reorder / click).
-const FrameRowHit = struct {
- row_r: dvui.Rect.Physical,
- buttons_r: dvui.Rect.Physical,
- branch_usize: usize,
- frame_index: usize,
- sprite_index: usize,
- hbox_tl: dvui.Point.Physical,
-};
-
-fn frameGestureMatches(file: *const fizzy.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 {
- frame_row_gesture = null;
-}
-
-fn frameTreeResetRowPointerGesture(_: *const fizzy.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 {
- const frames = file.animations.get(anim_index).frames;
- file.clearSelectedSprites();
- for (file.editor.selected_frame_indices.items) |fi| {
- if (fi >= frames.len) continue;
- const si = frames[fi].sprite_index;
- if (si < file.editor.selected_sprites.capacity()) file.editor.selected_sprites.set(si);
- }
-}
-
-/// Frame selection is scoped to one animation at a time. `selected_frame_indices` always mirrors
-/// `selected_sprites` for this animation's frames (so canvas changes can't leave stale tree state).
-fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id: u64) void {
- const frames = file.animations.get(anim_index).frames;
-
- if (file.editor.selected_frame_indices_for_animation_id != anim_id) {
- file.editor.selected_frame_indices.clearRetainingCapacity();
- file.editor.frame_selection_anchor = null;
- file.editor.selected_frame_indices_for_animation_id = anim_id;
- }
-
- if (frames.len == 0) {
- file.editor.selected_frame_indices.clearRetainingCapacity();
- file.editor.frame_selection_anchor = null;
- file.selected_animation_frame_index = 0;
- return;
- }
-
- if (file.selected_animation_frame_index >= frames.len) {
- file.selected_animation_frame_index = frames.len - 1;
- }
-
- file.editor.selected_frame_indices.clearRetainingCapacity();
- for (frames, 0..) |f, i| {
- if (f.sprite_index < file.editor.selected_sprites.capacity() and file.editor.selected_sprites.isSet(f.sprite_index)) {
- file.editor.selected_frame_indices.append(fizzy.app.allocator, i) catch return;
- }
- }
- std.sort.pdq(usize, file.editor.selected_frame_indices.items, {}, std.sort.asc(usize));
-
- if (file.editor.frame_selection_anchor) |a| {
- var need_reanchor = false;
- if (a >= frames.len) {
- need_reanchor = true;
- } else {
- const spr = frames[a].sprite_index;
- if (spr >= file.editor.selected_sprites.capacity() or !file.editor.selected_sprites.isSet(spr)) {
- need_reanchor = true;
- }
- }
- if (need_reanchor) {
- // While animation plays, `selected_animation_frame_index` is the playhead and must not
- // be used to re-establish shift-range / TreeSelection "primary" when the user changes
- // which frames are selected (e.g. canvas rect select).
- if (file.editor.selected_frame_indices.items.len > 0) {
- file.editor.frame_selection_anchor = file.editor.selected_frame_indices.items[0];
- } else if (file.editor.playing) {
- file.editor.frame_selection_anchor = null;
- } else {
- file.editor.frame_selection_anchor = file.selected_animation_frame_index;
- }
- }
- }
-}
-
-fn applyFrameClick(
- file: *fizzy.Internal.File,
- anim_index: usize,
- anim_id: u64,
- clicked: usize,
- mode: fizzy.dvui.TreeSelection.ClickMode,
-) !bool {
- ensureFrameSelection(file, anim_index, anim_id);
-
- const prev_multi = file.editor.selected_frame_indices.items;
-
- var clicked_in_prev = false;
- for (prev_multi) |i| {
- if (i == clicked) {
- clicked_in_prev = true;
- break;
- }
- }
- const defer_narrow = (mode == .replace and prev_multi.len > 1 and clicked_in_prev);
-
- if (defer_narrow) {
- file.selected_animation_frame_index = clicked;
- return true;
- }
-
- var out: std.ArrayList(usize) = .empty;
- defer out.deinit(fizzy.app.allocator);
-
- // When anchor is null, shift-extend uses `primary_opt` as the range endpoint. During playback
- // that index is the animated playhead, not the editor's last stable focus — use a selection
- // bound instead.
- const primary_for_tree: ?usize = if (mode == .extend and
- file.editor.playing and
- file.editor.frame_selection_anchor == null and
- file.editor.selected_frame_indices.items.len > 0) blk: {
- break :blk file.editor.selected_frame_indices.items[0];
- } else file.selected_animation_frame_index;
-
- const res = try fizzy.dvui.TreeSelection.applyClickUsize(
- fizzy.app.allocator,
- prev_multi,
- primary_for_tree,
- file.editor.frame_selection_anchor,
- clicked,
- mode,
- false,
- &out,
- );
-
- file.editor.selected_frame_indices.clearRetainingCapacity();
- try file.editor.selected_frame_indices.appendSlice(fizzy.app.allocator, out.items);
- file.editor.selected_frame_indices_for_animation_id = anim_id;
- file.editor.frame_selection_anchor = res.anchor;
- if (res.primary) |p| file.selected_animation_frame_index = p;
- syncSpritesFromCurrentFrameSelection(file, anim_index);
- return false;
-}
-
-fn narrowFrameSelectionTo(file: *fizzy.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_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 {
- const frames = file.animations.get(animation_index).frames;
- var len: usize = 0;
- const playhead = file.selected_animation_frame_index;
- const primary: usize = if (file.editor.selected_frame_indices.items.len > 0) blk: {
- for (file.editor.selected_frame_indices.items) |fi| {
- if (fi == playhead) break :blk playhead;
- }
- break :blk file.editor.selected_frame_indices.items[0];
- } else playhead;
- for (hits) |h| {
- if (h.frame_index == primary) {
- if (len < out.len) {
- out[len] = h.branch_usize;
- len += 1;
- }
- break;
- }
- }
- for (frames, 0..) |f, i| {
- if (i == primary) continue;
- if (f.sprite_index < file.editor.selected_sprites.capacity() and file.editor.selected_sprites.isSet(f.sprite_index)) {
- for (hits) |h| {
- if (h.frame_index == i) {
- if (len < out.len) {
- out[len] = h.branch_usize;
- len += 1;
- }
- break;
- }
- }
- }
- }
- return out[0..len];
-}
-
-fn processFrameTreePointerEvents(
- tree: *fizzy.dvui.TreeWidget,
- file: *fizzy.Internal.File,
- anim_id: u64,
- animation_index: usize,
- hits: []const FrameRowHit,
- viewport_r: ?dvui.Rect.Physical,
-) void {
- if (!tree.init_options.enable_reordering) return;
-
- for (dvui.events()) |*e| {
- switch (e.evt) {
- .mouse => |me| {
- if (me.action == .press and me.button.pointer()) {
- if (!animationPointerInScrollViewport(me.p, viewport_r)) continue;
-
- var row_hit: ?FrameRowHit = null;
- var ri = hits.len;
- while (ri > 0) {
- ri -= 1;
- const h = hits[ri];
- if (h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) {
- row_hit = h;
- break;
- }
- }
- if (row_hit) |h| {
- const cw = dvui.currentWindow();
- if (cw.dragging.state != .none) dvui.dragEnd();
- frameTreeClearGestureKeysOnly(file);
- dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) });
-
- const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod);
- const narrow_on_release = applyFrameClick(file, animation_index, anim_id, h.frame_index, mode) catch blk: {
- dvui.log.err("Failed to apply frame click", .{});
- break :blk false;
- };
-
- frame_row_gesture = .{
- .file_id = file.id,
- .anim_id = anim_id,
- .press_idx = h.frame_index,
- .press_p = me.p,
- .drag_branch = h.branch_usize,
- .moved = false,
- .reorder_drag = false,
- .narrow_on_release = narrow_on_release,
- };
-
- dvui.refresh(null, @src(), tree.data().id);
- } else {
- frameTreeResetRowPointerGesture(file);
- }
- continue;
- }
-
- if (me.action == .motion) {
- if (frame_row_gesture) |*g| {
- if (g.file_id == file.id and g.anim_id == anim_id) {
- const dx = me.p.x - g.press_p.x;
- const dy = me.p.y - g.press_p.y;
- if (dx * dx + dy * dy > 16.0) {
- g.moved = true;
- }
- }
- }
-
- if (tree.reorderDragActive()) {
- _ = tree.matchEvent(e);
- continue;
- }
-
- const branch_usize = if (frameGestureMatches(file, anim_id)) frame_row_gesture.?.drag_branch else null;
- if (branch_usize == null) continue;
- _ = tree.matchEvent(e);
- if (!animationTreeMotionAllowsReorder(tree, e)) continue;
-
- const prev_th = dvui.Dragging.threshold;
- dvui.Dragging.threshold = @max(prev_th, 8.0);
- defer dvui.Dragging.threshold = prev_th;
- if (dvui.dragging(me.p, null)) |_| {
- var row_size: dvui.Size = .{};
- for (hits) |h| {
- if (h.branch_usize == branch_usize.?) {
- const rn = h.row_r.toNatural();
- row_size = .{ .w = rn.w, .h = rn.h };
- break;
- }
- }
-
- var multi_buf: [256]usize = undefined;
- const multi_ids = buildFrameMultiDragIds(file, animation_index, hits, &multi_buf);
- if (multi_ids.len > 1) {
- tree.dragStartMulti(branch_usize.?, multi_ids, me.p, row_size);
- } else {
- tree.dragStart(branch_usize.?, me.p, row_size);
- }
-
- if (frame_row_gesture) |*g| {
- if (g.file_id == file.id and g.anim_id == anim_id) {
- g.reorder_drag = true;
- g.drag_branch = null;
- g.narrow_on_release = false;
- }
- }
- }
- } else if (me.action == .release and me.button.pointer()) {
- const release_in_vp = animationPointerInScrollViewport(me.p, viewport_r);
-
- var release_frame_idx: ?usize = null;
- var rj = hits.len;
- while (rj > 0) {
- rj -= 1;
- const h = hits[rj];
- if (release_in_vp and h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) {
- release_frame_idx = h.frame_index;
- break;
- }
- }
-
- const idx_opt: ?usize = if (frameGestureMatches(file, anim_id)) frame_row_gesture.?.press_idx else null;
- const did_reorder = if (frameGestureMatches(file, anim_id)) frame_row_gesture.?.reorder_drag else false;
- const narrow_on_release = if (frameGestureMatches(file, anim_id)) frame_row_gesture.?.narrow_on_release else false;
-
- var selected_on_release = false;
- // Finder-style narrow on release: only when a plain click lands & releases on
- // the same already-multi-selected row.
- if (!did_reorder and !tree.drag_ending and narrow_on_release and release_in_vp) {
- if (release_frame_idx) |rh| {
- if (idx_opt) |pi| if (rh == pi) {
- narrowFrameSelectionTo(file, animation_index, anim_id, rh);
- selected_on_release = true;
- };
- }
- }
-
- if (idx_opt != null) {
- frameTreeResetRowPointerGesture(file);
- if (!did_reorder and !dvui.captured(tree.data().id)) {
- dvui.captureMouse(null, e.num);
- }
- }
-
- if (selected_on_release) {
- dvui.refresh(null, @src(), tree.data().id);
- }
- }
- },
- else => {},
- }
- }
-}
-
-/// Geometry for one animation row; used for tree pointer pass (reorder / click / rename).
-const AnimationRowHit = struct {
- row_r: dvui.Rect.Physical,
- buttons_r: dvui.Rect.Physical,
- branch_usize: usize,
- anim_index: usize,
- hbox_tl: dvui.Point.Physical,
-};
-
-fn animationGestureMatches(file: *const fizzy.Internal.File) bool {
- return animation_row_gesture != null and animation_row_gesture.?.file_id == file.id;
-}
-
-fn animationTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void {
- animation_row_gesture = null;
-}
-
-fn animationTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void {
- dvui.dragEnd();
- animation_row_gesture = null;
-}
-
-fn animationPointerRenameConsumes(e: *const dvui.Event, me: dvui.Event.Mouse) bool {
- if (e.handled) return true;
- if (anim_rename_hit_te_id) |rid| {
- if (e.target_widgetId) |tid| {
- if (tid == rid) return true;
- }
- }
- if (anim_rename_hit_rect) |r| {
- if (r.contains(me.p)) return true;
- }
- return false;
-}
-
-fn animationPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Rect.Physical) bool {
- if (viewport_r) |r| return r.contains(p);
- return true;
-}
-
-fn animationTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
- if (floating_win != dvui.subwindowCurrentId()) return false;
- const tr = tree.data().borderRectScale().r;
- if (!tr.contains(p)) return false;
- if (!dvui.clipGet().contains(p)) return false;
- return true;
-}
-
-fn animationTreePointerInTreeBorder(tree: *fizzy.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 {
- if (e.target_widgetId) |fwid| {
- if (fwid == tree.data().id) return true;
- }
- const cw = dvui.currentWindow();
- if (cw.dragging.state == .dragging and cw.dragging.name != null) return false;
- const me = e.evt.mouse;
- const in_surface = animationTreePointerInTreeSurface(tree, me.p, me.floating_win);
- const in_border = animationTreePointerInTreeBorder(tree, me.p, me.floating_win);
- return in_surface or in_border;
-}
-
-fn syncAnimationSelectionFrames(file: *fizzy.Internal.File, anim_index: usize) void {
- const anim = file.animations.get(anim_index);
- if (anim.frames.len > 0) {
- if (file.selected_animation_frame_index >= anim.frames.len) {
- file.selected_animation_frame_index = anim.frames.len - 1;
- }
- } else {
- file.selected_animation_frame_index = 0;
- }
-}
-
-fn animationIndexInMulti(file: *const fizzy.Internal.File, anim_index: usize) bool {
- for (file.editor.selected_animation_indices.items) |i| {
- if (i == anim_index) return true;
- }
- return false;
-}
-
-/// Keep `selected_animation_indices` consistent with the authoritative single-selection and the
-/// current animation count. The set may be empty (no animations yet), but if `selected_animation_index`
-/// is set we guarantee it appears in the set.
-fn ensureAnimationSelection(file: *fizzy.Internal.File) void {
- const count = file.animations.len;
- if (count == 0) {
- file.editor.selected_animation_indices.clearRetainingCapacity();
- file.editor.animation_selection_anchor = null;
- file.selected_animation_index = null;
- return;
- }
-
- var w: usize = 0;
- var items = file.editor.selected_animation_indices.items;
- for (items) |v| {
- if (v < count) {
- items[w] = v;
- w += 1;
- }
- }
- file.editor.selected_animation_indices.shrinkRetainingCapacity(w);
-
- if (file.selected_animation_index) |p| {
- if (p >= count) file.selected_animation_index = null;
- }
- if (file.selected_animation_index) |p| {
- var found = false;
- for (file.editor.selected_animation_indices.items) |v| {
- if (v == p) {
- found = true;
- break;
- }
- }
- if (!found) {
- file.editor.selected_animation_indices.append(fizzy.app.allocator, p) catch return;
- std.sort.pdq(usize, file.editor.selected_animation_indices.items, {}, std.sort.asc(usize));
- }
- }
-
- if (file.editor.animation_selection_anchor) |a| {
- if (a >= count) file.editor.animation_selection_anchor = file.selected_animation_index;
- }
-}
-
-/// Apply a modifier-aware click to the animation selection. Returns whether the click should defer
-/// narrowing until release (Finder-style): plain click on an already-multi-selected row.
-fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.dvui.TreeSelection.ClickMode) !bool {
- const prev_multi = file.editor.selected_animation_indices.items;
- const was_in_multi = animationIndexInMulti(file, clicked);
- const was_multi = prev_multi.len > 1;
-
- const defer_narrow = (mode == .replace and was_multi and was_in_multi);
-
- var out: std.ArrayList(usize) = .empty;
- defer out.deinit(fizzy.app.allocator);
-
- if (defer_narrow) {
- try out.appendSlice(fizzy.app.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);
- file.selected_animation_index = clicked;
- syncAnimationSelectionFrames(file, clicked);
- return true;
- }
-
- const res = try fizzy.dvui.TreeSelection.applyClickUsize(
- fizzy.app.allocator,
- prev_multi,
- file.selected_animation_index,
- file.editor.animation_selection_anchor,
- clicked,
- mode,
- false,
- &out,
- );
-
- file.editor.selected_animation_indices.clearRetainingCapacity();
- try file.editor.selected_animation_indices.appendSlice(fizzy.app.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 {
- file.editor.selected_animation_indices.clearRetainingCapacity();
- file.editor.selected_animation_indices.append(fizzy.app.allocator, clicked) catch return;
- file.editor.animation_selection_anchor = clicked;
- file.selected_animation_index = clicked;
- syncAnimationSelectionFrames(file, clicked);
-}
-
-/// Populate `out` with the branch-ids of every selected animation row (primary first), for
-/// `TreeWidget.dragStartMulti`. Returns a slice into `out` with just the written entries.
-fn buildAnimationMultiDragIds(file: *const fizzy.Internal.File, hits: []const AnimationRowHit, out: []usize) []usize {
- var len: usize = 0;
- const primary = file.selected_animation_index;
- if (primary) |p| {
- for (hits) |h| {
- if (h.anim_index == p) {
- if (len < out.len) {
- out[len] = h.branch_usize;
- len += 1;
- }
- break;
- }
- }
- }
- for (file.editor.selected_animation_indices.items) |i| {
- if (primary) |p| if (i == p) continue;
- for (hits) |h| {
- if (h.anim_index == i) {
- if (len < out.len) {
- out[len] = h.branch_usize;
- len += 1;
- }
- break;
- }
- }
- }
- return out[0..len];
-}
-
-fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget, file: *fizzy.Internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void {
- if (!tree.init_options.enable_reordering) return;
-
- for (dvui.events()) |*e| {
- switch (e.evt) {
- .mouse => |me| {
- if (me.action == .press and me.button.pointer()) {
- if (animationPointerRenameConsumes(e, me)) continue;
- if (!animationPointerInScrollViewport(me.p, viewport_r)) continue;
-
- var row_hit: ?AnimationRowHit = null;
- var ri = hits.len;
- while (ri > 0) {
- ri -= 1;
- const h = hits[ri];
- if (h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) {
- row_hit = h;
- break;
- }
- }
- if (row_hit) |h| {
- const cw = dvui.currentWindow();
- if (cw.dragging.state != .none) dvui.dragEnd();
- animationTreeClearGestureKeysOnly(file);
- dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) });
-
- const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod);
- const narrow_on_release = applyAnimationClick(file, h.anim_index, mode) catch blk: {
- dvui.log.err("Failed to apply animation click", .{});
- break :blk false;
- };
-
- animation_row_gesture = .{
- .file_id = file.id,
- .press_idx = h.anim_index,
- .press_p = me.p,
- .drag_branch = h.branch_usize,
- .moved = false,
- .reorder_drag = false,
- .narrow_on_release = narrow_on_release,
- };
-
- dvui.refresh(null, @src(), tree.data().id);
- } else {
- animationTreeResetRowPointerGesture(file);
- }
- continue;
- }
-
- if (me.action == .motion) {
- if (animationPointerRenameConsumes(e, me)) continue;
-
- if (animation_row_gesture) |*g| {
- if (g.file_id == file.id) {
- const dx = me.p.x - g.press_p.x;
- const dy = me.p.y - g.press_p.y;
- if (dx * dx + dy * dy > 16.0) {
- g.moved = true;
- }
- }
- }
-
- if (tree.reorderDragActive()) {
- _ = tree.matchEvent(e);
- continue;
- }
-
- const branch_usize = if (animationGestureMatches(file)) animation_row_gesture.?.drag_branch else null;
- if (branch_usize == null) continue;
- _ = tree.matchEvent(e);
- if (!animationTreeMotionAllowsReorder(tree, e)) continue;
-
- const prev_th = dvui.Dragging.threshold;
- dvui.Dragging.threshold = @max(prev_th, 8.0);
- defer dvui.Dragging.threshold = prev_th;
- if (dvui.dragging(me.p, null)) |_| {
- var row_size: dvui.Size = .{};
- for (hits) |h| {
- if (h.branch_usize == branch_usize.?) {
- const rn = h.row_r.toNatural();
- row_size = .{ .w = rn.w, .h = rn.h };
- break;
- }
- }
-
- var multi_buf: [64]usize = undefined;
- const multi_ids = buildAnimationMultiDragIds(file, hits, &multi_buf);
- if (multi_ids.len > 1) {
- tree.dragStartMulti(branch_usize.?, multi_ids, me.p, row_size);
- } else {
- tree.dragStart(branch_usize.?, me.p, row_size);
- }
-
- if (animation_row_gesture) |*g| {
- if (g.file_id == file.id) {
- g.reorder_drag = true;
- g.drag_branch = null;
- g.narrow_on_release = false;
- }
- }
- }
- } else if (me.action == .release and me.button.pointer()) {
- if (animationPointerRenameConsumes(e, me)) continue;
-
- const release_in_vp = animationPointerInScrollViewport(me.p, viewport_r);
-
- var release_anim: ?usize = null;
- var rj = hits.len;
- while (rj > 0) {
- rj -= 1;
- const h = hits[rj];
- if (release_in_vp and h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) {
- release_anim = h.anim_index;
- break;
- }
- }
-
- const idx_opt: ?usize = if (animationGestureMatches(file)) animation_row_gesture.?.press_idx else null;
- const did_reorder = if (animationGestureMatches(file)) animation_row_gesture.?.reorder_drag else false;
- const narrow_on_release = if (animationGestureMatches(file)) animation_row_gesture.?.narrow_on_release else false;
- var selected_on_release = false;
- if (!did_reorder and !tree.drag_ending and narrow_on_release and release_in_vp) {
- if (release_anim) |rh| {
- if (idx_opt) |pi| if (rh == pi) {
- narrowAnimationSelectionTo(file, rh);
- selected_on_release = true;
- };
- }
- }
-
- if (idx_opt != null) {
- animationTreeResetRowPointerGesture(file);
- if (!did_reorder and !dvui.captured(tree.data().id)) {
- dvui.captureMouse(null, e.num);
- }
- }
-
- if (selected_on_release) {
- dvui.refresh(null, @src(), tree.data().id);
- }
- }
- },
- else => {},
- }
- }
-}
-
-const FrameSort = struct {
- pub fn asc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool {
- return a.sprite_index < b.sprite_index;
- }
-
- pub fn desc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool {
- return a.sprite_index > b.sprite_index;
- }
-};
diff --git a/src/editor/explorer/tools.zig b/src/editor/explorer/tools.zig
deleted file mode 100644
index 2ec7f3b8..00000000
--- a/src/editor/explorer/tools.zig
+++ /dev/null
@@ -1,1647 +0,0 @@
-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 Tools = @This();
-
-var insert_before_index: ?usize = null;
-/// Sorted (ascending) list of layer indices whose TreeWidget branch reported `removed()` on the
-/// last frame's drag completion. Used by the drop handler to reorder multiple selected layers as
-/// a group. Bounded because layer count is expected to be modest in practice.
-var removed_layer_indices_buf: [64]usize = undefined;
-var removed_layer_indices_len: usize = 0;
-var edit_layer_id: ?u64 = null;
-var prev_layer_count: usize = 0;
-var max_split_ratio: f32 = 0.4;
-
-/// In-flight primary-button gesture for the active file's layer list (reorder / click / rename).
-/// Not stored in `dvui.data`: a single path at end of `drawLayers` processes events after rename `textEntry`.
-const LayerRowGesture = struct {
- file_id: u64,
- press_idx: usize,
- press_p: dvui.Point.Physical,
- drag_branch: ?usize,
- moved: bool,
- reorder_drag: bool,
- /// True when the press landed on a row that was already part of the current multi-selection
- /// with no modifier key. We preserve the full selection so the user can drag the whole group;
- /// on release without drag we narrow the selection to just `press_idx` (Finder-style).
- narrow_on_release: bool,
-};
-var layer_row_gesture: ?LayerRowGesture = null;
-
-/// Filled while the layer rename text entry exists so `processLayerTreePointerEvents` can skip those hits.
-var layer_rename_hit_te_id: ?dvui.Id = null;
-var layer_rename_hit_rect: ?dvui.Rect.Physical = null;
-
-layers_rect: ?dvui.Rect.Physical = null,
-/// Visible clip of the layer list (scroll container content rect). Rows can have screen rects that
-/// extend below this when scrolled; without gating, those rects overlap the palettes pane and steal hover/input.
-layers_scroll_viewport_rect: ?dvui.Rect.Physical = null,
-
-pub fn init() Tools {
- return .{};
-}
-
-pub fn draw(self: *Tools) !void {
- var tools_top = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .background = false,
- });
- defer tools_top.deinit();
-
- // First time (or after the tools pane was not drawn last frame), horizontal boxes lack
- // published min sizes and can lay out like a vertical stack for one frame. Clip drawing
- // until the next frame when sizes settle.
- const tools_top_settling = dvui.firstFrame(tools_top.data().id);
- const prev_clip: ?dvui.Rect.Physical = if (tools_top_settling)
- dvui.clip(.{ .x = 0, .y = 0, .w = 0, .h = 0 })
- else
- null;
-
- drawTools() catch {};
- if (prev_clip) |p| dvui.clipSet(p);
- drawColors() catch {};
- drawLayerControls() catch {};
-
- // Collect layers length to trigger a refit of the panel
- const layer_count: usize = if (fizzy.editor.activeFile()) |file| file.layers.len else 0;
- defer prev_layer_count = layer_count;
-
- var paned = fizzy.dvui.paned(@src(), .{
- .direction = .vertical,
- .collapsed_size = 0,
- .handle_size = 10,
- .handle_dynamic = .{},
- }, .{ .expand = .both, .background = false });
- defer paned.deinit();
-
- if (paned.dragging) {
- max_split_ratio = paned.split_ratio.*;
- fizzy.editor.explorer.layers_ratio = paned.split_ratio.*;
- }
-
- if (paned.showFirst()) {
- self.layers_rect = self.drawLayers() catch {
- dvui.log.err("Failed to draw layers", .{});
- return;
- };
- } else {
- self.layers_rect = null;
- self.layers_scroll_viewport_rect = null;
- }
-
- const autofit = !paned.dragging and !paned.collapsed_state and !paned.animating;
-
- // Refit must be done between showFirst and showSecond
- if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !fizzy.editor.explorer.pinned_palettes) {
- if (dvui.firstFrame(paned.data().id) and layer_count == 0)
- paned.split_ratio.* = 0.0;
-
- // `firstFrame` is also true the first time we see the paned after it was not drawn
- // (e.g. another explorer tab was active). Min sizes for the subtree are not published
- // from the prior frame, so getFirstFittedRatio can be clamped to max_split, then a
- // second pass animates to the true fit. Restore from the saved ratio; refit+animate
- // next frame when min sizes are valid.
- if (dvui.firstFrame(paned.data().id) and layer_count > 0) {
- paned.split_ratio.* = 0.01;
- //fizzy.editor.explorer.layers_ratio = paned.split_ratio.*;
- } else {
- const ratio = paned.getFirstFittedRatio(
- .{
- .min_split = 0,
- .max_split = @min(max_split_ratio, 0.75),
- .min_size = 0,
- },
- );
-
- const diff = @abs(ratio - paned.split_ratio.*);
-
- if (diff > 0.000001 and layer_count > 0) {
- paned.animateSplit(ratio, dvui.easing.outBack);
- }
- }
- } else {
- if (dvui.firstFrame(paned.data().id)) {
- if (layer_count == 0)
- paned.split_ratio.* = 0.0
- else
- paned.split_ratio.* = fizzy.editor.explorer.layers_ratio;
-
- fizzy.editor.explorer.layers_ratio = paned.split_ratio.*;
- }
- }
-
- if (paned.showSecond()) {
- drawPaletteControls() catch {};
- drawPalettes() catch {};
- }
-}
-
-pub fn layersHovered(self: *Tools) bool {
- const mp = dvui.currentWindow().mouse_pt;
- if (self.layers_scroll_viewport_rect) |vr| {
- if (!vr.contains(mp)) return false;
- }
- if (self.layers_rect) |rect| {
- return rect.contains(mp);
- }
- return false;
-}
-
-pub fn drawTools() !void {
- const toolbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .gravity_x = 0.5,
- .padding = .{ .h = 10.0, .w = 4.0, .x = 4.0, .y = 4.0 },
- });
- defer toolbox.deinit();
- for (0..std.meta.fields(fizzy.Editor.Tools.Tool).len) |i| {
- const tool: fizzy.Editor.Tools.Tool = @enumFromInt(i);
- const id_extra = i;
-
- const selected = fizzy.editor.tools.current == tool;
-
- var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.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 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],
- .selection => selection_sprite,
- };
- var button: dvui.ButtonWidget = undefined;
- button.init(@src(), .{}, .{
- .expand = .none,
- .min_size_content = .{ .w = 40, .h = 40 },
- .id_extra = id_extra,
- .background = true,
- .corner_radius = dvui.Rect.all(1000),
- .color_fill = if (selected) dvui.themeGet().color(.content, .fill) else .transparent,
- .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
- .box_shadow = if (selected) .{
- .color = .black,
- .offset = .{ .x = -2.5, .y = 2.5 },
- .fade = 4.0,
- .alpha = 0.25,
- } else null,
- .padding = .all(0),
- //.border = dvui.Rect.all(1.0),
- //.color_border = if (selected) color else dvui.themeGet().color(.control, .fill),
- });
- defer button.deinit();
-
- fizzy.editor.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 uv = dvui.Rect{
- .x = @as(f32, @floatFromInt(sprite.source[0])) / size.w,
- .y = @as(f32, @floatFromInt(sprite.source[1])) / size.h,
- .w = @as(f32, @floatFromInt(sprite.source[2])) / size.w,
- .h = @as(f32, @floatFromInt(sprite.source[3])) / size.h,
- };
-
- button.processEvents();
- button.drawBackground();
-
- var rs = button.data().contentRectScale();
-
- const width = @as(f32, @floatFromInt(sprite.source[2])) * rs.s;
- const height = @as(f32, @floatFromInt(sprite.source[3])) * rs.s;
-
- rs.r.x = @round(rs.r.x + (rs.r.w - width) / 2.0);
- rs.r.y = @round(rs.r.y + (rs.r.h - height) / 2.0);
- rs.r.w = width;
- rs.r.h = height;
-
- dvui.renderImage(fizzy.editor.atlas.source, rs, .{
- .uv = uv,
- .fade = 0.0,
- }) catch {
- dvui.log.err("Failed to render image", .{});
- };
-
- if (button.clicked()) {
- fizzy.editor.tools.set(tool);
- }
- }
-}
-
-pub fn drawLayerControls() !void {
- var box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer box.deinit();
- dvui.labelNoFmt(@src(), "LAYERS", .{}, .{ .font = dvui.Font.theme(.heading), .gravity_y = 0.5 });
-
- if (fizzy.editor.activeFile()) |file| {
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .background = false,
- .gravity_x = 1.0,
- });
- defer hbox.deinit();
-
- const merge_up_enabled = file.selected_layer_index > 0;
- const merge_down_enabled = file.selected_layer_index + 1 < file.layers.len;
-
- {
- const a = dvui.alpha(if (merge_up_enabled) 1.0 else 0.35);
- defer dvui.alphaSet(a);
- if (dvui.buttonIcon(@src(), "MergeLayerUp", icons.tvg.lucide.@"arrow-up-to-line", .{}, .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- })) {
- if (merge_up_enabled) {
- file.mergeSelectedLayerUp() catch {
- dvui.log.err("Failed to merge layer up", .{});
- };
- }
- }
- }
-
- {
- const a = dvui.alpha(if (merge_down_enabled) 1.0 else 0.35);
- defer dvui.alphaSet(a);
- if (dvui.buttonIcon(@src(), "MergeLayerDown", icons.tvg.lucide.@"arrow-down-to-line", .{}, .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- })) {
- if (merge_down_enabled) {
- file.mergeSelectedLayerDown() catch {
- dvui.log.err("Failed to merge layer down", .{});
- };
- }
- }
- }
-
- if (dvui.buttonIcon(
- @src(),
- "TogglePeek",
- if (file.editor.isolate_layer) icons.tvg.lucide.@"layers-2" else icons.tvg.lucide.layers,
- .{},
- .{},
- .{
- .expand = .none,
- .gravity_y = 0.5,
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .style = if (file.editor.isolate_layer) .highlight else .control,
- },
- )) {
- file.editor.isolate_layer = !file.editor.isolate_layer;
- }
-
- if (dvui.buttonIcon(@src(), "AddLayer", icons.tvg.lucide.plus, .{}, .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- })) {
- if (file.createLayer() catch null) |id| {
- edit_layer_id = id;
- }
- }
-
- if (dvui.buttonIcon(@src(), "DuplicateLayer", icons.tvg.lucide.@"copy-plus", .{}, .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill),
- })) {
- if (file.duplicateLayer(file.selected_layer_index) catch null) |id| {
- edit_layer_id = id;
- }
- }
-
- if (file.layers.len > 1) {
- if (dvui.buttonIcon(@src(), "DeleteLayer", icons.tvg.lucide.trash, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{
- .style = .err,
- .expand = .none,
- .gravity_y = 0.5,
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- })) {
- file.deleteLayer(file.selected_layer_index) catch {
- dvui.log.err("Failed to delete layer", .{});
- };
- }
- }
- }
-}
-
-pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
- tools.layers_scroll_viewport_rect = null;
-
- const vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .background = false,
- });
- defer vbox.deinit();
-
- if (fizzy.editor.activeFile()) |file| {
- layer_rename_hit_te_id = null;
- layer_rename_hit_rect = null;
- file.editor.layer_drag_preview_removed = null;
- file.editor.layer_drag_preview_insert_before = null;
-
- var scroll_area = dvui.scrollArea(@src(), .{ .scroll_info = &file.editor.layers_scroll_info }, .{
- .expand = .both,
- .background = false,
- .corner_radius = dvui.Rect.all(1000),
- });
-
- defer scroll_area.deinit();
-
- // Visible clip for the layer list (same rect used for scroll content clipping). Row widgets can
- // still have screen rects extending below this when scrolled; gate hover/hits to this rect.
- if (dvui.ScrollContainerWidget.current()) |sc| {
- tools.layers_scroll_viewport_rect = sc.data().contentRectScale().r;
- }
-
- const vertical_scroll = file.editor.layers_scroll_info.offset(.vertical);
-
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer tree.deinit();
-
- var layer_hits_buf: [256]LayerRowHit = undefined;
- var layer_hits_len: usize = 0;
-
- // Drag and drop is completing — supports single- and multi-row drags.
- if (insert_before_index) |insert_before_raw| {
- if (removed_layer_indices_len > 0) {
- const sources = removed_layer_indices_buf[0..removed_layer_indices_len];
-
- const prev_order = try fizzy.app.allocator.alloc(u64, file.layers.len);
- for (file.layers.items(.id), 0..) |id, i| {
- prev_order[i] = id;
- }
-
- const primary_before = file.selected_layer_index;
- var primary_was_moved: bool = false;
- var primary_pos_in_sources: usize = 0;
- for (sources, 0..) |s, pi| {
- if (s == primary_before) {
- primary_was_moved = true;
- primary_pos_in_sources = pi;
- break;
- }
- }
-
- // Snapshot moved layers before any removal so indices stay valid.
- var moved = try fizzy.app.allocator.alloc(fizzy.Internal.Layer, sources.len);
- defer fizzy.app.allocator.free(moved);
- for (sources, 0..) |s, i| {
- moved[i] = file.layers.get(s);
- }
-
- // Remove from highest → lowest so earlier indices aren't shifted.
- var ri = sources.len;
- while (ri > 0) {
- ri -= 1;
- file.layers.orderedRemove(sources[ri]);
- }
-
- const target_raw = fizzy.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 {
- dvui.log.err("Failed to insert layer", .{});
- };
- }
-
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
-
- if (primary_was_moved) {
- file.selected_layer_index = target + primary_pos_in_sources;
- }
-
- // After a group move the moved rows become contiguous; resync multi-selection to reflect that.
- file.editor.selected_layer_indices.clearRetainingCapacity();
- for (0..moved.len) |i| {
- file.editor.selected_layer_indices.append(fizzy.app.allocator, target + i) catch {
- dvui.log.err("Failed to update layer selection", .{});
- };
- }
- file.editor.layer_selection_anchor = file.selected_layer_index;
-
- if (!std.mem.eql(u64, file.layers.items(.id)[0..file.layers.len], prev_order)) {
- file.history.append(.{
- .layers_order = .{
- .order = prev_order,
- // Layer ids are u64 on disk; convert to the usize the
- // history's `selected` field expects.
- .selected = @intCast(file.layers.items(.id)[file.selected_layer_index]),
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- } else {
- fizzy.app.allocator.free(prev_order);
- }
-
- insert_before_index = null;
- removed_layer_indices_len = 0;
- } else {
- insert_before_index = null;
- }
- } else if (removed_layer_indices_len > 0) {
- // Drag ended without a valid drop target; discard the removal intent.
- removed_layer_indices_len = 0;
- }
-
- // Sync the multi-selection list with the primary index each frame so it tracks operations
- // (delete/duplicate/merge) that only update `selected_layer_index`. The set must always
- // contain the primary — the editor cannot have zero selected layers.
- ensureLayerSelection(file);
-
- const box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .horizontal,
- .background = false,
- .corner_radius = dvui.Rect.all(1000),
- .margin = dvui.Rect.rect(4, 0, 4, 4),
- });
- defer box.deinit();
-
- for (file.layers.items(.id), 0..) |layer_id, layer_index| {
- const in_multi = layerIndexInMulti(file, layer_index);
- const is_primary_row = file.selected_layer_index == layer_index;
- const selected = if (edit_layer_id) |id| id == layer_id else (is_primary_row or in_multi);
- const visible = file.layers.items(.visible)[layer_index];
- const font = if (visible) dvui.Font.theme(.body) else dvui.Font.theme(.body).withStyle(.italic);
-
- var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
- color = palette.getDVUIColor(@intCast(layer_id));
- }
-
- // `process_events` must be false: Tree Branch's header `ButtonWidget.processEvents` runs
- // `dvui.clicked`, which captures on press + dragPreStart for the full button rect (~row height),
- // stealing presses before label/sink (dvui `clickedEx` press handler).
- var branch = tree.branch(@src(), .{
- .expanded = false,
- .process_events = false,
- .can_accept_children = false,
- .animation_duration = 250_000,
- .animation_easing = dvui.easing.outBack,
- }, .{
- .id_extra = @intCast(layer_id),
- .expand = .horizontal,
- .corner_radius = dvui.Rect.all(1000),
- .background = false,
- .margin = .all(0),
- .padding = dvui.Rect.all(1),
- });
- defer branch.deinit();
-
- if (branch.removed()) {
- if (removed_layer_indices_len < removed_layer_indices_buf.len) {
- removed_layer_indices_buf[removed_layer_indices_len] = layer_index;
- removed_layer_indices_len += 1;
- }
- } else if (branch.insertBefore()) {
- insert_before_index = layer_index;
- }
-
- const row_r = branch.data().borderRectScale().r;
- const mp = dvui.currentWindow().mouse_pt;
- const row_hovered = row_r.contains(mp) and layerPointerInScrollViewport(mp, tools.layers_scroll_viewport_rect);
-
- if (tree.reorderDragActive()) {
- if (tree.id_branch) |idb| {
- if (idb == branch.data().id.asUsize()) {
- file.peek_layer_index = layer_index;
- }
- }
- } else if (row_hovered) {
- file.peek_layer_index = layer_index;
- }
-
- var min_layer_index: usize = 0;
- if (file.editor.isolate_layer) {
- if (file.peek_layer_index) |peek_layer_index| {
- min_layer_index = peek_layer_index;
- } else if (!fizzy.editor.explorer.tools.layersHovered()) {
- min_layer_index = file.selected_layer_index;
- }
- }
-
- const below_mouse = dvui.currentWindow().mouse_pt.y > branch.data().contentRectScale().r.y + branch.data().contentRectScale().r.h;
-
- var alpha: f32 = dvui.alpha(1.0);
- if (file.editor.isolate_layer and (layer_index < min_layer_index or (below_mouse and tools.layersHovered()))) {
- alpha = dvui.alpha(0.5);
- }
- defer dvui.alphaSet(alpha);
-
- const ctrl_hover = dvui.themeGet().color(.control, .fill).opacity(0.5);
- const row_highlight = blk: {
- if (tree.reorderDragActive()) {
- if (tree.id_branch) |idb| {
- break :blk idb == branch.data().id.asUsize();
- }
- break :blk false;
- }
- break :blk row_hovered and tree.drag_point == null;
- };
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .both,
- .background = true,
- .color_fill = if ((selected or row_highlight) and !branch.floating())
- ctrl_hover
- else
- .transparent,
- .color_fill_hover = .transparent,
- .margin = dvui.Rect{},
- .padding = dvui.Rect.all(1),
- .corner_radius = dvui.Rect.all(8),
- .box_shadow = null,
- });
- defer hbox.deinit();
-
- // _ = dvui.icon(
- // @src(),
- // "LayerIcon",
- // icons.tvg.heroicons.solid.@"square-3-stack-3d",
- // .{
- // .stroke_color = if (!(selected or row_hovered)) dvui.themeGet().color(.control, .fill) else if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.window, .fill),
- // .fill_color = if (!(selected or row_hovered)) dvui.themeGet().color(.control, .fill) else if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.window, .fill),
- // },
- // .{ .expand = .none, .gravity_y = 0.5, .margin = .{ .x = 4, .w = 4 } },
- // );
-
- var color_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .background = true,
- .gravity_y = 0.5,
- .min_size_content = .{ .w = 8.0, .h = 8.0 },
- .color_fill = color,
- .corner_radius = dvui.Rect.all(1000),
- .margin = dvui.Rect.all(2),
- .padding = dvui.Rect.all(0),
- });
- color_box.deinit();
-
- if (edit_layer_id != layer_id) {
- // Always use the same label wrapper so sibling widget ids (drag_sink, button_box) stay stable
- // when selection changes — otherwise the extra box only on the selected row causes a layout flash.
- var name_label_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .background = false,
- .gravity_y = 0.5,
- .margin = dvui.Rect.rect(2, 0, 2, 0),
- .padding = dvui.Rect.all(0),
- });
- defer name_label_box.deinit();
-
- const name_text = file.layers.items(.name)[layer_index];
- const name_color: dvui.Color = if (!selected)
- dvui.themeGet().color(.control, .text)
- else if (is_primary_row)
- dvui.themeGet().color(.window, .text)
- else
- dvui.themeGet().color(.control, .text);
-
- if (selected) {
- if (dvui.labelClick(@src(), "{s}", .{name_text}, .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .margin = dvui.Rect{},
- .font = font,
- .padding = dvui.Rect.all(0),
- .color_text = name_color,
- })) {
- const lr = name_label_box.data().borderRectScale().r;
- if (pointerReleaseInRectWithoutSelectionModifier(lr)) {
- edit_layer_id = layer_id;
- }
- }
- } else {
- dvui.labelNoFmt(@src(), name_text, .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .margin = dvui.Rect{},
- .font = font,
- .padding = dvui.Rect.all(0),
- .color_text = name_color,
- });
- }
- } else {
- var te = dvui.textEntry(@src(), .{}, .{
- .expand = .horizontal,
- .background = false,
- .padding = dvui.Rect.all(0),
- .margin = dvui.Rect.all(0),
- .font = font,
- .gravity_y = 0.5,
- });
- defer te.deinit();
-
- if (dvui.firstFrame(te.data().id)) {
- te.textSet(file.layers.items(.name)[layer_index], true);
- dvui.focusWidget(te.data().id, null, null);
- }
-
- layer_rename_hit_te_id = te.data().id;
- layer_rename_hit_rect = te.data().borderRectScale().r;
-
- const should_commit_rename = te.enter_pressed or dvui.focusedWidgetId() != te.data().id;
- if (should_commit_rename) {
- if (!std.mem.eql(u8, file.layers.items(.name)[layer_index], te.getText()) and te.getText().len > 0) {
- file.history.append(.{
- .layer_name = .{
- .index = layer_index,
- .name = try fizzy.app.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());
- }
- if (te.enter_pressed) {
- file.selected_layer_index = layer_index;
- }
- dvui.captureMouse(null, 0);
- dvui.focusWidget(null, null, null);
- edit_layer_id = null;
- dvui.refresh(null, @src(), tree.data().id);
- }
- }
-
- if (edit_layer_id != layer_id) {
- var drag_sink = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .both,
- .background = false,
- .min_size_content = .{ .w = 0, .h = 0 },
- .gravity_y = 0.5,
- });
- defer drag_sink.deinit();
-
- var button_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .background = false,
- .gravity_x = 1.0,
- .gravity_y = 0.5,
- });
- defer button_box.deinit();
-
- if (dvui.buttonIcon(
- @src(),
- "collapse_button",
- if (file.layers.items(.collapse)[layer_index]) icons.tvg.lucide.@"arrow-down-to-line" else icons.tvg.lucide.package,
- .{ .draw_focus = false },
- .{},
- .{
- .expand = .ratio,
- .min_size_content = .{ .w = 1.0, .h = 11.0 },
- .id_extra = layer_index,
- .corner_radius = dvui.Rect.all(1000),
- .margin = dvui.Rect.all(1),
- },
- )) {
- file.layers.items(.collapse)[layer_index] = !file.layers.items(.collapse)[layer_index];
- }
-
- if (dvui.buttonIcon(
- @src(),
- "hide_button",
- if (file.layers.items(.visible)[layer_index]) icons.tvg.lucide.eye else icons.tvg.lucide.@"eye-closed",
- .{ .draw_focus = false },
- .{},
- .{
- .expand = .ratio,
- .min_size_content = .{ .w = 1.0, .h = 11.0 },
- .id_extra = layer_index,
- .corner_radius = dvui.Rect.all(1000),
- .margin = dvui.Rect.all(1),
- },
- )) {
- file.layers.items(.visible)[layer_index] = !file.layers.items(.visible)[layer_index];
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
- }
-
- if (layer_hits_len < layer_hits_buf.len) {
- layer_hits_buf[layer_hits_len] = .{
- .row_r = branch.data().borderRectScale().r,
- .buttons_r = button_box.data().borderRectScale().r,
- .branch_usize = branch.data().id.asUsize(),
- .layer_index = layer_index,
- .hbox_tl = hbox.data().rectScale().r.topLeft(),
- };
- layer_hits_len += 1;
- }
-
- if (row_hovered) {
- if (!button_box.data().borderRectScale().r.contains(mp)) {
- dvui.cursorSet(.hand);
- }
- }
- } else {
- var button_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .background = false,
- .gravity_x = 1.0,
- });
- defer button_box.deinit();
-
- if (dvui.buttonIcon(
- @src(),
- "collapse_button",
- if (file.layers.items(.collapse)[layer_index]) icons.tvg.lucide.@"arrow-down-to-line" else icons.tvg.lucide.package,
- .{ .draw_focus = false },
- .{},
- .{
- .expand = .ratio,
- .min_size_content = .{ .w = 1.0, .h = 11.0 },
- .id_extra = layer_index,
- .corner_radius = dvui.Rect.all(1000),
- .margin = dvui.Rect.all(1),
- },
- )) {
- file.layers.items(.collapse)[layer_index] = !file.layers.items(.collapse)[layer_index];
- }
-
- if (dvui.buttonIcon(
- @src(),
- "hide_button",
- if (file.layers.items(.visible)[layer_index]) icons.tvg.lucide.eye else icons.tvg.lucide.@"eye-closed",
- .{ .draw_focus = false },
- .{},
- .{
- .expand = .ratio,
- .min_size_content = .{ .w = 1.0, .h = 11.0 },
- .id_extra = layer_index,
- .corner_radius = dvui.Rect.all(1000),
- .margin = dvui.Rect.all(1),
- },
- )) {
- file.layers.items(.visible)[layer_index] = !file.layers.items(.visible)[layer_index];
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
- }
- }
- }
-
- processLayerTreePointerEvents(tree, file, layer_hits_buf[0..layer_hits_len], tools.layers_scroll_viewport_rect);
-
- var layer_tail_branch_id: ?usize = null;
- if (tree.drag_point != null) {
- const tail = tree.branch(@src(), .{
- .expanded = false,
- .process_events = false,
- .can_accept_children = false,
- }, .{
- .id_extra = 0x7fff_fffe,
- .expand = .horizontal,
- .min_size_content = .{ .w = 0, .h = 14 },
- .color_fill = .transparent,
- .color_fill_hover = .transparent,
- .color_fill_press = .transparent,
- });
- defer tail.deinit();
- layer_tail_branch_id = tail.data().id.asUsize();
- if (tail.insertBefore()) {
- insert_before_index = file.layers.len;
- }
- }
-
- if (tree.reorderDragActive()) {
- if (tree.id_branch) |idb| {
- var from: ?usize = null;
- for (layer_hits_buf[0..layer_hits_len]) |h| {
- if (h.branch_usize == idb) {
- from = h.layer_index;
- break;
- }
- }
- if (from) |fr| {
- var insert_before: ?usize = null;
- if (tree.drop_target_branch_id) |dtb| {
- if (dtb == idb) {
- insert_before = fr;
- } else if (layer_tail_branch_id) |tid| {
- if (dtb == tid) {
- insert_before = file.layers.len;
- }
- }
- if (insert_before == null) {
- for (layer_hits_buf[0..layer_hits_len]) |h| {
- if (h.branch_usize == dtb) {
- insert_before = h.layer_index;
- break;
- }
- }
- }
- }
- if (insert_before) |ins| {
- if (fr != ins) {
- file.editor.layer_drag_preview_removed = fr;
- file.editor.layer_drag_preview_insert_before = ins;
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
- }
- }
- }
- }
- }
-
- // Only draw shadow if the scroll bar has been scrolled some
- if (vertical_scroll > 0.0)
- fizzy.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, .{});
- }
-
- if (fizzy.dvui.hovered(vbox.data())) {
- const mp = dvui.currentWindow().mouse_pt;
- if (tools.layers_scroll_viewport_rect) |vr| {
- if (!vr.contains(mp)) return null;
- }
- return vbox.data().contentRectScale().r;
- }
-
- return null;
-}
-
-pub fn drawColors() !void {
- dvui.labelNoFmt(@src(), "COLORS", .{}, .{ .font = dvui.Font.theme(.heading) });
-
- var hbox = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{
- .expand = .horizontal,
- .background = false,
- .min_size_content = .{ .w = 64.0, .h = 64.0 },
- .margin = dvui.Rect.all(4),
- });
- defer hbox.deinit();
-
- const primary: dvui.Color = .{ .r = 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 button_opts: dvui.Options = .{
- .expand = .both,
- .background = true,
- .corner_radius = dvui.Rect.all(8.0),
- .color_fill = primary,
- .color_fill_hover = primary,
- .color_fill_press = primary,
- .margin = dvui.Rect.all(4),
- .padding = dvui.Rect.all(0),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(8.0),
- },
- };
-
- const secondary_overrider: dvui.Options = .{
- .color_fill = secondary,
- .color_fill_hover = secondary,
- .color_fill_press = secondary,
- };
-
- var clicked: bool = false;
- {
- var primary_button: dvui.ButtonWidget = undefined;
- primary_button.init(@src(), .{}, button_opts);
- defer primary_button.deinit();
-
- try drawColorPicker(primary_button.data().rectScale().r, &fizzy.editor.colors.primary, 0);
-
- primary_button.processEvents();
- primary_button.drawBackground();
-
- if (primary_button.clicked()) clicked = true;
- }
-
- {
- var secondary_button: dvui.ButtonWidget = undefined;
- secondary_button.init(@src(), .{}, button_opts.override(secondary_overrider));
- defer secondary_button.deinit();
-
- try drawColorPicker(secondary_button.data().rectScale().r, &fizzy.editor.colors.secondary, 1);
-
- secondary_button.processEvents();
- secondary_button.drawBackground();
-
- if (secondary_button.clicked()) clicked = true;
- }
-
- if (clicked) {
- std.mem.swap([4]u8, &fizzy.editor.colors.primary, &fizzy.editor.colors.secondary);
- }
-}
-
-fn drawColorPicker(rect: dvui.Rect.Physical, backing_color: *[4]u8, id_extra: usize) !void {
- var context = dvui.context(@src(), .{ .rect = rect }, .{ .id_extra = id_extra });
- defer context.deinit();
-
- if (context.activePoint()) |point| {
- var fw2 = dvui.floatingMenu(@src(), .{ .from = dvui.Rect.Natural.fromPoint(point) }, .{ .box_shadow = .{
- .color = .black,
- .offset = .{ .x = 0, .y = 0 },
- .shrink = 0,
- .fade = 10,
- .alpha = 0.15,
- } });
- defer fw2.deinit();
-
- var color: dvui.Color.HSV = .fromColor(.{
- .r = backing_color.*[0],
- .g = backing_color.*[1],
- .b = backing_color.*[2],
- .a = backing_color.*[3],
- });
-
- if (dvui.colorPicker(@src(), .{ .alpha = true, .hsv = &color }, .{
- .expand = .horizontal,
- .background = false,
- .corner_radius = dvui.Rect.all(1000),
- // Default saturation box is 100×100 and the sliders / hue strip size off of
- // that. Bumping the outer min_size_content to ~2× makes everything inside
- // (the value/saturation pad, the hue strip, the RGB+A sliders, the hex
- // entry) scale up via their `.expand = .ratio` / `.expand = .horizontal`,
- // giving a touch-friendly hit area without restyling each piece.
- .min_size_content = .{ .w = 220, .h = 220 },
- })) {
- const c = color.toColor();
- backing_color.* = .{
- c.r,
- c.g,
- c.b,
- c.a,
- };
- }
- }
-}
-
-pub fn drawPaletteControls() !void {
- var box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer box.deinit();
-
- dvui.labelNoFmt(@src(), "PALETTES", .{}, .{ .font = dvui.Font.theme(.heading) });
-
- if (dvui.buttonIcon(@src(), "PinPalettes", dvui.entypo.pin, .{ .draw_focus = false }, .{}, .{
- .expand = .none,
- .gravity_y = 0.5,
- .gravity_x = 1.0,
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.0, .y = 2.0 },
- .fade = 6.0,
- .alpha = 0.15,
- .corner_radius = dvui.Rect.all(1000),
- },
- .rotation = std.math.pi * 0.25,
- .style = if (fizzy.editor.explorer.pinned_palettes) .highlight else .control,
- })) {
- fizzy.editor.explorer.pinned_palettes = !fizzy.editor.explorer.pinned_palettes;
- }
-}
-
-pub fn drawPalettes() !void {
- var scroll_area = dvui.scrollArea(@src(), .{}, .{
- .expand = .both,
- .background = false,
- });
- defer scroll_area.deinit();
-
- // Palette search dropdown
- {
- const oldt = dvui.themeGet();
- var t = oldt;
- t.control.fill = t.window.fill;
- dvui.themeSet(t);
- defer dvui.themeSet(oldt);
-
- var dropdown: dvui.DropdownWidget = undefined;
- dropdown.init(@src(), .{ .label = "Palette" }, .{
- .expand = .horizontal,
- .corner_radius = dvui.Rect.all(1000),
- });
-
- defer dropdown.deinit();
-
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .vertical,
- .gravity_x = 1.0,
- });
-
- if (fizzy.editor.colors.palette) |*palette| {
- dvui.label(@src(), "{s}", .{palette.name}, .{ .margin = .all(0), .padding = .all(0) });
- } else {
- dvui.label(@src(), "Palette Search", .{}, .{ .margin = .all(0), .padding = .all(0) });
- }
-
- dvui.icon(
- @src(),
- "dropdown_triangle",
- dvui.entypo.triangle_down,
- .{},
- .{ .gravity_y = 0.5 },
- );
-
- hbox.deinit();
-
- if (dropdown.dropped()) {
- dvui.labelNoFmt(@src(), "Built-in", .{}, .{
- .margin = .all(0),
- .gravity_x = 0.5,
- });
- _ = dvui.separator(@src(), .{ .expand = .horizontal });
-
- var it = (try assets.root.dir("palettes")).iterate();
- while (it.next()) |entry| {
- switch (entry.data) {
- .file => |data| {
- const ext = std.fs.path.extension(entry.name);
- if (std.mem.eql(u8, ext, ".hex")) {
- if (dropdown.addChoiceLabel(entry.name)) {
- fizzy.editor.colors.palette = fizzy.Internal.Palette.loadFromBytes(fizzy.app.allocator, entry.name, data) catch |err| {
- dvui.log.err("Failed to load palette: {s}", .{@errorName(err)});
- return error.FailedToLoadPalette;
- };
- }
- }
- },
- .dir => {},
- }
- }
-
- _ = dvui.separator(@src(), .{ .expand = .horizontal });
- // User palette folder scan uses Io.Dir.iterate (NAME_MAX) — unavailable on wasm.
- if (comptime builtin.target.cpu.arch != .wasm32) {
- searchPalettes(&dropdown) catch {
- dvui.log.err("Failed to search palettes", .{});
- };
- }
- }
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } });
- }
-
- {
- if (fizzy.editor.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,
- },
- });
-
- var triangles = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), palette.colors.len * 300, palette.colors.len * 300 * 30) catch return;
-
- for (palette.colors, 0..) |color, i| {
- var anim = dvui.animate(
- @src(),
- .{
- .duration = 250_000 + 10_000 * @as(i32, @intCast(i)),
- .kind = .horizontal,
- .easing = dvui.easing.outBack,
- },
- .{
- .expand = .none,
- .id_extra = dvui.Id.extendId(flex_box.data().id, @src(), i).update(palette.name).asUsize(),
- },
- );
- defer anim.deinit();
-
- var box_widget = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .min_size_content = .{ .w = 18.0, .h = 18.0 },
- .id_extra = i,
- .background = false,
- .margin = dvui.Rect.all(1),
- });
-
- const button_center = box_widget.data().rectScale().r.center();
- const dist = dvui.currentWindow().mouse_pt.diff(button_center).length();
-
- // Calculate scale based on mouse distance (closer = larger)
- const max_distance = 24.0 * dvui.currentWindow().natural_scale; // Maximum distance for scaling effect
- const scale_factor = if (dist < max_distance)
- 1.0 + (1.0 - (dist / max_distance)) * 0.5 // Scale up to 1.5x when very close
- else
- 1.0;
-
- var path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- defer path.deinit();
-
- var rect = box_widget.data().rect.scale(scale_factor, dvui.Rect);
- rect.x = box_widget.data().rect.center().x - rect.w / 2.0;
- rect.y = box_widget.data().rect.center().y - rect.h / 2.0;
-
- box_widget.deinit();
-
- var button_widget: dvui.ButtonWidget = undefined;
- button_widget.init(@src(), .{ .touch_drag = false }, .{
- .expand = .none,
- .rect = rect,
- .id_extra = i,
- });
-
- defer button_widget.deinit();
-
- path.addRect(button_widget.data().rectScale().r, .all(1000));
-
- const base_index: u16 = @intCast(triangles.vertexes.items.len);
-
- const b = path.build().fillConvexTriangles(
- dvui.currentWindow().arena(),
- .{ .color = .{
- .r = color[0],
- .g = color[1],
- .b = color[2],
- .a = color[3],
- }, .fade = 1.0 },
- ) catch return;
- for (b.vertexes) |vertex| {
- triangles.appendVertex(vertex);
- }
- for (b.indices) |*index| {
- index.* += @as(u16, @intCast(base_index));
- }
- triangles.appendTriangles(b.indices);
-
- if (dvui.clickedEx(button_widget.data(), .{ .buttons = .any, .touch_drag = true })) |evt| {
- switch (evt) {
- .mouse => |mouse_evt| {
- if (mouse_evt.button.pointer() or mouse_evt.button.touch()) {
- @memcpy(&fizzy.editor.colors.primary, &color);
- } else if (mouse_evt.button == .right) {
- @memcpy(&fizzy.editor.colors.secondary, &color);
- }
- },
- else => {},
- }
- }
- }
-
- flex_box.deinit();
-
- const clip = dvui.clip(dvui.currentWindow().rect_pixels);
- defer dvui.clipSet(clip);
-
- dvui.renderTriangles(triangles.build(), null) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
- }
- }
-}
-fn searchPalettes(dropdown: *dvui.DropdownWidget) !void {
- const io = dvui.io;
- var dir_opt = std.Io.Dir.cwd().openDir(io, fizzy.editor.palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null;
- if (dir_opt) |*dir| {
- defer dir.close(io);
- var iter = dir.iterate();
- while (try iter.next(io)) |entry| {
- if (entry.kind == .file) {
- const ext = std.fs.path.extension(entry.name);
- if (std.mem.eql(u8, ext, ".hex")) {
- const label = try std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{entry.name});
- if (dropdown.addChoiceLabel(label)) {
- const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ fizzy.editor.palette_folder, entry.name });
-
- if (fizzy.editor.colors.palette) |*palette|
- palette.deinit();
-
- fizzy.editor.colors.palette = fizzy.Internal.Palette.loadFromFile(fizzy.app.allocator, abs_path) catch |err| {
- dvui.log.err("Failed to load palette: {s}", .{@errorName(err)});
- return error.FailedToLoadPalette;
- };
- }
- }
- }
- }
- }
-}
-
-/// Geometry for one layer row, collected while drawing; used for a single chronological pointer pass.
-const LayerRowHit = struct {
- row_r: dvui.Rect.Physical,
- buttons_r: dvui.Rect.Physical,
- branch_usize: usize,
- layer_index: usize,
- hbox_tl: dvui.Point.Physical,
-};
-
-fn pointerReleaseInRectWithoutSelectionModifier(r: dvui.Rect.Physical) bool {
- for (dvui.events()) |*e| {
- switch (e.evt) {
- .mouse => |me| {
- if (me.action == .release and me.button.pointer() and r.contains(me.p)) {
- return !me.mod.shift() and !me.mod.control() and !me.mod.command();
- }
- },
- else => {},
- }
- }
- return false;
-}
-
-fn layerGestureMatches(file: *const fizzy.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 {
- for (file.editor.selected_layer_indices.items) |i| {
- if (i == layer_index) return true;
- }
- return false;
-}
-
-/// Sync the multi-selection list with `file.selected_layer_index` and the current layer count.
-/// The primary must always be present; stale / out-of-range entries from deletions are dropped.
-fn ensureLayerSelection(file: *fizzy.Internal.File) void {
- var sel = &file.editor.selected_layer_indices;
-
- // Drop out-of-range entries.
- var write: usize = 0;
- for (sel.items) |i| {
- if (i < file.layers.len) {
- sel.items[write] = i;
- write += 1;
- }
- }
- sel.items.len = write;
-
- // Clamp primary to valid range (should already be, but be defensive).
- if (file.selected_layer_index >= file.layers.len) {
- file.selected_layer_index = if (file.layers.len == 0) 0 else file.layers.len - 1;
- }
-
- // Guarantee the primary index is present.
- var has_primary = false;
- for (sel.items) |i| {
- if (i == file.selected_layer_index) {
- has_primary = true;
- break;
- }
- }
- if (!has_primary and file.layers.len > 0) {
- sel.append(fizzy.app.allocator, file.selected_layer_index) catch return;
- std.sort.pdq(usize, sel.items, {}, std.sort.asc(usize));
- }
-}
-
-/// Apply a modifier-aware click to the layer multi-selection. Returns the new primary index and
-/// whether the narrow-on-release deferral should be armed (true when a plain click lands on a row
-/// that is already part of a multi-selection: selection stays until the user releases without
-/// dragging, at which point we narrow to just that row).
-const LayerClickApplied = struct {
- primary: usize,
- narrow_on_release: bool,
-};
-
-fn applyLayerClick(
- file: *fizzy.Internal.File,
- clicked: usize,
- mode: fizzy.dvui.TreeSelection.ClickMode,
-) LayerClickApplied {
- const count_before = file.editor.selected_layer_indices.items.len;
-
- // Plain click on a row that is already part of the current multi-selection preserves the set
- // so the user can drag the whole group. We narrow later on release if no drag happened.
- if (mode == .replace and layerIndexInMulti(file, clicked) and count_before > 1) {
- return .{ .primary = clicked, .narrow_on_release = true };
- }
-
- var tmp: std.ArrayList(usize) = .empty;
- defer tmp.deinit(fizzy.app.allocator);
-
- const res = fizzy.dvui.TreeSelection.applyClickUsize(
- fizzy.app.allocator,
- file.editor.selected_layer_indices.items,
- file.selected_layer_index,
- file.editor.layer_selection_anchor,
- clicked,
- mode,
- true, // require_primary: layers always has ≥ 1 selected
- &tmp,
- ) catch return .{ .primary = file.selected_layer_index, .narrow_on_release = false };
-
- file.editor.selected_layer_indices.clearRetainingCapacity();
- file.editor.selected_layer_indices.appendSlice(fizzy.app.allocator, tmp.items) catch {};
-
- const new_primary = res.primary orelse clicked;
- file.selected_layer_index = new_primary;
- file.editor.layer_selection_anchor = res.anchor;
-
- return .{ .primary = new_primary, .narrow_on_release = false };
-}
-
-/// Narrow the multi-selection to just `clicked` — used when the user performed a plain press on an
-/// already-multi-selected row and released without dragging. Mirrors Finder-style behavior.
-fn narrowLayerSelectionTo(file: *fizzy.Internal.File, clicked: usize) void {
- file.editor.selected_layer_indices.clearRetainingCapacity();
- file.editor.selected_layer_indices.append(fizzy.app.allocator, clicked) catch {};
- file.selected_layer_index = clicked;
- file.editor.layer_selection_anchor = clicked;
-}
-
-/// Build a list of branch widget ids (one per selected layer) to pass into `tree.dragStartMulti`.
-/// Uses the per-row `LayerRowHit` geometry captured during drawing. Only layers currently visible
-/// in the row-hits buffer are included (out-of-viewport selections are allowed because hits are
-/// populated for every drawn row, not just hovered ones).
-fn buildLayerMultiDragIds(
- file: *const fizzy.Internal.File,
- hits: []const LayerRowHit,
- out: []usize,
-) usize {
- var n: usize = 0;
- for (file.editor.selected_layer_indices.items) |layer_index| {
- for (hits) |h| {
- if (h.layer_index == layer_index) {
- if (n < out.len) {
- out[n] = h.branch_usize;
- n += 1;
- }
- break;
- }
- }
- }
- return n;
-}
-
-/// Clear in-flight gesture only (no `dragEnd`). Used before arming a new row press.
-fn layerTreeClearGestureKeysOnly(_: *const fizzy.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 {
- dvui.dragEnd();
- layer_row_gesture = null;
-}
-
-/// Rename `textEntry` is drawn above the row; skip layer-tree handling when it already consumed the event
-/// or the pointer maps to its rect (runs after `textEntry()` so rects/targets are valid this frame).
-fn layerPointerRenameConsumes(e: *const dvui.Event, me: dvui.Event.Mouse) bool {
- if (e.handled) return true;
- if (layer_rename_hit_te_id) |rid| {
- if (e.target_widgetId) |tid| {
- if (tid == rid) return true;
- }
- }
- if (layer_rename_hit_rect) |r| {
- if (r.contains(me.p)) return true;
- }
- return false;
-}
-
-/// Layer row rects can extend outside the scroll viewport when content is scrolled; only treat the
-/// pointer as interacting with the list when it lies inside the scroll container's visible clip.
-fn layerPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Rect.Physical) bool {
- if (viewport_r) |r| return r.contains(p);
- return true;
-}
-
-fn layerTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
- if (floating_win != dvui.subwindowCurrentId()) return false;
- const tr = tree.data().borderRectScale().r;
- if (!tr.contains(p)) return false;
- if (!dvui.clipGet().contains(p)) return false;
- return true;
-}
-
-fn layerTreePointerInTreeBorder(tree: *fizzy.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 {
- if (e.target_widgetId) |fwid| {
- if (fwid == tree.data().id) return true;
- }
- const cw = dvui.currentWindow();
- if (cw.dragging.state == .dragging and cw.dragging.name != null) return false;
- const me = e.evt.mouse;
- const in_surface = layerTreePointerInTreeSurface(tree, me.p, me.floating_win);
- const in_border = layerTreePointerInTreeBorder(tree, me.p, me.floating_win);
- return in_surface or in_border;
-}
-
-/// One pass over `events()` in frame order: press → motion → release.
-/// Runs after layer rows (and rename `textEntry`) are built so geometry and `e.handled` reflect z-order.
-fn processLayerTreePointerEvents(tree: *fizzy.dvui.TreeWidget, file: *fizzy.Internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void {
- if (!tree.init_options.enable_reordering) return;
-
- for (dvui.events()) |*e| {
- switch (e.evt) {
- .mouse => |me| {
- if (me.action == .press and me.button.pointer()) {
- if (layerPointerRenameConsumes(e, me)) continue;
- if (!layerPointerInScrollViewport(me.p, layers_viewport_r)) continue;
-
- var row_hit: ?LayerRowHit = null;
- var ri = hits.len;
- while (ri > 0) {
- ri -= 1;
- const h = hits[ri];
- if (h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) {
- row_hit = h;
- break;
- }
- }
- if (row_hit) |h| {
- const cw = dvui.currentWindow();
- if (cw.dragging.state != .none) dvui.dragEnd();
- layerTreeClearGestureKeysOnly(file);
- dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) });
-
- const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod);
- const applied = applyLayerClick(file, h.layer_index, mode);
-
- layer_row_gesture = .{
- .file_id = file.id,
- .press_idx = h.layer_index,
- .press_p = me.p,
- .drag_branch = h.branch_usize,
- .moved = false,
- .reorder_drag = false,
- .narrow_on_release = applied.narrow_on_release,
- };
- } else {
- layerTreeResetRowPointerGesture(file);
- }
- continue;
- }
-
- if (me.action == .motion) {
- if (layerPointerRenameConsumes(e, me)) continue;
-
- if (layer_row_gesture) |*g| {
- if (g.file_id == file.id) {
- const dx = me.p.x - g.press_p.x;
- const dy = me.p.y - g.press_p.y;
- if (dx * dx + dy * dy > 16.0) {
- g.moved = true;
- }
- }
- }
-
- // After `tree.dragStart`, `drag_branch` is cleared — do not gate `matchEvent` on it.
- if (tree.reorderDragActive()) {
- _ = tree.matchEvent(e);
- continue;
- }
-
- const branch_usize = if (layerGestureMatches(file)) layer_row_gesture.?.drag_branch else null;
- if (branch_usize == null) continue;
- _ = tree.matchEvent(e);
- if (!layerTreeMotionAllowsLayerReorder(tree, e)) continue;
-
- const prev_th = dvui.Dragging.threshold;
- dvui.Dragging.threshold = @max(prev_th, 8.0);
- defer dvui.Dragging.threshold = prev_th;
- if (dvui.dragging(me.p, null)) |_| {
- // Row size in natural units; `.{}` → `TreeWidget.dragStart` uses `branch_size`.
- var row_size: dvui.Size = .{};
- for (hits) |h| {
- if (h.branch_usize == branch_usize.?) {
- const rn = h.row_r.toNatural();
- row_size = .{ .w = rn.w, .h = rn.h };
- break;
- }
- }
-
- var multi_buf: [128]usize = undefined;
- const multi_len = buildLayerMultiDragIds(file, hits, multi_buf[0..]);
- if (multi_len > 1) {
- tree.dragStartMulti(branch_usize.?, multi_buf[0..multi_len], me.p, row_size);
- } else {
- tree.dragStart(branch_usize.?, me.p, row_size);
- }
-
- if (layer_row_gesture) |*g| {
- if (g.file_id == file.id) {
- g.reorder_drag = true;
- g.drag_branch = null;
- g.narrow_on_release = false;
- }
- }
- }
- } else if (me.action == .release and me.button.pointer()) {
- if (layerPointerRenameConsumes(e, me)) continue;
-
- const release_in_vp = layerPointerInScrollViewport(me.p, layers_viewport_r);
-
- var release_layer: ?usize = null;
- var rj = hits.len;
- while (rj > 0) {
- rj -= 1;
- const h = hits[rj];
- if (release_in_vp and h.row_r.contains(me.p) and !h.buttons_r.contains(me.p)) {
- release_layer = h.layer_index;
- break;
- }
- }
-
- const idx_opt: ?usize = if (layerGestureMatches(file)) layer_row_gesture.?.press_idx else null;
- const did_reorder = if (layerGestureMatches(file)) layer_row_gesture.?.reorder_drag else false;
- const narrow = if (layerGestureMatches(file)) layer_row_gesture.?.narrow_on_release else false;
-
- var selection_changed = false;
- if (!did_reorder and !tree.drag_ending and release_in_vp and narrow) {
- if (idx_opt) |pi| {
- narrowLayerSelectionTo(file, pi);
- selection_changed = true;
- }
- }
-
- if (idx_opt != null) {
- layerTreeResetRowPointerGesture(file);
- if (!did_reorder and !dvui.captured(tree.data().id)) {
- dvui.captureMouse(null, e.num);
- }
- }
-
- if (selection_changed) {
- dvui.refresh(null, @src(), tree.data().id);
- }
- }
- },
- else => {},
- }
- }
-}
diff --git a/src/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/panel/sprites.zig b/src/editor/panel/sprites.zig
deleted file mode 100644
index e685370b..00000000
--- a/src/editor/panel/sprites.zig
+++ /dev/null
@@ -1,1329 +0,0 @@
-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 Sprites = @This();
-
-/// Side-card fly-out / fly-in master timeline (microseconds, linear 0↔1).
-const fly_anim_duration_us: i64 = 750_000;
-/// Normalised fly speed below which a card stops stirring the water.
-const ripple_vel_dead: f32 = 0.06;
-/// Per-slot reflection bookkeeping arrays are indexed by `it.d + field_center`.
-const max_refl_ripple_slots: usize = wsurf.max_slots;
-/// Mean per-cell surface energy below which the water is settled (stop refreshing).
-const water_settle_energy: f32 = 0.006;
-/// Fly motion → velocity impulse (normalised fly speed × k).
-const water_stir_k: f32 = 68.0;
-/// Inertial drag wake: velocity impulse per unit change in shelf speed (slots/s),
-/// injected as a localized splash (like fly-in) so it ripples rather than uniformly
-/// shrinking the reflections. Acceleration-driven, so small quick shakes read big
-/// and an abrupt stop throws a forward wake, while a steady drag stays calm.
-const water_drag_k: f32 = 20.0;
-/// Inject radius (field columns) for the drag wake. Wider than a point so the
-/// per-frame scroll stir excites smooth, propagating ripples instead of grid-scale
-/// spikes (a 1-cell impulse shimmers in place; a broad bump travels and reads watery).
-const water_drag_radius: f32 = 10.0;
-/// Steady drag/coast bow wake: dv-only injects vanish at constant speed, so a small
-/// per-frame velocity stir keeps ripples visible under the finger (scaled by dt).
-const water_scroll_bow_k: f32 = 3.2;
-/// Extra reflection refraction while the shelf is actively moving.
-const water_scroll_disp_boost: f32 = 1.25;
-/// Fly-out transition splash at the centre (velocity impulse).
-const water_fly_out_impulse: f32 = -10.5;
-/// Fly-in: card bottom is `baseline_y - fly_offset`; ripple only once this close.
-/// Fly-out: stir while the card is still near the line as it lifts away.
-const water_fly_out_near_k: f32 = 0.22;
-/// Downward velocity impulse when a flown-in card splashes back through the waterline.
-const water_land_impulse: f32 = -20.0;
-/// Surface slope → horizontal refraction at the waterline (fraction of card height).
-const water_disp_k: f32 = 1.1;
-/// Fly stir / splash: Gaussian radius as a fraction of one card's field span.
-/// Wider + more taps than a point inject — spreads energy instead of column bars.
-const water_fly_stir_radius_frac: f32 = 0.24;
-/// Fly in/out impulses are scaled down vs scroll/drag — staggered lifts otherwise
-/// over-drive the surface and pull the reflection seam up.
-const water_fly_impulse_scale: f32 = 0.36;
-/// Reflection wobble while `fly_t > 0` (field still ripples, seam rise reads softer).
-const water_fly_refl_scale: f32 = 0.40;
-/// Fly stir velocity dead-zone — higher than scroll so only brisk line contact stirs.
-const water_fly_vel_dead: f32 = 0.11;
-/// Scroll wake spread (slots): the head-on cards each carve their own wake, fading
-/// to nothing ~this many slots out, so ripples emanate from every card the shelf
-/// drags across instead of one point at the focus.
-const wake_spread_slots: f32 = 3.5;
-
-/// Per-card scroll-wake weight by screen offset — 1 at the focus, linear to 0 at
-/// `wake_spread_slots`. Used to distribute the shelf's stir across the visible cards.
-fn wakeWeight(off: f32) f32 {
- return @max(0.0, 1.0 - @abs(off) / wake_spread_slots);
-}
-
-const FlowItem = struct { idx: usize, off: f32, d: i64, id: usize, center: bool };
-
-/// Spread a fly velocity impulse across a card's width with per-card phase so
-/// staggered fly-in/out stirs don't line up as slot-column vertical bars.
-fn injectFlyRipple(water: *wsurf.WaterSurface, slot_d: i64, vel_strength: f32, phase: f32) void {
- const strength = vel_strength * water_fly_impulse_scale;
- if (@abs(strength) < 0.0001) return;
- const left = wsurf.slotLeftCol(slot_d);
- const span: f32 = @as(f32, @floatFromInt(wsurf.cols_per_slot));
- const r = span * water_fly_stir_radius_frac;
- const wobble = std.math.sin(phase + @as(f32, @floatFromInt(slot_d)) * 2.17) * span * 0.11;
- const taps = [_]struct { t: f32, w: f32 }{
- .{ .t = 0.18, .w = 0.28 },
- .{ .t = 0.40, .w = 0.26 },
- .{ .t = 0.62, .w = 0.24 },
- .{ .t = 0.82, .w = 0.22 },
- };
- for (taps) |tap| {
- water.inject(left + tap.t * span + wobble, r, 0, strength * tap.w);
- }
-}
-
-/// True once a flying side card's bottom has reached the shared waterline.
-fn flyCardTouchesWater(fly_offset: f32, fly_anim_out: bool, max_fly_off: f32) bool {
- if (fly_anim_out) return fly_offset < max_fly_off * water_fly_out_near_k;
- return fly_offset <= 0.0;
-}
-
-const CardDraw = struct {
- item: FlowItem,
- rect: dvui.Rect,
- w: f32,
- h: f32,
- depth: f32,
- opacity: f32,
- item_scale: f32,
- fly_offset: f32,
- off: f32,
- is_focus: bool,
-};
-
-/// Stable widget id for a cover-flow slot (sprite draw + reflection ripple share this).
-const SpriteSlot = struct {
- fn src() std.builtin.SourceLocation {
- return @src();
- }
- fn id(id_extra: usize) dvui.Id {
- return dvui.parentGet().extendId(src(), id_extra);
- }
-};
-
-/// Cover-flow scrub momentum tuning (sprite-index units). See `fizzy.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 = .{
- .decay = 4.0,
- .min_start = 1.2,
- .stop = 0.6,
- .max = 50.0,
- .idle_s = 0.12,
-};
-/// Window the mouse/trackpad release velocity is averaged over (s).
-const sprite_fling_window_s: f32 = 0.08;
-/// Touch scrub: a finger flick is short and bursty, so start coasting at a lower
-/// speed and tolerate the small gap the browser leaves before `touchend`. Velocity is
-/// measured over a position/time window (`releaseWindowed`) rather than the last frame.
-const sprite_fling_touch: fizzy.Fling.Tuning = .{
- .decay = 4.0,
- .min_start = 0.6,
- .stop = 0.6,
- .max = 50.0,
- .idle_s = 0.2,
-};
-/// Window the touch release velocity is averaged over (s).
-const sprite_fling_touch_window_s: f32 = 0.1;
-/// Upper bound on the per-frame delta fed to the passive cover-flow ease.
-const max_ease_dt: f32 = 1.0 / 30.0;
-/// Extra skewed shelf slots drawn beyond the pane-fit estimate (each side).
-const shelf_edge_extra: i64 = 2;
-/// Slot distance past `flat_zone` over which skewed shelf cards fade out. Kept
-/// short so a wide pane doesn't spread a gentle fade across the whole window.
-const shelf_opacity_fade_span: f32 = 2.5 + @as(f32, @floatFromInt(shelf_edge_extra));
-/// Pane-edge distance (in card widths) over which cards fade to transparent.
-const shelf_edge_fade_w: f32 = 2.5;
-/// Below this opacity a non-focus card is invisible — skip building/rendering its
-/// (expensive O(n²)) reflection mesh entirely rather than drawing it transparent.
-const card_cull_opacity: f32 = 0.012;
-/// Reflection mesh density for a fully-skewed shelf card, as a fraction of the
-/// head-on (focus) density. The head-on three cards render at full detail; skewed
-/// cards ramp down to this so the off-axis shelf stays cheap on slower GPUs.
-const skewed_reflection_detail: f32 = 0.3;
-/// Draw an on-screen readout of the last touch fling decision (velocity / idle / coast)
-/// so the touch-only momentum can be tuned on a real device. Set false to hide.
-const debug_touch_fling = false;
-
-// Animated fit-scale state (shared, like a singleton preview).
-var prev_scale: f32 = 1.0;
-var current_scale: f32 = 1.0;
-
-// ---- Cover-flow state (persisted on the Panel's Sprites instance) ----
-/// Current fractional center index that the flow is rendered around. The sprite
-/// nearest this value is drawn flat and on top; neighbours rotate away like
-/// records on a shelf.
-scroll_pos: f32 = 0.0,
-/// Index the flow is easing toward. Driven either by the editor selection or by
-/// the user scrolling/dragging the flow itself.
-goal: f32 = 0.0,
-/// Last virtual center index we observed from the rest of the editor, so we
-/// can tell an external selection change apart from one we caused ourselves.
-last_sel_virtual: usize = std.math.maxInt(usize),
-/// Last virtual index we pushed into editor state from the cover flow.
-last_committed_virtual: usize = std.math.maxInt(usize),
-/// Accumulates fractional wheel deltas until they cross a whole step.
-wheel_accum: f32 = 0.0,
-/// True only on frames where the user is actively dragging the flow.
-drag_active: bool = false,
-/// Whether the pointer moved between press and release (drag vs. click).
-moved_since_press: bool = false,
-/// True when the active scrub began with a touch press (not mouse).
-drag_was_touch: bool = false,
-/// Release momentum for the scrub: coasts the flow after a flick, then snaps.
-fling: fizzy.Fling = .{},
-/// Set once we've seeded `scroll_pos` from the initial selection.
-initialized: bool = false,
-/// Previous "flown" state (see `sideCardsFlown`), so we can fire the fly-out /
-/// fly-in transition the frame it flips. While flown, the side cards lift up
-/// out of view so only the focused card shows (less distracting).
-was_flown: bool = false,
-/// Direction of the in-flight `play_fly` animation (outBack vs inBack).
-fly_anim_out: bool = false,
-/// Shared water surface (slot space) all reflections ripple in. See `water_surface.zig`.
-water: wsurf.WaterSurface = .{},
-/// Focused slot index last frame — re-anchors the water field as the shelf scrolls.
-prev_center_i: i64 = 0,
-/// Per-slot previous fly offset for velocity estimation (indexed by `d + field_center`).
-prev_fly_offset: [max_refl_ripple_slots]f32 = .{0} ** max_refl_ripple_slots,
-/// Per-slot: card dipped below the waterline (fly-in inBack overshoot), awaiting a splash.
-was_dipping: [max_refl_ripple_slots]bool = .{false} ** max_refl_ripple_slots,
-/// Previous `scroll_pos` — the per-frame delta drives the inertial slosh.
-prev_scroll_pos: f32 = 0.0,
-/// Smoothed shelf velocity (slots/s); its per-frame change tilts the water.
-shelf_vel: f32 = 0.0,
-
-pub fn draw(self: *Sprites) !void {
- if (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) {
- return;
- }
-
- self.drawAnimationControlsDialog();
-
- // Since not all panel screens will likely want shadows, which should be reserved for canvases?
- // Text editors, consoles, etc would likely want flat panels or to handle shadows themselves.
- defer {
- 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 });
- }
-
- const parent = dvui.parentGet().data().rect;
- const parent_height = parent.h;
-
- const mode = scrollMode(file);
- const count = scrollCount(file, mode);
- if (count == 0) {
- return;
- }
-
- // ---- Fly-out / fly-in master timeline. `fly_t` runs 0 (all cards at
- // rest) → 1 (side cards lifted out of view) as a linear master clock; each
- // card derives a staggered, eased offset from it below. We flip the target
- // the frame playback starts/stops. ----
- const playing = file.editor.playing;
- const flown = sideCardsFlown(playing);
- const panel_id = dvui.parentGet().data().id;
- if (flown != self.was_flown) {
- const cur: f32 = if (dvui.animationGet(panel_id, "play_fly")) |a| a.value() else (if (self.was_flown) 1.0 else 0.0);
- self.fly_anim_out = flown;
- dvui.animation(panel_id, "play_fly", .{
- .end_time = fly_anim_duration_us,
- .easing = dvui.easing.linear,
- .start_val = cur,
- .end_val = if (flown) 1.0 else 0.0,
- });
- if (flown) {
- @memset(&self.water.height, 0);
- @memset(&self.water.vel, 0);
- if (!dvui.reduce_motion) {
- injectFlyRipple(&self.water, 0, water_fly_out_impulse, cur);
- }
- } else {
- @memset(&self.was_dipping, false);
- }
- self.was_flown = flown;
- }
- const fly_t: f32 = if (dvui.animationGet(panel_id, "play_fly")) |a|
- std.math.clamp(a.value(), 0.0, 1.0)
- else if (flown) 1.0 else 0.0;
-
- // Every sprite in a file shares the same cell size, so any sprite rect
- // works for sizing the flow.
- const src_rect = file.spriteRect(0);
-
- // ---- Animated fit-scale: aim the front sprite at a fraction of the
- // pane so several neighbours are visible at once. ----
- const scale = blk: {
- const steps = fizzy.editor.settings.zoom_steps;
- const sprite_width = src_rect.w;
- const sprite_height = src_rect.h;
- const target_width = parent.w * 0.34;
- const target_height = parent.h * 0.62;
- var target_scale: f32 = 1.0;
-
- for (steps, 0..) |zoom, i| {
- if ((sprite_width * zoom) >= target_width or (sprite_height * zoom) >= target_height) {
- if (i > 0) {
- target_scale = steps[i - 1];
- break;
- }
- target_scale = steps[i];
- break;
- }
- }
-
- if (target_scale != current_scale) {
- if (dvui.animationGet(dvui.parentGet().data().id, "scale")) |a| {
- if (a.done()) {
- current_scale = target_scale;
- prev_scale = current_scale;
- } else {
- if (a.end_val != target_scale) {
- _ = dvui.currentWindow().animations.remove(dvui.parentGet().data().id.update("scale"));
- dvui.animation(dvui.parentGet().data().id, "scale", .{
- .end_time = 600_000,
- .easing = dvui.easing.outBack,
- .start_val = a.value(),
- .end_val = target_scale,
- });
- } else {
- current_scale = a.value();
- }
- }
- } else {
- prev_scale = current_scale;
- dvui.animation(dvui.parentGet().data().id, "scale", .{
- .end_time = 600_000,
- .easing = dvui.easing.outBack,
- .start_val = prev_scale,
- .end_val = target_scale,
- });
- }
- }
-
- break :blk current_scale;
- };
-
- const item_w = @as(f32, @floatFromInt(file.column_width)) * scale;
- const item_h = @as(f32, @floatFromInt(file.row_height)) * scale;
-
- // Front group: the focus card plus `flat_zone` neighbours each side sit
- // flat, spaced `front_gap` apart. Past the group a `shelf_gap` opens up
- // (eased in, not a hard step) and the rest tile `far_spread` apart while
- // rotating onto the shelf over `tilt_ramp` index units.
- const front_gap = item_w * 1.2;
- const shelf_gap = item_w * 0.5;
- const far_spread = item_w * 0.62;
- const max_depth: f32 = 0.55;
- const flat_zone: f32 = 1.0;
- const tilt_ramp: f32 = 1.5;
- const gap_ramp: f32 = 1.0;
-
- // ---- Seed the flow position from the current selection on first frame. ----
- const sel_virtual = currentVirtualTarget(file, mode, count);
- if (!self.initialized) {
- self.scroll_pos = @floatFromInt(sel_virtual);
- self.goal = self.scroll_pos;
- self.prev_scroll_pos = self.scroll_pos;
- self.prev_center_i = @intFromFloat(@floor(self.scroll_pos));
- self.last_sel_virtual = sel_virtual;
- self.last_committed_virtual = sel_virtual;
- self.initialized = true;
- }
-
- // ---- User input (wheel / drag) may override the flow and the selection. ----
- self.handleInput(file, mode, count, front_gap, flown);
-
- if (debug_touch_fling) {
- const d = self.fling.last_debug;
- dvui.label(@src(), "touch fling: vel {d:.2} idle {d:.3}s dt {d:.3}s n {d} coast {}", .{
- d.vel, d.idle_s, d.dt, d.samples, d.coasted,
- }, .{
- .color_text = dvui.themeGet().color(.content, .text),
- .background = true,
- .color_fill = dvui.themeGet().color(.window, .fill),
- });
- }
-
- // An external selection change (clicking a sprite, picking an animation,
- // playback advancing a frame) retargets the flow. Pick the wrapped
- // representative nearest the current position so we ease the short way
- // around the loop (e.g. from the first sprite leftwards to the last).
- if (!self.drag_active and sel_virtual != self.last_sel_virtual) {
- self.goal = nearestWrapped(self.scroll_pos, sel_virtual, count);
- self.last_sel_virtual = sel_virtual;
- self.last_committed_virtual = sel_virtual;
- }
-
- // ---- Move toward the goal. While cards are flown (playback, drawing
- // tools, or the preview toggle) we snap so the focus card swaps instantly
- // instead of sliding through neighbours; reduce_motion snaps always.
- // Otherwise ease (frame-rate independent). ----
- if (flown or dvui.reduce_motion) {
- self.scroll_pos = self.goal;
- self.fling.cancel();
- self.commitCenteredIfNeeded(file, mode, count);
- } else if (self.drag_active) {
- // Position is driven directly by the drag in handleInput.
- self.fling.cancel();
- } else if (self.fling.coasting) {
- // Coast with decaying momentum from the release, then snap to (and
- // select) the nearest sprite once the coast slows to a stop.
- if (self.fling.step(sprite_fling)) |d| {
- self.scroll_pos += d;
- self.goal = self.scroll_pos;
- }
- if (!self.fling.coasting) {
- const snapped: i64 = @intFromFloat(@round(self.scroll_pos));
- self.goal = @floatFromInt(snapped);
- }
- dvui.refresh(null, @src(), dvui.parentGet().data().id);
- } else {
- const diff = self.goal - self.scroll_pos;
- if (@abs(diff) > 0.001) {
- // Clamp dt so a wake-from-idle frame (huge secondsSinceLastFrame) doesn't
- // collapse the ease into a single-frame snap. See `max_ease_dt`.
- const dt = @min(dvui.secondsSinceLastFrame(), max_ease_dt);
- const t = 1.0 - @exp(-12.0 * dt);
- self.scroll_pos += diff * t;
- dvui.refresh(null, @src(), dvui.parentGet().data().id);
- } else {
- self.scroll_pos = self.goal;
- // Passive ease finished — sync editor state once at the destination.
- self.commitCenteredIfNeeded(file, mode, count);
- }
- }
- // Infinite wrap: keep scroll_pos (and the goal it chases) within one loop
- // by shifting both by whole turns. The wrapped rendering below is identical
- // regardless of which turn we're on, so this is seamless even mid-ease.
- {
- const c: f32 = @floatFromInt(count);
- const k = @floor(self.scroll_pos / c);
- if (k != 0.0) {
- self.scroll_pos -= k * c;
- self.goal -= k * c;
- self.prev_scroll_pos -= k * c;
- }
- }
-
- // Only push selection / frame changes while the user is actively scrubbing.
- // During passive ease toward a goal, scroll_pos lags behind — per-frame
- // commits would fight wheel/drag commits and retrigger canvas bubble animations.
- if (self.drag_active or self.fling.coasting) {
- self.commitCenteredIfNeeded(file, mode, count);
- }
-
- if (parent.h < 32.0) {
- return;
- }
-
- const perf_sp = fizzy.perf.spritePreviewBegin();
- defer fizzy.perf.spritePreviewEnd(perf_sp);
-
- const center_x = parent.center().x;
- // Lift the row a little so the reflection has room below it.
- const center_y = parent.center().y - item_h * 0.10;
- // The waterline: the shared bottom edge every card stands on (the focus
- // card's full-height bottom). Side cards pin their bottom here too.
- const baseline_y = center_y + item_h / 2.0;
-
- // ---- Collect a window of sprites around the centre and draw them back
- // to front so the focused sprite lands on top. The window grows with the
- // pane so we show as many cards as actually fit, up to a sane cap. ----
- const max_window: i64 = 12;
- const window: i64 = blk: {
- const half_visible = parent.w / 2.0 + item_w;
- const front_extent = flat_zone * front_gap + shelf_gap;
- if (far_spread <= 0.0 or half_visible <= front_extent) break :blk @max(1, @as(i64, @intFromFloat(flat_zone)));
- const extra = @floor((half_visible - front_extent) / far_spread);
- const fit = @as(i64, @intFromFloat(flat_zone)) + 1 + @as(i64, @intFromFloat(extra)) + shelf_edge_extra;
- break :blk std.math.clamp(fit, 1, max_window);
- };
-
- // Floor (not round) so the focused slot doesn't swap at half-integers while
- // scroll_pos eases toward goal after a slow release.
- const center_i: i64 = @intFromFloat(@floor(self.scroll_pos));
-
- const scroll_dt = @max(dvui.secondsSinceLastFrame(), 0.0001);
- // Signed slots the shelf moved this frame — covers drag, ease, and fling
- // coast alike (they all move scroll_pos). This single delta drives the wake.
- const scroll_travel = self.scroll_pos - self.prev_scroll_pos;
- self.prev_scroll_pos = self.scroll_pos;
-
- // ---- Advance the shared water surface. Ripples live in cover-flow slot
- // space anchored to the focused card. While side cards are flown out,
- // scroll snaps instantly (playback / focus mode) — skip stir and reset on
- // slot change so frame advances don't retrigger endless waves. ----
- const water_live = !dvui.reduce_motion;
- const water_scroll_stir = water_live and !flown;
- // Scroll-wake velocity impulses for this frame, distributed across the
- // visible cards in pass 1 (so each head-on card stirs its own slot) rather
- // than injected at one point here. `dv` ≈ acceleration; `bow` is the steady
- // drag/coast stir. Both are computed once and shared out by `wakeWeight`.
- var wake_dv_impulse: f32 = 0;
- var wake_bow_impulse: f32 = 0;
- if (water_live) {
- if (flown) {
- if (center_i != self.prev_center_i) {
- @memset(&self.water.height, 0);
- @memset(&self.water.vel, 0);
- }
- } else {
- self.water.reanchor(center_i - self.prev_center_i);
- }
- self.water.step(scroll_dt);
-
- if (water_scroll_stir) {
- // Inertial drag wake: the same localized splash the fly-in uses, but
- // triggered by the *change* in shelf speed (≈ acceleration) rather
- // than a bulk tilt. A localized impulse makes curved, propagating
- // ripples — the watery look — whereas tilting the whole field just
- // shifts each reflection uniformly. Driving by velocity-change means
- // a small quick shake fires a big ripple and an abrupt stop throws a
- // forward wake, while a steady drag stays calm. Zero-mean over a
- // gesture, so it settles on its own.
- const v_raw = scroll_travel / scroll_dt; // signed slots/s
- // Smooth the shelf-velocity estimate more (was 42): pointer input is
- // noisy frame-to-frame, and `dv` drives the wake — a gentler tracker
- // means a steadier stir instead of a jittery stream of impulses.
- const v_new = std.math.lerp(self.shelf_vel, v_raw, 1.0 - @exp(-18.0 * scroll_dt));
- const dv = v_new - self.shelf_vel;
- self.shelf_vel = v_new;
- if (@abs(dv) > 0.0001) {
- wake_dv_impulse = -dv * water_drag_k;
- }
- // Acceleration injects miss steady drags — add a bow wake while moving.
- if (@abs(v_new) > 0.22 and (self.drag_active or self.fling.coasting)) {
- wake_bow_impulse = -v_new * water_scroll_bow_k * scroll_dt;
- }
- } else {
- self.shelf_vel = 0;
- }
- }
- self.prev_center_i = center_i;
-
- // `slot` is the unwrapped position (so `off` and the skew stay continuous);
- // `idx` is the wrapped sprite it shows; `id` is a per-slot widget id so
- // duplicate sprites (loop shorter than the window) don't collide.
- var items: [2 * 12 + 1]FlowItem = undefined;
- var n: usize = 0;
- var d: i64 = -window;
- while (d <= window) : (d += 1) {
- const slot = center_i + d;
- const virtual = wrapIndex(slot, count);
- items[n] = .{
- .idx = virtualToSpriteIndex(file, mode, virtual),
- .off = @as(f32, @floatFromInt(slot)) - self.scroll_pos,
- .d = d,
- .id = @intCast(d + window),
- .center = d == 0,
- };
- n += 1;
- }
-
- const SortCtx = struct {
- fn lessThan(_: void, a: FlowItem, b: FlowItem) bool {
- return @abs(a.off) > @abs(b.off);
- }
- };
- std.sort.pdq(FlowItem, items[0..n], {}, SortCtx.lessThan);
-
- // Total wake weight across the visible cards, so the per-card stir in pass 1
- // shares out the *same* total energy as the old single-point wake — just
- // spread over the cards by `wakeWeight` instead of all at the focus.
- var wake_w_total: f32 = 0;
- if (water_scroll_stir and (wake_dv_impulse != 0 or wake_bow_impulse != 0)) {
- for (items[0..n]) |it| wake_w_total += wakeWeight(it.off);
- }
-
- // Cull side cards only once the fly-out has finished — not when outBack
- // crosses 1 mid-animation (that overshoot is the visible fling).
- const fly_cull_side_cards = blk: {
- if (dvui.animationGet(panel_id, "play_fly")) |a| break :blk a.done() and flown;
- break :blk flown;
- };
-
- var draws: [max_refl_ripple_slots]CardDraw = undefined;
- var draw_n: usize = 0;
- // Pass 1 — layout, then inject this card's motion into the shared water.
- for (items[0..n]) |it| {
- const off = it.off;
-
- // Per-card scroll wake: stir this card's own patch of water (its slot's
- // sample band) so ripples are born under each head-on card and fade out
- // as cards skew toward the edges — not all from the focus. The normalized
- // weight keeps the total energy equal to the old single wake.
- if (water_scroll_stir and wake_w_total > 0.0) {
- const w = wakeWeight(off) / wake_w_total;
- if (w > 0.0) {
- const col = wsurf.slotCenterCol(it.d);
- if (wake_dv_impulse != 0) self.water.inject(col, water_drag_radius, 0, wake_dv_impulse * w);
- if (wake_bow_impulse != 0) self.water.inject(col, water_drag_radius * 1.15, 0, wake_bow_impulse * w);
- }
- }
-
- const a = std.math.clamp(off, -flat_zone, flat_zone);
- const beyond = off - a;
-
- const tilt = std.math.clamp((@abs(off) - flat_zone) / tilt_ramp, 0.0, 1.0);
- const gap_t = std.math.clamp((@abs(off) - flat_zone) / gap_ramp, 0.0, 1.0);
- const x_off = a * front_gap + beyond * far_spread + std.math.sign(off) * gap_t * shelf_gap;
-
- const depth = -std.math.sign(off) * tilt * max_depth;
-
- // Every card is the same size: the three head-on cards match, and each
- // skewed card's standing (baseline) edge is full height too. Depth reads
- // from the perspective fold, shelf spacing, and opacity fade — not from
- // shrinking the cards (which would also distort the sprite's aspect).
- const item_scale: f32 = 1.0;
- const w = item_w * item_scale;
- const h = item_h * item_scale;
-
- // Head-on cards (inside `flat_zone`) stay fully opaque. Skewed shelf
- // cards fade over `shelf_opacity_fade_span` slots — not the full window
- // — so outer cards fall off quickly on wide panes. Pane-edge clipping
- // fades further when cards actually run into the sides.
- const card_x = center_x + x_off;
- const abs_off = @abs(off);
- const opacity: f32 = if (abs_off <= flat_zone) 1.0 else blk: {
- const skew_t = std.math.clamp((abs_off - flat_zone) / shelf_opacity_fade_span, 0.0, 1.0);
- const slot_op = 1.0 - skew_t;
- const edge_dist = @min(card_x - parent.x, (parent.x + parent.w) - card_x);
- const edge_op = std.math.clamp(edge_dist / (item_w * shelf_edge_fade_w), 0.0, 1.0);
- break :blk @min(slot_op, edge_op);
- };
- const is_focus = it.center;
-
- const si: usize = @intCast(it.d + @as(i64, @intCast(wsurf.field_center)));
- const max_fly_off = parent.h + item_h;
-
- var fly_offset: f32 = 0.0;
- if (!is_focus and fly_t > 0.0) {
- const s = std.math.clamp((@abs(off) - 1.0) / @as(f32, @floatFromInt(window)), 0.0, 1.0);
- const stagger_span: f32 = 0.5;
- const local = std.math.clamp((fly_t - s * stagger_span) / (1.0 - stagger_span), 0.0, 1.0);
- const f = if (self.fly_anim_out) dvui.easing.outBack(local) else dvui.easing.inBack(local);
- fly_offset = f * max_fly_off;
- if (fly_cull_side_cards and f >= 1.0) {
- self.prev_fly_offset[si] = fly_offset;
- continue;
- }
- }
-
- // Index per-slot bookkeeping by slot position (d + field_center), so the
- // arrays track the card currently at each screen slot as the shelf flows.
- const fly_delta = fly_offset - self.prev_fly_offset[si];
- // Cards culled during fly-out reappear with a huge position jump — don't
- // treat that as velocity or the water ripples before they reach the line.
- const fly_teleport = @abs(fly_delta) > max_fly_off * 0.4;
- const fly_vel: f32 = if (fly_teleport) 0 else fly_delta / scroll_dt;
- self.prev_fly_offset[si] = fly_offset;
-
- // Stand every card on a shared waterline: pin the bottom edge to the
- // baseline (so shrunk side cards drop to the same line as the focus
- // card). Per-column wobble is applied in the sprite mesh via
- // `reflection_lag.cols_dy`; the rect stays on the resting line.
- const rect = dvui.Rect{
- .x = center_x + x_off - w / 2.0,
- .y = baseline_y - h - fly_offset,
- .w = w,
- .h = h,
- };
-
- if (water_live and !is_focus and fly_t > 0.0) {
- const fly_speed = fly_vel / @max(parent.h, 1.0);
- const touches_water = flyCardTouchesWater(fly_offset, self.fly_anim_out, max_fly_off);
- const ripple_phase = fly_offset * 0.008 + fly_t * 6.28 + @as(f32, @floatFromInt(it.id)) * 0.41;
-
- if (touches_water and !fly_teleport and @abs(fly_speed) > water_fly_vel_dead) {
- injectFlyRipple(&self.water, it.d, -fly_speed * water_stir_k, ripple_phase);
- }
-
- const dipping = !self.fly_anim_out and touches_water and fly_offset < -1.0;
- if (dipping) {
- self.was_dipping[si] = true;
- } else if (self.was_dipping[si] and fly_offset >= -0.3) {
- self.was_dipping[si] = false;
- injectFlyRipple(&self.water, it.d, water_land_impulse, ripple_phase + 1.9);
- }
- } else if (fly_cull_side_cards or fly_t <= 0.0) {
- self.was_dipping[si] = false;
- }
-
- draws[draw_n] = .{
- .item = it,
- .rect = rect,
- .w = w,
- .h = h,
- .depth = depth,
- .opacity = opacity,
- .item_scale = item_scale,
- .fly_offset = fly_offset,
- .off = off,
- .is_focus = is_focus,
- };
- draw_n += 1;
- }
-
- const max_fly_off_draw = parent.h + item_h;
-
- // Pass 2 — draw cards; reflections sample the shared water surface across
- // each card's slot span, so adjacent reflections distort continuously.
- for (draws[0..draw_n]) |cd| {
- // Faded-out edge cards are invisible — skip them so we don't build and
- // render their reflection meshes (the per-card hot path) for nothing.
- if (!cd.is_focus and cd.opacity <= card_cull_opacity) continue;
-
- const it = cd.item;
-
- // Grow the shadow smoothly as a card nears the centre (1 at the focus,
- // 0 by one slot out) instead of a hard focus/non-focus switch — so the
- // heavier shadow doesn't snap between cards as the focus flips on scroll.
- const focusness = std.math.clamp(1.0 - @abs(cd.off), 0.0, 1.0);
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .id_extra = it.id,
- .expand = .none,
- .rect = cd.rect,
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = 0.0, .y = std.math.lerp(5.0, 8.0, focusness) },
- .fade = std.math.lerp(8.0, 12.0, focusness),
- .alpha = std.math.lerp(0.2, 0.25, focusness) * cd.opacity,
- .corner_radius = dvui.Rect.all(parent_height / 32.0),
- },
- });
- defer hbox.deinit();
-
- const item_src = file.spriteRect(it.idx);
-
- // Sample the shared surface once the card bottom is on the waterline.
- // During fly-in the reflection travels with the card but the surface
- // field stays flat until contact — avoids the line rising early.
- var lag_sample: ReflectionLagSample = .{};
- const touches_water_draw = flyCardTouchesWater(cd.fly_offset, self.fly_anim_out, max_fly_off_draw);
- const refl_water = !dvui.reduce_motion and (it.center or fly_t <= 0.0 or touches_water_draw);
- if (refl_water) {
- const left_col = wsurf.slotLeftCol(it.d);
- const span: f32 = @floatFromInt(wsurf.cols_per_slot);
- const refl_scale: f32 = if (fly_t > 0.0) water_fly_refl_scale else 1.0;
- // Horizontal refraction only — cols_dy is unused (vertical mesh warp squished
- // the reflection while the field was active).
- const scroll_wake_disp: f32 = if (flown or fly_t > 0.0 or
- !(self.drag_active or self.fling.coasting or @abs(self.shelf_vel) > 0.45))
- 1.0
- else
- water_scroll_disp_boost;
- inline for (0..reflection_surface_cols) |c| {
- const t = @as(f32, @floatFromInt(c)) / @as(f32, @floatFromInt(reflection_surface_cols - 1));
- const col = left_col + t * span;
- const slope = self.water.visualSlopeAt(col);
- lag_sample.cols_dx[c] = slope * cd.h * water_disp_k * refl_scale * scroll_wake_disp;
- }
- }
-
- // Head-on cards (no skew → depth 0) get the full, high-res reflection
- // mesh; skewed shelf cards ramp down to `skewed_reflection_detail` so the
- // off-axis cards stay cheap. Ramps with the tilt so there's no pop as a
- // card scrolls between the flat group and the shelf.
- const tiltness = if (max_depth > 0.0) std.math.clamp(@abs(cd.depth) / max_depth, 0.0, 1.0) else 0.0;
- const refl_detail = std.math.lerp(1.0, skewed_reflection_detail, tiltness);
-
- _ = fizzy.dvui.sprite(SpriteSlot.src(), .{
- .source = file.layers.items(.source)[file.selected_layer_index],
- .file = file,
- .alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null,
- .sprite = .{
- .source = .{
- @intFromFloat(item_src.x),
- @intFromFloat(item_src.y),
- @intFromFloat(item_src.w),
- @intFromFloat(item_src.h),
- },
- .origin = .{ 0, 0 },
- },
- .scale = scale * cd.item_scale,
- .depth = cd.depth,
- .opacity = cd.opacity,
- .reflection = true,
- // Peel the reflection down as the card lifts (2× fly_offset). 1:1 left the
- // seam at the waterline while the card rose — reflection stayed put and
- // vanished on cull. Seam pinning + no fly cols_dy keeps 2× from reading
- // as the old ~⅛-card rise.
- .reflection_offset = 2.0 * cd.fly_offset,
- .reflection_lag = if (refl_water) lag_sample else null,
- .reflection_detail = refl_detail,
- }, .{
- .id_extra = it.id,
- .margin = .all(0),
- .padding = .all(0),
- });
- }
-
- // Keep animating until the water settles, so ripples decay smoothly after
- // the cards stop moving. Crucially, stay awake (and never hard-reset) while
- // the shelf is still moving: a small drag injects only a little localized
- // velocity, so its mean energy can sit below `water_settle_energy` for the
- // first frames — without this, the reset below would wipe the disturbance
- // the same frame it's injected, before the wave develops into ripples (the
- // intermittent "sometimes no ripple" bug).
- if (!dvui.reduce_motion) {
- const e = self.water.energy();
- const moving = self.drag_active or self.fling.coasting or @abs(scroll_travel) > 1e-5;
- if (e > water_settle_energy or moving) {
- dvui.refresh(null, @src(), panel_id);
- } else if (e > 0.0001) {
- @memset(&self.water.height, 0);
- @memset(&self.water.vel, 0);
- }
- }
- }
-}
-
-/// Side cards lift away during playback, while a drawing tool is active, or when
-/// `settings.scrolling_cards` is off (focus mode; toggled in settings or the sprites pane).
-fn sideCardsFlown(playing: bool) bool {
- return playing or drawingToolActive() or !fizzy.editor.settings.scrolling_cards;
-}
-
-/// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee).
-fn drawingToolActive() bool {
- return switch (fizzy.editor.tools.current) {
- .pointer, .selection => false,
- .pencil, .eraser, .bucket => true,
- };
-}
-
-/// How the cover-flow loop and scroll-to-editor sync behave.
-const ScrollMode = enum {
- /// All sprites; scrolling does not change selection or animation frame.
- all_passive,
- /// All sprites; the centered sprite becomes the sole selection.
- all_follow_selection,
- /// Animation frames only; the active frame follows the center; no sprite selection.
- animation_passive,
- /// Animation frames; active frame and a single in-animation sprite follow the center.
- animation_follow_selection,
- /// Multi-sprite selection only; primary tile follows the centered sprite.
- selection_only,
-};
-
-fn scrollMode(file: anytype) ScrollMode {
- const sel_count = file.editor.selected_sprites.count();
- if (sel_count > 1) return .selection_only;
-
- if (file.selected_animation_index) |ai| {
- const frames = file.animations.get(ai).frames;
- if (frames.len == 0) return .all_passive;
- if (sel_count == 1) {
- const si = file.editor.selected_sprites.findFirstSet() orelse return .all_passive;
- for (frames) |f| {
- if (f.sprite_index == si) return .animation_follow_selection;
- }
- return .all_follow_selection;
- }
- return .animation_passive;
- }
-
- if (sel_count == 1) return .all_follow_selection;
- return .all_passive;
-}
-
-fn scrollCount(file: anytype, mode: ScrollMode) usize {
- return switch (mode) {
- .all_passive, .all_follow_selection => file.spriteCount(),
- .animation_passive, .animation_follow_selection => blk: {
- const ai = file.selected_animation_index orelse return file.spriteCount();
- break :blk file.animations.get(ai).frames.len;
- },
- .selection_only => file.editor.selected_sprites.count(),
- };
-}
-
-fn nthSelectedSprite(file: anytype, n: usize) usize {
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- var i: usize = 0;
- while (iter.next()) |si| {
- if (i == n) return si;
- i += 1;
- }
- return 0;
-}
-
-fn selectedSpriteVirtual(file: anytype, sprite_index: usize) ?usize {
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- var i: usize = 0;
- while (iter.next()) |si| {
- if (si == sprite_index) return i;
- i += 1;
- }
- return null;
-}
-
-fn virtualToSpriteIndex(file: anytype, mode: ScrollMode, virtual: usize) usize {
- return switch (mode) {
- .all_passive, .all_follow_selection => virtual,
- .animation_passive, .animation_follow_selection => {
- const ai = file.selected_animation_index orelse return virtual;
- const frames = file.animations.get(ai).frames;
- if (frames.len == 0) return virtual;
- return frames[@min(virtual, frames.len - 1)].sprite_index;
- },
- .selection_only => nthSelectedSprite(file, virtual),
- };
-}
-
-fn virtualFromSprite(file: anytype, mode: ScrollMode, sprite_index: usize) ?usize {
- return switch (mode) {
- .all_passive, .all_follow_selection => sprite_index,
- .animation_passive, .animation_follow_selection => {
- const ai = file.selected_animation_index orelse return sprite_index;
- const frames = file.animations.get(ai).frames;
- for (frames, 0..) |f, i| {
- if (f.sprite_index == sprite_index) return i;
- }
- return null;
- },
- .selection_only => selectedSpriteVirtual(file, sprite_index),
- };
-}
-
-/// Virtual center index the cover flow eases toward when the user isn't driving it.
-fn currentVirtualTarget(file: anytype, mode: ScrollMode, count: usize) usize {
- if (count == 0) return 0;
-
- if (file.editor.playing and (mode == .animation_passive or mode == .animation_follow_selection)) {
- return @min(file.selected_animation_frame_index, count - 1);
- }
-
- if (file.editor.canvas.hovered and drawingToolActive()) {
- if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt_prev))) |sprite_index| {
- if (virtualFromSprite(file, mode, sprite_index)) |v| return @min(v, count - 1);
- }
- }
-
- return switch (mode) {
- .all_passive, .all_follow_selection => blk: {
- if (file.editor.selected_sprites.count() > 0) {
- if (file.editor.selected_sprites.findLastSet()) |last| break :blk @min(last, count - 1);
- }
- break :blk 0;
- },
- .animation_passive, .animation_follow_selection => @min(file.selected_animation_frame_index, count - 1),
- .selection_only => blk: {
- if (file.primarySpriteIndex()) |primary| {
- if (selectedSpriteVirtual(file, primary)) |v| break :blk @min(v, count - 1);
- }
- break :blk 0;
- },
- };
-}
-
-/// Wrap an unbounded slot index into a real sprite index in [0, count).
-fn wrapIndex(slot: i64, count: usize) usize {
- return @intCast(@mod(slot, @as(i64, @intCast(count))));
-}
-
-/// Advance the cover flow by one whole item and snap `scroll_pos` to match (flown-out mode).
-fn stepScrollGoal(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, step: f32) void {
- const next_slot: i64 = @as(i64, @intFromFloat(@round(self.goal))) + @as(i64, @intFromFloat(step));
- const v = wrapIndex(next_slot, count);
- self.goal = @floatFromInt(v);
- self.scroll_pos = self.goal;
- self.fling.cancel();
- if (mode != .all_passive) {
- self.commitVirtualCenter(file, mode, v);
- }
-}
-
-/// The representative of sprite `target` nearest to `from` in the infinite wrapped
-/// index space, so easing crosses the seam the short way round.
-fn nearestWrapped(from: f32, target: usize, count: usize) f32 {
- const c: f32 = @floatFromInt(count);
- const base: f32 = @floatFromInt(target);
- return base + @round((from - base) / c) * c;
-}
-
-/// Sync editor state to the sprite/frame under the cover-flow center, if it changed.
-fn commitCenteredIfNeeded(self: *Sprites, file: anytype, mode: ScrollMode, count: usize) void {
- if (mode == .all_passive or count == 0) return;
- const centered = wrapIndex(@intFromFloat(@round(self.scroll_pos)), count);
- if (centered == self.last_committed_virtual) return;
- self.commitVirtualCenter(file, mode, centered);
-}
-
-/// Apply the centered virtual index to editor state. Records the virtual index so
-/// external-selection sync doesn't treat our own change as a new target to chase.
-fn commitVirtualCenter(self: *Sprites, file: anytype, mode: ScrollMode, virtual: usize) void {
- switch (mode) {
- .all_passive => return,
- .all_follow_selection => {
- const si = virtualToSpriteIndex(file, mode, virtual);
- if (file.editor.selected_sprites.count() != 1 or
- si >= file.editor.selected_sprites.capacity() or
- !file.editor.selected_sprites.isSet(si))
- {
- file.clearSelectedSprites();
- if (si < file.editor.selected_sprites.capacity()) {
- file.editor.selected_sprites.set(si);
- }
- }
- file.editor.primary_sprite_index = si;
- },
- .selection_only => {
- const si = virtualToSpriteIndex(file, mode, virtual);
- file.promotePrimarySprite(si);
- },
- .animation_passive => {
- if (file.selected_animation_frame_index != virtual) {
- file.selected_animation_frame_index = virtual;
- }
- },
- .animation_follow_selection => {
- const si = virtualToSpriteIndex(file, mode, virtual);
- if (file.selected_animation_frame_index != virtual or
- file.editor.selected_sprites.count() != 1 or
- si >= file.editor.selected_sprites.capacity() or
- !file.editor.selected_sprites.isSet(si))
- {
- file.selected_animation_frame_index = virtual;
- file.clearSelectedSprites();
- if (si < file.editor.selected_sprites.capacity()) {
- file.editor.selected_sprites.set(si);
- }
- }
- file.promotePrimarySprite(si);
- },
- }
- self.last_committed_virtual = virtual;
- self.last_sel_virtual = virtual;
-}
-
-/// True when pointer events at `p` belong to the main workspace, not a floating
-/// dialog/tooltip drawn above it (e.g. Grid Layout over this pane).
-fn pointerTargetsMainPane(p: dvui.Point.Physical) bool {
- const cw = dvui.currentWindow();
- const main_id = cw.data().id;
- const target = cw.subwindows.windowFor(p);
- if (target != .zero and target != main_id) return false;
- for (cw.subwindows.stack.items[1..]) |sub| {
- if (sub.modal) return false;
- }
- return true;
-}
-
-/// Wheel scrolls one step at a time; horizontal drag scrubs the flow freely and
-/// snaps to the nearest item on release. When `snap_scroll` (cards flown out),
-/// every step jumps straight to the next centered sprite with no in-between pan.
-fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px_per_index: f32, snap_scroll: bool) void {
- const pane = dvui.parentGet().data();
- const rs = pane.rectScale();
- const id = pane.id;
-
- self.drag_active = false;
-
- // Total drag distance (index units) accumulated across this frame's motion
- // events, plus whether a drag was released this frame — both finalized after
- // the loop so velocity is computed once per frame (frameTimeNS is per-frame).
- var frame_dx: f32 = 0.0;
- var released_moved = false;
-
- // Dialogs/subwindows stack above the sprites pane in z-order but share the same
- // screen rect — don't capture clicks meant for their footer or chrome.
- if (fizzy.dvui.canvasPointerInputSuppressed()) {
- if (dvui.captured(id)) {
- for (dvui.events()) |*e| {
- if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
- }
- }
- }
- return;
- }
-
- for (dvui.events()) |*e| {
- if (e.handled) continue;
- if (e.evt != .mouse) continue;
- const me = e.evt.mouse;
- if (!pointerTargetsMainPane(me.p)) continue;
- const inside = rs.r.contains(me.p);
- if (!inside and !dvui.captured(id)) continue;
-
- switch (me.action) {
- .press => {
- if (me.button.pointer()) {
- e.handle(@src(), pane);
- dvui.captureMouse(pane, e.num);
- dvui.dragPreStart(me.p, .{ .name = "coverflow_drag", .cursor = .hand });
- self.moved_since_press = false;
- self.drag_was_touch = me.button.touch();
- self.wheel_accum = 0.0;
- // Grabbing again cancels any in-flight coast and its velocity.
- self.fling.begin();
- }
- },
- .release => {
- if (me.button.pointer() and dvui.captured(id)) {
- e.handle(@src(), pane);
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
- if (self.moved_since_press) released_moved = true;
- self.moved_since_press = false;
- }
- },
- .motion => {
- if (!dvui.captured(id)) continue;
- // Touch moves use the event delta directly — waiting for the mouse drag
- // threshold drops most of the last samples before `touchend`.
- const dps: dvui.Point.Physical = if (me.button.touch())
- me.action.motion
- else if (dvui.dragging(me.p, "coverflow_drag")) |d|
- d
- else
- continue;
- self.drag_active = true;
- self.moved_since_press = true;
- if (px_per_index > 0.0) {
- const di = -dps.x / rs.s / px_per_index;
- if (snap_scroll) {
- self.wheel_accum += di;
- while (@abs(self.wheel_accum) >= 1.0) {
- const step: f32 = if (self.wheel_accum > 0.0) 1.0 else -1.0;
- self.wheel_accum -= step;
- stepScrollGoal(self, file, mode, count, step);
- }
- } else {
- self.scroll_pos += di;
- self.goal = self.scroll_pos;
- frame_dx += di;
- }
- }
- dvui.refresh(null, @src(), id);
- },
- .wheel_x, .wheel_y => {
- if (inside) {
- e.handle(@src(), pane);
- const amt = if (me.action == .wheel_x) me.action.wheel_x else me.action.wheel_y;
- // A discrete mouse wheel advances one sprite per notch; a trackpad
- // accumulates its stream of small deltas smoothly. We can't key off the
- // raw magnitude: a single wheel notch is ~1.0
- if (dvui.mouseType() == .mouse) {
- self.wheel_accum += std.math.sign(amt);
- } else {
- self.wheel_accum += amt * 0.01;
- }
- while (@abs(self.wheel_accum) >= 1.0) {
- const step: f32 = if (self.wheel_accum > 0.0) 1.0 else -1.0;
- self.wheel_accum -= step;
- if (snap_scroll) {
- stepScrollGoal(self, file, mode, count, step);
- } else {
- const ng = @round(self.goal) + step;
- self.goal = ng;
- if (mode != .all_passive) {
- const v = wrapIndex(@intFromFloat(ng), count);
- self.commitVirtualCenter(file, mode, v);
- // scroll_pos may still be easing toward ng; don't let a
- // passive-ease commit revert this until we arrive.
- self.last_committed_virtual = v;
- }
- }
- }
- dvui.refresh(null, @src(), id);
- }
- },
- else => {},
- }
- }
-
- if (!snap_scroll) {
- // Touch and mouse/trackpad share one path: record each moved frame into the
- // position/time history and, on release, coast from a velocity averaged over a
- // wall-clock window. That window is refresh-independent, so momentum is reliable
- // at 60 Hz and 120 Hz alike — unlike the old per-frame EMA, which underread short
- // flicks at lower refresh rates. Only the feel tuning differs per input type.
- if (self.drag_active) self.fling.sampleTimed(frame_dx);
- if (released_moved) {
- // The last move and the release commonly land on the same frame (more so at
- // low refresh), which leaves `drag_active` set. Clear it after sampling that
- // final move so draw()'s `drag_active` branch doesn't cancel the coast we
- // start here — that race was eating momentum on a large share of flicks.
- self.drag_active = false;
- const tuning = if (self.drag_was_touch) sprite_fling_touch else sprite_fling;
- const window_s = if (self.drag_was_touch) sprite_fling_touch_window_s else sprite_fling_window_s;
- if (!self.fling.releaseWindowed(tuning, window_s)) {
- const snapped: i64 = @intFromFloat(@round(self.scroll_pos));
- self.goal = @floatFromInt(snapped);
- dvui.refresh(null, @src(), id);
- }
- }
- } else if (released_moved) {
- const v = wrapIndex(@intFromFloat(@round(self.goal)), count);
- self.goal = @floatFromInt(v);
- self.scroll_pos = self.goal;
- self.fling.cancel();
- if (mode != .all_passive) {
- self.commitVirtualCenter(file, mode, v);
- }
- }
-}
-
-pub fn drawAnimationControlsDialog(_: *Sprites) void {
- if (fizzy.editor.activeFile()) |file| {
- const rect = dvui.parentGet().data().rectScale().r;
-
- if (dvui.parentGet().data().rect.h < 48.0) {
- return;
- }
-
- // Round controls floating in the top-left corner. Mirrors the workspace
- // hamburger / sample buttons: content-fill circles with a soft drop
- // shadow and a centered icon.
- const button_size: f32 = 32;
- const gap: f32 = 6;
- const base_x = rect.toNatural().x + 10;
- const base_y = rect.toNatural().y + 10;
-
- // Play / pause. Always present; "disabled" (muted, no action) when no
- // animation is selected.
- const play_enabled = file.selected_animation_index != null;
- if (drawRoundButton(
- @src(),
- base_x,
- base_y,
- button_size,
- "Play",
- if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play,
- play_enabled,
- file.editor.playing,
- ) and play_enabled) {
- file.editor.playing = !file.editor.playing;
- }
-
- // Fly-out preview. Toggles the side cards out / in without advancing
- // playback — a static look at the focused-card layout. Highlighted while
- // active; inert while playback or drawing tools already flew them.
- const playing = file.editor.playing;
- const flown = sideCardsFlown(playing);
- const fly_forced = playing or drawingToolActive();
- if (drawRoundButton(
- @src(),
- base_x + button_size + gap,
- base_y,
- button_size,
- "Toggle card focus",
- if (flown) icons.tvg.entypo.doc else icons.tvg.entypo.docs,
- !fly_forced,
- flown,
- ) and !fly_forced) {
- fizzy.editor.settings.scrolling_cards = !fizzy.editor.settings.scrolling_cards;
- fizzy.editor.markSettingsDirty();
- dvui.refresh(null, @src(), dvui.parentGet().data().id);
- }
- }
-}
-
-/// One round, floating action button matching the workspace hamburger / sample
-/// buttons. Returns true on click. `enabled` mutes the icon (the caller also
-/// gates the action on it); `active` tints the fill to show a toggled-on state.
-/// Each call site supplies its own `@src()` for a stable, distinct id.
-fn drawRoundButton(
- src: std.builtin.SourceLocation,
- x: f32,
- y: f32,
- size: f32,
- name: []const u8,
- icon_tvg: []const u8,
- enabled: bool,
- active: bool,
-) bool {
- const btn_radius: f32 = size / 2;
- const icon_padding: f32 = size * 0.33;
-
- var fw: dvui.FloatingWidget = undefined;
- fw.init(src, .{}, .{
- .rect = .{ .x = x, .y = y, .w = size, .h = size },
- .expand = .none,
- .background = false,
- });
- defer fw.deinit();
-
- const fill = if (active)
- dvui.themeGet().color(.highlight, .fill)
- else
- dvui.themeGet().color(.content, .fill);
-
- var btn: dvui.ButtonWidget = undefined;
- btn.init(src, .{}, .{
- .expand = .both,
- .min_size_content = .{ .w = size, .h = size },
- .background = true,
- .corner_radius = dvui.Rect.all(btn_radius),
- .color_fill = fill,
- .color_fill_hover = fill.lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
- .color_border = .transparent,
- // Inset lives on the button (not the icon): a uniform pad on the icon
- // would force its content rect square and skew non-square glyphs like
- // the entypo play/pause. Padding here keeps the icon's own rect free to
- // take the glyph's native aspect under `expand = .ratio`.
- .padding = dvui.Rect.all(icon_padding),
- .margin = .{},
- .box_shadow = .{
- .color = .black,
- .alpha = 0.2,
- .fade = 4,
- .offset = .{ .x = 0, .y = 2 },
- .corner_radius = dvui.Rect.all(btn_radius),
- },
- });
- defer btn.deinit();
- btn.processEvents();
- btn.drawBackground();
-
- const text_color = if (active)
- dvui.themeGet().color(.highlight, .text)
- else
- dvui.themeGet().color(.content, .text);
- const icon_color = if (enabled) text_color else text_color.opacity(0.35);
-
- // `min_size_content.h` must be a real height: IconWidget derives width as
- // `iconWidth(h)` but clamps it up to at least `min_size_content.w`. With a
- // height of 1 a glyph taller than wide derives width < 1, gets clamped to a
- // square min size, and `expand = .ratio` then stretches it. A full-size
- // height keeps the derived width true to the glyph's aspect.
- dvui.icon(
- src,
- name,
- icon_tvg,
- .{ .stroke_color = icon_color, .fill_color = icon_color },
- .{
- .expand = .ratio,
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .min_size_content = .{ .w = 1.0, .h = size },
- },
- );
-
- return btn.clicked();
-}
diff --git a/src/editor/readme.zig b/src/editor/readme.zig
new file mode 100644
index 00000000..032eb3f7
--- /dev/null
+++ b/src/editor/readme.zig
@@ -0,0 +1,214 @@
+//! Plugin README rendering for the store.
+//!
+//! Fetches a plugin's `README.md` from its repository over HTTPS on a worker thread, then
+//! renders it read-only via the in-tree markdown render library (`src/markdown`). There is no
+//! document/plugin detour — the store calls `select()` when a plugin is chosen and `draw()`
+//! each frame to paint the current selection's README.
+//!
+//! Native-only: the markdown engine links cmark (needs libc) and the store itself never runs on
+//! wasm, so this whole module is gated out of the web build at the import site in the store.
+const std = @import("std");
+const dvui = @import("dvui");
+const fizzy = @import("../fizzy.zig");
+const markdown = @import("markdown");
+
+const Status = enum(u8) { idle, fetching, ready, not_found, failed };
+
+/// One in-flight / rendered README. Only one plugin is selected at a time, so the module keeps a
+/// single `current`.
+const Readme = struct {
+ id: []u8,
+ repo: []u8,
+ io: std.Io,
+ status: std.atomic.Value(u8) = .init(@intFromEnum(Status.idle)),
+ /// Fetched README bytes (app-allocator owned). Written once by the worker before it flips
+ /// `status` to `ready` with release ordering; read on the UI thread only after an acquire
+ /// load sees `ready`, so no lock is needed for the bytes themselves.
+ bytes: ?[]u8 = null,
+ thread: ?std.Thread = null,
+ preview: markdown.Preview = .{},
+
+ fn statusValue(self: *Readme) Status {
+ return @enumFromInt(self.status.load(.acquire));
+ }
+};
+
+var current: ?Readme = null;
+
+fn gpa() std.mem.Allocator {
+ return fizzy.app.allocator;
+}
+
+/// Select `id` (from its `repo` URL) as the README to show. No-op if already selected. Spawns the
+/// fetch worker on first selection of an id.
+pub fn select(id: []const u8, repo: []const u8) void {
+ if (current) |*c| {
+ if (std.mem.eql(u8, c.id, id)) return;
+ clearCurrent();
+ }
+
+ const id_owned = gpa().dupe(u8, id) catch return;
+ const repo_owned = gpa().dupe(u8, repo) catch {
+ gpa().free(id_owned);
+ return;
+ };
+
+ current = .{ .id = id_owned, .repo = repo_owned, .io = dvui.io };
+ const self = ¤t.?;
+ self.status.store(@intFromEnum(Status.fetching), .release);
+ self.thread = std.Thread.spawn(.{}, worker, .{self}) catch {
+ self.status.store(@intFromEnum(Status.failed), .release);
+ return;
+ };
+}
+
+/// The id currently selected (or null). Lets the store highlight the active card.
+pub fn selectedId() ?[]const u8 {
+ return if (current) |*c| c.id else null;
+}
+
+pub fn deinit() void {
+ clearCurrent();
+}
+
+/// Drop the current selection (e.g. the store "back" button).
+pub fn clear() void {
+ clearCurrent();
+}
+
+fn clearCurrent() void {
+ if (current) |*c| {
+ if (c.thread) |t| {
+ t.join();
+ c.thread = null;
+ }
+ c.preview.deinit();
+ if (c.bytes) |b| gpa().free(b);
+ gpa().free(c.id);
+ gpa().free(c.repo);
+ }
+ current = null;
+}
+
+/// Render the current selection's README into the current dvui parent. Shows placeholder text
+/// while fetching / on failure. Safe to call every frame.
+pub fn draw() void {
+ const c = if (current) |*cur| cur else {
+ dvui.labelNoFmt(@src(), "Select a plugin to read its README.", .{}, .{
+ .expand = .both,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .color_text = dvui.themeGet().color(.window, .text).opacity(0.7),
+ });
+ return;
+ };
+
+ switch (c.statusValue()) {
+ .idle, .fetching => dvui.labelNoFmt(@src(), "Loading README…", .{}, .{
+ .expand = .both,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .color_text = dvui.themeGet().color(.window, .text).opacity(0.7),
+ }),
+ .not_found => dvui.labelNoFmt(@src(), "No README found for this plugin.", .{}, .{
+ .expand = .both,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .color_text = dvui.themeGet().color(.window, .text).opacity(0.7),
+ }),
+ .failed => dvui.labelNoFmt(@src(), "Could not fetch the README.", .{}, .{
+ .expand = .both,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .color_text = dvui.themeGet().color(.err, .text).opacity(0.85),
+ }),
+ .ready => {
+ const bytes = c.bytes orelse return;
+ markdown.drawPreview(&c.preview, bytes, gpa(), .{ .io = c.io });
+ },
+ }
+}
+
+// ---- worker -----------------------------------------------------------------
+
+fn worker(self: *Readme) void {
+ const candidates = rawReadmeUrls(self.repo) orelse {
+ self.status.store(@intFromEnum(Status.not_found), .release);
+ return;
+ };
+
+ var found: ?[]u8 = null;
+ for (candidates.slice()) |url| {
+ if (fetchOk(self.io, url)) |body| {
+ found = body;
+ break;
+ }
+ }
+
+ if (found) |body| {
+ self.bytes = body;
+ self.status.store(@intFromEnum(Status.ready), .release);
+ } else {
+ self.status.store(@intFromEnum(Status.not_found), .release);
+ }
+}
+
+/// GET `url`; return the body bytes (app-allocator owned) on HTTP 200, else null.
+fn fetchOk(io: std.Io, url: []const u8) ?[]u8 {
+ var client: std.http.Client = .{ .allocator = gpa(), .io = io };
+ defer client.deinit();
+
+ var body: std.Io.Writer.Allocating = .init(gpa());
+ defer body.deinit();
+
+ const result = client.fetch(.{
+ .location = .{ .url = url },
+ .response_writer = &body.writer,
+ }) catch return null;
+ if (result.status != .ok) return null;
+
+ return gpa().dupe(u8, body.written()) catch null;
+}
+
+const UrlList = struct {
+ buf: [3][]const u8 = undefined,
+ len: usize = 0,
+ fn slice(self: *const UrlList) []const []const u8 {
+ return self.buf[0..self.len];
+ }
+};
+
+var url_storage: [3][256]u8 = undefined;
+
+/// Derive candidate raw README URLs from a GitHub repository link. Returns null for hosts we
+/// can't map. Not thread-safe across selections, but only one worker runs at a time.
+fn rawReadmeUrls(repo: []const u8) ?UrlList {
+ var r = repo;
+ // Strip scheme.
+ inline for (.{ "https://", "http://" }) |p| {
+ if (std.mem.startsWith(u8, r, p)) r = r[p.len..];
+ }
+ if (!std.mem.startsWith(u8, r, "github.com/")) return null;
+ r = r["github.com/".len..];
+ r = std.mem.trimEnd(u8, r, "/");
+ if (std.mem.endsWith(u8, r, ".git")) r = r[0 .. r.len - 4];
+
+ // owner/repo = first two path segments.
+ var it = std.mem.splitScalar(u8, r, '/');
+ const owner = it.next() orelse return null;
+ const name = it.next() orelse return null;
+ if (owner.len == 0 or name.len == 0) return null;
+
+ var list: UrlList = .{};
+ const refs = [_][]const u8{ "HEAD", "main", "master" };
+ for (refs, 0..) |ref, i| {
+ const s = std.fmt.bufPrint(
+ &url_storage[i],
+ "https://raw.githubusercontent.com/{s}/{s}/{s}/README.md",
+ .{ owner, name, ref },
+ ) catch continue;
+ list.buf[list.len] = s;
+ list.len += 1;
+ }
+ return list;
+}
diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig
deleted file mode 100644
index f9a6989c..00000000
--- a/src/editor/widgets/FileWidget.zig
+++ /dev/null
@@ -1,5967 +0,0 @@
-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;
-const Point = dvui.Point;
-
-const BoxWidget = dvui.BoxWidget;
-const ButtonWidget = dvui.ButtonWidget;
-const ScrollAreaWidget = dvui.ScrollAreaWidget;
-const ScrollContainerWidget = dvui.ScrollContainerWidget;
-const ScaleWidget = dvui.ScaleWidget;
-
-pub const FileWidget = @This();
-const CanvasWidget = @import("CanvasWidget.zig");
-const icons = @import("icons");
-
-init_options: InitOptions,
-options: Options,
-drag_data_point: ?dvui.Point = null,
-/// Absolute Δx/Δy from opposite corner → dragged corner at transform vertex press; used for default (no-mod) aspect lock.
-transform_aspect_w: ?f32 = null,
-transform_aspect_h: ?f32 = null,
-sample_data_point: ?dvui.Point = null,
-resize_data_point: ?dvui.Point = null,
-previous_mods: dvui.enums.Mod = .none,
-left_mouse_down: bool = false,
-right_mouse_down: bool = false,
-sample_key_down: bool = false,
-shift_key_down: bool = false,
-hide_distance_bubble: bool = false,
-hovered_bubble_sprite_index: ?usize = null,
-grid_reorder_point: ?dvui.Point = null,
-cell_reorder_point: ?dvui.Point = null,
-cell_reorder_mode: SpriteReorderMode = .replace,
-
-removed_sprite_indices: ?[]usize = null,
-insert_before_sprite_indices: ?[]usize = null,
-
-const SpriteReorderMode = enum {
- replace,
- insert,
-};
-
-pub const InitOptions = struct {
- file: *fizzy.Internal.File,
- center: bool = false,
-};
-
-pub const temp_ms: u32 = 1000; // Default 1 second
-
-pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) FileWidget {
- const fw: FileWidget = .{
- .init_options = init_opts,
- .options = opts,
- .drag_data_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "drag_data_point", dvui.Point)) |point| point else null,
- .transform_aspect_w = dvui.dataGet(null, init_opts.file.editor.canvas.id, "transform_aspect_w", f32),
- .transform_aspect_h = dvui.dataGet(null, init_opts.file.editor.canvas.id, "transform_aspect_h", f32),
- .sample_data_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "sample_data_point", dvui.Point)) |point| point else null,
- .sample_key_down = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "sample_key_down", bool)) |key| key else false,
- .resize_data_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "resize_data_point", dvui.Point)) |point| point else null,
- .grid_reorder_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "grid_reorder_point", dvui.Point)) |point| point else null,
- .cell_reorder_point = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "cell_reorder_point", dvui.Point)) |point| point else null,
- .right_mouse_down = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "right_mouse_down", bool)) |key| key else false,
- .left_mouse_down = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "left_mouse_down", bool)) |key| key else false,
- .hide_distance_bubble = if (dvui.dataGet(null, init_opts.file.editor.canvas.id, "hide_distance_bubble", bool)) |key| key else false,
- .removed_sprite_indices = if (dvui.dataGetSlice(null, init_opts.file.editor.canvas.id, "removed_sprite_indices", []usize)) |slice| slice else null,
- };
-
- init_opts.file.editor.canvas.install(src, .{
- .id = init_opts.file.editor.canvas.id,
- .data_size = .{
- .w = @floatFromInt(init_opts.file.width()),
- .h = @floatFromInt(init_opts.file.height()),
- },
- .center = init_opts.center,
- }, opts);
-
- return fw;
-}
-
-pub fn processSample(self: *FileWidget) void {
- const file = self.init_options.file;
-
- const current_mods = dvui.currentWindow().modifiers;
-
- const mouse_pt = dvui.currentWindow().mouse_pt;
-
- if (current_mods.matchBind("ctrl/cmd") and !self.previous_mods.matchBind("ctrl/cmd") and (self.right_mouse_down or self.sample_key_down)) {
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_pt);
- self.sample(file, current_point, mouse_pt, true, true);
- }
-
- if (current_mods.matchBind("sample") and !self.previous_mods.matchBind("sample")) {
- self.sample_key_down = true;
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_pt);
- self.sample(file, current_point, mouse_pt, self.right_mouse_down or self.left_mouse_down, false);
- } else if (!current_mods.matchBind("sample") and self.sample_key_down) {
- self.sample_key_down = false;
- if (!self.right_mouse_down) {
- self.sample_data_point = null;
- }
- }
-
- const canvas = &self.init_options.file.editor.canvas;
- const scroll_container = canvas.scroll_container;
- if (!canvas.installed) return;
-
- const scroll_id = scroll_container.data().id;
-
- for (dvui.events()) |*e| {
- switch (e.evt) {
- .mouse => |me| {
- const sample_captured = dvui.captured(scroll_id);
- if (!scroll_container.matchEvent(e) and !sample_captured) {
- continue;
- }
- const current_point = canvas.dataFromScreenPoint(me.p);
-
- if (me.action == .press and me.button.pointer()) {
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- self.left_mouse_down = true;
- if (dvui.dragging(me.p, "sample_drag")) |_| {
- self.sample(file, current_point, me.p, true, false);
- }
- } else if (me.action == .release and me.button.pointer()) {
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- self.left_mouse_down = false;
- }
-
- if (me.action == .press and me.button == .right) {
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- self.right_mouse_down = true;
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num);
- dvui.dragPreStart(me.p, .{ .name = "sample_drag" });
- self.drag_data_point = current_point;
-
- self.sample(file, current_point, me.p, self.sample_key_down or self.left_mouse_down, false);
-
- clearTempPreview(&file.editor);
- if (file.editor.temp_layer_has_content) {
- @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 });
- }
- file.editor.temp_layer_has_content = false;
- file.editor.temporary_layer.dirty = false;
- } else if (me.action == .release and me.button == .right) {
- dvui.refresh(null, @src(), scroll_container.data().id);
- self.right_mouse_down = false;
- if (sample_captured) {
- e.handle(@src(), scroll_container.data());
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
- if (canvas.samplePointerInViewport(me.p)) {
- self.sample(file, current_point, me.p, self.sample_key_down or self.left_mouse_down, true);
- }
- if (!self.sample_key_down) {
- self.drag_data_point = null;
- self.sample_data_point = null;
- }
- }
- } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) {
- if (sample_captured and !canvas.samplePointerInViewport(me.p)) {
- self.sample_data_point = null;
- }
- if (dvui.captured(scroll_id)) {
- if (dvui.dragging(me.p, "sample_drag")) |diff| {
- const previous_point = current_point.plus(self.init_options.file.editor.canvas.dataFromScreenPoint(diff));
- // Construct a rect spanning between current_point and previous_point
- const min_x = @min(previous_point.x, current_point.x);
- const min_y = @min(previous_point.y, current_point.y);
- const max_x = @max(previous_point.x, current_point.x);
- const max_y = @max(previous_point.y, current_point.y);
- const span_rect = dvui.Rect{
- .x = min_x,
- .y = min_y,
- .w = max_x - min_x + 5,
- .h = max_y - min_y + 5,
- };
-
- const screen_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect);
-
- dvui.scrollDrag(.{
- .mouse_pt = me.p,
- .screen_rect = screen_rect,
- });
-
- self.sample(file, current_point, me.p, self.sample_key_down or self.left_mouse_down or current_mods.matchBind("ctrl/cmd"), false);
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- }
- } else if (self.right_mouse_down or self.sample_key_down) {
- self.sample(file, current_point, me.p, self.right_mouse_down and (self.sample_key_down or self.left_mouse_down or current_mods.matchBind("ctrl/cmd")), false);
- }
- }
- },
- else => {},
- }
- }
-}
-
-/// Set `file.peek_layer_index` to the visible layer with an opaque pixel at `point`, mirroring
-/// `sampleColorAtPoint`'s selection rule (bottommost match wins). Called every frame while the
-/// sample key is held so other layers dim like during layer-list hover.
-pub fn peekLayerAtPoint(file: *fizzy.Internal.File, point: dvui.Point) void {
- if (file.editor.isolate_layer) return;
-
- var layer_index: usize = file.layers.len;
- while (layer_index > 0) {
- layer_index -= 1;
- var layer = file.layers.get(layer_index);
- if (!layer.visible) continue;
- if (layer.pixelIndex(point)) |index| {
- const c = layer.pixels()[index];
- if (c[3] > 0) {
- file.peek_layer_index = layer_index;
- }
- }
- }
-}
-
-/// Walk visible layers for an opaque pixel at `point`. Optionally selects the hit layer,
-/// sets the primary color (`apply_primary`), and/or adjusts the active tool (`change_tool`).
-pub fn sampleColorAtPoint(
- file: *fizzy.Internal.File,
- point: dvui.Point,
- change_layer: bool,
- apply_primary: bool,
- change_tool: bool,
-) void {
- var color: [4]u8 = .{ 0, 0, 0, 0 };
-
- var min_layer_index: usize = 0;
- if (file.editor.isolate_layer) {
- if (file.peek_layer_index) |peek_layer_index| {
- min_layer_index = peek_layer_index;
- } else if (!fizzy.editor.explorer.tools.layersHovered()) {
- min_layer_index = file.selected_layer_index;
- }
- }
-
- var layer_index: usize = file.layers.len;
- while (layer_index > min_layer_index) {
- layer_index -= 1;
- var layer = file.layers.get(layer_index);
- if (!layer.visible) continue;
- if (layer.pixelIndex(point)) |index| {
- const c = layer.pixels()[index];
- if (c[3] > 0) {
- color = c;
- if (change_layer and !file.editor.isolate_layer) {
- file.selected_layer_index = layer_index;
- file.peek_layer_index = layer_index;
- // Sample acts as a focused layer-pick: narrow multi-selection to just this layer
- // so the ctrl modifier (also the layer-list multi-select toggle) doesn't accumulate.
- file.editor.selected_layer_indices.clearRetainingCapacity();
- file.editor.selected_layer_indices.append(fizzy.app.allocator, layer_index) catch {};
- file.editor.layer_selection_anchor = layer_index;
- }
- }
- }
- }
-
- if (change_tool) {
- const off_canvas = point.x < 0 or point.y < 0 or
- point.x >= @as(f32, @floatFromInt(file.width())) or
- point.y >= @as(f32, @floatFromInt(file.height()));
- if (off_canvas) {
- // Sampling the empty margin outside the artboard isn't an erase — drop back
- // to the pointer tool so the click reads as "leave drawing mode".
- if (fizzy.editor.tools.current != .pointer) {
- fizzy.editor.tools.set(.pointer);
- }
- } else if (color[3] == 0) {
- if (fizzy.editor.tools.current != .eraser) {
- fizzy.editor.tools.set(.eraser);
- }
- } else {
- fizzy.editor.colors.primary = color;
- if (switch (fizzy.editor.tools.current) {
- .pencil, .bucket => false,
- else => true,
- })
- fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool);
- }
- } else if (apply_primary and color[3] > 0) {
- fizzy.editor.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 {
- if (!file.editor.canvas.samplePointerInViewport(screen_p)) {
- self.sample_data_point = null;
- return;
- }
- self.sample_data_point = point;
- sampleColorAtPoint(file, point, change_layer, change_tool, change_tool);
-}
-
-/// Responsible for changing the currently selected animation index, the animation frame index, and the animations scroll to index
-/// when the user clicks on a sprite that is part of an animation.
-///
-/// This is not restricted to any pane or tool, and will change on hover for any tool except the pointer tool.
-pub fn processAnimationSelection(self: *FileWidget) void {
- const file = self.init_options.file;
- for (dvui.events()) |*e| {
- if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- switch (e.evt) {
- .mouse => |me| {
- if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (fizzy.editor.tools.current != .pointer and self.sample_data_point == null)) {
- if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| {
- var found: bool = false;
- for (file.animations.items(.frames), 0..) |frames, anim_index| {
- for (frames, 0..) |frame, frame_index| {
- if (frame.sprite_index == sprite_index) {
- file.selected_animation_index = anim_index;
- file.editor.animations_scroll_to_index = anim_index;
-
- if (!file.editor.playing)
- file.selected_animation_frame_index = frame_index;
-
- file.collapseAnimationSelectionToPrimary();
- found = true;
- break;
- }
- if (found) break;
- }
- if (found) break;
- }
- }
- }
- },
- else => {},
- }
- }
-}
-
-pub fn processCellReorder(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .pointer) return;
- if (self.init_options.file.editor.transform != null) return;
- if (self.sample_data_point != null) return;
- if (self.drag_data_point != null) return;
- if (dvui.currentWindow().modifiers.matchBind("shift")) return;
-
- const file = self.init_options.file;
-
- for (dvui.events()) |*e| {
- if (!file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- switch (e.evt) {
- .mouse => |me| {
- const current_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
-
- var selected_sprite_move_hovered: bool = false;
-
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |sprite_index| {
- const sprite_rect = file.spriteRect(sprite_index);
- if (sprite_rect.contains(current_point)) {
- selected_sprite_move_hovered = true;
- break;
- }
- }
-
- if (selected_sprite_move_hovered) {
- dvui.cursorSet(.hand);
- }
-
- if (me.action == .press and me.button.pointer()) {
- if (file.editor.selected_sprites.count() > 0) {
- if (selected_sprite_move_hovered) {
- e.handle(@src(), file.editor.canvas.scroll_container.data());
- dvui.captureMouse(file.editor.canvas.scroll_container.data(), e.num);
-
- const index = file.spriteIndex(current_point);
- var offset: dvui.Point = .{};
- if (index) |i| {
- offset = file.spriteRect(i).topLeft().diff(current_point);
- }
- dvui.dragPreStart(me.p, .{ .name = "sprite_reorder_drag", .offset = file.editor.canvas.screenFromDataPoint(offset) });
-
- self.cell_reorder_point = current_point;
- }
- }
- } else if (me.action == .release and me.button.pointer()) {
- if (dvui.captured(file.editor.canvas.scroll_container.data().id) and dvui.dragging(me.p, "sprite_reorder_drag") != null) {
- e.handle(@src(), file.editor.canvas.scroll_container.data());
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
- dvui.refresh(null, @src(), file.editor.canvas.scroll_container.data().id);
- }
-
- if (self.cell_reorder_point) |cell_reorder_point| {
- defer self.cell_reorder_point = null;
- const drag_index = file.spriteIndex(cell_reorder_point.plus(file.editor.canvas.dataFromScreenPoint(dvui.dragOffset())));
- if (drag_index) |di| {
- if (di != file.spriteIndex(current_point)) {
- // Drag has moved to a new cell, so we have shifted some sprites around
- // and we have released, so we need to allocate a new array of insert_before_sprite_indices
-
- if (self.removed_sprite_indices) |removed_sprite_indices| {
- if (self.insert_before_sprite_indices) |insert_before_sprite_indices| {
- fizzy.app.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 {
- dvui.log.err("Failed to allocate insert before sprite indices", .{});
- return;
- };
- for (removed_sprite_indices, 0..) |removed_sprite_index, i| {
- const removed_sprite_rect = file.spriteRect(removed_sprite_index);
- const difference = current_point.diff(cell_reorder_point);
-
- if (file.spriteIndex(removed_sprite_rect.center().plus(difference))) |index| {
- insert_before_sprite_indices[i] = index;
- } else {
- insert_before_sprite_indices[i] = file.wrappedSpriteIndex(removed_sprite_rect.center().plus(difference));
- }
- }
-
- self.insert_before_sprite_indices = insert_before_sprite_indices;
-
- // This is where we will call reorder
- file.reorderCells(removed_sprite_indices, insert_before_sprite_indices, .replace, false) catch {
- dvui.log.err("Failed to reorder sprites", .{});
- return;
- };
-
- file.history.append(.{
- .reorder_cell = .{
- .removed_sprite_indices = fizzy.app.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 {
- dvui.log.err("Failed to duplicate insert before sprite indices", .{});
- return;
- },
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- return;
- };
- }
- }
- }
- }
-
- if (self.removed_sprite_indices) |_| {
- self.removed_sprite_indices = null;
- dvui.dataRemove(null, file.editor.canvas.id, "removed_sprite_indices");
- }
- } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) {
- if (dvui.captured(file.editor.canvas.scroll_container.data().id)) {
- if (dvui.dragging(me.p, "sprite_reorder_drag")) |_| {
- dvui.cursorSet(.hand);
- defer e.handle(@src(), file.editor.canvas.scroll_container.data());
- if (self.removed_sprite_indices == null and file.editor.selected_sprites.count() > 0) {
- var removed_sprite_indices = fizzy.app.allocator.alloc(usize, file.editor.selected_sprites.count()) catch {
- dvui.log.err("Failed to allocate removed sprite indices", .{});
- return;
- };
- var i: usize = 0;
- var sprite_iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (sprite_iter.next()) |sprite_index| {
- removed_sprite_indices[i] = sprite_index;
- i += 1;
- }
- self.removed_sprite_indices = removed_sprite_indices;
- dvui.dataSetSlice(null, file.editor.canvas.id, "removed_sprite_indices", removed_sprite_indices);
- }
- }
- }
- }
- },
- else => {},
- }
- }
-}
-
-/// Responsible for handling rough/broad sprite selection (grid tiles)
-/// Sprites can only be selected with the pointer tool.
-///
-/// Supports add/remove, drag selection, etc.
-pub fn processSpriteSelection(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .pointer) return;
- if (self.init_options.file.editor.transform != null) return;
- if (self.sample_data_point != null) return;
-
- const file = self.init_options.file;
-
- // A second finger landing on a pending single-finger press promotes the touch to a
- // pan/pinch gesture (see CanvasWidget.updateTouchGesture). The first press already
- // ran through this handler and seeded `drag_data_point` / a `sprite_selection_drag`,
- // which would otherwise render the marquee box while the user pans. The gesture
- // takeover already reassigned mouse capture to the scaler, so just clear our own
- // drag state and end the named drag.
- if (file.editor.canvas.gesture_active) {
- if (self.drag_data_point != null) {
- self.drag_data_point = null;
- if (dvui.dragName("sprite_selection_drag")) {
- dvui.dragEnd();
- }
- dvui.refresh(null, @src(), file.editor.canvas.scroll_container.data().id);
- }
- return;
- }
-
- for (dvui.events()) |*e| {
- if (!file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- switch (e.evt) {
- .key => |ke| {
- if (ke.mod.matchBind("shift")) {
- switch (ke.action) {
- .down, .repeat => {
- self.shift_key_down = true;
- },
- .up => {
- self.shift_key_down = false;
- },
- }
- }
- },
- .mouse => |me| {
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p);
-
- if (me.action == .press and me.button.pointer()) {
- // A press off the artboard with no selection modifier belongs to
- // the canvas pan (handled later in canvas.processEvents) — yield it
- // so dragging empty space pans instead of starting a marquee. Holding
- // ctrl/cmd (add) or shift (subtract) keeps the selection meaning even
- // out in the margins, so those still fall through to the logic below.
- const sel_mod = me.mod.matchBind("ctrl/cmd") or me.mod.matchBind("shift");
- if (!sel_mod and !file.editor.canvas.pointerOverDrawable(me.p)) {
- continue;
- }
-
- if (me.mod.matchBind("shift")) {
- self.shift_key_down = true;
- if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| {
- file.editor.selected_sprites.unset(sprite_index);
- }
- } else if (me.mod.matchBind("ctrl/cmd")) {
- if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| {
- file.editor.selected_sprites.set(sprite_index);
- file.editor.primary_sprite_index = sprite_index;
- }
- } else {
- if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| {
- const selected = file.editor.selected_sprites.isSet(sprite_index);
- file.clearSelectedSprites();
-
- if (!selected) {
- file.editor.selected_sprites.set(sprite_index);
- file.editor.primary_sprite_index = sprite_index;
- }
- } else if (!file.editor.canvas.hovered) {
- fizzy.editor.cancel() catch {
- dvui.log.err("Failed to cancel", .{});
- };
- }
- }
-
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num);
- dvui.dragPreStart(me.p, .{ .name = "sprite_selection_drag" });
-
- self.drag_data_point = current_point;
- } else if (me.action == .release and me.button.pointer()) {
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id) and dvui.dragging(me.p, "sprite_selection_drag") != null) {
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- }
- self.drag_data_point = null;
- } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) {
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- if (dvui.dragging(me.p, "sprite_selection_drag")) |_| {
- if (self.drag_data_point) |previous_point| {
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- const min_x = @min(previous_point.x, current_point.x);
- const min_y = @min(previous_point.y, current_point.y);
- const max_x = @max(previous_point.x, current_point.x);
- const max_y = @max(previous_point.y, current_point.y);
- const span_rect = dvui.Rect{
- .x = min_x,
- .y = min_y,
- .w = @max(max_x - min_x, 1),
- .h = @max(max_y - min_y, 1),
- };
-
- const screen_selection_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect);
-
- dvui.scrollDrag(.{
- .mouse_pt = me.p,
- .screen_rect = screen_selection_rect,
- });
-
- if (me.mod.matchBind("shift")) {
- file.setSpriteSelection(span_rect, false);
- self.shift_key_down = true;
- //selection_color = dvui.themeGet().color(.err, .fill).opacity(0.5);
- } else if (me.mod.matchBind("ctrl/cmd")) {
- file.setSpriteSelection(span_rect, true);
- self.shift_key_down = false;
- } else {
- self.shift_key_down = false;
- file.clearSelectedSprites();
- file.setSpriteSelection(span_rect, true);
- }
- }
- }
- }
- }
- },
- else => {},
- }
- }
-}
-
-/// Cached once per `drawSpriteBubbles` / grid batch — avoids per-sprite `matchBind`, `count`, and `animationGet`.
-const BubblePanShared = struct {
- bubble_open: ?dvui.Animation,
- bubble_close: ?dvui.Animation,
- peek: bool,
- selection_nonempty: bool,
- tool_not_pointer: bool,
-};
-
-/// 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.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 mod_shift = cw.modifiers.matchBind("shift");
- const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd");
- const sample_active = self.sample_data_point != null;
- const drag_sprite_selection = dvui.dragName("sprite_selection_drag");
-
- return .{
- .bubble_open = dvui.animationGet(animation_id, "bubble_open"),
- .bubble_close = dvui.animationGet(animation_id, "bubble_close"),
- .peek = drag_sprite_selection or mod_shift or mod_ctrl_cmd or tool_not_pointer or sample_active,
- .selection_nonempty = self.init_options.file.editor.selected_sprites.count() > 0,
- .tool_not_pointer = tool_not_pointer,
- };
-}
-
-/// Returns whether `drawSpriteBubbles` will invoke `drawSpriteBubble` for this sprite (same
-/// conditions as the inner loop, without the shadow/bubble pass split). Used so horizontal grid
-/// can be drawn per cell: we skip the flat grid segment where the bubble arc replaces it.
-/// Pass shared bubble state from `bubblePanSharedForGrid` when iterating many sprites (avoids repeated `animationGet`).
-fn spriteDrawsBubbleTopEdge(self: *FileWidget, sprite_index: usize, pan: ?BubblePanShared) bool {
- const p = pan orelse return false;
-
- const sprite_rect = self.init_options.file.spriteRect(sprite_index);
-
- var automatic_animation: bool = false;
- var animation_index: ?usize = null;
-
- if (self.init_options.file.selected_animation_index) |selected_animation_index| {
- for (self.init_options.file.animations.items(.frames)[selected_animation_index], 0..) |frame, i| {
- _ = i;
- if (frame.sprite_index == sprite_index) {
- animation_index = selected_animation_index;
- break;
- }
- }
- }
-
- if (animation_index == null) {
- anim_blk: for (self.init_options.file.animations.items(.frames), 0..) |frames, i| {
- for (frames, 0..) |frame, j| {
- _ = j;
- if (frame.sprite_index == sprite_index) {
- animation_index = i;
- break :anim_blk;
- }
- }
- }
- }
-
- if (animation_index) |ai| {
- if (self.init_options.file.selected_animation_index == ai) {
- automatic_animation = true;
- }
- }
-
- const sel_nonempty = p.selection_nonempty;
- if (sel_nonempty) {
- if (self.init_options.file.editor.selected_sprites.isSet(sprite_index)) {
- automatic_animation = true;
- }
- }
-
- if (automatic_animation) {
- return true;
- }
-
- if (sel_nonempty) {
- if (!self.init_options.file.editor.selected_sprites.isSet(sprite_index) or (animation_index != self.init_options.file.selected_animation_index and !self.init_options.file.editor.selected_sprites.isSet(sprite_index))) {
- return false;
- }
- }
-
- var max_distance: f32 = sprite_rect.h * 1.2;
-
- if (p.bubble_open) |anim| {
- max_distance += (max_distance * 0.5) * (1.0 - anim.value());
- } else if (p.bubble_close) |anim| {
- max_distance += (max_distance * 0.5) * (1.0 - anim.value());
- } else {
- max_distance += (max_distance * 0.5) * if (!self.hide_distance_bubble) @as(f32, 0.0) else @as(f32, 1.0);
- }
-
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
-
- const dx = @abs(current_point.x - (sprite_rect.x + sprite_rect.w * 0.5));
- const dy = @abs(current_point.y - (sprite_rect.y - sprite_rect.h * 0.25));
- const distance = @sqrt((dx * dx) * 0.5 + (dy * dy) * 2.0);
-
- return distance < (max_distance * 2.0);
-}
-
-/// Accumulator that merges multiple Triangles batches into a single draw call.
-const TriAcc = struct {
- vtx: std.ArrayList(dvui.Vertex) = .empty,
- idx: std.ArrayList(dvui.Vertex.Index) = .empty,
- alloc: std.mem.Allocator,
-
- fn init(alloc: std.mem.Allocator) TriAcc {
- return .{ .alloc = alloc };
- }
-
- fn append(self: *TriAcc, tris: dvui.Triangles) void {
- const base: dvui.Vertex.Index = @intCast(self.vtx.items.len);
- self.vtx.appendSlice(self.alloc, tris.vertexes) catch return;
- self.idx.ensureUnusedCapacity(self.alloc, tris.indices.len) catch return;
- for (tris.indices) |idx| {
- self.idx.appendAssumeCapacity(idx + base);
- }
- }
-
- fn render(self: *const TriAcc, tex: ?dvui.Texture) void {
- if (self.vtx.items.len == 0) return;
- var min_x: f32 = std.math.floatMax(f32);
- var min_y: f32 = std.math.floatMax(f32);
- var max_x: f32 = -std.math.floatMax(f32);
- var max_y: f32 = -std.math.floatMax(f32);
- for (self.vtx.items) |v| {
- min_x = @min(min_x, v.pos.x);
- min_y = @min(min_y, v.pos.y);
- max_x = @max(max_x, v.pos.x);
- max_y = @max(max_y, v.pos.y);
- }
- dvui.renderTriangles(.{
- .vertexes = self.vtx.items,
- .indices = self.idx.items,
- .bounds = .{ .x = min_x, .y = min_y, .w = max_x - min_x, .h = max_y - min_y },
- }, tex) catch {};
- }
-
- fn clear(self: *TriAcc) void {
- self.vtx.clearRetainingCapacity();
- self.idx.clearRetainingCapacity();
- }
-};
-
-const BubbleAccs = struct {
- shadow: TriAcc,
- fill: TriAcc,
- tex: TriAcc,
- outline: TriAcc,
-
- fn init(alloc: std.mem.Allocator) BubbleAccs {
- return .{
- .shadow = TriAcc.init(alloc),
- .fill = TriAcc.init(alloc),
- .tex = TriAcc.init(alloc),
- .outline = TriAcc.init(alloc),
- };
- }
-
- fn clearAll(self: *BubbleAccs) void {
- self.shadow.clear();
- self.fill.clear();
- self.tex.clear();
- self.outline.clear();
- }
-};
-
-/// Responsible for drawing the indicators for animation frames as bubbles over each sprite.
-///
-/// Bubbles contain a button that acts as a toggle for adding/removing a sprite from an animation.
-/// When using the pointer tool, bubbles will be drawn based on distance from the mouse location, as well as the currently selected animation frames.
-/// When using other tools, bubbles will be drawn based on the currently selected animation frames.
-///
-/// Bubbles use a elastic animation, and also display the currently viewed animation frame in the panel.
-pub fn drawSpriteBubbles(self: *FileWidget) void {
- if (self.init_options.file.editor.transform != null) return;
- if (self.resize_data_point != null) return;
-
- const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id;
- const cw = dvui.currentWindow();
- const drag_sprite_selection = dvui.dragName("sprite_selection_drag");
- const tool_not_pointer = fizzy.editor.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 sample_active = self.sample_data_point != null;
- const canvas_gesturing = self.init_options.file.editor.canvas.trackpadPinching() or
- self.init_options.file.editor.canvas.gestureActive();
-
- { // Create animations for closing or opening bubbles
- const bubble_open_hdr = dvui.animationGet(animation_id, "bubble_open");
- const bubble_close_hdr = dvui.animationGet(animation_id, "bubble_close");
-
- if ((drag_sprite_selection or tool_not_pointer or mod_shift or mod_ctrl_cmd) or
- radial_visible or
- sample_active or
- canvas_gesturing)
- {
- if (bubble_close_hdr) |anim| {
- if (anim.done()) {
- self.hide_distance_bubble = true;
- }
- } else if (bubble_open_hdr != null) {
- _ = dvui.currentWindow().animations.remove(animation_id.update("bubble_open"));
- dvui.animation(animation_id, "bubble_close", .{
- .easing = dvui.easing.outQuint,
- .end_time = 200_000,
- .start_val = 1.0,
- .end_val = 0.0,
- });
- } else if (!self.hide_distance_bubble) {
- dvui.animation(animation_id, "bubble_close", .{
- .easing = dvui.easing.outQuint,
- .end_time = 200_000,
- .start_val = 1.0,
- .end_val = 0.0,
- });
- }
- } else {
- if (bubble_open_hdr) |anim| {
- if (anim.done()) {
- self.hide_distance_bubble = false;
- }
- } else if (bubble_close_hdr != null) {
- _ = dvui.currentWindow().animations.remove(animation_id.update("bubble_close"));
-
- dvui.animation(animation_id, "bubble_open", .{
- .easing = dvui.easing.outElastic,
- .end_time = 900_000,
- .start_val = 0.0,
- .end_val = 1.0,
- });
- } else if (self.hide_distance_bubble) {
- dvui.animation(animation_id, "bubble_open", .{
- .easing = dvui.easing.outElastic,
- .end_time = 900_000,
- .start_val = 0.0,
- .end_val = 1.0,
- });
- }
- }
- }
-
- const bubble_open_draw = dvui.animationGet(animation_id, "bubble_open");
- const bubble_close_draw = dvui.animationGet(animation_id, "bubble_close");
- const selection_nonempty = self.init_options.file.editor.selected_sprites.count() > 0;
- const pan_shared: BubblePanShared = .{
- .bubble_open = bubble_open_draw,
- .bubble_close = bubble_close_draw,
- .peek = drag_sprite_selection or mod_shift or mod_ctrl_cmd or tool_not_pointer or sample_active or canvas_gesturing,
- .selection_nonempty = selection_nonempty,
- .tool_not_pointer = tool_not_pointer,
- };
-
- const visible_data = self.init_options.file.editor.canvas.dataFromScreenRect(self.init_options.file.editor.canvas.rect);
- const file = self.init_options.file;
- const cols = file.columns;
- const total_rows = file.rows;
- if (total_rows == 0 or cols == 0) return;
-
- const row_h: f32 = @floatFromInt(file.row_height);
- const col_w: f32 = @floatFromInt(file.column_width);
- if (row_h <= 0 or col_w <= 0) return;
- const bubble_headroom = @max(row_h, col_w);
-
- // Determine the visible row range to skip entire offscreen rows.
- // Use explicit comparisons rather than clamp to be NaN-safe
- // (NaN comparisons are always false, so NaN falls through to 0).
- const max_row_f: f32 = @floatFromInt(total_rows);
- const first_vis_f = (visible_data.y - bubble_headroom) / row_h;
- const first_vis_row: usize = if (first_vis_f > 0 and first_vis_f < max_row_f)
- @intFromFloat(first_vis_f)
- else if (first_vis_f >= max_row_f)
- total_rows
- else
- 0;
- const last_vis_f = (visible_data.y + visible_data.h) / row_h + 2.0;
- const last_vis_row: usize = if (last_vis_f > 0 and last_vis_f < max_row_f)
- @intFromFloat(last_vis_f)
- else if (last_vis_f >= max_row_f)
- total_rows
- else
- 0;
-
- const checkerboard_tex = file.checkerboardTileTexture();
- var accs = BubbleAccs.init(dvui.currentWindow().arena());
-
- // Row-based iteration with batched geometry rendering.
- // Geometry is accumulated into TriAccs and rendered in bulk to minimize draw calls.
- //
- // `hovered_bubble_sprite_index` is set from geometry hit tests; it must reflect the
- // bubble button under the mouse across *all* visible rows before any row's UI runs.
- // Otherwise a vertical selection shows stale plus/minus hints on rows drawn earlier.
- self.hovered_bubble_sprite_index = null;
-
- const vx0 = visible_data.x;
- const vx1 = visible_data.x + visible_data.w;
-
- for (0..2) |pass_i| {
- var row: usize = first_vis_row;
- while (row < last_vis_row) : (row += 1) {
- const row_start = row * cols;
- const row_end = @min(row_start + cols, file.spriteCount());
- if (row_end <= row_start) continue;
-
- const row_span = row_end - row_start;
- const base_y = @as(f32, @floatFromInt(row)) * row_h;
-
- // Horizontal clip: only columns whose cells can intersect the visible rect in x.
- // Avoids spriteRect + cull for off-screen tiles (major win when zoomed / panned).
- var col_lo: usize = 0;
- if (vx0 > 0) col_lo = @intFromFloat(@floor(vx0 / col_w));
- if (vx1 <= 0) continue;
- var col_hi_excl: usize = @intFromFloat(@ceil(vx1 / col_w));
- col_lo = @min(col_lo, row_span);
- col_hi_excl = @min(col_hi_excl, row_span);
- if (col_lo >= col_hi_excl) continue;
-
- const si_start = row_start + col_lo;
- const si_end_excl = row_start + col_hi_excl;
-
- const first_sprite = dvui.Rect{
- .x = 0,
- .y = base_y,
- .w = col_w,
- .h = row_h,
- };
- const row_clip_screen = file.editor.canvas.screenFromDataRect(.{
- .x = first_sprite.x,
- .y = first_sprite.y - bubble_headroom,
- .w = col_w * @as(f32, @floatFromInt(cols)),
- .h = bubble_headroom,
- });
-
- if (pass_i == 0) {
- // Pass 0 — geometry: accumulate shadow + fill + tex + outline in one pass.
- {
- var si: usize = si_end_excl;
- while (si > si_start) {
- si -= 1;
- const col_in_row = si - row_start;
- const sprite_rect = bubbleSpriteDataRect(col_in_row, base_y, col_w, row_h);
- if (!spriteCullVisible(sprite_rect, bubble_headroom, visible_data)) continue;
- drawSpriteBubbleForRow(self, file, si, sprite_rect, &accs, pan_shared);
- }
- }
-
- // Render all accumulated geometry under the row clip
- {
- const prev_clip = dvui.clip(row_clip_screen);
- defer dvui.clipSet(prev_clip);
- accs.shadow.render(null);
- accs.fill.render(null);
- accs.tex.render(checkerboard_tex);
- accs.outline.render(null);
- }
- accs.clearAll();
- } else {
- // Pass 1 — UI: buttons, text, icons rendered per-sprite.
- {
- var si: usize = si_end_excl;
- while (si > si_start) {
- si -= 1;
- const col_in_row = si - row_start;
- const sprite_rect = bubbleSpriteDataRect(col_in_row, base_y, col_w, row_h);
- if (!spriteCullVisible(sprite_rect, bubble_headroom, visible_data)) continue;
- drawSpriteBubbleForRow(self, file, si, sprite_rect, null, pan_shared);
- }
- }
- }
- }
- }
-}
-
-fn spriteCullVisible(sprite_rect: dvui.Rect, headroom: f32, visible: dvui.Rect) bool {
- const cull = dvui.Rect{
- .x = sprite_rect.x,
- .y = sprite_rect.y - headroom,
- .w = sprite_rect.w,
- .h = sprite_rect.h + headroom,
- };
- return !cull.intersect(visible).empty();
-}
-
-/// Data-space rect for sprite `si` when `row_start == row * cols` (same as `file.spriteRect(si)`).
-fn bubbleSpriteDataRect(col_in_row: usize, base_y: f32, col_w: f32, row_h: f32) dvui.Rect {
- return .{
- .x = @as(f32, @floatFromInt(col_in_row)) * col_w,
- .y = base_y,
- .w = col_w,
- .h = row_h,
- };
-}
-
-/// Per-sprite bubble logic extracted for use in the row-based loop.
-/// Computes animation state and progress, then calls drawSpriteBubble.
-/// When `accs` is non-null, geometry is accumulated instead of rendered.
-/// When `accs` is null and `shadow_only` is false, only UI elements are drawn.
-fn drawSpriteBubbleForRow(
- self: *FileWidget,
- file: *fizzy.Internal.File,
- sprite_index: usize,
- sprite_rect: dvui.Rect,
- accs: ?*BubbleAccs,
- pan: BubblePanShared,
-) void {
- var color = dvui.themeGet().color(.window, .fill);
-
- var automatic_animation: bool = false;
- var automatic_animation_frame_i: usize = 0;
-
- var animation_index: ?usize = null;
-
- if (file.selected_animation_index) |selected_animation_index| {
- for (file.animations.items(.frames)[selected_animation_index], 0..) |frame, i| {
- if (frame.sprite_index == sprite_index) {
- automatic_animation_frame_i = i;
- animation_index = selected_animation_index;
- break;
- }
- }
- }
-
- if (animation_index == null) {
- anim_blk: for (file.animations.items(.frames), 0..) |frames, i| {
- for (frames, 0..) |frame, j| {
- if (frame.sprite_index == sprite_index) {
- automatic_animation_frame_i = j;
- animation_index = i;
- break :anim_blk;
- }
- }
- }
- }
-
- if (animation_index) |ai| {
- const id = file.animations.get(ai).id;
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
- color = palette.getDVUIColor(@intCast(id));
- }
- if (file.selected_animation_index == ai) {
- automatic_animation = true;
- }
- }
-
- if (pan.selection_nonempty) {
- if (file.editor.selected_sprites.isSet(sprite_index)) {
- automatic_animation = true;
- if (animation_index) |ai| {
- if (ai != file.selected_animation_index) {
- color = dvui.themeGet().color(.control, .fill_hover);
- }
- }
- }
- }
-
- if (automatic_animation) {
- const total_duration: i32 = 1_500_000;
- const max_step_duration: i32 = @divTrunc(total_duration, 3);
-
- var duration_step = max_step_duration;
-
- if (animation_index) |ai| {
- duration_step = std.math.clamp(@divTrunc(total_duration, @as(i32, @intCast(file.animations.get(ai).frames.len))), 0, max_step_duration);
- }
-
- const duration = max_step_duration + (duration_step * @as(i32, @intCast(automatic_animation_frame_i + 1)));
-
- var open: bool = true;
- var id_extra: usize = sprite_index;
-
- if (animation_index) |ai| {
- id_extra = dvui.Id.extendId(@enumFromInt(sprite_index), @src(), ai).asUsize();
- }
-
- {
- const current_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
-
- const max_distance: f32 = @max(sprite_rect.h, sprite_rect.w) * 1.5;
-
- const dx = @abs(current_point.x - (sprite_rect.x + sprite_rect.w * 0.5));
- const dy = @abs(current_point.y - (sprite_rect.y) + sprite_rect.h * 0.5);
- const distance = @sqrt(dx * dx + dy * dy);
-
- if (distance < max_distance and pan.peek and current_point.y - sprite_rect.y < 0.0 and current_point.y - sprite_rect.y > -sprite_rect.h) {
- open = false;
- id_extra = dvui.Id.update(@enumFromInt(id_extra), "peek").asUsize();
- } else {
- id_extra = dvui.Id.update(@enumFromInt(id_extra), "unpeek").asUsize();
- }
- }
-
- if (accs != null) {
- id_extra = dvui.Id.update(@enumFromInt(id_extra), "geom").asUsize();
- } else {
- id_extra = dvui.Id.update(@enumFromInt(id_extra), "ui").asUsize();
- }
-
- var t: f32 = 0.0;
-
- const anim = dvui.animate(@src(), .{
- .duration = if (open) duration else @divTrunc(duration, 4),
- .kind = .vertical,
- .easing = if (open) dvui.easing.outElastic else dvui.easing.outQuint,
- }, .{
- .id_extra = id_extra,
- });
- defer anim.deinit();
-
- t = if (open) anim.val orelse 1.0 else std.math.clamp(1.0 - (anim.val orelse 1.0), 0.0, 2.0);
-
- if (drawSpriteBubble(self, sprite_index, sprite_rect, t, color, animation_index, accs, pan.bubble_open, pan.bubble_close, pan.tool_not_pointer)) {
- self.hovered_bubble_sprite_index = sprite_index;
- }
- } else {
- if (pan.selection_nonempty) {
- if (!file.editor.selected_sprites.isSet(sprite_index) or (animation_index != file.selected_animation_index and !file.editor.selected_sprites.isSet(sprite_index))) {
- return;
- }
- }
-
- const current_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
-
- var max_distance: f32 = sprite_rect.h * 1.2;
-
- if (pan.bubble_open) |anim| {
- max_distance += (max_distance * 0.5) * (1.0 - anim.value());
- } else if (pan.bubble_close) |anim| {
- max_distance += (max_distance * 0.5) * (1.0 - anim.value());
- } else {
- max_distance += (max_distance * 0.5) * if (!self.hide_distance_bubble) @as(f32, 0.0) else @as(f32, 1.0);
- }
-
- const dx = @abs(current_point.x - (sprite_rect.x + sprite_rect.w * 0.5));
- const dy = @abs(current_point.y - (sprite_rect.y - sprite_rect.h * 0.25));
- const distance = @sqrt((dx * dx) * 0.5 + (dy * dy) * 2.0);
-
- if (distance < (max_distance * 2.0)) {
- var t: f32 = distance / max_distance;
-
- if (pan.bubble_open) |anim| {
- t = (1.0 - t) * anim.value();
- } else if (pan.bubble_close) |anim| {
- t = (1.0 - t) * anim.value();
- } else {
- t = (1.0 - t) * if (self.hide_distance_bubble) @as(f32, 0.0) else @as(f32, 1.0);
- }
-
- t = std.math.clamp(t, 0.0, 2.0);
-
- if (drawSpriteBubble(
- self,
- sprite_index,
- sprite_rect,
- t,
- dvui.themeGet().color(.window, .fill).lerp(color, 1.0 - (distance / (max_distance * 2.0))),
- animation_index,
- accs,
- pan.bubble_open,
- pan.bubble_close,
- pan.tool_not_pointer,
- )) {
- self.hovered_bubble_sprite_index = sprite_index;
- }
- }
- }
-}
-
-/// Draw a single sprite bubble based on sprite index and progress. Animation index just lets us know if not null, its part of an animation,
-/// and if its equal to the currently selected animation index, we need to draw a checkmark in the bubble because its part of the currently selected animation.
-/// When `accs` is non-null, triangle geometry is accumulated into the
-/// accumulators instead of being rendered immediately. Pass null for the
-/// UI-only phase so that only buttons/text/icons are drawn.
-pub fn drawSpriteBubble(
- self: *FileWidget,
- sprite_index: usize,
- sprite_rect: dvui.Rect,
- progress: f32,
- color: dvui.Color,
- animation_index: ?usize,
- accs: ?*BubbleAccs,
- bubble_open: ?dvui.Animation,
- bubble_close: ?dvui.Animation,
- tool_not_pointer: bool,
-) bool {
-
- // Would this sprite be removed if the user clicked the button?
- var remove: bool = false;
- if (self.init_options.file.selected_animation_index) |anim_index| {
- const anim = self.init_options.file.animations.get(anim_index);
- for (anim.frames) |frame| {
- if (frame.sprite_index == sprite_index) {
- remove = true;
- }
- }
- }
-
- //if (sprite_index != 0) return;
- const t = progress;
-
- const cell_tint = checkerboardTintAtSpriteCellCenter(self.init_options.file, sprite_index);
-
- const target_button_height: f32 = 24.0;
- // Figure out artwork's baseline size (width or height, whichever is smaller)
- const baseline_sprite_size: f32 = 64.0;
- const min_sprite_size: f32 = @min(sprite_rect.w, sprite_rect.h);
- const baseline_scale: f32 = baseline_sprite_size / min_sprite_size;
- // Compensate the button size so that it stays visually consistent even if the tile is smaller/larger than 64x64
- var button_height = std.math.clamp((target_button_height * dvui.easing.outBack(t) / self.init_options.file.editor.canvas.scale), 0.0, min_sprite_size / 3.0);
-
- const sprite_rect_scale: dvui.RectScale = .{
- .r = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect),
- .s = self.init_options.file.editor.canvas.scale,
- };
-
- var bubble_max_height: f32 = @min(sprite_rect.h, sprite_rect.w) * 0.5;
-
- if (self.init_options.file.selected_animation_index) |ai| {
- if (self.init_options.file.selected_animation_frame_index < self.init_options.file.animations.get(ai).frames.len) {
- const animation = self.init_options.file.animations.get(ai);
- if (animation.frames.len > 0) {
- const frame = animation.frames[self.init_options.file.selected_animation_frame_index];
- if (frame.sprite_index != sprite_index and animation_index == ai) {
- bubble_max_height = @min(sprite_rect.h, sprite_rect.w) * 0.3333;
- }
- }
- }
- } else if (self.init_options.file.editor.selected_sprites.count() > 1) {
- if (self.init_options.file.primarySpriteIndex()) |primary| {
- if (sprite_index != primary) {
- bubble_max_height = @min(sprite_rect.h, sprite_rect.w) * 0.3333;
- }
- }
- }
-
- const bubble_height = std.math.clamp((bubble_max_height * t / self.init_options.file.editor.canvas.scale) * baseline_scale, 0.0, bubble_max_height * t);
- const bubble_rect = dvui.Rect{
- .x = sprite_rect.x,
- .y = sprite_rect.y - bubble_height,
- .w = sprite_rect.w,
- .h = bubble_height,
- };
-
- var bubble_rect_scale: dvui.RectScale = .{
- .r = self.init_options.file.editor.canvas.screenFromDataRect(bubble_rect),
- .s = self.init_options.file.editor.canvas.scale,
- };
-
- var path = dvui.Path.Builder.init(dvui.currentWindow().lifo());
-
- const center = bubble_rect.center();
-
- // Choose a font size that fits scaled to button size.
- const font = dvui.Font.theme(.body);
-
- const sprite_label = self.init_options.file.fmtSprite(dvui.currentWindow().arena(), sprite_index, .grid) catch {
- dvui.log.err("Failed to format sprite index", .{});
- return false;
- };
-
- const text_size = font.textSize(sprite_label);
-
- var button_width = @max(button_height, (text_size.w + 4.0) / self.init_options.file.editor.canvas.scale);
-
- if (bubble_close) |anim| {
- button_height *= anim.value();
- button_width *= anim.value();
- } else if (bubble_open) |anim| {
- button_height *= anim.value();
- button_width *= anim.value();
- } else if (tool_not_pointer or self.hide_distance_bubble) {
- button_height = 0.0;
- button_width = 0.0;
- }
-
- const button_rect = dvui.Rect{ .x = center.x - button_width / 2, .y = center.y - (button_height / 2), .w = button_width, .h = button_height };
-
- if (bubble_rect_scale.r.h <= dvui.currentWindow().natural_scale) {
- if (accs) |a| {
- path.addPoint(bubble_rect_scale.r.topRight());
- path.addPoint(bubble_rect_scale.r.topLeft());
- const tris = path.build().strokeTriangles(dvui.currentWindow().arena(), .{ .thickness = 1, .color = color }) catch return false;
- a.shadow.append(tris);
- }
- return false;
- } else {
- const ns = dvui.currentWindow().natural_scale;
- // Upper bound can drop below `ns` when the sprite is only a few physical pixels (zoomed far out);
- // `std.math.clamp` panics if min > max.
- const sprite_screen_min = @min(sprite_rect_scale.r.h, sprite_rect_scale.r.w);
- const arc_upper = sprite_screen_min * 0.5 - ns;
- const arc_height = std.math.clamp(bubble_rect_scale.r.h, ns, @max(ns, arc_upper));
-
- const d = bubble_rect_scale.r.w / 2;
-
- const radius: f32 = (d * d + arc_height * arc_height) / (2 * arc_height);
-
- const center_x: f32 = sprite_rect_scale.r.x + (sprite_rect_scale.r.w / 2);
-
- const arc_center: dvui.Point.Physical = .{ .x = center_x, .y = sprite_rect_scale.r.y + radius - arc_height };
-
- const end_angle: f32 = std.math.atan2(arc_center.y - sprite_rect_scale.r.topLeft().y, arc_center.x - sprite_rect_scale.r.topLeft().x);
- const start_angle: f32 = std.math.atan2(arc_center.y - sprite_rect_scale.r.topRight().y, arc_center.x - sprite_rect_scale.r.topRight().x);
-
- path.addArc(arc_center, radius, dvui.math.pi + start_angle, dvui.math.pi + end_angle, false);
-
- const built = path.build();
- defer path.deinit();
-
- // Geometry phase: accumulate shadow + fill + outline into accumulators.
- if (accs) |a| {
- const shadow_fade = arc_height * 0.66 * dvui.easing.outExpo(t);
- const shadow_color = dvui.Color.black.opacity(0.25);
- var shadow_path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- shadow_path.addArc(arc_center, radius, dvui.math.pi + start_angle, dvui.math.pi + end_angle, false);
- const shadow_tris = shadow_path.build().fillConvexTriangles(dvui.currentWindow().arena(), .{ .color = shadow_color, .fade = shadow_fade }) catch return false;
- a.shadow.append(shadow_tris);
-
- if (self.init_options.file.editor.canvas.scale < 0.1) {
- const fill_tris = built.fillConvexTriangles(dvui.currentWindow().arena(), .{ .color = cell_tint, .fade = 0.0 }) catch return false;
- a.fill.append(fill_tris);
- } else {
- const fill_tris = built.fillConvexTriangles(dvui.currentWindow().arena(), .{ .color = cell_tint, .fade = 1.0 }) catch return false;
- a.fill.append(fill_tris);
- var tex_tris = built.fillConvexTriangles(dvui.currentWindow().arena(), .{ .color = cell_tint, .fade = 0.0 }) catch return false;
- const h_ratio = arc_height / sprite_rect_scale.r.h;
- tex_tris.uvFromRectuv(bubble_rect_scale.r, .{ .x = 0.0, .w = 1.0, .y = 1.0 - h_ratio, .h = h_ratio });
- a.tex.append(tex_tris);
- }
- const outline_tris = built.strokeTriangles(dvui.currentWindow().arena(), .{ .color = color, .thickness = dvui.currentWindow().natural_scale }) catch return false;
- a.outline.append(outline_tris);
-
- const mouse_data_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
- return button_rect.contains(mouse_data_pt);
- }
-
- // UI-only phase: geometry was already batched, draw interactive content only.
- // Dont draw any buttons if the button is too small or too large.
- if (button_rect.w > bubble_rect.w * 0.666 or button_rect.w < bubble_rect.w * 0.001) return false;
-
- var add_rem_message: ?[]const u8 = null;
-
- var border_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
- if (self.init_options.file.selected_animation_index) |index| {
- border_color = palette.getDVUIColor(@intCast(self.init_options.file.animations.get(index).id));
- add_rem_message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{self.init_options.file.animations.get(index).name}) catch {
- dvui.log.err("Failed to allocate add/remove message", .{});
- return false;
- };
- } else {
- add_rem_message = std.fmt.allocPrint(dvui.currentWindow().arena(), "New Animation", .{}) catch {
- dvui.log.err("Failed to allocate add/remove message", .{});
- return false;
- };
- }
- }
-
- var show_hint: bool = false;
- if (self.hovered_bubble_sprite_index) |hovered_button_index| {
- var iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |selected_index| {
- if (selected_index == sprite_index) {
- show_hint = true;
- }
- }
-
- if (self.init_options.file.selected_animation_index) |selected_animation_index| {
- const selected_animation = self.init_options.file.animations.get(selected_animation_index);
- if (selected_animation.frames.len > 0) {
- var hovered_in_animation: bool = false;
- for (selected_animation.frames) |frame| {
- if (frame.sprite_index == hovered_button_index) {
- hovered_in_animation = true;
- break;
- }
- }
-
- var current_in_animation: bool = false;
- for (selected_animation.frames) |frame| {
- if (frame.sprite_index == sprite_index) {
- current_in_animation = true;
- break;
- }
- }
-
- if (hovered_in_animation != current_in_animation) {
- show_hint = false;
- }
- }
- }
-
- var found_current_in_selection: bool = false;
-
- iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |selected_index| {
- if (selected_index == hovered_button_index) {
- found_current_in_selection = true;
- }
- }
-
- if (!found_current_in_selection)
- show_hint = false;
- }
-
- var button: dvui.ButtonWidget = undefined;
- button.init(@src(), .{
- .draw_focus = false,
- }, .{
- .rect = button_rect,
- .margin = .all(0),
- .padding = .all(0),
- .id_extra = sprite_index,
- .color_fill = dvui.themeGet().color(.control, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
- //.color_border = dvui.themeGet().color(.control, .fill),
- //.border = dvui.Rect.all(1).scale(1.0 / self.init_options.file.editor.canvas.scale, dvui.Rect),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -0.05 * button_height, .y = 0.08 * button_height },
- .fade = (button_height / 10) * t,
- .alpha = 0.5 * t,
- },
- .corner_radius = dvui.Rect.all(1000000),
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- });
- defer button.deinit();
-
- button.processEvents();
-
- if (button.hovered() or show_hint) {
- if (remove) {
- button.data().options.color_border = dvui.themeGet().color(.err, .fill).opacity(0.75);
- } else {
- button.data().options.color_border = dvui.themeGet().color(.highlight, .fill).opacity(0.75);
- }
- }
-
- button.drawBackground();
-
- if (button.clicked()) { // Toggle animation frame on or off for this selection/animation
- if (self.init_options.file.selected_animation_index) |anim_index| {
- // TODO: Efficiently resize the animation frames array instead of duplicating it
-
- var anim = self.init_options.file.animations.get(anim_index);
-
- var frames = std.array_list.Managed(fizzy.Animation.Frame).init(fizzy.app.allocator);
- frames.appendSlice(anim.frames) catch {
- dvui.log.err("Failed to append frames", .{});
- return false;
- };
-
- for (frames.items, 0..) |frame, i| {
- if (frame.sprite_index == sprite_index) {
-
- // First remove the currently clicked frame, regardless
- _ = frames.orderedRemove(i);
- }
- }
-
- if (self.init_options.file.editor.selected_sprites.count() > 0) {
- var in_selection: bool = false;
- var iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |selected_index| {
- if (selected_index == sprite_index) {
- in_selection = true;
- break;
- }
- }
-
- if (in_selection) {
- // Remove all selected_sprite_index values from frames, regardless of their position.
- // To avoid skipping items due to shifting, iterate backward through frames.
- iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |selected_sprite_index| {
- var j: usize = frames.items.len;
- while (j > 0) : (j -= 1) {
- if (frames.items[j - 1].sprite_index == selected_sprite_index) {
- _ = frames.orderedRemove(j - 1);
- }
- }
- }
- }
- }
-
- if (!remove) {
- if (self.init_options.file.editor.selected_sprites.count() > 0) {
- var in_selection: bool = false;
-
- var iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |selected_index| {
- if (selected_index == sprite_index) {
- in_selection = true;
- break;
- }
- }
-
- if (in_selection) {
- iter = self.init_options.file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |selected_index| {
- frames.append(.{ .sprite_index = selected_index, .ms = temp_ms }) catch {
- dvui.log.err("Failed to append frame", .{});
- return false;
- };
- }
- } else {
- frames.append(.{ .sprite_index = sprite_index, .ms = temp_ms }) catch {
- dvui.log.err("Failed to append frame", .{});
- return false;
- };
- }
- } else {
- frames.append(.{ .sprite_index = sprite_index, .ms = temp_ms }) catch {
- dvui.log.err("Failed to append frame", .{});
- return false;
- };
- }
- }
-
- if (!anim.eqlFrames(frames.items)) {
- self.init_options.file.history.append(.{
- .animation_frames = .{
- .index = anim_index,
- .frames = fizzy.app.allocator.dupe(fizzy.Animation.Frame, anim.frames) catch {
- dvui.log.err("Failed to dupe frames", .{});
- return false;
- },
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
-
- fizzy.app.allocator.free(anim.frames);
- anim.frames = frames.toOwnedSlice() catch {
- dvui.log.err("Failed to free frames", .{});
- return false;
- };
-
- self.init_options.file.animations.set(anim_index, anim);
- }
- } else {
- if (self.init_options.file.createAnimation() catch null) |anim_index| {
- self.init_options.file.selected_animation_index = anim_index;
- self.init_options.file.collapseAnimationSelectionToPrimary();
- self.init_options.file.editor.animations_scroll_to_index = anim_index;
- fizzy.editor.explorer.sprites.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index];
- fizzy.editor.explorer.pane = .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 {
- dvui.log.err("Failed to append frame", .{});
- return false;
- };
- }
- self.init_options.file.animations.set(anim_index, anim);
-
- self.init_options.file.history.append(.{
- .animation_restore_delete = .{
- .action = .delete,
- .index = anim_index,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- }
- }
- }
-
- if (button.data().contentRectScale().r.w > text_size.w) {
- // Determine the rect to draw in
- const btn_rect = button.data().contentRectScale().r;
-
- const scaled_text_size = text_size.scale(dvui.currentWindow().natural_scale, dvui.Size.Physical);
-
- const text_rect = dvui.Rect.Physical{
- .x = btn_rect.x + (btn_rect.w - scaled_text_size.w) / 2,
- .y = btn_rect.y + (btn_rect.h - scaled_text_size.h) / 2,
- .w = scaled_text_size.w,
- .h = scaled_text_size.h,
- };
-
- const color_main = if (button.hovered() or animation_index == self.init_options.file.selected_animation_index) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text);
-
- dvui.renderText(.{
- .text = sprite_label,
- .font = font,
- .color = color_main.opacity(progress),
- .rs = .{ .r = text_rect, .s = dvui.currentWindow().natural_scale },
- }) catch {
- dvui.log.err("Failed to render text", .{});
- return false;
- };
-
- var icon_rect = button.data().rectScale().r;
- icon_rect.x += icon_rect.w;
- icon_rect.w = icon_rect.w / 2.0;
- icon_rect.h = icon_rect.h / 2.0;
- icon_rect.x = icon_rect.x - icon_rect.w / 1.5;
- icon_rect.y = icon_rect.y - icon_rect.h / 3;
-
- var fill_rect = icon_rect;
- fill_rect.x += icon_rect.w + (2.0 * dvui.currentWindow().natural_scale);
-
- // Center fill_rect over the button rect if there is more than one selected sprite.
- if (self.init_options.file.editor.selected_sprites.count() > 1) {
- // Center fill_rect horizontally and vertically over button rect
- fill_rect.x = button.data().rectScale().r.x + (button.data().rectScale().r.w - fill_rect.w) / 2.0;
-
- fill_rect.y -= fill_rect.h + fill_rect.h / 3;
- }
-
- if (button.hovered() or show_hint) {
- var icon_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .rect = button.data().rectScale().rectFromPhysical(icon_rect),
- .border = dvui.Rect.all(0),
- .background = true,
- .corner_radius = dvui.Rect.all(1000000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -0.05 * button_height, .y = 0.08 * button_height },
- .fade = (button_height / 10) * t,
- .alpha = 0.35 * t,
- },
- .color_fill = if (remove) dvui.themeGet().color(.err, .fill).opacity(0.75) else dvui.themeGet().color(.highlight, .fill).opacity(0.75),
- });
-
- dvui.renderIcon("close", if (remove) icons.tvg.lucide.minus else icons.tvg.lucide.plus, .{ .r = icon_box.data().rectScale().r, .s = dvui.currentWindow().natural_scale }, .{}, .{}) catch {
- dvui.log.err("Failed to render icon", .{});
- return false;
- };
- icon_box.deinit();
-
- var message_size: dvui.Size = .{};
-
- if (add_rem_message) |message| {
- message_size.w = font.textSize(message).w * dvui.currentWindow().natural_scale;
- message_size.h = font.textSize(message).h * dvui.currentWindow().natural_scale + 2.0 * dvui.currentWindow().natural_scale;
-
- fill_rect.w += message_size.w * 1.5;
- fill_rect.h = @max(fill_rect.h, message_size.h);
- }
- if (button.hovered()) {
- if (add_rem_message) |message| {
- const fill_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .rect = button.data().rectScale().rectFromPhysical(fill_rect),
- .border = dvui.Rect.all(0),
- .background = true,
- .corner_radius = dvui.Rect.all(1000000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -0.05 * button_height, .y = 0.08 * button_height },
- .fade = (button_height / 10) * t,
- .alpha = 0.35 * t,
- },
- .color_fill = if (remove) dvui.themeGet().color(.err, .fill).opacity(0.75) else dvui.themeGet().color(.highlight, .fill).opacity(0.75),
- });
- defer fill_box.deinit();
-
- var text_box = fill_box.data().contentRectScale().r;
- text_box.x += (text_box.w - (message_size.w)) / 2.0;
- text_box.y += (text_box.h - (message_size.h)) / 2.0;
-
- dvui.renderText(.{
- .text = message,
- .font = font,
- .color = .white,
- .rs = .{ .r = text_box, .s = dvui.currentWindow().natural_scale },
- }) catch {
- dvui.log.err("Failed to render text", .{});
- };
- }
- }
- }
- }
- }
-
- return false;
-}
-
-/// Draw the highlight colored selection box for each selected sprite.
-pub fn drawSpriteSelection(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .pointer) return;
- if (self.init_options.file.editor.transform != null) return;
- if (self.sample_data_point != null) return;
-
- if (self.drag_data_point) |previous_point| {
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
- const min_x = @min(previous_point.x, current_point.x);
- const min_y = @min(previous_point.y, current_point.y);
- const max_x = @max(previous_point.x, current_point.x);
- const max_y = @max(previous_point.y, current_point.y);
- const span_rect = dvui.Rect{
- .x = min_x,
- .y = min_y,
- .w = max_x - min_x,
- .h = max_y - min_y,
- };
-
- const screen_selection_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect);
- const selection_color = if (dvui.currentWindow().modifiers.matchBind("shift")) dvui.themeGet().color(.err, .fill).opacity(0.5) else dvui.themeGet().color(.highlight, .fill).opacity(0.5);
- screen_selection_rect.fill(
- dvui.Rect.Physical.all(6 * dvui.currentWindow().natural_scale),
- .{
- .color = selection_color,
- },
- );
- }
-}
-
-/// Arc-length point along a piecewise-linear polyline (`cum` = cumulative segment lengths).
-fn marqueePointAtArcLength(
- points: []const dvui.Point.Physical,
- cum: []const f32,
- s: f32,
-) dvui.Point.Physical {
- const n = points.len;
- std.debug.assert(n == cum.len);
- if (n == 0) return .{ .x = 0, .y = 0 };
- if (n == 1) return points[0];
-
- const total = cum[n - 1];
- const clamped = std.math.clamp(s, cum[0], total);
-
- var i: usize = 0;
- while (i + 1 < n and cum[i + 1] < clamped) {
- i += 1;
- }
- const seg_len = cum[i + 1] - cum[i];
- if (seg_len < 1e-5) {
- return points[i + 1];
- }
- const t = (clamped - cum[i]) / seg_len;
- return .{
- .x = points[i].x + (points[i + 1].x - points[i].x) * t,
- .y = points[i].y + (points[i + 1].y - points[i].y) * t,
- };
-}
-
-fn marqueeAppendSpan(
- points: []const dvui.Point.Physical,
- cum: []const f32,
- s0: f32,
- s1: f32,
- out: *std.array_list.Managed(dvui.Point.Physical),
-) !void {
- out.clearRetainingCapacity();
- const eps = 1e-4;
- if (s1 <= s0 + eps) return;
-
- try out.append(marqueePointAtArcLength(points, cum, s0));
-
- const n = points.len;
- var k: usize = 1;
- while (k < n) : (k += 1) {
- const d = cum[k];
- if (d <= s0 + eps) continue;
- if (d >= s1 - eps) break;
- try out.append(points[k]);
- }
-
- const end_pt = marqueePointAtArcLength(points, cum, s1);
- const last = out.items[out.items.len - 1];
- const dx = end_pt.x - last.x;
- const dy = end_pt.y - last.y;
- if (dx * dx + dy * dy > 1e-8) {
- try out.append(end_pt);
- }
-}
-
-/// Dashed stroke along a polyline (same approach as graphl `previewStrokePolylineDashed`).
-fn strokePolylineDashedPhysical(
- points: []const dvui.Point.Physical,
- dash_len: f32,
- gap_len: f32,
- stroke: dvui.Path.StrokeOptions,
-) void {
- const n = points.len;
- if (n < 2) return;
- if (dash_len <= 0.0) return;
- const gap = @max(0.0, gap_len);
- const pattern = dash_len + gap;
- if (pattern < 1e-5) return;
-
- const arena = dvui.currentWindow().arena();
- const cum = arena.alloc(f32, n) catch return;
- cum[0] = 0;
- var i: usize = 1;
- while (i < n) : (i += 1) {
- cum[i] = cum[i - 1] + dvui.Point.Physical.diff(points[i], points[i - 1]).length();
- }
-
- const total = cum[n - 1];
- if (total < 1e-4) return;
-
- var buf = std.array_list.Managed(dvui.Point.Physical).init(arena);
- defer buf.deinit();
-
- const edge_eps = 1e-5;
- var s: f32 = 0;
- while (s < total - edge_eps) {
- const dash_end = @min(s + dash_len, total);
- if (dash_end <= s + edge_eps) break;
- marqueeAppendSpan(points, cum, s, dash_end, &buf) catch return;
- if (buf.items.len != 0) {
- dvui.Path.stroke(.{ .points = buf.items }, stroke);
- }
- s = dash_end + gap;
- }
-}
-
-fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .selection) return;
- if (fizzy.editor.tools.selection_mode != .box) return;
- const start = self.drag_data_point orelse return;
- if (dvui.dragging(dvui.currentWindow().mouse_pt, "stroke_drag") == null) return;
-
- const file = self.init_options.file;
- const canvas = &file.editor.canvas;
- const current = canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
-
- const min_x = @min(start.x, current.x);
- const min_y = @min(start.y, current.y);
- const max_x = @max(start.x, current.x);
- const max_y = @max(start.y, current.y);
- if (@abs(max_x - min_x) < 1e-4 and @abs(max_y - min_y) < 1e-4) return;
-
- const tl = canvas.screenFromDataPoint(.{ .x = min_x, .y = min_y });
- const tr = canvas.screenFromDataPoint(.{ .x = max_x, .y = min_y });
- const br = canvas.screenFromDataPoint(.{ .x = max_x, .y = max_y });
- const bl = canvas.screenFromDataPoint(.{ .x = min_x, .y = max_y });
-
- const arena = dvui.currentWindow().arena();
- const loop_buf = arena.alloc(dvui.Point.Physical, 5) catch return;
- loop_buf[0] = tl;
- loop_buf[1] = tr;
- loop_buf[2] = br;
- loop_buf[3] = bl;
- loop_buf[4] = tl;
-
- const rs = canvas.scroll_container.data().rectScale();
- const stroke_w = @max(1.0, 1.0 * rs.s);
-
- const outline_color = dvui.themeGet().color(.window, .text);
-
- const dash_px: f32 = 14.0;
- const gap_px: f32 = 8.75;
-
- strokePolylineDashedPhysical(loop_buf, dash_px, gap_px, .{
- .thickness = stroke_w,
- .color = outline_color,
- .endcap_style = .none,
- .after = true,
- });
-}
-
-/// Preview for rectangular selection while dragging (box mode).
-fn applySelectionBoxPreview(
- file: *fizzy.Internal.File,
- active_layer: *const fizzy.Internal.Layer,
- start: dvui.Point,
- end: dvui.Point,
- mod: dvui.enums.Mod,
-) void {
- const read_layer = file.layers.get(file.selected_layer_index);
- file.editor.temporary_layer.clearMask();
- file.editor.temporary_layer.mask.setUnion(file.editor.selection_layer.mask);
- file.editor.temporary_layer.mask.setIntersection(active_layer.mask);
-
- const x0: i32 = @intFromFloat(@floor(@min(start.x, end.x)));
- const y0: i32 = @intFromFloat(@floor(@min(start.y, end.y)));
- const x1: i32 = @intFromFloat(@floor(@max(start.x, end.x)));
- const y1: i32 = @intFromFloat(@floor(@max(start.y, end.y)));
-
- const iw: i32 = @intCast(file.width());
- const ih: i32 = @intCast(file.height());
-
- const sub = mod.matchBind("shift");
-
- var py = y0;
- while (py <= y1) : (py += 1) {
- if (py < 0 or py >= ih) continue;
- var px = x0;
- while (px <= x1) : (px += 1) {
- if (px < 0 or px >= iw) continue;
- const pt: dvui.Point = .{ .x = @floatFromInt(px), .y = @floatFromInt(py) };
- if (file.editor.temporary_layer.pixelIndex(pt)) |idx| {
- if (read_layer.pixels()[idx][3] == 0) continue;
- if (sub) {
- file.editor.temporary_layer.mask.setValue(idx, false);
- } else {
- file.editor.temporary_layer.mask.setValue(idx, true);
- }
- }
- }
- }
-}
-
-/// Responsible for processing events to create/modify the current fine-grained selection.
-/// This selection is pixel-based, and includes shift/ctrl/cmd modifiers to support add/remove.
-/// The selection uses the same logic as the stroke tool to brush the selection over existing pixels.
-pub fn processSelection(self: *FileWidget) void {
- if (switch (fizzy.editor.tools.current) {
- .selection,
- => false,
- else => true,
- }) return;
-
- if (self.sample_key_down or self.right_mouse_down) return;
-
- const file = self.init_options.file;
- const widget_active = self.active();
- const active_layer = &file.layers.get(file.selected_layer_index);
-
- const selection_alpha: u8 = 185;
- const selection_color_primary: dvui.Color = .{ .r = 200, .g = 200, .b = 200, .a = selection_alpha };
- const selection_color_secondary: dvui.Color = .{ .r = 50, .g = 50, .b = 50, .a = selection_alpha };
-
- const selection_alpha_stroke: u8 = 225;
- var selection_color_primary_stroke: dvui.Color = .{ .r = 255, .g = 255, .b = 255, .a = selection_alpha_stroke };
- var selection_color_secondary_stroke: dvui.Color = .{ .r = 200, .g = 200, .b = 200, .a = selection_alpha_stroke };
-
- // Pixel mode: draw the committed selection before handling events (brush preview layers on top).
- // Box mode: skip — the mask is updated on mouse release in the same frame as this paint; drawing
- // here would use stale data until the next frame. Box repaints from the current mask after events.
- if (fizzy.editor.tools.selection_mode == .pixel or fizzy.editor.tools.selection_mode == .color) {
- @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 });
- file.editor.temporary_layer.clearMask();
-
- file.editor.temporary_layer.mask.setUnion(file.editor.selection_layer.mask);
- file.editor.temporary_layer.mask.setIntersection(active_layer.mask);
-
- file.editor.temporary_layer.setColorFromMask(selection_color_primary);
- file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard);
- file.editor.temporary_layer.setColorFromMask(selection_color_secondary);
- }
-
- for (dvui.events()) |*e| {
- if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- switch (e.evt) {
- .key => |ke| {
- var update: bool = false;
- if (fizzy.editor.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;
-
- fizzy.editor.tools.setStrokeSize(fizzy.editor.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;
-
- fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size);
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- update = true;
- }
- }
-
- if (update) {
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
- {
-
- // Clear temporary layer pixels and mask
- @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 });
- file.editor.temporary_layer.clearMask();
-
- // Set the temporary layer mask to the selection layer mask
- file.editor.temporary_layer.mask.setUnion(file.editor.selection_layer.mask);
-
- // Draw the point at the stroke size to the temporary layer mask only
- file.drawPoint(
- current_point,
- .temporary,
- .{
- .mask_only = true,
- .stroke_size = fizzy.editor.tools.stroke_size,
- },
- );
-
- // Intersect with the active layer mask so the stroke is confined to only non-transparent pixels
- file.editor.temporary_layer.mask.setIntersection(active_layer.mask);
- file.editor.temporary_layer.setColorFromMask(selection_color_primary);
-
- // Intersect with the checkerboard mask so we can show the pattern
- file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard);
- file.editor.temporary_layer.setColorFromMask(selection_color_secondary);
- }
- }
- },
- .mouse => |me| {
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p);
-
- if (me.action == .position) {
- const box_mode = fizzy.editor.tools.selection_mode == .box;
- const color_mode = fizzy.editor.tools.selection_mode == .color;
- const is_drag = dvui.dragging(me.p, "stroke_drag") != null;
- const box_drag = box_mode and is_drag and self.drag_data_point != null;
-
- if ((box_mode and !box_drag) or color_mode) {
- // Box: committed selection is painted after events. Color: no brush preview.
- } else {
- // Clear the mask, we now need to only draw the point at the stroke size to the mask
- file.editor.temporary_layer.clearMask();
-
- if (box_drag) {
- if (self.drag_data_point) |start| {
- // Clear pixels so subtract preview can drop overlay where the mask is cleared
- // (setColorFromMask only writes where the mask is set).
- @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 });
- applySelectionBoxPreview(
- file,
- active_layer,
- start,
- current_point,
- me.mod,
- );
- }
- // Same checkerboard two-tone as the committed selection (no err/highlight tint).
- file.editor.temporary_layer.mask.setIntersection(active_layer.mask);
- file.editor.temporary_layer.setColorFromMask(selection_color_primary);
- file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard);
- file.editor.temporary_layer.setColorFromMask(selection_color_secondary);
- } else {
- var default: bool = true;
-
- if (me.mod.matchBind("shift")) {
- default = false;
- selection_color_primary_stroke = selection_color_primary_stroke.lerp(dvui.themeGet().color(.err, .fill), 0.7);
- selection_color_primary_stroke.a = selection_alpha_stroke;
- selection_color_secondary_stroke = selection_color_secondary_stroke.lerp(dvui.themeGet().color(.err, .fill), 0.7);
- selection_color_secondary_stroke.a = selection_alpha_stroke;
- } else if (me.mod.matchBind("ctrl/cmd")) {
- default = false;
- selection_color_primary_stroke = selection_color_primary_stroke.lerp(dvui.themeGet().color(.highlight, .fill), 0.7);
- selection_color_primary_stroke.a = selection_alpha_stroke;
- selection_color_secondary_stroke = selection_color_secondary_stroke.lerp(dvui.themeGet().color(.highlight, .fill), 0.7);
- selection_color_secondary_stroke.a = selection_alpha_stroke;
- }
-
- // Draw the point at the stroke size to the temporary layer mask only
- file.drawPoint(
- current_point,
- .temporary,
- .{
- .mask_only = true,
- .stroke_size = fizzy.editor.tools.stroke_size,
- },
- );
-
- // Only show stroke over relevant pixels to make selection clearer
- if (me.mod.matchBind("shift")) {
- file.editor.temporary_layer.mask.setIntersection(file.editor.selection_layer.mask);
- } else if (me.mod.matchBind("ctrl/cmd")) {
- var copy_mask = file.editor.selection_layer.mask.clone(dvui.currentWindow().arena()) catch {
- dvui.log.err("Failed to clone selection layer mask", .{});
- return;
- };
- copy_mask.toggleAll();
- file.editor.temporary_layer.mask.setIntersection(copy_mask);
- }
-
- // Intersect with the active layer mask so the stroke is confined to only non-transparent pixels
- file.editor.temporary_layer.mask.setIntersection(active_layer.mask);
- file.editor.temporary_layer.setColorFromMask(selection_color_primary_stroke);
-
- // Intersect with the checkerboard mask so we can show the pattern
- file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard);
- file.editor.temporary_layer.setColorFromMask(if (default) selection_color_secondary_stroke else selection_color_primary_stroke);
- }
- }
- }
-
- if (me.action == .press and me.button.pointer()) {
- if (!widget_active) continue;
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
-
- if (fizzy.editor.tools.selection_mode == .color) {
- // Only clear the mask if we don't have ctrl/cmd pressed
- if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift"))
- file.editor.selection_layer.clearMask();
-
- file.selectColorFloodFromPoint(current_point, !me.mod.matchBind("shift")) catch {
- dvui.log.err("Color selection flood failed", .{});
- };
- continue;
- }
-
- dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num);
- dvui.dragPreStart(me.p, .{ .name = "stroke_drag" });
-
- // Only clear the mask if we don't have ctrl/cmd pressed
- if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift"))
- file.editor.selection_layer.clearMask();
-
- if (fizzy.editor.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,
- },
- );
-
- self.drag_data_point = current_point;
- }
- } else if (me.action == .release and me.button.pointer()) {
- if (!widget_active) continue;
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
-
- if (fizzy.editor.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,
- },
- );
- }
- } else if (fizzy.editor.tools.selection_mode != .color) {
- file.selectPoint(
- current_point,
- .{
- .value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.editor.tools.stroke_size,
- },
- );
- }
-
- self.drag_data_point = null;
- }
- } else if (me.action == .position or me.action == .wheel_x or me.action == .wheel_y) {
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- if (!widget_active) continue;
- if (dvui.dragging(me.p, "stroke_drag")) |_| {
- if (self.drag_data_point) |previous_point| {
- // Construct a rect spanning between current_point and previous_point
- const min_x = @min(previous_point.x, current_point.x);
- const min_y = @min(previous_point.y, current_point.y);
- const max_x = @max(previous_point.x, current_point.x);
- const max_y = @max(previous_point.y, current_point.y);
- const span_rect = dvui.Rect{
- .x = min_x,
- .y = min_y,
- .w = max_x - min_x + 1,
- .h = max_y - min_y + 1,
- };
-
- const screen_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect);
-
- dvui.scrollDrag(.{
- .mouse_pt = me.p,
- .screen_rect = screen_rect,
- });
- }
-
- if (fizzy.editor.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,
- },
- );
- }
-
- self.drag_data_point = current_point;
- }
- }
- }
- }
- },
- else => {},
- }
- }
-
- if (fizzy.editor.tools.selection_mode == .box) {
- const mouse_pt = dvui.currentWindow().mouse_pt;
- const is_drag = dvui.dragging(mouse_pt, "stroke_drag") != null;
- if (!(is_drag and self.drag_data_point != null)) {
- @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 });
- file.editor.temporary_layer.clearMask();
-
- file.editor.temporary_layer.mask.setUnion(file.editor.selection_layer.mask);
- file.editor.temporary_layer.mask.setIntersection(active_layer.mask);
-
- file.editor.temporary_layer.setColorFromMask(selection_color_primary);
- file.editor.temporary_layer.mask.setIntersection(file.editor.checkerboard);
- file.editor.temporary_layer.setColorFromMask(selection_color_secondary);
- file.editor.temp_layer_generation +%= 1;
- }
- }
-
- file.editor.temp_layer_has_content = true;
-}
-
-fn processStrokeDragSegment(
- self: *FileWidget,
- file: *fizzy.Internal.File,
- previous_point: dvui.Point,
- current_point: dvui.Point,
- screen_pt: dvui.Point.Physical,
- color: [4]u8,
- stroke_size: u8,
- shift: bool,
-) void {
- const min_x = @min(previous_point.x, current_point.x);
- const min_y = @min(previous_point.y, current_point.y);
- const max_x = @max(previous_point.x, current_point.x);
- const max_y = @max(previous_point.y, current_point.y);
- const span_rect = dvui.Rect{
- .x = min_x,
- .y = min_y,
- .w = max_x - min_x + 1,
- .h = max_y - min_y + 1,
- };
-
- const screen_rect = self.init_options.file.editor.canvas.screenFromDataRect(span_rect);
- dvui.scrollDrag(.{
- .mouse_pt = screen_pt,
- .screen_rect = screen_rect,
- });
-
- if (shift) {
- // Skip the shift-line temp preview on touch — finger occludes it anyway.
- if (self.init_options.file.editor.canvas.last_input_was_touch) return;
- const preview_clip = tempStrokePreviewClipRect(&self.init_options.file.editor.canvas, file, stroke_size);
- const line_cover = file.lineBrushCoverRect(previous_point, current_point, stroke_size);
- const dirty = dvui.Rect.intersect(line_cover, preview_clip);
- if (!dirty.empty()) {
- file.drawLine(
- previous_point,
- current_point,
- .temporary,
- .{
- .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] },
- .stroke_size = stroke_size,
- .clip_rect = preview_clip,
- },
- );
- file.editor.temp_preview_dirty_rect = dirty;
- file.editor.temp_layer_has_content = true;
- expandTempGpuDirtyRect(&file.editor, dirty);
- }
- return;
- }
-
- if (file.strokeUndoExpandToCoverRect(file.lineBrushCoverRect(previous_point, current_point, stroke_size))) |_| {
- file.drawLine(
- previous_point,
- current_point,
- .selected,
- .{
- .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] },
- .invalidate = true,
- .to_change = false,
- .stroke_size = stroke_size,
- },
- );
- fizzy.perf.draw_event_count += 1;
- } else |err| {
- dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err});
- }
-
- self.drag_data_point = current_point;
-
- // Drag-position brush preview: pointless on touch (finger occludes the pixel and
- // hover == drag), and leaves a phantom that lingers after the stroke ends.
- if (!self.init_options.file.editor.canvas.last_input_was_touch and
- self.init_options.file.editor.canvas.rect.contains(screen_pt) and
- self.sample_data_point == null)
- {
- if (self.sample_data_point == null or color[3] == 0) {
- clearTempPreview(&file.editor);
- const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
- file.drawPoint(
- current_point,
- .temporary,
- .{
- .color = .{ .r = temp_color[0], .g = temp_color[1], .b = temp_color[2], .a = temp_color[3] },
- .stroke_size = stroke_size,
- },
- );
- const brush_rect = tempBrushRect(current_point, stroke_size, file.width(), file.height());
- file.editor.temp_preview_dirty_rect = brush_rect;
- file.editor.temp_layer_has_content = true;
- expandTempGpuDirtyRect(&file.editor, brush_rect);
- }
- }
-}
-
-/// Responsible for processing events to modify pixels on the current layer for strokes of various size
-/// Supports using shift to draw a line between two points, and increasing/decreasing stroke size
-pub fn processStroke(self: *FileWidget) void {
- const file = self.init_options.file;
- const stroke_size = fizzy.editor.tools.stroke_size;
- const widget_active = self.active();
-
- if (self.cell_reorder_point != null) return;
-
- if (switch (fizzy.editor.tools.current) {
- .pencil,
- .eraser,
- => false,
- else => true,
- }) return;
-
- 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,
- .eraser => [_]u8{ 0, 0, 0, 0 },
- else => unreachable,
- };
-
- for (dvui.events()) |*e| {
- if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- switch (e.evt) {
- .mouse => |me| {
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p);
-
- if (me.action == .press and me.button.pointer()) {
- if (!widget_active) continue;
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num);
- dvui.dragPreStart(me.p, .{ .name = "stroke_drag" });
- file.editor.active_drawing = true;
-
- file.buffers.stroke.clearAndFree();
- file.strokeUndoBegin(file.brushStampRect(current_point, stroke_size)) catch |err| {
- dvui.log.err("strokeUndoBegin failed: {}", .{err});
- };
-
- if (!me.mod.matchBind("shift")) {
- file.drawPoint(
- current_point,
- .selected,
- .{
- .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] },
- .invalidate = true,
- .to_change = false,
- .stroke_size = stroke_size,
- },
- );
- }
-
- self.drag_data_point = current_point;
- } else if (me.action == .release and me.button.pointer()) {
- if (!widget_active) continue;
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
-
- // Touch drags do not get `.position` hover updates, so the per-frame temp
- // cleanup in `process()` may not run after lift. Clear any brush preview now.
- resetTempLayerPreview(&file.editor);
-
- if (me.mod.matchBind("shift")) {
- if (self.drag_data_point) |previous_point| {
- if (file.strokeUndoExpandToCoverRect(file.lineBrushCoverRect(previous_point, current_point, stroke_size))) |_| {
- file.drawLine(
- previous_point,
- current_point,
- .selected,
- .{
- .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] },
- .invalidate = true,
- .to_change = true,
- .stroke_size = stroke_size,
- },
- );
- } else |err| {
- dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err});
- }
- }
- } else {
- if (self.drag_data_point) |start| {
- const full_cover = file.lineBrushCoverRect(start, current_point, stroke_size);
- if (file.strokeUndoExpandToCoverRect(full_cover)) |_| {} else |err| {
- dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err});
- }
- }
-
- file.drawPoint(
- current_point,
- .selected,
- .{
- .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] },
- .invalidate = true,
- .to_change = true,
- .stroke_size = stroke_size,
- },
- );
- }
-
- // Redraw without the temp brush overlay; needed when hover stops after touch lift.
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
-
- // End active drawing after committing the release stroke.
- // Reset the composite frame guard so the canvas renderLayers
- // (which runs later this frame) can rebuild the full composite
- // immediately rather than showing a stale pre-drawing composite.
- file.editor.active_drawing = false;
- file.editor.layer_composite_dirty = true;
- file.editor.layer_composite_frame_built = 0;
-
- self.drag_data_point = null;
- }
- } else if (me.action == .motion and me.button.touch()) {
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- if (!widget_active) continue;
- if (dvui.dragging(me.p, "stroke_drag")) |_| {
- if (self.drag_data_point) |previous_point| {
- processStrokeDragSegment(
- self,
- file,
- previous_point,
- current_point,
- me.p,
- color,
- stroke_size,
- me.mod.matchBind("shift"),
- );
- }
- }
- }
- } else if (me.action == .position or me.action == .wheel_x or me.action == .wheel_y) {
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- if (!widget_active) continue;
- if (dvui.dragging(me.p, "stroke_drag")) |_| {
- if (self.drag_data_point) |previous_point| {
- processStrokeDragSegment(
- self,
- file,
- previous_point,
- current_point,
- me.p,
- color,
- stroke_size,
- me.mod.matchBind("shift"),
- );
- }
- }
- } else {
- // Hover (cursor-follow) brush preview — mouse only. Touch input
- // has no hover state, and the finger covers the pixel anyway.
- if (!self.init_options.file.editor.canvas.last_input_was_touch and
- self.init_options.file.editor.canvas.rect.contains(me.p) and
- self.sample_data_point == null)
- {
- clearTempPreview(&file.editor);
- const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
- file.drawPoint(
- current_point,
- .temporary,
- .{
- .stroke_size = stroke_size,
- .color = .{ .r = temp_color[0], .g = temp_color[1], .b = temp_color[2], .a = temp_color[3] },
- },
- );
- const brush_rect = tempBrushRect(current_point, stroke_size, file.width(), file.height());
- file.editor.temp_preview_dirty_rect = brush_rect;
- file.editor.temp_layer_has_content = true;
- expandTempGpuDirtyRect(&file.editor, brush_rect);
- }
- }
- }
- },
- else => {},
- }
- }
-}
-
-/// Responsible for processing events to fill pixels on the current layer with a solid color.
-/// Supports using ctrl/cmd to replace all existing pixels of the same color with the new color,
-/// or without modifiers to flood fill the layer with the new color.
-pub fn processFill(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .bucket) return;
- if (self.sample_key_down) return;
- const file = self.init_options.file;
- const color = fizzy.editor.colors.primary;
- const widget_active = self.active();
-
- // Skip the cursor-follow temp preview on touch: the finger occludes the pixel and
- // hover == drag, so the preview adds nothing but a phantom that lingers after lift.
- if (!self.init_options.file.editor.canvas.last_input_was_touch and
- self.init_options.file.editor.canvas.rect.contains(dvui.currentWindow().mouse_pt) and
- self.sample_data_point == null)
- {
- clearTempPreview(&file.editor);
- const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
- const fill_preview_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
- file.drawPoint(
- fill_preview_pt,
- .temporary,
- .{
- .stroke_size = 1,
- .color = .{ .r = temp_color[0], .g = temp_color[1], .b = temp_color[2], .a = temp_color[3] },
- },
- );
- const brush_rect = tempBrushRect(fill_preview_pt, 1, file.width(), file.height());
- file.editor.temp_preview_dirty_rect = brush_rect;
- file.editor.temp_layer_has_content = true;
- expandTempGpuDirtyRect(&file.editor, brush_rect);
- }
-
- for (dvui.events()) |*e| {
- if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- switch (e.evt) {
- .mouse => |me| {
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p);
-
- if (me.action == .press and me.button.pointer()) {
- if (!widget_active) continue;
- file.fillPoint(current_point, .selected, .{
- .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] },
- .invalidate = true,
- .to_change = true,
- .replace = me.mod.matchBind("ctrl/cmd"),
- });
- }
- },
- else => {},
- }
- }
-}
-
-/// Responsible for processing events to create/modify a transform. A transform is basically a quad with controls on each corner, and
-/// allows moving, rotating, skewing and scaling the quad. The controls also include a pivot point for the rotation.
-pub fn processTransform(self: *FileWidget) void {
- const file = self.init_options.file;
- const image_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) });
- const image_rect_physical = dvui.Rect.Physical.fromSize(.{ .w = image_rect.w, .h = image_rect.h });
-
- if (file.editor.transform) |*transform| {
-
- // Data path is necessary to build and fill with convex triangles, which will be how we render to the target texture
- var data_path: dvui.Path.Builder = .init(dvui.currentWindow().arena());
- for (transform.data_points[0..4]) |*point| {
- data_path.addPoint(.{ .x = point.x, .y = point.y });
- }
-
- // Calculate the centroid of the four corner points
- const centroid = transform.centroid();
-
- var triangle_opts: ?dvui.Triangles = data_path.build().fillConvexTriangles(dvui.currentWindow().arena(), .{
- .center = .{ .x = centroid.x, .y = centroid.y },
- .color = .white,
- }) catch null;
-
- { // Update the rotate point to locate towards the mouse
- const diff = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt).diff(transform.point(.pivot).*);
- transform.point(.rotate).* = transform.point(.pivot).plus(diff.normalize().scale(transform.radius, dvui.Point));
- }
-
- if (triangle_opts) |*triangles| {
- // First, we rotate the triangles to match the angle
- triangles.rotate(.{ .x = transform.point(.pivot).x, .y = transform.point(.pivot).y }, transform.rotation);
-
- for (transform.data_points[0..6], 0..) |*data_point, point_index| {
- const transform_point = @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index));
- const screen_point = if (point_index < 4) file.editor.canvas.screenFromDataPoint(.{ .x = triangles.vertexes[point_index].pos.x, .y = triangles.vertexes[point_index].pos.y }) else file.editor.canvas.screenFromDataPoint(data_point.*);
-
- var screen_rect = dvui.Rect.Physical.fromPoint(screen_point);
- screen_rect.w = 16 * dvui.currentWindow().natural_scale;
- screen_rect.h = 16 * dvui.currentWindow().natural_scale;
- screen_rect.x -= screen_rect.w / 2;
- screen_rect.y -= screen_rect.h / 2;
-
- for (dvui.events()) |*e| {
- if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- if (screen_rect.contains(dvui.currentWindow().mouse_pt)) {
- dvui.cursorSet(.hand);
- } else if (transform.active_point) |active_point| {
- if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) {
- dvui.cursorSet(.hand);
- }
- }
-
- switch (e.evt) {
- .key => |ke| {
- if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("up")) {
- transform.move(.{ .x = 0, .y = -1 });
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- }
- if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("down")) {
- transform.move(.{ .x = 0, .y = 1 });
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- }
- if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("left")) {
- transform.move(.{ .x = -1, .y = 0 });
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- }
- if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("right")) {
- transform.move(.{ .x = 1, .y = 0 });
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- }
- },
- .mouse => |me| {
- const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p);
-
- if (me.action == .press and me.button.pointer()) {
- if (screen_rect.contains(me.p)) {
- transform.active_point = @enumFromInt(point_index);
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num);
- dvui.dragPreStart(me.p, .{ .name = "transform_vertex_drag" });
- self.drag_data_point = current_point;
- transform.start_rotation = transform.rotation;
- if (point_index < 4) {
- const oi: usize = switch (point_index) {
- 0 => 2,
- 1 => 3,
- 2 => 0,
- 3 => 1,
- else => unreachable,
- };
- const opp = transform.data_points[oi];
- const cur = transform.data_points[point_index];
- self.transform_aspect_w = @abs(cur.x - opp.x);
- self.transform_aspect_h = @abs(cur.y - opp.y);
- }
- }
- } else if (me.action == .release and me.button.pointer()) {
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- if (transform.active_point) |active_point| {
- if (active_point == .pivot and transform.dragging == false) {
- transform.point(.pivot).* = transform.centroid();
- transform.updateRadius();
- }
- }
-
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
- transform.active_point = null;
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- self.drag_data_point = null;
- self.transform_aspect_w = null;
- self.transform_aspect_h = null;
- transform.dragging = false;
- }
- } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) {
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- if (dvui.dragging(me.p, "transform_vertex_drag")) |_| {
- transform.dragging = true;
- if (transform.active_point) |active_point| {
- if (@intFromEnum(active_point) == point_index) {
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
-
- // Set this state in advance so we can use it for the radius calculation
- transform.track_pivot = active_point == .pivot;
-
- // This is the new data point of the dragged point
- var new_point = file.editor.canvas.dataFromScreenPoint(me.p);
-
- // Calculate the radius of the transform no matter what point is changing
- defer transform.updateRadius();
-
- if (point_index < 4) {
- // Only round the corner points
- new_point.x = @round(new_point.x);
- new_point.y = @round(new_point.y);
-
- // Now we have to un-rotate the vertex and set the original location
- new_point = fizzy.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation);
-
- const opposite_index: usize = switch (point_index) {
- 0 => 2,
- 1 => 3,
- 2 => 0,
- 3 => 1,
- else => unreachable,
- };
-
- // ctrl/cmd: free skew. shift: axis-aligned rect (old default). no mod: same rect + locked aspect vs opposite corner.
- if (me.mod.matchBind("ctrl/cmd")) {
- data_point.* = new_point;
- transform.ortho = false;
- } else {
- transform.ortho = true;
- if (me.mod.matchBind("shift")) {
- data_point.* = new_point;
- } else {
- const opp = transform.data_points[opposite_index];
- var constrained = new_point;
- if (self.transform_aspect_w) |aw| {
- if (self.transform_aspect_h) |ah| {
- if (aw > 1e-4 and ah > 1e-4) {
- const mx = new_point.x - opp.x;
- const my = new_point.y - opp.y;
- const ax = @abs(mx);
- const ay = @abs(my);
- const den = aw * aw + ah * ah;
- if (den > 1e-8) {
- const t = (aw * ax + ah * ay) / den;
- constrained.x = @round(opp.x + math.copysign(aw * t, mx));
- constrained.y = @round(opp.y + math.copysign(ah * t, my));
- }
- }
- }
- }
- data_point.* = constrained;
- }
-
- blk_vert: {
- // Find adjacent verts
- const adjacent_index_cw = if (point_index < 3) point_index + 1 else 0;
- const adjacent_index_ccw = if (point_index > 0) point_index - 1 else 3;
-
- // Get the adjacent points
- const adjacent_point_cw = &transform.data_points[adjacent_index_cw];
- const adjacent_point_ccw = &transform.data_points[adjacent_index_ccw];
-
- const opposite_point = &transform.data_points[opposite_index];
-
- var rotation_direction: dvui.Point = 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);
-
- // Calculate the difference between the adjacent points and the new point
-
- { // Calculate intersection point to set adjacent vert
- const as = data_point.*;
- const bs = opposite_point.*;
- const ad = rotation_direction.scale(-1.0, dvui.Point);
- const bd = rotation_perp;
- const dx = bs.x - as.x;
- const dy = bs.y - as.y;
- const det = bd.x * ad.y - bd.y * ad.x;
- if (det == 0.0) break :blk_vert;
- const u = (dy * bd.x - dx * bd.y) / det;
- switch (point_index) {
- 0, 2 => adjacent_point_cw.* = as.plus(ad.scale(u, dvui.Point)),
- 1, 3 => adjacent_point_ccw.* = as.plus(ad.scale(u, dvui.Point)),
- else => unreachable,
- }
- }
-
- { // Calculate intersection point to set adjacent vert
- const as = data_point.*;
- const bs = opposite_point.*;
- const ad = rotation_perp.scale(-1.0, dvui.Point);
- const bd = rotation_direction;
- const dx = bs.x - as.x;
- const dy = bs.y - as.y;
- const det = bd.x * ad.y - bd.y * ad.x;
- if (det == 0.0) break :blk_vert;
- const u = (dy * bd.x - dx * bd.y) / det;
- switch (point_index) {
- 0, 2 => adjacent_point_ccw.* = as.plus(ad.scale(u, dvui.Point)),
- 1, 3 => adjacent_point_cw.* = as.plus(ad.scale(u, dvui.Point)),
- else => unreachable,
- }
- }
- }
- }
- }
- if (active_point == .pivot) {
- data_point.* = new_point;
- }
- if (transform_point == .rotate) {
- if (self.drag_data_point) |drag_data_point| {
- const drag_diff = drag_data_point.diff(transform.point(.pivot).*);
- const drag_angle = std.math.atan2(drag_diff.y, drag_diff.x);
-
- const diff = new_point.diff(transform.point(.pivot).*);
- const angle = std.math.atan2(diff.y, diff.x);
-
- transform.rotation = std.math.degreesToRadians(@round(std.math.radiansToDegrees(transform.start_rotation + (angle - drag_angle))));
-
- if (me.mod.matchBind("ctrl/cmd")) { // Lock rotation to cardinal directions
- const direction = fizzy.math.Direction.fromRadians(transform.rotation);
- transform.rotation = switch (direction) {
- .n => std.math.pi / 2.0,
- .ne => std.math.pi / 4.0,
- .e => 0,
- .s => (3.0 * std.math.pi) / 2.0,
- .nw => (3.0 * std.math.pi) / 4.0,
- .w => std.math.pi,
- .sw => (5.0 * std.math.pi) / 4.0,
- .se => (7.0 * std.math.pi) / 4.0,
- else => unreachable,
- };
- }
- }
- }
- }
- }
- }
- }
- }
- },
- else => {},
- }
- }
- }
-
- // Now if we havent selected any of the points, we need to handle dragging the interior of the polygon
- // to move the entire transform
- if (transform.active_point == null) {
- for (dvui.events()) |*e| {
- if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- var is_hovered: bool = false;
-
- if (transform.hovered(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) {
- dvui.cursorSet(.hand);
- is_hovered = true;
- }
-
- switch (e.evt) {
- .mouse => |me| {
- if (me.action == .press and me.button.pointer()) {
- //if (is_hovered or me.mod.matchBind("ctrl/cmd")) {
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), e.num);
- dvui.dragPreStart(me.p, .{ .name = "transform_drag" });
- //}
- } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) {
- if (dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- if (dvui.dragging(me.p, "transform_drag")) |_| {
- dvui.cursorSet(.hand);
- transform.dragging = true;
- e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
-
- var prev_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt_prev);
- prev_point.x = @round(prev_point.x);
- prev_point.y = @round(prev_point.y);
- var new_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
- new_point.x = @round(new_point.x);
- new_point.y = @round(new_point.y);
-
- transform.move(new_point.diff(prev_point));
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.scroll_container.data().id);
- }
- }
- }
- },
- else => {},
- }
- }
- }
-
- // Here pass in the data rect, since we will be rendering directly to the low-res texture
-
- transform.target_texture.clear();
- const previous_target = dvui.renderTarget(.{ .texture = transform.target_texture, .offset = image_rect_physical.topLeft() });
-
- // Make sure we clip to the image rect, if we don't and the texture overlaps the canvas,
- // the rendering will be clipped incorrectly
- // Use clipSet instead of clip, clip unions with current clip
- const clip_rect = image_rect_physical;
- const prev_clip = dvui.clipGet();
- dvui.clipSet(clip_rect);
-
- // Set UVs, there are 5 vertexes, or 1 more than the number of triangles, and is at the center
- triangles.vertexes[0].uv = .{ 0.0, 0.0 }; // TL
- triangles.vertexes[1].uv = .{ 1.0, 0.0 }; // TR
- triangles.vertexes[2].uv = .{ 1.0, 1.0 }; // BR
- triangles.vertexes[3].uv = .{ 0.0, 1.0 }; // BL
- triangles.vertexes[4].uv = .{ 0.5, 0.5 }; // C
-
- dvui.renderTriangles(triangles.*, transform.source.getTexture() catch null) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
-
- // Restore the previous clip
- dvui.clipSet(prev_clip);
- // Set the target back
- _ = dvui.renderTarget(previous_target);
-
- // Read the target texture and copy it to the selection layer
- // This is currently very slow, and is a bottleneck for the editor
- // TODO: look into how to draw the target texture without needing to read the target back
- // if (dvui.textureReadTarget(dvui.currentWindow().arena(), transform.target_texture) catch null) |image_data| {
- // @memcpy(file.editor.temporary_layer.bytes(), @as([*]u8, @ptrCast(image_data.ptr)));
- // file.editor.temporary_layer.invalidate();
- // } else {
- // dvui.log.err("Failed to read target", .{});
- // }
- } else {
- dvui.log.err("Failed to fill triangles", .{});
- }
- }
-}
-
-/// Responsible for drawing the transform guides and controls for the current transform after processing.
-/// Includes guides for the sprite size and angle in appropriately scaled text labels.
-pub fn drawTransform(self: *FileWidget) void {
- const file = self.init_options.file;
-
- if (file.editor.transform) |*transform| {
- const show_ortho_dims = transform.ortho and blk: {
- if (transform.active_point) |ap| {
- break :blk @intFromEnum(ap) < 4;
- }
- break :blk transform.dragging;
- };
- const dim_cell_opt: ?usize = if (show_ortho_dims) file.spriteIndex(transform.centroid()) else null;
-
- var path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- for (transform.data_points[0..4]) |*point| {
- const screen_point = file.editor.canvas.screenFromDataPoint(point.*);
- path.addPoint(screen_point);
- }
-
- var centroid = transform.centroid();
- centroid = fizzy.math.rotate(centroid, transform.point(.pivot).*, transform.rotation);
-
- // Full-sprite center guides (magenta). When ortho cell dimensions are shown, centering is
- // indicated on those dimension lines (blue) instead — avoids overlapping magenta guides.
- if (dim_cell_opt == null) {
- if (file.spriteIndex(centroid)) |sprite_index| {
- const sprite_rect = file.spriteRect(sprite_index);
- const sprite_center = sprite_rect.center();
-
- const sprite_diff = sprite_center.diff(centroid);
-
- if (@floor(sprite_diff.x) == 0) {
- const point_1: dvui.Point = .{ .x = sprite_center.x, .y = sprite_rect.topLeft().y };
- const point_2: dvui.Point = .{ .x = sprite_center.x, .y = sprite_rect.bottomRight().y };
-
- dvui.Path.stroke(.{ .points = &.{
- file.editor.canvas.screenFromDataPoint(point_1),
- file.editor.canvas.screenFromDataPoint(point_2),
- } }, .{ .thickness = 1, .color = .magenta });
- }
-
- if (@floor(sprite_diff.y) == 0) {
- const point_1: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = sprite_center.y };
- const point_2: dvui.Point = .{ .x = sprite_rect.bottomRight().x, .y = sprite_center.y };
-
- dvui.Path.stroke(.{ .points = &.{
- file.editor.canvas.screenFromDataPoint(point_1),
- file.editor.canvas.screenFromDataPoint(point_2),
- } }, .{ .thickness = 1, .color = .magenta });
- }
- }
- }
-
- {
- const centroid_rect = dvui.Rect.fromPoint(centroid);
- var centroid_screen_rect = file.editor.canvas.screenFromDataRect(centroid_rect);
- centroid_screen_rect.w = 8 * dvui.currentWindow().natural_scale;
- centroid_screen_rect.h = 8 * dvui.currentWindow().natural_scale;
- centroid_screen_rect.x -= centroid_screen_rect.w / 2;
- centroid_screen_rect.y -= centroid_screen_rect.h / 2;
-
- centroid_screen_rect.fill(dvui.Rect.Physical.all(100000), .{
- .color = dvui.themeGet().color(.control, .fill),
- });
-
- centroid_screen_rect = centroid_screen_rect.insetAll(2 * dvui.currentWindow().natural_scale);
- centroid_screen_rect.fill(dvui.Rect.Physical.all(100000), .{
- .color = dvui.themeGet().color(.window, .text),
- });
- }
-
- if (!show_ortho_dims) {
- { // Draw circular outline for the rotation path
- var rotate_path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- var outline_rect = dvui.Rect.fromSize(.{ .w = transform.radius * 2, .h = transform.radius * 2 });
-
- outline_rect.x = transform.point(.pivot).x - transform.radius;
- outline_rect.y = transform.point(.pivot).y - transform.radius;
- const outline_screen_rect = file.editor.canvas.screenFromDataRect(outline_rect);
-
- rotate_path.addRect(outline_screen_rect, dvui.Rect.Physical.all(100000));
- rotate_path.build().stroke(.{
- .thickness = 4 * dvui.currentWindow().natural_scale,
- .color = dvui.themeGet().color(.control, .fill),
- .closed = true,
- .endcap_style = .square,
- });
- rotate_path.build().stroke(.{
- .thickness = 2,
- .color = dvui.themeGet().color(.window, .text),
- .closed = true,
- .endcap_style = .square,
- });
- }
-
- if (transform.active_point) |active_point| {
- if (active_point == .rotate) {
- // Draw the arms of the rotation
- if (self.drag_data_point) |drag_data_point| {
- const diff = drag_data_point.diff(transform.point(.pivot).*);
-
- // Start angle
- doubleStroke(&.{
- file.editor.canvas.screenFromDataPoint(transform.point(.pivot).*),
- file.editor.canvas.screenFromDataPoint(transform.point(.pivot).plus(diff.normalize().scale(transform.radius, dvui.Point))),
- }, dvui.themeGet().color(.control, .text), 2);
-
- // New angle
- doubleStroke(&.{
- file.editor.canvas.screenFromDataPoint(transform.point(.pivot).*),
- file.editor.canvas.screenFromDataPoint(transform.point(.rotate).*),
- }, dvui.themeGet().color(.control, .text), 2);
- }
- }
- }
- }
-
- var triangles_opt = path.build().fillConvexTriangles(dvui.currentWindow().arena(), .{
- .center = .{ .x = centroid.x, .y = centroid.y },
- .color = .white,
- }) catch null;
-
- if (triangles_opt) |*triangles| {
- triangles.rotate(file.editor.canvas.screenFromDataPoint(transform.point(.pivot).*), transform.rotation);
-
- { // Draw the outline of the triangles
- const is_hovered = transform.hovered(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt));
- var outline_path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- for (triangles.vertexes[0..4]) |*vertex| {
- outline_path.addPoint(.{ .x = vertex.pos.x, .y = vertex.pos.y });
- }
-
- outline_path.build().stroke(.{
- .thickness = 4 * dvui.currentWindow().natural_scale,
- .color = dvui.themeGet().color(.control, .fill),
- .closed = true,
- .endcap_style = .square,
- });
- outline_path.build().stroke(.{
- .thickness = 2,
- .color = if ((is_hovered and transform.active_point == null) or transform.dragging) dvui.themeGet().color(.highlight, .fill) else dvui.themeGet().color(.window, .text),
- .closed = true,
- .endcap_style = .square,
- });
- }
-
- // Dimensions and angle labels
- {
- const dim_font = dvui.Font.theme(.mono).larger(-2);
-
- if (show_ortho_dims) {
- const ns = dvui.currentWindow().natural_scale;
- const canvas = &file.editor.canvas;
- const px_per_data_x = blk: {
- const a = canvas.screenFromDataPoint(.{ .x = 0, .y = 0 });
- const b = canvas.screenFromDataPoint(.{ .x = 1, .y = 0 });
- break :blk @max(@abs(b.x - a.x), 0.001);
- };
- const px_per_data_y = blk: {
- const a = canvas.screenFromDataPoint(.{ .x = 0, .y = 0 });
- const b = canvas.screenFromDataPoint(.{ .x = 0, .y = 1 });
- break :blk @max(@abs(b.y - a.y), 0.001);
- };
- const tick_half_px = 2.9 * ns;
- const label_off_screen = 9 * ns;
-
- const tl_d = transform.data_points[0];
- const tr_d = transform.data_points[1];
- const br_d = transform.data_points[2];
- const bl_d = transform.data_points[3];
- const bbox_min_x = @min(@min(tl_d.x, tr_d.x), @min(bl_d.x, br_d.x));
- const bbox_max_x = @max(@max(tl_d.x, tr_d.x), @max(bl_d.x, br_d.x));
- const bbox_min_y = @min(@min(tl_d.y, tr_d.y), @min(bl_d.y, br_d.y));
- const bbox_max_y = @max(@max(tl_d.y, tr_d.y), @max(bl_d.y, br_d.y));
-
- const cell_cap_x: f32 = if (dim_cell_opt) |ci| file.spriteRect(ci).w else bbox_max_x - bbox_min_x;
- const cell_cap_y: f32 = if (dim_cell_opt) |ci| file.spriteRect(ci).h else bbox_max_y - bbox_min_y;
- const arm_x_data = @max(0.2, @min(tick_half_px / px_per_data_x, cell_cap_x * 0.11));
- const arm_y_data = @max(0.2, @min(tick_half_px / px_per_data_y, cell_cap_y * 0.11));
- const dim_tick_thick: f32 = 0.65;
-
- const x_c = (bbox_min_x + bbox_max_x) * 0.5;
- const y_c = (bbox_min_y + bbox_max_y) * 0.5;
-
- if (dim_cell_opt) |ci| {
- const cell = file.spriteRect(ci);
- const cell_left = cell.x;
- const cell_right = cell.x + cell.w;
- const cell_top = cell.y;
- const cell_bot = cell.y + cell.h;
- const arena = dvui.currentWindow().arena();
- const sprite_c = cell.center();
- const sd = sprite_c.diff(centroid);
- const dim_inner_h: dvui.Color = if (@floor(sd.x) == 0) .blue else .magenta;
- const dim_inner_v: dvui.Color = if (@floor(sd.y) == 0) .blue else .magenta;
-
- // Left: edge midpoint (bbox left, vertical center) → cell left; label near line.
- {
- const span = bbox_min_x - cell_left;
- if (@abs(span) > 0.001) {
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = cell_left, .y = y_c }),
- canvas.screenFromDataPoint(.{ .x = bbox_min_x, .y = y_c }),
- }, 1, dim_inner_h);
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = cell_left, .y = y_c - arm_y_data }),
- canvas.screenFromDataPoint(.{ .x = cell_left, .y = y_c + arm_y_data }),
- }, dim_tick_thick, dim_inner_h);
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = bbox_min_x, .y = y_c - arm_y_data }),
- canvas.screenFromDataPoint(.{ .x = bbox_min_x, .y = y_c + arm_y_data }),
- }, dim_tick_thick, dim_inner_h);
- const t = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(span)))}) catch "—";
- var lp = canvas.screenFromDataPoint(.{ .x = (cell_left + bbox_min_x) * 0.5, .y = y_c });
- lp.x -= label_off_screen;
- renderTransformDimLabel(dim_font, t, lp);
- }
- }
- // Right: bbox right → cell right
- {
- const span = cell_right - bbox_max_x;
- if (@abs(span) > 0.001) {
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = bbox_max_x, .y = y_c }),
- canvas.screenFromDataPoint(.{ .x = cell_right, .y = y_c }),
- }, 1, dim_inner_h);
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = bbox_max_x, .y = y_c - arm_y_data }),
- canvas.screenFromDataPoint(.{ .x = bbox_max_x, .y = y_c + arm_y_data }),
- }, dim_tick_thick, dim_inner_h);
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = cell_right, .y = y_c - arm_y_data }),
- canvas.screenFromDataPoint(.{ .x = cell_right, .y = y_c + arm_y_data }),
- }, dim_tick_thick, dim_inner_h);
- const t = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(span)))}) catch "—";
- var lp = canvas.screenFromDataPoint(.{ .x = (bbox_max_x + cell_right) * 0.5, .y = y_c });
- lp.x += label_off_screen;
- renderTransformDimLabel(dim_font, t, lp);
- }
- }
- // Top: horizontal center of top edge → cell top
- {
- const span = bbox_min_y - cell_top;
- if (@abs(span) > 0.001) {
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = x_c, .y = cell_top }),
- canvas.screenFromDataPoint(.{ .x = x_c, .y = bbox_min_y }),
- }, 1, dim_inner_v);
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = x_c - arm_x_data, .y = cell_top }),
- canvas.screenFromDataPoint(.{ .x = x_c + arm_x_data, .y = cell_top }),
- }, dim_tick_thick, dim_inner_v);
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = x_c - arm_x_data, .y = bbox_min_y }),
- canvas.screenFromDataPoint(.{ .x = x_c + arm_x_data, .y = bbox_min_y }),
- }, dim_tick_thick, dim_inner_v);
- const t = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(span)))}) catch "—";
- var lp = canvas.screenFromDataPoint(.{ .x = x_c, .y = (cell_top + bbox_min_y) * 0.5 });
- lp.y -= label_off_screen;
- renderTransformDimLabel(dim_font, t, lp);
- }
- }
- // Bottom: bbox bottom → cell bottom
- {
- const span = cell_bot - bbox_max_y;
- if (@abs(span) > 0.001) {
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = x_c, .y = bbox_max_y }),
- canvas.screenFromDataPoint(.{ .x = x_c, .y = cell_bot }),
- }, 1, dim_inner_v);
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = x_c - arm_x_data, .y = bbox_max_y }),
- canvas.screenFromDataPoint(.{ .x = x_c + arm_x_data, .y = bbox_max_y }),
- }, dim_tick_thick, dim_inner_v);
- doubleStrokeDimensionTickColor(&.{
- canvas.screenFromDataPoint(.{ .x = x_c - arm_x_data, .y = cell_bot }),
- canvas.screenFromDataPoint(.{ .x = x_c + arm_x_data, .y = cell_bot }),
- }, dim_tick_thick, dim_inner_v);
- const t = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(span)))}) catch "—";
- var lp = canvas.screenFromDataPoint(.{ .x = x_c, .y = (bbox_max_y + cell_bot) * 0.5 });
- lp.y += label_off_screen;
- renderTransformDimLabel(dim_font, t, lp);
- }
- }
-
- // Transform width (bottom edge) and height (left edge): labels only, no dimension lines.
- {
- const top_left_v = triangles.vertexes[0].pos;
- const bottom_left_v = triangles.vertexes[3].pos;
- const bottom_right_v = triangles.vertexes[2].pos;
-
- const offset_v = fizzy.math.rotate(
- dvui.Point{ .x = label_off_screen, .y = 0 },
- .{ .x = 0, .y = 0 },
- transform.rotation,
- );
- const off_v: dvui.Point.Physical = .{ .x = offset_v.x, .y = offset_v.y };
-
- const center_v = top_left_v.plus(bottom_left_v).scale(0.5, dvui.Point.Physical);
- const inner_h_f = transform.data_points[0].diff(transform.data_points[3]).length();
- const simple_v = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(inner_h_f)))}) catch "—";
- renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v));
-
- const offset_h = fizzy.math.rotate(
- dvui.Point{ .x = 0, .y = -label_off_screen },
- .{ .x = 0, .y = 0 },
- transform.rotation,
- );
- const off_h: dvui.Point.Physical = .{ .x = offset_h.x, .y = offset_h.y };
-
- const center_h = bottom_right_v.plus(bottom_left_v).scale(0.5, dvui.Point.Physical);
- const inner_w_f = transform.data_points[3].diff(transform.data_points[2]).length();
- const simple_h = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(inner_w_f)))}) catch "—";
- renderTransformDimLabel(dim_font, simple_h, center_h.plus(off_h));
- }
- } else {
- const top_left = triangles.vertexes[0].pos;
- const bottom_left = triangles.vertexes[3].pos;
- const bottom_right = triangles.vertexes[2].pos;
-
- const offset_v = fizzy.math.rotate(
- dvui.Point{ .x = label_off_screen, .y = 0 },
- .{ .x = 0, .y = 0 },
- transform.rotation,
- );
- const off_v: dvui.Point.Physical = .{ .x = offset_v.x, .y = offset_v.y };
-
- const center_v = top_left.plus(bottom_left).scale(0.5, dvui.Point.Physical);
- const inner_h_f = transform.data_points[0].diff(transform.data_points[3]).length();
- const simple_v = std.fmt.allocPrint(
- dvui.currentWindow().arena(),
- "{d}",
- .{@as(i32, @intFromFloat(@round(inner_h_f)))},
- ) catch "—";
- renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v));
-
- const offset_h = fizzy.math.rotate(
- dvui.Point{ .x = 0, .y = -label_off_screen },
- .{ .x = 0, .y = 0 },
- transform.rotation,
- );
- const off_h: dvui.Point.Physical = .{ .x = offset_h.x, .y = offset_h.y };
-
- const center_h = bottom_right.plus(bottom_left).scale(0.5, dvui.Point.Physical);
- const inner_w_f = transform.data_points[3].diff(transform.data_points[2]).length();
- const simple_h = std.fmt.allocPrint(
- dvui.currentWindow().arena(),
- "{d}",
- .{@as(i32, @intFromFloat(@round(inner_w_f)))},
- ) catch "—";
- renderTransformDimLabel(dim_font, simple_h, center_h.plus(off_h));
- }
- }
-
- if (transform.active_point == .rotate and !show_ortho_dims) {
- // Draw a stroke from transform.point(.rotate).* to the point on the circle at the midpoint of the rotation arc,
- // but if the arc is > 180 degrees, the midpoint angle needs to be flipped 180 degrees.
- const pivot = transform.point(.pivot).*;
- const radius = transform.radius;
-
- // Find the angle of the start (drag) and end (current) rotation arms
- const start_angle = blk: {
- if (self.drag_data_point) |drag_data_point| {
- const drag_diff = drag_data_point.diff(pivot);
- break :blk std.math.atan2(drag_diff.y, drag_diff.x);
- } else {
- // Fallback: use current rotation
- break :blk std.math.atan2(transform.point(.rotate).y - pivot.y, transform.point(.rotate).x - pivot.x);
- }
- };
-
- // Compute the shortest arc between start and end
- var delta_angle = transform.rotation - transform.start_rotation;
- // Normalize to [-pi, pi]
- if (delta_angle > std.math.pi) {
- delta_angle -= 2.0 * std.math.pi;
- } else if (delta_angle < -std.math.pi) {
- delta_angle += 2.0 * std.math.pi;
- }
-
- // The midpoint angle along the arc
- var mid_angle = start_angle + delta_angle / 2.0;
-
- // If the arc is more than 180 degrees, flip the midpoint angle by 180 degrees
- if (delta_angle < 0) {
- mid_angle += std.math.pi;
- }
-
- // Calculate the point on the circle at the midpoint angle
- const center = file.editor.canvas.screenFromDataPoint(pivot.plus(.{
- .x = radius * (1.0 + 0.075 * dvui.currentWindow().natural_scale) * std.math.cos(mid_angle),
- .y = radius * (1.0 + 0.075 * dvui.currentWindow().natural_scale) * std.math.sin(mid_angle),
- }));
-
- var degrees = std.math.radiansToDegrees(delta_angle);
- if (degrees < 0) degrees += 360.0;
-
- const angle_text = std.fmt.allocPrint(
- dvui.currentWindow().arena(),
- "{d}°",
- .{@as(i32, @intFromFloat(@round(degrees)))},
- ) catch "—";
-
- renderTransformDimLabel(dim_font, angle_text, center);
- }
- }
-
- for (transform.data_points[0..6], 0..) |*point, point_index| {
- if (show_ortho_dims and point_index == 5) continue;
- if (transform.active_point) |active_point| {
- if (active_point == .pivot) {
- if (point_index == 5) continue; // skip drawing the rotate point if we are dragging the pivot
- }
- }
-
- var screen_point = file.editor.canvas.screenFromDataPoint(point.*);
-
- // Use the triangle points for the corners
- if (point_index < 4)
- screen_point = triangles.vertexes[point_index].pos;
-
- var screen_rect = dvui.Rect.Physical.fromPoint(screen_point);
- screen_rect.w = 16 * dvui.currentWindow().natural_scale;
- screen_rect.h = 16 * dvui.currentWindow().natural_scale;
- screen_rect.x -= screen_rect.w / 2;
- screen_rect.y -= screen_rect.h / 2;
-
- screen_rect.fill(dvui.Rect.Physical.all(100000), .{
- .color = dvui.themeGet().color(.control, .fill),
- });
-
- screen_rect = screen_rect.inset(dvui.Rect.Physical.all(1 * dvui.currentWindow().natural_scale));
-
- var color = dvui.themeGet().color(.window, .text);
-
- if (transform.active_point) |active_point| {
- if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) {
- color = dvui.themeGet().color(.highlight, .fill);
- }
- } else if (screen_rect.contains(dvui.currentWindow().mouse_pt)) {
- color = dvui.themeGet().color(.highlight, .fill);
- }
-
- screen_rect.fill(dvui.Rect.Physical.all(100000), .{
- .color = color,
- });
-
- screen_rect = screen_rect.inset(dvui.Rect.Physical.all(2 * dvui.currentWindow().natural_scale));
- screen_rect.fill(dvui.Rect.Physical.all(100000), .{
- .color = dvui.themeGet().color(.control, .fill),
- });
- }
- }
- }
-}
-
-/// Text size in physical pixels for `renderText` with `.rs.s == render_s` (must stay in sync with
-/// `dvui.renderText` / `Font.textSizeEx` fraction rules).
-fn transformDimTextSizePhysical(font: dvui.Font, text: []const u8, render_s: f32) dvui.Size {
- if (text.len == 0 or render_s == 0) return .{};
- const cw = dvui.currentWindow();
- const target_size = font.size * render_s;
- const sized_font = font.withSize(target_size);
- const fce = dvui.fontCacheGet(sized_font) catch return .{};
- const target_fraction = if (cw.snap_to_pixels) 1.0 else target_size / fce.em_height;
- var opts: dvui.Font.TextSizeOptions = .{};
- opts.kerning = cw.kerning;
- const s = fce.textSizeRaw(cw.gpa, text, opts) catch return .{};
- return s.scale(target_fraction, dvui.Size);
-}
-
-/// Constant on-screen size: render at `natural_scale` only.
-fn renderTransformDimLabel(font: dvui.Font, text: []const u8, center_phys: dvui.Point.Physical) void {
- const ns = dvui.currentWindow().natural_scale;
- const ts = transformDimTextSizePhysical(font, text, ns);
- const pad = 2 * ns;
- const text_rect = dvui.Rect.Physical.rect(
- center_phys.x - ts.w / 2,
- center_phys.y - ts.h / 2,
- ts.w,
- ts.h,
- );
- var outline_rect = text_rect.outsetAll(pad);
- const corner = @min(4 * ns, @min(outline_rect.w, outline_rect.h) * 0.48);
- outline_rect.fill(dvui.Rect.Physical.all(corner), .{
- .color = dvui.themeGet().color(.control, .fill).opacity(0.85),
- });
- dvui.renderText(.{
- .text = text,
- .font = font,
- .color = dvui.themeGet().color(.window, .text),
- .rs = .{ .r = text_rect, .s = ns },
- }) catch {
- dvui.log.err("Failed to render transform dimension label", .{});
- };
-}
-
-fn doubleStroke(points: []const dvui.Point.Physical, color: dvui.Color, thickness: f32) void {
- dvui.Path.stroke(.{
- .points = points,
- }, .{
- .thickness = thickness * 2 * dvui.currentWindow().natural_scale,
- .color = dvui.themeGet().color(.control, .fill),
- });
- dvui.Path.stroke(.{
- .points = points,
- }, .{
- .thickness = thickness,
- .color = color,
- });
-}
-
-/// Double stroke for dimension lines: outer control fill, inner accent color.
-fn doubleStrokeDimensionLike(points: []const dvui.Point.Physical, thickness: f32, inner_thickness: f32, inner_color: dvui.Color) void {
- const ns = dvui.currentWindow().natural_scale;
- dvui.Path.stroke(.{
- .points = points,
- }, .{
- .thickness = thickness * 2 * ns,
- .color = dvui.themeGet().color(.control, .fill),
- });
- dvui.Path.stroke(.{
- .points = points,
- }, .{
- .thickness = inner_thickness,
- .color = inner_color,
- });
-}
-
-fn doubleStrokeDimension(points: []const dvui.Point.Physical, thickness: f32) void {
- doubleStrokeDimensionLike(points, thickness, thickness, .magenta);
-}
-
-/// Tick marks: inner stroke is one physical pixel thicker for visibility.
-fn doubleStrokeDimensionTick(points: []const dvui.Point.Physical, thickness: f32) void {
- doubleStrokeDimensionLike(points, thickness, thickness + 1.0, .magenta);
-}
-
-fn doubleStrokeDimensionTickColor(points: []const dvui.Point.Physical, thickness: f32, inner_color: dvui.Color) void {
- doubleStrokeDimensionLike(points, thickness, thickness + 1.0, inner_color);
-}
-
-/// Batches all grid lines into a single draw call. Each line becomes a thin
-/// axis-aligned quad (4 vertices, 2 triangles) submitted via one `renderTriangles`.
-fn drawBatchedGridLines(
- self: *FileWidget,
- file: *fizzy.Internal.File,
- columns: usize,
- rows: usize,
- grid_color: dvui.Color,
- grid_thickness: f32,
- grid_x0: f32,
- grid_x1: f32,
- grid_y0: f32,
- grid_y1: f32,
- vertical_inner: usize,
-) void {
- const canvas = &self.init_options.file.editor.canvas;
- const half = @max(grid_thickness, 1.0) * 0.5;
-
- const cw = dvui.currentWindow();
- const pma_col: dvui.Color.PMA = .fromColor(grid_color.opacity(cw.alpha));
-
- var max_lines: usize = 0;
- if (vertical_inner > 1) max_lines += vertical_inner - 1;
- if (columns > file.columns) max_lines += columns - file.columns;
- max_lines += file.spriteCount();
- if (columns > file.columns) {
- const row_horiz_end = if (rows > file.rows) file.rows else rows;
- if (row_horiz_end > 1) max_lines += row_horiz_end - 1;
- }
- if (rows > file.rows) max_lines += rows - file.rows;
- if (self.resize_data_point != null) max_lines += 2;
-
- if (max_lines == 0) return;
-
- var builder = dvui.Triangles.Builder.init(cw.arena(), max_lines * 4, max_lines * 6) catch return;
- defer builder.deinit(cw.arena());
-
- const screen_y0 = canvas.screenFromDataPoint(.{ .x = 0, .y = grid_y0 }).y;
- const screen_y1 = canvas.screenFromDataPoint(.{ .x = 0, .y = grid_y1 }).y;
-
- const grid_pan = bubblePanSharedForGrid(self);
-
- // Vertical lines: inner columns
- for (1..vertical_inner) |i| {
- const x = @as(f32, @floatFromInt(i * file.column_width));
- const sx = canvas.screenFromDataPoint(.{ .x = x, .y = 0 }).x;
- appendLineQuad(&builder, .{ .x = sx - half, .y = screen_y0 }, .{ .x = sx + half, .y = screen_y1 }, pma_col);
- }
-
- // Vertical lines: preview columns beyond the sprite grid
- if (columns > file.columns) {
- for (file.columns..columns) |k| {
- const x = @as(f32, @floatFromInt(k * file.column_width));
- const sx = canvas.screenFromDataPoint(.{ .x = x, .y = 0 }).x;
- appendLineQuad(&builder, .{ .x = sx - half, .y = screen_y0 }, .{ .x = sx + half, .y = screen_y1 }, pma_col);
- }
- }
-
- // Horizontal lines: sprite row-top edges (visible rows/columns; coalesce runs without bubbles)
- if (fileCanvasVisibleGridParams(file)) |gp| {
- if (gp.vx1 > 0) {
- var row: usize = @max(1, gp.first_vis_row);
- while (row < gp.last_vis_row) : (row += 1) {
- const row_start = row * gp.cols;
- const row_end = @min(row_start + gp.cols, file.spriteCount());
- if (row_end <= row_start) continue;
-
- const row_span = row_end - row_start;
- var col_lo: usize = 0;
- if (gp.vx0 > 0) col_lo = @intFromFloat(@floor(gp.vx0 / gp.col_w));
- var col_hi_excl: usize = @intFromFloat(@ceil(gp.vx1 / gp.col_w));
- col_lo = @min(col_lo, row_span);
- col_hi_excl = @min(col_hi_excl, row_span);
- appendHorizontalGridRunsForRow(self, &builder, canvas, grid_pan, row, row_start, col_lo, col_hi_excl, gp.col_w, gp.row_h, half, pma_col);
- }
- }
- }
-
- // Horizontal lines: extended strip rows (wider preview than sprite grid)
- if (columns > file.columns) {
- const x_strip = @as(f32, @floatFromInt(file.columns * file.column_width));
- const row_horiz_end = if (rows > file.rows) file.rows else rows;
- for (1..row_horiz_end) |k| {
- const y = @as(f32, @floatFromInt(k * file.row_height));
- const sl = canvas.screenFromDataPoint(.{ .x = x_strip, .y = y });
- const sr = canvas.screenFromDataPoint(.{ .x = grid_x1, .y = y });
- appendLineQuad(&builder, .{ .x = sl.x, .y = sl.y - half }, .{ .x = sr.x, .y = sr.y + half }, pma_col);
- }
- }
-
- // Horizontal lines: preview rows beyond the sprite grid
- if (rows > file.rows) {
- for (file.rows..rows) |k| {
- const y = @as(f32, @floatFromInt(k * file.row_height));
- const sl = canvas.screenFromDataPoint(.{ .x = grid_x0, .y = y });
- const sr = canvas.screenFromDataPoint(.{ .x = grid_x1, .y = y });
- appendLineQuad(&builder, .{ .x = sl.x, .y = sl.y - half }, .{ .x = sr.x, .y = sr.y + half }, pma_col);
- }
- }
-
- // Resize guide lines
- if (self.resize_data_point) |resize_data_point| {
- const rx = canvas.screenFromDataPoint(.{ .x = resize_data_point.x, .y = 0 }).x;
- appendLineQuad(&builder, .{ .x = rx - half, .y = screen_y0 }, .{ .x = rx + half, .y = screen_y1 }, pma_col);
-
- const ry = canvas.screenFromDataPoint(.{ .x = 0, .y = resize_data_point.y }).y;
- const sx0 = canvas.screenFromDataPoint(.{ .x = grid_x0, .y = 0 }).x;
- const sx1 = canvas.screenFromDataPoint(.{ .x = grid_x1, .y = 0 }).x;
- appendLineQuad(&builder, .{ .x = sx0, .y = ry - half }, .{ .x = sx1, .y = ry + half }, pma_col);
- }
-
- if (builder.vertexes.items.len == 0) return;
-
- const tris = builder.build_unowned();
- dvui.renderTriangles(tris, null) catch {
- dvui.log.err("Failed to render batched grid lines", .{});
- };
-}
-
-/// Appends a single axis-aligned quad (4 vertices, 2 triangles) from `tl` to `br`.
-fn appendLineQuad(builder: *dvui.Triangles.Builder, tl: dvui.Point.Physical, br: dvui.Point.Physical, col: dvui.Color.PMA) void {
- const base: dvui.Vertex.Index = @intCast(builder.vertexes.items.len);
- builder.appendVertex(.{ .pos = tl, .col = col });
- builder.appendVertex(.{ .pos = .{ .x = br.x, .y = tl.y }, .col = col });
- builder.appendVertex(.{ .pos = br, .col = col });
- builder.appendVertex(.{ .pos = .{ .x = tl.x, .y = br.y }, .col = col });
- builder.appendTriangles(&.{ base, base + 1, base + 2, base, base + 2, base + 3 });
-}
-
-/// Viewport in data space + row/column index range for culling (matches bubble / grid logic).
-fn fileCanvasVisibleGridParams(file: *fizzy.Internal.File) ?struct {
- visible_data: dvui.Rect,
- row_h: f32,
- col_w: f32,
- cols: usize,
- first_vis_row: usize,
- last_vis_row: usize,
- vx0: f32,
- vx1: f32,
-} {
- const canvas = &file.editor.canvas;
- const visible_data = canvas.dataFromScreenRect(canvas.rect);
- const total_rows = file.rows;
- const cols = file.columns;
- if (total_rows == 0 or cols == 0) return null;
- const row_h: f32 = @floatFromInt(file.row_height);
- const col_w: f32 = @floatFromInt(file.column_width);
- if (row_h <= 0 or col_w <= 0) return null;
- const bubble_headroom = @max(row_h, col_w);
- const max_row_f: f32 = @floatFromInt(total_rows);
- const first_vis_f = (visible_data.y - bubble_headroom) / row_h;
- const first_vis_row: usize = if (first_vis_f > 0 and first_vis_f < max_row_f)
- @intFromFloat(first_vis_f)
- else if (first_vis_f >= max_row_f)
- total_rows
- else
- 0;
- const last_vis_f = (visible_data.y + visible_data.h) / row_h + 2.0;
- const last_vis_row: usize = if (last_vis_f > 0 and last_vis_f < max_row_f)
- @intFromFloat(last_vis_f)
- else if (last_vis_f >= max_row_f)
- total_rows
- else
- 0;
- return .{
- .visible_data = visible_data,
- .row_h = row_h,
- .col_w = col_w,
- .cols = cols,
- .first_vis_row = first_vis_row,
- .last_vis_row = last_vis_row,
- .vx0 = visible_data.x,
- .vx1 = visible_data.x + visible_data.w,
- };
-}
-
-/// Horizontal grid segments along row tops: one quad per maximal run of sprites without a bubble arc.
-fn appendHorizontalGridRunsForRow(
- self: *FileWidget,
- builder: *dvui.Triangles.Builder,
- canvas: *CanvasWidget,
- grid_pan: ?BubblePanShared,
- row: usize,
- row_start: usize,
- col_lo: usize,
- col_hi_excl: usize,
- col_w: f32,
- row_h: f32,
- half: f32,
- pma_col: dvui.Color.PMA,
-) void {
- if (col_lo >= col_hi_excl) return;
- var col = col_lo;
- while (col < col_hi_excl) {
- const si0 = row_start + col;
- if (self.spriteDrawsBubbleTopEdge(si0, grid_pan)) {
- col += 1;
- continue;
- }
- const run_start = col;
- col += 1;
- while (col < col_hi_excl) : (col += 1) {
- if (self.spriteDrawsBubbleTopEdge(row_start + col, grid_pan)) break;
- }
- const run_end_excl = col;
- const x_left = @as(f32, @floatFromInt(run_start)) * col_w;
- const x_right = @as(f32, @floatFromInt(run_end_excl)) * col_w;
- const y_top = @as(f32, @floatFromInt(row)) * row_h;
- const tl = canvas.screenFromDataPoint(.{ .x = x_left, .y = y_top });
- const tr = canvas.screenFromDataPoint(.{ .x = x_right, .y = y_top });
- appendLineQuad(builder, .{ .x = tl.x, .y = tl.y - half }, .{ .x = tr.x, .y = tr.y + half }, pma_col);
- }
-}
-
-/// Batches grid lines for the resize-shrink overlay (original layer_rect shown in error tint).
-fn drawBatchedResizeOverlayGrid(
- self: *FileWidget,
- file: *fizzy.Internal.File,
- columns: usize,
- layer_rect: dvui.Rect,
- grid_thickness: f32,
-) void {
- const canvas = &self.init_options.file.editor.canvas;
- const half = @max(grid_thickness, 1.0) * 0.5;
- const cw = dvui.currentWindow();
- const pma_col: dvui.Color.PMA = .fromColor(dvui.themeGet().color(.window, .fill).opacity(cw.alpha));
-
- var max_lines: usize = 0;
- if (columns > 1) max_lines += columns - 1;
- max_lines += file.spriteCount();
- if (max_lines == 0) return;
-
- var builder = dvui.Triangles.Builder.init(cw.arena(), max_lines * 4, max_lines * 6) catch return;
- defer builder.deinit(cw.arena());
-
- const screen_y0 = canvas.screenFromDataPoint(.{ .x = 0, .y = layer_rect.y }).y;
- const screen_y1 = canvas.screenFromDataPoint(.{ .x = 0, .y = layer_rect.y + layer_rect.h }).y;
-
- const grid_pan = bubblePanSharedForGrid(self);
-
- for (1..columns) |i| {
- const gx = @as(f32, @floatFromInt(i * file.column_width));
- const sx = canvas.screenFromDataPoint(.{ .x = gx, .y = 0 }).x;
- appendLineQuad(&builder, .{ .x = sx - half, .y = screen_y0 }, .{ .x = sx + half, .y = screen_y1 }, pma_col);
- }
-
- if (fileCanvasVisibleGridParams(file)) |gp| {
- if (gp.vx1 > 0) {
- var row: usize = @max(1, gp.first_vis_row);
- while (row < gp.last_vis_row) : (row += 1) {
- const row_start = row * gp.cols;
- const row_end = @min(row_start + gp.cols, file.spriteCount());
- if (row_end <= row_start) continue;
-
- const row_span = row_end - row_start;
- var col_lo: usize = 0;
- if (gp.vx0 > 0) col_lo = @intFromFloat(@floor(gp.vx0 / gp.col_w));
- var col_hi_excl: usize = @intFromFloat(@ceil(gp.vx1 / gp.col_w));
- col_lo = @min(col_lo, row_span);
- col_hi_excl = @min(col_hi_excl, row_span);
- appendHorizontalGridRunsForRow(self, &builder, canvas, grid_pan, row, row_start, col_lo, col_hi_excl, gp.col_w, gp.row_h, half, pma_col);
- }
- }
- }
-
- if (builder.vertexes.items.len == 0) return;
-
- const tris = builder.build_unowned();
- dvui.renderTriangles(tris, null) catch {
- dvui.log.err("Failed to render batched resize overlay grid", .{});
- };
-}
-
-fn checkerboardGridColorBilinear(c_tl: dvui.Color, c_tr: dvui.Color, c_bl: dvui.Color, c_br: dvui.Color, u: f32, v: f32) dvui.Color {
- const top = c_tl.lerp(c_tr, u);
- const bottom = c_bl.lerp(c_br, u);
- return top.lerp(bottom, v);
-}
-
-/// Near the smoothed mouse (mu, mv): flat `tone` (normal checkerboard tint). Far away: full bilinear corner colors at (u, v).
-fn checkerboardVertexColor(
- c_tl: dvui.Color,
- c_tr: dvui.Color,
- c_bl: dvui.Color,
- c_br: dvui.Color,
- u: f32,
- v: f32,
- mu: f32,
- mv: f32,
- tone: dvui.Color,
-) dvui.Color {
- const c_corner = checkerboardGridColorBilinear(c_tl, c_tr, c_bl, c_br, u, v);
-
- const du = u - mu;
- const dv = v - mv;
- const dist = math.sqrt(du * du + dv * dv);
- // 0 at cursor → tone only; 1 far away → full corner UV gradient (scaled for visible falloff in 0..1 UV space)
- var t = math.clamp(dist * 1.55, 0, 1);
- t = t * t * (3.0 - 2.0 * t);
-
- return tone.lerp(c_corner, t);
-}
-
-/// Animation color for transparency tint; matches bubble arc palette lookup order (selected animation first, else first containing animation).
-fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color {
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
- var animation_index: ?usize = null;
-
- if (file.selected_animation_index) |selected_animation_index| {
- for (file.animations.items(.frames)[selected_animation_index]) |frame| {
- if (frame.sprite_index == sprite_index) {
- animation_index = selected_animation_index;
- break;
- }
- }
- }
-
- if (animation_index == null) {
- anim_blk: for (file.animations.items(.frames), 0..) |frames, i| {
- for (frames) |frame| {
- if (frame.sprite_index == sprite_index) {
- animation_index = i;
- break :anim_blk;
- }
- }
- }
- }
-
- if (animation_index) |ai| {
- const id = file.animations.get(ai).id;
- return palette.getDVUIColor(@intCast(id));
- }
- }
- return null;
-}
-
-fn checkerboardCellCornerColor(
- effect: fizzy.Editor.Settings.TransparencyEffect,
- file: *fizzy.Internal.File,
- sprite_index: usize,
- c_tl: dvui.Color,
- c_tr: dvui.Color,
- c_bl: dvui.Color,
- c_br: dvui.Color,
- u: f32,
- v: f32,
- mu: f32,
- mv: f32,
- tone: dvui.Color,
-) dvui.Color {
- switch (effect) {
- .none => return tone,
- .rainbow => return checkerboardVertexColor(c_tl, c_tr, c_bl, c_br, u, v, mu, mv, tone),
- .animation => {
- if (spriteAnimationPaletteColor(file, sprite_index)) |ac| {
- const row = file.rowFromIndex(sprite_index);
- const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0);
- const v_cell_top = @as(f32, @floatFromInt(row)) / rows_f;
- const v_cell_bot = @as(f32, @floatFromInt(row + 1)) / rows_f;
- const v_mid = (v_cell_top + v_cell_bot) * 0.5;
- // Top of cell: normal tone; bottom: animation tint (fade upward across the cell).
- if (v <= v_mid) return tone;
- return tone.lerp(ac, 0.4);
- }
- return tone;
- },
- }
-}
-
-fn checkerboardGridPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr: dvui.Color, c_bl: dvui.Color, c_br: dvui.Color } {
- const tone = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(dvui.currentWindow().alpha);
- const c_tl = tone;
- const c_tr = tone.lerp(.red, 0.18);
- const c_bl = tone.lerp(.blue, 0.12);
- const c_br = c_tr.lerp(c_bl, 0.5);
- return .{ .tone = tone, .c_tl = c_tl, .c_tr = c_tr, .c_bl = c_bl, .c_br = c_br };
-}
-
-/// Same tint as the batched checkerboard for the cell under `sprite_index` (center UV), for bubbles etc.
-fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index: usize) dvui.Color {
- const pal = checkerboardGridPalette();
- const tone = pal.tone;
- switch (fizzy.editor.settings.transparency_effect) {
- .none => return tone,
- .rainbow => {
- const mu_mv = dvui.dataGet(null, file.editor.canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 };
- const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0);
- const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0);
- const col = file.columnFromIndex(sprite_index);
- const row = file.rowFromIndex(sprite_index);
- const u = (@as(f32, @floatFromInt(col)) + 0.5) / cols_f;
- const v = (@as(f32, @floatFromInt(row)) + 0.5) / rows_f;
- return checkerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u, v, mu_mv.x, mu_mv.y, tone);
- },
- // Bubbles: base checkerboard tone only (no animation palette tint; that applies on the canvas grid).
- .animation => return tone,
- }
-}
-
-/// Checkerboard behind layers: one batched quad per visible cell (UV 0..1 per cell — vertex colors
-/// vary per cell for rainbow / animation effects, which is why this isn't a single wrapped quad).
-fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void {
- const n = file.spriteCount();
- if (n == 0) return;
-
- const te = fizzy.editor.settings.transparency_effect;
- const pal = checkerboardGridPalette();
- const tone = pal.tone;
- const rs = file.editor.canvas.screen_rect_scale;
-
- const gp = fileCanvasVisibleGridParams(file) orelse return;
- if (gp.first_vis_row >= gp.last_vis_row or gp.vx1 <= 0) return;
-
- const arena = dvui.currentWindow().arena();
- var builder = dvui.Triangles.Builder.init(arena, n * 4, n * 6) catch {
- dvui.log.err("Failed to allocate checkerboard batch", .{});
- return;
- };
- defer builder.deinit(arena);
-
- const c_tl = pal.c_tl;
- const c_tr = pal.c_tr;
- const c_bl = pal.c_bl;
- const c_br = pal.c_br;
-
- const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0);
- const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0);
-
- const canvas = file.editor.canvas;
- const mouse_screen = dvui.currentWindow().mouse_pt;
- var target_mu: f32 = 0.5;
- var target_mv: f32 = 0.5;
- if (canvas.rect.contains(mouse_screen)) {
- const md = canvas.screen_rect_scale.pointFromPhysical(mouse_screen);
- const fw = @as(f32, @floatFromInt(file.width()));
- const fh = @as(f32, @floatFromInt(file.height()));
- if (fw > 0) target_mu = math.clamp(md.x / fw, 0, 1);
- if (fh > 0) target_mv = math.clamp(md.y / fh, 0, 1);
- }
-
- const prev_uv = dvui.dataGet(null, canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 };
- const smooth_t: f32 = 0.15;
- const mu = prev_uv.x + (target_mu - prev_uv.x) * smooth_t;
- const mv = prev_uv.y + (target_mv - prev_uv.y) * smooth_t;
- dvui.dataSet(null, canvas.id, "checkerboard_mouse_uv", dvui.Point{ .x = mu, .y = mv });
-
- var quad_idx: usize = 0;
- var row: usize = gp.first_vis_row;
- while (row < gp.last_vis_row) : (row += 1) {
- const row_start = row * gp.cols;
- const row_end = @min(row_start + gp.cols, n);
- if (row_end <= row_start) continue;
-
- const row_span = row_end - row_start;
- var col_lo: usize = 0;
- if (gp.vx0 > 0) col_lo = @intFromFloat(@floor(gp.vx0 / gp.col_w));
- var col_hi_excl: usize = @intFromFloat(@ceil(gp.vx1 / gp.col_w));
- col_lo = @min(col_lo, row_span);
- col_hi_excl = @min(col_hi_excl, row_span);
-
- var col = col_lo;
- while (col < col_hi_excl) : (col += 1) {
- const i = row_start + col;
- const sr = file.spriteRect(i);
- if (gp.visible_data.intersect(sr).empty()) continue;
-
- const r = rs.rectToPhysical(sr);
- const tl = r.topLeft();
- const tr = r.topRight();
- const br = r.bottomRight();
- const bl = r.bottomLeft();
-
- const col_i = file.columnFromIndex(i);
- const row_i = file.rowFromIndex(i);
- const u_left = @as(f32, @floatFromInt(col_i)) / cols_f;
- const u_right = @as(f32, @floatFromInt(col_i + 1)) / cols_f;
- const v_top = @as(f32, @floatFromInt(row_i)) / rows_f;
- const v_bot = @as(f32, @floatFromInt(row_i + 1)) / rows_f;
-
- const pma_tl = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, i, c_tl, c_tr, c_bl, c_br, u_left, v_top, mu, mv, tone));
- const pma_tr = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, i, c_tl, c_tr, c_bl, c_br, u_right, v_top, mu, mv, tone));
- const pma_br = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, i, c_tl, c_tr, c_bl, c_br, u_right, v_bot, mu, mv, tone));
- const pma_bl = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, i, c_tl, c_tr, c_bl, c_br, u_left, v_bot, mu, mv, tone));
-
- builder.appendVertex(.{ .pos = tl, .col = pma_tl, .uv = .{ 0, 0 } });
- builder.appendVertex(.{ .pos = tr, .col = pma_tr, .uv = .{ 1, 0 } });
- builder.appendVertex(.{ .pos = br, .col = pma_br, .uv = .{ 1, 1 } });
- builder.appendVertex(.{ .pos = bl, .col = pma_bl, .uv = .{ 0, 1 } });
-
- const quad_base: dvui.Vertex.Index = @intCast(quad_idx * 4);
- builder.appendTriangles(&.{ quad_base + 1, quad_base + 0, quad_base + 3, quad_base + 1, quad_base + 3, quad_base + 2 });
- quad_idx += 1;
- }
- }
-
- if (quad_idx == 0) return;
-
- const triangles = builder.build();
- dvui.renderTriangles(triangles, file.checkerboardTileTexture()) catch {
- dvui.log.err("Failed to render batched checkerboard", .{});
- };
-}
-
-pub fn active(self: *FileWidget) bool {
- if (fizzy.editor.activeFile()) |file| {
- if (file.id == self.init_options.file.id) {
- return true;
- }
- }
- return false;
-}
-
-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 (self.init_options.file.editor.transform != null) return;
- if (self.init_options.file.editor.canvas.gestureActive()) return;
- if (self.init_options.file.editor.canvas.trackpadPinching()) return;
-
- var subtract = false;
- var add = false;
-
- for (dvui.events()) |*e| {
- if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
- switch (e.evt) {
- .key => |ke| {
- if (ke.mod.matchBind("shift")) {
- subtract = true;
- } else if (ke.mod.matchBind("ctrl/cmd")) {
- add = true;
- }
- },
- .mouse => |me| {
- if (me.mod.matchBind("shift")) {
- subtract = true;
- } else if (me.mod.matchBind("ctrl/cmd")) {
- add = true;
- }
- },
- else => {},
- }
- }
-
- const mouse_point = dvui.currentWindow().mouse_pt;
- if (!self.init_options.file.editor.canvas.pointerOverDrawable(mouse_point)) return;
- if (self.sample_data_point != null) return;
-
- _ = dvui.cursorSet(.hidden);
-
- const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point);
-
- const selection_sprite = switch (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],
- };
-
- 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],
- .selection => selection_sprite,
- else => null,
- }) |sprite| {
- const atlas_size = dvui.imageSize(fizzy.editor.atlas.source) catch {
- dvui.log.err("Failed to get atlas size", .{});
- return;
- };
-
- const uv = dvui.Rect{
- .x = (@as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w),
- .y = (@as(f32, @floatFromInt(sprite.source[1])) / atlas_size.h),
- .w = (@as(f32, @floatFromInt(sprite.source[2])) / atlas_size.w),
- .h = (@as(f32, @floatFromInt(sprite.source[3])) / atlas_size.h),
- };
-
- const origin = dvui.Point{
- .x = sprite.origin[0] * 1 / self.init_options.file.editor.canvas.scale,
- .y = sprite.origin[1] * 1 / self.init_options.file.editor.canvas.scale,
- };
-
- const position = data_point.diff(origin);
-
- const box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .rect = .{
- .x = position.x,
- .y = position.y,
- .w = @as(f32, @floatFromInt(sprite.source[2])) * 1 / self.init_options.file.editor.canvas.scale,
- .h = @as(f32, @floatFromInt(sprite.source[3])) * 1 / self.init_options.file.editor.canvas.scale,
- },
- .border = dvui.Rect.all(0),
- .corner_radius = .{ .x = 0, .y = 0 },
- .padding = .{ .x = 0, .y = 0 },
- .margin = .{ .x = 0, .y = 0 },
- .background = false,
- .color_fill = dvui.themeGet().color(.err, .fill),
- });
- defer box.deinit();
-
- const rs = box.data().rectScale();
-
- dvui.renderImage(fizzy.editor.atlas.source, rs, .{
- .uv = uv,
- }) catch {
- dvui.log.err("Failed to render cursor image", .{});
- };
- }
-}
-
-fn drawSamplePixelOutline(canvas: *CanvasWidget, data_point: dvui.Point) void {
- const pixel_box_size = canvas.scale * dvui.currentWindow().rectScale().s;
- const pixel_point: dvui.Point = .{
- .x = @round(data_point.x - 0.5),
- .y = @round(data_point.y - 0.5),
- };
- const pixel_box_point = canvas.screenFromDataPoint(pixel_point);
- var pixel_box = dvui.Rect.Physical.fromSize(.{ .w = pixel_box_size, .h = pixel_box_size });
- pixel_box.x = pixel_box_point.x;
- pixel_box.y = pixel_box_point.y;
- dvui.Path.stroke(.{ .points = &.{
- pixel_box.topLeft(),
- pixel_box.topRight(),
- pixel_box.bottomRight(),
- pixel_box.bottomLeft(),
- } }, .{ .thickness = 2, .color = .white, .closed = true });
-}
-
-pub fn drawSample(self: *FileWidget) void {
- const file = self.init_options.file;
- if (self.sample_data_point) |data_point| {
- if (!file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return;
- drawSamplePixelOutline(&file.editor.canvas, data_point);
- }
-}
-
-/// Color-dropper magnifier: composite built to a render target, presented via RenderFrontToBack (not FloatingWidget).
-/// Call after the file widget has finished drawing for the frame.
-/// Map a data rect inside `region_data` to the corresponding physical pixels in `dest_phys`.
-fn mapDataRectToPhysicalStrip(sr: dvui.Rect, parent_data: dvui.Rect, parent_phys: dvui.Rect.Physical) dvui.Rect.Physical {
- const rel_x = sr.x - parent_data.x;
- const rel_y = sr.y - parent_data.y;
- return .{
- .x = parent_phys.x + rel_x / parent_data.w * parent_phys.w,
- .y = parent_phys.y + rel_y / parent_data.h * parent_phys.h,
- .w = sr.w / parent_data.w * parent_phys.w,
- .h = sr.h / parent_data.h * parent_phys.h,
- };
-}
-
-/// Draw the checkerboard alpha pattern into `dest_phys`. Uses wrap=.repeat on the tile texture so
-/// the entire region is one quad with UV scaled so each `cw × ch` of data space spans one tile.
-fn drawSampleMagnifierCheckerboardTiles(
- file: *fizzy.Internal.File,
- region_data: dvui.Rect,
- dest_phys: dvui.Rect.Physical,
- scale: f32,
-) void {
- const tile = file.checkerboardTileTexture() orelse return;
- const cw: f32 = @floatFromInt(file.column_width);
- const ch: f32 = @floatFromInt(file.row_height);
- if (region_data.w <= 0 or region_data.h <= 0 or dest_phys.w <= 0 or dest_phys.h <= 0) return;
- if (cw <= 0 or ch <= 0) return;
-
- dvui.renderTexture(tile, .{ .r = dest_phys, .s = scale }, .{
- .colormod = dvui.themeGet().color(.content, .fill).lighten(12.0),
- .uv = .{
- .x = region_data.x / cw,
- .y = region_data.y / ch,
- .w = region_data.w / cw,
- .h = region_data.h / ch,
- },
- }) catch {
- dvui.log.err("Failed to render magnifier checkerboard", .{});
- };
-}
-
-/// Build checkerboard + layers into an offscreen target. Layer composites are synced on the screen
-/// target first so `renderLayers` does not rebind this target via `syncLayerComposite`.
-fn drawSampleMagnifierCompositeBuild(
- file: *fizzy.Internal.File,
- region_data: dvui.Rect,
- content_rs: dvui.RectScale,
- file_w: f32,
- file_h: f32,
-) ?dvui.Texture.Target {
- if (region_data.w <= 0 or region_data.h <= 0 or content_rs.r.w <= 0 or content_rs.r.h <= 0) return null;
-
- const w: u32 = @intFromFloat(@max(@ceil(content_rs.r.w), 1));
- const h: u32 = @intFromFloat(@max(@ceil(content_rs.r.h), 1));
-
- const layer_region = region_data.intersect(dvui.Rect{ .x = 0, .y = 0, .w = file_w, .h = file_h });
- const layer_opts_base = fizzy.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 {
- 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 {
- dvui.log.err("Failed to create magnifier composite target", .{});
- return null;
- };
-
- target.clear();
-
- const prev_target = dvui.renderTarget(.{ .texture = target, .offset = .{ .x = 0, .y = 0 } });
- defer _ = dvui.renderTarget(prev_target);
-
- const prev_clip = dvui.clipGet();
- defer dvui.clipSet(prev_clip);
- dvui.clipSet(dvui.Rect.Physical{ .x = 0, .y = 0, .w = @floatFromInt(w), .h = @floatFromInt(h) });
-
- const dest_phys = dvui.Rect.Physical{ .x = 0, .y = 0, .w = @floatFromInt(w), .h = @floatFromInt(h) };
-
- drawSampleMagnifierCheckerboardTiles(file, region_data, dest_phys, 1.0);
-
- if (!layer_region.empty()) {
- const layer_phys = mapDataRectToPhysicalStrip(layer_region, region_data, dest_phys);
- const uv_rect = dvui.Rect{
- .x = layer_region.x / file_w,
- .y = layer_region.y / file_h,
- .w = layer_region.w / file_w,
- .h = layer_region.h / file_h,
- };
- fizzy.render.renderLayersMagnifierSample(.{
- .file = file,
- .rs = .{ .r = layer_phys, .s = 1.0 },
- .uv = uv_rect,
- .allow_peek = false,
- }) catch {
- dvui.log.err("Failed to render magnifier layers into composite", .{});
- };
- }
-
- return target;
-}
-
-/// Present magnifier chrome + composite (deferred via RenderFrontToBack so it stacks above the canvas).
-fn drawSampleMagnifierPresent(
- composite: dvui.Texture.Target,
- frame_phys: dvui.Rect.Physical,
- content_rs: dvui.RectScale,
- corner_radius: dvui.Rect,
- border_nat: f32,
-) void {
- const window_fill = dvui.themeGet().color(.window, .fill);
- const border_color = dvui.themeGet().color(.control, .text);
- const ns = dvui.currentWindow().natural_scale;
-
- const corner_frame_phys = corner_radius.scale(content_rs.s, dvui.Rect.Physical);
- const inner_corner = dvui.Rect{
- .x = @max(0, corner_radius.x - border_nat),
- .y = @max(0, corner_radius.y - border_nat),
- .w = @max(0, corner_radius.w - border_nat),
- .h = @max(0, corner_radius.h - border_nat),
- };
-
- // Shadow (matches FloatingWidget magnifier styling).
- const shadow_offset = dvui.Point.Physical{ .x = 2.0 / ns * content_rs.s, .y = 2.0 / ns * content_rs.s };
- const shadow_rect = dvui.Rect.Physical{
- .x = frame_phys.x + shadow_offset.x,
- .y = frame_phys.y + shadow_offset.y,
- .w = frame_phys.w,
- .h = frame_phys.h,
- };
- var shadow_path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- defer shadow_path.deinit();
- shadow_path.addRect(shadow_rect, corner_frame_phys);
- dvui.Path.fillConvex(shadow_path.build(), .{
- .color = dvui.Color.black.opacity(0.2),
- .fade = 15.0 / ns * content_rs.s,
- });
-
- // Window background behind content.
- var bg_path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- defer bg_path.deinit();
- bg_path.addRect(frame_phys, corner_frame_phys);
- dvui.Path.fillConvex(bg_path.build(), .{ .color = window_fill, .fade = 0 });
-
- const tex = dvui.Texture.fromTargetTemp(composite) catch {
- dvui.log.err("Failed to get magnifier composite texture", .{});
- return;
- };
-
- const source: dvui.ImageSource = .{ .texture = tex };
- dvui.renderImage(source, content_rs, .{
- .colormod = .white,
- .uv = .{ .x = 0, .y = 0, .w = 1, .h = 1 },
- // Natural radii; `renderTexture` scales by `content_rs.s` once (not pre-scaled).
- .corner_radius = inner_corner,
- }) catch {
- dvui.log.err("Failed to render magnifier composite", .{});
- };
-
- // Border stroke centerline (matches FloatingWidget `borderAndBackground`).
- const border_thickness = border_nat * content_rs.s;
- const half = border_thickness * 0.5;
- const border_stroke_rect = frame_phys.inset(dvui.Rect.Physical.all(half));
- border_stroke_rect.stroke(corner_frame_phys, .{ .thickness = border_thickness, .color = border_color });
-
- const center_x = content_rs.r.x + content_rs.r.w / 2;
- const center_y = content_rs.r.y + content_rs.r.h / 2;
- const cross_size = @min(content_rs.r.w, content_rs.r.h) * 0.2;
-
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = center_x - cross_size / 2, .y = center_y },
- .{ .x = center_x + cross_size / 2, .y = center_y },
- } }, .{ .thickness = 4, .color = .white });
-
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = center_x, .y = center_y - cross_size / 2 },
- .{ .x = center_x, .y = center_y + cross_size / 2 },
- } }, .{ .thickness = 4, .color = .white });
-
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = center_x - cross_size / 2 + 4, .y = center_y },
- .{ .x = center_x + cross_size / 2 - 4, .y = center_y },
- } }, .{ .thickness = 2, .color = .black });
-
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = center_x, .y = center_y - cross_size / 2 + 4 },
- .{ .x = center_x, .y = center_y + cross_size / 2 - 4 },
- } }, .{ .thickness = 2, .color = .black });
-}
-
-pub fn drawSampleMagnifier(file: *fizzy.Internal.File, data_point: dvui.Point) void {
- const canvas = &file.editor.canvas;
- if (fizzy.dvui.canvasPointerInputSuppressed()) return;
- if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return;
-
- _ = dvui.cursorSet(.hidden);
-
- const enlarged_scale: f32 = canvas.scale * (8.0 / (1.0 + canvas.scale));
- const sample_box_size: f32 = 200.0 * 1 / canvas.scale;
- const sample_region_size: f32 = sample_box_size / enlarged_scale;
-
- // Home placement: bottom-left corner of the magnifier sits exactly at the sample point.
- const default_magnifier_phys = canvas.screenFromDataRect(.{
- .x = data_point.x,
- .y = data_point.y - sample_box_size,
- .w = sample_box_size,
- .h = sample_box_size,
- });
-
- // Slide the magnifier inside the OS window without flipping. Only the right and top edges
- // can clip because home is up-and-right of the sample point.
- const window_rect = dvui.windowRectPixels();
- const push_x_phys = @max(0, (default_magnifier_phys.x + default_magnifier_phys.w) - (window_rect.x + window_rect.w));
- const push_y_phys = @max(0, window_rect.y - default_magnifier_phys.y);
-
- const magnifier_phys = dvui.Rect.Physical{
- .x = default_magnifier_phys.x - push_x_phys,
- .y = default_magnifier_phys.y + push_y_phys,
- .w = default_magnifier_phys.w,
- .h = default_magnifier_phys.h,
- };
- const magnifier_nat = magnifier_phys.toNatural();
-
- // Corner-radius rect maps {x: TL, y: TR, w: BR, h: BL}. At home BL is sharp (0) so it "points"
- // at the sample. As the window pushes the magnifier away, grow BL toward `cr_max` so the
- // rectangle's rounded edge slides tangent to the sample point — fully circular when far enough.
- // `cr_max` is just under half-width because `Path.addRect` skips the apex when two adjacent
- // radii both equal half the edge length.
- const cr_max = magnifier_nat.w / 2 - 0.51;
- const win_scale = dvui.windowRectScale().s;
- const push_dist_phys = @sqrt(push_x_phys * push_x_phys + push_y_phys * push_y_phys);
- const push_dist_nat = if (win_scale > 0) push_dist_phys / win_scale else push_dist_phys;
- const bl_radius = @min(cr_max, push_dist_nat);
- const corner_radius = dvui.Rect{ .x = cr_max, .y = cr_max, .w = cr_max, .h = bl_radius };
-
- const win_rs = dvui.windowRectScale();
- const ns = dvui.currentWindow().natural_scale;
- const border_nat = 2.0 / ns;
- const border_phys = border_nat * win_rs.s;
-
- const frame_phys = magnifier_phys;
- const content_phys = frame_phys.inset(dvui.Rect.Physical.all(border_phys));
- const content_rs = dvui.RectScale{ .r = content_phys, .s = win_rs.s };
-
- const region_data = dvui.Rect{
- .x = data_point.x - sample_region_size / 2,
- .y = data_point.y - sample_region_size / 2,
- .w = sample_region_size,
- .h = sample_region_size,
- };
-
- const file_w: f32 = @floatFromInt(file.width());
- const file_h: f32 = @floatFromInt(file.height());
-
- const composite = drawSampleMagnifierCompositeBuild(file, region_data, content_rs, file_w, file_h) orelse return;
- defer composite.destroyLater();
-
- // Break out of canvas/file clipping (same as FloatingWidget) so the border is not cut off at the top.
- const prev_clip = dvui.clipGet();
- defer dvui.clipSet(prev_clip);
- dvui.clipSet(dvui.windowRectPixels());
-
- // Draw on top of the canvas after normal widgets (FloatingWidget defers; this uses RenderFrontToBack).
- var ftb: dvui.RenderFrontToBack = undefined;
- ftb.init();
- defer ftb.deinit();
-
- drawSampleMagnifierPresent(composite, frame_phys, content_rs, corner_radius, border_nat);
-}
-
-pub fn updateActiveLayerMask(self: *FileWidget) void {
- var file = self.init_options.file;
- if (file.selected_layer_index >= file.layers.len) return;
-
- const source_hash = file.layers.items(.source)[file.selected_layer_index].hash();
- const cached = file.editor.mask_built_for_layer == file.selected_layer_index and
- file.editor.mask_built_source_hash == source_hash and
- dvui.textureGetCached(source_hash) != null;
-
- if (cached) return;
-
- var active_layer = file.layers.get(file.selected_layer_index);
- active_layer.clearMask();
- active_layer.setMaskFromTransparency(true);
-
- file.editor.mask_built_for_layer = file.selected_layer_index;
- file.editor.mask_built_source_hash = source_hash;
-}
-
-pub fn drawLayers(self: *FileWidget) void {
- const perf_t0 = fizzy.perf.drawLayersBegin();
- defer fizzy.perf.drawLayersEnd(perf_t0);
-
- var file = self.init_options.file;
- var columns: usize = file.columns;
- var rows: usize = file.rows;
-
- const layer_rect = self.init_options.file.editor.canvas.dataFromScreenRect(self.init_options.file.editor.canvas.rect);
- var canvas_rect = layer_rect;
-
- if (self.resize_data_point) |resize_data_point| {
- canvas_rect.w = resize_data_point.x;
- canvas_rect.h = resize_data_point.y;
-
- if (resize_data_point.x < layer_rect.x + layer_rect.w or resize_data_point.y < layer_rect.y + layer_rect.h) {
- const grid_thickness = std.math.clamp(dvui.currentWindow().natural_scale * self.init_options.file.editor.canvas.scale, 0, dvui.currentWindow().natural_scale);
- self.init_options.file.editor.canvas.screenFromDataRect(layer_rect).fill(.all(0), .{ .color = dvui.themeGet().color(.err, .fill).opacity(0.5), .fade = 1.5 });
- drawBatchedResizeOverlayGrid(self, file, columns, layer_rect, grid_thickness);
- }
-
- columns = @divTrunc(@as(u32, @intFromFloat(canvas_rect.w)), file.column_width);
- rows = @divTrunc(@as(u32, @intFromFloat(canvas_rect.h)), file.row_height);
- }
-
- const shadow_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .rect = canvas_rect,
- .border = dvui.Rect.all(0),
- .box_shadow = .{
- .fade = 20 * 1 / self.init_options.file.editor.canvas.scale,
- .corner_radius = dvui.Rect.all(2 * 1 / self.init_options.file.editor.canvas.scale),
- .alpha = if (dvui.themeGet().dark) 0.4 else 0.2,
- .offset = .{
- .x = 2 * 1 / self.init_options.file.editor.canvas.scale,
- .y = 2 * 1 / self.init_options.file.editor.canvas.scale,
- },
- },
- });
- shadow_box.deinit();
-
- const fill_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .rect = .{ .x = layer_rect.x, .y = layer_rect.y, .w = @min(canvas_rect.w, layer_rect.w), .h = @min(canvas_rect.h, layer_rect.h) },
- .border = dvui.Rect.all(0),
- .background = true,
- .color_fill = dvui.themeGet().color(.window, .fill),
- });
- fill_box.deinit();
-
- // Content fill + batched checkerboard (including resize and column/row reorder preview; skip during cell reorder).
- if (self.removed_sprite_indices == null) {
- const bg_rect = dvui.Rect{
- .x = layer_rect.x,
- .y = layer_rect.y,
- .w = @min(canvas_rect.w, layer_rect.w),
- .h = @min(canvas_rect.h, layer_rect.h),
- };
- const bg_screen = self.init_options.file.editor.canvas.screenFromDataRect(bg_rect);
- if (self.init_options.file.editor.canvas.scale < 0.1) {
- bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 });
- } else {
- bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 });
- drawCheckerboardCellsBatched(file);
- }
- }
-
- // Render all layers and update our bounding box;
- {
- if (self.removed_sprite_indices != null) {
- self.drawCellReorderPreview();
- return;
- } else if (file.editor.workspace.columns_drag_index != null or file.editor.workspace.rows_drag_index != null) {
- self.drawColumnRowReorderPreview();
- return;
- } else {
- fizzy.render.renderLayers(.{
- .file = file,
- .rs = .{
- .r = self.init_options.file.editor.canvas.rect,
- .s = self.init_options.file.editor.canvas.scale,
- },
- }) catch {
- dvui.log.err("Failed to render file image", .{});
- return;
- };
- }
- }
-
- // Draw the resize fill area if a resize is happening
- if (self.resize_data_point) |resize_data_point| {
- if (resize_data_point.x > layer_rect.x + layer_rect.w) {
- const new_tiles_rect = dvui.Rect{
- .x = layer_rect.topRight().x,
- .y = layer_rect.topRight().y,
- .w = resize_data_point.x - layer_rect.topRight().x,
- .h = @min(resize_data_point.y - layer_rect.topRight().y, layer_rect.h),
- };
-
- self.init_options.file.editor.canvas.screenFromDataRect(new_tiles_rect).fill(.all(0), .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), .fade = 0.0 });
- }
- if (resize_data_point.y > layer_rect.y + layer_rect.h) {
- const new_tiles_rect = dvui.Rect{
- .x = layer_rect.topLeft().x,
- .y = layer_rect.bottomLeft().y,
- .w = resize_data_point.x,
- .h = resize_data_point.y - layer_rect.bottomLeft().y,
- };
-
- self.init_options.file.editor.canvas.screenFromDataRect(new_tiles_rect).fill(.all(0), .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), .fade = 0.0 });
- }
- }
-
- // Draw the grid lines for the canvas as a single batched draw call.
- {
- const grid_color = dvui.themeGet().color(.control, .fill);
- const c_scale = self.init_options.file.editor.canvas.scale;
- const grid_thickness = std.math.clamp(dvui.currentWindow().natural_scale * c_scale, 0, dvui.currentWindow().natural_scale);
- const grid_y0 = canvas_rect.y;
- const grid_y1 = canvas_rect.y + canvas_rect.h;
- const grid_x0 = canvas_rect.x;
- const grid_x1 = canvas_rect.x + canvas_rect.w;
- const vertical_inner = @min(columns, file.columns);
-
- drawBatchedGridLines(self, file, columns, rows, grid_color, grid_thickness, grid_x0, grid_x1, grid_y0, grid_y1, vertical_inner);
- }
-
- // Draw the selection box for the selected sprites
- if (fizzy.editor.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) {
- const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] };
-
- const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y };
- const horizontal_line_end: dvui.Point = .{ .x = sprite_rect.topRight().x, .y = origin.y };
- const vertical_line_start: dvui.Point = .{ .x = origin.x, .y = sprite_rect.topLeft().y };
- const vertical_line_end: dvui.Point = .{ .x = origin.x, .y = sprite_rect.bottomLeft().y };
-
- dvui.Path.stroke(.{ .points = &.{
- file.editor.canvas.screenFromDataPoint(horizontal_line_start),
- file.editor.canvas.screenFromDataPoint(horizontal_line_end),
- } }, .{ .thickness = 1, .color = dvui.themeGet().color(.err, .fill) });
-
- dvui.Path.stroke(.{ .points = &.{
- file.editor.canvas.screenFromDataPoint(vertical_line_start),
- file.editor.canvas.screenFromDataPoint(vertical_line_end),
- } }, .{ .thickness = 1, .color = dvui.themeGet().color(.err, .fill) });
- }
-
- sprite_rect_physical.inset(.all(dvui.currentWindow().natural_scale * 1.5)).stroke(dvui.Rect.Physical.all(@min(sprite_rect_physical.w, sprite_rect_physical.h) / 8), .{
- .thickness = 1.5 * dvui.currentWindow().natural_scale,
- .color = dvui.themeGet().color(.highlight, .fill),
- .closed = true,
- });
- }
- }
-}
-
-const ReorderAxis = enum { columns, rows };
-
-/// Checkerboard alpha over each cell of the floating column/row, matching `drawCheckerboardCellsBatched` tint/UVs at half opacity.
-fn drawCheckerboardReorderFloatingStrip(
- self: *FileWidget,
- file: *fizzy.Internal.File,
- removed_data_rect: dvui.Rect,
- strip_phys: dvui.Rect.Physical,
- axis: ReorderAxis,
- removed_index: usize,
-) void {
- _ = self;
- const pd = removed_data_rect;
- if (pd.w <= 0 or pd.h <= 0) return;
- if (strip_phys.w <= 0 or strip_phys.h <= 0) return;
-
- const n = switch (axis) {
- .columns => file.rows,
- .rows => file.columns,
- };
- if (n == 0) return;
-
- const arena = dvui.currentWindow().arena();
- var builder = dvui.Triangles.Builder.init(arena, n * 4, n * 6) catch {
- dvui.log.err("Failed to allocate reorder floating checkerboard", .{});
- return;
- };
- defer builder.deinit(arena);
-
- const pal = checkerboardGridPalette();
- const tone = pal.tone;
- const c_tl = pal.c_tl;
- const c_tr = pal.c_tr;
- const c_bl = pal.c_bl;
- const c_br = pal.c_br;
- const te = fizzy.editor.settings.transparency_effect;
-
- const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0);
- const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0);
-
- const mu_mv = dvui.dataGet(null, file.editor.canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 };
- const mu = mu_mv.x;
- const mv = mu_mv.y;
-
- const half_op = dvui.Color.PMA{ .r = 128, .g = 128, .b = 128, .a = 128 };
-
- var quad_i: usize = 0;
- for (0..n) |i| {
- const si = switch (axis) {
- .columns => removed_index + i * file.columns,
- .rows => i + removed_index * file.columns,
- };
- const sr = file.spriteRect(si);
- const phys = mapDataRectToPhysicalStrip(sr, pd, strip_phys);
- const col = file.columnFromIndex(si);
- const row = file.rowFromIndex(si);
- const u_left = @as(f32, @floatFromInt(col)) / cols_f;
- const u_right = @as(f32, @floatFromInt(col + 1)) / cols_f;
- const v_top = @as(f32, @floatFromInt(row)) / rows_f;
- const v_bot = @as(f32, @floatFromInt(row + 1)) / rows_f;
-
- const pma_tl = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, si, c_tl, c_tr, c_bl, c_br, u_left, v_top, mu, mv, tone)).multiply(half_op);
- const pma_tr = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, si, c_tl, c_tr, c_bl, c_br, u_right, v_top, mu, mv, tone)).multiply(half_op);
- const pma_br = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, si, c_tl, c_tr, c_bl, c_br, u_right, v_bot, mu, mv, tone)).multiply(half_op);
- const pma_bl = dvui.Color.PMA.fromColor(checkerboardCellCornerColor(te, file, si, c_tl, c_tr, c_bl, c_br, u_left, v_bot, mu, mv, tone)).multiply(half_op);
-
- const tl = phys.topLeft();
- const tr = phys.topRight();
- const br = phys.bottomRight();
- const bl = phys.bottomLeft();
-
- builder.appendVertex(.{ .pos = tl, .col = pma_tl, .uv = .{ 0, 0 } });
- builder.appendVertex(.{ .pos = tr, .col = pma_tr, .uv = .{ 1, 0 } });
- builder.appendVertex(.{ .pos = br, .col = pma_br, .uv = .{ 1, 1 } });
- builder.appendVertex(.{ .pos = bl, .col = pma_bl, .uv = .{ 0, 1 } });
-
- const quad_base: dvui.Vertex.Index = @intCast(quad_i * 4);
- builder.appendTriangles(&.{ quad_base + 1, quad_base + 0, quad_base + 3, quad_base + 1, quad_base + 3, quad_base + 2 });
- quad_i += 1;
- }
-
- const triangles = builder.build();
- dvui.renderTriangles(triangles, file.checkerboardTileTexture()) catch {
- dvui.log.err("Failed to render reorder floating checkerboard", .{});
- };
-}
-
-/// Content fill + batched checkerboard for the file canvas (same as the normal `drawLayers` path).
-fn drawCanvasCheckerboardBackground(self: *FileWidget) void {
- const file = self.init_options.file;
- const canvas = &file.editor.canvas;
- const layer_rect = canvas.dataFromScreenRect(canvas.rect);
- const bg_rect = dvui.Rect{
- .x = layer_rect.x,
- .y = layer_rect.y,
- .w = layer_rect.w,
- .h = layer_rect.h,
- };
- const bg_screen = canvas.screenFromDataRect(bg_rect);
- if (canvas.scale < 0.1) {
- bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 });
- } else {
- bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 });
- drawCheckerboardCellsBatched(file);
- }
-}
-
-fn drawColumnRowReorderPreview(self: *FileWidget) void {
- const file = self.init_options.file;
- const workspace = file.editor.workspace;
- if (workspace.columns_drag_index == null and workspace.rows_drag_index == null) return;
-
- const axis: ReorderAxis = if (workspace.columns_drag_index != null) .columns else .rows;
- const target_index = switch (axis) {
- .columns => workspace.columns_target_index,
- .rows => workspace.rows_target_index,
- };
- const removed_index = switch (axis) {
- .columns => workspace.columns_drag_index,
- .rows => workspace.rows_drag_index,
- } orelse return;
-
- self.drawReorderPreviewForAxis(file, axis, target_index, removed_index);
-}
-
-fn renderLayersInDataRect(
- self: *FileWidget,
- file: *fizzy.Internal.File,
- data_rect: dvui.Rect,
- screen_rect_override: ?dvui.Rect.Physical,
-) void {
- const scale = self.init_options.file.editor.canvas.scale;
- const w = @as(f32, @floatFromInt(file.width()));
- const h = @as(f32, @floatFromInt(file.height()));
- const r = screen_rect_override orelse file.editor.canvas.screenFromDataRect(data_rect);
- fizzy.render.renderLayers(.{
- .file = file,
- .rs = .{ .r = r, .s = scale },
- .uv = .{
- .x = data_rect.x / w,
- .y = data_rect.y / h,
- .w = data_rect.w / w,
- .h = data_rect.h / h,
- },
- }) catch dvui.log.err("Failed to render file image", .{});
-}
-
-fn reorderSegmentRects(
- axis: ReorderAxis,
- file: *fizzy.Internal.File,
- target_index: usize,
- removed_index: usize,
- target_rect: dvui.Rect,
- removed_rect: dvui.Rect,
-) struct {
- first: dvui.Rect,
- middle: ?dvui.Rect,
- last: dvui.Rect,
- middle_screen_offset: dvui.Point,
-} {
- const slot_size = switch (axis) {
- .columns => file.column_width,
- .rows => file.row_height,
- };
- const slot_count = switch (axis) {
- .columns => file.columns,
- .rows => file.rows,
- };
- const slot_f = @as(f32, @floatFromInt(slot_size));
- const extent_other = switch (axis) {
- .columns => @as(f32, @floatFromInt(file.height())),
- .rows => @as(f32, @floatFromInt(file.width())),
- };
-
- if (target_index <= removed_index) {
- const first: dvui.Rect = switch (axis) {
- .columns => .{ .x = 0.0, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(target_index)), .h = extent_other },
- .rows => .{ .x = 0.0, .y = 0.0, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(target_index)) },
- };
- const middle_n = removed_index - target_index;
- const middle: ?dvui.Rect = if (middle_n >= 1)
- switch (axis) {
- .columns => .{ .x = target_rect.x, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(middle_n)), .h = extent_other },
- .rows => .{ .x = 0.0, .y = target_rect.y, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(middle_n)) },
- }
- else
- null;
- const last: dvui.Rect = switch (axis) {
- .columns => .{ .x = removed_rect.x + removed_rect.w, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(slot_count - removed_index - 1)), .h = extent_other },
- .rows => .{ .x = 0.0, .y = removed_rect.y + removed_rect.h, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(slot_count - removed_index - 1)) },
- };
- const middle_screen_offset: dvui.Point = switch (axis) {
- .columns => .{ .x = slot_f, .y = 0.0 },
- .rows => .{ .x = 0.0, .y = slot_f },
- };
- return .{ .first = first, .middle = middle, .last = last, .middle_screen_offset = middle_screen_offset };
- } else {
- const first: dvui.Rect = switch (axis) {
- .columns => .{ .x = 0.0, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(removed_index)), .h = extent_other },
- .rows => .{ .x = 0.0, .y = 0.0, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(removed_index)) },
- };
- const middle_n = target_index - removed_index;
- const middle: ?dvui.Rect = if (middle_n >= 1)
- switch (axis) {
- .columns => .{ .x = removed_rect.x + removed_rect.w, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(middle_n)), .h = extent_other },
- .rows => .{ .x = 0.0, .y = removed_rect.y + removed_rect.h, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(middle_n)) },
- }
- else
- null;
- const last: dvui.Rect = switch (axis) {
- .columns => .{ .x = target_rect.x + target_rect.w, .y = 0.0, .w = slot_f * @as(f32, @floatFromInt(slot_count - target_index - 1)), .h = extent_other },
- .rows => .{ .x = 0.0, .y = target_rect.y + target_rect.h, .w = extent_other, .h = slot_f * @as(f32, @floatFromInt(slot_count - target_index - 1)) },
- };
- const middle_screen_offset: dvui.Point = switch (axis) {
- .columns => .{ .x = -slot_f, .y = 0.0 },
- .rows => .{ .x = 0.0, .y = -slot_f },
- };
- return .{ .first = first, .middle = middle, .last = last, .middle_screen_offset = middle_screen_offset };
- }
-}
-
-fn drawReorderPreviewForAxis(
- self: *FileWidget,
- file: *fizzy.Internal.File,
- axis: ReorderAxis,
- target_index: ?usize,
- removed_index: usize,
-) void {
- self.drawCanvasCheckerboardBackground();
-
- const canvas = &file.editor.canvas;
- const layer_rect = canvas.dataFromScreenRect(canvas.rect);
- const grid_y0 = layer_rect.y;
- const grid_y1 = layer_rect.y + layer_rect.h;
- const grid_x0 = layer_rect.x;
- const grid_x1 = layer_rect.x + layer_rect.w;
- const grid_thickness = std.math.clamp(dvui.currentWindow().natural_scale * canvas.scale, 0, dvui.currentWindow().natural_scale);
- const grid_color = dvui.themeGet().color(.control, .fill);
-
- const removed_rect = switch (axis) {
- .columns => file.columnRect(removed_index),
- .rows => file.rowRect(removed_index),
- };
-
- if (target_index == null) {
- // Dragging but not over canvas: draw full layers unchanged, then dim removed slot only
-
- {
- for (1..file.columns) |i| {
- const gx = @as(f32, @floatFromInt(i * file.column_width));
- dvui.Path.stroke(.{ .points = &.{
- canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y0 }),
- canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y1 }),
- } }, .{ .thickness = grid_thickness, .color = grid_color });
- }
-
- for (1..file.rows) |i| {
- const gy = @as(f32, @floatFromInt(i * file.row_height));
- dvui.Path.stroke(.{ .points = &.{
- canvas.screenFromDataPoint(.{ .x = grid_x0, .y = gy }),
- canvas.screenFromDataPoint(.{ .x = grid_x1, .y = gy }),
- } }, .{ .thickness = grid_thickness, .color = grid_color });
- }
- }
-
- const full_rect = dvui.Rect{
- .x = 0.0,
- .y = 0.0,
- .w = @floatFromInt(file.width()),
- .h = @floatFromInt(file.height()),
- };
- self.renderLayersInDataRect(file, full_rect, null);
- return;
- }
-
- const target_i = target_index.?;
- const same_slot = removed_index == target_i;
-
- const target_rect = switch (axis) {
- .columns => file.columnRect(target_i),
- .rows => file.rowRect(target_i),
- };
-
- const scale = file.editor.canvas.scale;
- const box_dir = switch (axis) {
- .columns => dvui.enums.Direction.horizontal,
- .rows => dvui.enums.Direction.vertical,
- };
-
- defer {
- var target_box_rect = target_rect;
-
- const tl = dvui.currentWindow().mouse_pt.plus(dvui.dragOffset());
- const data_tl = file.editor.canvas.dataFromScreenPoint(tl);
-
- switch (axis) {
- .columns => {
- target_box_rect.x = data_tl.x;
- },
- .rows => {
- target_box_rect.y = data_tl.y;
- },
- }
-
- var animated_target_box_rect = target_rect;
-
- {
- const current_tl: dvui.Point = self.grid_reorder_point orelse .{ .x = 0.0, .y = 0.0 };
-
- if (animated_target_box_rect.topLeft().x != current_tl.x or animated_target_box_rect.topLeft().y != current_tl.y) {
- defer self.grid_reorder_point = animated_target_box_rect.topLeft();
-
- if (self.grid_reorder_point != null) {
- if (dvui.animationGet(self.init_options.file.editor.canvas.id, "reorder_target_rect_x")) |anim| {
- if (anim.end_val != animated_target_box_rect.x) {
- _ = dvui.currentWindow().animations.remove(self.init_options.file.editor.canvas.id.update("reorder_target_rect_x"));
- dvui.animation(self.init_options.file.editor.canvas.id, "reorder_target_rect_x", .{
- .start_val = anim.value(),
- .end_val = animated_target_box_rect.x,
- .end_time = 350_000,
- .easing = dvui.easing.outBack,
- });
- }
- } else if (animated_target_box_rect.x != current_tl.x) {
-
- // If we are here, we need to trigger a new animation to move the resize button rect to the new point
- dvui.animation(self.init_options.file.editor.canvas.id, "reorder_target_rect_x", .{
- .start_val = current_tl.x,
- .end_val = animated_target_box_rect.x,
- .end_time = 350_000,
- .easing = dvui.easing.outBack,
- });
- } else if (dvui.animationGet(self.init_options.file.editor.canvas.id, "reorder_target_rect_y")) |anim| {
- if (anim.end_val != animated_target_box_rect.y) {
- _ = dvui.currentWindow().animations.remove(self.init_options.file.editor.canvas.id.update("reorder_target_rect_y"));
- dvui.animation(self.init_options.file.editor.canvas.id, "reorder_target_rect_y", .{
- .start_val = anim.value(),
- .end_val = animated_target_box_rect.y,
- .end_time = 350_000,
- .easing = dvui.easing.outBack,
- });
- }
- } else if (animated_target_box_rect.y != current_tl.y) {
-
- // If we are here, we need to trigger a new animation to move the resize button rect to the new point
- dvui.animation(self.init_options.file.editor.canvas.id, "reorder_target_rect_y", .{
- .start_val = current_tl.y,
- .end_val = animated_target_box_rect.y,
- .end_time = 350_000,
- .easing = dvui.easing.outBack,
- });
- }
- }
-
- if (dvui.animationGet(self.init_options.file.editor.canvas.id, "reorder_target_rect_x")) |anim| {
- animated_target_box_rect.x = anim.value();
- }
-
- if (dvui.animationGet(self.init_options.file.editor.canvas.id, "reorder_target_rect_y")) |anim| {
- animated_target_box_rect.y = anim.value();
- }
- }
- }
-
- file.editor.canvas.screenFromDataRect(animated_target_box_rect).fill(.all(3.0 / scale), .{
- .color = if (same_slot)
- dvui.themeGet().color(.control, .fill).opacity(0.6)
- else
- dvui.themeGet().color(.highlight, .fill).opacity(0.6),
- .fade = 1.0,
- });
-
- {
- fizzy.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, .{
- .opacity = 0.5,
- });
- }
-
- const target_box = dvui.box(@src(), .{ .dir = box_dir }, .{
- .expand = .none,
- .rect = target_box_rect,
- .border = dvui.Rect.all(0),
- .background = true,
- .color_fill = if (same_slot)
- dvui.themeGet().color(.control, .fill).opacity(0.75)
- else
- dvui.themeGet().color(.control, .fill).opacity(0.75),
- .box_shadow = .{
- .color = .black,
- .offset = .{
- .x = -4 / scale,
- .y = 0.0,
- },
- .alpha = 0.25,
- .fade = 16 / scale,
- .corner_radius = dvui.Rect.all(target_rect.w / 2.0 / scale),
- },
- });
- defer target_box.deinit();
-
- self.renderLayersInDataRect(file, removed_rect, target_box.data().rectScale().r);
- self.drawCheckerboardReorderFloatingStrip(file, removed_rect, target_box.data().rectScale().r, axis, removed_index);
- }
-
- defer {
- const err_color = dvui.themeGet().color(.err, .fill);
- if (!same_slot) {
- // Tint the original removed slot with err color so the canvas matches the
- // dragged-from indicator used in our tree widgets (files / layers / animations).
- file.editor.canvas.screenFromDataRect(removed_rect).fill(.all(0), .{
- .color = err_color.opacity(0.25),
- .fade = 1.0,
- });
- }
-
- if (removed_index != target_i) {
- if (axis == .columns) {
- const top = if (removed_index < target_i) removed_rect.topLeft() else removed_rect.topRight();
- const bottom = if (removed_index < target_i) removed_rect.bottomLeft() else removed_rect.bottomRight();
- dvui.Path.stroke(.{ .points = &.{
- file.editor.canvas.screenFromDataPoint(top),
- file.editor.canvas.screenFromDataPoint(bottom),
- } }, .{ .thickness = 3, .color = err_color });
-
- dvui.Path.fillConvex(.{
- .points = &.{
- file.editor.canvas.screenFromDataPoint(top),
- file.editor.canvas.screenFromDataPoint(top.plus(.{ .x = 5.0 / scale, .y = -10.0 / scale })),
- file.editor.canvas.screenFromDataPoint(top.plus(.{ .x = -5.0 / scale, .y = -10.0 / scale })),
- },
- }, .{
- .color = err_color,
- .fade = 1.0,
- });
-
- dvui.Path.fillConvex(.{
- .points = &.{
- file.editor.canvas.screenFromDataPoint(bottom),
- file.editor.canvas.screenFromDataPoint(bottom.plus(.{ .x = 5.0 / scale, .y = 10.0 / scale })),
- file.editor.canvas.screenFromDataPoint(bottom.plus(.{ .x = -5.0 / scale, .y = 10.0 / scale })),
- },
- }, .{
- .color = err_color,
- .fade = 1.0,
- });
- } else {
- const left = if (removed_index < target_i) removed_rect.topLeft() else removed_rect.bottomLeft();
- const right = if (removed_index < target_i) removed_rect.topRight() else removed_rect.bottomRight();
- dvui.Path.stroke(.{ .points = &.{
- file.editor.canvas.screenFromDataPoint(left),
- file.editor.canvas.screenFromDataPoint(right),
- } }, .{ .thickness = 3, .color = err_color });
-
- dvui.Path.fillConvex(.{
- .points = &.{
- file.editor.canvas.screenFromDataPoint(left),
- file.editor.canvas.screenFromDataPoint(left.plus(.{ .x = -8.0 / scale, .y = -5.0 / scale })),
- file.editor.canvas.screenFromDataPoint(left.plus(.{ .x = -8.0 / scale, .y = 5.0 / scale })),
- },
- }, .{
- .color = err_color,
- .fade = 1.0,
- });
- dvui.Path.fillConvex(.{
- .points = &.{
- file.editor.canvas.screenFromDataPoint(right),
- file.editor.canvas.screenFromDataPoint(right.plus(.{ .x = 8.0 / scale, .y = -5.0 / scale })),
- file.editor.canvas.screenFromDataPoint(right.plus(.{ .x = 8.0 / scale, .y = 5.0 / scale })),
- },
- }, .{
- .color = err_color,
- .fade = 1.0,
- });
- }
- }
- }
-
- const segments = reorderSegmentRects(axis, file, target_i, removed_index, target_rect, removed_rect);
-
- self.renderLayersInDataRect(file, segments.first, null);
- if (segments.middle) |middle_rect| {
- const screen_rect = canvas.screenFromDataRect(middle_rect.offsetPoint(segments.middle_screen_offset));
- self.renderLayersInDataRect(file, middle_rect, screen_rect);
- }
- if (segments.last.w > 0.0 and segments.last.h > 0.0) {
- self.renderLayersInDataRect(file, segments.last, null);
- }
-
- {
- for (1..file.columns) |i| {
- const gx = @as(f32, @floatFromInt(i * file.column_width));
- dvui.Path.stroke(.{ .points = &.{
- canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y0 }),
- canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y1 }),
- } }, .{ .thickness = grid_thickness, .color = grid_color });
- }
-
- for (1..file.rows) |i| {
- const gy = @as(f32, @floatFromInt(i * file.row_height));
- dvui.Path.stroke(.{ .points = &.{
- canvas.screenFromDataPoint(.{ .x = grid_x0, .y = gy }),
- canvas.screenFromDataPoint(.{ .x = grid_x1, .y = gy }),
- } }, .{ .thickness = grid_thickness, .color = grid_color });
- }
- }
-}
-
-pub fn drawCellReorderPreview(self: *FileWidget) void {
- const file = self.init_options.file;
- self.drawCanvasCheckerboardBackground();
-
- const canvas = &file.editor.canvas;
- const layer_rect = canvas.dataFromScreenRect(canvas.rect);
- const grid_y0 = layer_rect.y;
- const grid_y1 = layer_rect.y + layer_rect.h;
- const grid_x0 = layer_rect.x;
- const grid_x1 = layer_rect.x + layer_rect.w;
- const grid_thickness = std.math.clamp(dvui.currentWindow().natural_scale * canvas.scale, 0, dvui.currentWindow().natural_scale);
- const grid_color = dvui.themeGet().color(.control, .fill);
-
- if (self.removed_sprite_indices) |removed_sprite_indices| {
- const insert_before_sprite_indices = dvui.currentWindow().arena().alloc(usize, removed_sprite_indices.len) catch {
- dvui.log.err("Failed to allocate insert before sprite indices", .{});
- return;
- };
-
- for (removed_sprite_indices, 0..) |removed_sprite_index, i| {
- if (self.cell_reorder_point) |cell_reorder_point| {
- const removed_sprite_rect = file.spriteRect(removed_sprite_index);
- const current_point = file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
- const difference = current_point.diff(cell_reorder_point);
-
- if (file.spriteIndex(removed_sprite_rect.center().plus(difference))) |index| {
- insert_before_sprite_indices[i] = index;
- } else {
- insert_before_sprite_indices[i] = file.wrappedSpriteIndex(removed_sprite_rect.center().plus(difference));
- }
- }
- }
-
- const new_sprite_indices = file.getReorderIndices(
- dvui.currentWindow().arena(),
- removed_sprite_indices,
- insert_before_sprite_indices,
- .replace,
- false,
- ) catch |err| {
- dvui.log.err("Failed to get reorder indices {any}", .{err});
- return;
- };
-
- const file_width = @as(f32, @floatFromInt(file.width()));
- const file_height = @as(f32, @floatFromInt(file.height()));
-
- { // Draw all sprites except the ones that are being dragged
- var builder = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), file.spriteCount() * 4, file.spriteCount() * 6) catch |err| {
- dvui.log.err("Failed to initialize triangles builder: {any}", .{err});
- return;
- };
- defer builder.deinit(dvui.currentWindow().arena());
-
- for (0..file.spriteCount()) |i| {
- const new_index = new_sprite_indices[i];
- const new_rect = file.spriteRect(new_index);
- const new_rect_physical = file.editor.canvas.screenFromDataRect(new_rect);
- const current_rect = file.spriteRect(i);
-
- const dragging: bool = file.editor.selected_sprites.isSet(i);
-
- // UVs: normalize sprite rect in data space to 0-1 over the layer texture (same size as file).
- // 0: TopLeft → uv (umin, vmin)
- // 1: TopRight → uv (umax, vmin)
- // 2: BottomRight → uv (umax, vmax)
- // 3: BottomLeft → uv (umin, vmax)
- const umin = current_rect.x / file_width;
- const vmin = current_rect.y / file_height;
- const umax = (current_rect.x + current_rect.w) / file_width;
- const vmax = (current_rect.y + current_rect.h) / file_height;
-
- const col = if (!dragging) dvui.Color.PMA.fromColor(dvui.Color.white) else dvui.Color.PMA.fromColor(dvui.Color.transparent);
-
- builder.appendVertex(.{ .pos = new_rect_physical.topLeft(), .col = col, .uv = .{ umin, vmin } });
- builder.appendVertex(.{ .pos = new_rect_physical.topRight(), .col = col, .uv = .{ umax, vmin } });
- builder.appendVertex(.{ .pos = new_rect_physical.bottomRight(), .col = col, .uv = .{ umax, vmax } });
- builder.appendVertex(.{ .pos = new_rect_physical.bottomLeft(), .col = col, .uv = .{ umin, vmax } });
-
- const base: dvui.Vertex.Index = @intCast(i * 4);
- builder.appendTriangles(&.{ base + 1, base + 0, base + 3, base + 1, base + 3, base + 2 });
- }
-
- {
- var temp_selected_sprite = file.editor.selected_sprites.clone(dvui.currentWindow().arena()) catch {
- dvui.log.err("Failed to clone selected sprites", .{});
- return;
- };
-
- var temp_insert_before_sprite = file.editor.selected_sprites.clone(dvui.currentWindow().arena()) catch {
- dvui.log.err("Failed to clone selected sprites", .{});
- return;
- };
-
- temp_insert_before_sprite.setRangeValue(.{ .start = 0, .end = file.spriteCount() }, false);
-
- for (insert_before_sprite_indices) |insert_before_sprite_index| {
- temp_selected_sprite.set(insert_before_sprite_index);
- temp_insert_before_sprite.set(insert_before_sprite_index);
- }
-
- var iter = temp_selected_sprite.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |sprite_index| {
- const image_rect = file.spriteRect(sprite_index);
-
- const image_rect_scale: dvui.RectScale = .{
- .r = self.init_options.file.editor.canvas.screenFromDataRect(image_rect),
- .s = self.init_options.file.editor.canvas.scale,
- };
-
- const highlight = dvui.themeGet().color(.highlight, .fill).opacity(0.5);
- const err = dvui.themeGet().color(.err, .fill).opacity(0.5);
-
- const color = if (temp_insert_before_sprite.isSet(sprite_index) and file.editor.selected_sprites.isSet(sprite_index)) highlight.average(err) else if (temp_insert_before_sprite.isSet(sprite_index)) highlight else if (file.editor.selected_sprites.isSet(sprite_index)) err else highlight;
-
- image_rect_scale.r.fill(.all(0), .{ .color = color, .fade = 1.5 });
-
- const left_index = file.spriteIndex(image_rect.center().diff(.{ .x = @as(f32, @floatFromInt(file.column_width)) }));
- const right_index = file.spriteIndex(image_rect.center().plus(.{ .x = @as(f32, @floatFromInt(file.column_width)) }));
- const top_index = file.spriteIndex(image_rect.center().diff(.{ .y = @as(f32, @floatFromInt(file.row_height)) }));
- const bottom_index = file.spriteIndex(image_rect.center().plus(.{ .y = @as(f32, @floatFromInt(file.row_height)) }));
-
- if (left_index) |left_index_value| {
- if (!temp_selected_sprite.isSet(left_index_value)) {
- fizzy.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 });
- }
- }
- if (top_index) |top_index_value| {
- if (!temp_selected_sprite.isSet(top_index_value)) {
- fizzy.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 });
- }
- }
- }
- }
-
- { // Render once for each layer
- const grid_triangles = builder.build();
-
- var i: usize = file.layers.len;
-
- while (i > 0) {
- i -= 1;
- const source = file.layers.items(.source)[i];
- dvui.renderTriangles(grid_triangles, source.getTexture() catch null) catch {
- dvui.log.err("Failed to render triangles", .{});
- return;
- };
- }
- }
-
- {
- for (1..file.columns) |i| {
- const gx = @as(f32, @floatFromInt(i * file.column_width));
- dvui.Path.stroke(.{ .points = &.{
- canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y0 }),
- canvas.screenFromDataPoint(.{ .x = gx, .y = grid_y1 }),
- } }, .{ .thickness = grid_thickness, .color = grid_color });
- }
-
- for (1..file.rows) |i| {
- const gy = @as(f32, @floatFromInt(i * file.row_height));
- dvui.Path.stroke(.{ .points = &.{
- canvas.screenFromDataPoint(.{ .x = grid_x0, .y = gy }),
- canvas.screenFromDataPoint(.{ .x = grid_x1, .y = gy }),
- } }, .{ .thickness = grid_thickness, .color = grid_color });
- }
- }
- }
-
- { // Render the sprites that are being dragged
- var builder = dvui.Triangles.Builder.init(dvui.currentWindow().arena(), file.spriteCount() * 4, file.spriteCount() * 6) catch |err| {
- dvui.log.err("Failed to initialize triangles builder: {any}", .{err});
- return;
- };
- defer builder.deinit(dvui.currentWindow().arena());
-
- for (removed_sprite_indices, 0..) |removed_sprite_index, i| {
- const base_quad: dvui.Vertex.Index = @intCast(i * 4);
-
- var shadow_path = dvui.Path.Builder.init(dvui.currentWindow().lifo());
- defer shadow_path.deinit();
-
- const new_rect = file.spriteRect(removed_sprite_index);
- var new_rect_physical = file.editor.canvas.screenFromDataRect(new_rect);
-
- if (self.cell_reorder_point) |cell_reorder_point| {
- new_rect_physical = new_rect_physical.offsetPoint(dvui.currentWindow().mouse_pt.diff(file.editor.canvas.screenFromDataPoint(cell_reorder_point)));
- }
-
- // UVs: normalize sprite rect in data space to 0-1 over the layer texture (same size as file).
- // 0: TopLeft → uv (umin, vmin)
- // 1: TopRight → uv (umax, vmin)
- // 2: BottomRight → uv (umax, vmax)
- // 3: BottomLeft → uv (umin, vmax)
- const umin = new_rect.x / file_width;
- const vmin = new_rect.y / file_height;
- const umax = (new_rect.x + new_rect.w) / file_width;
- const vmax = (new_rect.y + new_rect.h) / file_height;
-
- builder.appendVertex(.{
- .pos = new_rect_physical.topLeft(),
- .col = .white,
- .uv = .{ umin, vmin },
- });
- builder.appendVertex(.{
- .pos = new_rect_physical.topRight(),
- .col = .white,
- .uv = .{ umax, vmin },
- });
- builder.appendVertex(.{
- .pos = new_rect_physical.bottomRight(),
- .col = .white,
- .uv = .{ umax, vmax },
- });
- builder.appendVertex(.{
- .pos = new_rect_physical.bottomLeft(),
- .col = .white,
- .uv = .{ umin, vmax },
- });
-
- builder.appendTriangles(&.{ base_quad + 1, base_quad + 0, base_quad + 3, base_quad + 1, base_quad + 3, base_quad + 2 });
- }
-
- const triangles = builder.build();
-
- var i: usize = file.layers.len;
- while (i > 0) {
- i -= 1;
- const source = file.layers.items(.source)[i];
- dvui.renderTriangles(triangles, source.getTexture() catch null) catch {
- dvui.log.err("Failed to render triangles", .{});
- return;
- };
- }
- }
- }
-}
-
-/// Edge-auto-pan for the grid resize drag. Pushes the canvas viewport when the cursor
-/// reaches/exceeds the scroll container edges; velocity ramps up as the cursor moves
-/// further past the edge, so the user can grow the grid well beyond the current view.
-fn autoPanForResize(self: *FileWidget, mouse_pt: dvui.Point.Physical) void {
- const canvas = &self.init_options.file.editor.canvas;
- const rs = canvas.scroll_container.data().contentRectScale();
- const r = rs.r;
- const win = dvui.currentWindow();
- const win_r = win.rect_pixels;
-
- // Distance past the edge (in screen px) drives velocity. Once `over >= ramp`, we're at
- // max speed; this prevents jitter from a cursor sitting exactly on the boundary.
- const ramp: f32 = 80.0 * win.natural_scale;
- const max_speed_px_per_sec: f32 = 2500.0 * win.natural_scale;
-
- // OS clamps the cursor at the window edge — if the canvas sits flush against that edge,
- // the user can only push a couple of pixels past `r`, leaving the ramp linger near zero.
- // Treat "cursor pinned to the window edge AND past the canvas edge" as full velocity.
- const edge_eps: f32 = 2.0 * win.natural_scale;
-
- var vx: f32 = 0;
- var vy: f32 = 0;
-
- if (mouse_pt.x < r.x) {
- const pinned = mouse_pt.x <= win_r.x + edge_eps;
- const t: f32 = if (pinned) 1.0 else @min((r.x - mouse_pt.x) / ramp, 1.0);
- vx = -t * max_speed_px_per_sec;
- } else if (mouse_pt.x > r.x + r.w) {
- const pinned = mouse_pt.x >= win_r.x + win_r.w - edge_eps;
- const t: f32 = if (pinned) 1.0 else @min((mouse_pt.x - (r.x + r.w)) / ramp, 1.0);
- vx = t * max_speed_px_per_sec;
- }
-
- if (mouse_pt.y < r.y) {
- const pinned = mouse_pt.y <= win_r.y + edge_eps;
- const t: f32 = if (pinned) 1.0 else @min((r.y - mouse_pt.y) / ramp, 1.0);
- vy = -t * max_speed_px_per_sec;
- } else if (mouse_pt.y > r.y + r.h) {
- const pinned = mouse_pt.y >= win_r.y + win_r.h - edge_eps;
- const t: f32 = if (pinned) 1.0 else @min((mouse_pt.y - (r.y + r.h)) / ramp, 1.0);
- vy = t * max_speed_px_per_sec;
- }
-
- if (vx == 0 and vy == 0) return;
- if (rs.s <= 0) return;
-
- const dt = dvui.secondsSinceLastFrame();
- const si = &canvas.scroll_info;
- si.viewport.x += vx * dt / rs.s;
- si.viewport.y += vy * dt / rs.s;
-
- // Grow virtual_size eagerly when panning down/right so we stay inside `scrollMax` and
- // the scroll container's bounce-back doesn't claw us back ~4 screen-px per frame.
- // (The up/left side is implicitly handled by CanvasWidget.deinit's bbox normalization,
- // which is why those directions already feel fast.)
- if (vx > 0) {
- const need_w = si.viewport.x + si.viewport.w;
- if (si.virtual_size.w < need_w) si.virtual_size.w = need_w;
- }
- if (vy > 0) {
- const need_h = si.viewport.y + si.viewport.h;
- if (si.virtual_size.h < need_h) si.virtual_size.h = need_h;
- }
-
- dvui.refresh(null, @src(), canvas.scroll_container.data().id);
- // Force a motion event next frame so the resize handle keeps tracking against the
- // newly-scrolled viewport even if the user holds the cursor stationary past the edge.
- dvui.currentWindow().inject_motion_event = true;
-}
-
-pub fn processResize(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .pointer) return;
- if (self.init_options.file.editor.transform != null) return;
- if (self.sample_data_point != null) return;
-
- const file = self.init_options.file;
- const file_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) });
-
- for (dvui.events()) |*e| {
- if (!self.init_options.file.editor.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- switch (e.evt) {
- .mouse => |me| {
- if (me.action == .release and me.button.pointer()) {
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.id);
- }
-
- // Auto-pan the canvas while dragging the resize handle so the user can
- // grow the grid past the viewport edge without zooming out first.
- // `dvui.scrollDrag` caps at ~5 screen px/frame which feels glacial on a
- // wide canvas; we drive the canvas viewport directly with a velocity that
- // ramps up as the cursor pushes past the edge.
- if (me.action == .motion and dvui.dragName("resize_drag")) {
- self.autoPanForResize(me.p);
- }
- },
- else => {},
- }
- }
-
- {
- const min_size: f32 = @as(f32, @floatFromInt(@min(file.column_width, file.row_height)));
- const baseline_size: f32 = 64.0;
- const baseline_scale: f32 = baseline_size / min_size;
- const target_button_height: f32 = min_size / 3.0;
- const button_size: f32 = std.math.clamp((target_button_height * 1.0 / self.init_options.file.editor.canvas.scale) * baseline_scale, 0.0, min_size);
- var resize_button_rect = dvui.Rect{
- .x = file_rect.x + file_rect.w - button_size / 2.0,
- .y = file_rect.y + file_rect.h - button_size / 2.0,
- .w = button_size,
- .h = button_size,
- };
-
- const offset_data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt).plus(.{
- .x = @as(f32, @floatFromInt(file.column_width)) / 2.0,
- .y = @as(f32, @floatFromInt(file.row_height)) / 2.0,
- });
-
- const dragging = dvui.dragging(dvui.currentWindow().mouse_pt, "resize_drag") != null and self.active();
-
- if (self.resize_data_point != null or dragging) {
- const current_point: dvui.Point = self.resize_data_point orelse .{ .x = 0.0, .y = 0.0 };
- var new_point = self.init_options.file.spritePoint(offset_data_point);
-
- if (current_point.x != new_point.x or current_point.y != new_point.y) {
- new_point.x = std.math.clamp(new_point.x, @as(f32, @floatFromInt(file.column_width)), std.math.floatMax(f32));
- new_point.y = std.math.clamp(new_point.y, @as(f32, @floatFromInt(file.row_height)), std.math.floatMax(f32));
-
- if (self.resize_data_point != null) {
- if (dvui.animationGet(self.init_options.file.editor.canvas.id, "resize_button_rect_x")) |anim| {
- _ = dvui.currentWindow().animations.remove(self.init_options.file.editor.canvas.id.update("resize_button_rect_x"));
- dvui.animation(self.init_options.file.editor.canvas.id, "resize_button_rect_x", .{
- .start_val = anim.value(),
- .end_val = new_point.x,
- .end_time = 250_000,
- .easing = dvui.easing.outBack,
- });
- } else {
-
- // If we are here, we need to trigger a new animation to move the resize button rect to the new point
- dvui.animation(self.init_options.file.editor.canvas.id, "resize_button_rect_x", .{
- .start_val = current_point.x,
- .end_val = new_point.x,
- .end_time = 250_000,
- .easing = dvui.easing.outBack,
- });
- }
-
- if (dvui.animationGet(self.init_options.file.editor.canvas.id, "resize_button_rect_y")) |anim| {
- _ = dvui.currentWindow().animations.remove(self.init_options.file.editor.canvas.id.update("resize_button_rect_y"));
- dvui.animation(self.init_options.file.editor.canvas.id, "resize_button_rect_y", .{
- .start_val = anim.value(),
- .end_val = new_point.y,
- .end_time = 250_000,
- .easing = dvui.easing.outBack,
- });
- } else {
-
- // If we are here, we need to trigger a new animation to move the resize button rect to the new point
- dvui.animation(self.init_options.file.editor.canvas.id, "resize_button_rect_y", .{
- .start_val = current_point.y,
- .end_val = new_point.y,
- .end_time = 250_000,
- .easing = dvui.easing.outBack,
- });
- }
- }
-
- self.resize_data_point = new_point;
- }
-
- if (dvui.animationGet(self.init_options.file.editor.canvas.id, "resize_button_rect_x")) |anim| {
- resize_button_rect.x = anim.value();
- } else {
- resize_button_rect.x = new_point.x;
- }
-
- if (dvui.animationGet(self.init_options.file.editor.canvas.id, "resize_button_rect_y")) |anim| {
- resize_button_rect.y = anim.value();
- } else {
- resize_button_rect.y = new_point.y;
- }
- }
-
- var icon_button: dvui.ButtonWidget = undefined;
- icon_button.init(@src(), .{ .draw_focus = false }, .{
- .rect = resize_button_rect,
- .border = dvui.Rect.all(0),
- .margin = .all(0),
- .padding = .all(0),
- .background = false,
- });
- defer icon_button.deinit();
- icon_button.processEvents();
-
- if (dragging) {
- var bounds_rect = dvui.Rect.Physical.fromSize(.{ .w = @as(f32, @floatFromInt(file.column_width)), .h = @as(f32, @floatFromInt(file.row_height)) });
- bounds_rect = bounds_rect.scale(self.init_options.file.editor.canvas.scale * dvui.currentWindow().natural_scale, dvui.Rect.Physical);
- bounds_rect.x = icon_button.data().contentRectScale().r.topLeft().x - bounds_rect.w / 2.0;
- bounds_rect.y = icon_button.data().contentRectScale().r.topLeft().y - bounds_rect.h / 2.0;
-
- var path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- path.addRect(bounds_rect, .{ .x = bounds_rect.w / 2.0, .y = bounds_rect.h / 2.0, .w = bounds_rect.w / 2.0, .h = bounds_rect.h / 2.0 });
- const built = path.build();
- built.fillConvex(.{ .color = dvui.themeGet().color(.window, .fill).opacity(0.5), .fade = 1.5 });
- built.stroke(.{ .color = dvui.themeGet().color(.control, .text).opacity(0.5), .thickness = 1.0, .closed = true });
-
- path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- path.addPoint(icon_button.data().contentRectScale().r.topLeft());
- path.addRect(.{
- .x = dvui.currentWindow().mouse_pt.x - icon_button.data().contentRectScale().r.w / 8.0,
- .y = dvui.currentWindow().mouse_pt.y - icon_button.data().contentRectScale().r.h / 8.0,
- .w = icon_button.data().contentRectScale().r.w / 4.0,
- .h = icon_button.data().contentRectScale().r.h / 4.0,
- }, .all(icon_button.data().contentRectScale().r.w / 8.0));
- path.build().fillConvex(.{ .color = dvui.themeGet().color(.control, .text).opacity(0.5), .fade = 1.5 });
-
- path = dvui.Path.Builder.init(dvui.currentWindow().arena());
- path.addRect(.{
- .x = dvui.currentWindow().mouse_pt.x - icon_button.data().contentRectScale().r.w / 8.0,
- .y = dvui.currentWindow().mouse_pt.y - icon_button.data().contentRectScale().r.h / 8.0,
- .w = icon_button.data().contentRectScale().r.w / 4.0,
- .h = icon_button.data().contentRectScale().r.h / 4.0,
- }, .all(icon_button.data().contentRectScale().r.w / 8.0));
- path.build().fillConvex(.{ .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), .fade = 1.5 });
- } else {
- dvui.icon(@src(), "resize", if (dragging) icons.tvg.lucide.move else icons.tvg.lucide.@"move-diagonal-2", .{
- .stroke_color = if (icon_button.hover) dvui.themeGet().color(.highlight, .fill) else dvui.themeGet().color(.control, .text),
- }, .{
- .expand = .ratio,
- .min_size_content = .{ .w = 1.0, .h = 1.0 },
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .border = dvui.Rect.all(0),
- .margin = .all(0),
- .padding = .all(0),
- .background = false,
- .rotation = dvui.math.degreesToRadians(0.0),
- });
- }
-
- if (icon_button.pressed()) {
- dvui.dragStart(
- dvui.currentWindow().mouse_pt,
- .{ .name = "resize_drag", .cursor = .hidden },
- );
- dvui.captureMouse(self.init_options.file.editor.canvas.scroll_container.data(), 0);
- }
-
- if (dragging == false) {
- if (self.resize_data_point) |resize_data_point| {
- self.init_options.file.resize(.{
- .columns = @divTrunc(@as(u32, @intFromFloat(resize_data_point.x)), self.init_options.file.column_width),
- .rows = @divTrunc(@as(u32, @intFromFloat(resize_data_point.y)), self.init_options.file.row_height),
- .history = true,
- }) catch |err| {
- dvui.log.err("Failed to resize file: {s}", .{@errorName(err)});
- };
- self.resize_data_point = null;
- dvui.dragEnd();
- dvui.captureMouse(null, 0);
- dvui.refresh(null, @src(), self.init_options.file.editor.canvas.id);
- }
- }
- }
-}
-
-pub fn processEvents(self: *FileWidget) void {
- const transform = self.init_options.file.editor.transform != null;
- const reorder = self.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;
-
- // Try to ensure that selected animation frame index is valid
- if (self.init_options.file.selected_animation_index) |ai| {
- if (self.init_options.file.animations.get(ai).frames.len > 0) {
- if (self.init_options.file.selected_animation_frame_index >= self.init_options.file.animations.get(ai).frames.len) {
- self.init_options.file.selected_animation_frame_index = self.init_options.file.animations.get(ai).frames.len - 1;
- }
- } else {
- self.init_options.file.selected_animation_frame_index = 0;
- }
- }
-
- defer self.previous_mods = dvui.currentWindow().modifiers;
-
- defer if (self.drag_data_point) |drag_data_point| {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "drag_data_point", drag_data_point);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "drag_data_point");
- };
-
- defer if (self.transform_aspect_w) |v| {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "transform_aspect_w", v);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "transform_aspect_w");
- };
- defer if (self.transform_aspect_h) |v| {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "transform_aspect_h", v);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "transform_aspect_h");
- };
-
- defer if (self.sample_data_point) |sample_data_point| {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "sample_data_point", sample_data_point);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "sample_data_point");
- };
-
- defer if (self.resize_data_point) |resize_data_point| {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "resize_data_point", resize_data_point);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "resize_data_point");
- };
-
- defer if (self.grid_reorder_point) |grid_reorder_point| {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "grid_reorder_point", grid_reorder_point);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "grid_reorder_point");
- };
-
- defer if (self.cell_reorder_point) |cell_reorder_point| {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "cell_reorder_point", cell_reorder_point);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "cell_reorder_point");
- };
-
- defer if (self.sample_key_down) {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "sample_key_down", self.sample_key_down);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "sample_key_down");
- };
-
- defer if (self.right_mouse_down) {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "right_mouse_down", self.right_mouse_down);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "right_mouse_down");
- };
-
- defer if (self.left_mouse_down) {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "left_mouse_down", self.left_mouse_down);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "left_mouse_down");
- };
-
- defer if (self.hide_distance_bubble) {
- dvui.dataSet(null, self.init_options.file.editor.canvas.id, "hide_distance_bubble", self.hide_distance_bubble);
- } else {
- dvui.dataRemove(null, self.init_options.file.editor.canvas.id, "hide_distance_bubble");
- };
-
- const canvas_ptr = &self.init_options.file.editor.canvas;
- const mouse_pt = dvui.currentWindow().mouse_pt;
- canvas_ptr.hovered = !fizzy.dvui.canvasPointerInputSuppressed() and
- canvas_ptr.pointerOverDrawable(mouse_pt);
-
- // Cursor-leave: when hover transitions true → false, the last brush/fill preview
- // pixels are still painted on the temp layer. Clear them exactly once on the way out
- // (we deliberately do NOT clear every frame — the temp layer can be 64 MB on large
- // files and clearing it each frame murders performance for nothing). Also restore the
- // OS cursor — drawCursor hides it while over the image, and explorer empty areas do not
- // call cursorSet, so it would otherwise stay hidden after crossing into the explorer.
- if (canvas_ptr.prev_hovered and !canvas_ptr.hovered) {
- _ = dvui.cursorSet(.arrow);
- if (self.init_options.file.editor.temp_layer_has_content) {
- resetTempLayerPreview(&self.init_options.file.editor);
- dvui.refresh(null, @src(), canvas_ptr.scroll_container.data().id);
- }
- }
-
- // Input-mode flip (mouse ↔ touch): clear the temp preview exactly once. On touch
- // there's no cursor to chase and the finger occludes the preview anyway — tools below
- // skip drawing it altogether — but any pixels left over from the prior mode would
- // otherwise sit there until the user moves a mouse / lifts a finger.
- if (canvas_ptr.prev_last_input_was_touch != canvas_ptr.last_input_was_touch and
- self.init_options.file.editor.temp_layer_has_content)
- {
- resetTempLayerPreview(&self.init_options.file.editor);
- dvui.refresh(null, @src(), canvas_ptr.scroll_container.data().id);
- }
-
- // Gesture takeover: if a 2-finger pan just activated while a stroke / fill drag was
- // in progress, the release event for that touch was swallowed by the canvas — the
- // pixels were drawn but the history entry never got appended. Finalize the stroke
- // here so undo still works.
- if (!canvas_ptr.prev_gesture_active and canvas_ptr.gesture_active and self.init_options.file.editor.active_drawing) {
- self.cancelActiveDrawing();
- }
-
- // Hover alone is enough for brush/bucket/selection previews (e.g. sampling a color on one
- // document while hovering another). Pixel edits are still gated inside each tool via `active()`.
- // Skip everything when a 2-finger pan is active or while we're still deciding whether the
- // current single touch will become one — otherwise the bucket/pencil hover preview would
- // flash on the pinned finger as the user starts a pan gesture.
- if (self.hovered() and !self.init_options.file.editor.canvas.gestureActive()) {
- const pe_t0 = fizzy.perf.processEventsBegin();
- defer fizzy.perf.processEventsEnd(pe_t0);
-
- resetTempLayerPreview(&self.init_options.file.editor);
-
- {
- const mask_t0 = fizzy.perf.updateMaskBegin();
- defer fizzy.perf.updateMaskEnd(mask_t0);
- self.updateActiveLayerMask();
- }
-
- if (fizzy.editor.tools.current == .selection) {
- if (dvui.timerDoneOrNone(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- self.init_options.file.editor.checkerboard.toggleAll();
-
- dvui.timer(self.init_options.file.editor.canvas.scroll_container.data().id, 500_000);
- }
- }
-
- if (self.init_options.file.editor.transform == null) {
- const tool_t0 = fizzy.perf.toolProcessBegin();
- switch (fizzy.editor.tools.current) {
- .bucket => self.processFill(),
- .pencil, .eraser => self.processStroke(),
- .selection => self.processSelection(),
- else => {},
- }
- fizzy.perf.toolProcessEnd(tool_t0);
- }
- } else if (self.hovered() and self.init_options.file.editor.canvas.gestureActive()) {
- // A 2-finger gesture (or its pending evaluation) just took over. Make sure any
- // hover brush / fill preview from a prior frame is cleared so it doesn't linger
- // under the panning fingers.
- resetTempLayerPreview(&self.init_options.file.editor);
- }
-
- // Use `active()`, not `hovered()`: `hovered` is the drawable artboard (`pointerOverDrawable`),
- // not the full scroll viewport or unclipped `canvas.rect`. The transform quad can extend outside
- // that rect; we still need presses/drags there and continued drags after the cursor leaves the
- // image (capture + motion).
- const suppress = self.init_options.file.editor.canvas.gestureActive();
-
- if (self.active() and self.init_options.file.editor.transform != null and !suppress) {
- self.processTransform();
- }
-
- // While the sample key is held, dim non-target layers like layer-list hover does. This
- // must run before `drawLayers` because `renderLayers` picks dimmed vs normal triangles at
- // call time based on `peek_layer_index`; `Editor.draw` resets peek at end of frame, so
- // setting it inside `processSample` (after `drawLayers`) wouldn't take effect.
- if (self.sample_key_down) {
- const sample_mouse = dvui.currentWindow().mouse_pt;
- if (self.init_options.file.editor.canvas.samplePointerInViewport(sample_mouse)) {
- const sample_point = self.init_options.file.editor.canvas.dataFromScreenPoint(sample_mouse);
- peekLayerAtPoint(self.init_options.file, sample_point);
- }
- }
-
- self.drawLayers();
-
- if (self.hovered() or dvui.captured(self.init_options.file.editor.canvas.scroll_container.data().id)) {
- self.drawBoxSelectionMarqueeOutline();
- }
-
- if ((self.active() or self.hovered()) and !transform and !reorder) {
- self.drawSpriteBubbles();
- }
-
- if (self.active() and !suppress) {
- self.processCellReorder();
- }
-
- if ((self.active() or self.hovered()) and !transform and !reorder and !suppress) {
- self.processResize();
-
- self.processAnimationSelection();
-
- self.processSpriteSelection();
- self.drawSpriteSelection();
- }
-
- // Draw shadows for the scroll container
- 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, .{});
-
- self.drawTransform();
- self.processSample();
- self.drawSample();
- if (self.hovered())
- self.drawCursor();
-
- // Then process the scroll and zoom events last
- self.init_options.file.editor.canvas.processEvents();
-}
-
-pub fn deinit(self: *FileWidget) void {
- self.init_options.file.editor.canvas.deinit();
-
- self.* = undefined;
-}
-
-pub fn hovered(self: *FileWidget) bool {
- if (fizzy.dvui.canvasPointerInputSuppressed()) return false;
- return self.init_options.file.editor.canvas.hovered;
-}
-
-/// Tear down an in-progress brush/eraser stroke when a 2-finger pan steals the touch.
-/// The release event never reaches `processStroke`, so its history-commit code doesn't
-/// run. We finalize the deferred undo snapshot here so the pixels already drawn are
-/// preserved in the undo stack, and drop the related capture/drag state.
-fn cancelActiveDrawing(self: *FileWidget) void {
- const file = self.init_options.file;
- if (!file.editor.active_drawing) return;
-
- // Commit whatever pixels were drawn so far — `strokeUndoCommit` is safe even if the
- // stroke buffer ended up empty (it appends nothing in that case).
- file.strokeUndoCommit();
-
- if (dvui.captured(file.editor.canvas.scroll_container.data().id)) {
- dvui.captureMouse(null, 0);
- }
- if (dvui.dragName("stroke_drag")) {
- dvui.dragEnd();
- }
-
- resetTempLayerPreview(&file.editor);
-
- file.editor.active_drawing = false;
- file.editor.layer_composite_dirty = true;
- file.editor.layer_composite_frame_built = 0;
- self.drag_data_point = null;
-
- dvui.refresh(null, @src(), file.editor.canvas.scroll_container.data().id);
-}
-
-/// Computes the pixel bounding rect of a brush draw, clamped to image bounds.
-fn tempBrushRect(point: dvui.Point, stroke_size: usize, img_w: u32, img_h: u32) dvui.Rect {
- const s: i32 = @intCast(stroke_size);
- const half: i32 = @divFloor(s, 2);
- const px: i32 = @intFromFloat(@floor(point.x));
- const py: i32 = @intFromFloat(@floor(point.y));
- const w: i32 = @intCast(img_w);
- const h: i32 = @intCast(img_h);
- const x0 = @max(px - half, 0);
- const y0 = @max(py - half, 0);
- const x1 = @min(px - half + s, w);
- const y1 = @min(py - half + s, h);
- return .{
- .x = @floatFromInt(x0),
- .y = @floatFromInt(y0),
- .w = @floatFromInt(@max(x1 - x0, 0)),
- .h = @floatFromInt(@max(y1 - y0, 0)),
- };
-}
-
-/// Data-space rect of the on-screen canvas, outset by brush size so edge stamps are not clipped.
-fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const fizzy.Internal.File, stroke_size: usize) dvui.Rect {
- const vis = canvas.dataFromScreenRect(canvas.rect);
- const m: f32 = @floatFromInt(stroke_size);
- const inflated = vis.outsetAll(m);
- const iw = @as(f32, @floatFromInt(file.width()));
- const ih = @as(f32, @floatFromInt(file.height()));
- return dvui.Rect.intersect(inflated, .{ .x = 0, .y = 0, .w = iw, .h = ih });
-}
-
-fn expandTempGpuDirtyRect(editor: *fizzy.Internal.File.EditorData, rect: dvui.Rect) void {
- if (editor.temp_gpu_dirty_rect) |existing| {
- editor.temp_gpu_dirty_rect = existing.unionWith(rect);
- } else {
- editor.temp_gpu_dirty_rect = rect;
- }
- // The temp layer's pixels just changed; let content-signature consumers
- // (the sprite preview composite) see it even though the buffer ptr is stable.
- editor.temp_layer_generation +%= 1;
-}
-
-/// Clears the pixels covered by the current temp preview dirty rect, then
-/// resets the tracking state. Used before redrawing the brush preview at a
-/// new position.
-fn clearTempPreview(editor: *fizzy.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);
- expandTempGpuDirtyRect(editor, dirty);
- }
- }
- editor.temp_preview_dirty_rect = null;
-}
-
-/// Clears the temporary brush preview layer and marks GPU/composite dirty.
-fn resetTempLayerPreview(editor: *fizzy.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);
- expandTempGpuDirtyRect(editor, dirty);
- }
- editor.temp_preview_dirty_rect = null;
- } else if (editor.temp_layer_has_content) {
- @memset(editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 });
- editor.temporary_layer.invalidate();
- editor.temp_gpu_dirty_rect = null;
- editor.temp_layer_generation +%= 1;
- }
- editor.temp_layer_has_content = false;
- editor.temporary_layer.clearMask();
-}
-
-test {
- @import("std").testing.refAllDecls(@This());
-}
diff --git a/src/editor/widgets/ImageWidget.zig b/src/editor/widgets/ImageWidget.zig
deleted file mode 100644
index 12e8ed47..00000000
--- a/src/editor/widgets/ImageWidget.zig
+++ /dev/null
@@ -1,474 +0,0 @@
-pub const ImageWidget = @This();
-const CanvasWidget = @import("CanvasWidget.zig");
-
-init_options: InitOptions,
-options: Options,
-last_mouse_event: ?dvui.Event = null,
-drag_data_point: ?dvui.Point = null,
-sample_data_point: ?dvui.Point = null,
-previous_mods: dvui.enums.Mod = .none,
-right_mouse_down: bool = false,
-sample_key_down: bool = false,
-
-pub const InitOptions = struct {
- canvas: *CanvasWidget,
- source: dvui.ImageSource,
- grouping: u64,
-};
-
-pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) ImageWidget {
- const iw: ImageWidget = .{
- .init_options = init_opts,
- .options = opts,
- .last_mouse_event = if (dvui.dataGet(null, init_opts.canvas.id, "mouse_point", dvui.Event)) |event| event else null,
- .drag_data_point = if (dvui.dataGet(null, init_opts.canvas.id, "drag_data_point", dvui.Point)) |point| point else null,
- .sample_data_point = if (dvui.dataGet(null, init_opts.canvas.id, "sample_data_point", dvui.Point)) |point| point else null,
- .sample_key_down = if (dvui.dataGet(null, init_opts.canvas.id, "sample_key_down", bool)) |key| key else false,
- .right_mouse_down = if (dvui.dataGet(null, init_opts.canvas.id, "right_mouse_down", bool)) |key| key else false,
- };
-
- const size: dvui.Size = dvui.imageSize(init_opts.source) catch .{ .w = 0, .h = 0 };
-
- init_opts.canvas.install(src, .{
- .id = init_opts.canvas.id,
- .data_size = .{
- .w = size.w,
- .h = size.h,
- },
- }, opts);
-
- return iw;
-}
-
-pub fn processSample(self: *ImageWidget) void {
- const current_mods = dvui.currentWindow().modifiers;
- defer self.previous_mods = current_mods;
-
- if (!current_mods.matchBind("sample")) {
- self.sample_key_down = false;
- if (!self.right_mouse_down) {
- self.sample_data_point = null;
- }
- } else if (current_mods.matchBind("sample") and !self.previous_mods.matchBind("sample")) {
- self.sample_key_down = true;
- if (self.last_mouse_event) |event| {
- const me = event.evt.mouse;
- const current_point = self.init_options.canvas.dataFromScreenPoint(me.p);
- self.sample(current_point, me.p);
- }
- }
-
- const canvas = self.init_options.canvas;
- const scroll_container = canvas.scroll_container;
- if (!canvas.installed) return;
-
- const scroll_id = scroll_container.data().id;
-
- for (dvui.events()) |*e| {
- switch (e.evt) {
- .mouse => |me| {
- const sample_captured = dvui.captured(scroll_id);
- if (!scroll_container.matchEvent(e) and !sample_captured)
- continue;
-
- self.last_mouse_event = e.*;
- const current_point = canvas.dataFromScreenPoint(me.p);
-
- if (me.action == .press and me.button == .right) {
- self.right_mouse_down = true;
- e.handle(@src(), self.init_options.canvas.scroll_container.data());
- dvui.captureMouse(self.init_options.canvas.scroll_container.data(), e.num);
- dvui.dragPreStart(me.p, .{ .name = "sample_drag" });
- self.drag_data_point = current_point;
-
- self.sample(current_point, me.p);
- } else if (me.action == .release and me.button == .right) {
- self.right_mouse_down = false;
- if (sample_captured) {
- e.handle(@src(), scroll_container.data());
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
-
- if (!self.sample_key_down) {
- self.drag_data_point = null;
- self.sample_data_point = null;
- }
- }
- } else if (me.action == .motion or me.action == .wheel_x or me.action == .wheel_y) {
- if (sample_captured and !canvas.samplePointerInViewport(me.p)) {
- self.sample_data_point = null;
- }
- if (dvui.captured(scroll_id)) {
- if (dvui.dragging(me.p, "sample_drag")) |diff| {
- const previous_point = current_point.plus(self.init_options.canvas.dataFromScreenPoint(diff));
- // Construct a rect spanning between current_point and previous_point
- const min_x = @min(previous_point.x, current_point.x);
- const min_y = @min(previous_point.y, current_point.y);
- const max_x = @max(previous_point.x, current_point.x);
- const max_y = @max(previous_point.y, current_point.y);
- const span_rect = dvui.Rect{
- .x = min_x,
- .y = min_y,
- .w = max_x - min_x + 5,
- .h = max_y - min_y + 5,
- };
-
- const screen_rect = self.init_options.canvas.screenFromDataRect(span_rect);
-
- dvui.scrollDrag(.{
- .mouse_pt = me.p,
- .screen_rect = screen_rect,
- });
-
- self.sample(current_point, me.p);
- e.handle(@src(), self.init_options.canvas.scroll_container.data());
- }
- } else if (self.right_mouse_down or self.sample_key_down) {
- self.sample(current_point, me.p);
- }
- }
- },
- else => {},
- }
- }
-}
-
-fn sample(self: *ImageWidget, point: dvui.Point, screen_p: dvui.Point.Physical) void {
- if (!self.init_options.canvas.samplePointerInViewport(screen_p)) {
- self.sample_data_point = null;
- return;
- }
-
- var color: [4]u8 = .{ 0, 0, 0, 0 };
-
- if (fizzy.image.pixelIndex(self.init_options.source, point)) |index| {
- const c = fizzy.image.pixels(self.init_options.source)[index];
- if (c[3] > 0) {
- color = c;
- }
- }
-
- fizzy.editor.colors.primary = color;
- self.sample_data_point = point;
-
- if (color[3] == 0) {
- if (fizzy.editor.tools.current != .eraser) {
- fizzy.editor.tools.set(.eraser);
- }
- } else {
- fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool);
- }
-}
-
-pub fn drawCursor(self: *ImageWidget) void {
- if (fizzy.dvui.canvasPointerInputSuppressed()) return;
- for (dvui.events()) |*e| {
- if (!self.init_options.canvas.scroll_container.matchEvent(e)) {
- continue;
- }
-
- switch (e.evt) {
- .mouse => |me| {
- if (self.init_options.canvas.rect.contains(me.p) and (self.right_mouse_down or self.sample_key_down)) {
- _ = dvui.cursorSet(.hidden);
- }
- },
- else => {},
- }
- }
-}
-
-fn drawSamplePixelOutline(canvas: *CanvasWidget, data_point: dvui.Point) void {
- const pixel_box_size = canvas.scale * dvui.currentWindow().rectScale().s;
- const pixel_point: dvui.Point = .{
- .x = @round(data_point.x - 0.5),
- .y = @round(data_point.y - 0.5),
- };
- const pixel_box_point = canvas.screenFromDataPoint(pixel_point);
- var pixel_box = dvui.Rect.Physical.fromSize(.{ .w = pixel_box_size, .h = pixel_box_size });
- pixel_box.x = pixel_box_point.x;
- pixel_box.y = pixel_box_point.y;
- dvui.Path.stroke(.{ .points = &.{
- pixel_box.topLeft(),
- pixel_box.topRight(),
- pixel_box.bottomRight(),
- pixel_box.bottomLeft(),
- } }, .{ .thickness = 2, .color = .white, .closed = true });
-}
-
-pub fn drawSample(self: *ImageWidget) void {
- if (self.sample_data_point) |data_point| {
- if (!self.init_options.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return;
- drawSamplePixelOutline(self.init_options.canvas, data_point);
- }
-}
-
-pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data_point: dvui.Point) void {
- if (fizzy.dvui.canvasPointerInputSuppressed()) return;
- if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return;
-
- _ = dvui.cursorSet(.hidden);
-
- const enlarged_scale: f32 = canvas.scale * 2.0;
- const sample_box_size: f32 = 200.0 * 1 / canvas.scale;
- const sample_region_size: f32 = sample_box_size / enlarged_scale;
-
- // Home placement: bottom-left corner of the magnifier sits exactly at the sample point.
- const default_magnifier_phys = canvas.screenFromDataRect(.{
- .x = data_point.x,
- .y = data_point.y - sample_box_size,
- .w = sample_box_size,
- .h = sample_box_size,
- });
-
- // Slide the magnifier inside the OS window without flipping. Only right and top can clip.
- const window_rect = dvui.windowRectPixels();
- const push_x_phys = @max(0, (default_magnifier_phys.x + default_magnifier_phys.w) - (window_rect.x + window_rect.w));
- const push_y_phys = @max(0, window_rect.y - default_magnifier_phys.y);
-
- const magnifier_phys = dvui.Rect.Physical{
- .x = default_magnifier_phys.x - push_x_phys,
- .y = default_magnifier_phys.y + push_y_phys,
- .w = default_magnifier_phys.w,
- .h = default_magnifier_phys.h,
- };
- const magnifier_nat = magnifier_phys.toNatural();
-
- // Corner-radius rect maps {x: TL, y: TR, w: BR, h: BL}. BL is sharp (0) at home so it points at
- // the sample; as the magnifier is pushed away from home, grow BL so the rectangle's edge slides
- // tangent to the sample point — fully circular at `cr_max`.
- const cr_max = magnifier_nat.w / 2;
- const win_scale = dvui.windowRectScale().s;
- const push_dist_phys = @sqrt(push_x_phys * push_x_phys + push_y_phys * push_y_phys);
- const push_dist_nat = if (win_scale > 0) push_dist_phys / win_scale else push_dist_phys;
- const bl_radius = @min(cr_max, push_dist_nat);
- const corner_radius = dvui.Rect{ .x = cr_max, .y = cr_max, .w = cr_max, .h = bl_radius };
-
- const ns = dvui.currentWindow().natural_scale;
- const border_nat = 2.0 / ns;
-
- var fw: dvui.FloatingWidget = undefined;
- fw.init(@src(), .{ .mouse_events = false }, .{
- .rect = dvui.Rect.cast(magnifier_nat),
- .expand = .none,
- .background = true,
- .color_fill = dvui.themeGet().color(.window, .fill),
- .border = dvui.Rect.all(border_nat),
- .color_border = dvui.themeGet().color(.control, .text),
- .corner_radius = corner_radius,
- .box_shadow = .{
- .fade = 15.0 / ns,
- .corner_radius = corner_radius,
- .alpha = 0.2,
- .offset = .{ .x = 2.0 / ns, .y = 2.0 / ns },
- },
- });
- defer fw.deinit();
-
- const size = fizzy.image.size(source);
- const uv_rect = dvui.Rect{
- .x = (data_point.x - sample_region_size / 2) / size.w,
- .y = (data_point.y - sample_region_size / 2) / size.h,
- .w = sample_region_size / size.w,
- .h = sample_region_size / size.h,
- };
-
- var rs = fw.data().borderRectScale();
- rs.r = rs.r.inset(dvui.Rect.Physical.all(2.0 * rs.s));
-
- const corner_scaled = dvui.Rect{
- .x = corner_radius.x * rs.s,
- .y = corner_radius.y * rs.s,
- .w = corner_radius.w * rs.s,
- .h = corner_radius.h * rs.s,
- };
-
- dvui.renderImage(source, rs, .{
- .uv = uv_rect,
- .corner_radius = corner_scaled,
- }) catch {
- std.log.err("Failed to render image", .{});
- };
-
- const center_x = rs.r.x + rs.r.w / 2;
- const center_y = rs.r.y + rs.r.h / 2;
- const cross_size = @min(rs.r.w, rs.r.h) * 0.2;
-
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = center_x - cross_size / 2, .y = center_y },
- .{ .x = center_x + cross_size / 2, .y = center_y },
- } }, .{ .thickness = 4, .color = .white });
-
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = center_x, .y = center_y - cross_size / 2 },
- .{ .x = center_x, .y = center_y + cross_size / 2 },
- } }, .{ .thickness = 4, .color = .white });
-
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = center_x - cross_size / 2 + 4, .y = center_y },
- .{ .x = center_x + cross_size / 2 - 4, .y = center_y },
- } }, .{ .thickness = 2, .color = .black });
-
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = center_x, .y = center_y - cross_size / 2 + 4 },
- .{ .x = center_x, .y = center_y + cross_size / 2 - 4 },
- } }, .{ .thickness = 2, .color = .black });
-}
-
-fn packedAtlasCheckerboardTexture() ?dvui.Texture {
- if (fizzy.packer.atlas) |atlas| return atlas.checkerboard_tile;
- return null;
-}
-
-fn drawPackedAtlasCheckerboardBackground(canvas: *CanvasWidget, data_rect: dvui.Rect) void {
- const bg_screen = canvas.screenFromDataRect(data_rect);
- bg_screen.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 });
- if (canvas.scale < 0.1) return;
-
- const tex = packedAtlasCheckerboardTexture() orelse return;
- if (data_rect.w <= 0 or data_rect.h <= 0) return;
-
- const target_tiles_per_side: f32 = 16.0;
- const min_data_tile: f32 = 32.0;
- const max_tiles_per_side: f32 = 32.0;
- const longest = @max(data_rect.w, data_rect.h);
- const data_tile: f32 = @max(min_data_tile, longest / target_tiles_per_side);
- if (data_rect.w / data_tile > max_tiles_per_side or data_rect.h / data_tile > max_tiles_per_side) return;
-
- dvui.renderTexture(tex, .{ .r = bg_screen, .s = canvas.screen_rect_scale.s }, .{
- .colormod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5),
- .uv = .{ .w = data_rect.w / data_tile, .h = data_rect.h / data_tile },
- }) catch {
- dvui.log.err("Failed to render packed atlas checkerboard", .{});
- };
-}
-
-pub fn drawImage(self: *ImageWidget) void {
- const size: dvui.Size = dvui.imageSize(self.init_options.source) catch .{ .w = 0, .h = 0 };
- const image_rect = dvui.Rect{ .x = 0, .y = 0, .w = size.w, .h = size.h };
-
- const shadow_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .rect = image_rect,
- .border = dvui.Rect.all(0),
- .box_shadow = .{
- .fade = 20 * 1 / self.init_options.canvas.scale,
- .corner_radius = dvui.Rect.all(2 * 1 / self.init_options.canvas.scale),
- .alpha = if (dvui.themeGet().dark) 0.4 else 0.2,
- .offset = .{
- .x = 2 * 1 / self.init_options.canvas.scale,
- .y = 2 * 1 / self.init_options.canvas.scale,
- },
- },
- });
- shadow_box.deinit();
-
- const fill_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .rect = image_rect,
- .border = dvui.Rect.all(0),
- .background = true,
- .color_fill = dvui.themeGet().color(.window, .fill),
- });
- fill_box.deinit();
-
- drawPackedAtlasCheckerboardBackground(self.init_options.canvas, image_rect);
-
- // Render the atlas image into the canvas's cached physical rect (NOT via dvui.image,
- // which goes through the ScaleWidget — that widget's `screenRectScale` dereferences
- // `&canvas.scale` live, so any scale mutation in `updateTouchGesture` (e.g. trackpad
- // pinch) is reflected immediately for the image but NOT for the checkerboard
- // background and outline, which use `canvas.screen_rect_scale` / `canvas.rect` cached
- // by `syncTransformCachesFromWidgets` before `updateTouchGesture` runs. The mismatch
- // is the visible "image moves at a different rate than the alpha layer" jitter on the
- // packed-atlas preview during pinch zoom. Mirror FileWidget.drawLayers, which renders
- // its layer textures via `fizzy.render.renderLayers` against the cached `canvas.rect`
- // for the same reason.
- dvui.renderImage(self.init_options.source, .{
- .r = self.init_options.canvas.rect,
- .s = self.init_options.canvas.scale,
- }, .{}) catch {
- std.log.err("Failed to render packed atlas image", .{});
- };
-
- // Outline the image with a rectangle
- dvui.Path.stroke(.{ .points = &.{
- self.init_options.canvas.rect.topLeft(),
- self.init_options.canvas.rect.topRight(),
- self.init_options.canvas.rect.bottomRight(),
- self.init_options.canvas.rect.bottomLeft(),
- } }, .{ .thickness = 1, .color = dvui.themeGet().color(.control, .fill_hover), .closed = true });
-}
-
-pub fn processEvents(self: *ImageWidget) void {
- defer if (self.last_mouse_event) |last_mouse_event| {
- dvui.dataSet(null, self.init_options.canvas.id, "mouse_point", last_mouse_event);
- } else {
- dvui.dataRemove(null, self.init_options.canvas.id, "mouse_point");
- };
- defer if (self.drag_data_point) |drag_data_point| {
- dvui.dataSet(null, self.init_options.canvas.id, "drag_data_point", drag_data_point);
- } else {
- dvui.dataRemove(null, self.init_options.canvas.id, "drag_data_point");
- };
- defer if (self.sample_data_point) |sample_data_point| {
- dvui.dataSet(null, self.init_options.canvas.id, "sample_data_point", sample_data_point);
- } else {
- dvui.dataRemove(null, self.init_options.canvas.id, "sample_data_point");
- };
- defer if (self.sample_key_down) {
- dvui.dataSet(null, self.init_options.canvas.id, "sample_key_down", self.sample_key_down);
- } else {
- dvui.dataRemove(null, self.init_options.canvas.id, "sample_key_down");
- };
- defer if (self.right_mouse_down) {
- dvui.dataSet(null, self.init_options.canvas.id, "right_mouse_down", self.right_mouse_down);
- } else {
- dvui.dataRemove(null, self.init_options.canvas.id, "right_mouse_down");
- };
-
- self.processSample();
-
- self.drawImage();
-
- 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 });
-
- self.drawCursor();
- self.drawSample();
-
- // Then process the scroll and zoom events last
- self.init_options.canvas.processEvents();
-}
-
-pub fn deinit(self: *ImageWidget) void {
- self.init_options.canvas.deinit();
-
- self.* = undefined;
-}
-
-pub fn hovered(self: *ImageWidget) ?dvui.Point {
- return self.init_options.canvas.hovered();
-}
-
-const Options = dvui.Options;
-const Rect = dvui.Rect;
-const Point = dvui.Point;
-
-const BoxWidget = dvui.BoxWidget;
-const ButtonWidget = dvui.ButtonWidget;
-const ScrollAreaWidget = dvui.ScrollAreaWidget;
-const ScrollContainerWidget = dvui.ScrollContainerWidget;
-const ScaleWidget = dvui.ScaleWidget;
-
-const std = @import("std");
-const math = std.math;
-const dvui = @import("dvui");
-const fizzy = @import("../../fizzy.zig");
-const builtin = @import("builtin");
-
-test {
- @import("std").testing.refAllDecls(@This());
-}
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..1a2dbf38 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, 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,
@@ -8,82 +10,44 @@ pub const version: std.SemanticVersion = .{
.patch = 0,
};
-// Generated files, these contain helpers for autocomplete
-// So you can get a named index into atlas.sprites
-pub const atlas = @import("generated/atlas.zig");
-
// 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");
// 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");
/// 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/generated/atlas.zig b/src/generated/atlas.zig
deleted file mode 100644
index 174bc9b5..00000000
--- a/src/generated/atlas.zig
+++ /dev/null
@@ -1,74 +0,0 @@
-// This is a generated file, do not edit.
-
-// Sprites
-
-pub const sprites = struct {
- pub const cursor_default = 0;
- pub const pencil_default = 1;
- pub const eraser_default = 2;
- pub const bucket_default = 3;
- pub const box_selection_default = 4;
- pub const box_selection_add_default = 5;
- pub const box_selection_rem_default = 6;
- pub const Sprite_7 = 7;
- pub const Sprite_8 = 8;
- pub const Sprite_9 = 9;
- pub const color_selection_default = 10;
- pub const color_selection_add_default = 11;
- pub const color_selection_rem_default = 12;
- pub const pixel_selection_default = 13;
- pub const pixel_selection_add_default = 14;
- pub const pixel_selection_rem_default = 15;
- pub const fox_default = 16;
- pub const logo_default = 17;
-};
-
-// Animations
-
-pub const animations = struct {
- pub var cursor_default = [_]usize {
- sprites.cursor_default,
- };
- pub var pencil_default = [_]usize {
- sprites.pencil_default,
- };
- pub var eraser_default = [_]usize {
- sprites.eraser_default,
- };
- pub var bucket_default = [_]usize {
- sprites.bucket_default,
- };
- pub var box_selection_default = [_]usize {
- sprites.box_selection_default,
- };
- pub var box_selection_add_default = [_]usize {
- sprites.box_selection_add_default,
- };
- pub var box_selection_rem_default = [_]usize {
- sprites.box_selection_rem_default,
- };
- pub var color_selection_default = [_]usize {
- sprites.color_selection_default,
- };
- pub var color_selection_add_default = [_]usize {
- sprites.color_selection_add_default,
- };
- pub var color_selection_rem_default = [_]usize {
- sprites.color_selection_rem_default,
- };
- pub var pixel_selection_default = [_]usize {
- sprites.pixel_selection_default,
- };
- pub var pixel_selection_add_default = [_]usize {
- sprites.pixel_selection_add_default,
- };
- pub var pixel_selection_rem_default = [_]usize {
- sprites.pixel_selection_rem_default,
- };
- pub var fox_default = [_]usize {
- sprites.fox_default,
- };
- pub var logo_default = [_]usize {
- sprites.logo_default,
- };
-};
diff --git a/src/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/gfx/render.zig b/src/gfx/render.zig
deleted file mode 100644
index 3631ee62..00000000
--- a/src/gfx/render.zig
+++ /dev/null
@@ -1,917 +0,0 @@
-const std = @import("std");
-const builtin = @import("builtin");
-const fizzy = @import("../fizzy.zig");
-const dvui = @import("dvui");
-const perf = fizzy.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,
- rs: dvui.RectScale,
- color_mod: dvui.Color = .white,
- fade: f32 = 0.0,
- uv: dvui.Rect = .{ .w = 1.0, .h = 1.0 },
- corner_radius: dvui.Rect = .all(0),
- allow_peek: bool = true,
- /// Optional skewed quad in physical corner order (tl, tr, br, bl). When set,
- /// the layer stack renders into this quad instead of the axis-aligned `rs.r`,
- /// so perspective/depth skew applies to the art itself — not just the
- /// background. Leave null for normal (canvas) rendering.
- quad: ?[4]dvui.Point.Physical = null,
- quad_subdivisions: usize = 8,
-};
-
-/// Web backends without `textureUpdateSubRect` recreate the GPU texture on upload; sync the cache
-/// when the pointer changes so we do not keep drawing a texture id that was destroyLater'd.
-fn uploadSubRectAndSyncCache(
- key: u64,
- tex: *dvui.Texture,
- pixels: [*]const u8,
- x: u32,
- y: u32,
- w: u32,
- h: u32,
-) void {
- const prev_ptr = tex.ptr;
- tex.updateSubRect(pixels, x, y, w, h) catch |err| {
- dvui.log.err("Sub-rect texture upload failed: {any}", .{err});
- return;
- };
- if (tex.ptr != prev_ptr) {
- dvui.textureAddToCache(key, tex.*);
- }
-}
-
-/// Pushes pending CPU pixel edits to GPU textures. Must run even when `renderLayers` returns early
-/// (scale zero / clip empty): otherwise `defer` blocks that normally perform uploads are never
-/// registered, and `temp_gpu_dirty_rect` keeps unioning every frame until it covers the whole image.
-fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void {
- const file = init_opts.file;
-
- if (file.editor.active_layer_dirty_rect) |dirty| {
- if (dirty.w > 0 and dirty.h > 0) {
- perf.draw_active_rect_area += @intFromFloat(dirty.w * dirty.h);
- const source = file.layers.items(.source)[file.selected_layer_index];
- const source_key = source.hash();
- if (dvui.textureGetCached(source_key)) |cached| {
- var tex = cached;
- uploadSubRectAndSyncCache(
- source_key,
- &tex,
- fizzy.image.bytes(source).ptr,
- @intFromFloat(dirty.x),
- @intFromFloat(dirty.y),
- @intFromFloat(dirty.w),
- @intFromFloat(dirty.h),
- );
- }
- }
- file.editor.active_layer_dirty_rect = null;
- }
-
- if (file.editor.temp_layer_has_content or
- file.editor.temp_gpu_dirty_rect != null)
- {
- const temp_source = file.editor.temporary_layer.source;
- const temp_key = temp_source.hash();
- if (dvui.textureGetCached(temp_key)) |cached| {
- if (file.editor.temp_gpu_dirty_rect) |dirty| {
- if (dirty.w > 0 and dirty.h > 0) {
- perf.draw_temp_rect_area += @intFromFloat(dirty.w * dirty.h);
- var tex = cached;
- uploadSubRectAndSyncCache(
- temp_key,
- &tex,
- fizzy.image.bytes(temp_source).ptr,
- @intFromFloat(dirty.x),
- @intFromFloat(dirty.y),
- @intFromFloat(dirty.w),
- @intFromFloat(dirty.h),
- );
- }
- file.editor.temp_gpu_dirty_rect = null;
- } else if (file.editor.temp_layer_has_content) {
- // CPU redraw (e.g. selection overlay via setColorFromMask) may leave the cache valid
- // without a dirty rect; sync the full texture so the GPU matches the pixel buffer.
- _ = temp_source.getTexture() catch null;
- }
- } else if (file.editor.temp_layer_has_content) {
- _ = temp_source.getTexture() catch null;
- file.editor.temp_gpu_dirty_rect = null;
- } else if (file.editor.temp_gpu_dirty_rect != null) {
- file.editor.temp_gpu_dirty_rect = null;
- }
- }
-}
-
-fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_index: usize, needs_dimmed: bool } {
- var min_layer_index: usize = 0;
- if (init_opts.allow_peek) {
- if (init_opts.file.editor.isolate_layer) {
- if (init_opts.file.peek_layer_index) |peek_layer_index| {
- min_layer_index = peek_layer_index;
- } else if (!fizzy.editor.explorer.tools.layersHovered()) {
- min_layer_index = init_opts.file.selected_layer_index;
- }
- }
- }
- const needs_dimmed = init_opts.allow_peek and init_opts.file.peek_layer_index != null;
- return .{ .min_layer_index = min_layer_index, .needs_dimmed = needs_dimmed };
-}
-
-/// Non-null while layer list DnD preview is active (`File.editor.layer_drag_preview_*`); maps list position → storage index.
-fn layerOrderBufForDragPreview(file: *fizzy.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]);
- return buf[0..file.layers.len];
-}
-
-/// Builds the same cached composites `renderLayers` would use (split when drawing, full when idle),
-/// so callers (e.g. sprite preview reflection) can draw before `renderLayers` runs.
-pub fn ensureLayerCompositesForPreview(init_opts: RenderFileOptions) !void {
- const vs = layerViewStateForRender(init_opts);
- if (splitCompositeEligible(init_opts, vs.min_layer_index, vs.needs_dimmed)) {
- try syncSplitComposite(init_opts.file);
- } else if (fullCompositeEligible(init_opts, vs.min_layer_index, vs.needs_dimmed)) {
- try syncLayerComposite(init_opts.file);
- }
-}
-
-fn renderTransformIfActive(init_opts: RenderFileOptions, triangles: dvui.Triangles) void {
- if (init_opts.file.editor.transform) |*transform| {
- if (dvui.textureFromTarget(transform.target_texture) catch null) |tex| {
- dvui.renderTriangles(triangles, tex) catch {
- dvui.log.err("Failed to render transform layer", .{});
- };
- }
- }
-}
-
-/// Draws the layer stack for the sprite-panel reflection using the same composite paths as
-/// `renderLayers` (1–3 draws) instead of N per-layer draws when possible.
-pub fn renderReflectionLayerStack(
- init_opts: RenderFileOptions,
- reflection_tris: dvui.Triangles,
- reflection_tris_dimmed: dvui.Triangles,
-) !void {
- const file = init_opts.file;
- const vs = layerViewStateForRender(init_opts);
- try ensureLayerCompositesForPreview(init_opts);
-
- var order_buf: [1024]usize = undefined;
- const order_opt = layerOrderBufForDragPreview(file, order_buf[0..]);
-
- if (file.peek_layer_index != null) {
- var list_pos: usize = file.layers.len;
- while (list_pos > vs.min_layer_index) {
- list_pos -= 1;
- const layer_index = if (order_opt) |o| o[list_pos] else list_pos;
- const visible = file.layers.items(.visible)[layer_index];
- var tris = reflection_tris;
- if (vs.needs_dimmed) {
- if (file.peek_layer_index) |peek_layer_index| {
- if (peek_layer_index != layer_index) {
- tris = reflection_tris_dimmed;
- }
- }
- }
- if (visible) {
- dvui.renderTriangles(tris, file.layers.items(.source)[layer_index].getTexture() catch null) catch {
- dvui.log.err("Failed to render reflection layer", .{});
- };
- }
- if (layer_index == file.selected_layer_index) {
- renderTransformIfActive(init_opts, reflection_tris);
- }
- }
- return;
- }
-
- if (splitCompositeEligible(init_opts, vs.min_layer_index, vs.needs_dimmed)) {
- if (order_opt != null) {
- var list_pos: usize = file.layers.len;
- while (list_pos > vs.min_layer_index) {
- list_pos -= 1;
- const layer_index = order_opt.?[list_pos];
- const visible = file.layers.items(.visible)[layer_index];
- var tris = reflection_tris;
- if (vs.needs_dimmed) {
- if (file.peek_layer_index) |peek_layer_index| {
- if (peek_layer_index != layer_index) {
- tris = reflection_tris_dimmed;
- }
- }
- }
- if (visible) {
- dvui.renderTriangles(tris, file.layers.items(.source)[layer_index].getTexture() catch null) catch {
- dvui.log.err("Failed to render reflection layer", .{});
- };
- }
- if (layer_index == file.selected_layer_index) {
- renderTransformIfActive(init_opts, reflection_tris);
- }
- }
- return;
- }
- if (file.editor.split_composite_below) |ct| {
- if (dvui.Texture.fromTargetTemp(ct) catch null) |tex| {
- dvui.renderTriangles(reflection_tris, tex) catch {
- dvui.log.err("Failed to render reflection below composite", .{});
- };
- }
- }
- const active_source = file.layers.items(.source)[file.selected_layer_index];
- if (file.layers.items(.visible)[file.selected_layer_index]) {
- if (active_source.getTexture() catch null) |tex| {
- dvui.renderTriangles(reflection_tris, tex) catch {
- dvui.log.err("Failed to render reflection active layer", .{});
- };
- }
- }
- renderTransformIfActive(init_opts, reflection_tris);
- if (file.editor.split_composite_above) |ct| {
- if (dvui.Texture.fromTargetTemp(ct) catch null) |tex| {
- dvui.renderTriangles(reflection_tris, tex) catch {
- dvui.log.err("Failed to render reflection above composite", .{});
- };
- }
- }
- return;
- }
-
- if (fullCompositeEligible(init_opts, vs.min_layer_index, vs.needs_dimmed)) {
- if (file.editor.layer_composite_target) |ct| {
- if (dvui.Texture.fromTargetTemp(ct) catch null) |ctex| {
- dvui.renderTriangles(reflection_tris, ctex) catch {
- dvui.log.err("Failed to render reflection full composite", .{});
- };
- return;
- }
- }
- }
-
- var list_pos2: usize = file.layers.len;
- while (list_pos2 > vs.min_layer_index) {
- list_pos2 -= 1;
- const layer_index = if (order_opt) |o| o[list_pos2] else list_pos2;
- const visible = file.layers.items(.visible)[layer_index];
- var tris = reflection_tris;
- if (vs.needs_dimmed) {
- if (file.peek_layer_index) |peek_layer_index| {
- if (peek_layer_index != layer_index) {
- tris = reflection_tris_dimmed;
- }
- }
- }
- if (visible) {
- dvui.renderTriangles(tris, file.layers.items(.source)[layer_index].getTexture() catch null) catch {
- dvui.log.err("Failed to render reflection layer stack fallback", .{});
- };
- }
- if (layer_index == file.selected_layer_index) {
- renderTransformIfActive(init_opts, reflection_tris);
- }
- }
-}
-
-/// Draw layers into the **current** render target using cached composites only (no `sync*` rebinding).
-/// Caller must run `ensureLayerCompositesForPreview` first while the screen target is active.
-pub fn renderLayersMagnifierSample(init_opts: RenderFileOptions) !void {
- flushPendingLayerTextureUploads(init_opts);
-
- if (init_opts.rs.s == 0) return;
- if (dvui.clipGet().intersect(init_opts.rs.r).empty()) return;
-
- const vs = layerViewStateForRender(init_opts);
-
- var path: dvui.Path.Builder = .init(fizzy.app.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);
-
- 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 (vs.needs_dimmed) {
- var dt = try triangles.dupe(fizzy.app.allocator);
- dt.color(.gray);
- dimmed_triangles = dt;
- }
-
- const dimmed = dimmed_triangles orelse triangles;
- try renderReflectionLayerStack(init_opts, triangles, dimmed);
-
- if (dvui.textureGetCached(init_opts.file.editor.selection_layer.source.hash()) == null)
- perf.draw_texture_creates += 1;
- dvui.renderTriangles(triangles, init_opts.file.editor.selection_layer.source.getTexture() catch null) catch {
- dvui.log.err("Failed to render magnifier selection layer", .{});
- };
-
- if (init_opts.file.editor.temp_layer_has_content) {
- const temp_source = init_opts.file.editor.temporary_layer.source;
- if (dvui.textureGetCached(temp_source.hash()) == null)
- perf.draw_texture_creates += 1;
- if (dvui.textureGetCached(temp_source.hash())) |cached| {
- dvui.renderTriangles(triangles, cached) catch {
- dvui.log.err("Failed to render magnifier temporary layer", .{});
- };
- } else {
- dvui.renderTriangles(triangles, temp_source.getTexture() catch null) catch {
- dvui.log.err("Failed to render magnifier temporary layer", .{});
- };
- }
- }
-}
-
-fn fullCompositeEligible(
- init_opts: RenderFileOptions,
- min_layer_index: usize,
- needs_dimmed: bool,
-) bool {
- if (needs_dimmed) return false;
- if (min_layer_index != 0) return false;
- if (init_opts.fade != 0) return false;
- // A uniform color_mod (e.g. the cover-flow opacity fade) is correct to apply
- // once to the flattened composite — and avoids the per-layer translucency
- // artifacts you'd get fading each layer separately.
- if (init_opts.file.editor.transform != null) return false;
- if (init_opts.file.editor.active_drawing) return false;
- const ce = layerCompositeExtent(init_opts.file);
- if (ce.w == 0 or ce.h == 0) return false;
- return true;
-}
-
-fn splitCompositeEligible(
- init_opts: RenderFileOptions,
- min_layer_index: usize,
- needs_dimmed: bool,
-) bool {
- if (!init_opts.file.editor.active_drawing and init_opts.file.editor.transform == null) return false;
- if (needs_dimmed) return false;
- if (min_layer_index != 0) return false;
- if (init_opts.fade != 0) return false;
- // See fullCompositeEligible: a uniform color_mod applies cleanly to the
- // split composites too, so it no longer forces the per-layer path.
- const ce = layerCompositeExtent(init_opts.file);
- if (ce.w == 0 or ce.h == 0) return false;
- return true;
-}
-
-/// Pixel size of the flattened layer stack — prefers the first layer (`canvasPixelSize`) so the
-/// composite matches bitmap data even when `columns × column_width` / `rows × row_height` disagree
-/// (slice/grid previews use the canvas as the locked image rect).
-fn layerCompositeExtent(file: *fizzy.Internal.File) struct { w: u32, h: u32 } {
- const c = file.canvasPixelSize();
- if (c.w > 0 and c.h > 0) return .{ .w = c.w, .h = c.h };
- const w = file.width();
- const h = file.height();
- return .{ .w = w, .h = h };
-}
-
-/// Pixel format for off-screen render targets (layer composites, transforms).
-/// The web backend only supports `.rgba_32`.
-pub fn compositeTargetPixelFormat() dvui.enums.TexturePixelFormat {
- return if (comptime builtin.target.cpu.arch == .wasm32)
- .rgba_32
- else
- .rgba_8_8_8_8;
-}
-
-/// Rebuilds the full-canvas flattened layer texture (all layers included).
-/// Used when NOT actively drawing.
-pub fn syncLayerComposite(file: *fizzy.Internal.File) !void {
- const ce = layerCompositeExtent(file);
- const w = ce.w;
- const h = ce.h;
- if (w == 0 or h == 0) return;
-
- if (file.editor.layer_composite_frame_built == frame_index) return;
- file.editor.layer_composite_frame_built = frame_index;
-
- if (file.editor.layer_composite_target) |t| {
- if (t.width != w or t.height != h) {
- t.destroyLater();
- file.editor.layer_composite_target = null;
- }
- }
-
- var needs_rebuild = file.editor.layer_composite_target == null or file.editor.layer_composite_dirty;
-
- if (!needs_rebuild) {
- var i: usize = file.layers.len;
- while (i > 0) {
- i -= 1;
- if (!file.layers.items(.visible)[i]) continue;
- if (dvui.textureGetCached(file.layers.items(.source)[i].hash()) == null) {
- needs_rebuild = true;
- break;
- }
- }
- }
-
- if (!needs_rebuild) return;
-
- perf.draw_full_composite_rebuilds += 1;
-
- const sc_t0 = perf.syncCompositeBegin();
- defer perf.syncCompositeEnd(sc_t0);
-
- const target = if (file.editor.layer_composite_target) |t| t else blk: {
- const nt = try dvui.textureCreateTarget(.{ .width = w, .height = h, .format = compositeTargetPixelFormat(), .interpolation = .nearest });
- file.editor.layer_composite_target = nt;
- break :blk nt;
- };
-
- try renderLayersIntoTarget(file, target, 0, file.layers.len, null);
- file.editor.layer_composite_dirty = false;
- file.editor.layer_composite_generation +%= 1;
-}
-
-/// Builds two split composites that exclude the active (selected) layer.
-/// The "below" target flattens layers visually below (higher index), and
-/// the "above" target flattens layers visually above (lower index).
-/// Only rebuilt when the split layer changes or a structural change occurs.
-fn syncSplitComposite(file: *fizzy.Internal.File) !void {
- const ce = layerCompositeExtent(file);
- const w = ce.w;
- const h = ce.h;
- if (w == 0 or h == 0) return;
-
- if (file.editor.split_composite_frame_built == frame_index) return;
- file.editor.split_composite_frame_built = frame_index;
-
- // Prevent the full composite from also rebuilding this frame (e.g. from
- // the sprite panel reflection calling syncLayerComposite directly).
- file.editor.layer_composite_frame_built = frame_index;
-
- const active_idx = file.selected_layer_index;
-
- var needs_rebuild = file.editor.split_composite_dirty or
- file.editor.split_composite_layer == null or
- file.editor.split_composite_layer.? != active_idx;
-
- inline for (&[_]*?dvui.Texture.Target{
- &file.editor.split_composite_below,
- &file.editor.split_composite_above,
- }) |target_ptr| {
- if (target_ptr.*) |t| {
- if (t.width != w or t.height != h) {
- t.destroyLater();
- target_ptr.* = null;
- needs_rebuild = true;
- }
- } else {
- needs_rebuild = true;
- }
- }
-
- if (!needs_rebuild) {
- var i: usize = file.layers.len;
- while (i > 0) {
- i -= 1;
- if (i == active_idx) continue;
- if (!file.layers.items(.visible)[i]) continue;
- if (dvui.textureGetCached(file.layers.items(.source)[i].hash()) == null) {
- needs_rebuild = true;
- break;
- }
- }
- }
-
- if (!needs_rebuild) return;
-
- perf.draw_split_rebuilds += 1;
-
- const sc_t0 = perf.syncCompositeBegin();
- defer perf.syncCompositeEnd(sc_t0);
-
- const below = if (file.editor.split_composite_below) |t| t else blk: {
- const nt = try dvui.textureCreateTarget(.{ .width = w, .height = h, .format = compositeTargetPixelFormat(), .interpolation = .nearest });
- file.editor.split_composite_below = nt;
- break :blk nt;
- };
-
- const above = if (file.editor.split_composite_above) |t| t else blk: {
- const nt = try dvui.textureCreateTarget(.{ .width = w, .height = h, .format = compositeTargetPixelFormat(), .interpolation = .nearest });
- file.editor.split_composite_above = nt;
- break :blk nt;
- };
-
- const t_below = perf.nanoTimestamp();
- try renderLayersIntoTarget(file, below, active_idx + 1, file.layers.len, null);
- if (perf.record) {
- perf.split_composite_below_ns = @intCast(perf.nanoTimestamp() - t_below);
- }
-
- const t_above = perf.nanoTimestamp();
- try renderLayersIntoTarget(file, above, 0, active_idx, null);
- if (perf.record) {
- perf.split_composite_above_ns = @intCast(perf.nanoTimestamp() - t_above);
- }
-
- file.editor.split_composite_layer = active_idx;
- file.editor.split_composite_dirty = false;
-}
-
-/// Pre-builds split-composite GPU targets and touches temp/selection textures so the first
-/// stroke does not pay allocation + flatten cost. Safe to call once after open or when
-/// selecting a drawing tool; no-op if composites are already current.
-pub fn warmupDrawingComposites(file: *fizzy.Internal.File) !void {
- const w0 = perf.nanoTimestamp();
- try syncSplitComposite(file);
- _ = file.editor.temporary_layer.source.getTexture() catch null;
- _ = file.editor.selection_layer.source.getTexture() catch null;
- perf.composite_warmup_last_ns = @intCast(perf.nanoTimestamp() - w0);
- perf.composite_warmup_total +%= 1;
-}
-
-/// Renders a range of visible layers into a render target. Layers are drawn
-/// from high index (visually bottom) to low index (visually top). An optional
-/// `skip_index` excludes a single layer.
-fn renderLayersIntoTarget(
- file: *fizzy.Internal.File,
- target: dvui.Texture.Target,
- min_index: usize,
- max_index: usize,
- skip_index: ?usize,
-) !void {
- const ce = layerCompositeExtent(file);
- const w = ce.w;
- const h = ce.h;
- const image_rect = dvui.Rect.Physical{
- .x = 0,
- .y = 0,
- .w = @floatFromInt(w),
- .h = @floatFromInt(h),
- };
-
- target.clear();
- const prev_target = dvui.renderTarget(.{ .texture = target, .offset = image_rect.topLeft() });
- defer _ = dvui.renderTarget(prev_target);
-
- const prev_clip = dvui.clipGet();
- defer dvui.clipSet(prev_clip);
- dvui.clipSet(image_rect);
-
- var path: dvui.Path.Builder = .init(fizzy.app.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);
- tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 });
-
- var order_buf: [1024]usize = undefined;
- const order_opt = layerOrderBufForDragPreview(file, order_buf[0..]);
-
- var list_pos: usize = max_index;
- while (list_pos > min_index) {
- list_pos -= 1;
- const i = if (order_opt) |o| o[list_pos] else list_pos;
- if (skip_index) |skip| {
- if (i == skip) continue;
- }
- if (!file.layers.items(.visible)[i]) continue;
- const source = file.layers.items(.source)[i];
- if (source.getTexture() catch null) |tex| {
- dvui.renderTriangles(tris, tex) catch {
- dvui.log.err("Failed to render layer into composite target", .{});
- };
- }
- }
-}
-
-/// Bakes the cover-flow preview composite — checkerboard backdrop + flattened
-/// layers + selection + temp, all at canvas resolution — into one texture. The
-/// sprite panel then draws each card (front and reflection) as a single textured
-/// pass sampling this, instead of replaying the whole stack as several
-/// overlapping alpha-blended fills per card. Rebuilt at most once per frame.
-pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void {
- const ce = layerCompositeExtent(file);
- const w = ce.w;
- const h = ce.h;
- if (w == 0 or h == 0) return;
-
- if (file.editor.preview_composite_frame_built == frame_index) return;
- file.editor.preview_composite_frame_built = frame_index;
-
- // The flattened layer stack feeds the bake; build/refresh it first.
- try syncLayerComposite(file);
-
- var fresh = false;
- if (file.editor.preview_composite_target) |t| {
- if (t.width != w or t.height != h) {
- t.destroyLater();
- file.editor.preview_composite_target = null;
- }
- }
- const target = if (file.editor.preview_composite_target) |t| t else blk: {
- const nt = try dvui.textureCreateTarget(.{ .width = w, .height = h, .format = compositeTargetPixelFormat(), .interpolation = .nearest });
- file.editor.preview_composite_target = nt;
- fresh = true;
- break :blk nt;
- };
-
- // Skip the (clear + several renderTriangles) re-bake when nothing that feeds
- // the composite changed. The cover-flow reflections animate via mesh
- // displacement only, so during a ripple the baked sprite content is identical
- // frame to frame — this turns a per-frame bake into a no-op while idle/rippling.
- const theme_fill = dvui.themeGet().color(.content, .fill);
- var sig: u64 = file.editor.layer_composite_generation;
- sig = sig *% 1000003 +% file.editor.selection_layer.source.hash();
- if (file.editor.temp_layer_has_content) {
- // `.ptr` invalidation makes `source.hash()` content-blind (it only sees the
- // stable buffer pointer), so a moved hover/brush preview that reuses the same
- // buffer wouldn't change the signature. Use the content generation instead.
- sig = sig *% 1000003 +% file.editor.temp_layer_generation;
- }
- const fill_packed: u32 = @as(u32, theme_fill.r) | @as(u32, theme_fill.g) << 8 | @as(u32, theme_fill.b) << 16 | @as(u32, theme_fill.a) << 24;
- sig = sig *% 1000003 +% @as(u64, fill_packed);
- sig = sig *% 1000003 +% (@as(u64, @intCast(file.columns)) << 16 | @as(u64, @intCast(file.rows)));
-
- if (!fresh and file.editor.preview_composite_valid and sig == file.editor.preview_composite_sig) return;
- file.editor.preview_composite_sig = sig;
- file.editor.preview_composite_valid = true;
-
- const sc_t0 = perf.syncCompositeBegin();
- defer perf.syncCompositeEnd(sc_t0);
-
- const image_rect = dvui.Rect.Physical{ .x = 0, .y = 0, .w = @floatFromInt(w), .h = @floatFromInt(h) };
-
- target.clear();
- const prev_target = dvui.renderTarget(.{ .texture = target, .offset = image_rect.topLeft() });
- defer _ = dvui.renderTarget(prev_target);
-
- const prev_clip = dvui.clipGet();
- defer dvui.clipSet(prev_clip);
- dvui.clipSet(image_rect);
-
- // 1) Opaque content-fill base — the transparency backdrop, matching the card.
- {
- var path: dvui.Path.Builder = .init(fizzy.app.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);
- 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);
- 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);
- 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);
- 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);
- tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 });
-
- if (file.editor.layer_composite_target) |ct| {
- if (dvui.Texture.fromTargetTemp(ct) catch null) |ctex| {
- dvui.renderTriangles(tris, ctex) catch {};
- }
- }
- dvui.renderTriangles(tris, file.editor.selection_layer.source.getTexture() catch null) catch {};
- if (file.editor.temp_layer_has_content) {
- dvui.renderTriangles(tris, file.editor.temporary_layer.source.getTexture() catch null) catch {};
- }
-}
-
-/// Returns the baked cover-flow preview composite texture for single-pass card
-/// drawing, or null when the fast path isn't eligible (peek / isolate / dimming /
-/// active drawing / transform). Callers fall back to the multi-pass stack.
-pub fn spritePreviewComposite(file: *fizzy.Internal.File) ?dvui.Texture {
- if (file.peek_layer_index != null) return null;
- if (file.editor.isolate_layer) return null;
- if (file.editor.transform != null) return null;
- if (file.editor.active_drawing) return null;
- const ce = layerCompositeExtent(file);
- if (ce.w == 0 or ce.h == 0) return null;
- syncPreviewComposite(file) catch return null;
- const t = file.editor.preview_composite_target orelse return null;
- return dvui.Texture.fromTargetTemp(t) catch null;
-}
-
-pub fn destroyLayerCompositeResources(file: *fizzy.Internal.File) void {
- if (file.editor.layer_composite_target) |t| {
- t.destroyLater();
- file.editor.layer_composite_target = null;
- }
- file.editor.layer_composite_dirty = true;
-
- if (file.editor.preview_composite_target) |t| {
- t.destroyLater();
- file.editor.preview_composite_target = null;
- }
- file.editor.preview_composite_frame_built = 0;
-
- destroySplitCompositeResources(file);
-}
-
-pub fn destroySplitCompositeResources(file: *fizzy.Internal.File) void {
- if (file.editor.split_composite_below) |t| {
- t.destroyLater();
- file.editor.split_composite_below = null;
- }
- if (file.editor.split_composite_above) |t| {
- t.destroyLater();
- file.editor.split_composite_above = null;
- }
- file.editor.split_composite_dirty = true;
- file.editor.split_composite_layer = null;
-}
-
-/// Renders visible layers of a file. Uses a cached composite texture when all
-/// layers are drawn without peeking or dimming; falls back to per-layer draws
-/// otherwise. During active drawing, uses split composites (below/above the
-/// active layer) to avoid per-frame render target switches while still reducing
-/// draw calls from N to 5.
-pub fn renderLayers(init_opts: RenderFileOptions) !void {
- const t0 = perf.renderLayersBegin();
- defer perf.renderLayersEnd(t0);
-
- perf.draw_render_layers_calls += 1;
-
- const content_rs = init_opts.rs;
-
- flushPendingLayerTextureUploads(init_opts);
-
- if (content_rs.s == 0) return;
- if (dvui.clipGet().intersect(content_rs.r).empty()) return;
-
- const vs = layerViewStateForRender(init_opts);
- const min_layer_index = vs.min_layer_index;
- const needs_dimmed = vs.needs_dimmed;
-
- var triangles = if (init_opts.quad) |q| blk: {
- // Skewed quad: build a subdivided mesh so the texture follows the
- // perspective instead of being mapped onto an axis-aligned rect.
- var qpath: dvui.Path.Builder = .init(fizzy.app.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, .{
- .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);
- 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 });
- t.uvFromRectuv(content_rs.r, init_opts.uv);
- break :blk t;
- };
- defer triangles.deinit(fizzy.app.allocator);
-
- var dimmed_triangles: ?dvui.Triangles = null;
- defer {
- if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator);
- }
- if (needs_dimmed) {
- var dt = try triangles.dupe(fizzy.app.allocator);
- dt.color(.gray);
- dimmed_triangles = dt;
- }
-
- defer {
- if (dvui.textureGetCached(init_opts.file.editor.selection_layer.source.hash()) == null)
- perf.draw_texture_creates += 1;
- dvui.renderTriangles(triangles, init_opts.file.editor.selection_layer.source.getTexture() catch null) catch {
- dvui.log.err("Failed to render selection layer", .{});
- };
-
- if (init_opts.file.editor.temp_layer_has_content) {
- const temp_source = init_opts.file.editor.temporary_layer.source;
- if (dvui.textureGetCached(temp_source.hash()) == null)
- perf.draw_texture_creates += 1;
- if (dvui.textureGetCached(temp_source.hash())) |cached| {
- dvui.renderTriangles(triangles, cached) catch {
- dvui.log.err("Failed to render temporary layer", .{});
- };
- } else {
- dvui.renderTriangles(triangles, temp_source.getTexture() catch null) catch {
- dvui.log.err("Failed to render temporary layer", .{});
- };
- }
- }
- }
-
- // Active stroke or transform: split composites (below + active + [transform] + above).
- if (splitCompositeEligible(init_opts, min_layer_index, needs_dimmed)) {
- syncSplitComposite(init_opts.file) catch |err| {
- dvui.log.err("Split composite sync failed: {any}", .{err});
- };
-
- const has_below = init_opts.file.editor.split_composite_below != null;
- const has_above = init_opts.file.editor.split_composite_above != null;
-
- if (has_below or has_above) {
- if (dvui.textureGetCached(init_opts.file.layers.items(.source)[init_opts.file.selected_layer_index].hash()) == null)
- perf.draw_texture_creates += 1;
- if (has_below) {
- if (dvui.Texture.fromTargetTemp(init_opts.file.editor.split_composite_below.?) catch null) |tex| {
- dvui.renderTriangles(triangles, tex) catch {
- dvui.log.err("Failed to render below composite", .{});
- };
- }
- }
-
- const active_source = init_opts.file.layers.items(.source)[init_opts.file.selected_layer_index];
- if (init_opts.file.layers.items(.visible)[init_opts.file.selected_layer_index]) {
- if (active_source.getTexture() catch null) |tex| {
- dvui.renderTriangles(triangles, tex) catch {
- dvui.log.err("Failed to render active layer", .{});
- };
- }
- }
-
- renderTransformIfActive(init_opts, triangles);
-
- if (has_above) {
- if (dvui.Texture.fromTargetTemp(init_opts.file.editor.split_composite_above.?) catch null) |tex| {
- dvui.renderTriangles(triangles, tex) catch {
- dvui.log.err("Failed to render above composite", .{});
- };
- }
- }
-
- return;
- }
- }
-
- // When idle: use full composite (all layers = 1 draw)
- if (fullCompositeEligible(init_opts, min_layer_index, needs_dimmed)) {
- syncLayerComposite(init_opts.file) catch |err| {
- dvui.log.err("Layer composite sync failed: {any}", .{err});
- };
- if (init_opts.file.editor.layer_composite_target) |ct| {
- if (dvui.Texture.fromTargetTemp(ct) catch null) |ctex| {
- dvui.renderTriangles(triangles, ctex) catch {
- dvui.log.err("Failed to render layer composite", .{});
- };
- return;
- }
- }
- }
-
- // Fallback: per-layer rendering
- var order_buf: [1024]usize = undefined;
- const order_opt = layerOrderBufForDragPreview(init_opts.file, order_buf[0..]);
-
- var list_pos: usize = init_opts.file.layers.len;
- while (list_pos > min_layer_index) {
- list_pos -= 1;
- const layer_index = if (order_opt) |o| o[list_pos] else list_pos;
-
- const visible = init_opts.file.layers.items(.visible)[layer_index];
-
- var tris = triangles;
-
- if (needs_dimmed) {
- if (init_opts.file.peek_layer_index) |peek_layer_index| {
- if (peek_layer_index != layer_index) {
- tris = dimmed_triangles.?;
- }
- }
- }
-
- if (visible) {
- const source = init_opts.file.layers.items(.source)[layer_index];
- if (source.getTexture() catch null) |tex| {
- dvui.renderTriangles(tris, tex) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
- }
- }
-
- if (layer_index == init_opts.file.selected_layer_index) {
- renderTransformIfActive(init_opts, triangles);
- }
- }
-}
diff --git a/src/internal/Animation.zig b/src/internal/Animation.zig
deleted file mode 100644
index 70c3c4a1..00000000
--- a/src/internal/Animation.zig
+++ /dev/null
@@ -1,140 +0,0 @@
-const std = @import("std");
-const dvui = @import("dvui");
-const Animation = @This();
-pub const Frame = @import("../Animation.zig").Frame;
-
-// TODO: make the same type as external without id
-id: u64,
-name: []const u8,
-frames: []Frame,
-
-pub const AnimationV2 = struct {
- id: u64,
- name: []const u8,
- frames: []usize,
- fps: f32,
-};
-
-pub fn init(allocator: std.mem.Allocator, id: u64, name: []const u8, frames: []Frame) !Animation {
- return .{
- .id = id,
- .name = try allocator.dupe(u8, name),
- .frames = try allocator.dupe(Frame, frames),
- };
-}
-
-pub fn appendFrame(self: *Animation, allocator: std.mem.Allocator, frame: Frame) !void {
- var new_frames = std.array_list.Managed(Frame).init(allocator);
- new_frames.appendSlice(self.frames) catch |err| {
- dvui.log.err("Failed to append frames", .{});
- return err;
- };
- new_frames.append(frame) catch |err| {
- dvui.log.err("Failed to append frame", .{});
- return err;
- };
-
- allocator.free(self.frames);
-
- self.frames = new_frames.toOwnedSlice() catch |err| {
- dvui.log.err("Failed to free frames", .{});
- return err;
- };
-}
-
-pub fn appendFrames(self: *Animation, allocator: std.mem.Allocator, frames: []Frame) !void {
- var new_frames = std.array_list.Managed(Frame).init(allocator);
- new_frames.appendSlice(self.frames) catch |err| {
- dvui.log.err("Failed to append frames", .{});
- return err;
- };
- new_frames.appendSlice(frames) catch |err| {
- dvui.log.err("Failed to append frames", .{});
- return err;
- };
-
- allocator.free(self.frames);
- self.frames = new_frames.toOwnedSlice() catch |err| {
- dvui.log.err("Failed to free frames", .{});
- return err;
- };
-}
-
-pub fn insertFrame(self: *Animation, allocator: std.mem.Allocator, index: usize, frame: Frame) !void {
- var new_frames = std.array_list.Managed(Frame).init(allocator);
- new_frames.appendSlice(self.frames) catch |err| {
- dvui.log.err("Failed to append frames", .{});
- return err;
- };
- new_frames.insert(index, frame) catch |err| {
- dvui.log.err("Failed to insert frame", .{});
- return err;
- };
-
- allocator.free(self.frames);
-
- self.frames = new_frames.toOwnedSlice() catch |err| {
- dvui.log.err("Failed to free frames", .{});
- return err;
- };
-}
-
-pub fn removeFrame(self: *Animation, allocator: std.mem.Allocator, index: usize) void {
- var new_frames = std.array_list.Managed(Frame).init(allocator);
- new_frames.appendSlice(self.frames) catch {
- dvui.log.err("Failed to append frames", .{});
- return;
- };
- _ = new_frames.orderedRemove(index);
-
- allocator.free(self.frames);
-
- self.frames = new_frames.toOwnedSlice() catch {
- dvui.log.err("Failed to free frames", .{});
- return;
- };
-}
-
-pub fn eql(a: Animation, b: Animation) bool {
- var e: bool = true;
- if (a.frames.len != b.frames.len) {
- return false;
- }
-
- for (a.frames, b.frames) |frame_a, frame_b| {
- if (frame_a.sprite_index != frame_b.sprite_index) {
- e = false;
- break;
- } else if (frame_a.ms != frame_b.ms) {
- e = false;
- break;
- }
- }
-
- return e;
-}
-
-pub fn eqlFrames(a: Animation, frames: []Frame) bool {
- var e: bool = true;
-
- if (a.frames.len != frames.len) {
- return false;
- }
-
- for (a.frames, frames) |frame_a, frame_b| {
- if (frame_a.sprite_index != frame_b.sprite_index) {
- e = false;
- break;
- } else if (frame_a.ms != frame_b.ms) {
- e = false;
- break;
- }
- }
-
- return e;
-}
-
-pub fn deinit(self: *Animation, allocator: std.mem.Allocator) void {
- allocator.free(self.name);
- allocator.free(self.frames);
-}
diff --git a/src/internal/Atlas.zig b/src/internal/Atlas.zig
deleted file mode 100644
index 676e9f1f..00000000
--- a/src/internal/Atlas.zig
+++ /dev/null
@@ -1,109 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const dvui = @import("dvui");
-
-const Atlas = @This();
-const ExternalAtlas = @import("../Atlas.zig");
-
-const alpha_checkerboard_count: u32 = 8;
-
-/// The packed atlas texture
-source: dvui.ImageSource,
-canvas: fizzy.dvui.CanvasWidget = .{},
-
-/// Checkerboard tile for the project-tab atlas preview (not tied to open files).
-checkerboard_tile: ?dvui.Texture = null,
-
-// /// The packed atlas heightmap
-// heightmap: ?fizzy.gfx.Texture = null,
-
-/// The actual atlas, which contains the sprites and animations data
-data: ExternalAtlas,
-
-pub fn initCheckerboardTile(atlas: *Atlas) void {
- deinitCheckerboardTile(atlas);
- atlas.checkerboard_tile = fizzy.image.checkerboardTile(
- alpha_checkerboard_count,
- alpha_checkerboard_count,
- fizzy.editor.settings.checker_color_even,
- fizzy.editor.settings.checker_color_odd,
- );
-}
-
-pub fn deinitCheckerboardTile(atlas: *Atlas) void {
- if (atlas.checkerboard_tile) |t| {
- dvui.textureDestroyLater(t);
- atlas.checkerboard_tile = null;
- }
-}
-
-pub const Selector = enum {
- source,
- data,
-};
-
-pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
- // Wasm: there's no on-disk path to write to. Encode the atlas into a buffer
- // and trigger a browser download via `wasm_download_data`. The native path
- // below writes through `std.Io.Dir.cwd()` which requires `posix.AT` (not
- // available on `wasm32-freestanding`).
- if (comptime @import("builtin").target.cpu.arch == .wasm32) {
- const allocator = fizzy.editor.arena.allocator();
- 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);
- } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
- try fizzy.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);
- },
- .data => {
- if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) {
- std.log.debug("File name must end with .atlas extension!", .{});
- return error.InvalidExtension;
- }
- const options: std.json.Stringify.Options = .{};
- const output = try std.json.Stringify.valueAlloc(allocator, atlas.data, options);
- defer allocator.free(output);
- try @import("../editor/WebFileIo.zig").downloadBytes(path, output);
- },
- }
- return;
- }
-
- 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;
-
- if (std.mem.eql(u8, ext, ".png")) {
- try fizzy.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);
- } else {
- std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{});
- return error.InvalidExtension;
- }
- },
- .data => {
- if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) {
- std.log.debug("File name must end with .atlas extension!", .{});
- return error.InvalidExtension;
- }
- const options: std.json.Stringify.Options = .{};
-
- const output = try std.json.Stringify.valueAlloc(fizzy.editor.arena.allocator(), 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/internal/Buffers.zig
deleted file mode 100644
index 88bb3c4f..00000000
--- a/src/internal/Buffers.zig
+++ /dev/null
@@ -1,132 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-
-const History = @import("History.zig");
-const Buffers = @This();
-
-stroke: Stroke,
-temporary_stroke: Stroke,
-
-pub const Stroke = struct {
- //indices: std.ArrayList(usize),
- //values: std.ArrayList([4]u8),
-
- pixels: std.AutoHashMap(usize, [4]u8),
- //canvas: fizzy.Internal.file.gui.canvas = .primary,
-
- pub fn init(allocator: std.mem.Allocator) Stroke {
- return .{
- .pixels = .init(allocator),
- // .indices = std.ArrayList(usize).init(allocator),
- // .values = std.ArrayList([4]u8).init(allocator),
- };
- }
-
- pub fn append(stroke: *Stroke, index: usize, value: [4]u8) !void {
- const ptr = try stroke.pixels.getOrPut(index);
- if (fizzy.perf.record) {
- fizzy.perf.stroke_append_calls += 1;
- if (!ptr.found_existing) fizzy.perf.stroke_append_new_keys += 1;
- }
- if (!ptr.found_existing)
- ptr.value_ptr.* = value;
-
- // try stroke.indices.append(index);
-
- // try stroke.values.append(value);
- //stroke.canvas = canvas;
- }
-
- /// Clears the stroke map and reserves hash buckets for up to `max_keys` entries (no rehash churn
- /// while filling). Call before a known full-layer pass such as transform accept.
- pub fn clearAndReserveCapacity(stroke: *Stroke, max_keys: usize) !void {
- stroke.clearAndFree();
- const cap: u32 = @intCast(@min(max_keys, std.math.maxInt(u32)));
- try stroke.pixels.ensureTotalCapacity(cap);
- }
-
- /// Like `append` but the map must already have capacity for new keys (see `clearAndReserveCapacity`).
- pub fn appendAssumeCapacity(stroke: *Stroke, index: usize, value: [4]u8) void {
- const gop = stroke.pixels.getOrPutAssumeCapacity(index);
- if (fizzy.perf.record) {
- fizzy.perf.stroke_append_calls += 1;
- if (!gop.found_existing) fizzy.perf.stroke_append_new_keys += 1;
- }
- if (!gop.found_existing)
- gop.value_ptr.* = value;
- }
-
- pub fn appendSlice(stroke: *Stroke, indices: []usize, values: [][4]u8) !void {
- for (indices, values) |index, value| {
- try stroke.append(index, value);
- }
-
- //try stroke.indices.appendSlice(indices);
- //try stroke.values.appendSlice(values);
- //stroke.canvas = canvas;
- }
-
- pub fn toChange(stroke: *Stroke, layer_id: u64) !History.Change {
- const t0: i128 = if (fizzy.perf.record) fizzy.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 it = stroke.pixels.iterator();
-
- var i: usize = 0;
- while (it.next()) |entry| {
- indices[i] = entry.key_ptr.*;
- values[i] = entry.value_ptr.*;
- i += 1;
- }
-
- stroke.pixels.clearAndFree();
-
- if (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;
- }
-
- return .{ .pixels = .{
- .layer_id = layer_id,
- .indices = indices,
- .values = values,
- } };
- }
-
- pub fn clearAndFree(stroke: *Stroke) void {
- stroke.pixels.clearAndFree();
- // stroke.indices.clearAndFree();
- // stroke.values.clearAndFree();
- }
-
- pub fn deinit(stroke: *Stroke) void {
- stroke.clearAndFree();
- // stroke.indices.deinit();
- // stroke.values.deinit();
- }
-};
-
-pub fn init(allocator: std.mem.Allocator) Buffers {
- return .{
- .stroke = Stroke.init(allocator),
- .temporary_stroke = Stroke.init(allocator),
- };
-}
-
-pub fn clearAndFree(buffers: *Buffers) void {
- buffers.stroke.clearAndFree();
- buffers.temporary_stroke.clearAndFree();
-}
-
-pub fn deinit(buffers: *Buffers) void {
- buffers.clearAndFree();
- buffers.stroke.deinit();
- buffers.temporary_stroke.deinit();
-}
diff --git a/src/internal/File.zig b/src/internal/File.zig
deleted file mode 100644
index 0747db78..00000000
--- a/src/internal/File.zig
+++ /dev/null
@@ -1,3850 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const zip = @import("zip");
-const dvui = @import("dvui");
-
-const Editor = fizzy.Editor;
-
-const File = @This();
-
-const Layer = @import("Layer.zig");
-const Sprite = @import("Sprite.zig");
-const Animation = @import("Animation.zig");
-
-const alpha_checkerboard_count: u32 = 8;
-
-/// Deferred brush snapshot is skipped above this area (width×height); drawing falls back to per-pixel stroke recording.
-pub const stroke_undo_max_snapshot_pixels: u64 = 16 * 1024 * 1024;
-
-id: u64,
-path: []const u8,
-
-columns: u32 = 1,
-rows: u32 = 1,
-column_width: u32,
-row_height: u32,
-
-selected_layer_index: usize = 0,
-peek_layer_index: ?usize = null,
-layers: std.MultiArrayList(Layer) = .{},
-deleted_layers: std.MultiArrayList(Layer) = .{},
-
-sprites: std.MultiArrayList(Sprite) = .{},
-
-selected_animation_index: ?usize = null,
-selected_animation_frame_index: usize = 0,
-
-animations: std.MultiArrayList(Animation) = .{},
-deleted_animations: std.MultiArrayList(Animation) = .{},
-
-history: History,
-buffers: Buffers,
-
-layer_id_counter: u64 = 0,
-anim_id_counter: u64 = 0,
-
-/// File-specific editor data
-editor: EditorData = .{},
-
-/// This may be a confusing distinction between "editor" and File fields,
-/// but the intent is that fields inside of the editor namespace are actively
-/// used each frame to write/read data the editor directly depends on.
-///
-/// Also, the fields here tend to be directly coupled with the UI library
-pub const EditorData = struct {
- // Only valid while file widget is drawing the file
- workspace: *fizzy.Editor.Workspace = undefined,
- canvas: fizzy.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,
-
- playing: bool = false,
- saving: bool = false,
- grouping: u64 = 0,
-
- // Internal layers for editor
- isolate_layer: bool = false,
- temporary_layer: Layer = undefined,
- selection_layer: Layer = undefined,
- transform_layer: Layer = undefined,
- selected_sprites: std.DynamicBitSet = undefined,
- /// Primary tile among a multi-sprite selection (cover-flow center / tallest bubble).
- /// When an animation is selected, `selected_animation_frame_index` is kept in sync.
- primary_sprite_index: ?usize = null,
-
- checkerboard: std.DynamicBitSet = undefined,
- checkerboard_tile: ?dvui.Texture = null,
-
- /// Flattened visible-layer stack cached as a render target.
- /// Reused across frames; rebuilt only when content or structure changes.
- layer_composite_target: ?dvui.Texture.Target = null,
- layer_composite_frame_built: u64 = 0,
- layer_composite_dirty: bool = true,
- /// Bumped each time the flattened layer texture is actually rebuilt, so
- /// downstream bakes (e.g. the sprite preview) can detect a stale input cheaply.
- layer_composite_generation: u64 = 0,
-
- /// Split composites for use during active drawing. The "below" target
- /// contains all visible layers below the active layer; the "above" target
- /// contains all visible layers above it. This avoids per-layer draws
- /// without requiring per-frame render target switches.
- split_composite_below: ?dvui.Texture.Target = null,
- split_composite_above: ?dvui.Texture.Target = null,
- split_composite_layer: ?usize = null,
- split_composite_dirty: bool = true,
- split_composite_frame_built: u64 = 0,
-
- /// Sprite-panel cover-flow preview: checkerboard + flattened layers +
- /// selection + temp baked into one texture, so each card (front and
- /// reflection) draws in a single textured pass instead of replaying the
- /// whole stack as several overlapping alpha-blended fills. Rebuilt at most
- /// once per frame (see `render.syncPreviewComposite`).
- preview_composite_target: ?dvui.Texture.Target = null,
- preview_composite_frame_built: u64 = 0,
- /// Content signature of the last preview-composite bake. The bake is skipped
- /// (texture reused) when this is unchanged — so a static cover flow whose
- /// reflections are merely rippling doesn't re-flatten the sprite every frame.
- preview_composite_sig: u64 = 0,
- preview_composite_valid: bool = false,
-
- /// Tracks when the active layer transparency mask was last built,
- /// so we can skip rebuilding it when the layer hasn't changed.
- mask_built_for_layer: ?usize = null,
- mask_built_source_hash: u64 = 0,
-
- /// Pixel region written by the last temp layer brush preview. Used to
- /// cheaply clear only the affected area instead of memset-ing the full
- /// 64 MB buffer each frame.
- temp_preview_dirty_rect: ?dvui.Rect = null,
- /// True when the temp layer contains any non-zero content (brush preview,
- /// selection visualization, etc.) and needs clearing next frame.
- temp_layer_has_content: bool = false,
- /// Bumped every time the temp layer's pixels change (brush preview moves,
- /// stroke segment, selection visualization, clear). The temp layer uses
- /// `.ptr` invalidation, so `source.hash()` only sees the buffer pointer and
- /// not its contents — downstream bakes (the sprite preview composite) hash
- /// this counter instead to notice a moved hover preview that reuses the
- /// same buffer.
- temp_layer_generation: u64 = 0,
- /// Accumulated region of the temp layer whose CPU pixels differ from the
- /// GPU texture. Persists across frames until flushed via sub-rect upload
- /// in renderLayers, so stale GPU data is always cleaned up.
- temp_gpu_dirty_rect: ?dvui.Rect = null,
- /// True while a stroke drag is in progress (mouse pressed and captured).
- active_drawing: bool = false,
- /// Accumulated dirty rect for the active layer during the current frame.
- /// Used to perform a sub-rect texture upload instead of a full invalidate.
- active_layer_dirty_rect: ?dvui.Rect = null,
-
- /// While true, brush painting skips per-pixel `buffers.stroke.append` during the drag; the
- /// pre-stroke region is snapshotted and diffed on commit (mouse release) instead.
- stroke_undo_deferred: bool = false,
- /// Row-major RGBA snapshot for `stroke_undo_{x,y,w,h}` (length `w * h * 4`).
- stroke_undo_pixels: ?[]u8 = null,
- stroke_undo_x: u32 = 0,
- stroke_undo_y: u32 = 0,
- stroke_undo_w: u32 = 0,
- stroke_undo_h: u32 = 0,
-
- /// Layer list reorder preview while dragging in the tree (`null` = no preview). Matches drop logic in `explorer/tools.zig`.
- layer_drag_preview_removed: ?usize = null,
- layer_drag_preview_insert_before: ?usize = null,
-
- /// Multi-selection for the layer list. Sorted, deduplicated. Lazily seeded with the primary
- /// `selected_layer_index` on first click gesture; the tree's drop handler uses this set to
- /// move multiple selected layers as a group. The primary index must always be present (the
- /// editor cannot have zero selected layers).
- selected_layer_indices: std.ArrayListUnmanaged(usize) = .empty,
- layer_selection_anchor: ?usize = null,
-
- /// Multi-selection for the animation list. Sorted, deduplicated. Empty iff no animation is
- /// selected; otherwise always contains the primary `selected_animation_index`.
- selected_animation_indices: std.ArrayListUnmanaged(usize) = .empty,
- animation_selection_anchor: ?usize = null,
-
- /// Multi-selection for the frame list of the currently selected animation. Cleared and
- /// reseeded whenever the primary animation changes. `..._for_animation_id` tracks which
- /// animation the set belongs to so stale selections are discarded cheaply.
- selected_frame_indices: std.ArrayListUnmanaged(usize) = .empty,
- selected_frame_indices_for_animation_id: u64 = 0,
- frame_selection_anchor: ?usize = null,
-
- /// Last frame's `isSaving()` — used on the GUI thread to detect save-finished transitions.
- was_saving: bool = false,
- /// Set from any thread in `setSaving(false)`; main-thread `tickSaveDoneFlash` arms the flash.
- save_complete: std.atomic.Value(bool) = .init(false),
- /// Monotonic deadline (`fizzy.perf.nanoTimestamp`): save-complete affordance in tab / tree.
- save_complete_show_duration: ?i128 = null,
- /// Set with `save_complete_show_duration` when the flash arms (`isSaving` → false).
- save_complete_show_start: ?i128 = null,
-};
-
-pub const History = @import("History.zig");
-pub const Buffers = @import("Buffers.zig");
-
-pub const InitOptions = struct {
- columns: u32 = 1,
- rows: u32 = 1,
- column_width: u32,
- row_height: u32,
-};
-
-pub fn init(path: []const u8, options: InitOptions) !fizzy.Internal.File {
- var internal: fizzy.Internal.File = .{
- .id = fizzy.editor.newFileID(),
- .path = try fizzy.app.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),
- };
-
- // 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.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.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);
- 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;
- }
-
- // Initialize sprites
- for (0..internal.spriteCount()) |_| {
- internal.sprites.append(fizzy.app.allocator, .{
- .origin = .{ 0.0, 0.0 },
- }) catch return error.FileLoadError;
- }
-
- return internal;
-}
-
-/// Tile pixel dimensions sized so each checker square is roughly square when stretched to a
-/// `column_width × row_height` cell. Width fixed at `alpha_checkerboard_count`; height tracks the
-/// inverse aspect ratio (clamped to a sane range).
-fn checkerboardTileDims(column_width: u32, row_height: u32) struct { w: u32, h: u32 } {
- const aspect = @as(f32, @floatFromInt(column_width)) / @as(f32, @floatFromInt(row_height));
- const h_f = @round(@as(f32, @floatFromInt(alpha_checkerboard_count)) / aspect);
- return .{ .w = alpha_checkerboard_count, .h = std.math.clamp(2, @as(u32, @intFromFloat(h_f)), 1024) };
-}
-
-/// Lazily build (or rebuild on grid resize) the wrap=.repeat checker texture. Called from UI render
-/// paths so it can use the dvui frame context — file load runs on a worker thread, so we can't
-/// touch `dvui.textureCreate` there.
-pub fn checkerboardTileTexture(file: *File) ?dvui.Texture {
- const want = checkerboardTileDims(file.column_width, file.row_height);
- if (file.editor.checkerboard_tile) |t| {
- if (t.width == want.w and t.height == want.h) return t;
- dvui.textureDestroyLater(t);
- file.editor.checkerboard_tile = null;
- }
- file.editor.checkerboard_tile = fizzy.image.checkerboardTile(
- want.w,
- want.h,
- fizzy.editor.settings.checker_color_even,
- fizzy.editor.settings.checker_color_odd,
- );
- return file.editor.checkerboard_tile;
-}
-
-pub fn width(file: *const File) u32 {
- return file.columns * file.column_width;
-}
-
-pub fn height(file: *const File) u32 {
- return file.rows * file.row_height;
-}
-
-/// Set the save-in-progress flag with an atomic store. `saveZip` runs on a background worker
-/// thread and writes through this helper; the main thread reads via `isSaving()` for the tab
-/// strip spinner. `monotonic` is sufficient — we don't synchronize any other data through this
-/// flag, just publish the boolean.
-pub fn setSaving(file: *File, v: bool) void {
- const was = file.isSaving();
- if (was == v) return;
- @atomicStore(bool, &file.editor.saving, v, .monotonic);
- if (v) {
- file.editor.save_complete.store(false, .monotonic);
- file.editor.save_complete_show_duration = null;
- file.editor.save_complete_show_start = null;
- } else {
- // Arm the finish animation immediately so synchronous wasm saves (and any save
- // that completes between frames) don't leave `save_complete` stuck true.
- const now = fizzy.perf.nanoTimestamp();
- file.editor.save_complete_show_start = now;
- file.editor.save_complete_show_duration = now + save_done_flash_duration_ns;
- file.editor.save_complete.store(false, .monotonic);
- }
-}
-
-/// Atomic-load counterpart to `setSaving`. Safe to call from any thread.
-pub fn isSaving(file: *const File) bool {
- return @atomicLoad(bool, &file.editor.saving, .monotonic);
-}
-
-/// Clears in-flight save UI state (tab spinner, blocked close). Call when a save dialog is cancelled.
-pub fn resetSaveUIState(file: *File) void {
- file.setSaving(false);
- file.editor.save_complete.store(false, .monotonic);
- file.editor.save_complete_show_duration = null;
- file.editor.save_complete_show_start = null;
- file.editor.was_saving = false;
-}
-
-const save_done_flash_duration_ns: i128 = 2 * std.time.ns_per_s;
-
-/// Call once per frame from the main thread. Arms save-complete feedback when
-/// `isSaving()` falls from true to false.
-pub fn tickSaveDoneFlash(file: *File) void {
- const now = fizzy.perf.nanoTimestamp();
- const saving = file.isSaving();
- const pending = file.editor.save_complete.swap(false, .monotonic);
- if (!saving and (pending or file.editor.was_saving) and file.editor.save_complete_show_duration == null) {
- file.editor.save_complete_show_start = now;
- file.editor.save_complete_show_duration = now + save_done_flash_duration_ns;
- }
- if (saving) {
- file.editor.save_complete_show_duration = null;
- file.editor.save_complete_show_start = null;
- file.editor.save_complete.store(false, .monotonic);
- }
- file.editor.was_saving = saving;
- if (file.editor.save_complete_show_duration) |until| {
- if (now >= until) {
- file.editor.save_complete_show_duration = null;
- file.editor.save_complete_show_start = null;
- }
- }
-}
-
-/// Tab / tree slot should show the bubble spinner (saving or finish animation).
-pub fn showsSaveStatusIndicator(file: *const File) bool {
- if (file.isSaving()) return true;
- return timeSinceSaveComplete(file) != null;
-}
-
-pub fn showSaveDoneFlash(file: *const File) bool {
- return timeSinceSaveComplete(file) != null;
-}
-
-/// Nanoseconds since save finished (`null` when inactive). Drives [`fizzy.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();
- if (now >= until) return null;
- return @max(@as(i128, 0), now - st);
-}
-
-/// Width × height of the artwork in pixels, taken from the first layer. This matches the in-memory
-/// canvas even if grid metadata were ever inconsistent with `width()` / `height()`.
-pub fn canvasPixelSize(file: *const File) struct { w: u32, h: u32 } {
- if (file.layers.len == 0) return .{ .w = 0, .h = 0 };
- const s = file.layers.get(0).size();
- return .{
- .w = @intFromFloat(s.w),
- .h = @intFromFloat(s.h),
- };
-}
-
-/// Clears the cached per-layer transparency mask used by the selection overlay (`FileWidget.updateActiveLayerMask`).
-/// Call after any in-memory edit to layer pixels while `ImageSource.hash()` is pointer-based and does not
-/// change when bytes change (see also `Transform.accept` / undo-redo).
-pub fn invalidateActiveLayerTransparencyMaskCache(file: *File) void {
- file.editor.mask_built_for_layer = null;
-}
-
-/// Fills `out[0..len]` with storage indices in list order (position 0 = top row / front of stack)
-/// after moving the layer at `removed` to sit before `insert_before`, matching `explorer/tools.zig` drop handling.
-///
-/// The implementation lives in `layer_order.zig` (pure logic, no dvui)
-/// so it can be unit-tested by `zig build test`. Re-exported here to
-/// keep existing call sites unchanged.
-pub const layerOrderAfterMove = @import("layer_order.zig").layerOrderAfterMove;
-
-/// Load from in-memory bytes (browser file picker). `path` is used for extension detection and display name.
-pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File {
- const extension = std.fs.path.extension(path);
- if (isFlatImageExtension(extension)) {
- return fromBytesFlatImage(path, file_bytes);
- }
- if (isFizzyExtension(extension)) {
- return fromBytesFizzy(path, file_bytes);
- }
- return error.InvalidExtension;
-}
-
-/// Attempts to load a file from the given path to create a new file
-pub fn fromPath(path: []const u8) !?fizzy.Internal.File {
- const extension = std.fs.path.extension(path[0..path.len]);
- if (isFlatImageExtension(extension)) {
- const file = fromPathFlatImage(path) catch |err| {
- dvui.log.err("{any}: {s}", .{ err, path });
- return err;
- };
- return file;
- }
-
- if (isFizzyExtension(extension)) {
- const file = fromPathFizzy(path) catch |err| {
- dvui.log.err("{any}: {s}", .{ err, path });
- return err;
- };
- return file;
- }
-
- return error.InvalidExtension;
-}
-
-/// `.fiz` is the current native extension; `.pixi` is kept for legacy file load support.
-pub fn isFizzyExtension(ext: []const u8) bool {
- return std.mem.eql(u8, ext, ".fiz") or std.mem.eql(u8, ext, ".pixi");
-}
-
-pub fn fromPathFizzy(path: []const u8) !?fizzy.Internal.File {
- return loadFizzyZip(path, null);
-}
-
-pub fn fromBytesFizzy(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File {
- return loadFizzyZip(path, file_bytes);
-}
-
-fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.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)
- else
- "";
- defer if (file_bytes == null) fizzy.app.allocator.free(null_terminated_path);
-
- zip_open: {
- const fizzy_file = if (file_bytes) |bytes|
- zip.zip_stream_open(bytes.ptr, bytes.len, 0, 'r')
- else
- zip.zip_open(null_terminated_path.ptr, 0, 'r') orelse break :zip_open;
- defer if (file_bytes != null) zip.zip_stream_close(fizzy_file) else zip.zip_close(fizzy_file);
-
- var buf: ?*anyopaque = null;
- var size: usize = 0;
- // Try the current entry name first, then fall back to the legacy `pixidata.json`
- // so files saved by the pre-rename Pixi versions still load.
- if (zip.zip_entry_open(fizzy_file, "fizzydata.json") != 0) {
- _ = zip.zip_entry_open(fizzy_file, "pixidata.json");
- }
- _ = zip.zip_entry_read(fizzy_file, &buf, &size);
- _ = zip.zip_entry_close(fizzy_file);
- defer if (buf) |b| {
- if (comptime @import("builtin").target.cpu.arch == .wasm32) {
- zip.fizzy_zip_free(b);
- } else {
- std.c.free(b);
- }
- };
-
- const content: []const u8 = if (buf) |b| @as([*]const u8, @ptrCast(b))[0..size] else "";
-
- const options = std.json.ParseOptions{
- .duplicate_field_behavior = .use_first,
- .ignore_unknown_fields = true,
- };
-
- var try_parse: ?std.json.Parsed(fizzy.File) = null;
- try_parse = std.json.parseFromSlice(fizzy.File, fizzy.app.allocator, content, options) catch null;
-
- var ext: fizzy.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| {
- std.log.info("Loading file v3: {s}", .{path});
- const animations = try fizzy.app.allocator.alloc(fizzy.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);
- for (animation.frames, old_animation.frames) |*frame, old_frame| {
- frame.sprite_index = old_frame;
- frame.ms = @intFromFloat(1000 / old_animation.fps);
- }
- }
-
- ext = .{
- .version = old_file.value.version,
- .columns = old_file.value.columns,
- .rows = old_file.value.rows,
- .column_width = old_file.value.column_width,
- .row_height = old_file.value.row_height,
- .layers = old_file.value.layers,
- .sprites = old_file.value.sprites,
- .animations = animations,
- };
- } else if (std.json.parseFromSlice(fizzy.File.FileV2, fizzy.app.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);
- 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);
- for (animation.frames, old_animation.frames) |*frame, old_frame| {
- frame.sprite_index = old_frame;
- frame.ms = @intFromFloat(1000 / old_animation.fps);
- }
- }
-
- ext = .{
- .version = old_file.value.version,
- .columns = @divExact(old_file.value.width, old_file.value.tile_width),
- .rows = @divExact(old_file.value.height, old_file.value.tile_height),
- .column_width = old_file.value.tile_width,
- .row_height = old_file.value.tile_height,
- .layers = old_file.value.layers,
- .sprites = old_file.value.sprites,
- .animations = animations,
- };
- } else if (std.json.parseFromSlice(fizzy.File.FileV1, fizzy.app.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);
- 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);
- for (animation.frames, 0..old_file.value.animations[i].length) |*frame, j| {
- frame.sprite_index = old_file.value.animations[i].start + j;
- frame.ms = @intFromFloat(1000 / old_file.value.animations[i].fps);
- }
- }
-
- ext = .{
- .version = old_file.value.version,
- .columns = @divExact(old_file.value.width, old_file.value.tile_width),
- .rows = @divExact(old_file.value.height, old_file.value.tile_height),
- .column_width = old_file.value.tile_width,
- .row_height = old_file.value.tile_height,
- .layers = old_file.value.layers,
- .sprites = old_file.value.sprites,
- .animations = animations,
- };
- }
- }
-
- defer if (try_parse) |parsed| parsed.deinit();
-
- //defer parsed.deinit();
-
- var internal: fizzy.Internal.File = .{
- .id = fizzy.editor.newFileID(),
- .path = try fizzy.app.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),
- };
-
- //Initialize editor layers and selected sprites
- // .ptr: same as new-file init — GPU sync via invalidate / temp_gpu_dirty_rect + updateSubRect.
- // .always would re-upload the full texture on every getTexture() (e.g. sprite panel reflection).
- internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount());
-
- internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.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);
- 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);
-
- var img_buf: ?*anyopaque = null;
- var img_len: usize = 0;
-
- if (zip.zip_entry_open(fizzy_file, layer_image_name.ptr) == 0) { // Read layer file as directly pixels
- _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len);
- const data = img_buf orelse continue;
-
- var new_layer: fizzy.Internal.Layer = try .fromPixelsPMA(
- internal.newLayerID(),
- l.name,
- @as([*]dvui.Color.PMA, @ptrCast(@constCast(data)))[0..(internal.width() * internal.height())],
- internal.width(),
- internal.height(),
- .ptr,
- );
-
- new_layer.visible = l.visible;
- new_layer.collapse = l.collapse;
-
- new_layer.setMaskFromTransparency(true);
-
- internal.layers.append(fizzy.app.allocator, new_layer) catch return error.FileLoadError;
-
- if (l.visible and !set_layer_index) {
- internal.selected_layer_index = i;
- set_layer_index = true;
- }
- } else if (zip.zip_entry_open(fizzy_file, png_image_name.ptr) == 0) { // Read the layer file as PNG file
- _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len);
- const data = img_buf orelse continue;
-
- var new_layer: fizzy.Internal.Layer = try .fromImageFileBytes(
- internal.newLayerID(),
- l.name,
- @as([*]u8, @ptrCast(data))[0..img_len],
- .ptr,
- );
-
- new_layer.visible = l.visible;
- new_layer.collapse = l.collapse;
-
- new_layer.setMaskFromTransparency(true);
-
- internal.layers.append(fizzy.app.allocator, new_layer) catch return error.FileLoadError;
-
- if (l.visible and !set_layer_index) {
- internal.selected_layer_index = i;
- set_layer_index = true;
- }
- }
-
- _ = zip.zip_entry_close(fizzy_file);
- }
- _ = zip.zip_entry_close(fizzy_file);
-
- for (0..internal.spriteCount()) |sprite_index| {
- if (sprite_index >= ext.sprites.len) {
- internal.sprites.append(fizzy.app.allocator, .{
- .origin = .{ 0, 0 },
- }) catch return error.FileLoadError;
- } else {
- internal.sprites.append(fizzy.app.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, .{
- .id = internal.newAnimationID(),
- .name = try fizzy.app.allocator.dupe(u8, animation.name),
- .frames = try fizzy.app.allocator.dupe(Animation.Frame, animation.frames),
- }) catch return error.FileLoadError;
- }
- return internal;
- }
- // { // Loading TAR experiment
- // var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
- // var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
-
- // if (fizzy.fs.read(fizzy.app.allocator, path) catch null) |file_bytes| {
- // std.log.debug("Read file bytes!", .{});
- // var input = std.io.fixedBufferStream(file_bytes);
- // var iter = std.tar.iterator(input.reader(), .{
- // .file_name_buffer = &file_name_buffer,
- // .link_name_buffer = &link_name_buffer,
- // });
-
- // var json_content = std.array_list.Managed(u8).init(fizzy.app.allocator);
- // defer json_content.deinit();
-
- // while (try iter.next()) |entry| {
- // const ext = std.fs.path.extension(entry.name);
- // if (std.mem.eql(u8, ext, ".json")) {
- // entry.writeAll(json_content.writer()) catch return error.FileLoadError;
- // }
- // }
-
- // const options = std.json.ParseOptions{
- // .duplicate_field_behavior = .use_first,
- // .ignore_unknown_fields = true,
- // };
-
- // if (std.json.parseFromSlice(fizzy.File, fizzy.app.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),
- // .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(
- // ext.tile_width * 2,
- // ext.tile_height * 2,
- // .{ .r = 0, .g = 0, .b = 0, .a = 0 },
- // .ptr,
- // ) catch return error.LayerCreateError,
- // .temporary_layer = undefined,
- // .selection_layer = undefined,
- // .selected_sprites = try std.DynamicBitSet.initEmpty(
- // fizzy.app.allocator,
- // @divExact(ext.width, ext.tile_width) * @divExact(ext.height, ext.tile_height),
- // ),
- // };
-
- // internal.temporary_layer = try .init(internal.newID(), "Temporary", internal.width, internal.height, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .always);
-
- // for (ext.layers, 0..) |ext_layer, i| {
- // const layer_image_name = std.fmt.allocPrintZ(dvui.currentWindow().arena(), "{s}.layer", .{ext_layer.name}) catch "Memory Allocation Failed";
-
- // if (ext_layer.visible) {
- // internal.selected_layer_index = i;
- // }
-
- // iter = std.tar.iterator(input.reader(), .{
- // .file_name_buffer = &file_name_buffer,
- // .link_name_buffer = &link_name_buffer,
- // });
-
- // while (iter.next() catch null) |entry| {
- // std.log.debug("Entry name: {s}", .{entry.name});
-
- // if (std.mem.eql(u8, entry.name, layer_image_name)) {
- // var layer_content = std.array_list.Managed(u8).init(fizzy.app.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;
-
- // 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;
- // } else {
- // std.log.err("Failed to create layer from pixels", .{});
- // }
- // }
- // }
- // }
-
- // return internal;
- // }
- // }
- // }
-
- return error.FileLoadError;
-}
-
-fn isFlatImageExtension(ext: []const u8) bool {
- return std.mem.eql(u8, ext, ".png") or
- std.mem.eql(u8, ext, ".jpg") or
- std.mem.eql(u8, ext, ".jpeg");
-}
-
-/// Extensions that `saveAsync` can write without a Save As dialog.
-pub fn hasRecognizedSaveExtension(path: []const u8) bool {
- const ext = std.fs.path.extension(path);
- return isFizzyExtension(ext) or isFlatImageExtension(ext);
-}
-
-/// True when the document holds state that a flat PNG/JPEG round-trip would not preserve
-/// (layers, tile grid, animations, per-sprite origins).
-pub fn requiresFizzyCompatibleSave(self: File) bool {
- if (self.layers.len != 1) return true;
- if (self.columns != 1 or self.rows != 1) return true;
- if (self.animations.len != 0) return true;
- for (self.sprites.items(.origin)) |o| {
- if (o[0] != 0.0 or o[1] != 0.0) return true;
- }
- return false;
-}
-
-pub fn shouldConfirmFlatRasterSave(self: File) bool {
- const ext = std.fs.path.extension(self.path);
- if (!isFlatImageExtension(ext)) return false;
- return requiresFizzyCompatibleSave(self);
-}
-
-pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?fizzy.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(),
- "Layer",
- file_bytes,
- .ptr,
- );
- return finishFlatImageFile(path, image_layer);
-}
-
-/// Loads a PNG or JPEG as the first layer of a new file, and retains the path
-/// when saved; layers will be flattened to that file
-pub fn fromPathFlatImage(path: []const u8) !?fizzy.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);
- return finishFlatImageFile(path, image_layer);
-}
-
-fn finishFlatImageFile(path: []const u8, image_layer: fizzy.Internal.Layer) !?fizzy.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),
- .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),
- };
-
- internal.layers.append(fizzy.app.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.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.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);
- internal.editor.checkerboard.setValue(i, value);
- }
-
- return internal;
-}
-
-pub const ResizeOptions = struct {
- columns: u32,
- rows: u32,
- history: bool = true, // If true, layer data will be recorded for undo/redo
- layer_data: ?[][][4]u8 = null, // If provided, the layer data will be applied to the layers after resizing
- animation_data: ?[][]fizzy.Animation.Frame = null, // If provided, the animation data will be applied to the animations after resizing
- sprite_data: ?[][2]f32 = null, // If provided, the sprite data will be applied to the sprites after resizing
-};
-
-pub fn resize(file: *File, options: ResizeOptions) !void {
- const current_columns = file.columns;
- const current_rows = file.rows;
-
- if (options.columns == current_columns and
- options.rows == current_rows) return;
-
- if (options.columns == 0 or options.rows == 0) return error.InvalidImageSize;
-
- const new_columns = options.columns;
- const new_rows = options.rows;
-
- const new_width = new_columns * file.column_width;
- const new_height = new_rows * file.row_height;
-
- // First, record the current layer data for undo/redo
- if (options.history) {
- file.history.append(.{ .resize = .{ .width = file.width(), .height = file.height() } }) catch return error.HistoryAppendError;
-
- var layer_data = try fizzy.app.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;
- }
- 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);
- 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;
- }
- 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());
- for (0..file.spriteCount()) |sprite_index| {
- sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index];
- }
- file.history.undo_sprite_data_stack.append(sprite_data) catch return error.MemoryAllocationFailed;
- }
-
- if (options.animation_data) |anim_data| {
- for (0..file.animations.len) |anim_index| {
- var current_animation = file.animations.get(anim_index);
- const current_data = anim_data[anim_index];
-
- var new_animation = fizzy.Internal.Animation.init(fizzy.app.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);
- for (current_data) |frame| {
- new_animation.appendFrame(fizzy.app.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;
- defer file.animations.set(anim_index, new_animation);
- defer animation.deinit(fizzy.app.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;
- }
- }
- }
-
- const old_sprite_count = current_columns * current_rows;
- const new_sprite_count = new_columns * new_rows;
-
- var old_origins_snapshot: ?[][2]f32 = null;
- defer if (old_origins_snapshot) |s| fizzy.app.allocator.free(s);
-
- if (options.sprite_data == null) {
- const snapshot = try fizzy.app.allocator.alloc([2]f32, old_sprite_count);
- for (0..old_sprite_count) |i| {
- snapshot[i] = file.sprites.items(.origin)[i];
- }
- old_origins_snapshot = snapshot;
- }
-
- file.sprites.resize(
- fizzy.app.allocator,
- new_sprite_count,
- ) catch return error.MemoryAllocationFailed;
-
- // MultiArrayList growth leaves new elements undefined; zero everything then restore from undo data or remaps.
- for (0..new_sprite_count) |i| {
- file.sprites.items(.origin)[i] = .{ 0, 0 };
- }
-
- if (options.sprite_data) |sprite_data| {
- const n = @min(new_sprite_count, sprite_data.len);
- for (0..n) |sprite_index| {
- const d = sprite_data[sprite_index];
- file.sprites.items(.origin)[sprite_index] = .{ d[0], d[1] };
- }
- } else {
- const old_origins = old_origins_snapshot orelse unreachable;
- for (0..old_sprite_count) |old_i| {
- if (getResizedIndex(file, old_i, new_columns, new_rows)) |new_i| {
- file.sprites.items(.origin)[new_i] = old_origins[old_i];
- }
- }
- }
-
- // Now, resize the layers, and apply any layer data if needed
- for (0..file.layers.len) |layer_index| {
- var layer = file.layers.get(layer_index);
-
- layer.resize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }) catch return error.LayerResizeError;
-
- if (options.layer_data) |data| {
- if (data[layer_index].len == new_width * new_height)
- layer.blit(data[layer_index], .fromSize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }), .{});
- }
- file.layers.set(layer_index, layer);
- }
-
- file.editor.temporary_layer.resize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }) catch return error.LayerResizeError;
- file.editor.selection_layer.resize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }) catch return error.LayerResizeError;
- file.editor.transform_layer.resize(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }) catch return error.LayerResizeError;
- file.editor.selected_sprites.resize(options.columns * options.rows, false) catch return error.MemoryAllocationFailed;
-
- file.editor.checkerboard.resize(new_width * new_height, false) catch return error.MemoryAllocationFailed;
- for (0..new_width * new_height) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i);
- file.editor.checkerboard.setValue(i, value);
- }
-
- file.columns = new_columns;
- file.rows = new_rows;
-}
-
-/// Returns the sprite index after a grid resize, or null if the cell is outside the new grid.
-/// Index layout is row-major: index = row * columns + column.
-pub fn getResizedIndex(
- self: *File,
- sprite_index: usize,
- new_columns: u32,
- new_rows: u32,
-) ?usize {
- const old_col: u32 = @intCast(@mod(sprite_index, self.columns));
- const old_row: u32 = @intCast(@divTrunc(sprite_index, self.columns));
-
- if (old_row >= self.rows or old_col >= self.columns)
- return null;
-
- if (old_row < new_rows and old_col < new_columns) {
- return old_row * new_columns + old_col;
- } else {
- return null;
- }
-}
-
-/// Returns the sprite index after a drag-and-drop reorder of one column, row, or single cell.
-/// For column/row: `removed_index` is the column/row that was dragged, `insert_before_index` is where it was dropped (before that column/row).
-/// For cell: `removed_index` and `insert_before_index` are sprite indices (grid cell indices); returns where `sprite_index` ends up after the move.
-pub fn getReorderedIndex(
- self: *File,
- removed_index: usize,
- insert_before_index: usize,
- orientation: enum { column, row, cell },
- sprite_index: usize,
-) usize {
- if (removed_index == insert_before_index) return sprite_index;
-
- const insert_pos: usize = if (insert_before_index > removed_index)
- insert_before_index - 1
- else
- insert_before_index;
-
- const col: u32 = @intCast(@mod(sprite_index, self.columns));
- const row: u32 = @intCast(@divTrunc(sprite_index, self.columns));
-
- const pos_along: usize = switch (orientation) {
- .column => col,
- .row => row,
- .cell => sprite_index,
- };
-
- const new_pos_along: usize = if (pos_along == removed_index)
- insert_pos
- else blk: {
- const temp = if (pos_along < removed_index) pos_along else pos_along - 1;
- break :blk if (temp >= insert_pos) temp + 1 else temp;
- };
-
- return switch (orientation) {
- .column => row * self.columns + @as(u32, @intCast(new_pos_along)),
- .row => @as(u32, @intCast(new_pos_along)) * self.columns + col,
- .cell => new_pos_along,
- };
-}
-
-const SpriteReorderMode = enum {
- replace,
- insert,
-};
-
-pub const CellMovePair = struct {
- remove: usize,
- insert: usize,
-};
-
-pub const CellSorting = struct {
- pub fn asc(_: void, a: CellMovePair, b: CellMovePair) bool {
-
- // This below line makes the sorting logic work correctly, but crashes when moving outside of the bounds sometimes.
- if (a.remove > a.insert and b.remove > b.insert) return a.remove < b.remove else if (a.remove < a.insert and b.remove < b.insert) return a.remove > b.remove;
-
- // This removes the crashing, and works for all cases, except for when moving a set forward (increasing index from removed to insert) and overlapping with the removed set.
- if ((a.remove > a.insert and b.remove > b.insert) or (a.remove < a.insert and b.remove < b.insert)) {
- return a.remove < b.remove;
- }
- return a.remove > a.insert;
- }
-
- pub fn desc(_: void, a: CellMovePair, b: CellMovePair) bool {
- return if (a.remove < a.insert) a.remove < b.remove else a.remove > b.remove;
- }
-};
-
-/// Returns a freshly allocated slice of length file.spriteCount() such that result[original_sprite_index]
-/// is the new sprite index after applying the given reorder moves. Caller owns the returned memory.
-pub fn getReorderIndices(
- file: *File,
- allocator: std.mem.Allocator,
- removed_sprite_indices: []const usize,
- insert_before_sprite_indices: []const usize,
- mode: SpriteReorderMode,
- reverse: bool,
-) ![]usize {
- if (removed_sprite_indices.len == 0 or insert_before_sprite_indices.len == 0) return error.InvalidReorderSlices;
- if (removed_sprite_indices.len != insert_before_sprite_indices.len) return error.InvalidReorderSlices;
-
- const sprite_count = file.spriteCount();
- if (removed_sprite_indices.len > sprite_count) return error.InvalidReorderSlices;
-
- var order = try allocator.alloc(usize, sprite_count);
- defer allocator.free(order);
- for (0..sprite_count) |i| order[i] = i;
-
- var pairs = try dvui.currentWindow().arena().alloc(CellMovePair, removed_sprite_indices.len);
- for (0..removed_sprite_indices.len) |i| {
- pairs[i] = .{ .remove = removed_sprite_indices[i], .insert = insert_before_sprite_indices[i] };
- }
-
- std.mem.sort(CellMovePair, pairs, {}, CellSorting.asc);
- if (reverse) {
- std.mem.reverse(CellMovePair, pairs);
- }
-
- for (pairs) |pair| {
- if (mode == .insert) {
- dvui.ReorderWidget.reorderSlice(usize, order, pair.remove, pair.insert);
- } else {
- std.mem.swap(usize, &order[pair.remove], &order[pair.insert]);
- }
- }
-
- const reorder_indices = try allocator.alloc(usize, sprite_count);
- for (order, 0..) |order_index, i| {
- reorder_indices[order_index] = i;
- }
-
- return reorder_indices;
-}
-
-pub fn reorderCells(file: *File, removed_sprite_indices: []const usize, insert_before_sprite_indices: []const usize, mode: SpriteReorderMode, reverse: bool) !void {
- const arena = dvui.currentWindow().arena();
- const new_sprite_indices = try file.getReorderIndices(arena, removed_sprite_indices, insert_before_sprite_indices, mode, reverse);
-
- const sprite_count = new_sprite_indices.len;
- const layer_count = file.layers.len;
-
- var old_pixels_per_layer = try arena.alloc([]?[][4]u8, layer_count);
- for (old_pixels_per_layer) |*slice| slice.* = try arena.alloc(?[][4]u8, sprite_count);
-
- for (0..layer_count) |layer_index| {
- var layer = file.layers.get(layer_index);
- for (0..sprite_count) |i| {
- const new_sprite_index = new_sprite_indices[i];
- if (new_sprite_index != i) {
- const old_rect = file.spriteRect(i);
- old_pixels_per_layer[layer_index][i] = layer.pixelsFromRect(arena, old_rect);
- }
- }
- }
-
- for (0..layer_count) |layer_index| {
- var layer = file.layers.get(layer_index);
- for (0..sprite_count) |original_sprite_index| {
- const new_sprite_index = new_sprite_indices[original_sprite_index];
- if (new_sprite_index != original_sprite_index) {
- const src_pixels = old_pixels_per_layer[layer_index][original_sprite_index] orelse return error.MemoryAllocationFailed;
- const dst_rect = file.spriteRect(new_sprite_index);
- layer.blit(src_pixels, dst_rect, .{ .transparent = false, .mask = false });
- }
- }
- }
-
- for (file.animations.items(.frames)) |*frames| {
- for (frames.*) |*frame| {
- frame.sprite_index = new_sprite_indices[frame.sprite_index];
- }
- }
-
- var new_origins = try arena.dupe([2]f32, file.sprites.items(.origin));
- for (file.sprites.items(.origin), 0..) |origin, sprite_index| {
- const new_index = new_sprite_indices[sprite_index];
- if (new_index != sprite_index) {
- new_origins[new_index] = origin;
- }
- }
- for (new_origins, 0..) |origin, sprite_index| {
- file.sprites.items(.origin)[sprite_index] = origin;
- }
-
- if (file.editor.selected_sprites.count() > 0) {
- const selected_count = file.editor.selected_sprites.count();
- var old_indices = try arena.alloc(usize, selected_count);
- var idx: usize = 0;
- var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |old_index| {
- old_indices[idx] = old_index;
- idx += 1;
- }
- file.editor.selected_sprites.setRangeValue(.{ .start = 0, .end = sprite_count }, false);
- for (old_indices) |old_index| {
- file.editor.selected_sprites.set(new_sprite_indices[old_index]);
- }
- }
-}
-
-pub fn reorderColumns(file: *File, removed_column_index: usize, insert_before_column_index: usize) !void {
- if (removed_column_index == insert_before_column_index) return;
- if (removed_column_index > file.columns or insert_before_column_index > file.columns) return error.InvalidIndex;
-
- for (0..file.layers.len) |layer_index| {
- var layer = file.layers.get(layer_index);
-
- var insert_column_rect = file.columnRect(insert_before_column_index);
- var removed_column_rect = file.columnRect(removed_column_index);
-
- if (insert_before_column_index < removed_column_index) {
- var translate_rect = insert_column_rect;
- translate_rect.w = @as(f32, @floatFromInt(file.column_width)) * @as(f32, @floatFromInt(removed_column_index - insert_before_column_index));
-
- const translate_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), translate_rect) orelse return error.MemoryAllocationFailed;
- translate_rect.x += @as(f32, @floatFromInt(file.column_width));
-
- const removed_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), removed_column_rect) orelse return error.MemoryAllocationFailed;
-
- layer.blit(translate_pixels, translate_rect, .{ .transparent = false, .mask = false });
- layer.blit(removed_pixels, insert_column_rect, .{ .transparent = false, .mask = false });
- } else {
- var translate_rect = removed_column_rect.offsetPoint(.{ .x = @as(f32, @floatFromInt(file.column_width)) });
- translate_rect.w = @as(f32, @floatFromInt(file.column_width)) * @as(f32, @floatFromInt(insert_before_column_index - removed_column_index));
-
- const translate_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), translate_rect) orelse return error.MemoryAllocationFailed;
- translate_rect.x -= @as(f32, @floatFromInt(file.column_width));
-
- const removed_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), removed_column_rect) orelse return error.MemoryAllocationFailed;
- layer.blit(translate_pixels, translate_rect, .{ .transparent = false, .mask = false });
- layer.blit(removed_pixels, insert_column_rect.offsetPoint(.{ .x = -@as(f32, @floatFromInt(file.column_width)) }), .{ .transparent = false, .mask = false });
- }
- }
-
- for (file.animations.items(.frames)) |*frames| {
- for (frames.*) |*frame| {
- frame.sprite_index = file.getReorderedIndex(
- removed_column_index,
- insert_before_column_index,
- .column,
- frame.sprite_index,
- );
- }
- }
-
- var new_origins = try dvui.currentWindow().arena().dupe([2]f32, file.sprites.items(.origin));
- for (file.sprites.items(.origin), 0..) |*origin, sprite_index| {
- const reordered_index = file.getReorderedIndex(removed_column_index, insert_before_column_index, .column, sprite_index);
-
- if (reordered_index != sprite_index) {
- new_origins[reordered_index] = origin.*;
- }
- }
-
- for (new_origins, 0..) |origin, sprite_index| {
- file.sprites.items(.origin)[sprite_index] = origin;
- }
-}
-
-pub fn reorderRows(file: *File, removed_row_index: usize, insert_before_row_index: usize) !void {
- if (removed_row_index + 1 == insert_before_row_index) return;
- if (removed_row_index >= file.rows or insert_before_row_index > file.rows) return error.InvalidIndex;
-
- for (0..file.layers.len) |layer_index| {
- var layer = file.layers.get(layer_index);
-
- var insert_row_rect = file.rowRect(insert_before_row_index);
- var removed_row_rect = file.rowRect(removed_row_index);
-
- if (insert_before_row_index < removed_row_index) {
- var translate_rect = insert_row_rect;
- translate_rect.h = @as(f32, @floatFromInt(file.row_height)) * @as(f32, @floatFromInt(removed_row_index - insert_before_row_index));
-
- const translate_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), translate_rect) orelse return error.MemoryAllocationFailed;
- translate_rect.y += @as(f32, @floatFromInt(file.row_height));
-
- const removed_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), removed_row_rect) orelse return error.MemoryAllocationFailed;
-
- layer.blit(translate_pixels, translate_rect, .{ .transparent = false, .mask = false });
- layer.blit(removed_pixels, insert_row_rect, .{ .transparent = false, .mask = false });
- } else {
- var translate_rect = removed_row_rect.offsetPoint(.{ .y = @as(f32, @floatFromInt(file.row_height)) });
- translate_rect.h = @as(f32, @floatFromInt(file.row_height)) * @as(f32, @floatFromInt(insert_before_row_index - removed_row_index));
-
- const translate_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), translate_rect) orelse return error.MemoryAllocationFailed;
- translate_rect.y -= @as(f32, @floatFromInt(file.row_height));
-
- const removed_pixels = layer.pixelsFromRect(dvui.currentWindow().arena(), removed_row_rect) orelse return error.MemoryAllocationFailed;
- layer.blit(translate_pixels, translate_rect, .{ .transparent = false, .mask = false });
- layer.blit(removed_pixels, insert_row_rect.offsetPoint(.{ .y = -@as(f32, @floatFromInt(file.row_height)) }), .{ .transparent = false, .mask = false });
- }
- }
-
- for (file.animations.items(.frames)) |*frames| {
- for (frames.*) |*frame| {
- frame.sprite_index = file.getReorderedIndex(
- removed_row_index,
- insert_before_row_index,
- .row,
- frame.sprite_index,
- );
- }
- }
-
- var new_origins = try dvui.currentWindow().arena().dupe([2]f32, file.sprites.items(.origin));
- for (file.sprites.items(.origin), 0..) |*origin, sprite_index| {
- const reordered_index = file.getReorderedIndex(removed_row_index, insert_before_row_index, .row, sprite_index);
-
- if (reordered_index != sprite_index) {
- new_origins[reordered_index] = origin.*;
- }
- }
-
- for (new_origins, 0..) |origin, sprite_index| {
- file.sprites.items(.origin)[sprite_index] = origin;
- }
-}
-
-pub fn deinit(file: *File) void {
- fizzy.render.destroyLayerCompositeResources(file);
-
- strokeUndoFreeSnapshot(file);
-
- file.history.deinit();
- file.buffers.deinit();
-
- for (file.layers.items(.name)) |name| {
- fizzy.app.allocator.free(name);
- }
-
- for (file.animations.items(.name)) |name| {
- fizzy.app.allocator.free(name);
- }
-
- for (file.animations.items(.frames)) |frames| {
- fizzy.app.allocator.free(frames);
- }
-
- file.editor.temporary_layer.deinit();
- file.editor.selection_layer.deinit();
- file.editor.transform_layer.deinit();
- if (file.editor.checkerboard_tile) |t| {
- dvui.textureDestroyLater(t);
- file.editor.checkerboard_tile = null;
- }
-
- file.editor.selected_layer_indices.deinit(fizzy.app.allocator);
- file.editor.selected_animation_indices.deinit(fizzy.app.allocator);
- file.editor.selected_frame_indices.deinit(fizzy.app.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);
-}
-
-pub fn dirty(self: File) bool {
- // Never-saved buffers use a path with no extension (e.g. `untitled-1`); treat as unsaved even at bookmark 0.
- if (std.fs.path.extension(self.path).len == 0) return true;
- return self.history.bookmark != 0;
-}
-
-pub fn newAnimationID(file: *File) u64 {
- file.anim_id_counter += 1;
- return file.anim_id_counter;
-}
-
-pub fn newLayerID(file: *File) u64 {
- file.layer_id_counter += 1;
- return file.layer_id_counter;
-}
-
-pub fn spritePoint(file: *File, point: dvui.Point) dvui.Point {
- const column = @divTrunc(@as(i32, @intFromFloat(point.x)), @as(i32, @intCast(file.column_width)));
- const row = @divTrunc(@as(i32, @intFromFloat(point.y)), @as(i32, @intCast(file.row_height)));
-
- return .{
- .x = @as(f32, @floatFromInt(column * @as(i32, @intCast(file.column_width)))),
- .y = @as(f32, @floatFromInt(row * @as(i32, @intCast(file.row_height)))),
- };
-}
-
-pub fn spriteCount(file: *File) usize {
- return file.columns * file.rows;
-}
-
-pub fn spriteIndex(file: *File, point: dvui.Point) ?usize {
- if (!file.editor.canvas.dataFromScreenRect(file.editor.canvas.rect).contains(point)) return null;
-
- const tiles_wide = @divExact(file.width(), file.column_width);
-
- const column = @divTrunc(@as(u32, @intFromFloat(point.x)), file.column_width);
- const row = @divTrunc(@as(u32, @intFromFloat(point.y)), file.row_height);
-
- return row * tiles_wide + column;
-}
-
-pub fn wrappedSpriteIndex(file: *File, point: dvui.Point) usize {
- if (file.spriteIndex(point)) |index| {
- return index;
- }
- // Point is outside bounds: wrap coordinates into [0, width) x [0, height)
- const w = @as(f32, @floatFromInt(file.width()));
- const h = @as(f32, @floatFromInt(file.height()));
- const wrapped_x = @mod(point.x, w);
- const wrapped_y = @mod(point.y, h);
-
- const tiles_wide = @divExact(file.width(), file.column_width);
- const column = @divTrunc(@as(u32, @intFromFloat(wrapped_x)), file.column_width);
- const row = @divTrunc(@as(u32, @intFromFloat(wrapped_y)), file.row_height);
-
- return row * tiles_wide + column;
-}
-
-pub const SpriteName = enum { index, animation, file, grid };
-
-// Names sprites based o
-pub fn fmtSprite(file: *File, allocator: std.mem.Allocator, sprite_index: usize, name_type: SpriteName) ![]const u8 {
- return switch (name_type) {
- .animation => blk: {
- for (file.animations.items(.frames), 0..) |frames, animation_index| {
- for (frames) |frame| {
- if (frame.sprite_index != sprite_index) continue;
-
- if (frames.len > 1) {
- break :blk std.fmt.allocPrint(allocator, "{s}_{d}", .{ file.animations.items(.name)[animation_index], animation_index }) catch return error.MemoryAllocationFailed;
- } else {
- break :blk std.fmt.allocPrint(allocator, "{s}", .{file.animations.items(.name)[animation_index]}) catch return error.MemoryAllocationFailed;
- }
- }
- }
-
- break :blk std.fmt.allocPrint(allocator, "{d}", .{sprite_index}) catch return error.MemoryAllocationFailed;
- },
- .file => std.fmt.allocPrint(allocator, "{s}_{s}_{d}", .{ std.fs.path.basename(file.path), file.layers.items(.name)[file.selected_layer_index], sprite_index }) catch return error.MemoryAllocationFailed,
- .index => std.fmt.allocPrint(allocator, "{d}", .{sprite_index}) catch return error.MemoryAllocationFailed,
- .grid => std.fmt.allocPrint(allocator, "{s}{d}", .{ try fmtColumn(file, allocator, file.columnFromIndex(sprite_index)), file.rowFromIndex(sprite_index) }) catch return error.MemoryAllocationFailed,
- };
-}
-
-/// Default base name (no extension) for exporting a single tile: `{file_stem}_{anim}_{frame_idx}` if the
-/// sprite appears in an animation, else `{file_stem}_{grid}`. If `selected_animation_index` is set
-/// and that animation contains the sprite, that animation and frame index are used; otherwise the
-/// first matching animation in file order is used.
-pub fn spriteExportName(file: *File, allocator: std.mem.Allocator, sprite_index: usize) ![]const u8 {
- const basename = std.fs.path.basename(file.path);
- const file_stem = std.fs.path.stem(basename);
-
- if (file.selected_animation_index) |ai| {
- const anim = file.animations.get(ai);
- for (anim.frames, 0..) |frame, fi| {
- if (frame.sprite_index == sprite_index) {
- return try std.fmt.allocPrint(allocator, "{s}_{s}_{d}", .{ file_stem, anim.name, fi });
- }
- }
- }
-
- for (file.animations.items(.frames), file.animations.items(.name), 0..) |frames, name, aidx| {
- _ = aidx;
- for (frames, 0..) |frame, fi| {
- if (frame.sprite_index == sprite_index) {
- return try std.fmt.allocPrint(allocator, "{s}_{s}_{d}", .{ file_stem, name, fi });
- }
- }
- }
-
- const grid = try file.fmtSprite(allocator, sprite_index, .grid);
- defer allocator.free(grid);
- return try std.fmt.allocPrint(allocator, "{s}_{s}", .{ file_stem, grid });
-}
-
-/// Default base filename (no extension) for exporting the full selected layer only.
-pub fn layerExportBaseName(file: *File, allocator: std.mem.Allocator) ![]const u8 {
- const file_stem = std.fs.path.stem(std.fs.path.basename(file.path));
- const lname = file.layers.items(.name)[file.selected_layer_index];
- return try std.fmt.allocPrint(allocator, "{s}_{s}", .{ file_stem, lname });
-}
-
-/// Default base filename (no extension) for exporting the flattened (all visible layers) image.
-pub fn allExportBaseName(file: *File, allocator: std.mem.Allocator) ![]const u8 {
- const file_stem = std.fs.path.stem(std.fs.path.basename(file.path));
- return try std.fmt.allocPrint(allocator, "{s}_all", .{file_stem});
-}
-
-pub fn fmtColumn(_: *File, allocator: std.mem.Allocator, column: usize) ![]const u8 {
- // Excel-style: 0 -> A, 1 -> B, ... 25 -> Z, 26 -> AA, 27 -> AB, etc.
- var temp: [10]u8 = undefined; // Enough for absurdly large columns (> 1 billion)
- var len: usize = 0;
-
- var idx = column;
- while (true) {
- const rem = idx % 26;
- temp[9 - len] = std.ascii.uppercase[rem];
- len += 1;
- if (idx < 26) break;
- // Adjust for 1-based carryover because Excel-style is nonzero-based
- idx = idx / 26 - 1;
- }
- const start = 10 - len;
- const fmt = allocator.alloc(u8, len) catch return error.MemoryAllocationFailed;
- @memcpy(fmt, temp[start .. start + len]);
- return fmt;
-}
-
-pub fn columnFromIndex(file: *File, index: usize) usize {
- return @mod(index, file.columns);
-}
-
-pub fn rowFromIndex(file: *File, index: usize) usize {
- return @divTrunc(index, file.columns);
-}
-
-pub fn columnFromPixel(file: *File, pixel: dvui.Point) usize {
- return @mod(@as(usize, @intFromFloat(pixel.x)), file.column_width);
-}
-
-pub fn rowFromPixel(file: *File, pixel: dvui.Point) usize {
- return @divTrunc(@as(usize, @intFromFloat(pixel.y)), file.row_height);
-}
-
-pub fn spriteRect(file: *File, index: usize) dvui.Rect {
- const column = file.columnFromIndex(index);
- const row = file.rowFromIndex(index);
-
- const out: dvui.Rect = .{
- .x = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width)),
- .y = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height)),
- .w = @as(f32, @floatFromInt(file.column_width)),
- .h = @as(f32, @floatFromInt(file.row_height)),
- };
- return out;
-}
-
-pub fn columnRect(file: *File, column_index: usize) dvui.Rect {
- return .{
- .x = @as(f32, @floatFromInt(column_index)) * @as(f32, @floatFromInt(file.column_width)),
- .y = 0,
- .w = @as(f32, @floatFromInt(file.column_width)),
- .h = @as(f32, @floatFromInt(file.height())),
- };
-}
-
-pub fn columnIndex(file: *File, point: dvui.Point) ?usize {
- if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width()))) return null;
- if (point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) return null;
- const index = @divTrunc(@as(usize, @intFromFloat(point.x)), file.column_width);
- if (index >= file.columns) return null;
- return index;
-}
-
-pub fn rowIndex(file: *File, point: dvui.Point) ?usize {
- if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width()))) return null;
- if (point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) return null;
- const index = @divTrunc(@as(usize, @intFromFloat(point.y)), file.row_height);
- if (index >= file.rows) return null;
- return index;
-}
-
-pub fn rowRect(file: *File, row_index: usize) dvui.Rect {
- return .{
- .x = 0,
- .y = @as(f32, @floatFromInt(row_index)) * @as(f32, @floatFromInt(file.row_height)),
- .w = @as(f32, @floatFromInt(file.width())),
- .h = @as(f32, @floatFromInt(file.row_height)),
- };
-}
-pub fn clearSelectedSprites(file: *File) void {
- file.editor.selected_sprites.setRangeValue(.{ .start = 0, .end = file.spriteCount() }, false);
- file.editor.primary_sprite_index = null;
-}
-
-/// Sprite that should read as primary (tallest frame bubble, cover-flow focus).
-pub fn primarySpriteIndex(file: *const File) ?usize {
- if (file.editor.primary_sprite_index) |p| {
- if (p < file.editor.selected_sprites.capacity() and file.editor.selected_sprites.isSet(p)) {
- return p;
- }
- }
- if (file.selected_animation_index) |ai| {
- const frames = file.animations.get(ai).frames;
- if (frames.len > 0 and file.selected_animation_frame_index < frames.len) {
- return frames[file.selected_animation_frame_index].sprite_index;
- }
- }
- return file.editor.selected_sprites.findLastSet();
-}
-
-/// Move the primary sprite/frame to `sprite_index` without changing the selection set.
-pub fn promotePrimarySprite(file: *File, sprite_index: usize) void {
- if (sprite_index >= file.editor.selected_sprites.capacity() or
- !file.editor.selected_sprites.isSet(sprite_index))
- {
- return;
- }
- file.editor.primary_sprite_index = sprite_index;
-
- const animation_index = file.selected_animation_index orelse return;
- const frames = file.animations.get(animation_index).frames;
- if (frames.len == 0) return;
-
- if (file.editor.selected_frame_indices.items.len > 0) {
- for (file.editor.selected_frame_indices.items) |fi| {
- if (fi < frames.len and frames[fi].sprite_index == sprite_index) {
- file.selected_animation_frame_index = fi;
- return;
- }
- }
- }
-
- for (frames, 0..) |f, fi| {
- if (f.sprite_index == sprite_index) {
- file.selected_animation_frame_index = fi;
- return;
- }
- }
-}
-
-/// Collapse animation list multi-selection to the current primary only. Used when the primary is
-/// set from the canvas/grid; the animation tree keeps multi-select via `applyAnimationClick`, but
-/// those paths must not leave stale extra indices when the grid picks a new animation.
-pub fn collapseAnimationSelectionToPrimary(file: *File) void {
- if (file.selected_animation_index) |p| {
- file.editor.selected_animation_indices.clearRetainingCapacity();
- file.editor.selected_animation_indices.append(fizzy.app.allocator, p) catch return;
- file.editor.animation_selection_anchor = p;
- }
-}
-
-pub fn setSpriteSelection(file: *File, selection_rect: dvui.Rect, value: bool) void {
- for (0..spriteCount(file)) |index| {
- if (!file.spriteRect(index).intersect(selection_rect).empty()) {
- file.editor.selected_sprites.setValue(index, value);
- }
- }
-}
-
-pub const SelectOptions = struct {
- value: bool = true,
- clear: bool = false,
- stroke_size: usize,
- constrain_to_tile: bool = false,
-};
-
-/// Selects a point by considering the current stroke size and setting bits in the selection layer mask if there are
-/// non-transparent pixels in the currently active layer.
-/// If `value` is true, the point will be selected, otherwise it will be deselected.
-/// If `clear` is true, the selection layer mask will be cleared before setting the new value.
-pub fn selectPoint(file: *File, point: dvui.Point, select_options: SelectOptions) void {
- const read_layer: Layer = file.layers.get(file.selected_layer_index);
- var selection_layer: *Layer = &file.editor.selection_layer;
-
- if (select_options.clear) {
- selection_layer.clearMask();
- }
-
- if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width())) or point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) {
- return;
- }
-
- const column = file.columnFromPixel(point);
- const row = file.rowFromPixel(point);
-
- const min_x: f32 = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width));
- const min_y: f32 = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height));
-
- const max_x: f32 = min_x + @as(f32, @floatFromInt(file.column_width));
- const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height));
-
- if (select_options.stroke_size < 10) {
- const size: usize = @intCast(select_options.stroke_size);
-
- for (0..(size * size)) |index| {
- if (selection_layer.getIndexShapeOffset(point, index)) |result| {
- if (select_options.constrain_to_tile) {
- if (result.point.x < min_x or result.point.x >= max_x or result.point.y < min_y or result.point.y >= max_y) {
- continue;
- }
- }
-
- if (read_layer.pixels()[result.index][3] > 0) {
- selection_layer.mask.setValue(result.index, select_options.value);
- }
- }
- }
- } else {
- var iter = fizzy.editor.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
- const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
-
- if (select_options.constrain_to_tile) {
- if (new_point.x < min_x or new_point.x >= max_x or new_point.y < min_y or new_point.y >= max_y) {
- continue;
- }
- }
-
- if (selection_layer.pixelIndex(new_point)) |index| {
- if (read_layer.pixels()[index][3] > 0) {
- selection_layer.mask.setValue(index, select_options.value);
- }
- }
- }
- }
-}
-
-pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_options: SelectOptions) void {
- const read_layer: Layer = file.layers.get(file.selected_layer_index);
- var selection_layer: *Layer = &file.editor.selection_layer;
-
- if (select_options.clear) {
- selection_layer.clearMask();
- }
-
- if (point1.x < 0 or point1.x >= @as(f32, @floatFromInt(file.width())) or point1.y < 0 or point1.y >= @as(f32, @floatFromInt(file.height()))) {
- return;
- }
-
- if (point2.x < 0 or point2.x >= @as(f32, @floatFromInt(file.width())) or point2.y < 0 or point2.y >= @as(f32, @floatFromInt(file.height()))) {
- return;
- }
-
- const column = file.columnFromPixel(point2);
- const row = file.rowFromPixel(point2);
-
- const min_x: f32 = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width));
- const min_y: f32 = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height));
-
- const max_x: f32 = min_x + @as(f32, @floatFromInt(file.column_width));
- const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height));
-
- const diff = point2.diff(point1).normalize().scale(4, dvui.Point);
- const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size);
-
- const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) };
- var mask = fizzy.editor.tools.stroke;
-
- if (select_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) {
- for (0..(stroke_size * stroke_size)) |index| {
- if (fizzy.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
- mask.unset(i);
- }
- }
- }
-
- if (fizzy.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) {
- selectPoint(file, point, select_options);
- } else {
- var stroke = if (point_i == 0) fizzy.editor.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 new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
-
- if (select_options.constrain_to_tile) {
- if (new_point.x < min_x or new_point.x >= max_x or new_point.y < min_y or new_point.y >= max_y) {
- continue;
- }
- }
-
- if (selection_layer.pixelIndex(new_point)) |index| {
- if (read_layer.pixels()[index][3] > 0) {
- selection_layer.mask.setValue(index, select_options.value);
- }
- }
- }
- }
- }
- }
-}
-
-/// Selects every opaque pixel on the active layer inside the axis-aligned rectangle between `p1` and `p2` (inclusive).
-pub fn selectRectBetweenPoints(file: *File, p1: dvui.Point, p2: dvui.Point, select_options: SelectOptions) void {
- const read_layer: Layer = file.layers.get(file.selected_layer_index);
- const selection_layer: *Layer = &file.editor.selection_layer;
-
- const x0: i32 = @intFromFloat(@floor(@min(p1.x, p2.x)));
- const y0: i32 = @intFromFloat(@floor(@min(p1.y, p2.y)));
- const x1: i32 = @intFromFloat(@floor(@max(p1.x, p2.x)));
- const y1: i32 = @intFromFloat(@floor(@max(p1.y, p2.y)));
-
- const iw: i32 = @intCast(file.width());
- const ih: i32 = @intCast(file.height());
-
- var py = y0;
- while (py <= y1) : (py += 1) {
- if (py < 0 or py >= ih) continue;
- var px = x0;
- while (px <= x1) : (px += 1) {
- if (px < 0 or px >= iw) continue;
- const pt: dvui.Point = .{ .x = @floatFromInt(px), .y = @floatFromInt(py) };
- if (selection_layer.pixelIndex(pt)) |idx| {
- if (read_layer.pixels()[idx][3] > 0) {
- selection_layer.mask.setValue(idx, select_options.value);
- }
- }
- }
- }
-}
-
-/// Flood-selects or flood-deselects every pixel in the active layer that matches the color at `p`
-/// (4-way contiguous region), same as bucket fill matching. `value` true adds to the selection mask, false removes.
-pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void {
- const read_layer = file.layers.get(file.selected_layer_index);
- const selection_layer: *Layer = &file.editor.selection_layer;
-
- const bounds = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) });
- if (!bounds.contains(p)) return;
-
- const start_idx = fizzy.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);
- defer visited.deinit();
-
- var queue = std.array_list.Managed(dvui.Point).init(fizzy.app.allocator);
- defer queue.deinit();
-
- try queue.append(p);
- visited.set(start_idx);
-
- const directions: [4]dvui.Point = .{
- .{ .x = 0, .y = -1 },
- .{ .x = 0, .y = 1 },
- .{ .x = -1, .y = 0 },
- .{ .x = 1, .y = 0 },
- };
-
- while (queue.pop()) |qp| {
- const idx = fizzy.image.pixelIndex(read_layer.source, qp) orelse continue;
- if (!std.meta.eql(original_color, read_layer.pixels()[idx])) continue;
-
- selection_layer.mask.setValue(idx, value);
-
- for (directions) |direction| {
- const np = qp.plus(direction);
- if (!bounds.contains(np)) continue;
- if (fizzy.image.pixelIndex(read_layer.source, np)) |ni| {
- if (visited.isSet(ni)) continue;
- if (!std.meta.eql(original_color, read_layer.pixels()[ni])) continue;
- visited.set(ni);
- try queue.append(np);
- }
- }
- }
-}
-
-pub const DrawLayer = enum {
- temporary,
- selected,
-};
-
-pub const DrawOptions = struct {
- stroke_size: usize,
- mask_only: bool = false,
- invalidate: bool = false,
- to_change: bool = false,
- constrain_to_tile: bool = false,
- color: dvui.Color = .{ .r = 0, .g = 0, .b = 0, .a = 255 },
- /// When set, only writes pixels inside this rect (data space). Used for temporary preview draws.
- clip_rect: ?dvui.Rect = null,
-};
-
-/// Computes the pixel bounding rect of a brush stamp, clamped to image bounds.
-fn brushRect(point: dvui.Point, stroke_size: usize, img_w: u32, img_h: u32) dvui.Rect {
- const s: i32 = @intCast(stroke_size);
- const half: i32 = @divFloor(s, 2);
- const px: i32 = @intFromFloat(@floor(point.x));
- const py: i32 = @intFromFloat(@floor(point.y));
- const w: i32 = @intCast(img_w);
- const h: i32 = @intCast(img_h);
- const x0 = @max(px - half, 0);
- const y0 = @max(py - half, 0);
- const x1 = @min(px - half + s, w);
- const y1 = @min(py - half + s, h);
- return .{
- .x = @floatFromInt(x0),
- .y = @floatFromInt(y0),
- .w = @floatFromInt(@max(x1 - x0, 0)),
- .h = @floatFromInt(@max(y1 - y0, 0)),
- };
-}
-
-/// Expands the active layer dirty rect to include a new brush stamp region.
-fn expandActiveLayerDirtyRect(file: *File, new_rect: dvui.Rect) void {
- if (file.editor.active_layer_dirty_rect) |existing| {
- file.editor.active_layer_dirty_rect = existing.unionWith(new_rect);
- } else {
- file.editor.active_layer_dirty_rect = new_rect;
- }
- file.editor.layer_composite_dirty = true;
-}
-
-fn intRectFromDvuiRect(r: dvui.Rect, img_w: u32, img_h: u32) struct { x: u32, y: u32, w: u32, h: u32 } {
- const x0 = @as(i32, @intFromFloat(@floor(r.x)));
- const y0 = @as(i32, @intFromFloat(@floor(r.y)));
- const x1 = @as(i32, @intFromFloat(@ceil(r.x + r.w)));
- const y1 = @as(i32, @intFromFloat(@ceil(r.y + r.h)));
- const wlim: i32 = @intCast(img_w);
- const hlim: i32 = @intCast(img_h);
- const ix0 = @max(x0, 0);
- const iy0 = @max(y0, 0);
- const ix1 = @min(x1, wlim);
- const iy1 = @min(y1, hlim);
- const cw: u32 = @intCast(@max(ix1 - ix0, 0));
- const ch: u32 = @intCast(@max(iy1 - iy0, 0));
- return .{ .x = @intCast(ix0), .y = @intCast(iy0), .w = cw, .h = ch };
-}
-
-/// Bounding box (clamped to image) that covers a brush stroke along the segment between two points.
-pub fn lineBrushCoverRect(file: *const File, p1: dvui.Point, p2: dvui.Point, stroke_size: usize) dvui.Rect {
- const iw = file.width();
- const ih = file.height();
- const w: i32 = @intCast(iw);
- const h: i32 = @intCast(ih);
- const s: i32 = @intCast(stroke_size);
- const half = @divFloor(s, 2);
- const ix1 = @as(i32, @intFromFloat(@floor(p1.x)));
- const iy1 = @as(i32, @intFromFloat(@floor(p1.y)));
- const ix2 = @as(i32, @intFromFloat(@floor(p2.x)));
- const iy2 = @as(i32, @intFromFloat(@floor(p2.y)));
- const min_px = @min(ix1, ix2) - half;
- const min_py = @min(iy1, iy2) - half;
- const max_px = @max(ix1, ix2) - half + s;
- const max_py = @max(iy1, iy2) - half + s;
- const x0 = @max(min_px, 0);
- const y0 = @max(min_py, 0);
- const x1 = @min(max_px, w);
- const y1 = @min(max_py, h);
- return .{
- .x = @floatFromInt(x0),
- .y = @floatFromInt(y0),
- .w = @floatFromInt(@max(x1 - x0, 0)),
- .h = @floatFromInt(@max(y1 - y0, 0)),
- };
-}
-
-pub fn brushStampRect(file: *const File, point: dvui.Point, stroke_size: usize) dvui.Rect {
- return brushRect(point, stroke_size, file.width(), file.height());
-}
-
-fn strokeUndoFreeSnapshot(file: *File) void {
- if (file.editor.stroke_undo_pixels) |p| {
- fizzy.app.allocator.free(p);
- file.editor.stroke_undo_pixels = null;
- }
- file.editor.stroke_undo_x = 0;
- file.editor.stroke_undo_y = 0;
- file.editor.stroke_undo_w = 0;
- file.editor.stroke_undo_h = 0;
- file.editor.stroke_undo_deferred = false;
-}
-
-/// Clears any prior snapshot and captures the current active layer pixels under `cover` (clamped).
-pub fn strokeUndoBegin(file: *File, cover: dvui.Rect) !void {
- strokeUndoFreeSnapshot(file);
-
- const iw = file.width();
- const ih = file.height();
- const b = intRectFromDvuiRect(cover, iw, ih);
- if (b.w == 0 or b.h == 0) {
- return;
- }
-
- const snap_area = @as(u64, b.w) * @as(u64, b.h);
- if (snap_area > stroke_undo_max_snapshot_pixels) {
- return;
- }
-
- const n = @as(usize, b.w) * @as(usize, b.h) * 4;
- const buf = try fizzy.app.allocator.alloc(u8, n);
-
- const layer = file.layers.get(file.selected_layer_index);
- const pix = layer.pixels();
- const stride: usize = @intCast(iw);
- var row: u32 = 0;
- while (row < b.h) : (row += 1) {
- const gy: usize = @intCast(b.y + row);
- const src_start: usize = gy * stride + @as(usize, b.x);
- const dst_start: usize = @as(usize, row) * @as(usize, b.w) * 4;
- const row_px: usize = @intCast(b.w);
- @memcpy(buf[dst_start..][0 .. row_px * 4], std.mem.sliceAsBytes(pix[src_start..][0..row_px]));
- }
-
- file.editor.stroke_undo_pixels = buf;
- file.editor.stroke_undo_x = b.x;
- file.editor.stroke_undo_y = b.y;
- file.editor.stroke_undo_w = b.w;
- file.editor.stroke_undo_h = b.h;
- file.editor.stroke_undo_deferred = true;
-}
-
-/// Grows the snapshot so it includes `cover` (copying newly exposed pixels from the layer before paint).
-pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void {
- if (!file.editor.stroke_undo_deferred) return;
-
- const old_buf = file.editor.stroke_undo_pixels orelse return;
- const iw = file.width();
- const ih = file.height();
- const ox = file.editor.stroke_undo_x;
- const oy = file.editor.stroke_undo_y;
- const ow = file.editor.stroke_undo_w;
- const oh = file.editor.stroke_undo_h;
-
- const nb = intRectFromDvuiRect(cover, iw, ih);
- if (nb.w == 0 or nb.h == 0) return;
-
- const tx: u32 = @min(ox, nb.x);
- const ty: u32 = @min(oy, nb.y);
- const tw: u32 = @max(ox + ow, nb.x + nb.w) - tx;
- const th: u32 = @max(oy + oh, nb.y + nb.h) - ty;
-
- if (tw == ow and th == oh and tx == ox and ty == oy) return;
-
- const snap_area = @as(u64, tw) * @as(u64, th);
- if (snap_area > stroke_undo_max_snapshot_pixels) {
- strokeUndoFlushSnapshotToStrokeBuffer(file);
- return;
- }
-
- const new_n = @as(usize, tw) * @as(usize, th) * 4;
- const new_buf = try fizzy.app.allocator.alloc(u8, new_n);
-
- const layer = file.layers.get(file.selected_layer_index);
- const pix = layer.pixels();
- const stride: usize = @intCast(iw);
-
- var gy: u32 = 0;
- while (gy < th) : (gy += 1) {
- var gx_off: u32 = 0;
- while (gx_off < tw) : (gx_off += 1) {
- const gx: u32 = tx + gx_off;
- const gyy: u32 = ty + gy;
- const dst: usize = (@as(usize, gy) * @as(usize, tw) + @as(usize, gx_off)) * 4;
- const in_old = gx >= ox and gx < ox + ow and gyy >= oy and gyy < oy + oh;
- if (in_old) {
- const ox_l = gx - ox;
- const oy_l = gyy - oy;
- const src: usize = (@as(usize, oy_l) * @as(usize, ow) + @as(usize, ox_l)) * 4;
- @memcpy(new_buf[dst..][0..4], old_buf[src..][0..4]);
- } else {
- const idx: usize = @as(usize, gyy) * stride + @as(usize, gx);
- @memcpy(new_buf[dst..][0..4], std.mem.asBytes(&pix[idx]));
- }
- }
- }
-
- fizzy.app.allocator.free(old_buf);
- file.editor.stroke_undo_pixels = new_buf;
- file.editor.stroke_undo_x = tx;
- file.editor.stroke_undo_y = ty;
- file.editor.stroke_undo_w = tw;
- file.editor.stroke_undo_h = th;
-}
-
-/// Move deferred snapshot diffs into the live stroke buffer and stop using the snapshot.
-/// Used when the snapshot can no longer grow (size cap) so later pixels still get undo entries.
-fn strokeUndoFlushSnapshotToStrokeBuffer(file: *File) void {
- if (!file.editor.stroke_undo_deferred) return;
- const snap = file.editor.stroke_undo_pixels orelse {
- file.editor.stroke_undo_deferred = false;
- return;
- };
-
- const layer = file.layers.get(file.selected_layer_index);
- const pixels = layer.pixels();
- const iw: usize = @intCast(file.width());
-
- const sx = file.editor.stroke_undo_x;
- const sy = file.editor.stroke_undo_y;
- const sw = file.editor.stroke_undo_w;
- const sh = file.editor.stroke_undo_h;
-
- var row: u32 = 0;
- while (row < sh) : (row += 1) {
- var col: u32 = 0;
- while (col < sw) : (col += 1) {
- const gx: usize = @as(usize, sx + col);
- const gyy: usize = @as(usize, sy + row);
- const idx: usize = gyy * iw + gx;
- const off: usize = (@as(usize, row) * @as(usize, sw) + @as(usize, col)) * 4;
- const old_px: [4]u8 = .{ snap[off], snap[off + 1], snap[off + 2], snap[off + 3] };
- const cur = pixels[idx];
- if (!std.mem.eql(u8, &old_px, &cur)) {
- file.buffers.stroke.append(idx, old_px) catch {
- dvui.log.err("Failed to append to stroke buffer (flush snapshot)", .{});
- };
- }
- }
- }
-
- strokeUndoFreeSnapshot(file);
-}
-
-pub fn strokeUndoCommit(file: *File) void {
- if (file.editor.stroke_undo_deferred) {
- strokeUndoFlushSnapshotToStrokeBuffer(file);
- }
-
- const change_opt = file.buffers.stroke.toChange(file.layers.get(file.selected_layer_index).id) catch null;
- if (change_opt) |change| {
- file.history.append(change) catch {
- dvui.log.err("Failed to append to history", .{});
- };
- }
-}
-
-fn selectionMaskHasPixels(file: *const File) bool {
- var it = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
- return it.next() != null;
-}
-
-fn pixelInAnySelectedSprite(file: *File, px: usize, py: usize) bool {
- const fx: f32 = @floatFromInt(px);
- const fy: f32 = @floatFromInt(py);
- var it = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (it.next()) |idx| {
- const r = file.spriteRect(idx);
- if (fx >= r.x and fx < r.x + r.w and fy >= r.y and fy < r.y + r.h) {
- return true;
- }
- }
- return false;
-}
-
-/// Clears pixels covered by the fine selection mask, or clears every selected sprite tile on the
-/// active layer. When the selection mask is non-empty, it takes precedence over sprite tile selection.
-pub fn deleteSelectedContents(file: *File) void {
- if (file.editor.transform != null) return;
-
- if (selectionMaskHasPixels(file)) {
- deleteSelectedFinePixels(file);
- return;
- }
-
- if (file.editor.selected_sprites.count() > 0) {
- deleteSelectedSpriteTiles(file);
- }
-}
-
-fn deleteSelectedFinePixels(file: *File) void {
- const iw: u32 = file.width();
- const stride: usize = @intCast(iw);
-
- var min_x: u32 = iw;
- var min_y: u32 = file.height();
- var max_x: u32 = 0;
- var max_y: u32 = 0;
- var any = false;
-
- {
- var it = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
- while (it.next()) |pixel_index| {
- const x: u32 = @intCast(pixel_index % stride);
- const y: u32 = @intCast(pixel_index / stride);
- min_x = @min(min_x, x);
- min_y = @min(min_y, y);
- max_x = @max(max_x, x);
- max_y = @max(max_y, y);
- any = true;
- }
- }
- if (!any) return;
-
- const cover = dvui.Rect{
- .x = @floatFromInt(min_x),
- .y = @floatFromInt(min_y),
- .w = @floatFromInt(max_x - min_x + 1),
- .h = @floatFromInt(max_y - min_y + 1),
- };
-
- file.strokeUndoBegin(cover) catch {
- dvui.log.err("deleteSelectedFinePixels: strokeUndoBegin failed", .{});
- return;
- };
-
- var layer = file.layers.get(file.selected_layer_index);
-
- if (file.editor.stroke_undo_deferred) {
- var it2 = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
- while (it2.next()) |pixel_index| {
- layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 };
- }
- layer.invalidate();
- file.strokeUndoCommit();
- } else {
- file.buffers.stroke.clearAndFree();
- var it2 = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
- while (it2.next()) |pixel_index| {
- file.buffers.stroke.append(pixel_index, layer.pixels()[pixel_index]) catch {
- dvui.log.err("deleteSelectedFinePixels: stroke buffer append failed", .{});
- return;
- };
- layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 };
- }
- layer.invalidate();
- const change = file.buffers.stroke.toChange(layer.id) catch |err| {
- dvui.log.err("deleteSelectedFinePixels: toChange failed: {}", .{err});
- return;
- };
- file.history.append(change) catch {
- dvui.log.err("deleteSelectedFinePixels: history append failed", .{});
- };
- }
-
- file.editor.selection_layer.clearMask();
- file.invalidateActiveLayerTransparencyMaskCache();
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
-}
-
-fn deleteSelectedSpriteTiles(file: *File) void {
- var cover: ?dvui.Rect = null;
- {
- var it = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (it.next()) |idx| {
- const r = file.spriteRect(idx);
- cover = if (cover) |c| c.unionWith(r) else r;
- }
- }
- const cvr = cover orelse return;
-
- file.strokeUndoBegin(cvr) catch {
- dvui.log.err("deleteSelectedSpriteTiles: strokeUndoBegin failed", .{});
- return;
- };
-
- var layer = file.layers.get(file.selected_layer_index);
-
- if (file.editor.stroke_undo_deferred) {
- var it = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (it.next()) |idx| {
- layer.clearRect(file.spriteRect(idx));
- }
- file.strokeUndoCommit();
- } else {
- file.buffers.stroke.clearAndFree();
- const b = intRectFromDvuiRect(cvr, file.width(), file.height());
- if (b.w == 0 or b.h == 0) {
- file.invalidateActiveLayerTransparencyMaskCache();
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
- return;
- }
- const iw_sz: usize = @intCast(file.width());
- var row: u32 = 0;
- while (row < b.h) : (row += 1) {
- var col: u32 = 0;
- while (col < b.w) : (col += 1) {
- const gx: usize = @as(usize, b.x + col);
- const gy: usize = @as(usize, b.y + row);
- if (!pixelInAnySelectedSprite(file, gx, gy)) continue;
- const pidx = gy * iw_sz + gx;
- file.buffers.stroke.append(pidx, layer.pixels()[pidx]) catch {
- dvui.log.err("deleteSelectedSpriteTiles: stroke buffer append failed", .{});
- return;
- };
- layer.pixels()[pidx] = .{ 0, 0, 0, 0 };
- }
- }
- layer.invalidate();
- const change = file.buffers.stroke.toChange(layer.id) catch |err| {
- dvui.log.err("deleteSelectedSpriteTiles: toChange failed: {}", .{err});
- return;
- };
- file.history.append(change) catch {
- dvui.log.err("deleteSelectedSpriteTiles: history append failed", .{});
- };
- }
-
- file.invalidateActiveLayerTransparencyMaskCache();
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
-}
-
-/// Draws a point on the `.selected` (the point will be added to the stroke buffer) or `.temporary` layer.
-/// If `to_change` is true, the point will be added to the stroke buffer and then the history will be appended.
-/// If `invalidate` is true, the layer will be invalidated.
-/// If `mask_only` is true, the drawn pixels will only be marked on the mask, not the layer pixels themselves.
-/// If `constrain_to_tile` is true, the drawn pixels will only be marked on the tile that the point is currently within
-/// regardless of the stroke size.
-pub fn drawPoint(file: *File, point: dvui.Point, layer: DrawLayer, draw_options: DrawOptions) void {
- var active_layer: Layer = switch (layer) {
- .temporary => file.editor.temporary_layer,
- .selected => file.layers.get(file.selected_layer_index),
- };
-
- defer active_layer.dirty = true;
-
- if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width())) or point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) {
- return;
- }
-
- const clip_rect: ?dvui.Rect = if (layer == .temporary) draw_options.clip_rect else null;
-
- const mask_value: bool = draw_options.color.a != 0;
-
- const column = file.columnFromPixel(point);
- const row = file.rowFromPixel(point);
-
- const min_x: f32 = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width));
- const min_y: f32 = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height));
-
- const max_x: f32 = min_x + @as(f32, @floatFromInt(file.column_width));
- const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height));
-
- if (clip_rect) |cr| {
- const br = brushRect(point, draw_options.stroke_size, file.width(), file.height());
- if (br.intersect(cr).empty()) return;
- }
-
- if (draw_options.stroke_size < 10) {
- const size: usize = @intCast(draw_options.stroke_size);
-
- for (0..(size * size)) |index| {
- if (active_layer.getIndexShapeOffset(point, index)) |result| {
- if (clip_rect) |cr| {
- if (!cr.contains(result.point)) continue;
- }
- if (draw_options.constrain_to_tile) {
- if (result.point.x < min_x or result.point.x >= max_x or result.point.y < min_y or result.point.y >= max_y) {
- continue;
- }
- }
-
- active_layer.mask.setValue(result.index, mask_value);
-
- if (draw_options.mask_only) {
- continue;
- }
-
- if (layer == .selected and !file.editor.stroke_undo_deferred) {
- file.buffers.stroke.append(result.index, result.color) catch {
- dvui.log.err("Failed to append to stroke buffer", .{});
- };
- }
-
- active_layer.pixels()[result.index] = draw_options.color.toRGBA();
- }
- }
- } else {
- var iter = fizzy.editor.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
- const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
-
- if (clip_rect) |cr| {
- if (!cr.contains(new_point)) continue;
- }
- if (draw_options.constrain_to_tile) {
- if (new_point.x < min_x or new_point.x >= max_x or new_point.y < min_y or new_point.y >= max_y) {
- continue;
- }
- }
-
- if (active_layer.pixelIndex(new_point)) |index| {
- active_layer.mask.setValue(index, mask_value);
- if (draw_options.mask_only) {
- continue;
- }
- if (layer == .selected and !file.editor.stroke_undo_deferred) {
- file.buffers.stroke.append(index, active_layer.pixels()[index]) catch {
- dvui.log.err("Failed to append to stroke buffer", .{});
- };
- }
-
- active_layer.pixels()[index] = draw_options.color.toRGBA();
- }
- }
- }
-
- if (draw_options.mask_only) {
- return;
- }
-
- if (draw_options.invalidate) {
- if (layer == .selected) {
- expandActiveLayerDirtyRect(file, brushRect(point, draw_options.stroke_size, file.width(), file.height()));
- } else {
- active_layer.invalidate();
- }
- }
-
- if (draw_options.to_change and layer == .selected) {
- if (file.editor.stroke_undo_deferred) {
- file.strokeUndoCommit();
- } else {
- const change_opt = file.buffers.stroke.toChange(active_layer.id) catch null;
- if (change_opt) |change| {
- file.history.append(change) catch {
- dvui.log.err("Failed to append to history", .{});
- };
- }
- }
- }
-}
-
-pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: DrawLayer, draw_options: DrawOptions) void {
- var active_layer: Layer = switch (layer) {
- .temporary => file.editor.temporary_layer,
- .selected => file.layers.get(file.selected_layer_index),
- };
-
- defer active_layer.dirty = true;
-
- if (point1.x < 0 or point1.x >= @as(f32, @floatFromInt(file.width())) or point1.y < 0 or point1.y >= @as(f32, @floatFromInt(file.height()))) {
- return;
- }
-
- if (point2.x < 0 or point2.x >= @as(f32, @floatFromInt(file.width())) or point2.y < 0 or point2.y >= @as(f32, @floatFromInt(file.height()))) {
- return;
- }
-
- const clip_rect: ?dvui.Rect = if (layer == .temporary) draw_options.clip_rect else null;
- const iw = file.width();
- const ih = file.height();
-
- const mask_value: bool = draw_options.color.a != 0;
-
- const column = file.columnFromPixel(point2);
- const row = file.rowFromPixel(point2);
-
- const min_x: f32 = @as(f32, @floatFromInt(column)) * @as(f32, @floatFromInt(file.column_width));
- const min_y: f32 = @as(f32, @floatFromInt(row)) * @as(f32, @floatFromInt(file.row_height));
-
- const max_x: f32 = min_x + @as(f32, @floatFromInt(file.column_width));
- const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height));
-
- const diff = point2.diff(point1).normalize().scale(4, dvui.Point);
- const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size);
-
- const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) };
- var mask = fizzy.editor.tools.stroke;
-
- if (draw_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) {
- for (0..(stroke_size * stroke_size)) |index| {
- if (fizzy.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
- mask.unset(i);
- }
- }
- }
-
- if (fizzy.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) {
- drawPoint(file, point, layer, .{
- .color = draw_options.color,
- .stroke_size = draw_options.stroke_size,
- .mask_only = draw_options.mask_only,
- .invalidate = false,
- .to_change = false,
- .constrain_to_tile = draw_options.constrain_to_tile,
- .clip_rect = draw_options.clip_rect,
- });
- } else {
- var stroke = if (point_i == 0) fizzy.editor.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 new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
-
- if (clip_rect) |cr| {
- if (!cr.contains(new_point)) continue;
- }
- if (draw_options.constrain_to_tile) {
- if (new_point.x < min_x or new_point.x >= max_x or new_point.y < min_y or new_point.y >= max_y) {
- continue;
- }
- }
-
- if (active_layer.pixelIndex(new_point)) |index| {
- active_layer.mask.setValue(index, mask_value);
- if (draw_options.mask_only) {
- continue;
- }
- if (layer == .selected and !file.editor.stroke_undo_deferred) {
- file.buffers.stroke.append(index, active_layer.pixels()[index]) catch {
- dvui.log.err("Failed to append to stroke buffer", .{});
- };
- }
-
- active_layer.pixels()[index] = draw_options.color.toRGBA();
- }
- }
- }
- }
-
- if (draw_options.mask_only) {
- return;
- }
-
- if (draw_options.invalidate) {
- if (layer == .selected) {
- const r1 = brushRect(point1, draw_options.stroke_size, file.width(), file.height());
- const r2 = brushRect(point2, draw_options.stroke_size, file.width(), file.height());
- expandActiveLayerDirtyRect(file, r1.unionWith(r2));
- } else {
- active_layer.invalidate();
- }
- }
-
- if (draw_options.to_change and layer == .selected) {
- if (file.editor.stroke_undo_deferred) {
- file.strokeUndoCommit();
- } else {
- const change_opt = file.buffers.stroke.toChange(active_layer.id) catch null;
- if (change_opt) |change| {
- file.history.append(change) catch {
- dvui.log.err("Failed to append to history", .{});
- };
- }
- }
- }
- }
-}
-
-pub const FillOptions = struct {
- invalidate: bool = false,
- to_change: bool = false,
- mask_only: bool = false,
- constrain_to_tile: bool = false,
- replace: bool = false,
- color: dvui.Color = .{ .r = 0, .g = 0, .b = 0, .a = 255 },
-};
-
-pub fn fillPoint(file: *File, point: dvui.Point, layer: DrawLayer, fill_options: FillOptions) void {
- var active_layer: Layer = switch (layer) {
- .temporary => file.editor.temporary_layer,
- .selected => file.layers.get(file.selected_layer_index),
- };
-
- defer active_layer.dirty = true;
-
- const active_mask_before = active_layer.mask.clone(dvui.currentWindow().arena()) catch {
- dvui.log.err("Failed to clone active mask", .{});
- return;
- };
-
- if (point.x < 0 or point.x >= @as(f32, @floatFromInt(file.width())) or point.y < 0 or point.y >= @as(f32, @floatFromInt(file.height()))) {
- return;
- }
-
- if (fill_options.replace) {
- if (active_layer.pixel(point)) |c| {
- active_layer.clearMask();
- active_layer.setMaskFromColor(.{ .r = c[0], .g = c[1], .b = c[2], .a = c[3] }, true);
- }
- } else {
- active_layer.clearMask();
- active_layer.floodMaskPoint(point, .fromSize(.{ .w = @as(f32, @floatFromInt(file.width())), .h = @as(f32, @floatFromInt(file.height())) }), true) catch {
- dvui.log.err("Failed to fill point", .{});
- };
- }
-
- if (fill_options.mask_only) {
- active_layer.mask.setUnion(active_mask_before);
- return;
- }
-
- var iter = active_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |index| {
- file.buffers.stroke.append(index, active_layer.pixels()[index]) catch {
- dvui.log.err("Failed to append to stroke buffer", .{});
- };
-
- active_layer.pixels()[index] = fill_options.color.toRGBA();
- }
-
- if (fill_options.invalidate) {
- active_layer.invalidate();
- }
-
- if (fill_options.to_change and layer == .selected and !fill_options.mask_only) {
- const change_opt = file.buffers.stroke.toChange(active_layer.id) catch null;
- if (change_opt) |change| {
- file.history.append(change) catch {
- dvui.log.err("Failed to append to history", .{});
- };
- }
- }
-
- if (fill_options.color.a != 0) {
- active_layer.mask.toggleAll(); // This will ensure that all drawn pixels are off, and all undrawn pixels are on
- }
-
- active_layer.mask.setUnion(active_mask_before);
-
- // Bucket fill leaves `active_layer.mask` in brush-tracking form (toggle+union) and does not
- // change `ImageSource.hash()`. The selection overlay (`updateActiveLayerMask` →
- // `setMaskFromTransparency`) must be rebuilt from actual pixels, not a stale cache.
- if (layer == .selected) {
- file.invalidateActiveLayerTransparencyMaskCache();
- }
-}
-
-pub fn getLayer(self: *File, id: u64) ?Layer {
- for (self.layers.items(.id), 0..) |layer_id, layer_index| {
- if (layer_id == id) {
- return self.layers.get(layer_index);
- }
- }
-
- return null;
-}
-
-pub fn deleteLayer(self: *File, index: usize) !void {
- try self.deleted_layers.append(fizzy.app.allocator, self.layers.slice().get(index));
- self.layers.orderedRemove(index);
- self.editor.layer_composite_dirty = true;
- self.editor.split_composite_dirty = true;
- try self.history.append(.{ .layer_restore_delete = .{
- .action = .restore,
- .index = index,
- } });
-
- if (index > 0) {
- self.selected_layer_index = index - 1;
- }
-}
-
-pub fn mergeSelectedLayerUp(self: *File) !void {
- const s = self.selected_layer_index;
- if (s == 0) return;
- try self.mergeLayerInternal(.up, s, s - 1);
-}
-
-pub fn mergeSelectedLayerDown(self: *File) !void {
- const s = self.selected_layer_index;
- if (s + 1 >= self.layers.len) return;
- try self.mergeLayerInternal(.down, s, s + 1);
-}
-
-fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: usize, dest_i: usize) !void {
- var dest = self.layers.get(dest_i);
- const src = self.layers.get(src_i);
-
- const pix_n = dest.pixels().len;
- if (src.pixels().len != pix_n) return error.InvalidLayerMerge;
-
- const dest_id = self.layers.items(.id)[dest_i];
- const src_id = self.layers.items(.id)[src_i];
-
- const dest_pixels_before = try fizzy.app.allocator.dupe([4]u8, dest.pixels());
- errdefer fizzy.app.allocator.free(dest_pixels_before);
-
- var dest_mask_before = try dest.mask.clone(fizzy.app.allocator);
- errdefer dest_mask_before.deinit();
-
- for (0..pix_n) |i| {
- const dpx = dest.pixels()[i];
- const spx = src.pixels()[i];
- dest.pixels()[i] = switch (kind) {
- .up => Layer.blendPmaSrcOver(dpx, spx),
- .down => Layer.blendPmaSrcOver(spx, dpx),
- };
- }
- dest.mask.setUnion(src.mask);
- dest.invalidate();
- self.layers.set(dest_i, dest);
-
- try self.deleted_layers.append(fizzy.app.allocator, self.layers.slice().get(src_i));
- self.layers.orderedRemove(src_i);
-
- self.editor.layer_composite_dirty = true;
- self.editor.split_composite_dirty = true;
-
- self.selected_layer_index = switch (kind) {
- .up => dest_i,
- .down => dest_i - 1,
- };
-
- try self.history.append(.{ .layer_merge = .{
- .kind = kind,
- .source_index = src_i,
- .dest_layer_id = dest_id,
- .source_layer_id = src_id,
- .dest_pixels_before = dest_pixels_before,
- .dest_mask_before = dest_mask_before,
- } });
- fizzy.editor.explorer.pane = .tools;
-}
-
-pub fn duplicateLayer(self: *File, index: usize) !u64 {
- const layer = self.layers.slice().get(index);
-
- const new_name = try std.fmt.allocPrint(dvui.currentWindow().lifo(), "{s}_copy", .{layer.name});
- defer dvui.currentWindow().lifo().free(new_name);
-
- var new_layer = Layer.init(self.newLayerID(), new_name, self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.FailedToDuplicateLayer;
- new_layer.visible = layer.visible;
- new_layer.collapse = layer.collapse;
-
- @memcpy(new_layer.pixels(), layer.pixels());
-
- self.layers.insert(fizzy.app.allocator, 0, new_layer) catch {
- dvui.log.err("Failed to append layer", .{});
- };
-
- self.selected_layer_index = 0;
- self.editor.layer_composite_dirty = true;
- self.editor.split_composite_dirty = true;
-
- self.history.append(.{
- .layer_restore_delete = .{
- .index = 0,
- .action = .delete,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
-
- return new_layer.id;
-}
-
-pub fn createLayer(self: *File) !u64 {
- if (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 {
- dvui.log.err("Failed to append layer", .{});
- };
- self.selected_layer_index = 0;
- self.editor.layer_composite_dirty = true;
- self.editor.split_composite_dirty = true;
-
- self.history.append(.{
- .layer_restore_delete = .{
- .index = 0,
- .action = .delete,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
-
- return layer.id;
- }
-
- return error.FailedToCreateLayer;
-}
-
-pub fn createAnimation(self: *File) !usize {
- var animation = Animation.init(
- fizzy.app.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());
-
- var iter = self.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- var i: usize = 0;
- while (iter.next()) |sprite_index| : (i += 1) {
- animation.frames[i] = .{ .sprite_index = sprite_index, .ms = @intFromFloat(1000.0 / @as(f32, @floatFromInt(self.editor.selected_sprites.count()))) };
- }
- }
-
- self.animations.append(fizzy.app.allocator, animation) catch {
- dvui.log.err("Failed to append animation", .{});
- };
- return self.animations.len - 1;
-}
-
-pub fn duplicateAnimation(self: *File, index: usize) !usize {
- const animation = self.animations.slice().get(index);
- const new_name = try std.fmt.allocPrint(dvui.currentWindow().lifo(), "{s}_copy", .{animation.name});
- const new_animation = Animation.init(fizzy.app.allocator, self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation;
- self.animations.insert(fizzy.app.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));
- self.animations.orderedRemove(index);
- try self.history.append(.{ .animation_restore_delete = .{
- .action = .restore,
- .index = index,
- } });
-}
-
-pub fn undo(self: *File) !void {
- return self.history.undoRedo(self, .undo);
-}
-
-pub fn redo(self: *File) !void {
- return self.history.undoRedo(self, .redo);
-}
-
-pub fn saveTar(self: *File, window: *dvui.Window) !void {
- if (self.saving) return;
- self.saving = true;
- var ext = try self.external(fizzy.app.allocator);
- defer ext.deinit(fizzy.app.allocator);
-
- const output_path = try fizzy.editor.arena.allocator().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);
- const out_stream = json.writer();
- const options = std.json.StringifyOptions{};
-
- try std.json.stringify(ext, options, out_stream);
-
- const json_output = try json.toOwnedSlice();
-
- try wrt.writeFileBytes("fizzydata.json", json_output, .{});
-
- if (self.layers.len > 0) {
- const slice = self.layers.slice();
- var index: usize = 0;
- while (index < self.layers.len) : (index += 1) {
- const layer = slice.get(index);
-
- const data: []u8 = switch (layer.source) {
- .pixels => |p| @as([*]u8, @ptrCast(@constCast(p.rgba.ptr)))[0..(p.width * p.height * 4)],
- .pixelsPMA => |p| @as([*]u8, @ptrCast(@constCast(p.rgba.ptr)))[0..(p.width * p.height * 4)],
- else => return error.InvalidImageSource,
- };
-
- try wrt.writeFileBytes(try std.fmt.allocPrintZ(fizzy.editor.arena.allocator(), "{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 = id_mutex.id;
- const message = std.fmt.allocPrint(window.arena(), "Saved {s}", .{std.fs.path.basename(self.path)}) catch "Saved file";
- dvui.dataSetSlice(window, id, "_message", message);
- id_mutex.mutex.unlock(dvui.io);
- }
-
- self.saving = false;
- self.history.bookmark = 0;
-}
-
-/// All visible layers composited with src-over (same as on-canvas), then encoded to PNG or JPEG.
-fn writeFlattenedLayersToPath(self: *File, out_path: []const u8, window: *dvui.Window, comptime kind: enum { png, jpg }) !void {
- const w = self.width();
- const h = self.height();
- if (w == 0 or h == 0) return error.InvalidImageSize;
-
- try fizzy.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);
- defer {
- const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.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);
- 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);
- },
- .jpg => {
- const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0));
- try fizzy.image.writeToJpgPpi(tmp_layer.source, out_path, ppi);
- },
- }
-}
-
-pub fn savePng(self: *File, window: *dvui.Window) !void {
- if (self.isSaving()) return;
- self.setSaving(true);
- errdefer self.setSaving(false);
-
- try self.writeFlattenedLayersToPath(self.path, window, .png);
-
- {
- // `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic
- // u64s; in practice they fit, so an `@intCast` is safe and panics if not.
- const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000);
- const id = id_mutex.id;
- const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file";
- dvui.dataSetSlice(window, id, "_message", message);
- id_mutex.mutex.unlock(dvui.io);
- }
-
- self.setSaving(false);
- self.history.bookmark = 0;
-}
-
-pub fn saveJpg(self: *File, window: *dvui.Window) !void {
- if (self.isSaving()) return;
- self.setSaving(true);
- errdefer self.setSaving(false);
-
- try self.writeFlattenedLayersToPath(self.path, window, .jpg);
-
- {
- // `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic
- // u64s; in practice they fit, so an `@intCast` is safe and panics if not.
- const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000);
- const id = id_mutex.id;
- const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file";
- dvui.dataSetSlice(window, id, "_message", message);
- id_mutex.mutex.unlock(dvui.io);
- }
-
- self.setSaving(false);
- self.history.bookmark = 0;
-}
-
-pub fn saveZip(self: *File, window: *dvui.Window) !void {
- if (self.isSaving()) return;
- self.setSaving(true);
- defer self.setSaving(false);
- // Synchronous callers (e.g. `saveAsFizzy`) run on the GUI thread, which is
- // already the only writer of `self.layers` — so a snapshot would be pointless
- // copying. Build the snapshot inline and immediately consume it. We still
- // use the same code path so there's a single zip-writing function.
- var snap = try SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator);
- defer snap.deinit(fizzy.app.allocator);
- try writeSnapshotToZip(self.id, window, &snap);
-}
-
-/// Layer pixel bytes + metadata copied from `*File` on the GUI thread before a
-/// worker save kicks off. The worker reads only this struct, never the live
-/// `*File`, so user edits during the save can't tear `self.layers` mid-iteration
-/// (manifested as MultiArrayList slice OOB / corrupt layer.name).
-pub const SaveSnapshot = struct {
- ext: fizzy.File,
- layer_bytes: [][]u8,
- layer_entry_names: [][:0]const u8,
- null_terminated_path: [:0]u8,
-
- /// Allocate + populate from `*File`. MUST be called from the GUI thread
- /// (the only writer of `file.layers`).
- pub fn fromFileOnGuiThread(file: *File, allocator: std.mem.Allocator) !SaveSnapshot {
- var snap: SaveSnapshot = .{
- .ext = try file.external(allocator),
- .layer_bytes = try allocator.alloc([]u8, file.layers.len),
- .layer_entry_names = try allocator.alloc([:0]const u8, file.layers.len),
- .null_terminated_path = try allocator.dupeZ(u8, file.path),
- };
- // Initialize slots so partial-init cleanup is safe.
- @memset(snap.layer_bytes, &[_]u8{});
- for (snap.layer_entry_names) |*n| n.* = "";
- errdefer snap.deinit(allocator);
-
- const slice = file.layers.slice();
- var i: usize = 0;
- while (i < file.layers.len) : (i += 1) {
- const layer = slice.get(i);
- snap.layer_bytes[i] = try allocator.dupe(u8, layer.bytes());
- snap.layer_entry_names[i] = try std.fmt.allocPrintSentinel(allocator, "{s}.layer", .{layer.name}, 0);
- }
- return snap;
- }
-
- pub fn deinit(self: *SaveSnapshot, allocator: std.mem.Allocator) void {
- self.ext.deinit(allocator);
- for (self.layer_bytes) |b| if (b.len != 0) allocator.free(b);
- allocator.free(self.layer_bytes);
- for (self.layer_entry_names) |n| if (n.len != 0) allocator.free(n);
- allocator.free(self.layer_entry_names);
- allocator.free(self.null_terminated_path);
- }
-};
-
-/// Single dedicated worker that serializes all `.fiz` async saves through one
-/// thread. Pushed-into by `saveAsync` (via `save_queue.submit`), drained by the
-/// long-lived `saveQueueWorker` thread spawned by `initSaveQueue`.
-///
-/// IMPORTANT: jobs reference the target file by `id`, not by `*File`. The editor's
-/// `open_files` is an `AutoArrayHashMap` with inline values, and `rawCloseFileID`
-/// does an `orderedRemove` that shifts later entries down to fill the gap. Any
-/// `*File` pointer captured at submit time would be silently invalidated the
-/// moment an earlier file in the queue completes and gets closed — manifesting
-/// as the worker dequeuing the "right" Job slot but its `file` pointer reading
-/// the SHIFTED-INTO-PLACE file's data.
-pub const SaveQueue = struct {
- pub const Job = struct {
- file_id: u64,
- window: *dvui.Window,
- snap: *SaveSnapshot,
- };
-
- mutex: std.Io.Mutex = .init,
- cond: std.Io.Condition = .init,
- queue: std.ArrayListUnmanaged(Job) = .empty,
- shutdown: bool = false,
- worker: ?std.Thread = null,
-
- pub fn submit(self: *SaveQueue, job: Job) !void {
- self.mutex.lockUncancelable(dvui.io);
- defer self.mutex.unlock(dvui.io);
- try self.queue.append(fizzy.app.allocator, job);
- self.cond.signal(dvui.io);
- }
-};
-
-var save_queue: SaveQueue = .{};
-
-/// Spawn the long-lived save-queue worker. Safe to call multiple times; second+
-/// calls are no-ops. Call from the GUI thread before any `saveAsync` runs.
-/// Wasm: no-op. Single-threaded freestanding wasm can't spawn threads; the
-/// browser save path needs a different strategy (inline + `wasm_download_data`).
-pub fn initSaveQueue() !void {
- if (comptime @import("builtin").target.cpu.arch == .wasm32) return;
- if (save_queue.worker != null) return;
- save_queue.worker = try std.Thread.spawn(.{}, saveQueueWorker, .{});
-}
-
-/// Signal the save-queue worker to drain remaining jobs and exit, then join.
-/// Call from the GUI thread during editor shutdown.
-/// Wasm: no-op (no worker was ever spawned).
-pub fn deinitSaveQueue() void {
- if (comptime @import("builtin").target.cpu.arch == .wasm32) return;
- save_queue.mutex.lockUncancelable(dvui.io);
- save_queue.shutdown = true;
- save_queue.cond.broadcast(dvui.io);
- save_queue.mutex.unlock(dvui.io);
- if (save_queue.worker) |w| {
- w.join();
- save_queue.worker = null;
- }
- // Anything still queued after worker exit is leaked snapshots — shouldn't
- // happen since the worker drains before exit, but clean up defensively.
- for (save_queue.queue.items) |*job| {
- job.snap.deinit(fizzy.app.allocator);
- fizzy.app.allocator.destroy(job.snap);
- }
- save_queue.queue.deinit(fizzy.app.allocator);
-}
-
-fn saveQueueWorker() void {
- while (true) {
- save_queue.mutex.lockUncancelable(dvui.io);
- while (save_queue.queue.items.len == 0 and !save_queue.shutdown) {
- save_queue.cond.waitUncancelable(dvui.io, &save_queue.mutex);
- }
- if (save_queue.shutdown and save_queue.queue.items.len == 0) {
- save_queue.mutex.unlock(dvui.io);
- return;
- }
- const job = save_queue.queue.orderedRemove(0);
- save_queue.mutex.unlock(dvui.io);
-
- // The snapshot owns everything the writer needs. For the post-write
- // `setSaving(false)` and `history.bookmark = 0` we MUST re-lookup the
- // file pointer at the moment of use — `editor.open_files` is an
- // AutoArrayHashMap with inline values, and any concurrent `orderedRemove`
- // shifts later entries down. A pointer captured here at dequeue time
- // becomes stale (silently aliasing a different file) as soon as the GUI
- // thread closes any earlier file from the in-flight set.
- defer {
- job.snap.deinit(fizzy.app.allocator);
- fizzy.app.allocator.destroy(job.snap);
- if (fizzy.editor.open_files.getPtr(job.file_id)) |f| f.setSaving(false);
- dvui.refresh(job.window, @src(), null);
- }
- writeSnapshotToZip(job.file_id, job.window, job.snap) catch |err| {
- dvui.log.err("Async save failed: {s}", .{@errorName(err)});
- };
- }
-}
-
-/// Shared zip-writing logic. Reads ONLY from `snap`, never `self.*` collection
-/// fields. `self` is used for the post-save `history.bookmark = 0` update.
-///
-/// This runs on a worker thread when invoked via `saveAsync`/`saveZipFromSnapshot`.
-/// Anything that mutates dvui state (toasts, window arena allocations, etc.) MUST
-/// stay off this path — concurrent workers calling into `dvui.toastAdd` race on
-/// the toast subsystem's mutex against the GUI thread's per-frame toast iteration,
-/// and the contention can wedge one of them indefinitely (observed in multi-doc
-/// save-all-quit). The save-complete toast is duplicate feedback — the dialog
-/// closing + tab disappearing already signals completion — so we skip it.
-/// Async-path entry takes `file_id` and re-looks up the file at the END (for
-/// `history.bookmark` reset). The snapshot owns its own bytes so the actual
-/// zip write doesn't need any access to the live file. Re-lookup is critical
-/// because `open_files` shifts inline values on remove and any pointer captured
-/// upfront would silently alias a different file by the time we write.
-fn writeSnapshotToZip(file_id: u64, window: *dvui.Window, snap: *const SaveSnapshot) !void {
- _ = window;
- const zip_file = zip.zip_open(snap.null_terminated_path.ptr, zip.ZIP_DEFAULT_COMPRESSION_LEVEL, 'w');
-
- if (zip_file) |z| {
- try writeSnapshotEntriesToZip(z, snap);
- zip.zip_close(z);
- }
-
- if (fizzy.editor.open_files.getPtr(file_id)) |f| f.history.bookmark = 0;
-}
-
-fn zipEntryOk(rc: c_int) !void {
- if (rc < 0) return error.ZipEntryFailed;
-}
-
-fn writeSnapshotEntriesToZip(z: *zip.struct_zip_t, snap: *const SaveSnapshot) !void {
- const options = std.json.Stringify.Options{};
- const output = try std.json.Stringify.valueAlloc(fizzy.app.allocator, snap.ext, options);
- defer fizzy.app.allocator.free(output);
-
- try zipEntryOk(zip.zip_entry_open(z, "fizzydata.json"));
- try zipEntryOk(zip.zip_entry_write(z, output.ptr, output.len));
- try zipEntryOk(zip.zip_entry_close(z));
-
- for (snap.layer_entry_names, snap.layer_bytes) |entry_name, bytes| {
- try zipEntryOk(zip.zip_entry_open(z, @as([*c]const u8, @ptrCast(entry_name))));
- try zipEntryOk(zip.zip_entry_write(z, @ptrCast(bytes.ptr), bytes.len));
- try zipEntryOk(zip.zip_entry_close(z));
- }
-}
-
-fn writeSnapshotToZipBytes(snap: *const SaveSnapshot, allocator: std.mem.Allocator) ![]u8 {
- // Stored (level 0) on wasm: DEFLATE in miniz is less tested on freestanding and some
- // readers reject malformed compressed entries from in-memory writers.
- const level: c_int = if (comptime @import("builtin").target.cpu.arch == .wasm32) 0 else zip.ZIP_DEFAULT_COMPRESSION_LEVEL;
- const z = zip.zip_stream_open(null, 0, level, 'w') orelse return error.ZipOpenFailed;
- defer zip.zip_stream_close(z);
- try writeSnapshotEntriesToZip(z, snap);
- var buf: ?*anyopaque = null;
- var bufsize: usize = 0;
- const n = zip.zip_stream_copy(z, &buf, &bufsize);
- if (n < 0 or buf == null) return error.ZipCopyFailed;
- const slice = @as([*]const u8, @ptrCast(buf))[0..bufsize];
- const owned = try allocator.dupe(u8, slice);
- zip.fizzy_zip_free(buf);
- if (owned.len < 4 or !std.mem.eql(u8, owned[0..4], "PK\x03\x04")) return error.InvalidZip;
- return owned;
-}
-
-/// Browser save: encode in memory and trigger a download (no on-disk project folder).
-pub fn saveToDownload(self: *File, window: *dvui.Window) !void {
- if (comptime @import("builtin").target.cpu.arch != .wasm32) return;
- if (self.isSaving()) return;
- self.setSaving(true);
- defer self.setSaving(false);
-
- const basename = std.fs.path.basename(self.path);
- const ext = std.fs.path.extension(self.path);
-
- if (isFizzyExtension(ext)) {
- var snap = try SaveSnapshot.fromFileOnGuiThread(self, 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);
- } 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);
- } 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);
- } 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 = id_mutex.id;
- const message = std.fmt.allocPrint(window.arena(), "Downloaded {s}", .{basename}) catch "Downloaded file";
- dvui.dataSetSlice(window, id, "_message", message);
- id_mutex.mutex.unlock(dvui.io);
-}
-
-fn flattenedImageBytes(self: *File, window: *dvui.Window, comptime kind: enum { png, jpg }) ![]u8 {
- const w = self.width();
- const h = self.height();
- if (w == 0 or h == 0) return error.InvalidImageSize;
-
- try fizzy.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);
- defer {
- const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.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);
- defer tmp_layer.deinit();
-
- var out = std.Io.Writer.Allocating.init(fizzy.app.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);
- },
- .jpg => {
- const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0));
- try fizzy.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi);
- },
- }
- return out.toOwnedSlice();
-}
-
-/// Point `path` at `new_path`, then `saveZip` (same on-disk work as a normal .pixi save). Restores the previous `path` if saving fails.
-pub fn saveAsFizzy(self: *File, new_path: []const u8, window: *dvui.Window) !void {
- if (self.isSaving()) return;
- if (std.mem.eql(u8, self.path, new_path)) {
- if (comptime @import("builtin").target.cpu.arch == .wasm32) {
- return saveToDownload(self, window);
- }
- return saveZip(self, window);
- }
- const old_path = self.path;
- const new_owned = try fizzy.app.allocator.dupe(u8, new_path);
- self.path = new_owned;
- errdefer {
- fizzy.app.allocator.free(self.path[0..self.path.len]);
- self.path = old_path;
- }
- if (comptime @import("builtin").target.cpu.arch == .wasm32) {
- try saveToDownload(self, window);
- } else {
- try saveZip(self, window);
- }
- fizzy.app.allocator.free(old_path[0..old_path.len]);
-}
-
-/// Default filename (with `.fiz`) for a Save As dialog, derived from the current path.
-pub fn defaultSaveAsFilename(allocator: std.mem.Allocator, current_path: []const u8) ![]u8 {
- const base = std.fs.path.basename(current_path);
- const stem: []const u8 = if (std.mem.lastIndexOf(u8, base, ".")) |i| base[0..i] else base;
- if (stem.len == 0) {
- return try std.fmt.allocPrint(allocator, "{s}", .{"untitled.fiz"});
- }
- return try std.fmt.allocPrint(allocator, "{s}.fiz", .{stem});
-}
-
-fn deinitAllUserLayers(self: *File) void {
- while (self.layers.len > 0) {
- const i = self.layers.len - 1;
- var layer = self.layers.get(i);
- layer.deinit();
- self.layers.orderedRemove(i);
- }
-}
-
-fn clearAnimationsForSaveAs(self: *File) void {
- for (self.animations.items(.name)) |n| {
- fizzy.app.allocator.free(n);
- }
- for (self.animations.items(.frames)) |frames| {
- fizzy.app.allocator.free(frames);
- }
- self.animations.clearRetainingCapacity();
- self.deleted_animations.clearRetainingCapacity();
- self.selected_animation_index = null;
- self.selected_animation_frame_index = 0;
- self.editor.selected_animation_indices.clearRetainingCapacity();
- self.editor.selected_frame_indices.clearRetainingCapacity();
-}
-
-fn reinitEditorSurfaceForFlatDocument(self: *File) !void {
- self.editor.temporary_layer.deinit();
- self.editor.selection_layer.deinit();
- self.editor.transform_layer.deinit();
- self.editor.checkerboard.deinit();
- if (self.editor.checkerboard_tile) |t| {
- dvui.textureDestroyLater(t);
- self.editor.checkerboard_tile = null;
- }
- self.editor.selected_sprites.deinit();
-
- self.editor.temporary_layer = try .init(self.newLayerID(), "Temporary", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- self.editor.selection_layer = try .init(self.newLayerID(), "Selection", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- self.editor.transform_layer = try .init(self.newLayerID(), "Transform", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- self.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, self.spriteCount());
-
- self.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.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);
- self.editor.checkerboard.setValue(i, value);
- }
- self.editor.selected_layer_indices.clearRetainingCapacity();
- try self.editor.selected_layer_indices.append(fizzy.app.allocator, 0);
-}
-
-/// Flattens visible layers (via GPU composite), writes PNG or JPEG to `output_path`, and replaces
-/// the document with a single layer matching the flattened result.
-pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Window) !void {
- if (self.isSaving()) return;
- self.setSaving(true);
- errdefer self.setSaving(false);
-
- strokeUndoFreeSnapshot(self);
- const w = self.width();
- const h = self.height();
- if (w == 0 or h == 0) {
- self.setSaving(false);
- return error.InvalidImageSize;
- }
-
- try fizzy.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);
- defer {
- const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
- }
-
- const ext = std.fs.path.extension(output_path);
- const is_png = std.mem.eql(u8, ext, ".png");
- const is_jpg = std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg");
- if (!is_png and !is_jpg) {
- self.setSaving(false);
- return error.InvalidExtension;
- }
-
- var single_layer: fizzy.Internal.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);
- errdefer out.deinit();
- try fizzy.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);
- errdefer out.deinit();
- try fizzy.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi);
- break :blk try out.toOwnedSlice();
- };
- defer fizzy.app.allocator.free(bytes);
- const dl_ext = if (is_png) ".png" else ".jpg";
- try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes);
- } 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);
- } else {
- const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0));
- try fizzy.image.writeToJpgPpi(single_layer.source, output_path, ppi);
- }
-
- fizzy.render.destroyLayerCompositeResources(self);
- fizzy.render.destroySplitCompositeResources(self);
-
- deinitAllUserLayers(self);
- clearAnimationsForSaveAs(self);
- self.sprites.clearRetainingCapacity();
- for (0..self.spriteCount()) |_| {
- self.sprites.append(fizzy.app.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]);
- self.path = new_path;
- self.columns = 1;
- self.rows = 1;
- self.column_width = w;
- self.row_height = h;
- self.selected_layer_index = 0;
- self.peek_layer_index = null;
- self.layers.append(fizzy.app.allocator, single_layer) catch {
- single_layer.deinit();
- return error.LayerCreateError;
- };
-
- self.history.deinit();
- self.history = .init(fizzy.app.allocator);
-
- try reinitEditorSurfaceForFlatDocument(self);
- self.editor.layer_composite_dirty = true;
- self.editor.split_composite_dirty = true;
- self.setSaving(false);
- {
- // `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic
- // u64s; in practice they fit, so an `@intCast` is safe and panics if not.
- const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.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();
-}
-
-pub const GridLayoutOptions = struct {
- column_width: u32,
- row_height: u32,
- columns: u32,
- rows: u32,
- anchor: fizzy.math.layout_anchor.LayoutAnchor,
- /// When true (default), `applyGridLayout` snapshots the previous state and pushes a
- /// `grid_layout` change to the file's history before mutating. Internal callers driving
- /// undo/redo restoration should pass `false` so the swap doesn't loop into itself.
- history: bool = true,
-};
-
-/// Captures everything `applyGridLayout` mutates, owning all returned slices. The caller is
-/// responsible for freeing via `Change.deinit` (see `History.Change.GridLayout.deinit`).
-pub fn captureGridLayoutSnapshot(file: *File) !fizzy.Internal.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_pixels = try fizzy.app.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 (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);
- @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);
- for (0..sprite_count) |i| sprite_origins[i] = file.sprites.items(.origin)[i];
-
- return .{
- .column_width = file.column_width,
- .row_height = file.row_height,
- .columns = file.columns,
- .rows = file.rows,
- .layer_ids = layer_ids,
- .layer_pixels = layer_pixels,
- .sprite_origins = sprite_origins,
- .selected_animation_index = file.selected_animation_index,
- .selected_animation_frame_index = file.selected_animation_frame_index,
- .selected_layer_index = file.selected_layer_index,
- };
-}
-
-/// Restores the file to the exact state described by `snap`. Mirrors the structural updates of
-/// `applyGridLayout` (resize layer buffers, sprite list, scratch layers, checkerboard, composite
-/// tear-down) but copies pixel data verbatim instead of re-anchoring it.
-pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change.GridLayout) !void {
- const new_w: u32 = snap.column_width * snap.columns;
- const new_h: u32 = snap.row_height * snap.rows;
- const total: usize = @as(usize, new_w) * @as(usize, new_h);
-
- // Replace each live layer's pixel buffer with the snapshot's. Layers are matched by id so an
- // intervening reorder doesn't paint pixels into the wrong layer.
- for (0..file.layers.len) |layer_index| {
- var live = file.layers.get(layer_index);
- const live_id = live.id;
-
- const snap_idx_opt: ?usize = blk: {
- for (snap.layer_ids, 0..) |sid, j| if (sid == live_id) break :blk j;
- break :blk null;
- };
-
- var rebuilt = fizzy.Internal.Layer.init(
- live.id,
- live.name,
- new_w,
- new_h,
- .{ .r = 0, .g = 0, .b = 0, .a = 0 },
- .ptr,
- ) catch return error.LayerCreateError;
- rebuilt.visible = live.visible;
- rebuilt.collapse = live.collapse;
-
- if (snap_idx_opt) |j| @memcpy(rebuilt.pixels(), snap.layer_pixels[j]);
-
- rebuilt.invalidate();
- live.deinit();
- file.layers.set(layer_index, rebuilt);
- }
-
- file.editor.temporary_layer.deinit();
- file.editor.selection_layer.deinit();
- file.editor.transform_layer.deinit();
- file.editor.temporary_layer = 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.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.editor.selected_sprites.deinit();
- file.editor.selected_sprites = std.DynamicBitSet.initEmpty(fizzy.app.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;
- for (0..total) |idx| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx);
- file.editor.checkerboard.setValue(idx, value);
- }
-
- file.editor.transform = null;
- file.selected_animation_index = snap.selected_animation_index;
- file.selected_animation_frame_index = snap.selected_animation_frame_index;
- if (snap.selected_layer_index < file.layers.len) {
- file.selected_layer_index = snap.selected_layer_index;
- }
-
- file.column_width = snap.column_width;
- file.row_height = snap.row_height;
- file.columns = snap.columns;
- file.rows = snap.rows;
-
- fizzy.render.destroyLayerCompositeResources(file);
- file.invalidateActiveLayerTransparencyMaskCache();
-}
-
-/// Mirrors the export size cap (4096×4096) and rejects degenerate proposals before any allocation.
-/// Pure logic lives in `internal/grid_layout_validate.zig` for unit-testability.
-pub const validateGridLayoutProposedDims = @import("grid_layout_validate.zig").validateGridLayoutProposedDims;
-
-pub const GridSliceOptions = struct {
- column_width: u32,
- row_height: u32,
- columns: u32,
- rows: u32,
- history: bool = true,
-};
-
-/// Re-tile metadata only: pixel buffers, layer masks, scratch layers, and per-cell artwork are left
-/// untouched. Requires `column_width × columns` and `row_height × rows` to match `canvasPixelSize`.
-/// Sprite origins are preserved for indices that still exist after the new `columns × rows`; new
-/// cells get origin `(0, 0)`.
-pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void {
- if (!validateGridLayoutProposedDims(options.column_width, options.row_height, options.columns, options.rows)) {
- return error.InvalidGridLayout;
- }
-
- const canvas = file.canvasPixelSize();
- if (canvas.w == 0 or canvas.h == 0) return error.InvalidGridLayout;
-
- const prop_w: u32 = options.column_width * options.columns;
- const prop_h: u32 = options.row_height * options.rows;
- if (prop_w != canvas.w or prop_h != canvas.h) return error.InvalidGridLayout;
-
- const same =
- options.column_width == file.column_width and
- options.row_height == file.row_height and
- options.columns == file.columns and
- options.rows == file.rows;
- if (same) return;
-
- var snapshot_opt: ?fizzy.Internal.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 };
- ch.deinit();
- };
-
- const new_cw = options.column_width;
- const new_rh = options.row_height;
- const new_cols = options.columns;
- const new_rows = options.rows;
- const new_sprite_count: usize = @as(usize, new_cols) * @as(usize, new_rows);
-
- const old_sprite_count = file.sprites.len;
- file.sprites.resize(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed;
-
- if (new_sprite_count > old_sprite_count) {
- var i: usize = old_sprite_count;
- while (i < new_sprite_count) : (i += 1) {
- file.sprites.items(.origin)[i] = .{ 0, 0 };
- }
- }
-
- var new_selected = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count);
- const sel_copy = @min(old_sprite_count, new_sprite_count);
- for (0..sel_copy) |i| {
- if (file.editor.selected_sprites.isSet(i)) new_selected.set(i);
- }
- file.editor.selected_sprites.deinit();
- file.editor.selected_sprites = new_selected;
-
- file.column_width = new_cw;
- file.row_height = new_rh;
- file.columns = new_cols;
- file.rows = new_rows;
-
- fizzy.render.destroyLayerCompositeResources(file);
- file.invalidateActiveLayerTransparencyMaskCache();
-
- if (snapshot_opt) |snap| {
- snapshot_opt = null;
- try file.history.append(.{ .grid_layout = snap });
- }
-}
-
-/// Re-grid the document. For every cell present in both the old and new grids,
-/// `cellAnchoredBlit` decides how the old `column_width × row_height` tile is composed
-/// into the new cell (pad on growth, crop on shrink, mixed axes resolved per-axis).
-///
-/// Layer pixel buffers are reallocated; sprite origins, the selected-sprite bitset, and the editor
-/// scratch layers (temporary/selection/transform) are rebuilt to the new total size. The composite
-/// targets are torn down so they get re-created at the next paint.
-///
-/// `applyGridLayout` is destructive: history is **not** repurposed for it (the existing `resize` event
-/// captures only width/height and would lose the cell-size delta). Callers should warn before invoking.
-pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void {
- if (!validateGridLayoutProposedDims(options.column_width, options.row_height, options.columns, options.rows)) {
- return error.InvalidGridLayout;
- }
-
- const same =
- options.column_width == file.column_width and
- options.row_height == file.row_height and
- options.columns == file.columns and
- options.rows == file.rows;
- if (same) return;
-
- // Capture undo state up front. If allocation fails we abort *before* mutating, so the file
- // is left untouched and the user can retry.
- var snapshot_opt: ?fizzy.Internal.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 };
- ch.deinit();
- };
-
- const old_cw = file.column_width;
- const old_rh = file.row_height;
- const old_cols = file.columns;
- const old_rows = file.rows;
- const old_w: u32 = old_cw * old_cols;
-
- const new_cw = options.column_width;
- const new_rh = options.row_height;
- const new_cols = options.columns;
- const new_rows = options.rows;
- const new_w: u32 = new_cw * new_cols;
- const new_h: u32 = new_rh * new_rows;
-
- // Slice/regrid: when total pixel dims don't change, the pixel buffer is bit-identical and
- // the operation is purely metadata + per-cell sprite reset. Going through `cellAnchoredBlit`
- // here would be wrong — that function maps cell index → cell index, so e.g. 1×1 → 4×4 of
- // the same total size only fills cell (0,0) and zeroes the other 15 cells.
- const total_preserved = new_w == old_w and new_h == old_rh * old_rows;
-
- // For each layer: build a new pixel buffer at the new total size. When total is preserved
- // we copy the whole buffer; otherwise re-grid each shared cell through `cellAnchoredBlit`.
- for (0..file.layers.len) |layer_index| {
- var old_layer = file.layers.get(layer_index);
- const old_pix = old_layer.pixels();
-
- var new_layer = fizzy.Internal.Layer.init(
- old_layer.id,
- old_layer.name,
- new_w,
- new_h,
- .{ .r = 0, .g = 0, .b = 0, .a = 0 },
- .ptr,
- ) catch return error.LayerCreateError;
- new_layer.visible = old_layer.visible;
- new_layer.collapse = old_layer.collapse;
-
- const new_pix = new_layer.pixels();
-
- if (total_preserved) {
- std.debug.assert(new_pix.len == old_pix.len);
- @memcpy(new_pix, old_pix);
- } else {
- var nrow: u32 = 0;
- while (nrow < @min(new_rows, old_rows)) : (nrow += 1) {
- var ncol: u32 = 0;
- while (ncol < @min(new_cols, old_cols)) : (ncol += 1) {
- const blk = fizzy.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw, new_rh, options.anchor);
- if (blk.sw == 0 or blk.sh == 0) continue;
-
- const src_x0: u32 = ncol * old_cw + blk.sx;
- const src_y0: u32 = nrow * old_rh + blk.sy;
- const dst_x0: u32 = ncol * new_cw + blk.dx;
- const dst_y0: u32 = nrow * new_rh + blk.dy;
-
- var row: u32 = 0;
- while (row < blk.sh) : (row += 1) {
- const src_off: usize = (@as(usize, src_y0 + row) * old_w) + src_x0;
- const dst_off: usize = (@as(usize, dst_y0 + row) * new_w) + dst_x0;
- @memcpy(
- new_pix[dst_off .. dst_off + blk.sw],
- old_pix[src_off .. src_off + blk.sw],
- );
- }
- }
- }
- }
-
- new_layer.invalidate();
- old_layer.deinit();
- file.layers.set(layer_index, new_layer);
- }
-
- // Editor scratch layers must follow the canvas dimensions or every brush/selection coordinate is wrong.
- file.editor.temporary_layer.deinit();
- file.editor.selection_layer.deinit();
- file.editor.transform_layer.deinit();
- file.editor.temporary_layer = 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;
-
- // 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.editor.selected_sprites.deinit();
- file.editor.selected_sprites = std.DynamicBitSet.initEmpty(fizzy.app.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;
- 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);
- file.editor.checkerboard.setValue(idx, value);
- }
-
- // Sprite-bound editor state (animations reference cell indices that may no longer exist; transforms
- // reference dimensions). Drop selections rather than guess at remaps.
- file.selected_animation_index = null;
- file.selected_animation_frame_index = 0;
- file.editor.transform = null;
-
- file.column_width = new_cw;
- file.row_height = new_rh;
- file.columns = new_cols;
- file.rows = new_rows;
-
- fizzy.render.destroyLayerCompositeResources(file);
- file.invalidateActiveLayerTransparencyMaskCache();
-
- if (snapshot_opt) |snap| {
- snapshot_opt = null;
- try file.history.append(.{ .grid_layout = snap });
- }
-}
-
-pub fn saveAsync(self: *File) !void {
- if (comptime @import("builtin").target.cpu.arch == .wasm32) {
- try self.saveToDownload(dvui.currentWindow());
- return;
- }
-
- //if (!self.dirty()) return;
-
- if (!hasRecognizedSaveExtension(self.path)) return;
-
- const ext = std.fs.path.extension(self.path);
-
- if (isFizzyExtension(ext)) {
- // Flip the in-flight flag here on the GUI thread, before submitting to
- // the save queue. Otherwise `Editor.tickPendingSaveCloses` can run on the
- // next GUI frame, observe `isSaving() == false` (worker hasn't dequeued
- // yet), and close the file before the worker reads it.
- if (self.isSaving()) return;
- self.setSaving(true);
-
- // Snapshot all save-relevant data on the GUI thread NOW, before the worker
- // could observe a torn `self.layers` (the user can still draw / add layers
- // while the async save runs). Worker reads only the snapshot.
- const snap_ptr = fizzy.app.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);
- self.setSaving(false);
- return err;
- };
-
- // Hand off to the single dedicated save-queue worker. Serializing all
- // .fiz writes through one thread (instead of spawning a per-save thread)
- // avoids worker-vs-worker contention on allocator / zip lib / dvui state
- // that previously wedged one of N concurrent saves indefinitely.
- //
- // We submit by file id rather than by `*File` pointer: the editor's
- // `open_files` AutoArrayHashMap shifts inline values on `orderedRemove`,
- // so a stored pointer would silently start aliasing a different file the
- // moment any earlier file in the queue completes and gets closed.
- save_queue.submit(.{
- .file_id = self.id,
- .window = dvui.currentWindow(),
- .snap = snap_ptr,
- }) catch |err| {
- snap_ptr.deinit(fizzy.app.allocator);
- fizzy.app.allocator.destroy(snap_ptr);
- self.setSaving(false);
- return err;
- };
- } else if (std.mem.eql(u8, ext, ".png")) {
- // `writeFlattenedLayersToPath` uses `syncLayerComposite` + `readTarget` (GPU); must run on the GUI thread.
- try savePng(self, dvui.currentWindow());
- } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
- try saveJpg(self, dvui.currentWindow());
- }
-}
-
-pub fn external(self: File, allocator: std.mem.Allocator) !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);
-
- for (layers, 0..) |*working_layer, i| {
- working_layer.name = try allocator.dupe(u8, self.layers.items(.name)[i]);
- working_layer.visible = self.layers.items(.visible)[i];
- working_layer.collapse = self.layers.items(.collapse)[i];
- }
-
- for (sprites, 0..) |*sprite, i| {
- sprite.origin = self.sprites.items(.origin)[i];
- }
-
- for (animations, 0..) |*animation, i| {
- animation.name = try allocator.dupe(u8, self.animations.items(.name)[i]);
- animation.frames = try allocator.dupe(Animation.Frame, self.animations.items(.frames)[i]);
- }
-
- return .{
- .version = fizzy.version,
- .columns = self.columns,
- .rows = self.rows,
- .column_width = self.column_width,
- .row_height = self.row_height,
- .layers = layers,
- .sprites = sprites,
- .animations = animations,
- };
-}
diff --git a/src/internal/History.zig b/src/internal/History.zig
deleted file mode 100644
index dc0efa2c..00000000
--- a/src/internal/History.zig
+++ /dev/null
@@ -1,964 +0,0 @@
-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");
-
-pub const Action = enum { undo, redo };
-pub const RestoreDelete = enum { restore, delete };
-pub const ChangeType = enum {
- pixels,
- origins,
- animation_name,
- animation_frames,
- animation_settings,
- animation_order,
- animation_restore_delete,
- layers_order,
- layer_restore_delete,
- layer_name,
- layer_settings,
- resize,
- reorder_col_row,
- reorder_cell,
- layer_merge,
- grid_layout,
-};
-
-pub const Change = union(ChangeType) {
- pub const Pixels = struct {
- layer_id: u64,
- indices: []usize,
- values: [][4]u8,
- temporary: bool = false,
- };
-
- pub const Origins = struct {
- indices: []usize,
- values: [][2]f32,
- };
-
- pub const AnimationName = struct {
- index: usize,
- name: []u8,
- };
-
- pub const AnimationSettings = struct {
- index: usize,
- fps: f32,
- };
-
- pub const AnimationOrder = struct {
- order: []u64,
- selected: usize,
- };
-
- pub const AnimationFrames = struct {
- index: usize,
- frames: []fizzy.Animation.Frame,
- };
-
- pub const AnimationRestoreDelete = struct {
- index: usize,
- action: RestoreDelete,
- };
-
- pub const LayersOrder = struct {
- order: []u64,
- selected: usize,
- };
-
- pub const LayerRestoreDelete = struct {
- index: usize,
- action: RestoreDelete,
- };
-
- pub const LayerMerge = struct {
- pub const Kind = enum { up, down };
-
- kind: Kind,
- /// Index of the merged-away layer before removal.
- source_index: usize,
- dest_layer_id: u64,
- source_layer_id: u64,
- dest_pixels_before: [][4]u8,
- dest_mask_before: std.DynamicBitSet,
- };
- pub const LayerName = struct {
- index: usize,
- name: []u8,
- };
- pub const LayerSettings = struct {
- index: usize,
- visible: bool,
- collapse: bool,
- };
-
- pub const Resize = struct {
- width: u32,
- height: u32,
- };
-
- pub const ColumnRowReorder = struct {
- pub const Mode = enum {
- columns,
- rows,
- };
-
- mode: Mode,
- removed_index: usize,
- insert_before_index: usize,
- };
-
- pub const CellReorder = struct {
- removed_sprite_indices: []usize,
- insert_before_sprite_indices: []usize,
- };
-
- /// Snapshot of all state that `File.applyGridLayout` mutates. Stored on the undo/redo stacks
- /// as the *previous* full state; `undoRedo` swaps the snapshot with the live file state to
- /// move forward or back. All slices are owned by the snapshot and freed in `deinit`.
- pub const GridLayout = struct {
- column_width: u32,
- row_height: u32,
- columns: u32,
- rows: u32,
-
- /// Layer ids in the order layers existed when the snapshot was captured. Pixel buffers are
- /// matched to live layers by id, not index, so layer-list reorderings between snapshots
- /// don't corrupt the restore.
- layer_ids: []u64,
- /// One full pixel buffer per id in `layer_ids`, sized `column_width * columns * row_height * rows`.
- layer_pixels: [][][4]u8,
-
- sprite_origins: [][2]f32,
-
- selected_animation_index: ?usize,
- selected_animation_frame_index: usize,
- selected_layer_index: usize,
- };
-
- pixels: Pixels,
- origins: Origins,
- animation_name: AnimationName,
- animation_frames: AnimationFrames,
- animation_settings: AnimationSettings,
- animation_order: AnimationOrder,
- animation_restore_delete: AnimationRestoreDelete,
- layers_order: LayersOrder,
- layer_restore_delete: LayerRestoreDelete,
- layer_name: LayerName,
- layer_settings: LayerSettings,
- resize: Resize,
- reorder_col_row: ColumnRowReorder,
- reorder_cell: CellReorder,
- layer_merge: LayerMerge,
- grid_layout: GridLayout,
-
- pub fn create(allocator: std.mem.Allocator, field: ChangeType, len: usize) !Change {
- return switch (field) {
- .pixels => .{
- .pixels = .{
- .layer_id = 0,
- .indices = try allocator.alloc(usize, len),
- .values = try allocator.alloc([4]u8, len),
- .temporary = false,
- },
- },
- .origins => .{
- .origins = .{
- .indices = try allocator.alloc(usize, len),
- .values = try allocator.alloc([2]f32, len),
- },
- },
- .animation => .{
- .animation = .{
- .index = 0,
- .name = undefined,
- .fps = 1,
- .start = 0,
- .length = 1,
- },
- },
- .layers_order => .{ .layers_order = .{
- .order = try allocator.alloc(usize, len),
- .selected = 0,
- } },
- .layer_name => .{ .animation_name = .{
- .name = [_:0]u8{0} ** Editor.Constants.max_name_len,
- .index = 0,
- } },
- else => error.NotSupported,
- };
- }
-
- pub fn deinit(self: *Change) void {
- switch (self.*) {
- .pixels => |*pixels| {
- fizzy.app.allocator.free(pixels.indices);
- fizzy.app.allocator.free(pixels.values);
- },
- .origins => |*origins| {
- fizzy.app.allocator.free(origins.indices);
- fizzy.app.allocator.free(origins.values);
- },
- .layers_order => |*layers_order| {
- fizzy.app.allocator.free(layers_order.order);
- },
- .layer_merge => |*layer_merge| {
- fizzy.app.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);
- },
- else => {},
- }
- }
-};
-
-bookmark: i32 = 0,
-undo_stack: std.array_list.Managed(Change),
-redo_stack: std.array_list.Managed(Change),
-
-undo_layer_data_stack: std.array_list.Managed([][][4]u8),
-redo_layer_data_stack: std.array_list.Managed([][][4]u8),
-
-undo_animation_data_stack: std.array_list.Managed([][]fizzy.Animation.Frame),
-redo_animation_data_stack: std.array_list.Managed([][]fizzy.Animation.Frame),
-
-undo_sprite_data_stack: std.array_list.Managed([][2]f32),
-redo_sprite_data_stack: std.array_list.Managed([][2]f32),
-
-pub fn init(allocator: std.mem.Allocator) History {
- return .{
- .undo_stack = std.array_list.Managed(Change).init(allocator),
- .redo_stack = std.array_list.Managed(Change).init(allocator),
-
- .undo_layer_data_stack = std.array_list.Managed([][][4]u8).init(allocator),
- .redo_layer_data_stack = std.array_list.Managed([][][4]u8).init(allocator),
-
- .undo_animation_data_stack = std.array_list.Managed([][]fizzy.Animation.Frame).init(allocator),
- .redo_animation_data_stack = std.array_list.Managed([][]fizzy.Animation.Frame).init(allocator),
-
- .undo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator),
- .redo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator),
- };
-}
-
-pub fn append(self: *History, change: Change) !void {
- const track_pixels = fizzy.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;
-
- if (self.redo_stack.items.len > 0) {
- for (self.redo_stack.items) |*c| {
- Change.deinit(c);
- }
- self.redo_stack.clearRetainingCapacity();
- }
-
- if (self.redo_layer_data_stack.items.len > 0) {
- for (self.redo_layer_data_stack.items) |data| {
- for (data) |layer| {
- fizzy.app.allocator.free(layer);
- }
- fizzy.app.allocator.free(data);
- }
- self.redo_layer_data_stack.clearRetainingCapacity();
- }
-
- // Equality check, don't append if equal
- var equal: bool = self.undo_stack.items.len > 0;
- if (self.undo_stack.getLastOrNull()) |last| {
- const last_active_tag = std.meta.activeTag(last);
- const change_active_tag = std.meta.activeTag(change);
-
- if (last_active_tag == change_active_tag) {
- switch (last) {
- .origins => |origins| {
- if (std.mem.eql(usize, origins.indices, change.origins.indices)) {
- for (origins.values, 0..) |value, i| {
- if (!std.mem.eql(f32, &value, &change.origins.values[i])) {
- equal = false;
- break;
- }
- }
- } else {
- equal = false;
- }
- },
- .pixels => |pixels| {
- equal = pixels.layer_id == change.pixels.layer_id;
- if (equal) {
- equal = std.mem.eql(usize, pixels.indices, change.pixels.indices);
- }
- if (equal) {
- for (pixels.values, 0..) |value, i| {
- equal = std.mem.eql(u8, &value, &change.pixels.values[i]);
- if (!equal) break;
- }
- }
- },
- .animation_name => {
- equal = false;
- },
- .animation_frames => {
- equal = false;
- },
- .animation_settings => {
- equal = false;
- },
- .animation_order => {
- equal = false;
- },
- .animation_restore_delete => {
- equal = false;
- },
- .layers_order => {
- equal = false;
- },
- .layer_restore_delete => {
- equal = false;
- },
- .layer_name => {
- equal = false;
- },
- .layer_settings => {
- equal = false;
- },
- .resize => {
- equal = false;
- },
- .reorder_col_row => {
- equal = false;
- },
- .reorder_cell => {
- equal = false;
- },
- .layer_merge => {
- equal = false;
- },
- .grid_layout => {
- equal = false;
- },
- }
- } else equal = false;
- }
-
- if (equal) {
- var discard = change;
- Change.deinit(&discard);
- } else {
- try self.undo_stack.append(change);
- self.bookmark += 1;
- }
-
- if (track_pixels and t_hist != 0) {
- 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;
- }
-}
-
-fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
- const dest_i = for (file.layers.items(.id), 0..) |id, i| {
- if (id == lm.dest_layer_id) break i;
- } else return error.InvalidLayerMerge;
-
- var dest = file.layers.get(dest_i);
- @memcpy(dest.pixels(), lm.dest_pixels_before);
- dest.mask.deinit();
- dest.mask = try lm.dest_mask_before.clone(fizzy.app.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);
-
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
- file.selected_layer_index = lm.source_index;
- fizzy.editor.explorer.pane = .tools;
- file.invalidateActiveLayerTransparencyMaskCache();
-}
-
-fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
- const src_i = for (file.layers.items(.id), 0..) |id, i| {
- if (id == lm.source_layer_id) break i;
- } else return error.InvalidLayerMerge;
- const dest_i = for (file.layers.items(.id), 0..) |id, i| {
- if (id == lm.dest_layer_id) break i;
- } else return error.InvalidLayerMerge;
-
- switch (lm.kind) {
- .up => if (dest_i + 1 != src_i) return error.InvalidLayerMerge,
- .down => if (src_i + 1 != dest_i) return error.InvalidLayerMerge,
- }
-
- var dest = file.layers.get(dest_i);
- const src = file.layers.get(src_i);
-
- for (0..dest.pixels().len) |i| {
- const dpx = dest.pixels()[i];
- const spx = src.pixels()[i];
- dest.pixels()[i] = switch (lm.kind) {
- .up => Layer.blendPmaSrcOver(dpx, spx),
- .down => Layer.blendPmaSrcOver(spx, dpx),
- };
- }
- dest.mask.setUnion(src.mask);
- dest.invalidate();
- file.layers.set(dest_i, dest);
-
- try file.deleted_layers.append(fizzy.app.allocator, file.layers.slice().get(src_i));
- file.layers.orderedRemove(src_i);
-
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
-
- file.selected_layer_index = switch (lm.kind) {
- .up => dest_i,
- .down => dest_i - 1,
- };
- fizzy.editor.explorer.pane = .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 {
- var active_stack = switch (action) {
- .undo => &self.undo_stack,
- .redo => &self.redo_stack,
- };
-
- var other_stack = switch (action) {
- .undo => &self.redo_stack,
- .redo => &self.undo_stack,
- };
-
- if (active_stack.items.len == 0) return;
-
- var temporary: bool = false;
-
- // Modify this change before its put into the other stack.
- var change = active_stack.pop().?;
-
- defer {
- // Microseconds-since-epoch (~1.7e15) overflows a 32-bit `usize` on wasm32, so a
- // direct `@intCast` to `usize` crashes the safe-mode build with an "integer cast
- // truncates value" panic every time the user undoes/redoes. `id_extra` only needs
- // to be a salt that varies between toasts, so truncate via u128 → low bits of usize.
- const ts_us: u128 = @intCast(@divTrunc(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 id = id_mutex.id;
- const action_text = switch (action) {
- .undo => "Undo:",
- .redo => "Redo:",
- };
- var message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{action_text}) catch "Invalid change";
-
- switch (change) {
- .pixels => |*pixels| {
- for (file.layers.items(.name), file.layers.items(.id)) |name, layer_id| {
- if (layer_id == pixels.layer_id) {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} pixels modified", .{ action_text, name }) catch "Invalid change";
- break;
- }
- }
- },
- .origins => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Sprite origins modified", .{action_text}) catch "Invalid change";
- },
- .animation_name => |*animation_name| {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation name {s} -> {s}", .{
- action_text,
- animation_name.name,
- file.animations.items(.name)[animation_name.index],
- }) catch "Invalid change";
- },
- .animation_frames => |*animation_frames| {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} frames modified", .{
- action_text,
- file.animations.items(.name)[animation_frames.index],
- }) catch "Invalid change";
- },
- .animation_settings => |*animation_settings| {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} settings modified", .{
- action_text,
- file.animations.items(.name)[animation_settings.index],
- }) catch "Invalid change";
- },
- .animation_order => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animations order modified", .{action_text}) catch "Invalid change";
- },
- .animation_restore_delete => |*animation_restore_delete| {
- switch (animation_restore_delete.action) {
- .restore => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} deleted", .{
- action_text,
- file.deleted_animations.items(.name)[file.deleted_animations.len - 1],
- }) catch "Invalid change";
- },
- .delete => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} created", .{
- action_text,
- file.animations.items(.name)[animation_restore_delete.index],
- }) catch "Invalid change";
- },
- }
- },
- .layers_order => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layers order modified", .{action_text}) catch "Invalid change";
- },
- .layer_restore_delete => |*layer_restore_delete| {
- switch (layer_restore_delete.action) {
- .restore => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} deleted", .{
- action_text,
- file.deleted_layers.items(.name)[file.deleted_layers.len - 1],
- }) catch "Invalid change";
- },
- .delete => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} created", .{
- action_text,
- file.layers.items(.name)[layer_restore_delete.index],
- }) catch "Invalid change";
- },
- }
- },
- .layer_name => |*layer_name| {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer name {s} -> {s}", .{
- action_text,
- layer_name.name,
- file.layers.items(.name)[layer_name.index],
- }) catch "Invalid change";
- },
- .layer_settings => |*layer_settings| {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} settings modified", .{
- action_text,
- file.layers.items(.name)[layer_settings.index],
- }) catch "Invalid change";
- },
- .resize => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} File resized to {d}x{d}", .{
- action_text,
- file.width(),
- file.height(),
- }) catch "Invalid change";
- },
- .reorder_col_row => |*reorder| {
- const removed = reorder.removed_index;
- const insert_before = reorder.insert_before_index;
- switch (reorder.mode) {
- .columns => {
- const removed_column_name = file.fmtColumn(dvui.currentWindow().arena(), removed) catch "Invalid change";
- const insert_before_column_name = file.fmtColumn(dvui.currentWindow().arena(), insert_before - 1) catch "Invalid change";
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Column {s} moved to {s}", .{ action_text, insert_before_column_name, removed_column_name }) catch "Invalid change";
- },
- .rows => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Row {d} moved to {d}", .{ action_text, insert_before, removed }) catch "Invalid change";
- },
- }
- },
- .reorder_cell => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Cells reordered", .{action_text}) catch "Invalid change";
- },
- .layer_merge => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer merge", .{action_text}) catch "Invalid change";
- },
- .grid_layout => {
- message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Grid layout {d}×{d} cells of {d}×{d}", .{
- action_text,
- file.columns,
- file.rows,
- file.column_width,
- file.row_height,
- }) catch "Invalid change";
- },
- }
-
- dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message);
- id_mutex.mutex.unlock(dvui.io);
- }
-
- switch (change) {
- .pixels => |*pixels| {
- if (pixels.temporary) temporary = true;
-
- const layer_index = for (file.layers.slice().items(.id), 0..) |layer_id, i| {
- if (layer_id == pixels.layer_id) break i;
- } else 0;
-
- var layer = file.layers.slice().get(layer_index);
-
- for (pixels.indices, 0..) |pixel_index, i| {
- std.mem.swap([4]u8, &pixels.values[i], &layer.pixels()[pixel_index]);
- }
-
- layer.invalidate();
- file.selected_layer_index = layer_index;
- file.invalidateActiveLayerTransparencyMaskCache();
- },
- .origins => |*origins| {
- //file.editor.selected_sprites.clearAndFree();
- for (origins.indices, 0..) |sprite_index, i| {
- const origin = origins.values[i];
- origins.values[i] = file.sprites.items(.origin)[sprite_index];
- file.sprites.items(.origin)[sprite_index] = origin;
-
- //try file.editor.selected_sprites.append(sprite_index);
- }
- fizzy.editor.explorer.pane = .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);
- for (0..file.layers.len) |layer_index| {
- new_order[layer_index] = file.layers.items(.id)[layer_index];
- }
-
- const slice = file.layers.slice();
-
- for (layers_order.order, 0..) |id, i| {
- if (slice.items(.id)[i] == id) continue;
-
- // Save current layer
- const current_layer = slice.get(i);
- layers_order.order[i] = current_layer.id;
-
- // Make changes to the layers
- var other_layer_index: usize = 0;
- while (other_layer_index < file.layers.len) : (other_layer_index += 1) {
- const layer = slice.get(other_layer_index);
- if (layer.id == layers_order.selected) {
- file.selected_layer_index = other_layer_index;
- }
- if (layer.id == id) {
- file.layers.set(i, layer);
- file.layers.set(other_layer_index, current_layer);
- continue;
- }
- }
- }
-
- @memcpy(layers_order.order, new_order);
- fizzy.app.allocator.free(new_order);
- file.invalidateActiveLayerTransparencyMaskCache();
- },
- .layer_restore_delete => |*layer_restore_delete| {
- file.editor.layer_composite_dirty = true;
- file.editor.split_composite_dirty = true;
- const a = layer_restore_delete.action;
- switch (a) {
- .restore => {
- try file.layers.insert(fizzy.app.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));
- file.layers.orderedRemove(layer_restore_delete.index);
- layer_restore_delete.action = .restore;
- },
- }
- fizzy.editor.explorer.pane = .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);
- layer_name.name = name;
- fizzy.editor.explorer.pane = .tools;
- },
- .layer_settings => |*layer_settings| {
- const idx = layer_settings.index;
- const cur_visible = file.layers.items(.visible)[idx];
- const cur_collapse = file.layers.items(.collapse)[idx];
- const incoming_visible = layer_settings.visible;
- const visibility_changed = cur_visible != incoming_visible;
-
- file.layers.items(.visible)[idx] = incoming_visible;
- file.layers.items(.collapse)[idx] = layer_settings.collapse;
- layer_settings.visible = cur_visible;
- layer_settings.collapse = cur_collapse;
-
- // Split composites only depend on layer visibility, not row collapse in the layer list.
- file.editor.layer_composite_dirty = true;
- if (visibility_changed) {
- file.editor.split_composite_dirty = true;
- }
- fizzy.editor.explorer.pane = .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);
- 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);
- animation_restore_delete.action = .restore;
-
- if (file.selected_animation_index) |selected_animation_index| {
- if (file.animations.len == 0) {
- file.selected_animation_index = null;
- } else if (selected_animation_index >= file.animations.len) {
- file.selected_animation_index = file.animations.len - 1;
- }
- }
- },
- }
- fizzy.editor.explorer.pane = .sprites;
- },
- .animation_name => |*animation_name| {
- const name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[animation_name.index]);
- fizzy.app.allocator.free(file.animations.items(.name)[animation_name.index]);
- file.animations.items(.name)[animation_name.index] = try fizzy.app.allocator.dupe(u8, animation_name.name);
- animation_name.name = name;
- fizzy.editor.explorer.pane = .sprites;
- },
- .animation_settings => {},
- .animation_order => |*animation_order| {
- // `new_order` holds animation ids (u64), matching `animation_order.order: []u64`.
- var new_order = try dvui.currentWindow().arena().alloc(u64, animation_order.order.len);
- for (0..file.animations.len) |anim_index| {
- new_order[anim_index] = file.animations.items(.id)[anim_index];
- }
-
- for (animation_order.order, 0..) |id, i| {
- // Save current animation
- const current_animation = file.animations.get(i);
- animation_order.order[i] = current_animation.id;
-
- // Make changes to the animations
- var other_animation_index: usize = 0;
- while (other_animation_index < file.animations.len) : (other_animation_index += 1) {
- const animation = file.animations.get(other_animation_index);
- if (animation.id == animation_order.selected) {
- file.selected_animation_index = other_animation_index;
- }
- if (animation.id == id and current_animation.id != id) {
- file.animations.set(i, animation);
- file.animations.set(other_animation_index, current_animation);
- continue;
- }
- }
- }
-
- @memcpy(animation_order.order, new_order);
-
- file.selected_animation_index = animation_order.selected;
- },
- .animation_frames => |*animation_frames| {
- const history_frames = &animation_frames.frames;
- const current_frames = &file.animations.items(.frames)[animation_frames.index];
-
- std.mem.swap([]fizzy.Animation.Frame, history_frames, current_frames);
-
- file.selected_animation_index = animation_frames.index;
- },
- .resize => |*resize| {
- const new_size_wide = resize.width;
- const new_size_high = resize.height;
- resize.width = file.width();
- resize.height = file.height();
-
- var layer_data: ?[][][4]u8 = null;
- var animation_data: ?[][]fizzy.Animation.Frame = null;
- var sprite_data: ?[][2]f32 = null;
-
- switch (action) {
- .undo => {
- if (self.undo_layer_data_stack.pop()) |ld| {
- try self.redo_layer_data_stack.append(ld);
- layer_data = ld;
- }
-
- if (self.undo_animation_data_stack.pop()) |ad| {
- animation_data = ad;
-
- var anim_data = try fizzy.app.allocator.alloc([]fizzy.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;
- }
- try self.redo_animation_data_stack.append(anim_data);
- }
-
- if (self.undo_sprite_data_stack.pop()) |sd| {
- sprite_data = sd;
-
- const new_sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount());
- for (0..file.spriteCount()) |sprite_index| {
- new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index];
- }
- try self.redo_sprite_data_stack.append(new_sprite_data);
- }
- },
- .redo => {
- if (self.redo_layer_data_stack.pop()) |ld| {
- try self.undo_layer_data_stack.append(ld);
- layer_data = ld;
- }
- if (self.redo_animation_data_stack.pop()) |ad| {
- animation_data = ad;
-
- var anim_data = try fizzy.app.allocator.alloc([]fizzy.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;
- }
- 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());
- for (0..file.spriteCount()) |sprite_index| {
- new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index];
- }
- try self.undo_sprite_data_stack.append(new_sprite_data);
- }
- },
- }
-
- file.resize(.{
- .columns = @divTrunc(new_size_wide, file.column_width),
- .rows = @divTrunc(new_size_high, file.row_height),
- .history = false,
- .layer_data = layer_data,
- .animation_data = animation_data,
- .sprite_data = sprite_data,
- }) catch return error.ResizeError;
-
- if (animation_data) |ad| {
- fizzy.app.allocator.free(ad);
- }
-
- if (sprite_data) |sd| {
- fizzy.app.allocator.free(sd);
- }
-
- file.invalidateActiveLayerTransparencyMaskCache();
- },
- .reorder_col_row => |*reorder| {
- switch (reorder.mode) {
- .columns => file.reorderColumns(reorder.removed_index, reorder.insert_before_index) catch return error.ReorderError,
- .rows => file.reorderRows(reorder.removed_index, reorder.insert_before_index) catch return error.ReorderError,
- }
-
- const prev_removed_index = reorder.removed_index;
- const prev_insert_before_index = reorder.insert_before_index;
-
- if (prev_removed_index < prev_insert_before_index) {
- // Column was removed before the insert position, so it "shifts left" after being inserted.
- reorder.removed_index = prev_insert_before_index - 1;
- reorder.insert_before_index = prev_removed_index;
- } else {
- reorder.removed_index = prev_insert_before_index;
- reorder.insert_before_index = prev_removed_index + 1;
- }
-
- file.invalidateActiveLayerTransparencyMaskCache();
- },
-
- .reorder_cell => |*reorder| {
- const reverse = (action == .undo);
- file.reorderCells(reorder.removed_sprite_indices, reorder.insert_before_sprite_indices, .replace, reverse) catch return error.ReorderError;
- file.invalidateActiveLayerTransparencyMaskCache();
- },
- .layer_merge => |*layer_merge| {
- switch (action) {
- .undo => try layerMergeUndo(file, layer_merge),
- .redo => try layerMergeRedo(file, layer_merge),
- }
- },
- .grid_layout => |*gl| {
- // Symmetric swap: capture live state into a fresh snapshot, restore the popped one
- // into the file, then put the fresh snapshot in place of the popped one (which is
- // now redundant — its data lives in the file again). The fresh snapshot rides the
- // normal `append` below to the opposite stack.
- const fresh = try file.captureGridLayoutSnapshot();
- file.applyGridLayoutSnapshot(gl.*) catch |err| {
- var fresh_ch = Change{ .grid_layout = fresh };
- Change.deinit(&fresh_ch);
- return err;
- };
- var old_ch = Change{ .grid_layout = gl.* };
- Change.deinit(&old_ch);
- gl.* = fresh;
- },
- }
-
- if (!temporary) {
- try other_stack.append(change);
- } else {
- var discard = change;
- Change.deinit(&discard);
- }
-
- self.bookmark += switch (action) {
- .undo => -1,
- .redo => 1,
- };
-}
-
-pub fn clearAndFree(self: *History) void {
- for (self.undo_stack.items) |*u| {
- Change.deinit(u);
- }
- for (self.redo_stack.items) |*r| {
- Change.deinit(r);
- }
- self.undo_stack.clearAndFree();
- self.redo_stack.clearAndFree();
-}
-
-pub fn clearRetainingCapacity(self: *History) void {
- for (self.undo_stack.items) |*u| {
- Change.deinit(u);
- }
- for (self.redo_stack.items) |*r| {
- Change.deinit(r);
- }
- self.undo_stack.clearRetainingCapacity();
- self.redo_stack.clearRetainingCapacity();
-}
-
-pub fn deinit(self: *History) void {
- for (self.undo_layer_data_stack.items) |data| {
- for (data) |layer| {
- fizzy.app.allocator.free(layer);
- }
- fizzy.app.allocator.free(data);
- }
-
- for (self.redo_layer_data_stack.items) |data| {
- for (data) |layer| {
- fizzy.app.allocator.free(layer);
- }
- fizzy.app.allocator.free(data);
- }
-
- self.undo_layer_data_stack.deinit();
- self.redo_layer_data_stack.deinit();
- self.clearAndFree();
- self.undo_stack.deinit();
- self.redo_stack.deinit();
-}
diff --git a/src/internal/Layer.zig b/src/internal/Layer.zig
deleted file mode 100644
index 73816b93..00000000
--- a/src/internal/Layer.zig
+++ /dev/null
@@ -1,480 +0,0 @@
-const std = @import("std");
-const dvui = @import("dvui");
-const fizzy = @import("../fizzy.zig");
-const zip = @import("zip");
-
-const Layer = @This();
-
-/// Bit count for layer masks must match `pixels(source).len`. Do not use `imageSize` float
-/// products: `s.w * s.h` loses integer precision once the true pixel count exceeds ~2^24.
-fn pixelCountForSource(source: dvui.ImageSource) usize {
- return switch (source) {
- .pixelsPMA => |p| @as(usize, @intCast(p.width)) * @as(usize, @intCast(p.height)),
- .pixels => |p| @as(usize, @intCast(p.width)) * @as(usize, @intCast(p.height)),
- .texture => |t| @as(usize, @intCast(t.width)) * @as(usize, @intCast(t.height)),
- .imageFile => |f| blk: {
- var w: c_int = undefined;
- var h: c_int = undefined;
- var n: c_int = undefined;
- if (dvui.c.stbi_info_from_memory(f.bytes.ptr, @as(c_int, @intCast(f.bytes.len)), &w, &h, &n) != 1)
- break :blk 0;
- break :blk @as(usize, @intCast(w)) * @as(usize, @intCast(h));
- },
- };
-}
-
-id: u64,
-name: []const u8,
-source: dvui.ImageSource,
-mask: std.DynamicBitSet,
-visible: bool = true,
-collapse: bool = false,
-dirty: bool = false,
-
-pub fn init(id: u64, name: []const u8, width: u32, height: u32, default_color: dvui.Color, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
- const num_pixels = width * height;
- const p = fizzy.app.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,
- .source = .{
- .pixelsPMA = .{
- .rgba = @ptrCast(p),
- .width = width,
- .height = height,
- .interpolation = .nearest,
- .invalidation = invalidation,
- },
- },
- .mask = std.DynamicBitSet.initEmpty(fizzy.app.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;
-
- return .{
- .id = id,
- .name = fizzy.app.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;
-
- return .{
- .id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
- .source = source,
- .mask = mask,
- };
-}
-
-pub fn fromPixelsPMA(id: u64, name: []const u8, pixel_data: []dvui.Color.PMA, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
- if (pixel_data.len != width * height) return error.InvalidPixelDataLength;
- const source = 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;
-
- return .{
- .id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
- .source = source,
- .mask = mask,
- };
-}
-
-pub fn fromPixels(id: u64, name: []const u8, pixel_data: []u8, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
- if (pixel_data.len != width * height) return error.InvalidPixelDataLength;
- const source = 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;
-
- return .{
- .id = id,
- .name = fizzy.app.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;
-
- return .{
- .id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
- .source = source,
- .mask = mask,
- };
-}
-
-pub fn size(self: Layer) dvui.Size {
- return dvui.imageSize(self.source) catch .{ .w = 0, .h = 0 };
-}
-
-pub fn deinit(self: *Layer) void {
- switch (self.source) {
- .imageFile => |image| fizzy.app.allocator.free(image.bytes),
- .pixels => |p| fizzy.app.allocator.free(p.rgba),
- .pixelsPMA => |p| fizzy.app.allocator.free(p.rgba),
- .texture => |t| dvui.textureDestroyLater(t),
- }
-
- fizzy.app.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);
-}
-
-/// 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);
-}
-
-/// Casts the source pixels into a slice of bytes
-pub fn bytes(self: *const Layer) []u8 {
- return fizzy.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);
-}
-
-/// 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);
-}
-
-/// 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);
-}
-
-/// 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);
-}
-
-/// Sets the mask at the given point
-pub fn setMaskPoint(self: *Layer, p: dvui.Point) void {
- if (self.pixelIndex(p)) |index| {
- self.mask.set(index);
- }
-}
-
-/// Clears the layer mask
-pub fn clearMask(self: *Layer) void {
- self.mask.setRangeValue(.{ .start = 0, .end = self.mask.capacity() }, false);
-}
-
-/// Sets all pixels in the mask that match the given color
-pub fn setMaskFromColor(self: *Layer, color: dvui.Color, value: bool) void {
- const test_color: [4]u8 = color.toRGBA();
- for (self.pixels(), 0..) |*p, index| {
- if (std.meta.eql(test_color, p.*)) {
- self.mask.setValue(index, value);
- }
- }
-}
-
-/// Sets all pixels in the mask that are not transparent
-pub fn setMaskFromTransparency(self: *Layer, value: bool) void {
- const pix_n = self.pixels().len;
- if (self.mask.capacity() != pix_n) {
- self.mask.resize(pix_n, false) catch return;
- }
- for (self.pixels(), 0..) |*p, index| {
- if (p[3] != 0) {
- self.mask.setValue(index, value);
- }
- }
-}
-
-/// Sets all pixels in the layer that are in the mask to the given color
-pub fn setColorFromMask(self: *Layer, color: dvui.Color) void {
- var iter = self.mask.iterator(.{ .kind = .set, .direction = .forward });
- while (iter.next()) |index| {
- self.pixels()[index] = color.toRGBA();
- }
-}
-
-/// Flood fill a pixel and mark the flood to the mask, so you can handle changes.
-pub fn floodMaskPoint(layer: *Layer, p: dvui.Point, bounds: dvui.Rect, value: bool) !void {
- if (!bounds.contains(p)) return;
-
- var queue = std.array_list.Managed(dvui.Point).init(fizzy.app.allocator);
- defer queue.deinit();
- queue.append(p) catch return error.MemoryAllocationFailed;
-
- const directions: [4]dvui.Point = .{
- .{ .x = 0, .y = -1 },
- .{ .x = 0, .y = 1 },
- .{ .x = -1, .y = 0 },
- .{ .x = 1, .y = 0 },
- };
-
- if (layer.pixelIndex(p)) |index| {
- layer.mask.setValue(index, value);
- const original_color = layer.pixels()[index];
-
- while (queue.pop()) |qp| {
- for (directions) |direction| {
- const new_point = qp.plus(direction);
- if (layer.pixelIndex(new_point)) |iter_index| {
- if (layer.mask.isSet(iter_index)) continue;
- if (!std.meta.eql(original_color, layer.pixels()[iter_index])) continue;
- if (!bounds.contains(new_point)) continue;
-
- queue.append(new_point) catch return error.MemoryAllocationFailed;
- layer.mask.setValue(iter_index, value);
- }
- }
- }
- }
-}
-
-pub fn setPixelIndex(self: *Layer, index: usize, color: [4]u8) void {
- fizzy.image.setPixelIndex(self.source, index, color);
-}
-
-pub const ShapeOffsetResult = struct {
- index: usize,
- color: [4]u8,
- point: dvui.Point,
-};
-
-pub fn invalidate(self: *Layer) void {
- dvui.textureInvalidateCache(self.source.hash());
- self.dirty = false;
-}
-
-/// Only used for handling getting the pixels surrounding the origin
-/// for stroke sizes larger than 1
-pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usize) ?ShapeOffsetResult {
- const shape = fizzy.editor.tools.stroke_shape;
- const s: i32 = @intCast(fizzy.editor.tools.stroke_size);
-
- if (s == 1) {
- if (current_index != 0)
- return null;
-
- if (self.pixelIndex(origin)) |index| {
- return .{
- .index = index,
- .color = self.pixels()[index],
- .point = origin,
- };
- }
- }
-
- const size_center_offset: i32 = -@divFloor(@as(i32, @intCast(s)), 2);
- const index_i32: i32 = @as(i32, @intCast(current_index));
- const pixel_offset: [2]i32 = .{ @mod(index_i32, s) + size_center_offset, @divFloor(index_i32, s) + size_center_offset };
-
- if (shape == .circle) {
- const extra_pixel_offset_circle: [2]i32 = if (@mod(s, 2) == 0) .{ 1, 1 } else .{ 0, 0 };
- const pixel_offset_circle: [2]i32 = .{ pixel_offset[0] * 2 + extra_pixel_offset_circle[0], pixel_offset[1] * 2 + extra_pixel_offset_circle[1] };
- const sqr_magnitude = pixel_offset_circle[0] * pixel_offset_circle[0] + pixel_offset_circle[1] * pixel_offset_circle[1];
-
- // adjust radius check for nicer looking circles
- const radius_check_mult: f32 = (if (s == 3 or s > 10) 0.7 else 0.8);
-
- if (@as(f32, @floatFromInt(sqr_magnitude)) > @as(f32, @floatFromInt(s * s)) * radius_check_mult) {
- return null;
- }
- }
-
- const pixel_i32: [2]i32 = .{ @as(i32, @intFromFloat(origin.x)) + pixel_offset[0], @as(i32, @intFromFloat(origin.y)) + pixel_offset[1] };
- const size_i32: [2]i32 = .{ @as(i32, @intFromFloat(self.size().w)), @as(i32, @intFromFloat(self.size().h)) };
-
- if (pixel_i32[0] < 0 or pixel_i32[1] < 0 or pixel_i32[0] >= size_i32[0] or pixel_i32[1] >= size_i32[1]) {
- return null;
- }
-
- const p: dvui.Point = .{ .x = @floatFromInt(pixel_i32[0]), .y = @floatFromInt(pixel_i32[1]) };
-
- if (self.pixelIndex(p)) |index| {
- return .{
- .index = index,
- .color = self.pixels()[index],
- .point = p,
- };
- }
-
- return null;
-}
-
-/// Porter–Duff "source over" for premultiplied RGBA (`pixelsPMA` byte layout).
-/// `top` is composited over `bottom`.
-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 fn clearRect(self: *Layer, rect: dvui.Rect) void {
- fizzy.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);
- self.invalidate();
-}
-
-pub const BlitOptions = struct {
- transparent: bool = true,
- mask: bool = false,
-};
-
-pub fn blit(self: *Layer, src_pixels: [][4]u8, dst_rect: dvui.Rect, options: BlitOptions) void {
- if (src_pixels.len != @as(usize, @intFromFloat(dst_rect.w)) * @as(usize, @intFromFloat(dst_rect.h))) {
- dvui.log.err("Source pixel length {d} does not match destination rectangle size {any}", .{ src_pixels.len, dst_rect });
- return;
- }
- const self_size = self.size();
-
- const x = @as(usize, @intFromFloat(dst_rect.x));
- const y = @as(usize, @intFromFloat(dst_rect.y));
- const width = @as(usize, @intFromFloat(dst_rect.w));
- const height = @as(usize, @intFromFloat(dst_rect.h));
-
- const tex_width = @as(usize, @intFromFloat(self_size.w));
-
- var yy = y;
- var h = height;
-
- var d = self.pixels()[x + yy * tex_width .. x + yy * tex_width + width];
- var src_y: usize = 0;
- while (h > 0) {
- h -= 1;
- const src_row = src_pixels[src_y * width .. (src_y * width) + width];
- if (!options.transparent) {
- if (options.mask) {
- self.mask.setRangeValue(
- .{ .start = x + yy * tex_width, .end = x + yy * tex_width + width },
- true,
- );
- }
-
- @memcpy(d, src_row);
- } else {
- for (src_row, d, 0..) |src, *dst, index| {
- if (src[3] > 0) {
- if (options.mask)
- self.mask.set(x + yy * tex_width + index);
-
- dst.* = src;
- }
- }
- }
-
- // next row and move our slice to it as well
- src_y += 1;
- yy += 1;
-
- const next_row_start = x + yy * tex_width;
- const next_row_end = next_row_start + width;
- // Exclusive end must satisfy `next_row_end <= len` (`== len` is valid for the bottom row).
- if (next_row_start < self.pixels().len and next_row_end <= self.pixels().len) {
- d = self.pixels()[next_row_start..next_row_end];
- }
- }
- self.invalidate();
-}
-
-pub fn clear(self: *Layer) void {
- @memset(self.pixels(), .{ 0, 0, 0, 0 });
- self.invalidate();
- self.dirty = false;
-}
-
-pub fn writeSourceToZip(
- layer: *const Layer,
- zip_file: ?*anyopaque,
- resolution: u32,
-) !void {
- return fizzy.image.writeToZip(layer.source, zip_file, resolution);
-}
-
-pub fn writeSourceToPng(layer: *const Layer, path: []const u8) !void {
- return fizzy.fs.writeSourceToPng(layer.source, path);
-}
-
-pub fn resize(layer: *Layer, new_size: dvui.Size) !void {
- const layer_size = layer.size();
- if (layer_size.w == new_size.w and layer_size.h == new_size.h) return;
-
- var new_layer = Layer.init(
- layer.id,
- fizzy.app.allocator.dupe(u8, layer.name) catch return error.MemoryAllocationFailed,
- @as(u32, @intFromFloat(new_size.w)),
- @as(u32, @intFromFloat(new_size.h)),
- .{ .r = 0, .g = 0, .b = 0, .a = 0 },
- .ptr,
- ) catch return error.MemoryAllocationFailed;
-
- new_layer.blit(layer.pixelsFromRect(dvui.currentWindow().arena(), .{
- .x = 0,
- .y = 0,
- .w = new_size.w,
- .h = new_size.h,
- }) orelse return error.MemoryAllocationFailed, .{
- .x = 0,
- .y = 0,
- .w = new_size.w,
- .h = new_size.h,
- }, .{});
-
- new_layer.invalidate();
-
- layer.deinit();
- layer.* = new_layer;
-}
-
-/// Tighten `src` to the smallest sub-rect of this layer containing every opaque pixel.
-/// Returns null when `src` is empty, off-layer, or covers only fully-transparent pixels.
-///
-/// Pure scalar logic lives in `fizzy.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, .{
- .x = @intFromFloat(src.x),
- .y = @intFromFloat(src.y),
- .w = @intFromFloat(src.w),
- .h = @intFromFloat(src.h),
- }) orelse return null;
-
- return .{
- .x = @floatFromInt(r.x),
- .y = @floatFromInt(r.y),
- .w = @floatFromInt(r.w),
- .h = @floatFromInt(r.h),
- };
-}
diff --git a/src/internal/Palette.zig b/src/internal/Palette.zig
deleted file mode 100644
index cefe2c2c..00000000
--- a/src/internal/Palette.zig
+++ /dev/null
@@ -1,51 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const dvui = @import("dvui");
-
-const palette_parse = @import("palette_parse.zig");
-
-pub const Palette = @This();
-
-name: []const u8,
-colors: [][4]u8,
-
-pub fn getDVUIColor(self: *Palette, id: usize) dvui.Color {
- if (self.colors.len == 0) return .magenta;
- const new_id = id % self.colors.len;
- return .{ .r = self.colors[new_id][0], .g = self.colors[new_id][1], .b = self.colors[new_id][2], .a = self.colors[new_id][3] };
-}
-
-pub fn loadFromFile(allocator: std.mem.Allocator, file: []const u8) !Palette {
- const ext = std.fs.path.extension(file);
-
- if (std.mem.eql(u8, ext, ".hex")) {
- if (fizzy.fs.read(fizzy.app.allocator, dvui.io, file) catch null) |read| {
- defer fizzy.app.allocator.free(read);
-
- return loadFromBytes(allocator, std.fs.path.basename(file), read);
- }
- }
- return error.WrongFileType;
-}
-
-pub fn loadFromBytes(allocator: std.mem.Allocator, name: []const u8, bytes: []const u8) !Palette {
- const colors = palette_parse.parseHexBytes(allocator, bytes) catch |err| {
- switch (err) {
- error.InvalidHexLine => {
- dvui.log.err("Failed to parse palette: invalid hex line", .{});
- return error.FailedToParseColor;
- },
- error.OutOfMemory => return error.OutOfMemory,
- }
- };
-
- return .{
- .name = try allocator.dupe(u8, name),
- .colors = colors,
- };
-}
-
-pub fn deinit(self: *Palette) void {
- fizzy.app.allocator.free(self.name);
- fizzy.app.allocator.free(self.colors);
-}
diff --git a/src/internal/Sprite.zig b/src/internal/Sprite.zig
deleted file mode 100644
index ec3c3e90..00000000
--- a/src/internal/Sprite.zig
+++ /dev/null
@@ -1 +0,0 @@
-origin: [2]f32 = .{ 0.0, 0.0 },
diff --git a/src/internal/grid_layout_validate.zig b/src/internal/grid_layout_validate.zig
deleted file mode 100644
index 66686e8f..00000000
--- a/src/internal/grid_layout_validate.zig
+++ /dev/null
@@ -1,70 +0,0 @@
-//! Pure validation predicate for proposed grid-layout changes.
-//!
-//! Mirrors the export size cap (4096×4096) and rejects degenerate proposals before any
-//! allocation. Lives in its own std-only file so the editor's grid-layout dialog and
-//! `Internal.File.applyGridLayout{,SliceOnly}` can share a single source of truth that's
-//! also reachable from `zig build test` without dvui / fizzy globals.
-//!
-//! Re-exported by `Internal.File.validateGridLayoutProposedDims` for backward compatibility
-//! with existing call sites.
-
-const std = @import("std");
-
-/// Maximum exported atlas dimension. Keep aligned with `tools/Packer.zig`'s texture size
-/// table — that table tops out at 8192 along the long axis but the editor caps single-axis
-/// document size at 4096 so the document can always be exported into a single atlas page.
-pub const max_axis: u32 = 4096;
-
-pub fn validateGridLayoutProposedDims(
- column_width: u32,
- row_height: u32,
- columns: u32,
- rows: u32,
-) bool {
- if (column_width == 0 or row_height == 0 or columns == 0 or rows == 0) return false;
- const total_w: u64 = @as(u64, column_width) * @as(u64, columns);
- const total_h: u64 = @as(u64, row_height) * @as(u64, rows);
- if (total_w == 0 or total_h == 0) return false;
- if (total_w > max_axis or total_h > max_axis) return false;
- return true;
-}
-
-const expect = std.testing.expect;
-
-test "rejects any zero dimension" {
- try expect(!validateGridLayoutProposedDims(0, 16, 1, 1));
- try expect(!validateGridLayoutProposedDims(16, 0, 1, 1));
- try expect(!validateGridLayoutProposedDims(16, 16, 0, 1));
- try expect(!validateGridLayoutProposedDims(16, 16, 1, 0));
-}
-
-test "accepts the smallest non-zero proposal" {
- try expect(validateGridLayoutProposedDims(1, 1, 1, 1));
-}
-
-test "accepts a tile-sliced layout that fits inside the cap" {
- try expect(validateGridLayoutProposedDims(32, 32, 16, 16)); // 512×512
- try expect(validateGridLayoutProposedDims(64, 64, 32, 32)); // 2048×2048
-}
-
-test "accepts the largest dimension exactly at the cap" {
- try expect(validateGridLayoutProposedDims(max_axis, max_axis, 1, 1));
- try expect(validateGridLayoutProposedDims(1, 1, max_axis, max_axis));
-}
-
-test "rejects when total width exceeds the cap by one" {
- try expect(!validateGridLayoutProposedDims(max_axis + 1, 1, 1, 1));
- try expect(!validateGridLayoutProposedDims(max_axis, 1, 2, 1));
-}
-
-test "rejects when total height exceeds the cap by one" {
- try expect(!validateGridLayoutProposedDims(1, max_axis + 1, 1, 1));
- try expect(!validateGridLayoutProposedDims(1, max_axis, 1, 2));
-}
-
-test "rejects multiplications that would overflow u32 (defensive — uses u64 internally)" {
- // column_width * columns would wrap to 0 in u32 arithmetic for these inputs; the predicate
- // must still reject (not silently accept the wrapped value).
- try expect(!validateGridLayoutProposedDims(0xFFFF_FFFF, 1, 2, 1));
- try expect(!validateGridLayoutProposedDims(1, 0xFFFF_FFFF, 1, 2));
-}
\ No newline at end of file
diff --git a/src/internal/layer_order.zig b/src/internal/layer_order.zig
deleted file mode 100644
index 8cb655f9..00000000
--- a/src/internal/layer_order.zig
+++ /dev/null
@@ -1,115 +0,0 @@
-//! Pure layer-list reorder algorithm.
-//!
-//! Extracted from `internal/File.zig` so `zig build test` can exercise
-//! it without pulling in dvui / fizzy globals. Re-exported by File.zig
-//! as `layerOrderAfterMove`.
-//!
-//! Mirrors the drop-handling logic in `editor/explorer/tools.zig`: given
-//! a logical "remove element at index `removed`, then insert it before
-//! position `insert_before`" operation, fill `out[0..len]` with the new
-//! position-to-storage-index mapping (position 0 = top of stack).
-
-const std = @import("std");
-
-/// Maximum list length supported by the in-place implementation.
-/// Layers in fizzy are bounded well below this; raising it just bumps
-/// the stack-allocated scratch buffer.
-pub const max_len: usize = 1024;
-
-pub fn layerOrderAfterMove(
- len: usize,
- removed: usize,
- insert_before: usize,
- out: []usize,
-) void {
- std.debug.assert(out.len >= len);
- std.debug.assert(removed < len);
- std.debug.assert(insert_before <= len);
- if (removed == insert_before) {
- for (0..len) |i| out[i] = i;
- return;
- }
- const insert_pos = if (removed < insert_before) insert_before - 1 else insert_before;
- var tmp: [max_len]usize = undefined;
- std.debug.assert(len <= tmp.len);
- var m: usize = 0;
- for (0..len) |i| {
- if (i == removed) continue;
- tmp[m] = i;
- m += 1;
- }
- var ti: usize = 0;
- for (0..len) |dst| {
- if (dst == insert_pos) {
- out[dst] = removed;
- } else {
- out[dst] = tmp[ti];
- ti += 1;
- }
- }
-}
-
-const expectEqualSlices = std.testing.expectEqualSlices;
-
-test "layerOrderAfterMove no-op when removed == insert_before" {
- var out: [5]usize = undefined;
- layerOrderAfterMove(5, 2, 2, &out);
- try expectEqualSlices(usize, &.{ 0, 1, 2, 3, 4 }, &out);
-}
-
-test "layerOrderAfterMove first to last" {
- // Move element 0 to insert before position 5 (i.e. the end).
- var out: [5]usize = undefined;
- layerOrderAfterMove(5, 0, 5, &out);
- try expectEqualSlices(usize, &.{ 1, 2, 3, 4, 0 }, &out);
-}
-
-test "layerOrderAfterMove last to first" {
- // Move element 4 to the very front.
- var out: [5]usize = undefined;
- layerOrderAfterMove(5, 4, 0, &out);
- try expectEqualSlices(usize, &.{ 4, 0, 1, 2, 3 }, &out);
-}
-
-test "layerOrderAfterMove forward middle" {
- // Move index 1 to before position 4 (slides past indexes 2 and 3).
- // Because removed (1) < insert_before (4), the insert position
- // collapses to 4 - 1 = 3 after the removal.
- var out: [5]usize = undefined;
- layerOrderAfterMove(5, 1, 4, &out);
- try expectEqualSlices(usize, &.{ 0, 2, 3, 1, 4 }, &out);
-}
-
-test "layerOrderAfterMove backward middle" {
- // Move index 3 to before position 1 (i.e. earlier in the list).
- var out: [5]usize = undefined;
- layerOrderAfterMove(5, 3, 1, &out);
- try expectEqualSlices(usize, &.{ 0, 3, 1, 2, 4 }, &out);
-}
-
-test "layerOrderAfterMove single-element list is a no-op" {
- var out: [1]usize = undefined;
- layerOrderAfterMove(1, 0, 0, &out);
- try expectEqualSlices(usize, &.{0}, &out);
-}
-
-test "layerOrderAfterMove permutation is always a valid permutation" {
- // Every output should be a permutation of 0..len. Sweep all
- // (removed, insert_before) pairs for a small list.
- const len: usize = 6;
- var out: [len]usize = undefined;
- var seen: [len]bool = undefined;
- var removed: usize = 0;
- while (removed < len) : (removed += 1) {
- var insert_before: usize = 0;
- while (insert_before <= len) : (insert_before += 1) {
- layerOrderAfterMove(len, removed, insert_before, &out);
- @memset(&seen, false);
- for (out) |idx| {
- try std.testing.expect(idx < len);
- try std.testing.expect(!seen[idx]);
- seen[idx] = true;
- }
- }
- }
-}
diff --git a/src/internal/palette_parse.zig b/src/internal/palette_parse.zig
deleted file mode 100644
index fd28c50c..00000000
--- a/src/internal/palette_parse.zig
+++ /dev/null
@@ -1,112 +0,0 @@
-//! Pure parser for `.hex` palette files.
-//!
-//! Extracted from `internal/Palette.zig` so `zig build test` can
-//! exercise it without pulling in dvui / fizzy globals.
-//!
-//! The `.hex` format is one 6-digit RRGGBB hex color per line. Empty
-//! lines and lines beginning with `#` (comments) are ignored. The
-//! parser intentionally accepts both LF (`\n`) and CRLF line endings;
-//! the historical implementation depended on a trailing newline, but
-//! this version handles a missing trailing newline gracefully too.
-
-const std = @import("std");
-
-pub const Error = error{
- InvalidHexLine,
- OutOfMemory,
-};
-
-const PackedColor = packed struct(u32) { r: u8, g: u8, b: u8, a: u8 };
-
-/// Parse `bytes` as a `.hex` palette file into a heap-allocated slice
-/// of RGBA colors. Returns `Error.InvalidHexLine` on the first line
-/// that is non-empty, non-comment, and fails to parse as a 6-digit
-/// hex value.
-///
-/// The caller owns the returned slice and must free it with the same
-/// allocator passed in.
-pub fn parseHexBytes(allocator: std.mem.Allocator, bytes: []const u8) Error![][4]u8 {
- var colors = std.array_list.Managed([4]u8).init(allocator);
- errdefer colors.deinit();
-
- var iter = std.mem.splitSequence(u8, bytes, "\n");
- while (iter.next()) |raw_line| {
- const line = trimLine(raw_line);
- if (line.len == 0) continue;
- if (line[0] == '#') continue;
-
- const color_u32 = std.fmt.parseInt(u32, line, 16) catch {
- return Error.InvalidHexLine;
- };
- const packed_color: PackedColor = @bitCast(color_u32);
- // The original loader byte-shuffles to {b, g, r, 255}; preserve
- // that exactly so existing palettes load identically.
- try colors.append(.{ packed_color.b, packed_color.g, packed_color.r, 255 });
- }
-
- return colors.toOwnedSlice() catch return Error.OutOfMemory;
-}
-
-/// Trim trailing CR (handles CRLF input) and surrounding whitespace.
-fn trimLine(line: []const u8) []const u8 {
- return std.mem.trim(u8, line, " \t\r");
-}
-
-const expectEqual = std.testing.expectEqual;
-const expectEqualSlices = std.testing.expectEqualSlices;
-
-test "parseHexBytes parses 4 valid hex lines" {
- const bytes = "112233\n445566\nAABBCC\nDEADBE\n";
- const colors = try parseHexBytes(std.testing.allocator, bytes);
- defer std.testing.allocator.free(colors);
-
- try expectEqual(@as(usize, 4), colors.len);
- for (colors) |c| try expectEqual(@as(u8, 255), c[3]);
-
- // Verify the historical byte-shuffle: line "112233" produces the
- // BGR-swapped triple {0x33, 0x22, 0x11, 0xff}.
- try expectEqualSlices(u8, &.{ 0x33, 0x22, 0x11, 0xff }, &colors[0]);
- try expectEqualSlices(u8, &.{ 0x66, 0x55, 0x44, 0xff }, &colors[1]);
- try expectEqualSlices(u8, &.{ 0xcc, 0xbb, 0xaa, 0xff }, &colors[2]);
- try expectEqualSlices(u8, &.{ 0xbe, 0xad, 0xde, 0xff }, &colors[3]);
-}
-
-test "parseHexBytes ignores blank lines and comments" {
- const bytes =
- \\# fizzy default palette
- \\
- \\112233
- \\# another comment
- \\445566
- \\
- ;
- const colors = try parseHexBytes(std.testing.allocator, bytes);
- defer std.testing.allocator.free(colors);
- try expectEqual(@as(usize, 2), colors.len);
-}
-
-test "parseHexBytes accepts CRLF line endings" {
- const bytes = "112233\r\n445566\r\n";
- const colors = try parseHexBytes(std.testing.allocator, bytes);
- defer std.testing.allocator.free(colors);
- try expectEqual(@as(usize, 2), colors.len);
-}
-
-test "parseHexBytes accepts a trailing line without newline" {
- const bytes = "112233\n445566";
- const colors = try parseHexBytes(std.testing.allocator, bytes);
- defer std.testing.allocator.free(colors);
- try expectEqual(@as(usize, 2), colors.len);
-}
-
-test "parseHexBytes returns InvalidHexLine on malformed input" {
- const bytes = "112233\nNOTHEX\n";
- const result = parseHexBytes(std.testing.allocator, bytes);
- try std.testing.expectError(Error.InvalidHexLine, result);
-}
-
-test "parseHexBytes on empty input returns an empty slice" {
- const colors = try parseHexBytes(std.testing.allocator, "");
- defer std.testing.allocator.free(colors);
- try expectEqual(@as(usize, 0), colors.len);
-}
diff --git a/src/markdown/markdown.zig b/src/markdown/markdown.zig
new file mode 100644
index 00000000..35bf57b2
--- /dev/null
+++ b/src/markdown/markdown.zig
@@ -0,0 +1,128 @@
+//! In-tree markdown render library (host-side, native-only).
+//!
+//! This is the reusable *render engine* extracted from the markdown plugin — no document,
+//! plugin, or editor machinery. The plugin store calls `drawPreview` to render fetched
+//! README bytes; a future `.md` editing wrapper can reuse the same engine for its preview
+//! pane (paired with `sdk.text` for the source pane).
+//!
+//! Depends on the `cmark-gfm` C library, so it is wired into native builds only and gated
+//! out of the wasm build (see `build/markdown.zig`).
+const std = @import("std");
+const dvui = @import("dvui");
+
+const md_parse = @import("md/cmark_parse.zig");
+const render_ast = @import("md/render_ast.zig");
+
+pub const RenderState = render_ast.RenderState;
+
+/// Persistent, caller-owned preview state: caches the parsed AST + precomputed render data
+/// keyed by a content hash, plus the scroll position. Reuse one instance across frames for a
+/// given rendered document (e.g. the store's currently-selected plugin README).
+pub const Preview = struct {
+ scroll: dvui.ScrollInfo = .{},
+ content_hash: u64 = std.math.maxInt(u64),
+ ast_root: ?*anyopaque = null,
+ gpa: ?std.mem.Allocator = null,
+ rs: render_ast.RenderState = .{},
+
+ pub fn deinit(self: *Preview) void {
+ md_parse.freeCachedRoot(self.ast_root);
+ self.ast_root = null;
+ if (self.gpa) |gpa| self.rs.deinit(gpa);
+ self.* = .{};
+ }
+
+ /// Re-parse only when the content changes (hash mismatch). All persistent allocations use
+ /// `gpa`; pass the same allocator every frame for a given Preview.
+ fn ensureParsed(self: *Preview, content: []const u8, gpa: std.mem.Allocator) void {
+ self.gpa = gpa;
+ var hasher = std.hash.XxHash3.init(0);
+ hasher.update(content);
+ const h = hasher.final();
+ if (self.content_hash == h and self.ast_root != null) return;
+ md_parse.freeCachedRoot(self.ast_root);
+ self.ast_root = null;
+ self.rs.clear(gpa);
+ self.content_hash = h;
+ if (md_parse.parseMarkdown(content)) |ast| {
+ self.ast_root = @ptrCast(ast.root.n);
+ _ = render_ast.scanNode(ast.root, &self.rs, gpa);
+ }
+ }
+};
+
+pub const PreviewOptions = struct {
+ /// `std.Io` used for image loads. Required.
+ io: std.Io,
+ /// Base dir for resolving relative `` images. READMEs fetched from a remote repo
+ /// have no local base, so relative images simply won't resolve — that's fine.
+ image_base_dir: []const u8 = ".",
+ /// Seed for widget ids so multiple previews don't collide.
+ id_extra: u64 = 0,
+};
+
+/// Render `bytes` as a read-only markdown preview (own scroll area) into the current dvui parent.
+/// Safe to call every frame; parsing is cached on `state` by content hash.
+pub fn drawPreview(
+ state: *Preview,
+ bytes: []const u8,
+ gpa: std.mem.Allocator,
+ opts: PreviewOptions,
+) void {
+ state.ensureParsed(bytes, gpa);
+
+ if (state.ast_root) |rp| {
+ const root: md_parse.Node = .{ .n = @ptrCast(@alignCast(rp)) };
+ render_ast.preloadImages(root, .{
+ .image_base_dir = opts.image_base_dir,
+ .io = opts.io,
+ .gpa = gpa,
+ .rs = &state.rs,
+ });
+ }
+
+ var scroll = dvui.scrollArea(@src(), .{
+ .scroll_info = &state.scroll,
+ .horizontal_bar = .hide,
+ .vertical_bar = .auto_overlay,
+ }, .{
+ .expand = .both,
+ .background = true,
+ .color_fill = dvui.themeGet().fill,
+ .style = .content,
+ .id_extra = opts.id_extra,
+ });
+ defer scroll.deinit();
+
+ if (state.ast_root) |rp| {
+ var v = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .horizontal,
+ .gravity_x = 0,
+ .padding = .{ .x = 8, .y = 8, .w = 8, .h = 8 },
+ .id_extra = opts.id_extra + 1,
+ });
+ defer v.deinit();
+
+ const root: md_parse.Node = .{ .n = @ptrCast(@alignCast(rp)) };
+ render_ast.renderDocument(root, .{
+ .image_base_dir = opts.image_base_dir,
+ .io = opts.io,
+ .gpa = gpa,
+ .rs = &state.rs,
+ .id_base = @intCast(opts.id_extra << 16),
+ });
+ } else {
+ dvui.labelNoFmt(
+ @src(),
+ "Could not parse markdown.",
+ .{},
+ .{
+ .expand = .both,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .color_text = dvui.themeGet().color(.err, .text).opacity(0.85),
+ .id_extra = opts.id_extra,
+ },
+ );
+ }
+}
diff --git a/src/markdown/md/cmark_headers.h b/src/markdown/md/cmark_headers.h
new file mode 100644
index 00000000..2cd4728d
--- /dev/null
+++ b/src/markdown/md/cmark_headers.h
@@ -0,0 +1,14 @@
+#ifndef DVUI_EDITOR_CMARK_HEADERS_H
+#define DVUI_EDITOR_CMARK_HEADERS_H
+
+#include
+#include
+#include
+#include
+
+#include "cmark-gfm.h"
+#include "cmark-gfm-extension_api.h"
+#include "registry.h"
+#include "cmark-gfm-core-extensions.h"
+
+#endif
diff --git a/src/markdown/md/cmark_parse.zig b/src/markdown/md/cmark_parse.zig
new file mode 100644
index 00000000..c3a39604
--- /dev/null
+++ b/src/markdown/md/cmark_parse.zig
@@ -0,0 +1,114 @@
+const std = @import("std");
+
+pub const c = @cImport({
+ @cInclude("cmark_headers.h");
+});
+
+/// Declared in `src/registry.h` (not all public headers re-export it).
+pub extern fn cmark_list_syntax_extensions(mem: *c.cmark_mem) ?*c.cmark_llist;
+
+pub const Node = struct {
+ n: *c.cmark_node,
+
+ pub fn firstChild(n: Node) ?Node {
+ const ptr = c.cmark_node_first_child(n.n) orelse return null;
+ return .{ .n = ptr };
+ }
+
+ pub fn nextSibling(n: Node) ?Node {
+ const ptr = c.cmark_node_next(n.n) orelse return null;
+ return .{ .n = ptr };
+ }
+
+ pub fn nodeType(n: Node) c.cmark_node_type {
+ return c.cmark_node_get_type(n.n);
+ }
+
+ pub fn typeString(n: Node) [:0]const u8 {
+ const s = c.cmark_node_get_type_string(n.n) orelse return "";
+ return std.mem.span(s);
+ }
+
+ pub fn literal(n: Node) ?[:0]const u8 {
+ const ptr = c.cmark_node_get_literal(n.n) orelse return null;
+ return std.mem.span(ptr);
+ }
+
+ pub fn linkUrl(n: Node) ?[:0]const u8 {
+ const ptr = c.cmark_node_get_url(n.n) orelse return null;
+ return std.mem.span(ptr);
+ }
+
+ pub fn linkTitle(n: Node) ?[:0]const u8 {
+ const ptr = c.cmark_node_get_title(n.n) orelse return null;
+ const s = std.mem.span(ptr);
+ if (s.len == 0) return null;
+ return s;
+ }
+
+ pub fn fenceInfo(n: Node) ?[:0]const u8 {
+ const ptr = c.cmark_node_get_fence_info(n.n) orelse return null;
+ return std.mem.span(ptr);
+ }
+
+ pub fn headingLevel(n: Node) i32 {
+ return c.cmark_node_get_heading_level(n.n);
+ }
+
+ pub const ListKind = enum { ul, ol };
+
+ pub fn listKind(n: Node) ListKind {
+ return switch (c.cmark_node_get_list_type(n.n)) {
+ c.CMARK_BULLET_LIST => .ul,
+ c.CMARK_ORDERED_LIST => .ol,
+ else => .ul,
+ };
+ }
+
+ pub fn listStart(n: Node) i32 {
+ return c.cmark_node_get_list_start(n.n);
+ }
+
+ pub fn tableRowIsHeader(n: Node) bool {
+ return c.cmark_gfm_extensions_get_table_row_is_header(n.n) != 0;
+ }
+
+ pub fn taskListItemChecked(n: Node) bool {
+ return c.cmark_gfm_extensions_get_tasklist_item_checked(n.n);
+ }
+};
+
+pub const CMarkAst = struct {
+ root: Node,
+ extensions: ?*c.cmark_llist,
+};
+
+pub fn parseMarkdown(src: []const u8) ?CMarkAst {
+ const extensions = blk: {
+ c.cmark_gfm_core_extensions_ensure_registered();
+ break :blk cmark_list_syntax_extensions(c.cmark_get_arena_mem_allocator());
+ };
+
+ const options = c.CMARK_OPT_DEFAULT | c.CMARK_OPT_SAFE | c.CMARK_OPT_SMART | c.CMARK_OPT_FOOTNOTES;
+ const parser = c.cmark_parser_new(options) orelse return null;
+ defer c.cmark_parser_free(parser);
+
+ _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("table"));
+ _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("strikethrough"));
+ _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("tasklist"));
+ _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("autolink"));
+
+ c.cmark_parser_feed(parser, src.ptr, @intCast(src.len));
+ const root_ptr = c.cmark_parser_finish(parser) orelse return null;
+ return .{
+ .root = .{ .n = root_ptr },
+ .extensions = extensions,
+ };
+}
+
+pub fn freeCachedRoot(ptr: ?*anyopaque) void {
+ if (ptr) |p| {
+ const n: *c.cmark_node = @ptrCast(@alignCast(p));
+ c.cmark_node_free(n);
+ }
+}
diff --git a/src/markdown/md/render_ast.zig b/src/markdown/md/render_ast.zig
new file mode 100644
index 00000000..d2a9efb0
--- /dev/null
+++ b/src/markdown/md/render_ast.zig
@@ -0,0 +1,909 @@
+const std = @import("std");
+const Io = std.Io;
+
+const dvui = @import("dvui");
+
+const md = @import("cmark_parse.zig");
+
+// Extension node kinds that cmark-gfm identifies by type string rather than
+// integer constant. Precomputed once after parsing so rendering never calls
+// typeString() or any C FFI inside the per-frame draw loop.
+pub const ExtNodeKind = enum { table, table_row, table_header, table_cell, strikethrough };
+
+/// All precomputed per-AST render data. Lives in MarkDownPreviewWidget.State,
+/// rebuilt whenever the content hash changes, freed on deinit.
+pub const RenderState = struct {
+ /// abs_path (gpa-owned) → raw image bytes (gpa-owned).
+ image_cache: std.StringHashMapUnmanaged([]u8) = .empty,
+ /// @intFromPtr(bytes.ptr) → natural image size, cached to avoid per-frame stbi_info.
+ image_sizes: std.AutoHashMapUnmanaged(usize, dvui.Size) = .empty,
+ /// @intFromPtr(node.n) → ExtNodeKind (extension nodes only).
+ ext_node_kinds: std.AutoHashMapUnmanaged(usize, ExtNodeKind) = .empty,
+ /// Set of @intFromPtr(node.n) for every node whose subtree contains an IMAGE.
+ subtree_has_image: std.AutoHashMapUnmanaged(usize, void) = .empty,
+ /// @intFromPtr(table_node.n) → column count (from header row).
+ /// Avoids re-traversing the header row every render frame.
+ table_col_counts: std.AutoHashMapUnmanaged(usize, usize) = .empty,
+
+ pub fn deinit(self: *RenderState, gpa: std.mem.Allocator) void {
+ self.clear(gpa);
+ self.image_cache.deinit(gpa);
+ self.image_sizes.deinit(gpa);
+ self.ext_node_kinds.deinit(gpa);
+ self.subtree_has_image.deinit(gpa);
+ self.table_col_counts.deinit(gpa);
+ }
+
+ pub fn clear(self: *RenderState, gpa: std.mem.Allocator) void {
+ var it = self.image_cache.iterator();
+ while (it.next()) |kv| {
+ gpa.free(kv.key_ptr.*);
+ gpa.free(kv.value_ptr.*);
+ }
+ self.image_cache.clearRetainingCapacity();
+ self.image_sizes.clearRetainingCapacity();
+ self.ext_node_kinds.clearRetainingCapacity();
+ self.subtree_has_image.clearRetainingCapacity();
+ self.table_col_counts.clearRetainingCapacity();
+ }
+};
+
+/// dvui ids derive from @src(); repeated layouts in loops/recursion need unique `.id_extra`.
+const IdGen = struct {
+ n: usize = 0,
+ fn next(g: *IdGen) usize {
+ g.n += 1;
+ return g.n;
+ }
+};
+
+pub const RenderContext = struct {
+ /// Directory of the markdown file (for resolving relative ``).
+ image_base_dir: ?[]const u8 = null,
+ io: Io,
+ /// Persistent allocator (same lifetime as State). Used for image cache.
+ gpa: std.mem.Allocator,
+ /// Precomputed per-AST data: node kind map, image subtree set, image cache.
+ rs: *RenderState,
+ /// Seed for per-document widget id_extra values (avoids collisions with other panes/docs).
+ id_base: usize = 0,
+};
+
+const max_image_bytes: usize = 16 * 1024 * 1024;
+const max_image_display_width: f32 = 720;
+const max_image_display_height: f32 = 540;
+
+// ---------------------------------------------------------------------------
+// Per-node fast lookups (replaces isTable/typeString calls in render loop)
+// ---------------------------------------------------------------------------
+
+inline fn extKind(ctx: RenderContext, n: md.Node) ?ExtNodeKind {
+ return ctx.rs.ext_node_kinds.get(@intFromPtr(n.n));
+}
+
+inline fn hasImageSubtree(ctx: RenderContext, n: md.Node) bool {
+ return ctx.rs.subtree_has_image.contains(@intFromPtr(n.n));
+}
+
+// ---------------------------------------------------------------------------
+// AST pre-scan (called once after parsing, results stored in State)
+// ---------------------------------------------------------------------------
+
+/// Walk the AST once, populating rs.ext_node_kinds and rs.subtree_has_image.
+/// Returns true when any node in the subtree rooted at `node` is an IMAGE.
+pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool {
+ const ts = node.typeString();
+ if (std.mem.eql(u8, ts, "table")) {
+ rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table) catch {};
+ // Count columns once from the header (or first body row) so the render
+ // loop never needs to re-traverse the row for this.
+ var num_cols: usize = 0;
+ var r = node.firstChild();
+ while (r) |row| : (r = row.nextSibling()) {
+ const rts = row.typeString();
+ if (!std.mem.eql(u8, rts, "table_header") and !std.mem.eql(u8, rts, "table_row")) continue;
+ var cl = row.firstChild();
+ while (cl) |cell| : (cl = cell.nextSibling()) {
+ if (std.mem.eql(u8, cell.typeString(), "table_cell")) num_cols += 1;
+ }
+ break;
+ }
+ rs.table_col_counts.put(gpa, @intFromPtr(node.n), num_cols) catch {};
+ } else if (std.mem.eql(u8, ts, "table_row"))
+ rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table_row) catch {}
+ else if (std.mem.eql(u8, ts, "table_header"))
+ rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table_header) catch {}
+ else if (std.mem.eql(u8, ts, "table_cell"))
+ rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table_cell) catch {}
+ else if (std.mem.eql(u8, ts, "strikethrough"))
+ rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .strikethrough) catch {};
+
+ var self_has_image = (node.nodeType() == md.c.CMARK_NODE_IMAGE);
+ var child = node.firstChild();
+ while (child) |ch| : (child = ch.nextSibling()) {
+ if (scanNode(ch, rs, gpa)) self_has_image = true;
+ }
+ if (self_has_image)
+ rs.subtree_has_image.put(gpa, @intFromPtr(node.n), {}) catch {};
+
+ return self_has_image;
+}
+
+// ---------------------------------------------------------------------------
+// Image preloading (keep GPU textures warm every frame, even when pane is closed)
+// ---------------------------------------------------------------------------
+
+/// Touch or create the GPU texture for every local image in the AST.
+/// Call every frame from MarkDownPreviewWidget.init() so dvui's one-frame
+/// texture eviction policy never fires between animation frames.
+pub fn preloadImages(root: md.Node, ctx: RenderContext) void {
+ if (!ctx.rs.subtree_has_image.contains(@intFromPtr(root.n))) return;
+ const arena = dvui.currentWindow().arena();
+ preloadImageSubtree(root, ctx, arena);
+}
+
+fn preloadImageSubtree(node: md.Node, ctx: RenderContext, arena: std.mem.Allocator) void {
+ if (node.nodeType() == md.c.CMARK_NODE_IMAGE) {
+ preloadSingleImage(node, ctx, arena);
+ return;
+ }
+ if (!ctx.rs.subtree_has_image.contains(@intFromPtr(node.n))) return;
+ var child = node.firstChild();
+ while (child) |ch| : (child = ch.nextSibling()) {
+ preloadImageSubtree(ch, ctx, arena);
+ }
+}
+
+fn preloadSingleImage(img: md.Node, ctx: RenderContext, arena: std.mem.Allocator) void {
+ const raw_url = img.linkUrl() orelse return;
+ const abs_path = resolvedLocalImagePath(ctx, arena, raw_url) orelse return;
+
+ const bytes: []const u8 = blk: {
+ if (ctx.rs.image_cache.get(abs_path)) |cached| break :blk cached;
+ const fresh = Io.Dir.cwd().readFileAlloc(ctx.io, abs_path, ctx.gpa, .limited(max_image_bytes)) catch return;
+ const key = ctx.gpa.dupe(u8, abs_path) catch {
+ ctx.gpa.free(fresh);
+ return;
+ };
+ ctx.rs.image_cache.put(ctx.gpa, key, fresh) catch {
+ ctx.gpa.free(key);
+ ctx.gpa.free(fresh);
+ return;
+ };
+ break :blk fresh;
+ };
+
+ const dvui_key: dvui.Texture.Cache.Key = blk: {
+ var h = dvui.fnv.init();
+ const bp = bytes.ptr;
+ h.update(std.mem.asBytes(&bp));
+ const it = @intFromEnum(dvui.enums.TextureInterpolation.linear);
+ h.update(std.mem.asBytes(&it));
+ break :blk h.final();
+ };
+
+ // Cache hit: texture already warm this frame, nothing to do.
+ if (dvui.textureGetCached(dvui_key) != null) return;
+
+ // Cache miss: decode + GPU upload now so the animation first frame is free.
+ const source: dvui.ImageSource = .{ .imageFile = .{
+ .bytes = bytes,
+ .name = abs_path,
+ .invalidation = .ptr,
+ } };
+ const tex = dvui.Texture.fromImageSource(source) catch return;
+ ctx.rs.image_sizes.put(ctx.gpa, @intFromPtr(bytes.ptr), .{
+ .w = @floatFromInt(tex.width),
+ .h = @floatFromInt(tex.height),
+ }) catch {};
+ dvui.textureAddToCache(dvui_key, tex);
+}
+
+// ---------------------------------------------------------------------------
+// Image rendering helpers
+// ---------------------------------------------------------------------------
+
+fn resolvedLocalImagePath(ctx: RenderContext, arena: std.mem.Allocator, src: []const u8) ?[]const u8 {
+ const t = std.mem.trim(u8, src, " \t\r\n");
+ if (t.len == 0) return null;
+ if (std.ascii.startsWithIgnoreCase(t, "http://")) return null;
+ if (std.ascii.startsWithIgnoreCase(t, "https://")) return null;
+ if (std.fs.path.isAbsolute(t))
+ return std.fs.path.resolve(arena, &.{t}) catch null;
+ const base = ctx.image_base_dir orelse return null;
+ return std.fs.path.resolve(arena, &.{ base, t }) catch null;
+}
+
+/// Plain UTF-8 for `TextLayoutWidget.addLink`; nested emph/strong in the label lose per-span styling.
+fn appendInlinePlainText(arena: std.mem.Allocator, n: md.Node, out: *std.ArrayList(u8)) std.mem.Allocator.Error!void {
+ var cur: ?md.Node = n.firstChild();
+ while (cur) |x| : (cur = x.nextSibling()) {
+ switch (x.nodeType()) {
+ md.c.CMARK_NODE_TEXT => {
+ if (x.literal()) |t| try out.appendSlice(arena, t);
+ },
+ md.c.CMARK_NODE_SOFTBREAK => {
+ try out.append(arena, ' ');
+ },
+ md.c.CMARK_NODE_LINEBREAK => {
+ try out.append(arena, '\n');
+ },
+ md.c.CMARK_NODE_CODE => {
+ if (x.literal()) |t| try out.appendSlice(arena, t);
+ },
+ md.c.CMARK_NODE_LINK => {
+ try appendInlinePlainText(arena, x, out);
+ },
+ md.c.CMARK_NODE_IMAGE => {
+ try out.appendSlice(arena, "![");
+ try appendInlinePlainText(arena, x, out);
+ try out.append(arena, ']');
+ if (x.linkUrl()) |u| {
+ try out.append(arena, '(');
+ try out.appendSlice(arena, u);
+ try out.append(arena, ')');
+ }
+ },
+ else => {
+ if (x.firstChild()) |_| {
+ try appendInlinePlainText(arena, x, out);
+ } else if (x.literal()) |t| {
+ try out.appendSlice(arena, t);
+ }
+ },
+ }
+ }
+}
+
+fn linkLabelPlainText(link: md.Node, arena: std.mem.Allocator) std.mem.Allocator.Error![]const u8 {
+ var list: std.ArrayList(u8) = .empty;
+ errdefer list.deinit(arena);
+ try appendInlinePlainText(arena, link, &list);
+ return try list.toOwnedSlice(arena);
+}
+
+fn renderMarkdownImagePlaceholder(msg: []const u8, ids: *IdGen) void {
+ dvui.labelNoFmt(@src(), msg, .{}, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 2, .h = 2 },
+ .color_text = dvui.themeGet().color(.control, .text).opacity(0.55),
+ .font = dvui.Font.theme(.mono).larger(-1),
+ .id_extra = ids.next(),
+ });
+}
+
+fn renderMarkdownImage(img: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void {
+ _ = span;
+ var outer = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 4, .h = 4 },
+ .id_extra = ids.next(),
+ });
+ defer outer.deinit();
+
+ const arena = dvui.currentWindow().arena();
+ const raw_url = img.linkUrl() orelse {
+ renderMarkdownImagePlaceholder("(missing image src)", ids);
+ return;
+ };
+ const url_trim = std.mem.trim(u8, raw_url, " \t\r\n");
+ if (url_trim.len == 0) {
+ renderMarkdownImagePlaceholder("(empty image src)", ids);
+ return;
+ }
+
+ const alt_owned = linkLabelPlainText(img, arena) catch "";
+ const alt: []const u8 = alt_owned;
+
+ if (std.ascii.startsWithIgnoreCase(url_trim, "http://") or std.ascii.startsWithIgnoreCase(url_trim, "https://")) {
+ var tl = dvui.textLayout(@src(), .{}, .{
+ .expand = .horizontal,
+ .id_extra = ids.next(),
+ });
+ defer tl.deinit();
+ if (alt.len > 0) {
+ tl.addText(alt, .{ .color_text = dvui.themeGet().color(.control, .text).opacity(0.85) });
+ tl.addText(" ", .{});
+ }
+ tl.addLink(.{ .url = url_trim, .text = "open" }, .{
+ .font = dvui.Font.theme(.mono),
+ });
+ return;
+ }
+
+ const abs_path = resolvedLocalImagePath(ctx, arena, url_trim) orelse {
+ renderMarkdownImagePlaceholder("cannot resolve image path (save file or use absolute path)", ids);
+ return;
+ };
+
+ // Use persistent cache to avoid reading the file every frame.
+ const bytes: []const u8 = blk: {
+ if (ctx.rs.image_cache.get(abs_path)) |cached| break :blk cached;
+
+ const fresh = Io.Dir.cwd().readFileAlloc(ctx.io, abs_path, ctx.gpa, .limited(max_image_bytes)) catch {
+ renderMarkdownImagePlaceholder("could not read image", ids);
+ return;
+ };
+ const key = ctx.gpa.dupe(u8, abs_path) catch {
+ ctx.gpa.free(fresh);
+ renderMarkdownImagePlaceholder("could not read image", ids);
+ return;
+ };
+ ctx.rs.image_cache.put(ctx.gpa, key, fresh) catch {
+ ctx.gpa.free(key);
+ ctx.gpa.free(fresh);
+ renderMarkdownImagePlaceholder("could not cache image", ids);
+ return;
+ };
+ break :blk fresh;
+ };
+
+ // Compute the same cache key dvui uses for this imageFile with .ptr invalidation.
+ // dvui's hash() calls stbi_info but ignores the result for .ptr — we skip it entirely.
+ const dvui_key: dvui.Texture.Cache.Key = blk: {
+ var h = dvui.fnv.init();
+ const bp = bytes.ptr;
+ h.update(std.mem.asBytes(&bp));
+ const it = @intFromEnum(dvui.enums.TextureInterpolation.linear);
+ h.update(std.mem.asBytes(&it));
+ break :blk h.final();
+ };
+
+ // Fast path: texture already in dvui's cache from a prior visible frame.
+ // Use .texture source to bypass hash()/stbi_info entirely on this frame.
+ // Slow path: texture not yet created. Use imageFile source so dvui creates it
+ // lazily inside renderImage (only when the image is actually in the clip rect).
+ var source: dvui.ImageSource = .{ .imageFile = .{
+ .bytes = bytes,
+ .name = abs_path,
+ .invalidation = .ptr,
+ } };
+ const nat: dvui.Size = if (dvui.textureGetCached(dvui_key)) |tex| nat: {
+ source = .{ .texture = tex };
+ break :nat .{ .w = @floatFromInt(tex.width), .h = @floatFromInt(tex.height) };
+ } else nat: {
+ const size_key = @intFromPtr(bytes.ptr);
+ break :nat ctx.rs.image_sizes.get(size_key) orelse sz: {
+ const sz = dvui.imageSize(source) catch {
+ renderMarkdownImagePlaceholder("unsupported or corrupt image", ids);
+ return;
+ };
+ ctx.rs.image_sizes.put(ctx.gpa, size_key, sz) catch {};
+ break :sz sz;
+ };
+ };
+
+ if (nat.w <= 0 or nat.h <= 0) {
+ renderMarkdownImagePlaceholder("invalid image size", ids);
+ return;
+ }
+
+ const r = nat.w / nat.h;
+ const max_fit_w = @min(max_image_display_width, max_image_display_height * r);
+ const max_fit_h = @min(max_image_display_height, max_image_display_width / r);
+
+ const scale = @min(1.0, @min(max_fit_w / nat.w, max_fit_h / nat.h));
+ const dw = nat.w * scale;
+ const dh = nat.h * scale;
+
+ _ = dvui.image(@src(), .{ .source = source, .shrink = .ratio }, .{
+ .min_size_content = .{ .w = dw, .h = dh },
+ .max_size_content = dvui.Options.MaxSize.size(.{ .w = max_fit_w, .h = max_fit_h }),
+ .expand = .ratio,
+ .label = .{ .text = if (alt.len > 0) alt else "markdown image" },
+ .id_extra = ids.next(),
+ });
+
+ if (alt.len > 0) {
+ var cap = dvui.textLayout(@src(), .{}, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 2, .h = 0 },
+ .id_extra = ids.next(),
+ });
+ defer cap.deinit();
+ cap.addText(alt, .{
+ .font = dvui.Font.theme(.body).larger(-1),
+ .color_text = dvui.themeGet().color(.control, .text).opacity(0.65),
+ });
+ }
+}
+
+fn renderInlineFlowContainer(container: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void {
+ var cur: ?md.Node = container.firstChild();
+ while (cur) |node| {
+ if (node.nodeType() == md.c.CMARK_NODE_IMAGE) {
+ renderMarkdownImage(node, span, ctx, ids);
+ cur = node.nextSibling();
+ continue;
+ }
+ if (hasImageSubtree(ctx, node)) {
+ switch (node.nodeType()) {
+ md.c.CMARK_NODE_EMPH => {
+ if (node.firstChild()) |_| {
+ const f = span.fontGet().withStyle(.italic);
+ renderInlineFlowContainer(node, span.override(.{ .font = f }), ctx, ids);
+ }
+ },
+ md.c.CMARK_NODE_STRONG => {
+ if (node.firstChild()) |_| {
+ const f = span.fontGet().withWeight(.bold);
+ renderInlineFlowContainer(node, span.override(.{ .font = f }), ctx, ids);
+ }
+ },
+ md.c.CMARK_NODE_LINK => {
+ const link_font = span.fontGet().withUnderline(.{});
+ const link_color = dvui.themeGet().focus;
+ renderInlineFlowContainer(node, span.override(.{ .font = link_font, .color_text = link_color }), ctx, ids);
+ },
+ else => {
+ if (extKind(ctx, node) == .strikethrough) {
+ const strike_font = span.fontGet().withStrike(.{});
+ const strike_color = dvui.themeGet().color(.control, .text).opacity(0.5);
+ renderInlineFlowContainer(node, span.override(.{ .font = strike_font, .color_text = strike_color }), ctx, ids);
+ } else if (node.firstChild()) |_| {
+ renderInlineFlowContainer(node, span, ctx, ids);
+ } else if (node.literal()) |t| {
+ var tl = dvui.textLayout(@src(), .{}, .{
+ .expand = .horizontal,
+ .background = span.background,
+ .id_extra = ids.next(),
+ });
+ defer tl.deinit();
+ tl.addText(t, .{ .font = span.font, .color_text = span.color_text });
+ }
+ },
+ }
+ cur = node.nextSibling();
+ continue;
+ }
+
+ // Batch a run of siblings that contain no images into one textLayout.
+ const run_first = node;
+ var run_last = node;
+ var scan: ?md.Node = node;
+ while (scan) |s| {
+ if (s.nodeType() == md.c.CMARK_NODE_IMAGE) break;
+ if (hasImageSubtree(ctx, s)) break;
+ run_last = s;
+ scan = s.nextSibling();
+ }
+
+ var tl = dvui.textLayout(@src(), .{}, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 2, .h = 2 },
+ .background = span.background,
+ .id_extra = ids.next(),
+ });
+ defer tl.deinit();
+ var z: ?md.Node = run_first;
+ while (z) |w| {
+ renderInlineNodeToTl(tl, w, span, ctx, ids);
+ if (w.n == run_last.n) break;
+ z = w.nextSibling();
+ }
+ cur = run_last.nextSibling();
+ }
+}
+
+/// `span` carries inherited font/color down into inline content.
+/// Only `.font` and `.color_text` are meaningful here.
+/// Caller must ensure `n` has no `CMARK_NODE_IMAGE` in any descendant.
+fn renderInlines(tl: *dvui.TextLayoutWidget, n: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void {
+ std.debug.assert(!hasImageSubtree(ctx, n));
+ var cur: ?md.Node = n.firstChild();
+ while (cur) |x| : (cur = x.nextSibling()) {
+ renderInlineNodeToTl(tl, x, span, ctx, ids);
+ }
+}
+
+fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void {
+ switch (x.nodeType()) {
+ md.c.CMARK_NODE_TEXT => {
+ if (x.literal()) |t| tl.addText(t, .{ .font = span.font, .color_text = span.color_text });
+ },
+ md.c.CMARK_NODE_SOFTBREAK => {
+ tl.addText(" ", .{});
+ },
+ md.c.CMARK_NODE_LINEBREAK => {
+ tl.addText("\n", .{});
+ },
+ md.c.CMARK_NODE_CODE => {
+ if (x.literal()) |t| {
+ tl.addText(t, .{
+ // Match the editor's monospace size (also `Font.theme(.mono)`).
+ .font = dvui.Font.theme(.mono),
+ .color_text = dvui.themeGet().color(.control, .text).opacity(0.9),
+ });
+ }
+ },
+ md.c.CMARK_NODE_EMPH => {
+ if (x.firstChild()) |_| {
+ const f = span.fontGet().withStyle(.italic);
+ renderInlines(tl, x, span.override(.{ .font = f }), ctx, ids);
+ }
+ },
+ md.c.CMARK_NODE_STRONG => {
+ if (x.firstChild()) |_| {
+ const f = span.fontGet().withWeight(.bold);
+ renderInlines(tl, x, span.override(.{ .font = f }), ctx, ids);
+ }
+ },
+ md.c.CMARK_NODE_LINK => {
+ const link_font = span.fontGet().withUnderline(.{});
+ const link_color = dvui.themeGet().focus;
+ const link_opts = span.override(.{ .font = link_font, .color_text = link_color });
+ const url = x.linkUrl() orelse "";
+ if (url.len == 0) {
+ if (x.firstChild()) |_| renderInlines(tl, x, link_opts, ctx, ids);
+ } else {
+ const arena = dvui.currentWindow().arena();
+ if (linkLabelPlainText(x, arena)) |display| {
+ tl.addLink(.{
+ .url = url,
+ .text = if (display.len == 0) null else display,
+ }, link_opts);
+ } else |_| {
+ if (x.firstChild()) |_| renderInlines(tl, x, link_opts, ctx, ids);
+ }
+ }
+ },
+ md.c.CMARK_NODE_IMAGE => unreachable,
+ md.c.CMARK_NODE_HTML_INLINE => {
+ if (x.literal()) |t| tl.addText(t, .{
+ .font = dvui.Font.theme(.mono),
+ .color_text = dvui.themeGet().color(.err, .text),
+ });
+ },
+ md.c.CMARK_NODE_FOOTNOTE_REFERENCE => {
+ if (x.literal()) |t| {
+ const fn_font = dvui.Font.theme(.mono).larger(-1);
+ const fn_color = dvui.themeGet().focus.opacity(0.8);
+ tl.addText("[^", .{ .font = fn_font, .color_text = fn_color });
+ tl.addText(t, .{ .font = fn_font, .color_text = fn_color });
+ tl.addText("]", .{ .font = fn_font, .color_text = fn_color });
+ }
+ },
+ else => {
+ if (extKind(ctx, x) == .strikethrough) {
+ const strike_font = span.fontGet().withStrike(.{});
+ const strike_color = dvui.themeGet().color(.control, .text).opacity(0.5);
+ renderInlines(tl, x, span.override(.{ .font = strike_font, .color_text = strike_color }), ctx, ids);
+ } else if (x.firstChild()) |_| {
+ renderInlines(tl, x, span, ctx, ids);
+ } else if (x.literal()) |t| {
+ tl.addText(t, .{ .font = span.font, .color_text = span.color_text });
+ }
+ },
+ }
+}
+
+fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void {
+ const t = n.nodeType();
+ switch (t) {
+ md.c.CMARK_NODE_DOCUMENT => {
+ var c = n.firstChild();
+ while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx);
+ },
+ md.c.CMARK_NODE_BLOCK_QUOTE => {
+ var outer = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ .margin = .{ .x = 4, .y = 4, .w = 4, .h = 4 },
+ .id_extra = ids.next(),
+ });
+ defer outer.deinit();
+
+ _ = dvui.spacer(@src(), .{
+ .min_size_content = .{ .w = 3, .h = 0 },
+ .expand = .vertical,
+ .background = true,
+ .color_fill = dvui.themeGet().color(.highlight, .fill).opacity(0.75),
+ .corner_radius = dvui.Rect.all(2),
+ .id_extra = ids.next(),
+ });
+
+ var content = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .horizontal,
+ .padding = .{ .x = 10, .y = 4, .w = 0, .h = 4 },
+ .id_extra = ids.next(),
+ });
+ defer content.deinit();
+
+ var c = n.firstChild();
+ while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx);
+ },
+ md.c.CMARK_NODE_LIST => {
+ var it = n.firstChild();
+ var idx: i32 = n.listStart();
+ const list_kind = n.listKind();
+ const col_w = dvui.Font.theme(.body).sizeM(2.2, 0).w;
+ while (it) |item_node| : (it = item_node.nextSibling()) {
+ if (item_node.nodeType() != md.c.CMARK_NODE_ITEM) {
+ renderBlock(item_node, ids, ctx);
+ continue;
+ }
+ var row = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 1 },
+ .id_extra = ids.next(),
+ });
+ defer row.deinit();
+
+ var buf: [24]u8 = undefined;
+ const is_task = list_kind == .ul and item_node.taskListItemChecked();
+ const bullet_str: []const u8 = if (is_task)
+ "✓"
+ else switch (list_kind) {
+ .ul => "•",
+ .ol => std.fmt.bufPrint(&buf, "{d}.", .{idx}) catch "?",
+ };
+ if (list_kind == .ol) idx += 1;
+
+ const bullet_color = if (is_task)
+ dvui.themeGet().color(.highlight, .fill)
+ else
+ dvui.themeGet().color(.control, .text).opacity(0.45);
+
+ {
+ var pb = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .min_size_content = .{ .w = col_w, .h = 0 },
+ .gravity_y = 0,
+ .id_extra = ids.next(),
+ });
+ _ = dvui.spacer(@src(), .{ .expand = .horizontal, .id_extra = ids.next() });
+ dvui.labelNoFmt(@src(), bullet_str, .{}, .{
+ .gravity_y = 0,
+ .color_text = bullet_color,
+ .id_extra = ids.next(),
+ });
+ pb.deinit();
+ }
+
+ _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 5, .h = 0 }, .id_extra = ids.next() });
+
+ var col = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .horizontal,
+ .id_extra = ids.next(),
+ });
+ defer col.deinit();
+
+ var sub = item_node.firstChild();
+ while (sub) |s| : (sub = s.nextSibling()) {
+ renderBlock(s, ids, ctx);
+ }
+ }
+ },
+ md.c.CMARK_NODE_ITEM => {
+ var c = n.firstChild();
+ while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx);
+ },
+ md.c.CMARK_NODE_CODE_BLOCK => {
+ const info = n.fenceInfo() orelse "";
+ const code = n.literal() orelse "";
+ var outer = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 6 },
+ .background = true,
+ .color_fill = dvui.themeGet().color(.window, .fill).opacity(0.9),
+ .corner_radius = dvui.Rect.all(6),
+ .border = dvui.Rect.all(1),
+ .color_border = dvui.themeGet().border.opacity(0.35),
+ .id_extra = ids.next(),
+ });
+ defer outer.deinit();
+
+ if (info.len > 0) {
+ var hdr = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ .padding = .{ .x = 10, .y = 5, .w = 10, .h = 5 },
+ .background = true,
+ .color_fill = dvui.themeGet().border.opacity(0.12),
+ .id_extra = ids.next(),
+ });
+ defer hdr.deinit();
+ var tl_i = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .id_extra = ids.next() });
+ tl_i.addText(info, .{
+ .font = dvui.Font.theme(.mono).withWeight(.bold),
+ .color_text = dvui.themeGet().color(.control, .text).opacity(0.55),
+ });
+ tl_i.deinit();
+ }
+
+ var tl_c = dvui.textLayout(@src(), .{}, .{
+ .expand = .horizontal,
+ .padding = .{ .x = 10, .y = 8, .w = 10, .h = 8 },
+ .id_extra = ids.next(),
+ });
+ defer tl_c.deinit();
+ tl_c.addText(code, .{ .font = dvui.Font.theme(.mono) });
+ },
+ md.c.CMARK_NODE_HTML_BLOCK => {
+ if (n.literal()) |h| {
+ var tl = dvui.textLayout(@src(), .{}, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 2 },
+ .padding = .{ .x = 8, .y = 4, .w = 8, .h = 4 },
+ .background = true,
+ .color_fill = dvui.themeGet().color(.err, .fill).opacity(0.08),
+ .id_extra = ids.next(),
+ });
+ defer tl.deinit();
+ tl.addText(h, .{
+ .font = dvui.Font.theme(.mono),
+ .color_text = dvui.themeGet().color(.err, .text).opacity(0.85),
+ });
+ }
+ },
+ md.c.CMARK_NODE_PARAGRAPH => {
+ if (!hasImageSubtree(ctx, n)) {
+ var tl = dvui.textLayout(@src(), .{}, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 4, .h = 4 },
+ .id_extra = ids.next(),
+ });
+ defer tl.deinit();
+ renderInlines(tl, n, .{}, ctx, ids);
+ } else {
+ var outer = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 4, .h = 4 },
+ .id_extra = ids.next(),
+ });
+ defer outer.deinit();
+ renderInlineFlowContainer(n, .{}, ctx, ids);
+ }
+ },
+ md.c.CMARK_NODE_HEADING => {
+ const level = @max(1, @min(6, n.headingLevel()));
+ const size_bump: f32 = switch (level) {
+ 1 => 9,
+ 2 => 6,
+ 3 => 3,
+ 4 => 1,
+ else => 0,
+ };
+ const top_margin: f32 = switch (level) {
+ 1 => 18,
+ 2 => 14,
+ 3 => 10,
+ else => 7,
+ };
+ const heading_font = dvui.Font.theme(.heading).larger(size_bump - 2).withWeight(.bold);
+ const span: dvui.Options = .{ .font = heading_font };
+
+ if (!hasImageSubtree(ctx, n)) {
+ var tl = dvui.textLayout(@src(), .{}, .{
+ .expand = .horizontal,
+ .margin = .{ .y = top_margin, .h = 2 },
+ .font = heading_font,
+ .id_extra = ids.next(),
+ });
+ defer tl.deinit();
+ renderInlines(tl, n, span, ctx, ids);
+ } else {
+ var outer = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .horizontal,
+ .margin = .{ .y = top_margin, .h = 2 },
+ .id_extra = ids.next(),
+ });
+ defer outer.deinit();
+ renderInlineFlowContainer(n, span, ctx, ids);
+ }
+ },
+ md.c.CMARK_NODE_THEMATIC_BREAK => {
+ _ = dvui.separator(@src(), .{
+ .expand = .horizontal,
+ .margin = .{ .y = 10, .h = 10 },
+ .color_fill = dvui.themeGet().border.opacity(0.45),
+ .id_extra = ids.next(),
+ });
+ },
+ md.c.CMARK_NODE_FOOTNOTE_DEFINITION => {
+ if (n.literal()) |name| {
+ var tl = dvui.textLayout(@src(), .{}, .{
+ .expand = .horizontal,
+ .margin = .{ .y = 4 },
+ .id_extra = ids.next(),
+ });
+ const fn_font = dvui.Font.theme(.mono).larger(-1);
+ const fn_color = dvui.themeGet().focus.opacity(0.8);
+ tl.addText("[^", .{ .font = fn_font, .color_text = fn_color });
+ tl.addText(name, .{ .font = fn_font, .color_text = fn_color });
+ tl.addText("]: ", .{ .font = fn_font, .color_text = fn_color });
+ tl.deinit();
+ }
+ var c = n.firstChild();
+ while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx);
+ },
+ else => {
+ if (extKind(ctx, n) == .table) {
+ const arena = dvui.currentWindow().arena();
+
+ const num_cols = ctx.rs.table_col_counts.get(@intFromPtr(n.n)) orelse return;
+ if (num_cols == 0) return;
+
+ var table_wrap = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .none,
+ .margin = .{ .y = 6 },
+ .id_extra = ids.next(),
+ });
+ defer table_wrap.deinit();
+
+ var g = dvui.grid(@src(), .numCols(num_cols), .{
+ .scroll_opts = .{
+ .horizontal_bar = .auto,
+ .vertical_bar = .hide,
+ },
+ }, .{
+ .expand = .none,
+ .background = true,
+ .color_fill = dvui.themeGet().color(.window, .fill).opacity(0.3),
+ .corner_radius = dvui.Rect.all(4),
+ .border = dvui.Rect.all(1),
+ .color_border = dvui.themeGet().border.opacity(0.3),
+ .id_extra = ids.next(),
+ });
+ defer g.deinit();
+
+ const banded: dvui.GridWidget.CellStyle.Banded = .{
+ .alt_cell_opts = .{
+ .color_fill = dvui.themeGet().color(.control, .fill_press),
+ .background = true,
+ },
+ };
+
+ const cell_padding: dvui.Rect = .{ .x = 8, .y = 5, .w = 8, .h = 5 };
+
+ var body_row: usize = 0;
+ var c = n.firstChild();
+ while (c) |row| : (c = row.nextSibling()) {
+ const rk = extKind(ctx, row);
+ if (rk != .table_row and rk != .table_header) continue;
+
+ if (rk == .table_header) {
+ var col: usize = 0;
+ var cl = row.firstChild();
+ while (cl) |cell| : (cl = cell.nextSibling()) {
+ if (extKind(ctx, cell) != .table_cell) continue;
+ const label = linkLabelPlainText(cell, arena) catch "";
+ const cell_pos: dvui.GridWidget.Cell = .colRow(col, 0);
+ var hdr_cell_opts = banded.cellOptions(cell_pos);
+ hdr_cell_opts.padding = cell_padding;
+ var hcell = g.headerCell(@src(), col, hdr_cell_opts);
+ defer hcell.deinit();
+ dvui.labelNoFmt(@src(), label, .{}, .{
+ .expand = .horizontal,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .font = dvui.Font.theme(.body).withWeight(.bold),
+ .id_extra = ids.next(),
+ });
+ col += 1;
+ }
+ } else {
+ var col: usize = 0;
+ var cl = row.firstChild();
+ while (cl) |cell| : (cl = cell.nextSibling()) {
+ if (extKind(ctx, cell) != .table_cell) continue;
+ const cell_pos: dvui.GridWidget.Cell = .colRow(col, body_row);
+ var cell_opts = banded.cellOptions(cell_pos);
+ cell_opts.padding = cell_padding;
+ var cell_box = g.bodyCell(@src(), cell_pos, cell_opts);
+ defer cell_box.deinit();
+ renderInlineFlowContainer(cell, .{ .background = false }, ctx, ids);
+ col += 1;
+ }
+ body_row += 1;
+ }
+ }
+ } else {
+ var c = n.firstChild();
+ while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx);
+ }
+ },
+ }
+}
+
+pub fn renderDocument(root: md.Node, ctx: RenderContext) void {
+ var ids: IdGen = .{ .n = ctx.id_base };
+ renderBlock(root, &ids, ctx);
+}
diff --git a/src/paths.zig b/src/paths.zig
deleted file mode 100644
index 721da93f..00000000
--- a/src/paths.zig
+++ /dev/null
@@ -1,41 +0,0 @@
-const std = @import("std");
-const builtin = @import("builtin");
-const known_folders = @import("known-folders");
-
-pub fn configRoot(
- io: std.Io,
- arena: std.mem.Allocator,
- environ: std.process.Environ,
- fallback: []const u8,
-) ![]const u8 {
- if (comptime builtin.target.cpu.arch == .wasm32) return fallback;
- var environ_map = try environ.createMap(arena);
- defer environ_map.deinit();
- return known_folders.getPath(io, arena, environ_map, .local_configuration) catch fallback orelse fallback;
-}
-
-pub fn configFolder(
- allocator: std.mem.Allocator,
- io: std.Io,
- arena: std.mem.Allocator,
- environ: std.process.Environ,
- fallback: []const u8,
-) ![]const u8 {
- const config_root = try configRoot(io, arena, environ, fallback);
- return std.fs.path.join(allocator, &.{ config_root, "fizzy" }) catch fallback;
-}
-
-pub fn configFolderZ(
- buf: []u8,
- io: std.Io,
- environ: std.process.Environ,
- fallback: []const u8,
-) ?[:0]const u8 {
- var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
- defer arena.deinit();
- const folder = configFolder(arena.allocator(), io, arena.allocator(), environ, fallback) catch return null;
- if (folder.len + 1 > buf.len) return null;
- @memcpy(buf[0..folder.len], folder);
- buf[folder.len] = 0;
- return buf[0..folder.len :0];
-}
diff --git a/src/plugins/example/build.zig b/src/plugins/example/build.zig
new file mode 100644
index 00000000..53372cb0
--- /dev/null
+++ b/src/plugins/example/build.zig
@@ -0,0 +1,20 @@
+//! Standalone build for the example plugin — the canonical third-party shape, and the simplest
+//! possible one: declare `fizzy`, call `fizzy.plugin.create` (defaults its root to `root.zig`),
+//! then `fizzy.plugin.install`. Copy this for a new pure-Zig plugin. `zig build install` builds it
+//! and installs it into this OS's fizzy plugins dir (the editor loads it on next launch), and also
+//! leaves `zig-out/example.` for packaging. See docs/PLUGINS.md.
+const std = @import("std");
+const fizzy = @import("fizzy");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const lib = fizzy.plugin.create(b, .{
+ .name = "example",
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("root.zig"),
+ });
+ fizzy.plugin.install(b, lib, .{});
+}
diff --git a/src/plugins/example/build.zig.zon b/src/plugins/example/build.zig.zon
new file mode 100644
index 00000000..77821601
--- /dev/null
+++ b/src/plugins/example/build.zig.zon
@@ -0,0 +1,19 @@
+.{
+ .name = .example,
+ .version = "0.0.0",
+ .minimum_zig_version = "0.16.0",
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "root.zig",
+ "example.zig",
+ "src",
+ "static",
+ },
+ .fingerprint = 0x6eec9b9f328e055f,
+ .dependencies = .{
+ .fizzy = .{
+ .path = "../../..",
+ },
+ },
+}
diff --git a/src/plugins/example/example.zig b/src/plugins/example/example.zig
new file mode 100644
index 00000000..b1428978
--- /dev/null
+++ b/src/plugins/example/example.zig
@@ -0,0 +1,14 @@
+//! Example plugin root module **and** intra-plugin import hub — the conventional `.zig`.
+//!
+//! - The shell resolves `@import("example")` to this file when the plugin is compiled into the
+//! app (static embed); `example.plugin` is its entry.
+//! - Files under `src/` import it as `../example.zig` for shared deps (`sdk`/`dvui`) and types.
+//!
+//! A minimal plugin keeps this tiny — it grows into the plugin's shared namespace as `src/`
+//! gains files. It must sit at the plugin root (a Zig module can't import above its root file's
+//! directory). The build-side static-embed glue lives in `static/`.
+pub const sdk = @import("sdk");
+pub const dvui = @import("dvui");
+
+pub const plugin = @import("src/plugin.zig");
+pub const State = @import("src/State.zig");
diff --git a/src/plugins/example/root.zig b/src/plugins/example/root.zig
new file mode 100644
index 00000000..49583a65
--- /dev/null
+++ b/src/plugins/example/root.zig
@@ -0,0 +1,8 @@
+//! Dylib entry for the example plugin — the canonical third-party shape (identical to
+//! `src/plugins/root.zig`): one `exportEntry` call wired to `src/plugin.zig`. Copy this verbatim
+//! into a new plugin; you never edit it.
+const sdk = @import("sdk");
+
+comptime {
+ sdk.dylib.exportEntry(@import("src/plugin.zig"));
+}
diff --git a/src/plugins/example/src/State.zig b/src/plugins/example/src/State.zig
new file mode 100644
index 00000000..79a6b72f
--- /dev/null
+++ b/src/plugins/example/src/State.zig
@@ -0,0 +1,11 @@
+//! Example plugin state. A plugin owns whatever state it needs; the host injects only the
+//! allocator and `*Host` (read via `sdk.allocator()` / `sdk.host()`), so this is just a plain
+//! struct the plugin holds. Trivial here — a real plugin keeps documents, caches, settings, etc.
+const std = @import("std");
+
+clicks: u64 = 0,
+
+pub fn deinit(self: *@This(), gpa: std.mem.Allocator) void {
+ _ = self;
+ _ = gpa;
+}
diff --git a/src/plugins/example/src/plugin.zig b/src/plugins/example/src/plugin.zig
new file mode 100644
index 00000000..7b7305f0
--- /dev/null
+++ b/src/plugins/example/src/plugin.zig
@@ -0,0 +1,80 @@
+//! Example plugin — the canonical, minimal Fizzy plugin and the copy-me template for new
+//! plugins. It registers a single sidebar view that renders a greeting and a click counter:
+//! the smallest useful shape, namely identity + `register` + one `Host.register*` contribution
+//! + plugin-owned state. The host injects only the allocator and `*Host` (read through
+//! `sdk.allocator()` / `sdk.host()`), so there is no storage file to write.
+//!
+//! This plugin implements no document hooks — it is a "shell" plugin (contributes a pane), not
+//! an "editor" plugin (opens/saves/draws files). For the editor shape, see the `code` plugin.
+//!
+//! To start a new plugin: copy this folder, rename the id/name, and implement your feature in
+//! `src/plugin.zig`. See docs/PLUGINS.md.
+const std = @import("std");
+// Shared deps + sibling types come through the plugin's `.zig` hub (`../example.zig`),
+// the conventional `@import("")` namespace. A single-file plugin could import `sdk`
+// and `dvui` directly; using the hub is what scales as `src/` grows.
+const example = @import("../example.zig");
+const sdk = example.sdk;
+const dvui = example.dvui;
+const State = example.State;
+
+/// Identity + versions embedded in the dylib (and read by the host on load).
+pub const manifest = sdk.PluginManifest{
+ .id = "example",
+ .name = "Example",
+ .version = .{ .major = 0, .minor = 1, .patch = 0 },
+};
+
+/// Stable, plugin-namespaced contribution id.
+const view_hello = "example.hello";
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .id = "example",
+ .display_name = "Example",
+};
+
+/// Only the hooks this plugin needs; every other vtable field stays `null`.
+const vtable: sdk.Plugin.VTable = .{
+ .deinit = deinit,
+};
+
+/// The plugin's own singleton state — just a variable it owns. The SDK holds gpa/host.
+var plugin_state: State = .{};
+
+/// Entry point the host calls once at startup (static) or after dlopen (dynamic). Wire state,
+/// register the plugin, then add any sidebar/bottom/center/menu/settings contributions.
+pub fn register(host: *sdk.Host) !void {
+ plugin.state = @ptrCast(&plugin_state);
+ try host.registerPlugin(&plugin);
+ try host.registerSidebarView(.{
+ .id = view_hello,
+ .owner = &plugin,
+ .icon = dvui.entypo.rocket,
+ .title = "Example",
+ .draw = drawHello,
+ });
+}
+
+/// Stable `*Plugin` for constructing `DocHandle.owner` / lookups (unused here, but part of the
+/// conventional plugin surface).
+pub fn pluginPtr() *sdk.Plugin {
+ return &plugin;
+}
+
+fn deinit(_: *anyopaque) void {
+ plugin_state.deinit(sdk.allocator());
+}
+
+/// Fills the left pane while this sidebar view is active.
+fn drawHello(_: ?*anyopaque) anyerror!void {
+ var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .margin = .all(8) });
+ defer box.deinit();
+
+ dvui.label(@src(), "Hello from the example plugin!", .{}, .{});
+ dvui.label(@src(), "Clicks: {d}", .{plugin_state.clicks}, .{});
+ if (dvui.button(@src(), "Click me", .{}, .{ .expand = .horizontal })) {
+ plugin_state.clicks += 1;
+ }
+}
diff --git a/src/plugins/example/static/integration.zig b/src/plugins/example/static/integration.zig
new file mode 100644
index 00000000..817906c8
--- /dev/null
+++ b/src/plugins/example/static/integration.zig
@@ -0,0 +1,59 @@
+//! Example plugin — fizzy-internal static-embed + bundled-dylib module graph.
+//! Runs only from the fizzy build root, so paths are single fizzy-relative literals.
+const std = @import("std");
+const helpers = @import("../../shared/build/helpers.zig");
+
+pub const id = "example";
+pub const installDylib = helpers.installDylib;
+
+const module_path = "src/plugins/example/example.zig";
+const dylib_path = "src/plugins/example/root.zig";
+
+pub const ModuleImports = struct {
+ dvui: *std.Build.Module,
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+ proxy_bridge: ?*std.Build.Module = null,
+};
+
+fn applyImports(module: *std.Build.Module, imports: ModuleImports) void {
+ module.addImport("dvui", imports.dvui);
+ module.addImport("core", imports.core);
+ module.addImport("sdk", imports.sdk);
+ if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge);
+}
+
+/// Static `@import("example")` module for exe / web / tests.
+pub fn addStaticModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+ consumer: *std.Build.Module,
+) *std.Build.Module {
+ const mod = helpers.addStaticModule(b, .{
+ .import_name = id,
+ .root_source_file = b.path(module_path),
+ .target = target,
+ .optimize = optimize,
+ }, consumer);
+ applyImports(mod, imports);
+ return mod;
+}
+
+/// Native dynamic library bundled beside the app (`example.dylib` / `.dll` / `.so`).
+pub fn addDylib(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+) *std.Build.Step.Compile {
+ const lib = helpers.addDylib(b, .{
+ .name = id,
+ .root_source_file = b.path(dylib_path),
+ .target = target,
+ .optimize = optimize,
+ });
+ applyImports(lib.root_module, imports);
+ return lib;
+}
diff --git a/src/plugins/shared/build/helpers.zig b/src/plugins/shared/build/helpers.zig
new file mode 100644
index 00000000..3551f235
--- /dev/null
+++ b/src/plugins/shared/build/helpers.zig
@@ -0,0 +1,93 @@
+//! Fizzy-internal build helpers for the static-embed + bundled-dylib graph of built-in
+//! plugins. These always run from the fizzy build root, so every path is a single
+//! fizzy-relative `b.path(...)` — there is no plugin-package root to disambiguate.
+//! Third-party plugins never touch this; they use `fizzy.plugin.create` / `.install`.
+const std = @import("std");
+
+/// C-ABI entry symbols the host looks up. Kept in sync with `plugin_sdk.dylib_exports`
+/// (the third-party path); duplicated here to avoid a deep relative import.
+pub const dylib_exports = [_][]const u8{
+ "fizzy_plugin_abi_fingerprint",
+ "fizzy_plugin_sdk_version",
+ "fizzy_plugin_min_sdk_version",
+ "fizzy_plugin_version",
+ "fizzy_plugin_id",
+ "fizzy_plugin_register",
+ "fizzy_plugin_set_dvui_context",
+ "fizzy_plugin_set_render_bridge",
+ "fizzy_plugin_set_globals",
+};
+
+pub const StaticModuleOptions = struct {
+ import_name: []const u8,
+ root_source_file: std.Build.LazyPath,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ options_name: ?[]const u8 = null,
+ options: ?*std.Build.Step.Options = null,
+};
+
+pub fn addStaticModule(
+ b: *std.Build,
+ opts: StaticModuleOptions,
+ consumer: *std.Build.Module,
+) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = opts.root_source_file,
+ .link_libc = opts.target.result.cpu.arch != .wasm32,
+ .single_threaded = opts.target.result.cpu.arch == .wasm32,
+ });
+ if (opts.options_name) |name| {
+ if (opts.options) |o| mod.addOptions(name, o);
+ }
+ consumer.addImport(opts.import_name, mod);
+ return mod;
+}
+
+pub const DylibOptions = struct {
+ name: []const u8,
+ root_source_file: std.Build.LazyPath,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ options_name: ?[]const u8 = null,
+ options: ?*std.Build.Step.Options = null,
+};
+
+pub fn addDylib(
+ b: *std.Build,
+ opts: DylibOptions,
+) *std.Build.Step.Compile {
+ const dylib_module = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = opts.root_source_file,
+ .link_libc = true,
+ });
+ if (opts.options_name) |name| {
+ if (opts.options) |o| dylib_module.addOptions(name, o);
+ }
+ const lib = b.addLibrary(.{
+ .name = opts.name,
+ .linkage = .dynamic,
+ .root_module = dylib_module,
+ });
+ lib.linker_allow_shlib_undefined = true;
+ lib.root_module.export_symbol_names = &dylib_exports;
+ return lib;
+}
+
+pub fn installDylib(b: *std.Build, lib: *std.Build.Step.Compile, name: []const u8) void {
+ const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) {
+ .windows => "dll",
+ .macos => "dylib",
+ else => "so",
+ };
+ const dest = b.fmt("{s}.{s}", .{ name, ext });
+ const install_step = b.addInstallArtifact(lib, .{
+ .dest_dir = .{ .override = .prefix },
+ .dest_sub_path = dest,
+ });
+ b.getInstallStep().dependOn(&install_step.step);
+}
diff --git a/src/plugins/text/build.zig b/src/plugins/text/build.zig
new file mode 100644
index 00000000..1f782fc4
--- /dev/null
+++ b/src/plugins/text/build.zig
@@ -0,0 +1,20 @@
+//! Standalone build for the text plugin — the canonical third-party shape.
+//! `cd src/plugins/text && zig build` produces `text.`. Identical in form to
+//! any external plugin: declare `fizzy`, call `fizzy.plugin.create` + `.install`. The
+//! fizzy-internal static-embed build lives separately in `static/` and is driven by the
+//! root build. See docs/PLUGINS.md.
+const std = @import("std");
+const fizzy = @import("fizzy");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const lib = fizzy.plugin.create(b, .{
+ .name = "text",
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("root.zig"),
+ });
+ fizzy.plugin.install(b, lib, .{});
+}
diff --git a/src/plugins/text/build.zig.zon b/src/plugins/text/build.zig.zon
new file mode 100644
index 00000000..17c13c6c
--- /dev/null
+++ b/src/plugins/text/build.zig.zon
@@ -0,0 +1,20 @@
+.{
+ .name = .text,
+ .version = "0.0.0",
+ .minimum_zig_version = "0.16.0",
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "root.zig",
+ "text.zig",
+ "src",
+ "queries",
+ "static",
+ },
+ .fingerprint = 0x77153098cc8cce17,
+ .dependencies = .{
+ .fizzy = .{
+ .path = "../../..",
+ },
+ },
+}
diff --git a/src/plugins/text/queries/zig.scm b/src/plugins/text/queries/zig.scm
new file mode 100644
index 00000000..f4d1217a
--- /dev/null
+++ b/src/plugins/text/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/text/root.zig b/src/plugins/text/root.zig
new file mode 100644
index 00000000..f3868120
--- /dev/null
+++ b/src/plugins/text/root.zig
@@ -0,0 +1,7 @@
+//! Dylib entry for the text plugin — identical in shape to the canonical third-party
+//! `src/plugins/root.zig`: one `exportEntry` call wired to `src/plugin.zig`.
+const sdk = @import("sdk");
+
+comptime {
+ sdk.dylib.exportEntry(@import("src/plugin.zig"));
+}
diff --git a/src/plugins/text/src/CodeEditor.zig b/src/plugins/text/src/CodeEditor.zig
new file mode 100644
index 00000000..a1f21448
--- /dev/null
+++ b/src/plugins/text/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("../text.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/text/src/Document.zig b/src/plugins/text/src/Document.zig
new file mode 100644
index 00000000..d6b78421
--- /dev/null
+++ b/src/plugins/text/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("../text.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/text/src/State.zig b/src/plugins/text/src/State.zig
new file mode 100644
index 00000000..db4bb32e
--- /dev/null
+++ b/src/plugins/text/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/text/src/SyntaxHighlight.zig b/src/plugins/text/src/SyntaxHighlight.zig
new file mode 100644
index 00000000..e7d31f15
--- /dev/null
+++ b/src/plugins/text/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("../text.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/text/src/plugin.zig b/src/plugins/text/src/plugin.zig
new file mode 100644
index 00000000..6e723252
--- /dev/null
+++ b/src/plugins/text/src/plugin.zig
@@ -0,0 +1,226 @@
+//! The text editor plugin: universal fallback owner for plain-text documents, rendered as
+//! editable, monospace tabs. Registration + the document vtable. Registered from
+//! `Editor.postInit`; document state lives in `State.docs`.
+const std = @import("std");
+const internal = @import("../text.zig");
+const sdk = internal.sdk;
+const dvui = internal.dvui;
+const State = internal.State;
+const Document = internal.Document;
+const CodeEditor = internal.CodeEditor;
+const DocHandle = sdk.DocHandle;
+
+pub const manifest = sdk.PluginManifest{
+ .id = "text",
+ .name = "Text",
+ .version = .{ .major = 0, .minor = 1, .patch = 0 },
+};
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .id = "text",
+ .display_name = "Text",
+};
+
+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);
+ try host.registerFileIcon(.{ .owner = &plugin, .draw = drawFileIcon });
+}
+
+/// 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;
+}
+
+/// Source/text extensions this editor draws a code glyph for in the file tree. Anything else
+/// (archives, unknown binaries, …) returns false so the workbench draws its generic icon.
+fn isTextIconExt(ext: []const u8) bool {
+ const text_exts = [_][]const u8{
+ ".zig", ".json", ".txt", ".atlas", ".md", ".markdown", ".c", ".h", ".cpp",
+ ".hpp", ".cc", ".js", ".ts", ".jsx", ".tsx", ".html", ".htm", ".css",
+ ".xml", ".yml", ".yaml", ".toml", ".ini", ".sh", ".bash", ".zsh", ".py",
+ ".rs", ".go", ".lua", ".rb", ".java", ".cs", ".php", ".sql", ".csv",
+ ".log", ".conf", ".cfg",
+ };
+ for (text_exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) return true;
+ return false;
+}
+
+fn drawFileIcon(_: ?*anyopaque, ext: []const u8, _: []const u8, color: dvui.Color) bool {
+ if (!isTextIconExt(ext)) return false;
+ dvui.icon(@src(), "CodeFileIcon", dvui.entypo.code, .{ .stroke_color = color, .fill_color = color }, .{
+ .gravity_y = 0.5,
+ .padding = dvui.Rect.all(3),
+ .background = false,
+ });
+ return true;
+}
+
+// ---- document staging buffer -------------------------------------------------
+
+fn documentStackSize(_: *anyopaque) usize {
+ 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/text/src/widgets/TextEntryWidget.zig b/src/plugins/text/src/widgets/TextEntryWidget.zig
new file mode 100644
index 00000000..263c0e6a
--- /dev/null
+++ b/src/plugins/text/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("../../text.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/text/src/widgets/TreeSitterQueryPredicates.zig b/src/plugins/text/src/widgets/TreeSitterQueryPredicates.zig
new file mode 100644
index 00000000..a8718746
--- /dev/null
+++ b/src/plugins/text/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("../../text.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/text/static/integration.zig b/src/plugins/text/static/integration.zig
new file mode 100644
index 00000000..32f188a3
--- /dev/null
+++ b/src/plugins/text/static/integration.zig
@@ -0,0 +1,59 @@
+//! Text plugin — fizzy-internal static-embed + bundled-dylib module graph.
+//! Runs only from the fizzy build root, so paths are single fizzy-relative literals.
+const std = @import("std");
+const helpers = @import("../../shared/build/helpers.zig");
+
+pub const id = "text";
+pub const installDylib = helpers.installDylib;
+
+const module_path = "src/plugins/text/text.zig";
+const dylib_path = "src/plugins/text/root.zig";
+
+pub const ModuleImports = struct {
+ dvui: *std.Build.Module,
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+ proxy_bridge: ?*std.Build.Module = null,
+};
+
+fn applyImports(module: *std.Build.Module, imports: ModuleImports) void {
+ module.addImport("dvui", imports.dvui);
+ module.addImport("core", imports.core);
+ module.addImport("sdk", imports.sdk);
+ if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge);
+}
+
+/// Static `@import("text")` module for exe / web / tests.
+pub fn addStaticModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+ consumer: *std.Build.Module,
+) *std.Build.Module {
+ const mod = helpers.addStaticModule(b, .{
+ .import_name = id,
+ .root_source_file = b.path(module_path),
+ .target = target,
+ .optimize = optimize,
+ }, consumer);
+ applyImports(mod, imports);
+ return mod;
+}
+
+/// Native dynamic library bundled beside the app (`text.dylib` / `.dll` / `.so`).
+pub fn addDylib(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ imports: ModuleImports,
+) *std.Build.Step.Compile {
+ const lib = helpers.addDylib(b, .{
+ .name = id,
+ .root_source_file = b.path(dylib_path),
+ .target = target,
+ .optimize = optimize,
+ });
+ applyImports(lib.root_module, imports);
+ return lib;
+}
diff --git a/src/plugins/text/text.zig b/src/plugins/text/text.zig
new file mode 100644
index 00000000..1c0228fa
--- /dev/null
+++ b/src/plugins/text/text.zig
@@ -0,0 +1,22 @@
+//! Text plugin root module **and** intra-plugin import hub.
+//!
+//! - The shell resolves `@import("text")` to this file when the plugin is compiled into the app
+//! (static embed) and reaches its public surface here (`plugin`, document types).
+//! - Files under `src/` import it as `../text.zig` for the shared deps (`sdk`/`core`/`dvui`)
+//! and sibling types — the conventional `.zig` namespace.
+//!
+//! It must sit at the plugin root: a Zig module cannot import files above its root file's
+//! directory, so this has to be beside `src/` to re-export from it. The build-side static-embed
+//! glue lives in `static/`. A minimal/third-party plugin only needs this file if it embeds
+//! statically or wants a shared import hub.
+const std = @import("std");
+
+pub const sdk = @import("sdk");
+pub const core = @import("core");
+pub const dvui = @import("dvui");
+
+pub const plugin = @import("src/plugin.zig");
+pub const State = @import("src/State.zig");
+pub const Document = @import("src/Document.zig");
+pub const CodeEditor = @import("src/CodeEditor.zig");
+pub const SyntaxHighlight = @import("src/SyntaxHighlight.zig");
diff --git a/src/plugins/workbench/build.zig b/src/plugins/workbench/build.zig
new file mode 100644
index 00000000..fe3d88a5
--- /dev/null
+++ b/src/plugins/workbench/build.zig
@@ -0,0 +1,34 @@
+//! Standalone build for the workbench plugin — the canonical third-party shape.
+//! `cd src/plugins/workbench && zig build` produces `workbench.`. The
+//! `-Dworkbench-file-tree` option feeds a `workbench_opts` module the plugin imports;
+//! attaching a build-options module to a `fizzy.plugin.create` lib is exactly how any
+//! third-party plugin would expose compile-time flags. See docs/PLUGINS.md.
+const std = @import("std");
+const fizzy = @import("fizzy");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const file_tree = b.option(
+ bool,
+ "workbench-file-tree",
+ "Register the Files sidebar view at compile time",
+ ) orelse true;
+ const workbench_opts = b.addOptions();
+ workbench_opts.addOption(bool, "file_tree", file_tree);
+
+ const lib = fizzy.plugin.create(b, .{
+ .name = "workbench",
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("root.zig"),
+ });
+ lib.root_module.addOptions("workbench_opts", workbench_opts);
+
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ lib.root_module.addImport("icons", dep.module("icons"));
+ }
+
+ fizzy.plugin.install(b, lib, .{});
+}
diff --git a/src/plugins/workbench/build.zig.zon b/src/plugins/workbench/build.zig.zon
new file mode 100644
index 00000000..9e850490
--- /dev/null
+++ b/src/plugins/workbench/build.zig.zon
@@ -0,0 +1,24 @@
+.{
+ .name = .workbench,
+ .version = "0.0.0",
+ .minimum_zig_version = "0.16.0",
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "root.zig",
+ "workbench.zig",
+ "src",
+ "static",
+ },
+ .fingerprint = 0xc23e2206858de248,
+ .dependencies = .{
+ .fizzy = .{
+ .path = "../../..",
+ },
+ .icons = .{
+ .url = "https://github.com/foxnne/zig-lib-icons/archive/db034786a1286ab28dc35aba534c098aa4f1a3aa.tar.gz",
+ .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_",
+ .lazy = true,
+ },
+ },
+}
diff --git a/src/plugins/workbench/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..3a91ff70
--- /dev/null
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -0,0 +1,1026 @@
+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;
+
+ 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;
+ }
+
+ // The owning plugin draws the tab icon for its file types (same hook as the file
+ // tree); the workbench falls back to a generic file glyph.
+ const tab_doc_path = doc.owner.documentPath(doc);
+ const tab_icon_color = dvui.themeGet().color(.control, .text);
+ if (!runtime.host().drawFileIcon(std.fs.path.extension(tab_doc_path), tab_doc_path, tab_icon_color)) {
+ dvui.icon(@src(), "file_icon", icons.tvg.lucide.file, .{
+ .stroke_color = tab_icon_color,
+ }, .{
+ .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,
+ });
+
+ // NOTE: a "New File" button used to live here, but it dispatched to a specific editor
+ // plugin's new-document dialog (pixel-art), which isn't generic. It's removed so the
+ // homepage stays plugin-neutral; a future hook will let plugins contribute homepage
+ // entries / their own homepages. New File is still reachable via the menu + file-tree.
+ {
+ var button: dvui.ButtonWidget = undefined;
+ button.init(@src(), .{ .draw_focus = true }, .{
+ .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 77%
rename from src/editor/explorer/files.zig
rename to src/plugins/workbench/src/files.zig
index 18b2f379..1484f9ad 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);
}
}
}
@@ -769,31 +724,22 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
.file => {
const ext = extension(entry.name);
//if (ext == .hidden) continue;
- const icon = switch (ext) {
- .fizzy, .psd => icons.tvg.lucide.@"file-pen-line",
- .jpg, .png, .aseprite, .pyxel, .gif => icons.tvg.entypo.picture,
- .pdf => icons.tvg.entypo.@"doc-text",
- .json, .zig, .txt, .atlas => icons.tvg.entypo.code,
- .tar, ._7z, .zip => icons.tvg.entypo.archive,
- else => icons.tvg.entypo.archive,
- };
-
const icon_color = color;
- const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color;
-
- if (ext == .fizzy) {
- _ = 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, .margin = padding, .padding = padding, .background = false },
- );
- } else {
+ // The plugin that owns this file type draws its own icon (see
+ // `Host.registerFileIcon`); the workbench only falls back to generic
+ // filesystem icons when no plugin claims it.
+ if (!runtime.host().drawFileIcon(std.fs.path.extension(entry.name), abs_path, icon_color)) {
+ const icon = switch (ext) {
+ .pdf => icons.tvg.entypo.@"doc-text",
+ .tar, ._7z, .zip => icons.tvg.entypo.archive,
+ else => icons.tvg.entypo.archive,
+ };
dvui.icon(
@src(),
"FileIcon",
icon,
- .{ .stroke_color = file_icon_color, .fill_color = file_icon_color },
+ .{ .stroke_color = icon_color, .fill_color = icon_color },
.{
.gravity_y = 0.5,
.padding = padding,
@@ -804,24 +750,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 +768,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 +844,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 +855,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 +884,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 +974,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 +1029,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 +1102,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 +1152,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 +1162,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 +1171,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 +1243,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..232cc4d4
--- /dev/null
+++ b/src/plugins/workbench/workbench.zig
@@ -0,0 +1,28 @@
+//! 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 platform = core.platform;
+pub const perf = core.perf;
+
+/// 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..052fdf8f
--- /dev/null
+++ b/src/sdk/EditorAPI.zig
@@ -0,0 +1,375 @@
+//! 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();
+
+/// 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,
+ /// 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 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..9c35b036
--- /dev/null
+++ b/src/sdk/Host.zig
@@ -0,0 +1,848 @@
+//! 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 {
+ /// Contributing plugin (null = shell built-in). Used to scope teardown in
+ /// `unregisterPlugin` when a plugin is unloaded at runtime.
+ owner: ?*Plugin = null,
+ ctx: ?*anyopaque = null,
+ color: *const fn (ctx: ?*anyopaque, color_index: usize) ?dvui.Color,
+};
+
+/// A registered inter-plugin service plus the plugin that owns it, so a runtime
+/// unload can remove the owner's services. `owner` is null for shell-registered
+/// services with no single plugin owner.
+pub const ServiceEntry = struct {
+ ptr: *anyopaque,
+ owner: ?*Plugin = null,
+};
+
+/// A file-tree row icon drawer. The workbench file tree calls registered drawers in order at
+/// each file row's icon slot; the first that returns `true` wins, otherwise the workbench draws
+/// a generic filesystem default. This lets the plugin that owns a file type draw its own icon (a
+/// glyph, a thumbnail, anything) instead of the shell hardcoding per-extension icons. `ext` is
+/// the extension including the dot, as on disk (compare case-insensitively); `path` is absolute;
+/// `color` is the row's themed icon color.
+pub const FileIcon = struct {
+ owner: ?*Plugin = null,
+ ctx: ?*anyopaque = null,
+ draw: *const fn (ctx: ?*anyopaque, ext: []const u8, path: []const u8, color: dvui.Color) bool,
+};
+
+allocator: std.mem.Allocator,
+
+/// All registered plugins (statically compiled in, or loaded from a runtime dylib).
+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(ServiceEntry) = .empty,
+
+/// The shell's read/utility surface (arena, folder, shared settings, dirty mark),
+/// installed by the shell during startup. Null until installed (headless/test).
+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,
+
+/// File-tree row icon drawers (workbench asks the Host; plugins register for their file types).
+file_icons: std.ArrayListUnmanaged(FileIcon) = .empty,
+
+// ---- shell region registries -----------------------------------------------
+// The shell iterates these instead of hardcoded enums/switches. Items keep their
+// registration order, which is the order they appear in the UI.
+
+/// 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);
+ self.file_icons.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);
+}
+
+/// 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);
+}
+
+/// Remove every contribution, service, and registry entry owned by `plugin`, then drop
+/// the plugin itself. The inverse of `registerPlugin` + the `register*` calls a plugin
+/// makes in its `register`. Used by the runtime unload path (the store's "disable" /
+/// "uninstall"); built-in plugins are never unregistered.
+///
+/// **Ordering matters for the dylib case:** a contribution's `id`/`title` slices and the
+/// `*Plugin` itself live in the plugin image's static memory. The caller must invoke
+/// this *before* `dlclose`, so that the active-selection ids (which may point into that
+/// image) are compared and reset while the memory is still mapped.
+pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void {
+ removeOwned(SidebarView, &self.sidebar_views, plugin);
+ removeOwned(BottomView, &self.bottom_views, plugin);
+ removeOwned(CenterProvider, &self.center_providers, plugin);
+ removeOwned(MenuContribution, &self.menus, plugin);
+ removeOwned(MenuSectionContribution, &self.menu_sections, plugin);
+ removeOwned(SettingsSection, &self.settings_sections, plugin);
+ removeOwned(Command, &self.commands, plugin);
+ removeOwned(FileRowFillColor, &self.file_row_fill_colors, plugin);
+ removeOwned(FileIcon, &self.file_icons, plugin);
+
+ // Services: free the owned key strings and drop the entries.
+ {
+ var it = self.services.iterator();
+ var doomed: std.ArrayListUnmanaged([]const u8) = .empty;
+ defer doomed.deinit(self.allocator);
+ while (it.next()) |e| {
+ if (e.value_ptr.owner == plugin) doomed.append(self.allocator, e.key_ptr.*) catch {};
+ }
+ for (doomed.items) |name| _ = self.services.remove(name);
+ }
+
+ // Drop the plugin from the registry (pointer identity; no `owner` field here).
+ for (self.plugins.items, 0..) |p, i| {
+ if (p == plugin) {
+ _ = self.plugins.orderedRemove(i);
+ break;
+ }
+ }
+
+ // Active-selection ids may name a now-removed view; reset so the next frame falls
+ // back to a still-registered contribution (or none).
+ if (self.active_sidebar_view) |id| {
+ if (!self.hasSidebarView(id)) self.active_sidebar_view = null;
+ }
+ if (self.active_bottom_view) |id| {
+ if (!self.hasBottomView(id)) self.active_bottom_view = null;
+ }
+ if (self.active_center) |id| {
+ if (!self.hasCenterProvider(id)) self.active_center = null;
+ }
+}
+
+/// Compact a registry in place, dropping every entry whose `owner` is `plugin`.
+/// `T` must have an `owner: ?*Plugin` field (all contribution structs do).
+fn removeOwned(comptime T: type, list: *std.ArrayListUnmanaged(T), plugin: *Plugin) void {
+ var w: usize = 0;
+ for (list.items) |item| {
+ const owned = if (item.owner) |o| o == plugin else false;
+ if (!owned) {
+ list.items[w] = item;
+ w += 1;
+ }
+ }
+ list.items.len = w;
+}
+
+fn hasSidebarView(self: *Host, id: []const u8) bool {
+ for (self.sidebar_views.items) |*v| if (std.mem.eql(u8, v.id, id)) return true;
+ return false;
+}
+
+fn hasBottomView(self: *Host, id: []const u8) bool {
+ for (self.bottom_views.items) |*v| if (std.mem.eql(u8, v.id, id)) return true;
+ return false;
+}
+
+fn hasCenterProvider(self: *Host, id: []const u8) bool {
+ for (self.center_providers.items) |*p| if (std.mem.eql(u8, p.id, id)) return true;
+ return false;
+}
+
+/// Lookup a registered plugin by stable id (`"pixi"`, `"workbench"`, …).
+pub fn pluginById(self: *Host, id: []const u8) ?*Plugin {
+ for (self.plugins.items) |plugin| {
+ if (std.mem.eql(u8, plugin.id, id)) return plugin;
+ }
+ 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 registerFileIcon(self: *Host, drawer: FileIcon) !void {
+ try self.file_icons.append(self.allocator, drawer);
+}
+
+/// Draw the file-tree row icon for `ext`/`path` via the first registered drawer that handles it.
+/// Returns true if a plugin drew it; false means the caller should draw a generic default.
+pub fn drawFileIcon(self: *Host, ext: []const u8, path: []const u8, color: dvui.Color) bool {
+ for (self.file_icons.items) |drawer| {
+ if (drawer.draw(drawer.ctx, ext, path, color)) return true;
+ }
+ return false;
+}
+
+/// Register an inter-plugin service. `owner` is the contributing plugin (null for a
+/// shell-registered service); it lets `unregisterPlugin` drop the service on unload.
+pub fn registerService(self: *Host, name: []const u8, service: *anyopaque, owner: ?*Plugin) !void {
+ try self.services.put(self.allocator, name, .{ .ptr = service, .owner = owner });
+}
+
+pub fn getService(self: *Host, name: []const u8) ?*anyopaque {
+ return if (self.services.get(name)) |entry| entry.ptr else null;
+}
+
+/// Typed service lookup. `Service` must declare `service_name` and match the registered layout.
+pub fn getServiceTyped(self: *Host, comptime Service: type) ?*Service {
+ const ptr = self.getService(Service.service_name) orelse return null;
+ return @ptrCast(@alignCast(ptr));
+}
+
+// ---- region registration (called from a plugin's register / postInit) -------
+
+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;
+ }
+ }
+}
+
+// ---- tests -----------------------------------------------------------------
+
+const testing = std.testing;
+
+test "unregisterPlugin removes a plugin's contributions, service, and resets active ids" {
+ const noopDraw = struct {
+ fn f(_: ?*anyopaque) anyerror!void {}
+ }.f;
+ const noopCenter = struct {
+ fn f(_: ?*anyopaque) anyerror!dvui.App.Result {
+ return .ok;
+ }
+ }.f;
+ const noopRun = struct {
+ fn f(_: *anyopaque) anyerror!void {}
+ }.f;
+ const noColor = struct {
+ fn f(_: ?*anyopaque, _: usize) ?dvui.Color {
+ return null;
+ }
+ }.f;
+ const noIcon = struct {
+ fn f(_: ?*anyopaque, _: []const u8, _: []const u8, _: dvui.Color) bool {
+ return false;
+ }
+ }.f;
+
+ var host = Host.init(testing.allocator);
+ defer host.deinit();
+
+ const vtable = Plugin.VTable{};
+ var plugin = Plugin{ .state = undefined, .vtable = &vtable, .id = "victim", .display_name = "Victim" };
+ var service_obj: u32 = 0;
+
+ // A second, surviving plugin so we can prove only the victim's entries are removed.
+ var keeper = Plugin{ .state = undefined, .vtable = &vtable, .id = "keeper", .display_name = "Keeper" };
+
+ try host.registerPlugin(&keeper);
+ try host.registerPlugin(&plugin);
+ try host.registerSidebarView(.{ .id = "keeper.view", .owner = &keeper, .icon = "", .title = "K", .draw = noopDraw });
+ try host.registerSidebarView(.{ .id = "victim.view", .owner = &plugin, .icon = "", .title = "V", .draw = noopDraw });
+ try host.registerBottomView(.{ .id = "victim.bottom", .owner = &plugin, .title = "V", .draw = noopDraw });
+ try host.registerCenterProvider(.{ .id = "victim.center", .owner = &plugin, .draw = noopCenter });
+ try host.registerMenu(.{ .id = "victim.menu", .owner = &plugin, .draw = noopDraw });
+ try host.registerMenuSection(.{ .id = "victim.section", .parent_menu_id = "shell.menu.view", .owner = &plugin, .draw = noopDraw });
+ try host.registerSettingsSection(.{ .id = "victim.settings", .owner = &plugin, .title = "V", .draw = noopDraw });
+ try host.registerCommand(.{ .id = "victim.cmd", .owner = &plugin, .title = "V", .run = noopRun });
+ try host.registerFileRowFillColor(.{ .owner = &plugin, .color = noColor });
+ try host.registerFileIcon(.{ .owner = &plugin, .draw = noIcon });
+ try host.registerService("victim.svc", &service_obj, &plugin);
+
+ // Active sidebar view points at the victim (keeper registered first, but force it).
+ host.setActiveSidebarView("victim.view");
+ host.setActiveBottomView("victim.bottom");
+ host.setActiveCenter("victim.center");
+
+ host.unregisterPlugin(&plugin);
+
+ // The victim is gone; the keeper survives.
+ try testing.expect(host.pluginById("victim") == null);
+ try testing.expect(host.pluginById("keeper") != null);
+
+ // Every victim contribution is gone; keeper's sidebar view remains.
+ try testing.expectEqual(@as(usize, 1), host.sidebar_views.items.len);
+ try testing.expectEqualStrings("keeper.view", host.sidebar_views.items[0].id);
+ try testing.expectEqual(@as(usize, 0), host.bottom_views.items.len);
+ try testing.expectEqual(@as(usize, 0), host.center_providers.items.len);
+ try testing.expectEqual(@as(usize, 0), host.menus.items.len);
+ try testing.expectEqual(@as(usize, 0), host.menu_sections.items.len);
+ try testing.expectEqual(@as(usize, 0), host.settings_sections.items.len);
+ try testing.expectEqual(@as(usize, 0), host.commands.items.len);
+ try testing.expectEqual(@as(usize, 0), host.file_row_fill_colors.items.len);
+ try testing.expectEqual(@as(usize, 0), host.file_icons.items.len);
+ try testing.expect(host.getService("victim.svc") == null);
+
+ // Active selections that named removed contributions reset to null; the next frame
+ // falls back to a still-registered view.
+ try testing.expect(host.active_sidebar_view == null);
+ try testing.expect(host.active_bottom_view == null);
+ try testing.expect(host.active_center == null);
+}
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
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..9f0460e2
--- /dev/null
+++ b/src/sdk/dylib.zig
@@ -0,0 +1,301 @@
+//! 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;
+
+/// C ABI — `fizzy_plugin_name`; returns the plugin's user-facing display name (NUL-terminated).
+/// Optional symbol queried *without* registering, so the host can show a sideloaded/disabled
+/// plugin's real name without loading it. Absent on plugins built before this symbol existed —
+/// the loader falls back to the id in that case.
+pub const GetPluginNameFn = *const fn () callconv(.c) [*:0]const u8;
+
+/// dvui data/handle types that cross the boundary by value or through the render bridge.
+const dvui_boundary_types = .{
+ dvui.Window,
+ dvui.Debug,
+ dvui.Vertex,
+ dvui.Vertex.Index,
+ dvui.Texture,
+ dvui.TextureTarget,
+ dvui.Rect.Physical,
+ dvui.Id,
+};
+
+/// SDK types whose full structure is part of the contract.
+const sdk_boundary_types = .{
+ Host,
+ Plugin,
+ Plugin.VTable,
+ DocHandle,
+ EditorAPI,
+ EditorAPI.VTable,
+ regions.SidebarView,
+ regions.BottomView,
+ regions.CenterProvider,
+ regions.MenuContribution,
+ regions.MenuSectionContribution,
+ regions.SettingsSection,
+ regions.Command,
+ Host.FileRowFillColor,
+ proxy_bridge.RenderBridge,
+ workbench_service.Api,
+ workbench_service.Api.VTable,
+ VersionTriplet,
+};
+
+const entry_symbol_types = .{
+ RegisterFn,
+ SetGlobalsFn,
+ SetRenderBridgeFn,
+ GetAbiFingerprintFn,
+ GetSdkVersionFn,
+ GetMinSdkVersionFn,
+ GetPluginVersionFn,
+ GetPluginIdFn,
+ dvui_context.SetContextFn,
+};
+
+/// Real-memory-layout structural hash (folds in `@sizeOf`/`@alignOf`/`@offsetOf`) — the hard
+/// runtime dlopen-time gate. Host and plugin each compute this from their own compiled copy and
+/// compare live (`fingerprintMatches`); there is no recorded literal to keep in sync because
+/// both sides are always freshly compiled. This is intentionally sensitive to arch, os, *and*
+/// optimize mode (Debug/ReleaseSafe vs ReleaseFast/ReleaseSmall genuinely lay out
+/// `std.HashMapUnmanaged`'s safety-lock field differently) — a host and plugin built with
+/// different targets or modes really do have incompatible field offsets, and rejecting that load
+/// is correct.
+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;
+};
+
+/// Target- and optimize-mode-*invariant* hash of only the Fizzy-owned `sdk_boundary_types`'
+/// declared shape (see `fingerprint.hashAllShape` doc for what that means and why). This is what
+/// `version.zig`'s "did you forget to bump `sdk_version`" guard checks against a single recorded
+/// literal — unlike `abi_fingerprint` above, its value does not depend on which target or
+/// optimize mode compiles it, so that guard needs no per-target table and no cross-compiling to
+/// populate. Deliberately excludes `dvui_boundary_types`: `dvui`'s own version is pinned and
+/// bumped separately (see `docs/PLUGINS.md` § Compatibility), and `abi_fingerprint` above already
+/// fully covers `dvui` layout drift at load time.
+pub const sdk_shape_fingerprint: u64 = blk: {
+ @setEvalBranchQuota(1_000_000);
+ var h = fingerprint.seed;
+ h = fingerprint.hashAllShape(h, sdk_boundary_types, 6);
+ h = fingerprint.hashAllShape(h, entry_symbol_types, 3);
+ break :blk h;
+};
+
+pub const symbol_register: [:0]const u8 = "fizzy_plugin_register";
+pub const symbol_set_dvui_context: [:0]const u8 = "fizzy_plugin_set_dvui_context";
+pub const symbol_set_render_bridge: [:0]const u8 = "fizzy_plugin_set_render_bridge";
+pub const symbol_set_globals: [:0]const u8 = "fizzy_plugin_set_globals";
+pub const symbol_abi_fingerprint: [:0]const u8 = "fizzy_plugin_abi_fingerprint";
+pub const symbol_sdk_version: [:0]const u8 = "fizzy_plugin_sdk_version";
+pub const symbol_min_sdk_version: [:0]const u8 = "fizzy_plugin_min_sdk_version";
+pub const symbol_plugin_version: [:0]const u8 = "fizzy_plugin_version";
+pub const symbol_plugin_id: [:0]const u8 = "fizzy_plugin_id";
+pub const symbol_plugin_name: [:0]const u8 = "fizzy_plugin_name";
+
+pub const RegisterStatus = enum(u32) {
+ ok = 0,
+ err_register = 1,
+ err_null_host = 2,
+ 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";
+ const name_z = manifest.name ++ "\x00";
+ fn pluginId() callconv(.c) [*:0]const u8 {
+ return id_z;
+ }
+ fn pluginName() callconv(.c) [*:0]const u8 {
+ return name_z;
+ }
+ };
+ const ManifestEntry = struct {
+ fn sdkVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(version.sdk_version);
+ }
+ fn minSdkVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(manifest.min_sdk_version);
+ }
+ fn pluginVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(manifest.version);
+ }
+ };
+ @export(&IdEntry.pluginId, .{ .name = symbol_plugin_id });
+ @export(&IdEntry.pluginName, .{ .name = symbol_plugin_name });
+ @export(&ManifestEntry.sdkVersion, .{ .name = symbol_sdk_version });
+ @export(&ManifestEntry.minSdkVersion, .{ .name = symbol_min_sdk_version });
+ @export(&ManifestEntry.pluginVersion, .{ .name = symbol_plugin_version });
+}
+
+/// Emit the C entry symbols every plugin dylib must export, wired to the plugin's
+/// own `register` and `manifest`.
+///
+/// `plugin_mod` must expose:
+/// - `pub fn register(*Host) !void`
+/// - `pub const manifest: PluginManifest`
+pub fn exportEntry(comptime plugin_mod: type) void {
+ comptime {
+ if (@hasDecl(plugin_mod, "manifest") == false) {
+ @compileError("plugin module must declare `pub const manifest: sdk.PluginManifest`");
+ }
+ }
+ const manifest = plugin_mod.manifest;
+ const IdEntry = struct {
+ const id_z = manifest.id ++ "\x00";
+ const name_z = manifest.name ++ "\x00";
+ fn pluginId() callconv(.c) [*:0]const u8 {
+ return id_z;
+ }
+ fn pluginName() callconv(.c) [*:0]const u8 {
+ return name_z;
+ }
+ };
+
+ const Entry = struct {
+ fn abiFingerprint() callconv(.c) u64 {
+ return abi_fingerprint;
+ }
+ fn sdkVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(version.sdk_version);
+ }
+ fn minSdkVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(manifest.min_sdk_version);
+ }
+ fn pluginVersion() callconv(.c) VersionTriplet {
+ return tripletFromSemver(manifest.version);
+ }
+ fn register(host: ?*Host) callconv(.c) u32 {
+ if (host == null) return @intFromEnum(RegisterStatus.err_null_host);
+ if (!version.sdkVersionSatisfies(version.sdk_version, manifest.min_sdk_version)) {
+ return @intFromEnum(RegisterStatus.err_sdk_version);
+ }
+ plugin_mod.register(host.?) catch return @intFromEnum(RegisterStatus.err_register);
+ return @intFromEnum(RegisterStatus.ok);
+ }
+ fn setDvuiContext(
+ window: ?*dvui.Window,
+ io: ?*anyopaque,
+ ft2lib: ?*anyopaque,
+ debug: ?*dvui.Debug,
+ ) callconv(.c) void {
+ dvui_context.inject(window, io, ft2lib, debug);
+ }
+ fn setRenderBridge(bridge: ?*const proxy_bridge.RenderBridge) callconv(.c) void {
+ proxy_bridge.setBridge(bridge);
+ }
+ fn setGlobals(gpa: ?*const anyopaque, host: ?*anyopaque, state: ?*anyopaque) callconv(.c) void {
+ runtime.installRuntime(
+ if (gpa) |p| @ptrCast(@alignCast(p)) else null,
+ if (host) |p| @ptrCast(@alignCast(p)) else null,
+ state,
+ );
+ }
+ };
+ @export(&Entry.abiFingerprint, .{ .name = symbol_abi_fingerprint });
+ @export(&Entry.sdkVersion, .{ .name = symbol_sdk_version });
+ @export(&Entry.minSdkVersion, .{ .name = symbol_min_sdk_version });
+ @export(&Entry.pluginVersion, .{ .name = symbol_plugin_version });
+ @export(&IdEntry.pluginId, .{ .name = symbol_plugin_id });
+ @export(&IdEntry.pluginName, .{ .name = symbol_plugin_name });
+ @export(&Entry.register, .{ .name = symbol_register });
+ @export(&Entry.setDvuiContext, .{ .name = symbol_set_dvui_context });
+ @export(&Entry.setRenderBridge, .{ .name = symbol_set_render_bridge });
+ @export(&Entry.setGlobals, .{ .name = symbol_set_globals });
+}
+
+test "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..063900a3
--- /dev/null
+++ b/src/sdk/fingerprint.zig
@@ -0,0 +1,326 @@
+//! 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;
+ }
+}
+
+/// Fold every type in `types` into `h_in`, hashing only target- and optimize-mode-*invariant*
+/// declaration shape: field names, field position (not byte offset), integer bit-width and
+/// signedness (except pointer-sized `usize`/`isize`, which are canonicalized to width-free tokens
+/// so a 64-bit host and a 32-bit wasm32 build agree), enum tag names, pointer kind/constness, and
+/// function calling-convention + parameter/return shapes. Deliberately never touches
+/// `@sizeOf`/`@alignOf`/`@offsetOf` — those
+/// vary with arch/os (pointer width, struct padding) and even with optimize mode alone (e.g.
+/// `std.HashMapUnmanaged` carries a `pointer_stability: std.debug.SafetyLock` field that is
+/// zero-sized under `ReleaseFast`/`ReleaseSmall` but not under `Debug`/`ReleaseSafe`, which
+/// shifts sibling field offsets with no boundary change involved). A single value from this
+/// function is therefore valid to record and check regardless of which target or optimize mode
+/// compiles it — unlike `hashAll`, whose result is real memory-layout information and must be
+/// (and is, via `dylib.abi_fingerprint`) recomputed live on both sides of the plugin boundary at
+/// load time rather than checked against a recorded literal.
+pub fn hashAllShape(h_in: u64, comptime types: anytype, comptime depth: comptime_int) u64 {
+ comptime {
+ var h = h_in;
+ for (types) |T| h = hashTypeShape(h, T, depth);
+ return h;
+ }
+}
+
+/// `callconv(.c)` is sugar for `std.builtin.CallingConvention.c`, which is itself
+/// `builtin.target.cCallingConvention().?` — a *concrete*, target-specific tag (e.g.
+/// `.x86_64_sysv` on Linux/macOS x86_64, `.x86_64_win` on Windows, `.aarch64_aapcs_darwin` on
+/// Apple aarch64). So two hook signatures declared identically as `callconv(.c)` report
+/// different `@typeInfo` tags depending only on which target compiled them — exactly the kind of
+/// target-specificity `hashTypeShape` exists to ignore. Canonicalize that one case back to a
+/// single key; every other calling convention (`.auto`, `.naked`, `.@"inline"`, ...) is already
+/// target-invariant and is hashed as-is.
+fn callingConventionShapeKey(cc: std.builtin.CallingConvention) u64 {
+ if (std.meta.eql(cc, std.builtin.CallingConvention.c)) return 0;
+ return 1 + @intFromEnum(std.meta.activeTag(cc));
+}
+
+/// `std.debug.SafetyLock.state`'s enum has a *different tag set* in safety builds
+/// (`.unlocked`/`.locked`) than in non-safety builds (`.unknown`) — see `lib/std/debug.zig`. It
+/// is a debug-only assertion helper embedded inside `std.HashMapUnmanaged` /
+/// `std.ArrayHashMapUnmanaged` (as `pointer_stability`), which several boundary types (`Host`,
+/// `dvui.Window`) hold by value. A plugin never reads it — it exists purely to assert against
+/// concurrent mutation during iteration — so exclude its internals from this target/mode-
+/// invariant hash; otherwise every Fizzy struct embedding a hash map by value would spuriously
+/// flag a "boundary change" on every Debug/ReleaseSafe vs ReleaseFast/ReleaseSmall build.
+fn hashFieldTypeShape(h_in: u64, comptime FieldType: type, comptime depth: comptime_int) u64 {
+ if (FieldType == std.debug.SafetyLock) return mixStr(h_in, "std.debug.SafetyLock");
+ return hashTypeShape(h_in, FieldType, depth);
+}
+
+fn hashTypeShape(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)));
+ switch (info) {
+ .int => {
+ // `usize`/`isize` are pointer-width: 64-bit on native, 32-bit on wasm32. Hashing
+ // their concrete `.bits` would make this "target-invariant" fingerprint diverge
+ // per target — and they are reached constantly (every `std` container holds
+ // `usize` len/capacity fields), which is exactly why the recorded native literal
+ // never matched a wasm32 build. Canonicalize them to a stable, width-free token.
+ // They are distinct Zig types from `u32`/`u64` (`usize == u64` is `false`), so a
+ // boundary field that genuinely wants a fixed width still uses `u32`/`u64` and is
+ // hashed by bit-width as before, staying distinguishable from `usize`/`isize`.
+ if (T == usize) {
+ h = mixStr(h, "usize");
+ } else if (T == isize) {
+ h = mixStr(h, "isize");
+ } else {
+ const i = info.int;
+ h = mixU64(h, @intFromEnum(i.signedness));
+ h = mixU64(h, i.bits);
+ }
+ },
+ .float => |f| h = mixU64(h, f.bits),
+ else => {},
+ }
+ 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);
+ h = mixU64(h, i); // declaration position, never a byte offset
+ h = hashFieldTypeShape(h, f.type, depth - 1);
+ }
+ },
+ .@"union" => |u| {
+ h = mixU64(h, u.fields.len);
+ for (u.fields, 0..) |f, i| {
+ h = mixStr(h, f.name);
+ h = mixU64(h, i);
+ h = hashFieldTypeShape(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 = hashTypeShape(h, o.child, depth - 1),
+ .array => |a| {
+ h = mixU64(h, a.len);
+ h = hashTypeShape(h, a.child, depth - 1);
+ },
+ .pointer => |p| {
+ h = mixU64(h, @intFromEnum(p.size));
+ h = mixU64(h, @intFromBool(p.is_const));
+ if (@typeInfo(p.child) == .@"fn") h = hashTypeShape(h, p.child, depth - 1);
+ },
+ .@"fn" => |fninfo| {
+ h = mixU64(h, callingConventionShapeKey(fninfo.calling_convention));
+ h = mixU64(h, fninfo.params.len);
+ for (fninfo.params) |param| {
+ if (param.type) |pt| {
+ h = hashTypeShape(h, pt, depth - 1);
+ } else {
+ h = mixStr(h, "anytype");
+ }
+ }
+ if (fninfo.return_type) |rt| h = hashTypeShape(h, rt, depth - 1);
+ },
+ else => {},
+ }
+ return h;
+ }
+}
+
+test "shape fingerprint is stable and order-sensitive" {
+ const A = struct { x: u32, y: u64 };
+ const B = struct { y: u64, x: u32 };
+ const a = comptime hashAllShape(seed, .{A}, 4);
+ const a2 = comptime hashAllShape(seed, .{A}, 4);
+ const b = comptime hashAllShape(seed, .{B}, 4);
+ try std.testing.expectEqual(a, a2);
+ try std.testing.expect(a != b); // field reorder changes the shape fingerprint
+ try std.testing.expect(a != seed);
+}
+
+test "shape fingerprint catches bit-width changes despite ignoring @sizeOf" {
+ const V1 = struct { x: u32 };
+ const V2 = struct { x: u64 };
+ const v1 = comptime hashAllShape(seed, .{V1}, 4);
+ const v2 = comptime hashAllShape(seed, .{V2}, 4);
+ try std.testing.expect(v1 != v2);
+}
+
+test "shape 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 hashAllShape(seed, .{V1}, 6);
+ const v2 = comptime hashAllShape(seed, .{V2}, 6);
+ try std.testing.expect(v1 != v2);
+}
+
+test "shape fingerprint treats usize/isize as target-width-invariant tokens" {
+ // The point of the pointer-width canonicalization: `usize` must not hash like whatever
+ // fixed-width int it happens to alias on the current target, or the recorded literal would
+ // drift between a 64-bit host and a 32-bit (wasm32) build.
+ const WithUsize = struct { n: usize };
+ const WithU64 = struct { n: u64 };
+ const WithU32 = struct { n: u32 };
+ const usize_fp = comptime hashAllShape(seed, .{WithUsize}, 4);
+ // Distinct from every fixed width — a boundary can still choose an explicit width and be told
+ // apart from a `usize` field.
+ try std.testing.expect(usize_fp != comptime hashAllShape(seed, .{WithU64}, 4));
+ try std.testing.expect(usize_fp != comptime hashAllShape(seed, .{WithU32}, 4));
+
+ const WithIsize = struct { n: isize };
+ const WithI64 = struct { n: i64 };
+ const isize_fp = comptime hashAllShape(seed, .{WithIsize}, 4);
+ try std.testing.expect(isize_fp != usize_fp);
+ try std.testing.expect(isize_fp != comptime hashAllShape(seed, .{WithI64}, 4));
+}
+
+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..b08d094b
--- /dev/null
+++ b/src/sdk/sdk.zig
@@ -0,0 +1,73 @@
+//! 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 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_fingerprints`).
+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..78fe170b
--- /dev/null
+++ b/src/sdk/version.zig
@@ -0,0 +1,110 @@
+//! SDK version and ABI fingerprint lock.
+//!
+//! `sdk_version` is bumped when the plugin ABI boundary changes. `recorded_sdk_shape_fingerprint`
+//! must be updated in the same commit — CI fails at compile time if the live shape fingerprint
+//! drifts from the recorded literal without an intentional version bump.
+//!
+//! **Two different fingerprints, two different jobs.** `dylib.abi_fingerprint` is a real
+//! memory-layout hash (`@sizeOf`/`@alignOf`/`@offsetOf`) — the hard runtime dlopen-time gate.
+//! Host and plugin each compute it live from their own compiled copy and compare directly
+//! (`dylib.fingerprintMatches`); there is nothing to record for it, and it is correctly sensitive
+//! to arch, os, *and* optimize mode, because a host and plugin built for different targets or
+//! modes really do have incompatible field offsets.
+//!
+//! `dylib.sdk_shape_fingerprint`, checked below, is a *different* hash: target- and
+//! optimize-mode-invariant declared shape (field names/order, integer bit-width, enum tags,
+//! pointer kind, fn signatures — never a byte offset or size) of only the Fizzy-owned SDK
+//! boundary types. Its value is the same no matter which target or optimize mode compiles it, so
+//! this guard needs exactly one recorded literal — not a table keyed by (arch, os, optimize
+//! mode) — and needs no cross-compiling to populate: `zig build test-sdk-version` on any target
+//! reports the correct value to record. See `fingerprint.hashAllShape` for the full rationale.
+//!
+//! **Cadence policy (decoupled from the app version).** The app version (`VERSION` /
+//! `build.zig.zon`) ships often and is *not* an input to either fingerprint or to `sdk_version`.
+//! The shape fingerprint is a pure function of the plugin-boundary types Fizzy itself declares —
+//! it only moves when one of those changes. `dvui` and the Zig toolchain are pinned (see the
+//! `dvui` dependency in `build.zig.zon` and `ZIG_VERSION` in CI) and bumped deliberately/batched,
+//! independently of `sdk_version`; drift there is instead caught by the live `abi_fingerprint` at
+//! load time. A Fizzy release that touches neither the SDK boundary nor `dvui`/the compiler keeps
+//! the same shape fingerprint, so the store's installed plugins keep loading. The store matches
+//! plugin binaries on `abi_fingerprint` (see `docs/PLUGINS.md` § Compatibility).
+const std = @import("std");
+const builtin = @import("builtin");
+const dylib = @import("dylib.zig");
+
+pub const VersionTriplet = dylib.VersionTriplet;
+
+/// ABI contract version. Bump minor (or major for breaking changes) when
+/// `recorded_sdk_shape_fingerprint` changes.
+pub const sdk_version = std.SemanticVersion{
+ .major = 0,
+ .minor = 8,
+ .patch = 0,
+};
+
+/// Recorded `dylib.sdk_shape_fingerprint` — see the module doc above for what this hashes and
+/// why it is a single target/mode-invariant literal rather than a per-target table. Update this
+/// value (from the `@compileError` it triggers) and bump `sdk_version` in the same commit
+/// whenever it changes.
+///
+/// 0.5.0: added `Host.FileRowFillColor.owner` + service-owner tracking for runtime unload.
+/// 0.6.0: added `Host.registerFileIcon`/`FileIcon` (plugins draw their own file-tree icons).
+/// 0.7.0: removed `host.uiAtlas`/`UiAtlasView`/`UiSprite` (plugins own their own sprite atlases).
+/// 0.8.0: boundary layout shifted (custom TextEntryWidget / workbench tabs work); also replaced
+/// the old per-(arch, os) `recorded_abi_fingerprints` table — which could not actually hold one
+/// value per platform once optimize mode is accounted for (see `fingerprint.hashAllShape`) — with
+/// this single shape fingerprint.
+/// ↳ Value re-recorded (no `sdk_version` bump, no boundary change) after fixing a
+/// target-*variance* bug in `hashAllShape`: it folded in the concrete bit-width of pointer-
+/// sized ints, so any `usize`/`isize` reached in the walk (every `std` container's len/
+/// capacity) hashed as 64-bit on native but 32-bit on wasm32. The old literal
+/// (`0xd8304e87baf922b2`) was a 64-bit-host value that no wasm32 build could ever match, which
+/// broke `zig build check-web`/`serve-web`. `hashTypeShape` now canonicalizes `usize`/`isize`
+/// to width-free tokens, so this literal is identical on every target. The runtime gate
+/// (`abi_fingerprint`) is unchanged, so already-installed plugins keep loading.
+pub const recorded_sdk_shape_fingerprint: u64 = 0xa62dd86a1dca36e3;
+
+comptime {
+ if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) {
+ @compileError(std.fmt.comptimePrint(
+ "SDK boundary shape fingerprint is 0x{x} — bump sdk_version and update " ++
+ "recorded_sdk_shape_fingerprint in src/sdk/version.zig",
+ .{dylib.sdk_shape_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 shape fingerprint lock is self-consistent" {
+ // If this were out of sync, the module-level comptime block above would already have
+ // failed to compile, so asserting it here just guards against future refactors.
+ try std.testing.expectEqual(recorded_sdk_shape_fingerprint, dylib.sdk_shape_fingerprint);
+}
+
+test "shape fingerprint is decoupled from the app version" {
+ // The shape fingerprint is a pure function of the Fizzy-owned SDK boundary types. The app
+ // version is not in that set, so a routine app-version bump must leave it — and therefore
+ // plugin compatibility — unchanged. This guards the cadence policy in the module doc comment:
+ // if someone ever wires an app-version-dependent value into a boundary type's declared shape,
+ // the live value drifts from the recorded literal and both this lock and the comptime check
+ // above fail, forcing a deliberate `sdk_version` bump rather than a silent one.
+ try std.testing.expectEqual(recorded_sdk_shape_fingerprint, dylib.sdk_shape_fingerprint);
+}
diff --git a/src/tools/LDTKTileset.zig b/src/tools/LDTKTileset.zig
deleted file mode 100644
index 86c67b96..00000000
--- a/src/tools/LDTKTileset.zig
+++ /dev/null
@@ -1,17 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const core = @import("mach").core;
-
-pub const LDTKCompatibility = struct {
- tilesets: []LDTKTileset,
-};
-
-const LDTKTileset = @This();
-
-pub const LDTKSprite = struct {
- src: [2]u32,
-};
-
-layer_paths: [][:0]const u8,
-sprite_size: [2]u32,
-sprites: []LDTKSprite,
diff --git a/src/tools/Packer.zig b/src/tools/Packer.zig
deleted file mode 100644
index b6b5ef01..00000000
--- a/src/tools/Packer.zig
+++ /dev/null
@@ -1,388 +0,0 @@
-const std = @import("std");
-const zstbi = @import("zstbi");
-const dvui = @import("dvui");
-
-const fizzy = @import("../fizzy.zig");
-
-pub const LDTKTileset = @import("LDTKTileset.zig");
-
-const Packer = @This();
-
-pub const Image = struct {
- width: usize,
- height: usize,
- pixels: [][4]u8,
-
- pub fn deinit(self: Image, allocator: std.mem.Allocator) void {
- allocator.free(self.pixels);
- }
-};
-
-pub const Sprite = struct {
- image: ?Image = null,
- origin: [2]f32 = .{ 0.0, 0.0 },
-
- pub fn deinit(self: *Sprite, allocator: std.mem.Allocator) void {
- if (self.image) |*image| {
- image.deinit(allocator);
- }
- }
-};
-
-frames: std.array_list.Managed(zstbi.Rect),
-sprites: std.array_list.Managed(Sprite),
-animations: std.array_list.Managed(fizzy.Animation),
-id_counter: u32 = 0,
-placeholder: Image,
-contains_height: bool = false,
-open_files: std.array_list.Managed(fizzy.Internal.File),
-target: PackTarget = .project,
-//camera: fizzy.gfx.Camera = .{},
-atlas: ?fizzy.Internal.Atlas = null,
-
-/// Monotonic time (`fizzy.perf.nanoTimestamp`) when the current in-memory atlas was last installed.
-last_packed_at_ns: ?i128 = null,
-
-ldtk: bool = false,
-ldtk_tilesets: std.array_list.Managed(LDTKTileset),
-
-pub const PackTarget = enum {
- project,
- all_open,
- single_open,
-};
-
-pub fn init(allocator: std.mem.Allocator) !Packer {
- const pixels: [][4]u8 = try allocator.alloc([4]u8, 4);
- for (pixels) |*pixel| {
- pixel[3] = 0;
- }
-
- return .{
- .sprites = std.array_list.Managed(Sprite).init(allocator),
- .frames = std.array_list.Managed(zstbi.Rect).init(allocator),
- .animations = std.array_list.Managed(fizzy.Animation).init(allocator),
- .open_files = std.array_list.Managed(fizzy.Internal.File).init(allocator),
- .placeholder = .{ .width = 2, .height = 2, .pixels = pixels },
- .ldtk_tilesets = std.array_list.Managed(LDTKTileset).init(allocator),
- };
-}
-
-pub fn newId(self: *Packer) u32 {
- const i = self.id_counter;
- self.id_counter += 1;
- return i;
-}
-
-pub fn deinit(self: *Packer) void {
- fizzy.app.allocator.free(self.placeholder.pixels);
- self.clearAndFree();
- self.sprites.deinit();
- self.frames.deinit();
- self.animations.deinit();
- self.ldtk_tilesets.deinit();
-}
-
-pub fn clearAndFree(self: *Packer) void {
- for (self.sprites.items) |*sprite| {
- sprite.deinit(fizzy.app.allocator);
- }
- for (self.animations.items) |*animation| {
- fizzy.app.allocator.free(animation.name);
- }
- for (self.ldtk_tilesets.items) |*tileset| {
- for (tileset.layer_paths) |path| {
- fizzy.app.allocator.free(path);
- }
- fizzy.app.allocator.free(tileset.sprites);
- fizzy.app.allocator.free(tileset.layer_paths);
- }
- self.frames.clearAndFree();
- self.sprites.clearAndFree();
- self.animations.clearAndFree();
- self.contains_height = false;
- self.ldtk_tilesets.clearAndFree();
-
- for (self.open_files.items) |*file| {
- file.deinit();
- }
- self.open_files.clearAndFree();
-}
-
-pub fn append(self: *Packer, file: *fizzy.Internal.File) !void {
- std.log.info("Appending file with sprites: {d}", .{file.sprites.slice().len});
- var layer_opt: ?fizzy.Internal.Layer = null;
- var index: usize = 0;
- while (index < file.layers.slice().len) : (index += 1) {
- var layer = file.layers.get(index);
- if (!layer.visible) continue;
-
- const last_item: bool = index == file.layers.slice().len - 1;
-
- // If this layer is collapsed, we need to record its texture to survive the next loop
- if ((layer.collapse and !last_item) or ((index != 0 and file.layers.slice().get(index - 1).collapse))) {
- const current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else try fizzy.Internal.Layer.init(
- 0,
- "",
- file.width(),
- file.height(),
- .{ .r = 0, .g = 0, .b = 0, .a = 0 },
- .ptr,
- );
-
- const src_pixels = layer.pixels();
- const dst_pixels = current_layer.pixels();
-
- for (src_pixels, dst_pixels) |src, *dst| {
- if (src[3] != 0 and dst[3] == 0) { //alpha
- dst.* = src;
- }
- }
- layer_opt = current_layer;
-
- if (layer.collapse and !last_item) {
- continue;
- }
- }
-
- var current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else layer;
-
- const size: dvui.Size = dvui.imageSize(layer.source) catch .{ .w = 0, .h = 0 };
-
- const layer_width = @as(usize, @intFromFloat(size.w));
- var sprite_index: usize = 0;
- while (sprite_index < file.spriteCount()) : (sprite_index += 1) {
- const sprite = file.sprites.slice().get(sprite_index);
- const columns = file.columns;
-
- const column = @mod(@as(u32, @intCast(sprite_index)), columns);
- const row = @divTrunc(@as(u32, @intCast(sprite_index)), columns);
-
- const src_x = std.math.clamp(column * file.column_width, 0, file.width());
- const src_y = std.math.clamp(row * file.row_height, 0, file.height());
-
- const src_rect: dvui.Rect = .{ .x = @floatFromInt(src_x), .y = @floatFromInt(src_y), .w = @floatFromInt(file.column_width), .h = @floatFromInt(file.row_height) };
-
- if (current_layer.reduce(src_rect)) |reduced_rect| {
- const reduced_src_x: usize = @intFromFloat(reduced_rect.x);
- const reduced_src_y: usize = @intFromFloat(reduced_rect.y);
- const reduced_src_width: usize = @intFromFloat(reduced_rect.w);
- const reduced_src_height: usize = @intFromFloat(reduced_rect.h);
-
- const offset = .{ reduced_src_x - src_x, reduced_src_y - src_y };
- const src_pixels = current_layer.pixels();
-
- // Allocate pixels for reduced image
- var image: Image = .{
- .width = reduced_src_width,
- .height = reduced_src_height,
- .pixels = try fizzy.app.allocator.alloc([4]u8, reduced_src_width * reduced_src_height),
- };
-
- @memset(image.pixels, .{ 0, 0, 0, 0 });
-
- // Copy pixels to image
- {
- var y: usize = reduced_src_y;
- while (y < reduced_src_y + reduced_src_height) : (y += 1) {
- const start = reduced_src_x + y * layer_width;
- const src = src_pixels[start .. start + reduced_src_width];
- const dst = image.pixels[(y - reduced_src_y) * image.width .. (y - reduced_src_y) * image.width + image.width];
- @memcpy(dst, src);
- }
- }
-
- try self.sprites.append(.{
- .image = image,
- //.heightmap_image = heightmap_image,
- .origin = .{ sprite.origin[0] - @as(f32, @floatFromInt(offset[0])), sprite.origin[1] - @as(f32, @floatFromInt(offset[1])) },
- });
-
- try self.frames.append(.{ .id = self.newId(), .w = @as(c_ushort, @intCast(image.width)), .h = @as(c_ushort, @intCast(image.height)) });
-
- const new_sprite_index = self.sprites.items.len - 1;
- for (0..file.animations.len) |animation_index| {
- const animation = file.animations.get(animation_index);
- if (animation.frames[0].sprite_index == sprite_index) {
- const frames = try fizzy.app.allocator.alloc(fizzy.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 }),
- .frames = frames,
- });
- }
- }
- } else {
- var animation_index: usize = 0;
- while (animation_index < file.animations.slice().len) : (animation_index += 1) {
- const animation = file.animations.slice().get(animation_index);
-
- for (animation.frames) |frame| {
- if (frame.sprite_index == sprite_index) {
-
- // Sprite contains no pixels but is part of an animation
- // To preserve the animation, add a blank pixel to the sprites list
- try self.sprites.append(.{
- .image = null,
- .origin = .{ 0, 0 },
- });
-
- try self.frames.append(.{
- .id = self.newId(),
- .w = 2,
- .h = 2,
- });
- }
- }
- }
- }
- }
-
- if (layer_opt) |*t| {
- t.deinit();
- layer_opt = null;
- }
- }
-}
-
-pub fn appendProject(packer: *Packer) !void {
- if (fizzy.editor.folder) |root_directory| {
- try recurseFiles(packer, root_directory);
- }
-}
-
-pub fn recurseFiles(packer: *Packer, root_directory: []const u8) !void {
- const recursor = struct {
- fn search(p: *Packer, directory: []const u8) !void {
- const io = dvui.io;
- var dir = try std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true });
- defer dir.close(io);
-
- var iter = dir.iterate();
- while (try iter.next(io)) |entry| {
- if (entry.kind == .file) {
- const ext = std.fs.path.extension(entry.name);
- if (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 (fizzy.editor.getFileFromPath(abs_path)) |file| {
- try p.append(file);
- } else {
- if (try fizzy.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);
- try search(p, abs_path);
- }
- }
- }
- }.search;
-
- try recursor(packer, root_directory);
-
- return;
-}
-
-pub fn packAndClear(packer: *Packer) !void {
- if (try packer.packRects()) |size| {
- //var atlas_texture = try fizzy.gfx.Texture.createEmpty(size[0], size[1], .{});
- var atlas_layer = try fizzy.Internal.Layer.init(
- 0,
- "",
- size[0],
- size[1],
- .{ .r = 0, .g = 0, .b = 0, .a = 0 },
- .ptr,
- );
-
- for (packer.frames.items, packer.sprites.items) |frame, sprite| {
- if (sprite.image) |image| {
- const slice = frame.slice();
-
- atlas_layer.blit(image.pixels, .{
- .x = @floatFromInt(slice[0]),
- .y = @floatFromInt(slice[1]),
- .w = @floatFromInt(slice[2]),
- .h = @floatFromInt(slice[3]),
- }, .{});
- }
- }
- atlas_layer.invalidate();
-
- const atlas: 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),
- };
-
- for (atlas.sprites, packer.sprites.items, packer.frames.items) |*dst, src, src_rect| {
- dst.source = .{ src_rect.x, src_rect.y, src_rect.w, src_rect.h };
- dst.origin = src.origin;
- }
-
- for (atlas.animations, packer.animations.items) |*dst, src| {
- dst.name = try fizzy.app.allocator.dupe(u8, src.name);
- dst.frames = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, src.frames);
- //dst.length = src.length;
- // dst.start = src.start;
- }
-
- if (packer.atlas) |*current_atlas| {
- current_atlas.deinitCheckerboardTile();
- for (current_atlas.data.animations) |*animation| {
- fizzy.app.allocator.free(animation.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.data = atlas;
- current_atlas.source = atlas_layer.source;
- current_atlas.initCheckerboardTile();
- } else {
- packer.atlas = .{
- .source = atlas_layer.source,
- .data = atlas,
- };
- packer.atlas.?.initCheckerboardTile();
- }
-
- packer.last_packed_at_ns = fizzy.perf.nanoTimestamp();
- packer.clearAndFree();
- }
-}
-
-pub fn packRects(self: *Packer) !?[2]u16 {
- if (self.frames.items.len == 0) return null;
-
- var ctx: zstbi.Context = undefined;
- const node_count = 4096 * 2;
- var nodes: [node_count]zstbi.Node = undefined;
-
- const texture_sizes = [_][2]u32{
- [_]u32{ 256, 256 }, [_]u32{ 512, 256 }, [_]u32{ 256, 512 },
- [_]u32{ 512, 512 }, [_]u32{ 1024, 512 }, [_]u32{ 512, 1024 },
- [_]u32{ 1024, 1024 }, [_]u32{ 2048, 1024 }, [_]u32{ 1024, 2048 },
- [_]u32{ 2048, 2048 }, [_]u32{ 4096, 2048 }, [_]u32{ 2048, 4096 },
- [_]u32{ 4096, 4096 }, [_]u32{ 8192, 4096 }, [_]u32{ 4096, 8192 },
- };
-
- for (texture_sizes) |tex_size| {
- zstbi.initTarget(&ctx, tex_size[0], tex_size[1], &nodes);
- zstbi.setupHeuristic(&ctx, zstbi.Heuristic.skyline_bl_sort_height);
- if (zstbi.packRects(&ctx, self.frames.items) == 1) {
- return .{ @as(u16, @intCast(tex_size[0])), @as(u16, @intCast(tex_size[1])) };
- }
- }
-
- return null;
-}
diff --git a/src/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/process_assets.zig b/src/tools/process_assets.zig
deleted file mode 100644
index 3597b0eb..00000000
--- a/src/tools/process_assets.zig
+++ /dev/null
@@ -1,158 +0,0 @@
-const std = @import("std");
-const path = std.fs.path;
-const Step = std.Build.Step;
-const Io = std.Io;
-
-const Atlas = @import("../Atlas.zig");
-const ProcessAssetsStep = @This();
-
-step: Step,
-builder: *std.Build,
-assets_path: []const u8,
-output_folder: []const u8,
-
-pub fn init(builder: *std.Build, comptime assets_path: []const u8, comptime output_folder: []const u8) !*ProcessAssetsStep {
- const self = try builder.allocator.create(ProcessAssetsStep);
- self.* = .{
- .step = Step.init(.{ .id = .custom, .name = "process-assets", .owner = builder, .makeFn = process }),
- .builder = builder,
- .assets_path = assets_path,
- .output_folder = output_folder,
- };
-
- return self;
-}
-
-fn process(step: *Step, options: Step.MakeOptions) anyerror!void {
- const progress = options.progress_node.start("Processing assets...", 100);
- defer progress.end();
- const self: *ProcessAssetsStep = @fieldParentPtr("step", step);
- const root = self.assets_path;
- const output_folder = self.output_folder;
- try generate(self.builder.allocator, step.owner.graph.io, root, output_folder);
-}
-
-pub fn generate(allocator: std.mem.Allocator, io: Io, assets_root: []const u8, output_folder: []const u8) !void {
- var atlases: usize = 0;
-
- const cwd = Io.Dir.cwd();
-
- var dir = cwd.openDir(io, assets_root, .{ .access_sub_paths = true }) catch |err| {
- std.debug.print("Not a directory: {s}, err: {}\n", .{ assets_root, err });
- return;
- };
- dir.close(io);
-
- const files = try getAllFiles(allocator, io, assets_root, true);
-
- if (files.len == 0) {
- std.debug.print("No assets found!", .{});
- return;
- }
-
- for (files) |file| {
- const ext = std.fs.path.extension(file);
-
- if (std.mem.eql(u8, ext, "")) continue;
-
- const base = std.fs.path.basename(file);
- const ext_ind = std.mem.lastIndexOf(u8, base, ".");
- const name = base[0..ext_ind.?];
-
- const path_fixed = try allocator.alloc(u8, file.len);
- _ = std.mem.replace(u8, file, "\\", "/", path_fixed);
-
- const name_fixed = try allocator.alloc(u8, name.len);
- _ = std.mem.replace(u8, name, "-", "_", name_fixed);
-
- if (!std.mem.eql(u8, ext, ".atlas")) continue;
- atlases += 1;
-
- var allocating: Io.Writer.Allocating = .init(allocator);
- defer allocating.deinit();
- const atlas_writer = &allocating.writer;
-
- try atlas_writer.writeAll("// This is a generated file, do not edit.\n\n");
- try atlas_writer.print("// Sprites \n\n", .{});
-
- var atlas = try Atlas.loadFromFile(allocator, io, file);
-
- try atlas_writer.print("pub const sprites = struct {{\n", .{});
-
- for (atlas.sprites, 0..) |_, sprite_index| {
- const sprite_name = try atlas.spriteName(allocator, sprite_index);
- try atlas_writer.print(" pub const {s} = {d};\n", .{ sprite_name, sprite_index });
- }
-
- try atlas_writer.print("}};\n\n", .{});
- try atlas_writer.print("// Animations \n\n", .{});
-
- if (atlas.animations.len > 0) {
- try atlas_writer.print("pub const animations = struct {{\n", .{});
-
- for (atlas.animations) |animation| {
- const animation_name = try allocator.alloc(u8, animation.name.len);
- _ = std.mem.replace(u8, animation.name, " ", "_", animation_name);
- _ = std.mem.replace(u8, animation_name, ".", "_", animation_name);
-
- try atlas_writer.print(" pub var {s} = [_]usize {{\n", .{animation_name});
-
- for (animation.frames) |frame| {
- try atlas_writer.print(" sprites.{s},\n", .{try atlas.spriteName(allocator, frame.sprite_index)});
- }
-
- try atlas_writer.print(" }};\n", .{});
- }
-
- try atlas_writer.print("}};\n", .{});
- }
-
- const atlas_path = if (atlases > 1) blk: {
- const atlas_name = try std.fmt.allocPrint(allocator, "{s}.zig", .{name});
- break :blk try std.fs.path.join(allocator, &[_][]const u8{ output_folder, atlas_name });
- } else try std.fs.path.join(allocator, &[_][]const u8{ output_folder, "atlas.zig" });
-
- try cwd.writeFile(io, .{
- .sub_path = atlas_path,
- .data = allocating.written(),
- });
- }
-}
-
-fn getAllFiles(allocator: std.mem.Allocator, io: Io, root_directory: []const u8, recurse: bool) ![][:0]const u8 {
- var list: std.ArrayList([:0]const u8) = .empty;
-
- const recursor = struct {
- fn search(alloc: std.mem.Allocator, scan_io: Io, directory: []const u8, recursive: bool, filelist: *std.ArrayList([:0]const u8)) !void {
- var dir = try Io.Dir.cwd().openDir(scan_io, directory, .{ .access_sub_paths = true, .iterate = true });
- defer dir.close(scan_io);
-
- var iter = dir.iterate();
- while (try iter.next(scan_io)) |entry| {
- if (entry.kind == .file) {
- const name_null_term = try std.mem.concat(alloc, u8, &[_][]const u8{ entry.name, "\x00" });
- const abs_path = try std.fs.path.join(alloc, &[_][]const u8{ directory, name_null_term });
- try filelist.append(alloc, abs_path[0 .. abs_path.len - 1 :0]);
- } else if (entry.kind == .directory) {
- if (!recursive) continue;
- const abs_path = try std.fs.path.join(alloc, &[_][]const u8{ directory, entry.name });
- try search(alloc, scan_io, abs_path, recursive, filelist);
- }
- }
- }
- }.search;
-
- try recursor(allocator, io, root_directory, recurse, &list);
-
- std.mem.sort([:0]const u8, list.items, Context{}, compare);
-
- return try list.toOwnedSlice(allocator);
-}
-
-const Context = struct {};
-fn compare(_: Context, a: [:0]const u8, b: [:0]const u8) bool {
- const base_a = std.fs.path.basename(a);
- const base_b = std.fs.path.basename(b);
-
- return std.mem.order(u8, base_a, base_b) == .lt;
-}
diff --git a/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..77e1e585 100644
--- a/src/web_main.zig
+++ b/src/web_main.zig
@@ -23,29 +23,6 @@ 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;
-
- // Top-level data types (.pixi format on-disk shapes)
- _ = fizzy.Animation;
- _ = fizzy.Atlas;
- _ = fizzy.File;
- _ = fizzy.Layer;
- _ = fizzy.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;
// Math + graphics helpers
_ = fizzy.math.checker;
@@ -54,11 +31,9 @@ comptime {
_ = fizzy.image.init;
_ = fizzy.image.pixels;
_ = fizzy.perf.record;
- _ = fizzy.render;
// Custom dvui wrapper + widgets — types compile even though the widget files
// contain dead `@import("backend")` SDL3 imports at file scope.
- _ = fizzy.dvui.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/root.zig b/tests/root.zig
index 54386d37..d6f24970 100644
--- a/tests/root.zig
+++ b/tests/root.zig
@@ -9,11 +9,8 @@ comptime {
// Phase 1: pure-logic unit tests.
_ = @import("fizzy-direction");
_ = @import("fizzy-easing");
- _ = @import("fizzy-layer-order");
- _ = @import("fizzy-palette-parse");
_ = @import("fizzy-layout-anchor");
- _ = @import("fizzy-reduce");
- _ = @import("fizzy-grid-validate");
- _ = @import("fizzy-animation");
_ = @import("fizzy-window-layout");
+ _ = @import("fizzy-plugin-dylib");
+ _ = @import("fizzy-plugin-store");
}