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 @@ ![buildworkflow](https://github.com/fizzyedit/fizzy/actions/workflows/ci.yml/badge.svg) # -**Fizzy** is a cross-platform open-source pixel art editor and animation editor written in [Zig](https://github.com/ziglang/zig). +**Fizzy** is a cross-platform open-source modular general editor written in [Zig](https://github.com/ziglang/zig). ### Try it in your browser [here](https://fizzyed.it/app/) @@ -17,21 +17,6 @@ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R5R4LL2PJ) -## Currently supported features -- [x] Typical pixel art operations. (draw, erase, dropper, bucket, selection, transformation, etc) -- [x] Tabs and splits, drag and drop to reorder and reconfigure -- [x] File explorer with search and drag and drop. -- [ ] Create animations and preview easily, edit directly on the preview. -- [ ] View previous and next frames of the animation. -- [ ] Set sprite origins for drawing sprites easily in game frameworks. -- [ ] Import and slice existing .png spritesheets. -- [x] Intuitive and customizeable user interface. -- [x] Sprite packing -- [ ] Theming -- [ ] Automatic packing and export on file save -- [x] Also a zig library offering modules for handling assets -- [ ] Export animations as .gifs - ## User Interface - The user interface is driven by [DVUI](https://github.com/david-vanderson/dvui). - The general layout takes many ideas from VSCode or IDE's, as well as general project setup using folders. diff --git a/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 `![alt](path)` images. READMEs fetched from a remote repo + /// have no local base, so relative images simply won't resolve — that's fine. + image_base_dir: []const u8 = ".", + /// Seed for widget ids so multiple previews don't collide. + id_extra: u64 = 0, +}; + +/// Render `bytes` as a read-only markdown preview (own scroll area) into the current dvui parent. +/// Safe to call every frame; parsing is cached on `state` by content hash. +pub fn drawPreview( + state: *Preview, + bytes: []const u8, + gpa: std.mem.Allocator, + opts: PreviewOptions, +) void { + state.ensureParsed(bytes, gpa); + + if (state.ast_root) |rp| { + const root: md_parse.Node = .{ .n = @ptrCast(@alignCast(rp)) }; + render_ast.preloadImages(root, .{ + .image_base_dir = opts.image_base_dir, + .io = opts.io, + .gpa = gpa, + .rs = &state.rs, + }); + } + + var scroll = dvui.scrollArea(@src(), .{ + .scroll_info = &state.scroll, + .horizontal_bar = .hide, + .vertical_bar = .auto_overlay, + }, .{ + .expand = .both, + .background = true, + .color_fill = dvui.themeGet().fill, + .style = .content, + .id_extra = opts.id_extra, + }); + defer scroll.deinit(); + + if (state.ast_root) |rp| { + var v = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .gravity_x = 0, + .padding = .{ .x = 8, .y = 8, .w = 8, .h = 8 }, + .id_extra = opts.id_extra + 1, + }); + defer v.deinit(); + + const root: md_parse.Node = .{ .n = @ptrCast(@alignCast(rp)) }; + render_ast.renderDocument(root, .{ + .image_base_dir = opts.image_base_dir, + .io = opts.io, + .gpa = gpa, + .rs = &state.rs, + .id_base = @intCast(opts.id_extra << 16), + }); + } else { + dvui.labelNoFmt( + @src(), + "Could not parse markdown.", + .{}, + .{ + .expand = .both, + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.err, .text).opacity(0.85), + .id_extra = opts.id_extra, + }, + ); + } +} diff --git a/src/markdown/md/cmark_headers.h b/src/markdown/md/cmark_headers.h new file mode 100644 index 00000000..2cd4728d --- /dev/null +++ b/src/markdown/md/cmark_headers.h @@ -0,0 +1,14 @@ +#ifndef DVUI_EDITOR_CMARK_HEADERS_H +#define DVUI_EDITOR_CMARK_HEADERS_H + +#include +#include +#include +#include + +#include "cmark-gfm.h" +#include "cmark-gfm-extension_api.h" +#include "registry.h" +#include "cmark-gfm-core-extensions.h" + +#endif diff --git a/src/markdown/md/cmark_parse.zig b/src/markdown/md/cmark_parse.zig new file mode 100644 index 00000000..c3a39604 --- /dev/null +++ b/src/markdown/md/cmark_parse.zig @@ -0,0 +1,114 @@ +const std = @import("std"); + +pub const c = @cImport({ + @cInclude("cmark_headers.h"); +}); + +/// Declared in `src/registry.h` (not all public headers re-export it). +pub extern fn cmark_list_syntax_extensions(mem: *c.cmark_mem) ?*c.cmark_llist; + +pub const Node = struct { + n: *c.cmark_node, + + pub fn firstChild(n: Node) ?Node { + const ptr = c.cmark_node_first_child(n.n) orelse return null; + return .{ .n = ptr }; + } + + pub fn nextSibling(n: Node) ?Node { + const ptr = c.cmark_node_next(n.n) orelse return null; + return .{ .n = ptr }; + } + + pub fn nodeType(n: Node) c.cmark_node_type { + return c.cmark_node_get_type(n.n); + } + + pub fn typeString(n: Node) [:0]const u8 { + const s = c.cmark_node_get_type_string(n.n) orelse return ""; + return std.mem.span(s); + } + + pub fn literal(n: Node) ?[:0]const u8 { + const ptr = c.cmark_node_get_literal(n.n) orelse return null; + return std.mem.span(ptr); + } + + pub fn linkUrl(n: Node) ?[:0]const u8 { + const ptr = c.cmark_node_get_url(n.n) orelse return null; + return std.mem.span(ptr); + } + + pub fn linkTitle(n: Node) ?[:0]const u8 { + const ptr = c.cmark_node_get_title(n.n) orelse return null; + const s = std.mem.span(ptr); + if (s.len == 0) return null; + return s; + } + + pub fn fenceInfo(n: Node) ?[:0]const u8 { + const ptr = c.cmark_node_get_fence_info(n.n) orelse return null; + return std.mem.span(ptr); + } + + pub fn headingLevel(n: Node) i32 { + return c.cmark_node_get_heading_level(n.n); + } + + pub const ListKind = enum { ul, ol }; + + pub fn listKind(n: Node) ListKind { + return switch (c.cmark_node_get_list_type(n.n)) { + c.CMARK_BULLET_LIST => .ul, + c.CMARK_ORDERED_LIST => .ol, + else => .ul, + }; + } + + pub fn listStart(n: Node) i32 { + return c.cmark_node_get_list_start(n.n); + } + + pub fn tableRowIsHeader(n: Node) bool { + return c.cmark_gfm_extensions_get_table_row_is_header(n.n) != 0; + } + + pub fn taskListItemChecked(n: Node) bool { + return c.cmark_gfm_extensions_get_tasklist_item_checked(n.n); + } +}; + +pub const CMarkAst = struct { + root: Node, + extensions: ?*c.cmark_llist, +}; + +pub fn parseMarkdown(src: []const u8) ?CMarkAst { + const extensions = blk: { + c.cmark_gfm_core_extensions_ensure_registered(); + break :blk cmark_list_syntax_extensions(c.cmark_get_arena_mem_allocator()); + }; + + const options = c.CMARK_OPT_DEFAULT | c.CMARK_OPT_SAFE | c.CMARK_OPT_SMART | c.CMARK_OPT_FOOTNOTES; + const parser = c.cmark_parser_new(options) orelse return null; + defer c.cmark_parser_free(parser); + + _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("table")); + _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("strikethrough")); + _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("tasklist")); + _ = c.cmark_parser_attach_syntax_extension(parser, c.cmark_find_syntax_extension("autolink")); + + c.cmark_parser_feed(parser, src.ptr, @intCast(src.len)); + const root_ptr = c.cmark_parser_finish(parser) orelse return null; + return .{ + .root = .{ .n = root_ptr }, + .extensions = extensions, + }; +} + +pub fn freeCachedRoot(ptr: ?*anyopaque) void { + if (ptr) |p| { + const n: *c.cmark_node = @ptrCast(@alignCast(p)); + c.cmark_node_free(n); + } +} diff --git a/src/markdown/md/render_ast.zig b/src/markdown/md/render_ast.zig new file mode 100644 index 00000000..d2a9efb0 --- /dev/null +++ b/src/markdown/md/render_ast.zig @@ -0,0 +1,909 @@ +const std = @import("std"); +const Io = std.Io; + +const dvui = @import("dvui"); + +const md = @import("cmark_parse.zig"); + +// Extension node kinds that cmark-gfm identifies by type string rather than +// integer constant. Precomputed once after parsing so rendering never calls +// typeString() or any C FFI inside the per-frame draw loop. +pub const ExtNodeKind = enum { table, table_row, table_header, table_cell, strikethrough }; + +/// All precomputed per-AST render data. Lives in MarkDownPreviewWidget.State, +/// rebuilt whenever the content hash changes, freed on deinit. +pub const RenderState = struct { + /// abs_path (gpa-owned) → raw image bytes (gpa-owned). + image_cache: std.StringHashMapUnmanaged([]u8) = .empty, + /// @intFromPtr(bytes.ptr) → natural image size, cached to avoid per-frame stbi_info. + image_sizes: std.AutoHashMapUnmanaged(usize, dvui.Size) = .empty, + /// @intFromPtr(node.n) → ExtNodeKind (extension nodes only). + ext_node_kinds: std.AutoHashMapUnmanaged(usize, ExtNodeKind) = .empty, + /// Set of @intFromPtr(node.n) for every node whose subtree contains an IMAGE. + subtree_has_image: std.AutoHashMapUnmanaged(usize, void) = .empty, + /// @intFromPtr(table_node.n) → column count (from header row). + /// Avoids re-traversing the header row every render frame. + table_col_counts: std.AutoHashMapUnmanaged(usize, usize) = .empty, + + pub fn deinit(self: *RenderState, gpa: std.mem.Allocator) void { + self.clear(gpa); + self.image_cache.deinit(gpa); + self.image_sizes.deinit(gpa); + self.ext_node_kinds.deinit(gpa); + self.subtree_has_image.deinit(gpa); + self.table_col_counts.deinit(gpa); + } + + pub fn clear(self: *RenderState, gpa: std.mem.Allocator) void { + var it = self.image_cache.iterator(); + while (it.next()) |kv| { + gpa.free(kv.key_ptr.*); + gpa.free(kv.value_ptr.*); + } + self.image_cache.clearRetainingCapacity(); + self.image_sizes.clearRetainingCapacity(); + self.ext_node_kinds.clearRetainingCapacity(); + self.subtree_has_image.clearRetainingCapacity(); + self.table_col_counts.clearRetainingCapacity(); + } +}; + +/// dvui ids derive from @src(); repeated layouts in loops/recursion need unique `.id_extra`. +const IdGen = struct { + n: usize = 0, + fn next(g: *IdGen) usize { + g.n += 1; + return g.n; + } +}; + +pub const RenderContext = struct { + /// Directory of the markdown file (for resolving relative `![alt](path)`). + image_base_dir: ?[]const u8 = null, + io: Io, + /// Persistent allocator (same lifetime as State). Used for image cache. + gpa: std.mem.Allocator, + /// Precomputed per-AST data: node kind map, image subtree set, image cache. + rs: *RenderState, + /// Seed for per-document widget id_extra values (avoids collisions with other panes/docs). + id_base: usize = 0, +}; + +const max_image_bytes: usize = 16 * 1024 * 1024; +const max_image_display_width: f32 = 720; +const max_image_display_height: f32 = 540; + +// --------------------------------------------------------------------------- +// Per-node fast lookups (replaces isTable/typeString calls in render loop) +// --------------------------------------------------------------------------- + +inline fn extKind(ctx: RenderContext, n: md.Node) ?ExtNodeKind { + return ctx.rs.ext_node_kinds.get(@intFromPtr(n.n)); +} + +inline fn hasImageSubtree(ctx: RenderContext, n: md.Node) bool { + return ctx.rs.subtree_has_image.contains(@intFromPtr(n.n)); +} + +// --------------------------------------------------------------------------- +// AST pre-scan (called once after parsing, results stored in State) +// --------------------------------------------------------------------------- + +/// Walk the AST once, populating rs.ext_node_kinds and rs.subtree_has_image. +/// Returns true when any node in the subtree rooted at `node` is an IMAGE. +pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool { + const ts = node.typeString(); + if (std.mem.eql(u8, ts, "table")) { + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table) catch {}; + // Count columns once from the header (or first body row) so the render + // loop never needs to re-traverse the row for this. + var num_cols: usize = 0; + var r = node.firstChild(); + while (r) |row| : (r = row.nextSibling()) { + const rts = row.typeString(); + if (!std.mem.eql(u8, rts, "table_header") and !std.mem.eql(u8, rts, "table_row")) continue; + var cl = row.firstChild(); + while (cl) |cell| : (cl = cell.nextSibling()) { + if (std.mem.eql(u8, cell.typeString(), "table_cell")) num_cols += 1; + } + break; + } + rs.table_col_counts.put(gpa, @intFromPtr(node.n), num_cols) catch {}; + } else if (std.mem.eql(u8, ts, "table_row")) + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table_row) catch {} + else if (std.mem.eql(u8, ts, "table_header")) + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table_header) catch {} + else if (std.mem.eql(u8, ts, "table_cell")) + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table_cell) catch {} + else if (std.mem.eql(u8, ts, "strikethrough")) + rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .strikethrough) catch {}; + + var self_has_image = (node.nodeType() == md.c.CMARK_NODE_IMAGE); + var child = node.firstChild(); + while (child) |ch| : (child = ch.nextSibling()) { + if (scanNode(ch, rs, gpa)) self_has_image = true; + } + if (self_has_image) + rs.subtree_has_image.put(gpa, @intFromPtr(node.n), {}) catch {}; + + return self_has_image; +} + +// --------------------------------------------------------------------------- +// Image preloading (keep GPU textures warm every frame, even when pane is closed) +// --------------------------------------------------------------------------- + +/// Touch or create the GPU texture for every local image in the AST. +/// Call every frame from MarkDownPreviewWidget.init() so dvui's one-frame +/// texture eviction policy never fires between animation frames. +pub fn preloadImages(root: md.Node, ctx: RenderContext) void { + if (!ctx.rs.subtree_has_image.contains(@intFromPtr(root.n))) return; + const arena = dvui.currentWindow().arena(); + preloadImageSubtree(root, ctx, arena); +} + +fn preloadImageSubtree(node: md.Node, ctx: RenderContext, arena: std.mem.Allocator) void { + if (node.nodeType() == md.c.CMARK_NODE_IMAGE) { + preloadSingleImage(node, ctx, arena); + return; + } + if (!ctx.rs.subtree_has_image.contains(@intFromPtr(node.n))) return; + var child = node.firstChild(); + while (child) |ch| : (child = ch.nextSibling()) { + preloadImageSubtree(ch, ctx, arena); + } +} + +fn preloadSingleImage(img: md.Node, ctx: RenderContext, arena: std.mem.Allocator) void { + const raw_url = img.linkUrl() orelse return; + const abs_path = resolvedLocalImagePath(ctx, arena, raw_url) orelse return; + + const bytes: []const u8 = blk: { + if (ctx.rs.image_cache.get(abs_path)) |cached| break :blk cached; + const fresh = Io.Dir.cwd().readFileAlloc(ctx.io, abs_path, ctx.gpa, .limited(max_image_bytes)) catch return; + const key = ctx.gpa.dupe(u8, abs_path) catch { + ctx.gpa.free(fresh); + return; + }; + ctx.rs.image_cache.put(ctx.gpa, key, fresh) catch { + ctx.gpa.free(key); + ctx.gpa.free(fresh); + return; + }; + break :blk fresh; + }; + + const dvui_key: dvui.Texture.Cache.Key = blk: { + var h = dvui.fnv.init(); + const bp = bytes.ptr; + h.update(std.mem.asBytes(&bp)); + const it = @intFromEnum(dvui.enums.TextureInterpolation.linear); + h.update(std.mem.asBytes(&it)); + break :blk h.final(); + }; + + // Cache hit: texture already warm this frame, nothing to do. + if (dvui.textureGetCached(dvui_key) != null) return; + + // Cache miss: decode + GPU upload now so the animation first frame is free. + const source: dvui.ImageSource = .{ .imageFile = .{ + .bytes = bytes, + .name = abs_path, + .invalidation = .ptr, + } }; + const tex = dvui.Texture.fromImageSource(source) catch return; + ctx.rs.image_sizes.put(ctx.gpa, @intFromPtr(bytes.ptr), .{ + .w = @floatFromInt(tex.width), + .h = @floatFromInt(tex.height), + }) catch {}; + dvui.textureAddToCache(dvui_key, tex); +} + +// --------------------------------------------------------------------------- +// Image rendering helpers +// --------------------------------------------------------------------------- + +fn resolvedLocalImagePath(ctx: RenderContext, arena: std.mem.Allocator, src: []const u8) ?[]const u8 { + const t = std.mem.trim(u8, src, " \t\r\n"); + if (t.len == 0) return null; + if (std.ascii.startsWithIgnoreCase(t, "http://")) return null; + if (std.ascii.startsWithIgnoreCase(t, "https://")) return null; + if (std.fs.path.isAbsolute(t)) + return std.fs.path.resolve(arena, &.{t}) catch null; + const base = ctx.image_base_dir orelse return null; + return std.fs.path.resolve(arena, &.{ base, t }) catch null; +} + +/// Plain UTF-8 for `TextLayoutWidget.addLink`; nested emph/strong in the label lose per-span styling. +fn appendInlinePlainText(arena: std.mem.Allocator, n: md.Node, out: *std.ArrayList(u8)) std.mem.Allocator.Error!void { + var cur: ?md.Node = n.firstChild(); + while (cur) |x| : (cur = x.nextSibling()) { + switch (x.nodeType()) { + md.c.CMARK_NODE_TEXT => { + if (x.literal()) |t| try out.appendSlice(arena, t); + }, + md.c.CMARK_NODE_SOFTBREAK => { + try out.append(arena, ' '); + }, + md.c.CMARK_NODE_LINEBREAK => { + try out.append(arena, '\n'); + }, + md.c.CMARK_NODE_CODE => { + if (x.literal()) |t| try out.appendSlice(arena, t); + }, + md.c.CMARK_NODE_LINK => { + try appendInlinePlainText(arena, x, out); + }, + md.c.CMARK_NODE_IMAGE => { + try out.appendSlice(arena, "!["); + try appendInlinePlainText(arena, x, out); + try out.append(arena, ']'); + if (x.linkUrl()) |u| { + try out.append(arena, '('); + try out.appendSlice(arena, u); + try out.append(arena, ')'); + } + }, + else => { + if (x.firstChild()) |_| { + try appendInlinePlainText(arena, x, out); + } else if (x.literal()) |t| { + try out.appendSlice(arena, t); + } + }, + } + } +} + +fn linkLabelPlainText(link: md.Node, arena: std.mem.Allocator) std.mem.Allocator.Error![]const u8 { + var list: std.ArrayList(u8) = .empty; + errdefer list.deinit(arena); + try appendInlinePlainText(arena, link, &list); + return try list.toOwnedSlice(arena); +} + +fn renderMarkdownImagePlaceholder(msg: []const u8, ids: *IdGen) void { + dvui.labelNoFmt(@src(), msg, .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 2, .h = 2 }, + .color_text = dvui.themeGet().color(.control, .text).opacity(0.55), + .font = dvui.Font.theme(.mono).larger(-1), + .id_extra = ids.next(), + }); +} + +fn renderMarkdownImage(img: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { + _ = span; + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .y = 4, .h = 4 }, + .id_extra = ids.next(), + }); + defer outer.deinit(); + + const arena = dvui.currentWindow().arena(); + const raw_url = img.linkUrl() orelse { + renderMarkdownImagePlaceholder("(missing image src)", ids); + return; + }; + const url_trim = std.mem.trim(u8, raw_url, " \t\r\n"); + if (url_trim.len == 0) { + renderMarkdownImagePlaceholder("(empty image src)", ids); + return; + } + + const alt_owned = linkLabelPlainText(img, arena) catch ""; + const alt: []const u8 = alt_owned; + + if (std.ascii.startsWithIgnoreCase(url_trim, "http://") or std.ascii.startsWithIgnoreCase(url_trim, "https://")) { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .id_extra = ids.next(), + }); + defer tl.deinit(); + if (alt.len > 0) { + tl.addText(alt, .{ .color_text = dvui.themeGet().color(.control, .text).opacity(0.85) }); + tl.addText(" ", .{}); + } + tl.addLink(.{ .url = url_trim, .text = "open" }, .{ + .font = dvui.Font.theme(.mono), + }); + return; + } + + const abs_path = resolvedLocalImagePath(ctx, arena, url_trim) orelse { + renderMarkdownImagePlaceholder("cannot resolve image path (save file or use absolute path)", ids); + return; + }; + + // Use persistent cache to avoid reading the file every frame. + const bytes: []const u8 = blk: { + if (ctx.rs.image_cache.get(abs_path)) |cached| break :blk cached; + + const fresh = Io.Dir.cwd().readFileAlloc(ctx.io, abs_path, ctx.gpa, .limited(max_image_bytes)) catch { + renderMarkdownImagePlaceholder("could not read image", ids); + return; + }; + const key = ctx.gpa.dupe(u8, abs_path) catch { + ctx.gpa.free(fresh); + renderMarkdownImagePlaceholder("could not read image", ids); + return; + }; + ctx.rs.image_cache.put(ctx.gpa, key, fresh) catch { + ctx.gpa.free(key); + ctx.gpa.free(fresh); + renderMarkdownImagePlaceholder("could not cache image", ids); + return; + }; + break :blk fresh; + }; + + // Compute the same cache key dvui uses for this imageFile with .ptr invalidation. + // dvui's hash() calls stbi_info but ignores the result for .ptr — we skip it entirely. + const dvui_key: dvui.Texture.Cache.Key = blk: { + var h = dvui.fnv.init(); + const bp = bytes.ptr; + h.update(std.mem.asBytes(&bp)); + const it = @intFromEnum(dvui.enums.TextureInterpolation.linear); + h.update(std.mem.asBytes(&it)); + break :blk h.final(); + }; + + // Fast path: texture already in dvui's cache from a prior visible frame. + // Use .texture source to bypass hash()/stbi_info entirely on this frame. + // Slow path: texture not yet created. Use imageFile source so dvui creates it + // lazily inside renderImage (only when the image is actually in the clip rect). + var source: dvui.ImageSource = .{ .imageFile = .{ + .bytes = bytes, + .name = abs_path, + .invalidation = .ptr, + } }; + const nat: dvui.Size = if (dvui.textureGetCached(dvui_key)) |tex| nat: { + source = .{ .texture = tex }; + break :nat .{ .w = @floatFromInt(tex.width), .h = @floatFromInt(tex.height) }; + } else nat: { + const size_key = @intFromPtr(bytes.ptr); + break :nat ctx.rs.image_sizes.get(size_key) orelse sz: { + const sz = dvui.imageSize(source) catch { + renderMarkdownImagePlaceholder("unsupported or corrupt image", ids); + return; + }; + ctx.rs.image_sizes.put(ctx.gpa, size_key, sz) catch {}; + break :sz sz; + }; + }; + + if (nat.w <= 0 or nat.h <= 0) { + renderMarkdownImagePlaceholder("invalid image size", ids); + return; + } + + const r = nat.w / nat.h; + const max_fit_w = @min(max_image_display_width, max_image_display_height * r); + const max_fit_h = @min(max_image_display_height, max_image_display_width / r); + + const scale = @min(1.0, @min(max_fit_w / nat.w, max_fit_h / nat.h)); + const dw = nat.w * scale; + const dh = nat.h * scale; + + _ = dvui.image(@src(), .{ .source = source, .shrink = .ratio }, .{ + .min_size_content = .{ .w = dw, .h = dh }, + .max_size_content = dvui.Options.MaxSize.size(.{ .w = max_fit_w, .h = max_fit_h }), + .expand = .ratio, + .label = .{ .text = if (alt.len > 0) alt else "markdown image" }, + .id_extra = ids.next(), + }); + + if (alt.len > 0) { + var cap = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 2, .h = 0 }, + .id_extra = ids.next(), + }); + defer cap.deinit(); + cap.addText(alt, .{ + .font = dvui.Font.theme(.body).larger(-1), + .color_text = dvui.themeGet().color(.control, .text).opacity(0.65), + }); + } +} + +fn renderInlineFlowContainer(container: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { + var cur: ?md.Node = container.firstChild(); + while (cur) |node| { + if (node.nodeType() == md.c.CMARK_NODE_IMAGE) { + renderMarkdownImage(node, span, ctx, ids); + cur = node.nextSibling(); + continue; + } + if (hasImageSubtree(ctx, node)) { + switch (node.nodeType()) { + md.c.CMARK_NODE_EMPH => { + if (node.firstChild()) |_| { + const f = span.fontGet().withStyle(.italic); + renderInlineFlowContainer(node, span.override(.{ .font = f }), ctx, ids); + } + }, + md.c.CMARK_NODE_STRONG => { + if (node.firstChild()) |_| { + const f = span.fontGet().withWeight(.bold); + renderInlineFlowContainer(node, span.override(.{ .font = f }), ctx, ids); + } + }, + md.c.CMARK_NODE_LINK => { + const link_font = span.fontGet().withUnderline(.{}); + const link_color = dvui.themeGet().focus; + renderInlineFlowContainer(node, span.override(.{ .font = link_font, .color_text = link_color }), ctx, ids); + }, + else => { + if (extKind(ctx, node) == .strikethrough) { + const strike_font = span.fontGet().withStrike(.{}); + const strike_color = dvui.themeGet().color(.control, .text).opacity(0.5); + renderInlineFlowContainer(node, span.override(.{ .font = strike_font, .color_text = strike_color }), ctx, ids); + } else if (node.firstChild()) |_| { + renderInlineFlowContainer(node, span, ctx, ids); + } else if (node.literal()) |t| { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .background = span.background, + .id_extra = ids.next(), + }); + defer tl.deinit(); + tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + } + }, + } + cur = node.nextSibling(); + continue; + } + + // Batch a run of siblings that contain no images into one textLayout. + const run_first = node; + var run_last = node; + var scan: ?md.Node = node; + while (scan) |s| { + if (s.nodeType() == md.c.CMARK_NODE_IMAGE) break; + if (hasImageSubtree(ctx, s)) break; + run_last = s; + scan = s.nextSibling(); + } + + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 2, .h = 2 }, + .background = span.background, + .id_extra = ids.next(), + }); + defer tl.deinit(); + var z: ?md.Node = run_first; + while (z) |w| { + renderInlineNodeToTl(tl, w, span, ctx, ids); + if (w.n == run_last.n) break; + z = w.nextSibling(); + } + cur = run_last.nextSibling(); + } +} + +/// `span` carries inherited font/color down into inline content. +/// Only `.font` and `.color_text` are meaningful here. +/// Caller must ensure `n` has no `CMARK_NODE_IMAGE` in any descendant. +fn renderInlines(tl: *dvui.TextLayoutWidget, n: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { + std.debug.assert(!hasImageSubtree(ctx, n)); + var cur: ?md.Node = n.firstChild(); + while (cur) |x| : (cur = x.nextSibling()) { + renderInlineNodeToTl(tl, x, span, ctx, ids); + } +} + +fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { + switch (x.nodeType()) { + md.c.CMARK_NODE_TEXT => { + if (x.literal()) |t| tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + }, + md.c.CMARK_NODE_SOFTBREAK => { + tl.addText(" ", .{}); + }, + md.c.CMARK_NODE_LINEBREAK => { + tl.addText("\n", .{}); + }, + md.c.CMARK_NODE_CODE => { + if (x.literal()) |t| { + tl.addText(t, .{ + // Match the editor's monospace size (also `Font.theme(.mono)`). + .font = dvui.Font.theme(.mono), + .color_text = dvui.themeGet().color(.control, .text).opacity(0.9), + }); + } + }, + md.c.CMARK_NODE_EMPH => { + if (x.firstChild()) |_| { + const f = span.fontGet().withStyle(.italic); + renderInlines(tl, x, span.override(.{ .font = f }), ctx, ids); + } + }, + md.c.CMARK_NODE_STRONG => { + if (x.firstChild()) |_| { + const f = span.fontGet().withWeight(.bold); + renderInlines(tl, x, span.override(.{ .font = f }), ctx, ids); + } + }, + md.c.CMARK_NODE_LINK => { + const link_font = span.fontGet().withUnderline(.{}); + const link_color = dvui.themeGet().focus; + const link_opts = span.override(.{ .font = link_font, .color_text = link_color }); + const url = x.linkUrl() orelse ""; + if (url.len == 0) { + if (x.firstChild()) |_| renderInlines(tl, x, link_opts, ctx, ids); + } else { + const arena = dvui.currentWindow().arena(); + if (linkLabelPlainText(x, arena)) |display| { + tl.addLink(.{ + .url = url, + .text = if (display.len == 0) null else display, + }, link_opts); + } else |_| { + if (x.firstChild()) |_| renderInlines(tl, x, link_opts, ctx, ids); + } + } + }, + md.c.CMARK_NODE_IMAGE => unreachable, + md.c.CMARK_NODE_HTML_INLINE => { + if (x.literal()) |t| tl.addText(t, .{ + .font = dvui.Font.theme(.mono), + .color_text = dvui.themeGet().color(.err, .text), + }); + }, + md.c.CMARK_NODE_FOOTNOTE_REFERENCE => { + if (x.literal()) |t| { + const fn_font = dvui.Font.theme(.mono).larger(-1); + const fn_color = dvui.themeGet().focus.opacity(0.8); + tl.addText("[^", .{ .font = fn_font, .color_text = fn_color }); + tl.addText(t, .{ .font = fn_font, .color_text = fn_color }); + tl.addText("]", .{ .font = fn_font, .color_text = fn_color }); + } + }, + else => { + if (extKind(ctx, x) == .strikethrough) { + const strike_font = span.fontGet().withStrike(.{}); + const strike_color = dvui.themeGet().color(.control, .text).opacity(0.5); + renderInlines(tl, x, span.override(.{ .font = strike_font, .color_text = strike_color }), ctx, ids); + } else if (x.firstChild()) |_| { + renderInlines(tl, x, span, ctx, ids); + } else if (x.literal()) |t| { + tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + } + }, + } +} + +fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { + const t = n.nodeType(); + switch (t) { + md.c.CMARK_NODE_DOCUMENT => { + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + }, + md.c.CMARK_NODE_BLOCK_QUOTE => { + var outer = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .margin = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, + .id_extra = ids.next(), + }); + defer outer.deinit(); + + _ = dvui.spacer(@src(), .{ + .min_size_content = .{ .w = 3, .h = 0 }, + .expand = .vertical, + .background = true, + .color_fill = dvui.themeGet().color(.highlight, .fill).opacity(0.75), + .corner_radius = dvui.Rect.all(2), + .id_extra = ids.next(), + }); + + var content = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .padding = .{ .x = 10, .y = 4, .w = 0, .h = 4 }, + .id_extra = ids.next(), + }); + defer content.deinit(); + + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + }, + md.c.CMARK_NODE_LIST => { + var it = n.firstChild(); + var idx: i32 = n.listStart(); + const list_kind = n.listKind(); + const col_w = dvui.Font.theme(.body).sizeM(2.2, 0).w; + while (it) |item_node| : (it = item_node.nextSibling()) { + if (item_node.nodeType() != md.c.CMARK_NODE_ITEM) { + renderBlock(item_node, ids, ctx); + continue; + } + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .margin = .{ .y = 1 }, + .id_extra = ids.next(), + }); + defer row.deinit(); + + var buf: [24]u8 = undefined; + const is_task = list_kind == .ul and item_node.taskListItemChecked(); + const bullet_str: []const u8 = if (is_task) + "✓" + else switch (list_kind) { + .ul => "•", + .ol => std.fmt.bufPrint(&buf, "{d}.", .{idx}) catch "?", + }; + if (list_kind == .ol) idx += 1; + + const bullet_color = if (is_task) + dvui.themeGet().color(.highlight, .fill) + else + dvui.themeGet().color(.control, .text).opacity(0.45); + + { + var pb = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .min_size_content = .{ .w = col_w, .h = 0 }, + .gravity_y = 0, + .id_extra = ids.next(), + }); + _ = dvui.spacer(@src(), .{ .expand = .horizontal, .id_extra = ids.next() }); + dvui.labelNoFmt(@src(), bullet_str, .{}, .{ + .gravity_y = 0, + .color_text = bullet_color, + .id_extra = ids.next(), + }); + pb.deinit(); + } + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 5, .h = 0 }, .id_extra = ids.next() }); + + var col = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .id_extra = ids.next(), + }); + defer col.deinit(); + + var sub = item_node.firstChild(); + while (sub) |s| : (sub = s.nextSibling()) { + renderBlock(s, ids, ctx); + } + } + }, + md.c.CMARK_NODE_ITEM => { + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + }, + md.c.CMARK_NODE_CODE_BLOCK => { + const info = n.fenceInfo() orelse ""; + const code = n.literal() orelse ""; + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .y = 6 }, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill).opacity(0.9), + .corner_radius = dvui.Rect.all(6), + .border = dvui.Rect.all(1), + .color_border = dvui.themeGet().border.opacity(0.35), + .id_extra = ids.next(), + }); + defer outer.deinit(); + + if (info.len > 0) { + var hdr = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .padding = .{ .x = 10, .y = 5, .w = 10, .h = 5 }, + .background = true, + .color_fill = dvui.themeGet().border.opacity(0.12), + .id_extra = ids.next(), + }); + defer hdr.deinit(); + var tl_i = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .id_extra = ids.next() }); + tl_i.addText(info, .{ + .font = dvui.Font.theme(.mono).withWeight(.bold), + .color_text = dvui.themeGet().color(.control, .text).opacity(0.55), + }); + tl_i.deinit(); + } + + var tl_c = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .padding = .{ .x = 10, .y = 8, .w = 10, .h = 8 }, + .id_extra = ids.next(), + }); + defer tl_c.deinit(); + tl_c.addText(code, .{ .font = dvui.Font.theme(.mono) }); + }, + md.c.CMARK_NODE_HTML_BLOCK => { + if (n.literal()) |h| { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 2 }, + .padding = .{ .x = 8, .y = 4, .w = 8, .h = 4 }, + .background = true, + .color_fill = dvui.themeGet().color(.err, .fill).opacity(0.08), + .id_extra = ids.next(), + }); + defer tl.deinit(); + tl.addText(h, .{ + .font = dvui.Font.theme(.mono), + .color_text = dvui.themeGet().color(.err, .text).opacity(0.85), + }); + } + }, + md.c.CMARK_NODE_PARAGRAPH => { + if (!hasImageSubtree(ctx, n)) { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 4, .h = 4 }, + .id_extra = ids.next(), + }); + defer tl.deinit(); + renderInlines(tl, n, .{}, ctx, ids); + } else { + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .y = 4, .h = 4 }, + .id_extra = ids.next(), + }); + defer outer.deinit(); + renderInlineFlowContainer(n, .{}, ctx, ids); + } + }, + md.c.CMARK_NODE_HEADING => { + const level = @max(1, @min(6, n.headingLevel())); + const size_bump: f32 = switch (level) { + 1 => 9, + 2 => 6, + 3 => 3, + 4 => 1, + else => 0, + }; + const top_margin: f32 = switch (level) { + 1 => 18, + 2 => 14, + 3 => 10, + else => 7, + }; + const heading_font = dvui.Font.theme(.heading).larger(size_bump - 2).withWeight(.bold); + const span: dvui.Options = .{ .font = heading_font }; + + if (!hasImageSubtree(ctx, n)) { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = top_margin, .h = 2 }, + .font = heading_font, + .id_extra = ids.next(), + }); + defer tl.deinit(); + renderInlines(tl, n, span, ctx, ids); + } else { + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .y = top_margin, .h = 2 }, + .id_extra = ids.next(), + }); + defer outer.deinit(); + renderInlineFlowContainer(n, span, ctx, ids); + } + }, + md.c.CMARK_NODE_THEMATIC_BREAK => { + _ = dvui.separator(@src(), .{ + .expand = .horizontal, + .margin = .{ .y = 10, .h = 10 }, + .color_fill = dvui.themeGet().border.opacity(0.45), + .id_extra = ids.next(), + }); + }, + md.c.CMARK_NODE_FOOTNOTE_DEFINITION => { + if (n.literal()) |name| { + var tl = dvui.textLayout(@src(), .{}, .{ + .expand = .horizontal, + .margin = .{ .y = 4 }, + .id_extra = ids.next(), + }); + const fn_font = dvui.Font.theme(.mono).larger(-1); + const fn_color = dvui.themeGet().focus.opacity(0.8); + tl.addText("[^", .{ .font = fn_font, .color_text = fn_color }); + tl.addText(name, .{ .font = fn_font, .color_text = fn_color }); + tl.addText("]: ", .{ .font = fn_font, .color_text = fn_color }); + tl.deinit(); + } + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + }, + else => { + if (extKind(ctx, n) == .table) { + const arena = dvui.currentWindow().arena(); + + const num_cols = ctx.rs.table_col_counts.get(@intFromPtr(n.n)) orelse return; + if (num_cols == 0) return; + + var table_wrap = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .none, + .margin = .{ .y = 6 }, + .id_extra = ids.next(), + }); + defer table_wrap.deinit(); + + var g = dvui.grid(@src(), .numCols(num_cols), .{ + .scroll_opts = .{ + .horizontal_bar = .auto, + .vertical_bar = .hide, + }, + }, .{ + .expand = .none, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill).opacity(0.3), + .corner_radius = dvui.Rect.all(4), + .border = dvui.Rect.all(1), + .color_border = dvui.themeGet().border.opacity(0.3), + .id_extra = ids.next(), + }); + defer g.deinit(); + + const banded: dvui.GridWidget.CellStyle.Banded = .{ + .alt_cell_opts = .{ + .color_fill = dvui.themeGet().color(.control, .fill_press), + .background = true, + }, + }; + + const cell_padding: dvui.Rect = .{ .x = 8, .y = 5, .w = 8, .h = 5 }; + + var body_row: usize = 0; + var c = n.firstChild(); + while (c) |row| : (c = row.nextSibling()) { + const rk = extKind(ctx, row); + if (rk != .table_row and rk != .table_header) continue; + + if (rk == .table_header) { + var col: usize = 0; + var cl = row.firstChild(); + while (cl) |cell| : (cl = cell.nextSibling()) { + if (extKind(ctx, cell) != .table_cell) continue; + const label = linkLabelPlainText(cell, arena) catch ""; + const cell_pos: dvui.GridWidget.Cell = .colRow(col, 0); + var hdr_cell_opts = banded.cellOptions(cell_pos); + hdr_cell_opts.padding = cell_padding; + var hcell = g.headerCell(@src(), col, hdr_cell_opts); + defer hcell.deinit(); + dvui.labelNoFmt(@src(), label, .{}, .{ + .expand = .horizontal, + .gravity_x = 0.5, + .gravity_y = 0.5, + .font = dvui.Font.theme(.body).withWeight(.bold), + .id_extra = ids.next(), + }); + col += 1; + } + } else { + var col: usize = 0; + var cl = row.firstChild(); + while (cl) |cell| : (cl = cell.nextSibling()) { + if (extKind(ctx, cell) != .table_cell) continue; + const cell_pos: dvui.GridWidget.Cell = .colRow(col, body_row); + var cell_opts = banded.cellOptions(cell_pos); + cell_opts.padding = cell_padding; + var cell_box = g.bodyCell(@src(), cell_pos, cell_opts); + defer cell_box.deinit(); + renderInlineFlowContainer(cell, .{ .background = false }, ctx, ids); + col += 1; + } + body_row += 1; + } + } + } else { + var c = n.firstChild(); + while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); + } + }, + } +} + +pub fn renderDocument(root: md.Node, ctx: RenderContext) void { + var ids: IdGen = .{ .n = ctx.id_base }; + renderBlock(root, &ids, ctx); +} diff --git a/src/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"); }